From 913d5e9738bda1794c409ceb25019f4107a7d6a6 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 3 Feb 2021 09:38:46 +0100 Subject: [PATCH 001/496] Initial commit --- backend/LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++ backend/README.md | 1 + 2 files changed, 202 insertions(+) create mode 100644 backend/LICENSE create mode 100644 backend/README.md diff --git a/backend/LICENSE b/backend/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/backend/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..bc4d012d --- /dev/null +++ b/backend/README.md @@ -0,0 +1 @@ +# shoppy-backend \ No newline at end of file From 36e8620583b5308316e73830021a5c7bc506c791 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 8 Mar 2021 18:15:43 +0100 Subject: [PATCH 002/496] Version Alpha 1 --- backend/.dockerignore | 20 ++ backend/.gitignore | 144 ++++++++++++++ backend/Dockerfile | 18 ++ backend/app/__init__.py | 3 + backend/app/config.py | 34 ++++ backend/app/controller/__init__.py | 7 + backend/app/controller/auth/__init__.py | 1 + .../app/controller/auth/auth_controller.py | 42 +++++ backend/app/controller/auth/schemas.py | 13 ++ backend/app/controller/health_controller.py | 11 ++ backend/app/controller/item/__init__.py | 1 + .../app/controller/item/item_controller.py | 27 +++ backend/app/controller/item/schemas.py | 7 + backend/app/controller/onboarding/__init__.py | 1 + .../onboarding/onboarding_controller.py | 26 +++ backend/app/controller/onboarding/schemas.py | 16 ++ backend/app/controller/recipe/__init__.py | 1 + .../controller/recipe/recipe_controller.py | 134 +++++++++++++ backend/app/controller/recipe/schemas.py | 53 ++++++ .../app/controller/shoppinglist/__init__.py | 1 + .../app/controller/shoppinglist/schemas.py | 55 ++++++ .../shoppinglist/shoppinglist_controller.py | 124 ++++++++++++ backend/app/controller/user/__init__.py | 1 + backend/app/controller/user/schemas.py | 30 +++ .../app/controller/user/user_controller.py | 84 +++++++++ backend/app/errors/__init__.py | 19 ++ backend/app/helpers/__init__.py | 4 + backend/app/helpers/admin_required.py | 16 ++ backend/app/helpers/db_model_mixin.py | 178 ++++++++++++++++++ backend/app/helpers/timestamp_mixin.py | 47 +++++ backend/app/helpers/validate_args.py | 30 +++ backend/app/models/__init__.py | 4 + backend/app/models/item.py | 30 +++ backend/app/models/recipe.py | 52 +++++ backend/app/models/shoppinglist.py | 38 ++++ backend/app/models/user.py | 33 ++++ backend/database.db | Bin 0 -> 57344 bytes backend/entrypoint.sh | 3 + backend/migrations/README | 1 + backend/migrations/alembic.ini | 45 +++++ backend/migrations/env.py | 96 ++++++++++ backend/migrations/script.py.mako | 24 +++ backend/migrations/versions/22d528c529ca_.py | 89 +++++++++ backend/migrations/versions/fffa4ab33d2a_.py | 28 +++ backend/requirements.txt | 5 + backend/wsgi.py | 5 + 46 files changed, 1601 insertions(+) create mode 100644 backend/.dockerignore create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/controller/__init__.py create mode 100644 backend/app/controller/auth/__init__.py create mode 100644 backend/app/controller/auth/auth_controller.py create mode 100644 backend/app/controller/auth/schemas.py create mode 100644 backend/app/controller/health_controller.py create mode 100644 backend/app/controller/item/__init__.py create mode 100644 backend/app/controller/item/item_controller.py create mode 100644 backend/app/controller/item/schemas.py create mode 100644 backend/app/controller/onboarding/__init__.py create mode 100644 backend/app/controller/onboarding/onboarding_controller.py create mode 100644 backend/app/controller/onboarding/schemas.py create mode 100644 backend/app/controller/recipe/__init__.py create mode 100644 backend/app/controller/recipe/recipe_controller.py create mode 100644 backend/app/controller/recipe/schemas.py create mode 100644 backend/app/controller/shoppinglist/__init__.py create mode 100644 backend/app/controller/shoppinglist/schemas.py create mode 100644 backend/app/controller/shoppinglist/shoppinglist_controller.py create mode 100644 backend/app/controller/user/__init__.py create mode 100644 backend/app/controller/user/schemas.py create mode 100644 backend/app/controller/user/user_controller.py create mode 100644 backend/app/errors/__init__.py create mode 100644 backend/app/helpers/__init__.py create mode 100644 backend/app/helpers/admin_required.py create mode 100644 backend/app/helpers/db_model_mixin.py create mode 100644 backend/app/helpers/timestamp_mixin.py create mode 100644 backend/app/helpers/validate_args.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/item.py create mode 100644 backend/app/models/recipe.py create mode 100644 backend/app/models/shoppinglist.py create mode 100644 backend/app/models/user.py create mode 100644 backend/database.db create mode 100755 backend/entrypoint.sh create mode 100644 backend/migrations/README create mode 100644 backend/migrations/alembic.ini create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/22d528c529ca_.py create mode 100644 backend/migrations/versions/fffa4ab33d2a_.py create mode 100644 backend/requirements.txt create mode 100644 backend/wsgi.py diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..cbe46ec9 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,20 @@ +# General files +.git +.github +database.db +docs + +# Development +.devcontainer +.vscode + +# Test related files +.tox +tests + +# Other virtualization methods +venv +.vagrant + +# Temporary files +**/__pycache__ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..5ee6b789 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,144 @@ +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..62f4cb64 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.8 + +## Setup Shoppy +COPY . /usr/src/kitchenowl/ +WORKDIR /usr/src/kitchenowl +VOLUME ["/data"] +ENV STORAGE_PATH='/data' +ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' +ENV DEBUG='False' +RUN pip3 install -r requirements.txt && rm requirements.txt +RUN flask db upgrade +RUN chmod u+x ./entrypoint.sh + +HEALTHCHECK --interval=5m --timeout=3s \ + CMD curl -f http://localhost:5000/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V || exit 1 + +EXPOSE 5000 +ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 00000000..d030779b --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +from app.config import app +from app.config import db +from app.controller import * \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 00000000..88a6aed0 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,34 @@ +from app.errors import NotFoundRequest +from flask import Flask, jsonify +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy +from flask_bcrypt import Bcrypt +from flask_jwt_extended import JWTManager +import os + +MIN_FRONTEND_VERSION = 1 +BACKEND_VERSION = 1 + +app = Flask(__name__) + +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.getenv('STORAGE_PATH','..') + '/database.db' +app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY','super-secret') + + +db = SQLAlchemy(app) +migrate = Migrate(app, db) +bcrypt = Bcrypt(app) +jwt = JWTManager(app) + + +@app.errorhandler(Exception) +def unhandled_exception(e): + if e is NotFoundRequest: + return "Requested resource not found", 404 + app.logger.error(e) + return jsonify(message="Something went wrong"), 500 + + +@app.errorhandler(404) +def not_found(error): + return "Requested resource not found", 404 diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py new file mode 100644 index 00000000..9b5a85aa --- /dev/null +++ b/backend/app/controller/__init__.py @@ -0,0 +1,7 @@ +from . import auth +from . import item +from . import user +from . import recipe +from . import shoppinglist +from . import onboarding +from . import health_controller \ No newline at end of file diff --git a/backend/app/controller/auth/__init__.py b/backend/app/controller/auth/__init__.py new file mode 100644 index 00000000..7d646274 --- /dev/null +++ b/backend/app/controller/auth/__init__.py @@ -0,0 +1 @@ +from . import auth_controller \ No newline at end of file diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py new file mode 100644 index 00000000..d0b54d32 --- /dev/null +++ b/backend/app/controller/auth/auth_controller.py @@ -0,0 +1,42 @@ +from app.helpers import validate_args +from flask import jsonify +from flask_jwt_extended import jwt_required, create_access_token, create_refresh_token, get_jwt_identity +from app.config import app +from app.models import User +from app.errors import UnauthorizedRequest +from .schemas import Login + + +@app.route('/auth', methods=['POST']) +@validate_args(Login) +def login(args): + username = args['username'] + user = User.find_by_username(username.lower()) + if not user or not user.check_password(args['password']): + raise UnauthorizedRequest(message='Unauthorized') + ret = { + 'access_token': create_access_token(identity=username), + 'refresh_token': create_refresh_token(identity=username) + } + return jsonify(ret) + + +@app.route('/auth/fresh-login', methods=['POST']) +@validate_args(Login) +def fresh_login(args): + username = args['username'] + user = User.find_by_username(username.lower()) + if not user or not user.check_password(args['password']): + raise UnauthorizedRequest(message='Unauthorized') + ret = {'access_token': create_access_token(identity=username, fresh=True)} + return jsonify(ret), 200 + + +@app.route('/auth/refresh', methods=['GET']) +@jwt_required(refresh=True) +def refresh(): + current_user = get_jwt_identity() + ret = { + 'access_token': create_access_token(identity=current_user) + } + return jsonify(ret) diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py new file mode 100644 index 00000000..ce8e1630 --- /dev/null +++ b/backend/app/controller/auth/schemas.py @@ -0,0 +1,13 @@ +from marshmallow import fields, Schema + +class Login(Schema): + username = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + password = fields.String( + required=True, + validate=lambda a: len(a) > 0, + load_only=True, + ) + diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py new file mode 100644 index 00000000..6d60d36b --- /dev/null +++ b/backend/app/controller/health_controller.py @@ -0,0 +1,11 @@ +from flask import jsonify +from app import app +from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION + + +@app.route( + '/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V', + methods=['GET'] +) +def get_health(): + return jsonify({'msg': "OK", 'version': BACKEND_VERSION, 'min_frontend_version': MIN_FRONTEND_VERSION}) diff --git a/backend/app/controller/item/__init__.py b/backend/app/controller/item/__init__.py new file mode 100644 index 00000000..dc326fe7 --- /dev/null +++ b/backend/app/controller/item/__init__.py @@ -0,0 +1 @@ +from . import item_controller \ No newline at end of file diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py new file mode 100644 index 00000000..09fcabf2 --- /dev/null +++ b/backend/app/controller/item/item_controller.py @@ -0,0 +1,27 @@ +from app.helpers import validate_args +import json +from flask import jsonify +from flask_jwt_extended import jwt_required +from app import app +from app.models import Item +from .schemas import SearchByNameRequest + + +@app.route('/item', methods=['GET']) +@jwt_required() +def getAllItems(): + return jsonify([e.obj_to_dict() for e in Item.all()]) + + +@app.route('/item/', methods=['DELETE']) +@jwt_required() +def deleteItemById(id): + Item.delete_by_id(id) + return jsonify({'msg': 'DONE'}) + + +@app.route('/item/search', methods=['GET']) +@jwt_required() +@validate_args(SearchByNameRequest) +def searchItemByName(args): + return jsonify([e.obj_to_dict() for e in Item.search_name(args['query'])]) diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py new file mode 100644 index 00000000..a597b802 --- /dev/null +++ b/backend/app/controller/item/schemas.py @@ -0,0 +1,7 @@ +from marshmallow import fields, Schema + +class SearchByNameRequest(Schema): + query = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) \ No newline at end of file diff --git a/backend/app/controller/onboarding/__init__.py b/backend/app/controller/onboarding/__init__.py new file mode 100644 index 00000000..12921e60 --- /dev/null +++ b/backend/app/controller/onboarding/__init__.py @@ -0,0 +1 @@ +from . import onboarding_controller \ No newline at end of file diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py new file mode 100644 index 00000000..c7e0f8a9 --- /dev/null +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -0,0 +1,26 @@ +from app.helpers import validate_args +import json +from flask import jsonify +from flask_jwt_extended import create_access_token, create_refresh_token +from app import app +from app.models import User +from .schemas import CreateUser + +@app.route('/onboarding', methods=['GET']) +def isOnboarding(): + onboarding = User.count() == 0 + return jsonify({"onboarding": onboarding}) + +@app.route('/onboarding', methods=['POST']) +@validate_args(CreateUser) +def onboarding(args): + if User.count() == 0: + username = args['username'].lower() + User.create(username, args['password'], args['name'], owner=True) + ret = { + 'access_token': create_access_token(identity=username), + 'refresh_token': create_refresh_token(identity=username) + } + return jsonify(ret) + + return jsonify({'msg': "Onboarding not allowed"}), 403 diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py new file mode 100644 index 00000000..8a611864 --- /dev/null +++ b/backend/app/controller/onboarding/schemas.py @@ -0,0 +1,16 @@ +from marshmallow import fields, Schema + +class CreateUser(Schema): + name = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + username = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + password = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + diff --git a/backend/app/controller/recipe/__init__.py b/backend/app/controller/recipe/__init__.py new file mode 100644 index 00000000..7b3e0a99 --- /dev/null +++ b/backend/app/controller/recipe/__init__.py @@ -0,0 +1 @@ +from . import recipe_controller \ No newline at end of file diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py new file mode 100644 index 00000000..53d9ded9 --- /dev/null +++ b/backend/app/controller/recipe/recipe_controller.py @@ -0,0 +1,134 @@ +from app.errors import NotFoundRequest +from app.models.recipe import RecipeItems +import json +from flask import jsonify +from flask_jwt_extended import jwt_required +from app import app +from app.helpers import validate_args +from app.models import Recipe, Item +from .schemas import SearchByNameRequest, AddItemByName, RemoveItem, AddRecipe, UpdateRecipe + + +@app.route('/recipe', methods=['GET']) +@jwt_required() +def getAllRecipes(): + return jsonify([e.obj_to_dict() for e in Recipe.all_by_name()]) + + +@app.route('/recipe/', methods=['GET']) +@jwt_required() +def getRecipeById(id): + recipe = Recipe.find_by_id(id) + if not recipe: + raise NotFoundRequest() + return jsonify(recipe.obj_to_full_dict()) + + +@app.route('/recipe', methods=['POST']) +@jwt_required() +@validate_args(AddRecipe) +def addRecipe(args): + recipe = Recipe() + recipe.name = args['name'] + recipe.description = args['description'] + recipe.save() + if 'items' in args: + for recipeItem in args['items']: + item = Item.find_by_name(recipeItem['name']) + if not item: + item = Item.create_by_name(recipeItem['name']) + con = RecipeItems( + description=recipeItem['description'], + optional=recipeItem['optional'] + ) + con.item = item + con.recipe = recipe + con.save() + return jsonify(recipe.obj_to_dict()) + + +@app.route('/recipe/', methods=['POST']) +@jwt_required() +@validate_args(UpdateRecipe) +def updateRecipe(args, id): + recipe = Recipe.find_by_id(id) + if not recipe: + raise NotFoundRequest() + if 'name' in args and args['name']: + recipe.name = args['name'] + if 'description' in args and args['description']: + recipe.description = args['description'] + recipe.save() + if 'items' in args: + for con in recipe.items: + item_names = [e['name'] for e in args['items']] + if not con.item.name in item_names: + con.delete() + for recipeItem in args['items']: + item = Item.find_by_name(recipeItem['name']) + if not item: + item = Item.create_by_name(recipeItem['name']) + con = RecipeItems.find_by_ids(recipe.id, item.id) + if con: + if 'description' in recipeItem and recipeItem['description']: + con.description = recipeItem['description'] + if 'optional' in recipeItem: + con.optional = recipeItem['optional'] + else: + con = RecipeItems( + description=recipeItem['description'], + optional=recipeItem['optional'] + ) + con.item = item + con.recipe = recipe + con.save() + return jsonify(recipe.obj_to_dict()) + + +@app.route('/recipe/', methods=['DELETE']) +@jwt_required() +def deleteRecipeById(id): + Recipe.delete_by_id(id) + return jsonify({'msg': 'DONE'}) + + +@app.route('/recipe/search', methods=['GET']) +@jwt_required() +@validate_args(SearchByNameRequest) +def searchRecipeByName(args): + return jsonify([e.obj_to_dict() for e in Recipe.search_name(args['query'])]) + + +# @app.route('/recipe//item', methods=['POST']) +# @jwt_required() +# @validate_args(AddItemByName) +# def addRecipeItemByName(args, id): +# recipe = Recipe.find_by_id(id) +# if not recipe: +# return jsonify(), 404 +# item = Item.find_by_name(args['name']) +# if not item: +# item = Item.create_by_name(args['name']) + +# description = args['description'] if 'description' in args else '' +# con = RecipeItems(description=description) +# con.item = item +# recipe.items.append(con) +# recipe.save() +# return jsonify(item.obj_to_dict()) + + +# @app.route('/recipe//item', methods=['DELETE']) +# @jwt_required() +# @validate_args(RemoveItem) +# def removeRecipeItem(args, id): +# recipe = Recipe.find_by_id(id) +# if not recipe: +# return jsonify(), 404 +# item = Item.find_by_id(args['item_id']) +# if not item: +# item = Item.create_by_name(args['name']) + +# con = RecipeItems.find_by_ids(id, args['item_id']) +# con.delete() +# return jsonify({'msg': "DONE"}) diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py new file mode 100644 index 00000000..5257f754 --- /dev/null +++ b/backend/app/controller/recipe/schemas.py @@ -0,0 +1,53 @@ +from marshmallow import fields, Schema + + +class AddRecipe(Schema): + class RecipeItem(Schema): + name = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + description = fields.String( + default='' + ) + optional = fields.Boolean( + default=True + ) + + name = fields.String( + required=True + ) + description = fields.String() + items = fields.List(fields.Nested(RecipeItem())) + +class UpdateRecipe(Schema): + class RecipeItem(Schema): + name = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + description = fields.String() + optional = fields.Boolean(default=True) + + name = fields.String() + description = fields.String() + items = fields.List(fields.Nested(RecipeItem())) + +class SearchByNameRequest(Schema): + query = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + + +class AddItemByName(Schema): + name = fields.String( + required=True + ) + description = fields.String() + + +class RemoveItem(Schema): + item_id = fields.Integer( + required=True, + ) diff --git a/backend/app/controller/shoppinglist/__init__.py b/backend/app/controller/shoppinglist/__init__.py new file mode 100644 index 00000000..904b2cb7 --- /dev/null +++ b/backend/app/controller/shoppinglist/__init__.py @@ -0,0 +1 @@ +from . import shoppinglist_controller \ No newline at end of file diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py new file mode 100644 index 00000000..9876bd3b --- /dev/null +++ b/backend/app/controller/shoppinglist/schemas.py @@ -0,0 +1,55 @@ +from marshmallow import fields, validates_schema, ValidationError, Schema +from app.models import Item + + +class AddItemByName(Schema): + name = fields.String( + required=True + ) + description = fields.String() + + +class AddRecipeItems(Schema): + class RecipeItem(Schema): + id = fields.Integer(required=True) + name = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + description = fields.String( + default='' + ) + optional = fields.Boolean( + default=True + ) + + items = fields.List(fields.Nested(RecipeItem)) + + +class CreateList(Schema): + name = fields.String( + required=True + ) + + +class UpdateDescription(Schema): + item_id = fields.Integer( + required=True, + ) + description = fields.String( + required=True + ) + + # def validate_id(self, args): + # if not ShoppinglistItem.find_by_ids(args['id'], args['item_id']): + # raise ValidationError('Item does not exist') + + +class RemoveItem(Schema): + item_id = fields.Integer( + required=True, + ) + + # def validate_id(self, args): + # if not ShoppinglistItem.find_by_id(args['id'], args['item_id']): + # raise ValidationError('Item does not exist') diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py new file mode 100644 index 00000000..20d3cf6f --- /dev/null +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -0,0 +1,124 @@ +from app.models import ShoppinglistItems +import json +from flask import jsonify +from flask_jwt_extended import jwt_required +from app import app, db +from app.models import Item, Shoppinglist +from app.helpers import validate_args +from .schemas import RemoveItem, UpdateDescription, AddItemByName, CreateList, AddRecipeItems +from app.errors import InvalidUsage + + +@app.before_first_request +def before_first_request(): + # Add default shoppinglist + if(not Shoppinglist.find_by_id(1)): + sl = Shoppinglist( + name='Default' + ) + sl.save() + + +@app.route('/shoppinglist//items', methods=['GET']) +@jwt_required() +def getAllShoppingListItems(id): + return jsonify([e.obj_to_item_dict() for e in Shoppinglist.find_by_id(id).items]) + + +@app.route('/shoppinglist//recent-items', methods=['GET']) +@jwt_required() +def getRecentItems(id): + sq = db.session.query(ShoppinglistItems.item_id).filter( + ShoppinglistItems.shoppinglist_id == id).subquery() + q = Item.query.filter(Item.id.notin_(sq)).order_by( + Item.updated_at).limit(9) + return jsonify([e.obj_to_dict() for e in q]) + + +@app.route('/shoppinglist//item', methods=['POST']) +@jwt_required() +@validate_args(AddItemByName) +def addShoppinglistItemByName(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + return jsonify(), 404 + item = Item.find_by_name(args['name']) + if not item: + item = Item.create_by_name(args['name']) + + description = args['description'] if 'description' in args else '' + con = ShoppinglistItems(description=description) + con.item = item + shoppinglist.items.append(con) + shoppinglist.save() + return jsonify(item.obj_to_dict()) + + +@app.route('/shoppinglist//item', methods=['DELETE']) +@jwt_required() +@validate_args(RemoveItem) +def removeShoppinglistItem(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + return jsonify(), 404 + item = Item.find_by_id(args['item_id']) + if not item: + item = Item.create_by_name(args['name']) + + con = ShoppinglistItems.find_by_ids(id, args['item_id']) + con.delete() + return jsonify({'msg': "DONE"}) + + +@app.route('/shoppinglist', methods=['POST']) +@jwt_required() +@validate_args(CreateList) +def createList(args): + return jsonify(Shoppinglist.create( + args['name']).save().obj_to_dict()) + + +@app.route('/shoppinglist//recipeitems', methods=['POST']) +@jwt_required() +@validate_args(AddRecipeItems) +def addRecipeItems(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + return jsonify(), 404 + + for recipeItem in args['items']: + item = Item.find_by_id(recipeItem['id']) + if item: + description = recipeItem['description'] + con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) + if con: + con.description = description if not con.description else con.description + \ + ', ' + description + con.save() + else: + con = ShoppinglistItems(description=description) + con.item = item + shoppinglist.items.append(con) + + shoppinglist.save() + return jsonify(item.obj_to_dict()) + +# @app.route('/shoppinglist//item', methods=['POST']) +# @jwt_required() +# @validate_args(UpdateDescription) +# def updateDescription(args, id): +# item = ShoppinglistItem.find_by_ids(id, args['item_id']) +# if (not item): +# raise Exception() +# item.desciption = args['description'] +# item.save() +# return jsonify(item.obj_to_dict()) + + +@app.route('/shoppinglist/', methods=['GET']) +@jwt_required() +def getShoppinglist(id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + return jsonify(), 404 + return jsonify(shoppinglist.obj_to_dict()) diff --git a/backend/app/controller/user/__init__.py b/backend/app/controller/user/__init__.py new file mode 100644 index 00000000..00a3b886 --- /dev/null +++ b/backend/app/controller/user/__init__.py @@ -0,0 +1 @@ +from . import user_controller \ No newline at end of file diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py new file mode 100644 index 00000000..ea3a6041 --- /dev/null +++ b/backend/app/controller/user/schemas.py @@ -0,0 +1,30 @@ +from marshmallow import fields, Schema + +class CreateUser(Schema): + name = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + username = fields.String( + required=True, + validate=lambda a: len(a) > 0, + load_only=True, + ) + password = fields.String( + required=True, + validate=lambda a: len(a) > 0, + load_only=True, + ) + +class UpdateUser(Schema): + name = fields.String( + validate=lambda a: len(a) > 0 + ) + username = fields.String( + validate=lambda a: len(a) > 0, + load_only=True, + ) + password = fields.String( + validate=lambda a: len(a) > 0, + load_only=True, + ) \ No newline at end of file diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py new file mode 100644 index 00000000..3724ccea --- /dev/null +++ b/backend/app/controller/user/user_controller.py @@ -0,0 +1,84 @@ +from app.errors import NotFoundRequest, UnauthorizedRequest +from app.helpers.admin_required import admin_required +from app.helpers import validate_args +import json +from flask import jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from app import app +from app.models import User +from .schemas import CreateUser, UpdateUser + + +@app.route('/users', methods=['GET']) +@jwt_required() +def getAllUsers(): + return jsonify([e.obj_to_dict(skip_columns=['password']) for e in User.all_by_name()]) + + +@app.route('/user', methods=['GET']) +@jwt_required() +def getLoggedInUser(): + return jsonify(User.find_by_username(get_jwt_identity()).obj_to_dict(skip_columns=['password'])) + + +@app.route('/user/', methods=['GET']) +@jwt_required() +@admin_required +def getUserById(id): + user = User.find_by_id(id) + if not user: + raise NotFoundRequest() + return jsonify(user.obj_to_dict(skip_columns=['password'])) + + +@app.route('/user/', methods=['DELETE']) +@jwt_required() +@admin_required +def deleteUserById(id): + user = User.find_by_id(id) + if not user or user.owner: + raise UnauthorizedRequest( + message='user_not_allowed' + ) + User.delete_by_id(id) + return jsonify({'msg': 'DONE'}) + + +@app.route('/user', methods=['POST']) +@jwt_required() +@validate_args(UpdateUser) +def updateUser(args): + user = User.find_by_username(get_jwt_identity()) + if not user: + raise NotFoundRequest() + if 'name' in args and args['name']: + user.name = args['name'] + if 'password' in args and args['password']: + user.set_password(args['password']) + user.save() + return jsonify({'msg': 'DONE'}) + + +@app.route('/user/', methods=['POST']) +@jwt_required() +@admin_required +@validate_args(UpdateUser) +def updateUserById(args, id): + user = User.find_by_id(id) + if not user: + raise NotFoundRequest() + if 'name' in args and args['name']: + user.name = args['name'] + if 'password' in args and args['password']: + user.set_password(args['password']) + user.save() + return jsonify({'msg': 'DONE'}) + + +@app.route('/new-user', methods=['POST']) +@jwt_required() +@admin_required +@validate_args(CreateUser) +def createUser(args): + User.create(args['username'].lower(), args['password'], args['name']) + return jsonify({'msg': 'DONE'}) diff --git a/backend/app/errors/__init__.py b/backend/app/errors/__init__.py new file mode 100644 index 00000000..2f67b7f1 --- /dev/null +++ b/backend/app/errors/__init__.py @@ -0,0 +1,19 @@ +class InvalidUsage(Exception): + def __init__(self, message): + super(InvalidUsage, self).__init__(message) + self.message = message + +class UnauthorizedRequest(Exception): + def __init__(self, message): + super(UnauthorizedRequest, self).__init__(message) + self.message = message + +class ForbiddenRequest(Exception): + def __init__(self, message): + super(ForbiddenRequest, self).__init__(message) + self.message = message + +class NotFoundRequest(Exception): + def __init__(self, message): + super(NotFoundRequest, self).__init__(message) + self.message = message \ No newline at end of file diff --git a/backend/app/helpers/__init__.py b/backend/app/helpers/__init__.py new file mode 100644 index 00000000..be0fa287 --- /dev/null +++ b/backend/app/helpers/__init__.py @@ -0,0 +1,4 @@ +from .db_model_mixin import DbModelMixin +from .timestamp_mixin import TimestampMixin +from .validate_args import validate_args +from .admin_required import admin_required \ No newline at end of file diff --git a/backend/app/helpers/admin_required.py b/backend/app/helpers/admin_required.py new file mode 100644 index 00000000..32d5d7ef --- /dev/null +++ b/backend/app/helpers/admin_required.py @@ -0,0 +1,16 @@ +from app.models import User +from functools import wraps +from flask_jwt_extended import get_jwt_identity +from app.errors import UnauthorizedRequest + + +def admin_required(func): + @wraps(func) + def func_wrapper(*args, **kwargs): + if not User.find_by_username(get_jwt_identity()).owner: + raise UnauthorizedRequest( + message='Elevated rights required' + ) + return func(*args, **kwargs) + + return func_wrapper diff --git a/backend/app/helpers/db_model_mixin.py b/backend/app/helpers/db_model_mixin.py new file mode 100644 index 00000000..0291b62f --- /dev/null +++ b/backend/app/helpers/db_model_mixin.py @@ -0,0 +1,178 @@ +from sqlalchemy import asc, desc +from app import db + + +class DbModelMixin(object): + + def save(self): + """ + Persist changes to current instance in db + """ + try: + db.session.add(self) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + return self + + def assign(self, **kwargs): + """ + Update an entry + """ + for k, v in kwargs.items(): + setattr(self, k, v) + + return self + + def assign_columns(self, args): + model_columns = list(self.__class__.__table__.columns.keys()) + for k, v in args.items(): + if k in model_columns: + setattr(self, k, v) + + return self + + def update(self, details): + model_columns = list(self.__class__.__table__.columns.keys()) + for k, v in details.items(): + if k in model_columns and (v or v == ''): + setattr(self, k, v) + self.save() + + def update_attr(self, key, value): + model_columns = list(self.__class__.__table__.columns.keys()) + if key in model_columns: + setattr(self, key, value) + self.save() + + @classmethod + def get_column_names(cls): + return list(cls.__table__.columns.keys()) + + @classmethod + def bulk_save(cls, records): + if not records: + return + + try: + db.session.bulk_save_objects(records) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + @classmethod + def bulk_delete(cls, query): + if not query.all(): + return + + try: + query.delete() + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + def delete(self): + """ + Delete this instance of model from db + """ + db.session.delete(self) + db.session.commit() + + def obj_to_dict(self, skip_columns=None, include_columns=None): + d = {} + for column in self.__table__.columns: + d[column.name] = getattr(self, column.name) + + for column_name in skip_columns or []: + del d[column_name] + + for column in self.__table__.columns: + if not include_columns: + break + + if column.name in d and column.name not in include_columns: + del d[column.name] + + return d + + def clone(self, overrides): + new_self = self.__class__() + new_self.assign_columns(self.obj_to_dict()) + + if overrides: + for k, v in overrides.items(): + setattr(new_self, k, v) + + return new_self + + @classmethod + def find_by_id(cls, target_id): + """ + Find the row with specified id + """ + return cls.query.filter(cls.id == target_id).first() + + @classmethod + def find_all_by_id(cls, target_id): + """ + Find all the rows with specified id + """ + return cls.query.filter(cls.id == target_id).all() + + @classmethod + def find_all_by_ids(cls, target_ids): + """ + Find all the rows with specified id + """ + return cls.query.filter(cls.id.in_(target_ids)).all() + + @classmethod + def delete_by_id(cls, target_id): + mc = cls.find_by_id(target_id) + if mc: + mc.delete() + + @classmethod + def all(cls): + """ + Return all instances of model + """ + return cls.query.order_by(cls.id).all() + + @classmethod + def all_by_name(cls): + """ + Return all instances of model ordered by name + IMPORTANT: requires name column + """ + return cls.query.order_by(cls.name).all() + + @classmethod + def first(cls): + """ + Returns the first entry of database + """ + entities = cls.query.order_by(asc(cls.id)).limit(1).all() + if len(entities) > 0: + return entities[0] + + return None + + @classmethod + def last(cls): + """ + Return the last entry of table in database + """ + entities = cls.query.order_by(desc(cls.id)).limit(1).all() + if len(entities) > 0: + return entities[0] + + return None + + @classmethod + def count(cls): + return cls.query.count() diff --git a/backend/app/helpers/timestamp_mixin.py b/backend/app/helpers/timestamp_mixin.py new file mode 100644 index 00000000..d3752723 --- /dev/null +++ b/backend/app/helpers/timestamp_mixin.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from flask_sqlalchemy import BaseQuery +from sqlalchemy import Column, DateTime + + +class Query(BaseQuery): + """ + Extends flask.ext.sqlalchemy.BaseQuery to add additional helper methods. + """ + + def notempty(self): + """ + Returns the equivalent of ``bool(query.count())`` but using an + efficient SQL EXISTS function, so the database stops counting + after the first result is found. + """ + return self.session.query(self.exists()).first()[0] + + def isempty(self): + """ + Returns the equivalent of ``not bool(query.count())`` but + using an efficient SQL EXISTS function, so the database stops + counting after the first result is found. + """ + return not self.session.query(self.exists()).first()[0] + + +class TimestampMixin(object): + """ + Provides the :attr:`created_at` and :attr:`updated_at` audit timestamps + """ + query_class = Query + + #: Timestamp for when this instance was created, in UTC + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + #: Timestamp for when this instance was last updated (via the app), in UTC + updated_at = Column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False + ) + + created_at._creation_order = 9998 + updated_at._creation_order = 9999 diff --git a/backend/app/helpers/validate_args.py b/backend/app/helpers/validate_args.py new file mode 100644 index 00000000..bb54a3ce --- /dev/null +++ b/backend/app/helpers/validate_args.py @@ -0,0 +1,30 @@ +from marshmallow.exceptions import ValidationError +from app.errors import InvalidUsage +from flask import request +from app.config import app +from functools import wraps + +def validate_args(schema_cls): + def validate(func): + @wraps(func) + def func_wrapper(*args, **kwargs): + if not schema_cls: + raise Exception("Invalid usage. Schema class missing") + + if request.method == 'GET': + request_data = request.args + load_fn = schema_cls().load + else: + request_data = request.data.decode('utf-8') + load_fn = schema_cls().loads + + try: + arguments = load_fn(request_data) + except ValidationError as exc: + raise InvalidUsage('{}'.format(exc)) + + return func(arguments, *args, **kwargs) + + return func_wrapper + + return validate diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 00000000..d8a85173 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,4 @@ +from .user import User +from .item import Item +from .recipe import RecipeItems, Recipe +from .shoppinglist import ShoppinglistItems, Shoppinglist \ No newline at end of file diff --git a/backend/app/models/item.py b/backend/app/models/item.py new file mode 100644 index 00000000..066693da --- /dev/null +++ b/backend/app/models/item.py @@ -0,0 +1,30 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin + +class Item(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'item' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), unique=True) + + recipes = db.relationship('RecipeItems', back_populates='item', cascade="all, delete-orphan") + + @classmethod + def create_by_name(cls, name): + return cls( + name=name, + ).save() + + @classmethod + def find_by_name(cls, name): + return cls.query.filter(cls.name == name).first() + + @classmethod + def search_name(cls, name): + if '*' in name or '_' in name: + looking_for = name.replace('_', '__')\ + .replace('*', '%')\ + .replace('?', '_') + else: + looking_for = '%{0}%'.format(name) + return cls.query.filter(cls.name.ilike(looking_for)).limit(10) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py new file mode 100644 index 00000000..2ff17c0b --- /dev/null +++ b/backend/app/models/recipe.py @@ -0,0 +1,52 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class Recipe(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'recipe' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + description = db.Column(db.String()) + photo = db.Column(db.String()) + items = db.relationship('RecipeItems', back_populates='recipe', cascade="all, delete-orphan") + + def obj_to_full_dict(self): + res = super().obj_to_dict() + res['items'] = [e.obj_to_item_dict() for e in self.items] + return res + + @classmethod + def search_name(cls, name): + if '*' in name or '_' in name: + looking_for = name.replace('_', '__')\ + .replace('*', '%')\ + .replace('?', '_') + else: + looking_for = '%{0}%'.format(name) + return cls.query.filter(cls.name.ilike(looking_for)).all() + + +class RecipeItems(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'recipe_items' + + recipe_id = db.Column(db.Integer, db.ForeignKey( + 'recipe.id'), primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey('item.id'), primary_key=True) + description = db.Column('description', db.String()) + optional = db.Column('optional', db.Boolean) + + item = db.relationship("Item", back_populates='recipes') + recipe = db.relationship("Recipe", back_populates='items') + + def obj_to_item_dict(self): + res = self.item.obj_to_dict() + res['description'] = getattr(self, 'description') + res['optional'] = getattr(self, 'optional') + res['created_at'] = getattr(self, 'created_at') + res['updated_at'] = getattr(self, 'updated_at') + return res + + @classmethod + def find_by_ids(cls, recipe_id, item_id): + return cls.query.filter(cls.recipe_id == recipe_id, cls.item_id == item_id).first() diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py new file mode 100644 index 00000000..256a8326 --- /dev/null +++ b/backend/app/models/shoppinglist.py @@ -0,0 +1,38 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from .item import Item + + +class Shoppinglist(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'shoppinglist' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), unique=True) + + items = db.relationship('ShoppinglistItems') + + @classmethod + def create(cls, name): + return cls(name=name).save() + + +class ShoppinglistItems(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'shoppinglist_items' + + shoppinglist_id = db.Column(db.Integer, db.ForeignKey( + 'shoppinglist.id'), primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey('item.id'), primary_key=True) + description = db.Column('description', db.String()) + + item = db.relationship("Item") + + def obj_to_item_dict(self): + res = self.item.obj_to_dict() + res['description'] = getattr(self, 'description') + res['created_at'] = getattr(self, 'created_at') + res['updated_at'] = getattr(self, 'updated_at') + return res + + @classmethod + def find_by_ids(cls, shoppinglist_id, item_id): + return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.item_id == item_id).first() \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 00000000..4d29a575 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,33 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from app.config import bcrypt + + +class User(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'user' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + username = db.Column(db.String(256), unique=True, nullable=False) + password = db.Column(db.String(256), nullable=False) + photo = db.Column(db.String()) + owner = db.Column(db.Boolean(), default=False) + + def check_password(self, password): + return bcrypt.check_password_hash(self.password, password) + + def set_password(self, password): + self.password = bcrypt.generate_password_hash(password).decode('utf-8') + + @classmethod + def find_by_username(cls, username): + return cls.query.filter(cls.username == username).first() + + @classmethod + def create(cls, username, password, name, owner=False): + cls( + username=username, + password=bcrypt.generate_password_hash(password).decode('utf-8'), + name=name, + owner=owner + ).save() diff --git a/backend/database.db b/backend/database.db new file mode 100644 index 0000000000000000000000000000000000000000..5bc23ace5225bb919f5bf43de2d601b25ea51b70 GIT binary patch literal 57344 zcmeI)-*4JR00(dz5{PMtC!q{g?Smg071S2_2S3wwt4rLLhC)I}yY$5(U=|v{gda=y z&B3tmA;@bUob4o+!O?cVM1hHBJ*TgAS-i|4f3D0tcc00ba#Vgg4-z#m>&VP6FMmEES%HL8t{ zp_9u+t7l(7mn~^(Srf|Yt-L1KV}wY6n{3yFT%oMp(Mm$0SQZM~`TSKOz|q9?#{1)H zMz7j!bovdmEj&<5*}H0q9#ZWZmA+Ba$>nN|ylgplUvnfm=+r(CZ(8Y9n!>GOF|Vlw zn&L(&cV8{-2At#biM+elY^m>P8xAyMP={@$$zB!4ey^DF+CONTnN&l^rb2YMYue4pr zZEkBqWKsgFIN7^ec3p^!GLS0>5%H=ZMWcb}WzHAAzC25wrlCu885nN*tgFP4G1Yt7 zn~C2cZ;$!d8aPfHTC=3^DgTE~JA3AxeZj);!Ms0w{yh84Z@XlUPE4NzU~&QYxRYknVK!X9x} zAuCOzwcDub-y7W?c@yH=^f-#{ilhqT!}Ru`HEHV+MRxWT*z{}fB*y_009U<00Izz z00ba#N(7cz&q}>suf!|6ic*s+0a7k}{y!y^i()_k0uX=z1Rwwb2tWV=5P$###sa)| zg|mPDr_cXA{4<9Cg)A^Z00Izz00bZa0SG_<0uX=z1R!u+0yjMK%(ALxEBl5l%F;Da zxhAFsDY+UKSCw=unMsS1G&3gg6+6#_tiz}GNyNmIC@0dhVv-s9{NF>r|Nj=h064A+ zM!FDy00bZa0SG_<0uX=z1Rwx`lNMO;F0=GbgU9C$ab$%)|0jd~VS)ezAOHafKmY;| zfB*y_009U<;1mmt#{YZC@GnnsAyG64KmY;|fB*y_009U<00Izzz-KLRlYIC8f~u|= z^~#{xpZb=+oQ|awC6!Ffib;v1@&Dd1{F~1j0-1*Z1Rwwb2tWV=5P$##AOHafK)@{! z@Lu4?_YAD@|Cat{_@`un2?7v+00bZa0SG_<0uX=z1R!wg1crd1gUX^M=_p z%uDj_B}u+?FI7%#8xM<*>RqLIubF<_tF9Y^CxwJ59_}mUc;`UgGVa#8xdY8O+(>sf z2iv_LoR8QQWmU?=GD%rVq-Mn=r6I$Pv%slp*=+UA);J5P^|k83{+8CxB=YNJNxGem zZDj}9c4wpZZRt__&i#!{tzLhek)I6un|rmLVrO^l;nWL>QY;zIh#A-Te=@oAP7JJ0uX=z1Rwwb2tWV=5P-m074UmQEVm~vw9++ z+z@~O1Rwwb2tWV=5P$##AaIrh=<|P!|9_Th9A$+71Rwwb2tWV=5P$##AOL~0C}92m zpZ}5JeT&*tKQ68+=~!CM$Z2tA zOk$PwaU3~#dXH2roe<-SYX}0G>L?~Ijeh_Cn&JQ9Ums@ykv0S%009U<00Izz00bZa W0SG_<0w*iro8ws4 Date: Mon, 8 Mar 2021 19:42:46 +0100 Subject: [PATCH 003/496] Update .gitignore --- backend/.gitignore | 2 ++ backend/database.db | Bin 57344 -> 0 bytes 2 files changed, 2 insertions(+) delete mode 100644 backend/database.db diff --git a/backend/.gitignore b/backend/.gitignore index 5ee6b789..c41e2194 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,5 @@ +database.db + # Visual Studio Code related .classpath .project diff --git a/backend/database.db b/backend/database.db deleted file mode 100644 index 5bc23ace5225bb919f5bf43de2d601b25ea51b70..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57344 zcmeI)-*4JR00(dz5{PMtC!q{g?Smg071S2_2S3wwt4rLLhC)I}yY$5(U=|v{gda=y z&B3tmA;@bUob4o+!O?cVM1hHBJ*TgAS-i|4f3D0tcc00ba#Vgg4-z#m>&VP6FMmEES%HL8t{ zp_9u+t7l(7mn~^(Srf|Yt-L1KV}wY6n{3yFT%oMp(Mm$0SQZM~`TSKOz|q9?#{1)H zMz7j!bovdmEj&<5*}H0q9#ZWZmA+Ba$>nN|ylgplUvnfm=+r(CZ(8Y9n!>GOF|Vlw zn&L(&cV8{-2At#biM+elY^m>P8xAyMP={@$$zB!4ey^DF+CONTnN&l^rb2YMYue4pr zZEkBqWKsgFIN7^ec3p^!GLS0>5%H=ZMWcb}WzHAAzC25wrlCu885nN*tgFP4G1Yt7 zn~C2cZ;$!d8aPfHTC=3^DgTE~JA3AxeZj);!Ms0w{yh84Z@XlUPE4NzU~&QYxRYknVK!X9x} zAuCOzwcDub-y7W?c@yH=^f-#{ilhqT!}Ru`HEHV+MRxWT*z{}fB*y_009U<00Izz z00ba#N(7cz&q}>suf!|6ic*s+0a7k}{y!y^i()_k0uX=z1Rwwb2tWV=5P$###sa)| zg|mPDr_cXA{4<9Cg)A^Z00Izz00bZa0SG_<0uX=z1R!u+0yjMK%(ALxEBl5l%F;Da zxhAFsDY+UKSCw=unMsS1G&3gg6+6#_tiz}GNyNmIC@0dhVv-s9{NF>r|Nj=h064A+ zM!FDy00bZa0SG_<0uX=z1Rwx`lNMO;F0=GbgU9C$ab$%)|0jd~VS)ezAOHafKmY;| zfB*y_009U<;1mmt#{YZC@GnnsAyG64KmY;|fB*y_009U<00Izzz-KLRlYIC8f~u|= z^~#{xpZb=+oQ|awC6!Ffib;v1@&Dd1{F~1j0-1*Z1Rwwb2tWV=5P$##AOHafK)@{! z@Lu4?_YAD@|Cat{_@`un2?7v+00bZa0SG_<0uX=z1R!wg1crd1gUX^M=_p z%uDj_B}u+?FI7%#8xM<*>RqLIubF<_tF9Y^CxwJ59_}mUc;`UgGVa#8xdY8O+(>sf z2iv_LoR8QQWmU?=GD%rVq-Mn=r6I$Pv%slp*=+UA);J5P^|k83{+8CxB=YNJNxGem zZDj}9c4wpZZRt__&i#!{tzLhek)I6un|rmLVrO^l;nWL>QY;zIh#A-Te=@oAP7JJ0uX=z1Rwwb2tWV=5P-m074UmQEVm~vw9++ z+z@~O1Rwwb2tWV=5P$##AaIrh=<|P!|9_Th9A$+71Rwwb2tWV=5P$##AOL~0C}92m zpZ}5JeT&*tKQ68+=~!CM$Z2tA zOk$PwaU3~#dXH2roe<-SYX}0G>L?~Ijeh_Cn&JQ9Ums@ykv0S%009U<00Izz00bZa W0SG_<0w*iro8ws4 Date: Wed, 10 Mar 2021 21:07:36 +0100 Subject: [PATCH 004/496] Bug-fixes --- .../app/controller/auth/auth_controller.py | 6 ++-- .../shoppinglist/shoppinglist_controller.py | 29 ++++++++++--------- backend/app/models/item.py | 1 + backend/app/models/shoppinglist.py | 3 +- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index d0b54d32..5c30407d 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -10,8 +10,8 @@ @app.route('/auth', methods=['POST']) @validate_args(Login) def login(args): - username = args['username'] - user = User.find_by_username(username.lower()) + username = args['username'].lower() + user = User.find_by_username(username) if not user or not user.check_password(args['password']): raise UnauthorizedRequest(message='Unauthorized') ret = { @@ -24,7 +24,7 @@ def login(args): @app.route('/auth/fresh-login', methods=['POST']) @validate_args(Login) def fresh_login(args): - username = args['username'] + username = args['username'].lower() user = User.find_by_username(username.lower()) if not user or not user.check_password(args['password']): raise UnauthorizedRequest(message='Unauthorized') diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 20d3cf6f..6969e1c3 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -6,7 +6,7 @@ from app.models import Item, Shoppinglist from app.helpers import validate_args from .schemas import RemoveItem, UpdateDescription, AddItemByName, CreateList, AddRecipeItems -from app.errors import InvalidUsage +from app.errors import InvalidUsage, NotFoundRequest @app.before_first_request @@ -41,16 +41,18 @@ def getRecentItems(id): def addShoppinglistItemByName(args, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: - return jsonify(), 404 + raise NotFoundRequest() item = Item.find_by_name(args['name']) if not item: item = Item.create_by_name(args['name']) - - description = args['description'] if 'description' in args else '' - con = ShoppinglistItems(description=description) - con.item = item - shoppinglist.items.append(con) - shoppinglist.save() + + con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) + if not con: + description = args['description'] if 'description' in args else '' + con = ShoppinglistItems(description=description) + con.item = item + con.shoppinglist = shoppinglist + con.save() return jsonify(item.obj_to_dict()) @@ -60,11 +62,12 @@ def addShoppinglistItemByName(args, id): def removeShoppinglistItem(args, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: - return jsonify(), 404 + raise NotFoundRequest() item = Item.find_by_id(args['item_id']) if not item: - item = Item.create_by_name(args['name']) - + item = Item.find_by_name(args['name']) + if not item: + raise NotFoundRequest() con = ShoppinglistItems.find_by_ids(id, args['item_id']) con.delete() return jsonify({'msg': "DONE"}) @@ -84,7 +87,7 @@ def createList(args): def addRecipeItems(args, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: - return jsonify(), 404 + raise NotFoundRequest() for recipeItem in args['items']: item = Item.find_by_id(recipeItem['id']) @@ -120,5 +123,5 @@ def addRecipeItems(args, id): def getShoppinglist(id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: - return jsonify(), 404 + raise NotFoundRequest() return jsonify(shoppinglist.obj_to_dict()) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 066693da..dec96139 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -8,6 +8,7 @@ class Item(db.Model, DbModelMixin, TimestampMixin): name = db.Column(db.String(128), unique=True) recipes = db.relationship('RecipeItems', back_populates='item', cascade="all, delete-orphan") + shoppinglists = db.relationship('ShoppinglistItems', back_populates='item', cascade="all, delete-orphan") @classmethod def create_by_name(cls, name): diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py index 256a8326..69e2ec69 100644 --- a/backend/app/models/shoppinglist.py +++ b/backend/app/models/shoppinglist.py @@ -24,7 +24,8 @@ class ShoppinglistItems(db.Model, DbModelMixin, TimestampMixin): item_id = db.Column(db.Integer, db.ForeignKey('item.id'), primary_key=True) description = db.Column('description', db.String()) - item = db.relationship("Item") + item = db.relationship("Item", back_populates='shoppinglists') + shoppinglist = db.relationship("Shoppinglist", back_populates='items') def obj_to_item_dict(self): res = self.item.obj_to_dict() From c3ea94bbe7d141c5ef3f11f1693b08b48e8ce6ad Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 12 Mar 2021 01:17:00 +0100 Subject: [PATCH 005/496] Release v1 --- backend/CONTRIBUTING.md | 45 +++++++++++ backend/Dockerfile | 2 + backend/README.md | 74 +++++++++++++++++- backend/app/config.py | 26 +++++- .../shoppinglist/shoppinglist_controller.py | 6 +- backend/docker-compose.yml | 30 +++++++ backend/docs/icon.png | Bin 0 -> 43354 bytes 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 backend/CONTRIBUTING.md create mode 100644 backend/docker-compose.yml create mode 100644 backend/docs/icon.png diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md new file mode 100644 index 00000000..9f52258b --- /dev/null +++ b/backend/CONTRIBUTING.md @@ -0,0 +1,45 @@ +## Contributing + +Thanks for wanting to contribute to KitchenOwl! + +### Where do I go from here? + +So you want to contribute to KitchenOwl? Great! + +If you have noticed a bug, please [create an issue](https://github.com/TomBursch/KitchenOwl/issues/new) for it before starting any work on a pull request. + +### Fork & create a branch + +If there is something you want to fix or add, the first step is to fork this repository. + +Next is to create a new branch with an appropriate name. The general format that should be used is + +``` +git checkout -b '/' +``` + +The `type` is the same as the `type` that you will use for [your commit message](https://www.conventionalcommits.org/en/v1.0.0/#summary). + +The `description` is a descriptive summary of the change the PR will make. + +### General Rules + +- All PRs should be rebased (with main) and commits squashed prior to the final merge process +- One PR per fix or feature + +### Setup & Install +- Create a new python environment and install dependencies `pip3 install -r requirements.txt` +- Run debug server with `python3 wsgi.py` +- The backend should be reachable at `localhost:5000` + +### Git Commit Message Style + +This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format. + +Example commit messages: + +``` +chore: update gqlgen dependency to v2.6.0 +docs(README): add new contributing section +fix: remove debug log statements +``` \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 62f4cb64..662e45ed 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,5 +14,7 @@ RUN chmod u+x ./entrypoint.sh HEALTHCHECK --interval=5m --timeout=3s \ CMD curl -f http://localhost:5000/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V || exit 1 +ENV FRONT_URL='http://localhost' + EXPOSE 5000 ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/README.md b/backend/README.md index bc4d012d..75cd969c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1 +1,73 @@ -# shoppy-backend \ No newline at end of file +

+ + KitchenOwl + +

+

+ + License + + + Docker pulls + +

+

+ KitchenOwl +

+ +

+ A grocery list and recipe manager +

+

+ KitchenOwl is a self-hosted grocery list and recipe manager. The backend is made with Flask and the frontend with Flutter. Easily add items to your shopping list before you go shopping. You can also create recipes and add items based on what you want to cook. +

+ +

+ 🍫 🥘 🍽 +

+ +## ✨ Features + +The following features have been implemented: + +- Add items to your shopping list and sync it with multiple users +- Partial offline support so you don't lose track of what to buy even when there is no signal +- Manage recipes and add items directly from a recipe. +- Mobile/Web/Desktop apps + +This project is still in development, so some options may not be fully implemented yet. + +For a list of planned features, check out the [Roadmap](https://github.com/TomBursch/KitchenOwl/wiki/Roadmap)! + +## 🤖 Install + +You can either install only the backend or add the web-app to it. [Docker](https://docs.docker.com/engine/install/) is required. + +### Backend only +Using docker cli: +``` +docker volume create kitchenowl_data +``` +``` +docker run -d -p 5000:5000 --name=kitchenowl --restart=unless-stopped -v kitchenowl_data:/data tombursch/kitchenowl:latest +``` + +### Backend and Web-app +Recommended using [docker-compose](https://docs.docker.com/compose/): +1. Download the [docker-compose.yml](docker-compose.yml) +2. Change default values such as `JWT_SECRET_KEY` and the URLs (corresponding to the ones your instance will be running on) +3. Run `docker-compose up -d` + +## 🙌 Contributing + +From opening a bug report to creating a pull request: every contribution is appreciated and welcomed. If you're planning to implement a new feature or change the API please create an issue first. This way we can ensure your work is not in vain. For more information see [Contributing](https://github.com/TomBursch/KitchenOwl/CONTRIBUTING.md) + +## 📚 Related +- [KitchenOwl App](https://github.com/TomBursch/KitchenOwl-app) Repository +- [DockerHub](https://hub.docker.com/repository/docker/tombursch/kitchenowl) +- Icons modified from [Those Icons](https://www.flaticon.com/authors/those-icons) and [Freepik](https://www.flaticon.com/authors/freepik) + +### 🔨 Built With +- [Flask](https://flask.palletsprojects.com/en/1.1.x/) +- [Flutter](https://flutter.dev/) +- [Docker](https://docs.docker.com/) \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 88a6aed0..cdb7a3a6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,5 +1,5 @@ from app.errors import NotFoundRequest -from flask import Flask, jsonify +from flask import Flask, jsonify, request from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from flask_bcrypt import Bcrypt @@ -11,8 +11,9 @@ app = Flask(__name__) -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.getenv('STORAGE_PATH','..') + '/database.db' -app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY','super-secret') +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + \ + os.getenv('STORAGE_PATH', '..') + '/database.db' +app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') db = SQLAlchemy(app) @@ -21,6 +22,25 @@ jwt = JWTManager(app) +@app.after_request +def add_cors_headers(response): + if not request.referrer: + return response + r = request.referrer[:-1] + url = os.environ['FRONT_URL'] if 'FRONT_URL' in os.environ else None + if url and r == url: + response.headers.add('Access-Control-Allow-Origin', r) + response.headers.add('Access-Control-Allow-Credentials', 'true') + response.headers.add('Access-Control-Allow-Headers', 'Content-Type') + response.headers.add('Access-Control-Allow-Headers', 'Cache-Control') + response.headers.add( + 'Access-Control-Allow-Headers', 'X-Requested-With') + response.headers.add('Access-Control-Allow-Headers', 'Authorization') + response.headers.add('Access-Control-Allow-Methods', + 'GET, POST, OPTIONS, PUT, DELETE') + return response + + @app.errorhandler(Exception) def unhandled_exception(e): if e is NotFoundRequest: diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 6969e1c3..7d5dd5ca 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -22,7 +22,9 @@ def before_first_request(): @app.route('/shoppinglist//items', methods=['GET']) @jwt_required() def getAllShoppingListItems(id): - return jsonify([e.obj_to_item_dict() for e in Shoppinglist.find_by_id(id).items]) + items = ShoppinglistItems.query.filter(ShoppinglistItems.shoppinglist_id == id).join(ShoppinglistItems.item).order_by( + Item.name).all() + return jsonify([e.obj_to_item_dict() for e in items]) @app.route('/shoppinglist//recent-items', methods=['GET']) @@ -45,7 +47,7 @@ def addShoppinglistItemByName(args, id): item = Item.find_by_name(args['name']) if not item: item = Item.create_by_name(args['name']) - + con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if not con: description = args['description'] if 'description' in args else '' diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 00000000..dd8a34c7 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3" +services: + front: + image: tombursch/kitchenowl-web:latest + ports: + - "80:80" + depends_on: + - back + networks: + - default + environment: + - BACK_URL=http://localhost:5000 + back: + image: tombursch/kitchenowl:latest + restart: unless-stopped + ports: + - "5000:5000" + networks: + - default + environment: + - JWT_SECRET_KEY=PLEASE_CHANGE_ME + - FRONT_URL=http://localhost + volumes: + - kitchenowl_data:/data + +volumes: + kitchenowl_data: + +networks: + default: \ No newline at end of file diff --git a/backend/docs/icon.png b/backend/docs/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6d05cdf287e15e69f78eed841e39b21f95e5b754 GIT binary patch literal 43354 zcmd42^;=Zm_da}P2!~QYI)@TaN?NIrMnqbX4h1Acl&%?&ZV*N34(UdamQY%{1(Z%< zh=G~;9$ugKbv^&W^Am7ghkedoYwxx0b+3Cx=sZ@VAY~#20DwY6U0DwRpy01i07eY{ zIQAOHfIlE__0$xB@*$Q@0DuD;$`1^@(so*W{S46QcswRe14{T=hoiQWoFs@-%uOXf zK8y3ot4W9EM9b4(Wu{II7%YykZf@_1%Dnq-IrPct>0FcP?1DLp(8W=uJyJ>?P?<|1 zJb?C*%Zo#J-Z8>7){ZV#=Xu}%84wiv>@CM0`&Q=d+S@mwM*%IunWt*-Ylq_k_bgz` zo)0j9D+%GP2S0^10-bW#WT8atKT_Py|&O4Am(|+m01>ITJv3Ad-+%C1nCN@> zqqs0iMcC#|euaegvG{*d?1J+;nVZYU%1=m9|7P=-g4(qKgXca1;49U0)UzHxJUpn{ zs%slQHp=boB)J|j*YIKI_6l4Q@=bgLSvG}t{ePsd9>FO;JXcKK zFlH;oOk8|sQc@*s?^~z4A+w+M4~~+7>wJp13w}BWqe_kvG_HYGB0*Gq{A~73l?#yub#kS^!7T}j}tGfyBMwbG%$5Wov;7SC< z5mQ4y%eS=YaMk$=t>qSkh-bUbVuYO955g;sdrv_RaS%|0meQf~Gia$7TCm8^yCD1N zn0(0){_XLqHu%xUfs_`3@f9DOpZPtVZM*I48}Q=EgY$^Pwou9?jXBS&@4Xjwxt~k+ zEoyAyiL@ZpvU3tIQ&b~(*+yyo>i)^q8h;4%R!&2d!VLTmVt8luTlSO4Pzl3=Y?8G~ z-}vC=kI=0opmqF|6!+w1cILlnuVb48KU`=P184unmRKe797ovU2NMQ+$>7_5*m3`n zNq^Xzwkm}m{$a2y_5y0@Hh9`g7ui>$XleT8L<4*wCF^-W7^Q_mtEHI;t*>STz;JZD z6vG!zvv-q{zQ7s@`X06!ODMl%Fr=le!VTYiiOS8y2e?}09lT&df?MKlfm!-J$UDdv zcERtBJU{)A26%z{*7f~EgfLCnL0rA#mlSlRx+D7mKI*A0Eq6`kcOq0UpUl00o2X|$ z$Wd2@IpiR0H4m`9+=t)P{@YBAwLJfme*?x5@M&Wm+%RP#q2ih5b<@RB9e7SVd!O+?cP6b#9UcrQzb)Qs?UA7R|+>3u~@$)1BIJ9+FxEBfxsY!C(~@t#@3&-v^+C! zcB3OS*I3B<&)Dqh{N0>~0RS5ur8`6&??J{og9E;$!=Mn@PYc(_D0hmMWHs0>$dg9HA*|;7(bE`%M6#Ll1-Dr?_2T7W)3-w*!jPPBAw^ z*={af-TR}5>+7+trNhoAwJeI2yM$0@-VwLkD1tHwVVh2Bw8jF?#0^k^aLj}x{oAMH z0i5s;TeKHWB$AUZFzyDy@r?&GaqY*yqQIr7X!CC_^RsG)xmFa?7X}e-ZB_Q zNuDXbL=zN*ig!|?s)=3vb|Qf-7KHvz{O59kR1S`BJ^0{NmE}ad7W+Bz?mPCFLND$w zy)nw@#yb$8qyFYJn;)~bzd=~RFhK!8si?&c!}50n9E=>)UujVszyLDhA0*#T^Y=}d zZc2nJmEw+9q?+Y}8FGUU!+lJ-`$?8_B;#z0A%(G%9Mc|99rEppg>7wqV%Vk>w=tMV zn9CI;aOjw_XMP`Y_*|ab8U#}!1m{!5d`RiEKiSZ1Qn)cGD1=gkdHl|g!-tQ2tu!MS z(L$)VQAb#ARfTMy5PYOTt>~32S=YRSacHavu>`5yw6!@@scAOczTtLh!4zdkt4Xy5 zv%z+-p8d^ErreUvWkNcpAesSP7-gbruqj}vU^%+<*W}|j;_%Om%rqR{>O#!83|P8; zNPDP~%U>UipKb#?xmd#37OxTDlDCF1#j zvOPAVoOV};7=`BqM2$}zX1e->5tiKMtV`-93l#=uEi@p_gA+cT0Ep`)zW zTKf5SVVxGn*Ij<_c`fArW|z3W_drQ^6J7Y?+g^@!X}UY_mcl)h@75#-gFMMH^+!em z2Y(VV2d= z8d&#}?S$X!wS$iP4O8d`a|K&3-Yx|PmfJ1wm)Vjqu$~kJ`Iz2C|Ld>$Pij#f+8hY= zUrq|fb+1})0}jb7^or3#$KkLa5`H}9vZC&~4-1cplmw?waOO_0rXzZ^lhs(eyJ z)j@)}^f_13mVI1$!0obw(R_pNE_T*>9~%{f-)@-+0kq znT>(5BP0lmC()3Ld(=W}_~PlUmQ2vsB_1xF^{1y_?)mk1%1Gw2J0DQE-22(5<}93( zayT8|l+eJ8`Lu$#GyTrsEr!g0YNh^8E6H%>pIqoRbA#1ileTxS`5l~5Ip67|gDHE} ziKZez(1w3Zb+-TLMrge0Rix2=vY6o3*Csy_Ugshg=e)lkv%6~|Yh^_eqofO`Vd!Ch zn3T{kRP&VZkuIeKK+-kcukN|OM0w4pyEQ4E3P=dAN{?0_U{|2f6mEum$oNAFV^+9; zEiIkDB^*wxRU_bo!ZctcUzMZF%>GqGQsO^tc2J6r9D=drFvT~K^!%EVU?cu7+M|oT ze{PDRnNvKsLZO~4Xis%9<2L1}JM>AOct0fP8!}b8lEKej3hnwmoP40|HPe$J<0J!R zxSLRPh_HMx7zhms8)@d2l;qe*A&P16{7ZV>))zm-P-h+m*OdAYx1O!CJKV789zsuF z?hR#7v`sYL=_C&`iTo4HbLjbB&GHkNdNH#MvnV-~&Sm8;4!el9`wGyY1gO>+Sh{9xa%d4{&+)%gA zx)4v@yHCvh?|k7Mj9jgFPJN%gr;{G1auf0}lRr>-h@azLT#}6P%txMm((oUj*~mDs zKaLaV2@nGXvKiT2SGlHWHTuwa!;~SWFm~DNj62AgH)g8jXT^7{K6T0Qjk>TZr|ngT zMs|_TW7XKDtr^GAp}pNZC%Zt6BoHnL#5X2Hy;LuAhoBrT1B=`c-`Qw4A`u=cA};e& zm9|5yBq{{P!*$CYn5C?fE-5&vDIyTMU#S;;&xIR|qmFZh&6Ny$IUMyz1^P>Vlt5*} zU%3lrT_h(dL82oFzmq0}fhq^S3}BiO}Sy^($8@e zQv}m2z@G$ve-|t(@Y5mLk0Y2N1-uT4d~bz51%0(Cu-x0|ul9M-qT|aDmFdsm&MtAh zg=Y5Nh3+oWb{*J#J*>(n%ZVh)uamCSGy#h41VvUn>clVyB)tx_L{fsu%lCkFM;a$- zejlgS3?ryk4X0W`*@E|gKY5muAU5(>gptmZ&uCSP!m7V~AD#W@cUGp}PgRfsa?q@& zO}!$Q1l@gZQKTD+S(?Jv$2lQpRA;T?bdIe7GJahBqW7Mw6^>y?u(Th6=bq3CIQix?%P#= zjlt{cCuz4dvAov~e$O$Fy4|J}F{48snhbt6T7r) z?UQdG*!?RaTV^weW;Cy(a>(}-z0B>&H{>fl9~1N1S+LtJW`nzM%5yho0dPiML(L7< zo8)y_dqxCX1QbOwlPE;dOl#m>cav$08q?rY0j##y!qCO@nUu?svtBJ1PRk#8-bT9T zX1y1r!6|pK^a_=kQAJ10=)WjJS)N>xu5C5&jaP7B^|3Q18~2`|vG5gZ(DM6*N!Q;l zsAFDmySe|Dh;L15D$sT4kkz-e(Wf$dq;Pn#THQSv4QhPrSGC*IP*bcpBMCA{f!grU6bKc zr{i35KdD^D{vV`C7gDnlvHLub1_;r^V)PH zU3A&5bCJ=-DXgyH+2qra9MO7O0;^IpOJ^G=<}icq_Fs2eBhFU~3%Lz-E;^VSeNT-w zo9%?0A7O4x9fTw+#BV-`zw9)VCTweRU|frB6)46MDhV3Z`HFpPJCn|ksU#Cz)-O0M z^Y&hg^p3Owf=6A4U&v40{)g`MG(8a<_-DH1C7|FJK+BXG=O%>{S~zHilfJx6x5jDh zAalo9t^Cf;UFY4b@^i7|LVTTHhpvyayHwX1OWIl(nFw#M_vZ^W=HWbOc0Kb*L)XL# zE3sWthuaD+z2K8xVR1peIxFep*!AS>mHuSQ3$=^U#ffb8Dm3!aKfbmD25dgP!f^Li zPx1;*LuR{-s@}>;wl(Yfci7(9$u=1$0Ec_&k=r&ALu_H;lAV zeta1~s%#bL|AjrK&Bj^<`rM9iyS(M*+g|f!d-ngVidRb8lg-UYR!p`%-Yo`-rEXPw zUFuz!zkQ)pM8@=NQo>03D#Vp7H3RClqZ->Y=^0 zvcYJL4z-=g{Ne&Ix1&3$zfye+`5X+SJZ9;xz<(pyO=mrbV+wmv-q{#elV{KOs;QE? zg6-m_@lY)^-l6BG&*g8I&^$VxF#`B`;H%4?y+_MH5ci{-o*Y_h#y(mk}iwJl=d)$=DSh`_8N34)x1KhbkHMRD7UIR8f#OCB6TwZR9?A*Z>D`#!oKgji~! zi0wfIKPS`fh;0E1WNBzuDgTD|ETaZ_>e*XcEh%XQ=W%uY@ zKvbXL&Nlve#JPiB+RbR&V>B-L$%xS=8!bl7b`24=ej<5HU$WsJCj>Ng;`ZU?%!2 zkRc+ocOBVMmSyMlOmhFHUcU3-?skZWzt_bmnC4@1X=4P{CR`PVv0YAdHg0Y=vraQ@ zYuolSJ}TK`E{PS4pUTwFo-0jqk>18+TW)|px@r;5rop2i;_*; zET2?H(2vko9dlaVMsZ&Ux6TjN^^Ife-{P-tbe(N;;GrsL4ElAOdAhU2<;VGeZv(8W z-|kq&)IkygmOVeKAdhil+HN$Knk>2~FrmEU2aXD)n4r{RMfJvzem9jlm%6@?g30g7 z?U5z8>)_fBhdCZK^+E+Ocmx81(EG`yPv32LJk@a6@CQRCpz?Hd;!=ZY7KE3v4GUoH z{WeXt-|9$pzm0tqHzX6=wtB|(fvxT3LGIfv4JJJkhjzVEdzySYK50r)>*XUnOET>0 ztWQkgT2i1J(F|gYMmG?NflyRlG>EiTMDu{kv(I5RO!$pG|H=ZOv_~P91*zRg7G{a* zi(++VCa)`sK>`;c8DH@My-Y-~P;Jg>k6)wfw_5@>H zFL8Olb=ij1rNkFMDfC7sCzt9NcR?#hO6fOusCHW}K#T}J!3Y7Ozxg z=Vig8qExjt%dY6*@oWnsEfLR63?7UW&?l~pqdgyUjZQjO@4m%hoi8Eo#3=nOf-gX2 z8QdO?gZSV-BVao(jyCF32{RclZ3L z{>nex(+OwE^%1hD!#HEF{I+NXpqC;F#3!Gq$TsxbEKQi6_;U$%akpe+2boz<2*8Y3 z$aERb_^$yCRez7&(I5m1^~$yMLe_n?IH*w}kti)>4Y$5>NEX%>njEFn52kiqU6KL|L z0e6a0&QM+87d?1-tZqN#?U~p{PV8y*tZv}W`Cfn4LSlZL5gZNzm0EZLfl&63wE#W# z2ce<`bGsEOP#F`U&>5<}i+44rIBb8mM^))om%mODHK34O@7ec$y>?oG`1X2AWncL5vD{ zUoKGy^(Zc3x&LfFoDv0YJRIbx>ArISPj>e>jw|d#Ep&HE)?a)+j8nD0hjfn)8pQ|3 z)-H;2KrP1!O6N~adNV6d27{W5rm2a-Q)d46Md5Z;nc$JqJz5u}a61n1GyVKoX}^{L zj@nynF&xo>Jiq3eb`4|>fiKAdAYauY2C=qM{vK?s)3hFZ)Exu3N7&fhLP?MUd1apx z{t$zf<5)SwO|_u?r1qEUW1A$&+Gm%AO{gSk;ZB~~6|Y$jSA2g^fsR3E{Z7mmB1=-#F$ED2k?OUEof6;K zVY@w?)BP3<8Z6Fs;t;5{*QF0PDhEDBIQRJeD`Eq7`^xppGBL>W6-@%($U3&)ZdbW% z_CwOP(2Uz_-uR2Rf8GS=i^Xe9_oT*X?(pDmjf$vL@U z``7Z{y3C=KE6i{tZLsMVuMeZkJg^ zR;HYHPtyNm5Z%|2DPQ_YrfZ4MqL*$~u4xZjqfNBIly5x{PX+VC6)&@rMa9<${dq1s z^oIimAtCXPSp7m)@#gqmMKcYWJ@tjB#R?bT4he|rVRBk$1U-UQAon-eacw&FF zhf)F5^fr;hp0#EmSx;cKY05#E2V?=zNT%B%wgkbI!#AFmvgO*!R`bO?*jav?4eNtS zB-*3~e4+2ze97NWnlxF*CmJAMw=|Klrc}f*(Qt5*l*BX`9lQweaZapGZ3AaFdFa5i zX)0}o9X7gmqYsz=HsnMB;_kpnGiGU2Qsmy1B_T{bxym^cLYY$Z?lzPLaT}^Q9Jg(+ z3*8vgE9*u*3!0rTa)JDIj}y&&-dIJ&$e;sI_I@9G#7)U0t+{U8qS{CU%l6gf+*&CP z`G9r{bEsoY?*o_j@XZhAP zV@UzHKKfgvxpIM$z$;LG9IAWrZkxl3%VI|~`HsivnVrl@zK2!l8ClNIOM~epO2~|2 zTGt_%3FIpo1ES#Y?@%s)TabYQg@RPMmB0Evp^h3U|2#?sxRKo$JF^qEeXb^>R<+i} ztd%}6{G9o))a5su0ZZ3G;R_?~Yggc(!>xP}7v%0>NN4`s-ANJQpWW-Meqtl{RPydf zzsHQKDL-(BdO;5eHUgQZmo{+W^wdG(KyFp}bp4ml){GG8q@NVjw9`77fR#_dPigvd z$%Hu=d#x|gF9Wx>E$^eI!IuQbn+EGcG}uAgseRwZHeaLA8dHb;5C*aS$_1iAAM!r@ zPeK})81sm1?o)Gc^ZQvaZF|j)JkvZB+?CxVAO+XldEIF(0j`ji~$UWXFZeR?(#+CH#}cy~RBAEa!dI^9DbbKPO} z#p%=Jxe487t7*Ua*#g1#8guUq*rTM_$8+$;y#-r+HYD2x`jDNoGght-5|ptuc}3Dy zM2&#&)K39f$wY7Pv)&Sp9D4(A)hyQR-y&b$+lrHVIb7W=xcUA=gQraS6@R(bOmez2 zhY}3){7i4)++x{cVkyZKo6)#0{HkLm%687YgfBh3n|`n-C3OAZD0KbJigd|O+usSr zQBD<`y$51FS3Rp6VWL)OLT8!H9xH?{hn4Gu_8+R=*#W7_T)Krye#``~7RE9Ke@BkZEt# zv^Q-po%iFulAl_fJgth;x1DkwVwLMYtdAKaaKutvZB*U#Dh9ilY#tJfye-Fs7}|co zp3=l2Kpx5emFN1ORQRwvG`(pwyvX`iqRO=~vb|0YBk|=X`34hN*VtX@7gO!`9|Kq1 zA!gq8@cX?3a!evtn(NmmsZ2pm%g3_NEFK5qcHkNE-Sg|D;FiGXe1&D8Ac5B*Ryux+ z;W9SSb9|dfzAByW8o>BmE!k&#N3TU~LE1K z6#pG^mF(pP!(KP7M&~-W?CBmPe=Knas2KQxRZPnAw$t`KyD=t6yF&FmAIp0xk{KSs z4A(UZRhREJwJ*LW(oeZOqhZ>Q@%pm%f_sahS>WYucy61PMZ}crnF52ugVjQ{o$P>a z`J1SCBM{A=Qu^GSpmSx=Q0hdZVgsv z)Ykn83$YDm8_jg)Ees+K4$lf&evW(_hx=>FV9sXUlMG>7={nd&Wm?i^4baDaG0eK);86-Ye z)L`QN0puN$cA+UNWhsTnYbP}qiJzN(@tFBhNQzD3uNil`?<$Ff& zFz`vM=Vv1y&tJXy<_zK$vC9f(WcQnzNdii0BG3_JTzN$>*~x>_)nUP7GqPdt`~o#b zGlowOJXnyuhxIe&G*$T3^EUN#alOeBh7>$@t6Z2TN}T69%1 zt!@~TrN4^HZFo?JpDh)17^rNeF1pM9S;i;QW}h9H+(WVw()#*-u5 zJ5#(ppF8W?v@YkJwt;oT-8)fhY@eI@bW3d04V$I1(N3w}EIoJm7UDwU*}&CEI0Tlv zB`Ag&9e>DJ;m%$fdHoQ`or&H_w56|C$3OXH62&CN@+M>Gph<1H{N;qMN(@0Rzm8DV zfTzCcBL2%lYIV7N`4=aU_GAdkrYnuny}UNKO)un2idt0IBmoI5X&*0LDzAgGrEc^n ztytSN{JG7<$0lR?`e^TwS+r~Kq324|{qjTpp@-EA9B71nF1_;`8n(|=@pGhRV@R>*pY+f_ll$9&puCavsP#$4q) zf)#>}uQnD2l&!`k(NGMwx~Z~JxlH?Y_ix(-4~RV#Uk61Yyms?!Q+J*4mYKJ$kLTOE zFTymN`AMVj;as5qJB^|sA;6P8JgXw>kngtdLaxqH*~PLDgLP>oXy}=;-m1dClqY^n zYBpJiV^{6cYR>coZBU=VQ(o!qpH-E5n)xH=p64>9Q`&iM<1hN`lgceDGn%Z@8}D;w zVYFy>mU(ke*!)EkJ!N=_@( z0N52|NTjLfLvdFA?Gti3{~gnKq6;!2hBY9^SKe{rJ|j4`{mWT7CP!7pQoEnys;IG6 zbGZFGZ0)wsWG8|NpLEk@qpe_>w(cK09mwf13>S$b?zh;2yZiQf0qe?)PYHcGfFqiu z3GFAxZtDU*`0xB62$x_WHt6`-5D--`9NUdP3`rhP9F*Gc76JR)=~!XtaS(rYc;gu3 zk#JgQ2)#wFDsb$EayOJC4NRhR>GlKV7PNBSc%g-hrWHVFR3q%mZo5#XE!mr%_iefd z_B+Jf^1k+blMg@@qiEvoyT8dfT^D=$3XL0cl9ub{q87#ZELvn9Rj7Rl7BbIKA#Xua zU-jDDVKwmLVhPk7?!aii86+gt7A&wVAV<_3g%YPH_RzN5i7kQaWX`~wKar|mI}F8b z6T~cZtu4I4Z%U=LQVW}ZVnt4~19VPJQ;Us#_h0DtV^gU>GjYp?O`L3ket0K?)bd?caMA75z%SKz zRxtzBWpP|1v-4`x^NVM^A42s9PnX_Z2xA{BW?F!>@YcgC4Ge5-gTAO?gHx3WNqqw} zAKFL{(gbVVuCw5-E%`YrKQ8D;PS#`ga{s2~VbPeij$m&k*b?56u2(3Jt7zTx06Wo= zGAS$+mVP{ojfI!a;1!iSc1ab^#8sXLRX_aZiXC1to*8~9wR$58#jgV=r3r`9E8)3c zUKz70A4=ozlNcn(eoF{CuhMPgkG@gqy{OQ+eu6O0g?wCTH(;DF+Kg`Ap5#0WIYM6t zvexEK!Ia3Es})Nu3Zk89uR%Fi|62?WRbe=*IQ4unr@cFe)~f@)2OHzvBq5i9Y|(9; zaMD_&CXtXKV6=`cJYY}{0Q00?Yy#;>KAilk@6@RBG(5Wm7iDEP#OUw}7k=Ufv>@) z#OrGgOGvqLtU>IdO$1UN#H1}hsG^`Y)n1)DjdGh;(S+yCMbRhi`U96CBK=C&H8~CD z0EF|u8q2Yk9;kvp6aG~T83Tql0t5iRRHdkXiwfI=`x@}wVz0t(m}nri|31BCb(EHA zxqW#}}iK#CY`dx?e-$0%q(z_VQjHkTi(4OwrV z7aI zJ$B(l&@@%HzLZ&;xMa}cm4Aq7zB=f4(G`QliWt*N^_2{d($5sV!)f0;>Dk^0K!XZR z#NSmE0GMVaOId54yt<)5n%*v6R%d!pC)IIl?Cex}p#pv$c@C2B)a5%3dhGV!(<}s~ z#%NLX0#(+yxe*ElaKB6y|(R-3wY-c>}T&I<~3z-GtB+hz=ciz#K| z!%>rPw2e_|QmkOgY;H5FbA$Cd#$4v_W;T+`B}2LU5^U$J;G#c(a+#7XNt=!yUn-b{ z=Mlzmcqt#7`uf=yteH5D+Zlx20bI6LSpgk)+#b-}Hmbq%56m6kD3UvH&|Bh>X@<)* zH8reiO|I6%7?!DH@3TtQvvlV+OBE9@>7~Liyl=J3T}-aJ^?qhs0!0+kZVO z#77U=#NeX{%37jmdDH)me?eMFWsxLCpIVHakrpVN`(J6l_RwTev>Fx$u25HC5DOXe3mx=z>l8 zL~qY5^T_v?*9sq#N$ko$-J8GJ#K))QLDd0r$CpXLdz0RNe@7(7z%AU^q({Px-jjn6 z9{5qL>}-h(eSmx^AiEw@>s*6<=Wld5&~^6vk1Jx7;>*@Olhk2YSzxTLM&@3MWcB+=bz?t+ZUS=(;bbdX zcDDKcz~PpvnF2nfY08D49+npz*;Be{=UVl_zy)URPwxR5XxHU-O zuZ_#MOSWih>nl%nF$5t@@<+nIjhwv~*NxwV#9u1IvYA*o3~aXa;K8+mo{^8yt++9Q zq5xYSwnmJv(fz-~b{1J7b1K|Wb-^*(1QaI(2I_KKi%2=f%~ZLAIpJh9ut6{4VZYG! zO}e^~;rybxjVB1*sFq;c=-}>VO_|V!(pau>S7LM~i;nM2HE(B zf1nWi6uVe3EdT2}>i%Q2^Y)T~vNx`HH;G<*u&)W*){7OcXka05QtZAjH)>pB34>r8 zDp;8jtJ=S=5dl9KFdr1U&=Qmu2@Qk+$&4=|IDn@0ybE44j^LD>QL?ph z8nW8E8sXuGPa7U(>px2PL}XseLFm5fQmi3zgr}=gugH83fNnEk!@Z^ki&b=N@@HcmrT{%Y~gwVtS>KH zFQ=ziovU-vxfCm)H?SYaQA(qE#O*IrLRA+nw%TLB9>pei7emcA!)2hp2-?2-<6fwQ zuW(YReHDWsP?bx^C~i>ITRbto9V%f=^1M&oT?VhmYC4ecJp1m%g*MDFxbxS0#8PPd zBQVcYGo^Mg0aHKs_{rNJ8O=5Hb1(ET%Bfl{N$O7zBR%k1HaYoVDu}^mJmkou=L29G zcNc{Ap81qD#E5D37K!QL3Q#<%R^pElPvVmQ2+tTk(YhKS2>^+ms~Hm|@ISymWrP2J z`DY#+RMQ-;f@Pux&((y7YnBA6ot}4x8P>1Ffw1PvA2_CjF;-p;Ht0prXUB?MuO%Lt zWvh2--`1H_f{37A(_in%A*rc4%b9siPsdy#l}`Fwrs7ftoCerZxW7ryvZl2HPQKhVMwJ7(@#^`5Wo>2hD8ad+#EaG2De@SqAT3LtJCpQkwbCBmZMk)#cx4AopvH&r+V(GU_93^HnCabSvwt*>abOtiYpR)IK8!4oBPfIn~? z)r;xNm`v+WqOj^3O^RM!LQ2JzyoTA_#dua9!YoZK6%P$G6QKxgybAa@A1>i<(br{j za(!aQhu@+$0mX0|u%8H)e?T-7_Y6-R4594H)%}iJ;{ikjP#@R1Q2Us>SAo245%H83 zQc403mTm_nVq`V@+I?pP0B1@sd&DQzr9|j&cV3w+pwIPTWT^E~%xO-J8>b4uk*5uI zi%*{IF7ZV`AqPHCU>4yO`6`tlFbdzJlQ znuhVk8Od$$FEi_gUR$~>8!ggAR7q_0eYnMHn7S8*Rei<6wrN-Y=YdTzSPVvj6`GhF z`L8%|hm4wSK5~~_KVQ!c^}cS}b{dLAHQ7*X5ny$4u50L!P(tB{50Rn=*+8GtZMBPo zl?L`sXQUT7h@pWL7$ramulqiNqKD2N!t7h{*D&BDgf+N_l^+l7C<4^?9zfy?y+9|` zyC?)-JGo?HzP_h(^d_?2=&|zkOCs6Q9t)}-D5B?(e0-IYTV?*ty zeqC1Q&}`sNYUG^x`Mt-U>AL#R$i9dq6nHG|5GcZ6yVI~AYFEOi=YLsaXQSTFheA(qI884{-Q|>PmAtro{1#kO&b=MPApwDx`IG zG(kV8+)ecU^>BIJ^q3#48ljv1g2X^YZIS9R`c{l z2p6^9&j5PpRfqc9ZeqU+s;zc@N0v^`Ab~NrbFRJdd|0>(?8wOATAY26F5~-TjHv-l zS*6r2rudqq;LE2w^PrM2_v+$ZO}YG|&s6tOxSB1(Vnm%%E^dPD;3XSz_<|xr`yS}s z`qkkia;C`Htq&m6@|2!uw8u$6(_u!+<=ZnC+_KH;URWC_)30Nvd6X-xEX4s=#t;Tp ze2xTxI#5skJ@&UuY=bbUP}*HO_Y}zRYxycrERopZPN_~4b;~r2-gb#S z83KaFFU!c11cTt0XJhuq5Q%x@dLHv(=gx(`Xp8Xw>AJ0tnq^*|y=4Fa&TU1sIN05? z+N;>_2~7ORumS{k}{*P=6mu1dC_$-*Epzpa0;Mh~z9 z024R@1QM8FgJ6JsjPt1Fx;lP^c`u~U>$#r)!Ih>bI^T-VSt{?vqz0%um_Ma&$g2&* z{|~o5&D`j*h_ZaW_UJ06L~Ue*az;mkGBlqcY={kNzH*7Mm{Uc*iY z$Ts3VVHxL->T*A(+5HSb*_KXyyAW9O5f0new7EGOgTE9#0}rHZ+!4Qdu<{rT&g-GTvvMKS!jqqV3Gx|NXM;+0}Sg-vbkSFdB|Ueqo8#p zv3zb;{#{X5U_a%tUs82Nn$!vD4zAzO%MD{4&FB{phlc!En+?g&ddLTDa6&R@WwL4x zKA@!L%{~$NBDv@&JB7?t)U9cr)CT^i1sG4&YS8Xg zzzhZe!HA-5@>ess3Ya^$#UFKb*$tI9Wp;BrR&SD_jln+pwHMrS$c2M8kUG-=CpRKv z5ib^SOSvGSQFVkBTJRoew*O`Nhv^aI54=BQrEW2TaHe3pGQGWI_ zHZVTJ9z20BOxV@_>yRNAY&`ha1`~oUKnvjj_(Rq-N;=`=-gH+w)Va7kc_!c!RUk(D`tK^wOno~FZYnBD z#ZO}tkoJ7=DPqqS9QT&Ot4@Na0m3{gvB4X`ph~vR_k9b0?8MofFAOSy);!! zon_+RH5WXw+I%6EHy^%LLkzV@yP88i7*AN(`tt5>Fz_2e4s(wDpjku#*s#Cj@wZYIc7x=G;bX*AXG7IQ!DJ^74%S@HenQ#@CF0C!E5s>TwBY&2 zS^Q}4RJ76_30)fh3|p3S;eKLzIA!G8ZIOEd(z>u#Fm&N^`3Y+e(R8$wm<#GJb}P_e zLO73Nx?L5ftgzC@MevL8S7I{WLB;&u%zvx!ubN=Pu+(?k+sPMM@lw3Zyp3na<>(W7 zq|G|C$Qm24ZYn}#_*d*#tL#2V%Pr%Qr{*Frn^@loS>_M|ISvfJ76#*w6=;C-@cits zds9-@%?K8Sx73>Eel#EGA9emLanaJC?=Uj3@S^=9{EZZZ@AcOfd%&|b?YkleA^|3A zc#~%4ZqOGPoRfpbO32K~OA@i0J*DIO+psVE`_(Y%y`F2xO=$q-d!XZ91y7s;M`~3z zoS4CTd^HwxrCTh)KveUSsBY6^zIj=N_DKvc9ei>hHU^nyC^!e#Ge4S~zp%00Tf6cX zT`$`vIPW4y92Fj@+nW_~i9Y6VMm`3yQXzx4Tk#?xP2H__nDcEEbw`Hw4ePx%Bm({2 z@wI1nXVTQQ4G^kR+ywxw!q{xw*gH@^3cL20=xV~des{9|sWuTJ%HBwiloDF4si@33 z9q>oX>HjhJ)o)RK(cUw33J6F^N_QhlOGtM~gVG(+%%GH{7<9KZBAwFG4bn(=cg(x_ z-h2Ot_wgqm;Ow(|txv3T=E8RI(O*1du^X=FhvoO4WRZNoMjGOCe0}9RXIC%9@G4wf$I!gZorIWRH5ceWoTY#%PYD z{jkWPwIr>Da}_`*fI!%t&VqJzN?6-KF$oA{i6BN;!?{=whhEmb7opBx$?rD#%JN~D zLrX3?#-Dc&gzrQi{rluWs~_7!w#4iqi-*@7U5l2NTW1>r02yZt!5_B5+O$iO`*jTu zi)29o7X?6zpA?;sn*y#mLFnEtK|J8a+{fw9p`X1#n2Rca`%>A7dUNd1vu{g|BET-Q zEE8&ddqRu_8D*OvMUwTGIFN!6{7`9l*@(wxe8;dJlO6<V@NFHi=^NQa`_dV-9FY zBCaJ~?V?GC@h>foH?R7vlh5w!9j#H*InSX2lJa(qe5yf&q=lNVUC9e1u6sWLB zH-N{Ho%ECS!4J1XRnN5Ff+e&=$9srCg&1l+kpL|prtHza_f7N{Kk)doIEUdtm~wvD zT(x5QZKQ@%UMTp&p3`M(E!q@^tt$ZqZ zO+MPbWM{&1A`XyBJs9Sx1)>R%cr!5nbJ=sh!I{MoGxfQG&znYAXH&N~=vrUOyFMt2 zx70DL1t5j7B=B$eK+ux=38E`ah%)*!Rr+6ir#sNAE2|GUET?{cR!{dj*xBnV^dKXlJw)mzAuS0M%{LZ_^T4H=VjsKS3 z7~p)pVSiuIu!@>SVM$Z?@hN9GScM^<`LpC!KmI(au8K4(?x%T|c0I6uVdGrYa!co4 zUDD9ak(Hj;{k+Riq~`^i+0h|%_Hl!hB(-cx3$*Sssg+9SvNk}yVrS~Vf-GdF_Vo9C!-yvDne@{F*D6o1y2YEPnc;D>K(RYv!k;$hADldUJ;HG zlNSQhyP|hetsyIejuV_AgOSyK!xJ3%?v?2LaQthUx>xM4?4_Zj=eg^tj+-1Y*`n8* zE`_Q#hw=z;w)_m=AAv^DK-iI5=a}=j%SXXq2uo9kJl`{+ZXdole1R+WhaWUhwDmqV zvT%j(bCbi81pVi^EGFRQJK#%`Yf-jP{fUPK;icKqCw=Yzai4lyoBnf32HvZb`?CB@ zCS-Jg%4(kaK;P3neyt+$O6m0?=xXD~u0VXLNL1$kO7f@z3;+~_@y0-fJXGij{r~g7 zL~nf{L2qAbD9FuX(vFHe{)dvrC6xjI*|3oRsXDVv_5umOU`>t`|5Iz``Me7npwl4i zAxyoc!^1h~z-bTC$5x>^Y)>15l=GaqVu|g9jhL6kUqli|Vh2b&!$ytWt;2 zx`0*IO;%O%!MiK>_FpHT`~TImd5}~29y;6}H;HF#omjx>Aed=vU);dbNqH-&&j8eG z3U&GE%-^#)C4nv(XXW?=#Q)hylh*Lg*43{rN1)VBYBL2N-KhC8h$7Bvi_}E-SwVDT zI?yom??<+Wh%WsGP%8<4d@y5t8)yY6iB;5cOqZ>pE#5(!#erDF%JNq@{&vnBvnpSF zRD|PYQEzGgshUp*ct;&=_Riz701CSW|0i6Oh-XriW|}ywf50#R6WkxnC(5;t06F(A z7=7#8Hg=iRS+H&{j~!^p#}4EK58~0EGlz27*nuDlv%9}#7kug? zFRDv~^cDhG0yKW0P}y%djSCne%m1={q-&9OfmM8U!Z+=F$RI%=NJ~@o-l)!04=qa# z*e2P#!rAW0-MS3{BDoL-M7|hM33XDfnC)SF2rpm-jlNz%#!K+yiFwjs8t#Eosw?G_Ehy z<;2`%&cIXmU_KPKxGj0e>mwF#t=Q|m1cCFHmo6|2CC7&=*L`k_(*USuUo>*x+^6u| z>%=J~g(_FVaMEXy|Nce&OUyGA-vJjfp@*5Gz;T#?3~;Gg(P=9l-}S#sr*co=Vkr}- zLG$k#s1wd>9z+{+nl(>=s>t#Ugp_+B7fc@z(lK+KT>e+_AhXh;dg{NMGCuFj7dV~G zPUc`8z5Xq~o0d-jH!LFdAO2 zU3)C)Q3SSl*M( z3NDWs9^B+-tF@++t{}vIOAQX)9T$MicO{A|=wk>!7~3x{%x7`0F z0Wa>I)qIi*6n)`<{(p#pK%E|U605eqSB2sWZ_|oI1;7f)22Z-h9OHqtoqh|e*^aPm z6UxG=vA@v;K5lG%QvwGfD19G3V3&C%zpuS!artGNn(a^|xEOjZkxkCa>HedJZck@P zqalgKywxyB!{)XIR*naJp`Sp7n>|Zub8`HCKXWemgrQ@#6T|1A#Oj~3k-mss- zL>ao)_NUUNK_LlzP8njPnYu{fE*y1jLF5=Eqi?4H03jOZ-|YYqv+fvo#h`AnTW-t2 zsh|GfEJy-DFwC*8oDg?|l6UAV>X3n<)QelE*%e$VD za|@~_VXIQfIvn6pu#?wb^8yap2av$#b+NGp&NK7X8Qc}tapj=qSmOE55`2t#W;VCG z&x*b7boHw8(N4Nm+voWl1FiJ_U2SUf!62^-=p^WN8vC?JA1X!tZ}p(0*hIYzwl^t% zjU!I{WVBGr_j;12-+NIv?yJ`pxzawlE>*sK4rqb$tLyZC)$vmWl+?LeG7?YZ9ADo* zP=1e_;Ot~8EkHQ%P^hPgBB0Y;x){wQ@M!u_$ERKgFs<2Gh60@V4TD+d!*&J2hpcfb zbx_ZtTGail4mzZw(km!EeYIb$qQDhuX)!+Z4>{sNu>nBehY>Uh6Ek_D&d9EgcQTi- zp+O9JG#I}o@&|+FS2w6CrguMzh?wxTtvF6H;BoRZCx9Ztc#E4Hg&n<@O~$2EFegzz z7k_wQNdJV~eaw9FfZXZ#lQ_ICPHU!${zZl;7_K+prOA7R`NODJKvhG4ojXcN+Fwm=KJQ6B;+$EDw0B1WG`?be&PKO5(kxo&fi3m zrZsst6W!EBJSypSYyNdYPj~I6S&Q1vR1S>fApZ{)a6_m{jZlEMw7>6Cb?KW71(n2- zzsFL1X(`8p{CV+(=l(&fGQ-vE#CI=sfnk*N+nMDOG3$GCSkU_tf$&dHM0x#IisZ3D z0*|En+mI4d7WxWSR@IX4@2BAN4xVgb)e}3{nAQ!8J2yoc^LzD+Rq&$4c@qx2nZ5VH zaJ-UIk=o8Tfe^a!q)%!iD3;}RNLU|HqO&GSH&TT8 zF8Z$;#qlmSECXbERj+?5+|D%hXvZZ8UHY3cT%QNbmPp@-U!JX8I^4wf3hiBfT192Q z8E7&4?S}U(p!!+%Mcw;-*B~~jB~jMTT4RIaL+EtMUonrQ(a8nHf^y45%rGzbx>|`! z2BBh>0fVDf3A`#k0g2zQQ)6`H>s`JyV_zc#?zW7^#r(N+4jlS7{bjqxP4|l5EzQdx zrqP=o97y0(Ng~0SnW>WBTgi5bk4$_DiNZ6RD2>u7GYJYglVUF#i9gswTg?B2(()cJ z-P|jMMTnNUqsbnAnr!~mgqNMVDMEPY9x^NLHFkDW!pzZ~HyaQZ+v}`}(j&`&M@OT^ zi!`)wX(~H$KO3{(y=xVB#yRfS|H63GDt{VLq_8~K~UM$=Dek5A3--|VBs?0ZbrgOpa z78g$=HnQpMp)ViR-F(n;tc>If%5BuSsxv09%c%5*fJNe5P=&>+ZePDpLY22H zZB5hT;@#U@-TgVZ%#e7}zcYiNH(crQYn8t!Zki(l#DyYgxSQF+Ie3@a7h)b|pD!M~ z>ic6+x_Hs{Nyz+n(2g>}XDz6llYng`6D3%Q-Tj?7J6I`g)4@`)CDpg?* zSWYkBN=?1Xw^~gJ_%w8!sNcmF1s($X&`RVud7viM1P|uA!$`(!+21~6Rp_g@&O4x6 zqA&{+EZY{s_<#ag7LF)O*?V!YIDJbV*L*B(+p}0}t>|shV*Vr`m+$r`Q_{Q06)aks zETk;qNGhKY$II&ON9KPf(`y4#CxrY-z&VxT8<(WlFm@IYVd6Dz-};SF!6Gd4@m=NN z4pmFoV9!E;ST|b@yb2>M^2cwZuNzRXk5+%2QJ};!y@ueYJ!Yran@P40 zmr-89`}0*RfvmcUB<$+g?s6|j8Z}{}yk`WS7yV4{en!=ZQkQDn z@T><6@y;Oq*Q6E33YUz{aKw~TdUWE!kpdeEPT-HI+2cjg;3n#NMRfM`X2|cKBQsbZ z&c|_OpkX3l!yOXUX!c}LI_TZDWp}w19qHmYnGktkmMwl+8OmN(m8*B=(@aQQIcJ+b z{>>>edWx;vZ6xLo{jg@iKctaH_L(93%O^d9>N9z}=|%Hf2X%7wXCsPly0AyFpu*%C zu$H*n3-VtnuDYbP+v^AVV-VTx5Dlnw01Z^at1G2K(~$TJL;Z^iz22*QzWZ{Ls6VLQ zR1^U(-?!7{b1vzYH@|yXT|?>Y(XTB88&X0(eLGc9OKcZ4P(Bk6PGBr@23O?=gl_0Fghr&@VFEF#n^s4 z;)*@qsywah#91#oJ zoZj;WTm}(6tBvKVg7+HDi_DE|N-JDr$!5YuzHyFvf)?G_LhFbt z?(Fb|(cSKDcHkH~qaPbd#;_J9Y)^VM^ioU(3`%{)axock4KQ$sCga}~OpL~kJpMb} ze&1<*i^>v9k$ygJ5rAs95HO-WZdr$KU&uMF#fm|HjEo7PJE%Ro*S)p$f8+F6Z0sw4 z+#Bt0S$vGVThfpdwrOXtP`2!!>=Ck^Q5V#z?xv}FBY2jZCeh=h69^gG+jqq)x&DYj z#-Wv1f5h@7Dd@;{*Pjmq^%}I2t5}aA(hGG>^$SPU$`u8mOy3b>)Ns+NaxcpVI)+q? z*Yx18?gpb2!0Rvd^g=1VoBp-dU4Xr*$Rp{~ej-o~V{q{FgpPSbkWOjdveb?r?wSX%NACsKMFa$ZNH$)jccN znI(&yXxt>VdtNZG5dF;hYPB}o|5YHXG^-u&6MK~9Cl9lhz&Gf9*`mHp z6F+*|ZH0Y@C*`Ek#mzb#*&9t5`t-nnBr?G`N3?7%#w=0Y`}@k&-}42rz1wR-yBXP! zu*RG`6$7#9HLpeQc-0}ja)Ka(huLaZJ~FX5QGw`uq?79D?=H?n(8BGt1{t}Y3$lWB zBNbRkL@;dPD6A(q@KM-J7nu|c&wCp! zoU$*9X)tl)+_77jCVOEzA$!DCE0gHCUa_};@lVs)*=0k>IQp|0WF8$lx>g%ONUYJV zaXN&DA0sa6{VyYtk<7DLT~n1GKTGiNXt>ZA3<=uqmS^#)h@~=ea5(ZUrd|Hw3pQRX zLUM-dTomfWnYu`y*IH`L;AK|>MKdv$mQ6@|oT?>!OM6B|R83=j)9<|FR1EhTN=jG< z^GB1Q>YT~KtT7#&{(%vLMLRP8DQ=8MWZ~HEm)~RLr2(%kuAn&8=oR}5??`sCk%RT$ z;f1GHur$jI408xLqK>7?=ax@f&UH@6nc5Ia=eHnQ>BIJV_9#DPyUEe)8P}b<^uo^5&pwS~SN|je z&ED0Me9}4?4H>h>rNI=OeOz~b2;+>}4Y%9h)cjSPaE%LsB3A)isosy|EC3@*<}d}$iajYWz+e76+UN;581eA8AylWLIrU) zMtjhaSC?$;KyfQoKXu7X0AkYlJtX%`!Jv7s+b7gjlulLmMh-2YxiWSh5pma%^D z%<=Yw=$?9G7&(my_9;?4mI1VY8k_)yyr#-^Awcf-7c41!6%z?Me|q z=r$rxUzS0+4|}rRgPU!WJus*4XLl`e(JaD~pW#@<7>k++sYmE#%_o%-pu$`;Eu%yG z2Cl-9Br3a`Z3ikg$ZHmj3_3!<)9gX)-6O2>Y8#Ep=UZvpd5R)hu&;#{?{9O7pI>#K zu#o5J-V=XO;lr2JQW)#WVy5j$B<(zLrSGeZnbv_#LD=Ubr$ z`GTtx1rTS1_v*3xY9T^|88Ve#vaB3&BSJBz)YbdALNcJmK(H;{g=O{Ahfr{RxV=$n zz|hC$gQs}@&^LH&|FiUw1X9N9=ga1gTry*8 z6C^XqKYPaTT8Qwo#wxL%S4tPO?ds_cuW0t?_yuEWmg9F~V#4WuD-*^_qW_IE#{A~9 z@%;~n9j25iJH8B&$U{by)eNyEO_4QVH=9ASiivP@uoX{f=ljE{GW9C#{jar4rR)6C zZQC-{KpOVX5Yo3wrQ`=^EICVsR(FTTT8U<60v=#Z@&@A z1o%7&GCbY&w1CV<#?wxV^vW~U4_UNRo9uZu088xpvi)_}1UWVptY&oiT@z+3#cK!= zCK<^nF^0-)hU_Uu$Xq4W>lSO}Qfpz#_K!D0r`L#)=xVKx|0FB2C_X+`tR;Ye)+tth zK)*zFC&MN-6nue-&Zd~x<~#v@CMh<_QQ#~X#mNwAOcPwLvZMTX6iHp$>=hXCuopNU zXd%RtT9hM@PMf|NXKm%)UoW^`WH?-zk~H7;VgV^!oXb83h&$)U!~Tk!kB3?Nn<@`4~T(tX)9D+`jo1AtQmlP!;XWxG?>Fsy*omlVWhGf`v8-SS+snhrO z0sU7O=;c4W^F0h#1;@PKjuvEO@3C{ltr2sdfW3?{@U5Ad+?oR}DVqaiz>t!6dmt{q zndc-bkm6R$_0BxcRr}uWGua?{I*o1t;(+IIyM7|?2*_5di1)Jki7e~Z`pTaZLyn|F z&+s0J^b)4#KP?H@BT2&9bvSLd61DKU#p5y^2;^8VgvmQpi8GSAaU4ot zij2KRmVm3A5?)ku{28OPV+Mvq?67Xwn2K8e5a+kLHk|35te=fyM6o&&D+}zwwxl?n zwUTD00jP&8a1$6O)y-*gmJmnpt0}2`lGTEz_xs)1M6$y+9Y0j4*tqBfuYFDF$XEEf zL$_9FyFx$leC^f}EEVqfbVBLzWo$$T@Ug1RpRime`A9}eUNL27G&HHP7)e2*= zh*KMmQ@j-Ml>RcuHGliit=K?$n87F2hAaOWH)>=enTEA#&1Am!;VTYNhb^sva$D{` zl#u>ZZX;jQnCaCL%AZ$td^W}XM;!>8md&dFGYOa>(KT z0#PJlm%89@Mj=$%qxtPyz6meQULdvio=Qnv)Eo7fY6H~}iRj?+rlwyy-0 zjw7M0`iRt@CowT?m@DCw%)d z(nar)k`orCDs7vQkB{}LSf5;wc=*e-BOPfRoh34;Hzs5aSUy6+ZJZR9+)5?XUu6vY+N5xslbuwb@3DnP1=uQroe=?*7>Q5Kf1FM^ zDRHq|(Dkz)|B-#?y&omMG?<~6hp0PZCUet9FQ;++>Y@MEV3l#o1M;50G%{X2-7Gp; zB(JMkIIG%~t}Q!-Y{)AMc_ijd3Ez{`HYHGnIkg`HniHb=bA&64CGr*SV@M~`Sl*~G zt%kq?&L5=7s*babD`Q2&@?4Mj6^Dc7bDe>u9Xbh-K{;~B=fZ!%BSc*?g_lD2aiMCw zOA8sKSFk{r(qi41(Xi>1=c~{6w5TI991W#~Zf$MH8S(Tqh3;bFMRsU)=ft-JyJy1# zP>&z?DFwPL7|$r>FV=mdf}rvS$G*k`2YZ{cs=tHJ6F~nUB|NFkb*Nc!*f8dy%#8h> z$D^*ilJ3+_*T~IC;mdQ8UeQQONW=FLsr8UqbFfM%MgwtUN%FzLdtQp;G<|#wQdk%v z2obsXfD2!{I}C1moX^ zimIYXzl6U8DdO>Dn_hDk$! zDsYsS-e-`)+pW}5>> zxhgbHqPUQamyQhBkbEw`aP-m!Z93Q!))NM9aC^D-B zuZ@wIo&|Ei3llyln_YW4I>!)ZD37_X+;tODPM=19Ly%ClxO~kLpyJLR)$jhp*heIr zlgAzQ;UgVH<^#90BWq<;M6-yvK^pmHuuz@}4i#)ern>g(3*7!>?)Iu9bogOGAGhaRmEQS=N%x1omQ~xuhSDbD*bxB{#WOy?;wPW(wyiN;)IXlS5_s4GCOD*7)CbVVb5wGsLNP&6^Uy8TPhz=ZCnk0NzgK z$hV?jHNW;e0Q-QM#V4lmhbV>N8EI}YF$MXbkC zR^U4*PnOkbo8DU-$~XX$Ab;oe06?)KZIb+b5LD!$5zUq~3bi7Fzu)^3)F9~TxCpwURTdZMLTk(FXB|D~XICsyEe2l(*F63!e=aqvV<5Ggh>LpL^M)L$I&*` zA&;^XRhoUbFT(eU!OmR?oVTOodQY$Cb*5BYfAmsk=w}$)07c=>kx8Wv4(ke60W*i( z$`n=G2h1tE&g730C~wVg35`%8x%k181-nTH*Le+9=w||J6)h8k@ zY;BAv&Y30pwn(Y|A{3{(^`9Tf+TTRLq$S84$5Du~)%E$8<0NBG4kE%wU5nGMjQ9+A|IL>B1%i255| zo#3$-_Iko=Je1L{@-@S}$lkIxk1>_3|B)xX{CbdrGJNacr$2O#?_KOIn z{(Cq)LwCKHgxHJwVIj`N5db`1@?GNP@MSTK-1BAWD?2UjvX?$D6+ zqO}~w15Lw26>@aJ246(i$x3KZPG>=C^g^(e)*(kQ_PhStd&q6VQ+Li@r)5QDyF?u! z2go2ezCf6zLEb{!W{oBn*;P7w|Mw`MYNsXCYlEc-!hGcIQ^BK?wlbG6BcwqV-Q|s- zSRR=mqvX3j;<>d(a8*qZYPuHA2pStlg0ssRk#nWzms=T8Oh}$=(hfpPvMW90$dv|hjZu+0%hu95SO&WyvG3zB zZMzY|vHV1Zd3af~nTL?pWDlQBzss!FixrE=9HxtuUVJzZq#0V0`CAi9-3n%%xxm<% z<_4T}Z(j(mV&vo@dBub~(}Z;yz``VK*1LFJDhOvSoR*I~19D}=*~h*dGod(^mAW-$ znK)szJFR|2Z9D7>(L8_t&SJ=vKpJO8^!e1&fWMa%s6L}^BTj*Kn!J?^DCX&D9Z5Cm z-EJkmr?+1B*+PKhsTKE|g*R|B*1vGM>7srUe&0Xba^LVZ;G=OU*zZwX;@g)m>+N^G zzJZ#~9JZ(u@&|AvEw9;zNFO|OA?P>GrJU)7LyXzmKNxa1B@44*6E{L6b9fV9u+)UT zR3A6W$v9u8o6Ya8k>w-56>LGEz3;#?>;Cl0n*a&4o*?=j3lLLmo*|3-dplQd?VASc z=89c1gOzMj1Q@43vSwb{1vihY^=|H!MFa+awfq-Yzl#}&8(L!lp7Gq}-eN}&Doj@P zH$C}-@Jim>+`FHmyG}g!Vg?H)F(_WC=LIDxVOCCF!z0yz1~k0mTI3&^=&SERQjFC{p`8<=Ko6BzMtlec z5y7)j$nRxXh|vkPTjSzSN(2cYHcL0QerdtgOLkmp2Tyr3r;rPle3VP(*ZX z*11tvDlt{cy-XnB*j%~$LnqO)p}|kXfRv$7#8=4r#8{ND$g0kOYzXrRM3mzJqyS-l zKn50+J7Q}Kgg$RuHPU5sw)cD?025_B_j3TC?*p>pKR1RF)R(M|uJ1;bi19jWC=P(i z3-QSZZOf-k;dD;i8-a=of^*ryWW?KRbAH2nhW{on;U;}?Dtra2+_YN*FT+(r75#9Z zem}gwz1`&c{ooMog7?p%_kDHTR9n{e0Rx8oXc}TBytiGoe_℞r54XW**NVt1T4R z^1oRjTJl8n+QrRVzIx7QQGair`@D1Njx|Pb>508yYU>~DefSPgtK!U8cQaVJ`PucI zSYJ5I_)CXLfh0sMj8P&iTUbh@4Zd73YSTQYvaLoQobTL1!ib+-!C0Qj9>+K&CqX^@V@GqgtejtIuXSkKMvz*i$`J_*ZN< z1uHQd9k>imm;mFKJP7F)bzojgfVqTBN~eU#NT3Xti0zXbNzTTgw3GO@`}ZpcNA7Yp z3$XUC&~iC;MA7Sm{Bd-daiTvTzA@_+5IYk*s~MZ0bwTkJf3fSe*Z>T|OAHpR+7p|( zPF_yN9E8Q~InGrs1hJfHVCE7Uu3%;-%TuB+yZwfz8^<0i2u8M;j9~W=v78o?dh4!V zkbRJ7=9#A;6k_U`rK4qFahrvODCq6r6`% z+G7o0BDcOu_iR3XtI2Nb9?Ng5*5Rob+sWjk+=(BIj0yh@00+P-cPH!OuyrHzJwcK$ zo!4?)cZLq#{Jh{q`h{&n=S|gTa)T&e3YHsoJx;LNNK{;9&{}87Cz|QgV_{x1(x%7M)r|+YFZGqAU(O7dAJK9wE`EQrRVL7>u2iueFBXr)dkIIA_@HbdK-axybGe$w~U`rg4zQ#t4tF zsW*1dnrOh>Yx@eCosaeLtLpLIz7c4&*-c!u8ayEhu10-(iwy2w*d<8M^H1(&w8B`z z11xlSq>1?HA!x{S2L;?B3OX}Bt{-)N`DnZZ!zdEyPQ zQmteVAXV_~%^(Zk9`I#Dl!|HMUpXzZYz4zV5N`N#<*+jbEvFxii-K>1WKSH<+VSOV zM(Q7NVOAk=-$TmzMHaQ?daKZlfBGOI|LXA254B9wK)LmXpFth%pnw(crxeE(`Qf?5 zR?ZB86q%U~=QWU>6bx|h=$|!mqU?8qJrI_@cX&OH-m$9Ko%~Nuuvm7Yk339m_vNU+};`+>Nj8~YxTbit*e5pu!hia7S6$a<6k zX^wcHuhkNSdlh0^_<~kX3~hL0{+0GX2p1|oDnvuHS|7)ghE z`om2AL8lSd9Ur-Y@yYcm!Jc(H*w_!Nq5B4ft&rx)aYfLZsJdHb42v(1j#6>rUZw|3 zfu9!7)j+c9MZ{kOPGf^r2?S@55tht-bPuDA9~abj)6~}T2NAQn>eH^ z9LDlrjuWnbbZ|HO?CHOYYm_kdEE9JF6;Rpi&d9p9P2U9mUo7sy^`!YS?EIsOE4R!< zz~)m_;z5bp_g{y5-+VveHlf5nETE`?QcxQb?(n?0KD}AXbhw4msX28?Z8){ zq6O`Jov6td|Jtmc%sY3lCWC@k0Vj84+EdI0JX;Z{E2;kIP}89!%ZKQ?%Bd+xO@??s z?LEb|)K`KZ#ebH`Z0YM?j1bug>S+DyZ|2_SHvQB^N_p7vO)1^n-IV_YkHQVnhh_9% z!!5~eEkK?Vss9M%R$8~Z?qCk+Nki1bK_$!GH{HU`*QO|nrFFzR3x8RIkF~f(Uw3?O ztAi&VXk!qeM!GnEPi*U~b&+`YyvVq9{aIP|(flG3t6a`cG>^Obq*JUuPnjFu+7EJ`*{M+hlC2<5Ja-;M{a(Z5i^!4JfcA-rU#1jYYNHzgZ^an$Aw8N0Slcd*l`rsGE5_GR1|Nj zEw%(;%U)CO_{fLd>B0fyxe_`^HWe`UbZH6oWaY_5>|T7 zSDQH80~ZOROkJH|fr{hTxNCtrJ;(PO zKIfvo`4!hw&7D8D*%3g9;Bs&p9J`R8zy37~VkK+gwMOreNlGH>XA{Je`#JCMyf|1T{M~)i8sojH z{mzO04e{p0()yy`rowv*$1{?rO=W5sSVxtV*1~@+8%gpcjvKN1miV=%R+8ginxJfs zEZ!lFJZkDu;)yDp=(*uMOa!HF&C8EW&o>YvhFbva31!Q6TtH|z=3zMRF>4La$%~2&y32AVK&wj!f4(J)(-U7Qj*6F*5%T;$Ex^4|c~0=IS%5)7i&~U8L`2&&eJA&GeDiUT`Yvlt zrl1(Q#b0NaSQbO5^eKpte@7k~`#q>S9+*DEJ!ADZ(Op+~v`ZAF+9M>182G1EUQr|c zGLMAgne&*ragjboap^v=`18$xfXv7u5}71Ii1@X? zEI?`t8y*hNnR(=7Upr zlN0L`3lo<))W_cf%1H?vLMX`-9t^+oF?t?cDrQB zvFipiFS451qjlrr)X$ST2`%e5ukFHRG$)12Ck=XVqnS^|NkPaGR>7{3h4@T8ea1&$ zKhF_YdAOaZ=dt41+VFKr^m-Q(%fnyk%E5*%oovqM*CmlWEr4ahCZAPEqpVqIWlKX zfbug_ej^i@EptU#cV^*wla8j&P4C;=g%&=+Ta;oT&O=j<&JD%vkmAdzaU+#$dl|39 zE9qrQ9|8U=@-p+R%zCOAWrJ0pmQIi)2;RmP-gAr)v))p;5A(N5M!QIU^|`L`^TSaS z-WC)3Gf_cjVT<4R76-^G%Y!PSY&>7XGCALC;3#v|;6&rVX#pYuKErTVTzN#g&(=wt z-SuzkV2?-SEmuCO7|#k-ket!F*A3YHHs2BF+?7;Wp%@bsntOBpWnh5>4*=%);fydm zsIeVl3pTdLi%dEfQ_^fnfkAA#l%Uozc|=3vaO#p zk?*~NY#IXOmzpQ{IO|VgEdeL$n=o!EBvWQHwr%g#0ff5|5ymFn(zS26kEO)#4Bx{< zcLzh!8{^2+nBh3*$uVe_vZGiRtr~Az)rfqeIpair*swTpVqG>ESk;O74clvmjDa@r zH7P=GSnL5w=a@@Hi(pF@Wb6*duuWR!J>T)8!khk`$P`iT?QZ&yA&;upDT7dyVU z^z3l&Oz2nw_c4B(x$|kn{@+z^_S-TXnO5y2j3s&`v=DxS2YlHmsnSw>i2Y#+%lsJ}>$2k4SukF^pOgVic9!h!e2ApFaCf;f%_iiaz4@Y(; zB2SOV&X?sjd*OH=<6C6w18w4X{7etXjTOmd(}HP7xxZadzW@uJHtff0=eX#e{^?r^ zz^j$i&oO|JPk?z-E{OXh$%$VLi#Yb$jdXTIzbni;N8i00YtZ9f_UUG;)&3qOb@)YL z*V=#VusoC7ttpA!dmQ1sSL3{5#q@GP45V&Oj`swQjlNtQ?7W*vr%M&`SJeQ5u|utS z|I*nTEbxWhQR^mqe$&zXo8QgHS;jGvn)hgc;|O7nqyBZD5SY0(UJ)tN%58$VZekqD zM>4pqcB6iBI0@S{|AMB-3Q1bt-L4BuToF6CB#rG&O5t3YQ!|RmUA`(kzv>2ebXgUZ z0t-K31RdFhYYXf;AZHl-n8|W(#a2c&Q7o!Om4`&^cn2G3D#W9@GeW?+>2h>$<&UXY z8|Ud5e9MRgF||A-A2)u13BszS-ASwF~0~!|%i{z=SOyRy4OV_fNUC2uXH}I|L@!n;Fc+0#C ze~(H_*P;^^BKo(|0KZnJXe^ z7JPuwS4|jTS$BY&x!mSLJSy{d?nyinTEFEB@4%r2GPfUetvw`mbf!1xPUiPP*O9M2 z6Us())-WzazM`g-x!skwQ-HVgd%6FP<<^8Aq+bytF}$m-cKxu@RMabLX1Pgzd%a0z zsPvxVQ7Q!i{{N74Vr|({rGtJCn(@MWO`pDBHk1(x1hs-T9>Stg@&I;WRznF*t zQaiNA5EOW`j70Yp-{l0gXLf)+>GyZpyf5y-a(kjwO9jwa>#NJ>;9`QPMxp5qy|bnp z!osg2Yl&)#s*CrBet(wY8>Ia%o15>_`{7mG>xiI<?yD846=HFhvQ|Jz zL3OxOe06s+?R=?C|7C;2n8qkb>3O^@94=xRo|YBmJVO04iHl3TA`fdxdZ#!Y*xGm>?+Zm0dPi zHaNVls2Op&S4xkCgLQ`xu#=G;8)&XulY8#7E&cymy6!-z|Ns9!JM)APg(Q0uGVX*> zXR8!(oU)RYY)58xpCTbjvQj8zZ#iXUBw6Q-aFV^<-S2gMfB)Y5b+6a+HQvwHb37i; zxBC>H>F|nQkK|VB*tbX2+_6lP?}N@I)mD&uTniY-CcMx6t1`f=MAi=mr$KT{jHZR} zbMqK``ghs2!I$2zZiafB9*VWUMMZ3WZ5GmL(Sh1jgUiKW( zrakP<-nGCJHKk6gc~d6RG$5Hj`;~v!S*1|YwKUY-mRHN_aqr-C8>aN*M({vt zsv}k94t~bAD&2BvBza%j``_zyoI{dBlQanMG*E4tnwjY<%PbVV0Q%A(QOYXXA%T%|Tb56Ae4D zMgR}F_+;x0lAhP^FvMERj#6E-oK^0;qKW}&i`e$@$~T@IeLFd3(sU88{0QGZ6S`D^ zb62LlrS$n|Js@j9o<|mYyWejy3BU5gAB5!P-p*EofV?b=%#EaGGvcS|>q(!xFZI(a zk)zpAlBR9tJZVzhhk)@&{H3cCPEDpX)}3Zk-M(?E86+>SI0Rj^ z{my9gbyt8z=2(=xj-L=e_6*N)C|XqJOa!gDKP+vo^THMk?_Jik51?G+#2Z-BN@Wy4 zU<=!8uZO%-&K2Z=7?KsAlWHY1mG52deA~z)1t182%#Fz|igjSn^<4Owx%DrIHMDZh$VKPz1iPHg}-O zDR|@glg-i7h1S&%0wQ77k*5%3nXUYRdD50oIA=A4!;JD?d=8yTwcy zshy%f@sDbsxgccG;sAsW0N$hH;dF-=QeMvA?>REd4k#f>B6vFhvJh3u#Ls~DjC%3v zV)xRl>%pSJ&I7^LM}1cGs^J$aVx;A|yZ0WYb0;%njjYbg+GE{Yh2{^B6#=liQm4gb zWI0#y;l$Z83X#M2B%6_;)uCfmP6`$G4)FZmpBEZVNJ5qks8*z0hH|G}%vv%0;cW~R zZ69bYQSL|r$Har9Avy*du6!n))??%>z`ni$;HR39F;6B2-zOtW$r(d9PSV-fzy;$! zo(K@0H4_1fVFeMw-;64ekZmtWfIpmn&8D~dW$%sb#*?C5NfgJbvT{#3URmgMD1yLu z9O{Tzk`{|7ILAyw<{aI8CvI+W^Y8W&4K(vYoD&hiOwG&`#J%L=)iilcqt1@_dCjx~ zK=|hh*19DDZ4*>>a+M=7Pe(2{MKTgs`M&#HU-{*HOc$@Xe@DxEQf)(y&KbDh8H>mn zC-MBERNKyy5|a0~@W}s^^mY86uWQ+yKiRsW$n^9n56b#C>ZiuA&i0F{mC%@K4uki9 zT}tc`$o=p(PJjt6OAF6{)c#&KrJMsvo6Q525HYZhjJuvKDSNuthuEm7h|wy8eN792 zCDvwwA`R0uYr{2%p${}ze*fDclpz4o6Nou}Y4*p()4fz!p!qN|)QUbXyx zPF!~A&{-bewY@y0v-$i>#N0O5J-{$?W)l@dCo)7E##C%eBQ~}sa(r=bDbbB9aj?#^ zNv5GdYw&Uh&URDgz6R~M?Y+^eeCcGP@!NjgfEs!QQBy0{0ANF#RNkS+U8%ayrLONe zMKa{o%qS!k`wT-hnP~k3=I(al%x`!0H$?^Jw~vh)I#}tVR--Q3BNP?Dop1wpnk{gr zaP{>+;Bz-;rJ31GoosiKJu!CoO(L{EbMVA15!`TL_WfSqavR<1GX=vrloGC^Xgw8K zlb(OcDs>;e3y3!myGk`SZReAW)5Qj?MzhS1h~Z>548Tn)?k4p7tQ1G>bYxYchnW2o z#TxSv9b?Q-m&HFs;Fr0C8HAD)y}ct8d-yefq_5)vD>Q4|)(1q8wZAII$?->d3m5P( z6LWdvbS4vM1t)60v)>otEsAez@?2f{xD~eAy=HqNgtJ)crP4b&y{nws{;bn>%EYC# zs^nqp(NxZIc(crq7Nfbp2}dAG<5CKuAzMFqcawXDup`l#y)XAkw29xHgvDO;g~X< zFQeIR(+KYkGJh?qdN|gow~&H-T`iUWqjG)~iIZqp>fLBiIs8iWy|6nLH;LCmOl{EaiG#xOIMgcpiENod! zAK^~VSFy6FBTJfD`xv-aXpjX#Ta^_C&Rxsm3{0|&sJSGnbuKkaStT#@em_q;rfG%& zTetU<)GsU0AfOsK6ZcHsROy| zqt7dFvOj*4>e3t$CvU}zjmegO<^IaF%P9FWoyqLPKzci04kl;mD!r1#EU4sE%s zULcU(QsFzCn)p(VTerYhi+J%d?8WYfcV_vD2RBP0iEV+mZ@)Tj#}b2&Kq4B4OMOr! za$xb+!pE!p^UuGQ$xYjSl`(Vk-y2BET(}Aq&%xyL3%{#O*w;CdRN@?KGIJRoTQd7{ zzRcVy!RyV(5*A9QST6F?nb+PpTk0E4lipdw6D91LV8H3=^(A+<*EK3EGu+I{K?#*EaG1WY#jvHwHbeY6Bh1jm{`dev@p&@@A;%Qn` zb~=MDOAJnbHU@yO6t=>_*R)tzMhz~gdfJ;=N!A_`qJ*Zp+n5Jh+Q*Zo1)!*5| z^o}GS@FOr1;)O>L za(-Id7$}K{?G)D8OG%fu%vNVn3q@m;)`QK`=z3b`l7Z{$U=^3G?RTXK_=$*TxII(hymZ5E+`(7sFDNK@mb z=G3QEth(yafTweHBA5cW%#KUaW;PAC+BfAm{c?HUr73&KVA;N&xsXhjy{pEeE zz&rq}3UzwK^T6v>MOs4s8^aH)AY`)%6)K~T6BaESf=j;3dAb+mrNk^vllZZ--{n%D zof}M>*&Zj-*rhtuRd&;FEJ){S3L+BY?g`2B*j#OT&~_+QGojyYqIG9 zcLEKZUvCI{R5oZH_TudqD_|u^%x62=_Z)$5h|Lka)1w|8eR8ii-oIV1CM9sLw}MGe z?gl4k@H2Y>ov+Jk=Z8X0dG{{Q2AiI?gK=Fm=F>E~G<-b2dVWNV(^>W842{>{XL5k1 z0y&SP<7Xe8h97XPC_ACD!(9c0GA_|=sfD}Y7eE{m$?iiT9 zjJx5#Iwr+{KFOlmxL(2C_-G;G4Y{}Loq*NS-K%R$8?*U$bP?Q`9u=p9$mjXZ5n5P> zF?`^A;U6um-^csKH#GR2kfFcPXu(*%X~906{0j*v?W07*{HCeuPD-a)sMiY3apchv zZghhzM}=A%7JY1ZAaW8jOH-=b>4ICV(1Bm?ONFkW+gtoR5c(3N@88&;Bb3)SJHjg1lM?yiahfHBy6}16 z%MXPjelfjJ_22xmqp9s`WmhkJ)&neWMZsW~;Bf}6K za4hq5A$c^DIFhZ9*50@@LW4H)3iQW#-NSuqHtzhoHMh+R@4R?)YJ(#B#g#j;b(PIy zSbxyY6jzS0c$WR-Jc2Tn6vi;?5*ok{U)7qIVy-%wFKC;oS>cZDy=_Ti(y5O@vAra&?ijeB!YA(n-7`Rh7hm& zuR8qDlqWXwYzaRa`gMv96k6a@0N`@~j7ykZ`PsBR7|}o>SMYInvib}>6RsFJtuU0P ztPTe9?RdaBlv>GmnV}gmnCwuVZ+iOU-Km2M_fB;7e|R6=RzdD!#~t?7&LlED&4^sK zLeINZXk3YdM;)hn1RxwL-cc@}3xK~~eNU1&a>-jf=&!&H!Lg?uRQs@X` zk`@r}mk3Z8m~J7T9r?q{n$<`hkQ)_+C%7r^G}n$p1k#m#pIW2T2CS1rK6bUbYUM#g zH^2;pxW$+}hT8bSu}T6tOFP+pkFtt$yq>@6$&YuiGOYboD8?$8RJO~sRNQJr z7oQq|0!(CD^a;uv6IdGP-eOLoC57XK0R(BYp&fXvh=IoNFtGhy@!keqKHJR$`(|?F zkNx!wU5DaKooO|rOM#bPK4z$peG81(HtOw{)FpAmu{E=weI^1-t=K7amnZg=c#a`V z|0I+%2t{iGRl-kDIm%lkqSz?o4Oq;+^#@XixgmuOlt5r|sqfbuiy4IU3&FD5ae=xb z>PPZP*z%Q|{I6#a57P#)9473xv~e_7ndnO;*pM}`pbSbD2yUfRdnTxQ#Dpp69o<}X z81S+_GKAAyQJg-u@DT>CcQEDmzyPV~Oom4N8g7K3A3+i$Opq>I5;(9kf#Y@PXds*i z5biyd?EiWNRLzKg;ie`{8F~ZT@)i%G(7kHi!s~;z2XQ6A6)WC^McD|keutRn48rj$ zRAC9{DERvg&p@ei4nKEcUHG|!RVT0d%?lCJU6((Kz<-V~Lg+F!B>Vw&5N|HB8Z!iY zi!-}LVQS;NR|tU0XU1qbVbc|597umC`V@$bNZTz?ZgT)C*vmD9?pCA`ZA~dHlZk1w z1e`xSpZ8xkSro4XgCRAF;}|Qnvih%G>|+M+b2LPK0Lr-T$OQX9(f0!42?7cQL7mbp zUU-$#-AL&@(V~z|fzu6$%NC_-j`a<)L?m8-N_e4Tur>w|B~c_;Jkyvu56s3L;Yn7A zbED`x5FvS71!-PN3ukc1I&K7nWc}ANA#K+4^~$?O0W6VZ1}B{f7m#(yF`=i60H)l~ zUScrximq#q>pYM*yIMG$fy`_P*n;5s{Qh@fjqJGYL$9ATyC&IW@D6@+*YV!@5y1RG#Q2W6Z=exq>d zZAV*Z=HufeVQQJ>jVxfp>}QGe#?XnLdKU`m?Cagx3j|IiY+ZV(I$_pR_-A>^+}P5svRMs>!5B>%+Q6xf;{xQl#?i zeRF{|DbO`5&Sb{K`Xuo|EocTIEuOyqhI81zO_LK47^c)Q-J-;nCLCWzxIiyF{qiQ% zM#G5k|Fe@v`pN<25K>u-w+;&8XBL13dvm}6(TBJy!WFG~oLBSKYWtgT2<63~@1sGJ zqY8@v!ARZSBLEw%8zdhvZ@!r;0{|<+6a#mB* zf_Z^{m-I!3HjHC8Q;$7dqa)&U3t~1qDq{q)I;@mw6jod&(V?%!#SOSsWL=!pm-E>q zb>UOM#RdTFf;d9ZJ_w1Ly+1OvH;a`3Re?+$O*YFLbl4sW3s$)AUh3$p@-XYL!(#fH zZL*`XAXa1UdH!yz{jZhu9=qOa92+5*^c5=zUF=%qINnC;-wx0}a!qVPro}cn@}=<6#sS+F+_LLBf>}GZyODS@HYDzl6GDUrh-U_O}w9d`Z=L zei5cKs~yKzsBWI8;jUY5vrV|I<4DN(hl$~IKi5|7qX}{~H|(Vn*)K*JC`=ww8#Q#g=VJ&+60J| zUw!RQWvoBelM-?n=-dC8DIs~^fTBCs!z!+n=X5fdKH{57B(gJ4xWVwql&A0*)NFRx zdot1YF+s@PyF#ewFr*wGoh5taGb}Uvqw<*q<}2LD`K*OJ>&UZ3;CkX_mgIzPRUB4X zLqQZhj=uGcO8Vt{S9Y;(qTqnUXfvljfrL?fwA?Ev+h4iza>jUYLPS+laN1;`q??f2 zjeAb6ug+fI*>MO97bvZs*gi*_ok0TQaNaAExHILKKCXp54x=NS>S@5#5vsw*d5M%) z^psn+?FRxOhxjQlih8?cXQlKZI5?sp3g>D?^Mx6M5?Rd?W@fJ)a|lLtVqG6Qe~Stu zVVm)>MA-n+f01vjIgaZIU}SyjC&fjmtEaZ+ycZ`YNSXSKo~%9`E9eIw{)pH=J!eO{ zK>O{(11J3fjGg(GrXKuzOsue094py8F0WC2Vi$?E)i}to_G((z?Ht9>A|$B?U)Yz$ zVQQoz+yrYYN%DjsT<0Mv%ocXn1_+t*kCNh~ClW&2GizqC37o-my$Do&Db zZR<4_AHTo_K`HV!6afg)yH`ei_^^vVXA`R#et0lxS{)z*K`-B3+Q&lV&xb?*`*Yk8 zVKfkLsis+T9Ln7JcyI4N2gbb{*AF@AsBbQpI<6>beV_V3?XVW~K1QB-e%^aW?cT2F zzp)R!J1KSBJKMO4zY8}Z2s!@Hzj;Q`?9K;y@ONGKugIu!ov8})8`Ms(5d^hVDjGhE z+C26AuNn1e2Q(duQ&e4#LIw2OK+}cz9W-jg1DfjD*e;vD|wXcQi8i17j3PuF$qBU@V)qjt#1QU8G(St8{oiccgzB&2O@1kmkr`*8hd%w3 z71!pQ8&b5{S-rEzsq&`9&hBdG;Ey}hkZ13nQd;)7WM4<}`WBZb8}=x2^63hOi`vRO zik32|joKfJt0t8xXXAQ%^9LCJN|v~pkTv-ZFPWzNo4g*ZPpx{r!-1_}tXa)h6uR$z z9={zRxTlEedDi_?gu3Ugbd15!(JF>*`E>UWk2!0wzT3a+m-&YmT1H*`NH?jAWbwy4 zg$uF`yxTgaT^sr$hXsQ6?#||aBlJ}Bv#R}`-KK6+J|Ce4D}FWH@O&aESX7<{?1a!Y z_uN!O$JH{ljOGx>G0@0Kp+&k zQg>PZ{j{Kb)H)CDLDPP>yg!Eef_z|D82>K`!R;~X-Lh=O z$^8_94{KEyWcZO&hfOm<3yC}G3(bzqhmPYb%R9kgau1j-A*NCR-k*vFwOrFIu=C%R zH~+0VnNXy$CKGkA`8l>|Hz@3Uqs4a`j_|7*&`6@CJ3d$ljz zC$x3!S)-1CICnCXRx%YDpxr__p45LE{iIPxh`O|kt*zOxyBPCo?hi$H0Ru_MS v!WedREeWgQVeZRu4%iF7w@dD1Q-qjXb#^Py$eaZl7cw?5)qj5x{qX+)uG85e literal 0 HcmV?d00001 From 22d283bb2a8b4cb409b184dbcaee035fb04bd8b4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 12 Mar 2021 01:31:50 +0100 Subject: [PATCH 006/496] Update docs --- backend/README.md | 6 +- backend/docs/_config.yml | 2 + backend/docs/about/app-privacy-policy.md | 134 +++++++++++++++++++++++ backend/docs/index.md | 10 ++ 4 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 backend/docs/_config.yml create mode 100644 backend/docs/about/app-privacy-policy.md create mode 100644 backend/docs/index.md diff --git a/backend/README.md b/backend/README.md index 75cd969c..f0f3de60 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,13 +1,13 @@

- + KitchenOwl

- + License - + Docker pulls

diff --git a/backend/docs/_config.yml b/backend/docs/_config.yml new file mode 100644 index 00000000..079f066b --- /dev/null +++ b/backend/docs/_config.yml @@ -0,0 +1,2 @@ +theme: jekyll-theme-tactile +title: Welcome to KitchenOwl \ No newline at end of file diff --git a/backend/docs/about/app-privacy-policy.md b/backend/docs/about/app-privacy-policy.md new file mode 100644 index 00000000..1f35ee94 --- /dev/null +++ b/backend/docs/about/app-privacy-policy.md @@ -0,0 +1,134 @@ +

Android & iOS Privacy Policy

+

Last updated: March 12, 2021

+

This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.

+

We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.

+

Interpretation and Definitions

+

Interpretation

+

The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.

+

Definitions

+

For the purposes of this Privacy Policy:

+
    +
  • +

    Account means a unique account created for You to access our Service or parts of our Service.

    +
  • +
  • +

    Affiliate means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority.

    +
  • +
  • +

    Application means the software program provided by the Company downloaded by You on any electronic device, named KitchenOwl

    +
  • +
  • +

    Company (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to KitchenOwl.

    +
  • +
  • +

    Country refers to: Nordrhein-Westfalen, Germany

    +
  • +
  • +

    Device means any device that can access the Service such as a computer, a cellphone or a digital tablet.

    +
  • +
  • +

    Personal Data is any information that relates to an identified or identifiable individual.

    +
  • +
  • +

    Service refers to the Application.

    +
  • +
  • +

    Service Provider means any natural or legal person who processes the data on behalf of the Company. It refers to third-party companies or individuals employed by the Company to facilitate the Service, to provide the Service on behalf of the Company, to perform services related to the Service or to assist the Company in analyzing how the Service is used.

    +
  • +
  • +

    Third-party Social Media Service refers to any website or any social network website through which a User can log in or create an account to use the Service.

    +
  • +
  • +

    Usage Data refers to data collected automatically, either generated by the use of the Service or from the Service infrastructure itself (for example, the duration of a page visit).

    +
  • +
  • +

    You means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.

    +
  • +
+

Collecting and Using Your Personal Data

+

Types of Data Collected

+

Personal Data

+

While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:

+
    +
  • Usage Data
  • +
+

Usage Data

+

Usage Data is collected automatically when using the Service.

+

Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.

+

When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.

+

We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.

+

Use of Your Personal Data

+

The Company may use Personal Data for the following purposes:

+
    +
  • +

    To provide and maintain our Service, including to monitor the usage of our Service.

    +
  • +
  • +

    To manage Your Account: to manage Your registration as a user of the Service. The Personal Data You provide can give You access to different functionalities of the Service that are available to You as a registered user.

    +
  • +
  • +

    For the performance of a contract: the development, compliance and undertaking of the purchase contract for the products, items or services You have purchased or of any other contract with Us through the Service.

    +
  • +
  • +

    To contact You: To contact You by email, telephone calls, SMS, or other equivalent forms of electronic communication, such as a mobile application's push notifications regarding updates or informative communications related to the functionalities, products or contracted services, including the security updates, when necessary or reasonable for their implementation.

    +
  • +
  • +

    To provide You with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about unless You have opted not to receive such information.

    +
  • +
  • +

    To manage Your requests: To attend and manage Your requests to Us.

    +
  • +
  • +

    For business transfers: We may use Your information to evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or other sale or transfer of some or all of Our assets, whether as a going concern or as part of bankruptcy, liquidation, or similar proceeding, in which Personal Data held by Us about our Service users is among the assets transferred.

    +
  • +
  • +

    For other purposes: We may use Your information for other purposes, such as data analysis, identifying usage trends, determining the effectiveness of our promotional campaigns and to evaluate and improve our Service, products, services, marketing and your experience.

    +
  • +
+

We may share Your personal information in the following situations:

+
    +
  • With Service Providers: We may share Your personal information with Service Providers to monitor and analyze the use of our Service, to contact You.
  • +
  • For business transfers: We may share or transfer Your personal information in connection with, or during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a portion of Our business to another company.
  • +
  • With Affiliates: We may share Your information with Our affiliates, in which case we will require those affiliates to honor this Privacy Policy. Affiliates include Our parent company and any other subsidiaries, joint venture partners or other companies that We control or that are under common control with Us.
  • +
  • With business partners: We may share Your information with Our business partners to offer You certain products, services or promotions.
  • +
  • With other users: when You share personal information or otherwise interact in the public areas with other users, such information may be viewed by all users and may be publicly distributed outside. If You interact with other users or register through a Third-Party Social Media Service, Your contacts on the Third-Party Social Media Service may see Your name, profile, pictures and description of Your activity. Similarly, other users will be able to view descriptions of Your activity, communicate with You and view Your profile.
  • +
  • With Your consent: We may disclose Your personal information for any other purpose with Your consent.
  • +
+

Retention of Your Personal Data

+

The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.

+

The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.

+

Transfer of Your Personal Data

+

Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.

+

Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.

+

The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.

+

Disclosure of Your Personal Data

+

Business Transactions

+

If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.

+

Law enforcement

+

Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).

+

Other legal requirements

+

The Company may disclose Your Personal Data in the good faith belief that such action is necessary to:

+
    +
  • Comply with a legal obligation
  • +
  • Protect and defend the rights or property of the Company
  • +
  • Prevent or investigate possible wrongdoing in connection with the Service
  • +
  • Protect the personal safety of Users of the Service or the public
  • +
  • Protect against legal liability
  • +
+

Security of Your Personal Data

+

The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to use commercially acceptable means to protect Your Personal Data, We cannot guarantee its absolute security.

+

Children's Privacy

+

Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age of 13 without verification of parental consent, We take steps to remove that information from Our servers.

+

If We need to rely on consent as a legal basis for processing Your information and Your country requires consent from a parent, We may require Your parent's consent before We collect and use that information.

+

Links to Other Websites

+

Our Service may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that third party's site. We strongly advise You to review the Privacy Policy of every site You visit.

+

We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.

+

Changes to this Privacy Policy

+

We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.

+

We will let You know via email and/or a prominent notice on Our Service, prior to the change becoming effective and update the "Last updated" date at the top of this Privacy Policy.

+

You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.

+

Contact Us

+

If you have any questions about this Privacy Policy, You can contact us:

+ \ No newline at end of file diff --git a/backend/docs/index.md b/backend/docs/index.md new file mode 100644 index 00000000..d12c8a5e --- /dev/null +++ b/backend/docs/index.md @@ -0,0 +1,10 @@ +# Welcome to the KitchenOwl! + +Useful links: +- [Wiki](https://github.com/TomBursch/KitchenOwl/wiki) +- [Discussion](https://github.com/TomBursch/KitchenOwl/discussion) +- Android & iOS [Privacy Policy](https://tombursch.github.io/KitchenOwl/about/app-privacy-policy) + + +### Support or Contact +Having troubles? Check out the [discussions](https://github.com/TomBursch/KitchenOwl/discussions) or [issues](https://github.com/TomBursch/KitchenOwl/issues) and we’ll sort it out. \ No newline at end of file From 4327fa9564a3d78d87ea9d7996af3247b7b6dd50 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 12 Mar 2021 01:53:52 +0100 Subject: [PATCH 007/496] Update README.md --- backend/README.md | 5 +++- backend/docs/index.md | 54 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/backend/README.md b/backend/README.md index f0f3de60..a51a8bf9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,11 +4,14 @@

+ + Stars + License - Docker pulls + Docker pulls

diff --git a/backend/docs/index.md b/backend/docs/index.md index d12c8a5e..c2f0ca8c 100644 --- a/backend/docs/index.md +++ b/backend/docs/index.md @@ -1,10 +1,60 @@ -# Welcome to the KitchenOwl! +

+ + KitchenOwl + +

+

+ + Stars + + + License + + + Docker pulls + +

+

+ KitchenOwl +

-Useful links: +

+ A grocery list and recipe manager +

+

+ KitchenOwl is a self-hosted grocery list and recipe manager. The backend is made with Flask and the frontend with Flutter. Easily add items to your shopping list before you go shopping. You can also create recipes and add items based on what you want to cook. +

+ +

+ 🍫 🥘 🍽 +

+ +## ✨ Features + +The following features have been implemented: + +- Add items to your shopping list and sync it with multiple users +- Partial offline support so you don't lose track of what to buy even when there is no signal +- Manage recipes and add items directly from a recipe. +- Mobile/Web/Desktop apps + +This project is still in development, so some options may not be fully implemented yet. + +For a list of planned features, check out the [Roadmap](https://github.com/TomBursch/KitchenOwl/wiki/Roadmap)! + +## 📚 Related - [Wiki](https://github.com/TomBursch/KitchenOwl/wiki) - [Discussion](https://github.com/TomBursch/KitchenOwl/discussion) - Android & iOS [Privacy Policy](https://tombursch.github.io/KitchenOwl/about/app-privacy-policy) +- [KitchenOwl App](https://github.com/TomBursch/KitchenOwl-app) Repository +- [DockerHub](https://hub.docker.com/repository/docker/tombursch/kitchenowl) +- Icons modified from [Those Icons](https://www.flaticon.com/authors/those-icons) and [Freepik](https://www.flaticon.com/authors/freepik) + +### 🔨 Built With +- [Flask](https://flask.palletsprojects.com/en/1.1.x/) +- [Flutter](https://flutter.dev/) +- [Docker](https://docs.docker.com/) ### Support or Contact Having troubles? Check out the [discussions](https://github.com/TomBursch/KitchenOwl/discussions) or [issues](https://github.com/TomBursch/KitchenOwl/issues) and we’ll sort it out. \ No newline at end of file From d2dc44c15895b2bde8653bb1605855e3725f7e74 Mon Sep 17 00:00:00 2001 From: cMensendiek <38719631+cMensendiek@users.noreply.github.com> Date: Sat, 13 Mar 2021 18:39:16 +0100 Subject: [PATCH 008/496] Update CONTRIBUTING.md --- backend/CONTRIBUTING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md index 9f52258b..438ce10c 100644 --- a/backend/CONTRIBUTING.md +++ b/backend/CONTRIBUTING.md @@ -29,6 +29,7 @@ The `description` is a descriptive summary of the change the PR will make. ### Setup & Install - Create a new python environment and install dependencies `pip3 install -r requirements.txt` +- Initialize/Upgrade the sqlite database with `flask db upgrade` - Run debug server with `python3 wsgi.py` - The backend should be reachable at `localhost:5000` @@ -42,4 +43,4 @@ Example commit messages: chore: update gqlgen dependency to v2.6.0 docs(README): add new contributing section fix: remove debug log statements -``` \ No newline at end of file +``` From 3dcfee740dc73ebca847385b7d6f0a9820296658 Mon Sep 17 00:00:00 2001 From: cMensendiek <38719631+cMensendiek@users.noreply.github.com> Date: Sat, 13 Mar 2021 20:41:55 +0100 Subject: [PATCH 009/496] Update README.md --- backend/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/README.md b/backend/README.md index a51a8bf9..17491a69 100644 --- a/backend/README.md +++ b/backend/README.md @@ -63,7 +63,7 @@ Recommended using [docker-compose](https://docs.docker.com/compose/): ## 🙌 Contributing -From opening a bug report to creating a pull request: every contribution is appreciated and welcomed. If you're planning to implement a new feature or change the API please create an issue first. This way we can ensure your work is not in vain. For more information see [Contributing](https://github.com/TomBursch/KitchenOwl/CONTRIBUTING.md) +From opening a bug report to creating a pull request: every contribution is appreciated and welcomed. If you're planning to implement a new feature or change the API please create an issue first. This way we can ensure your work is not in vain. For more information see [Contributing](https://github.com/TomBursch/KitchenOwl/blob/main/CONTRIBUTING.md) ## 📚 Related - [KitchenOwl App](https://github.com/TomBursch/KitchenOwl-app) Repository @@ -73,4 +73,4 @@ From opening a bug report to creating a pull request: every contribution is appr ### 🔨 Built With - [Flask](https://flask.palletsprojects.com/en/1.1.x/) - [Flutter](https://flutter.dev/) -- [Docker](https://docs.docker.com/) \ No newline at end of file +- [Docker](https://docs.docker.com/) From 733e7acb2ae3193559a0a1274e5e2aea16b11fe5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 15 Mar 2021 16:00:41 +0100 Subject: [PATCH 010/496] Fix: Shopping list add items from recipe --- backend/app/controller/shoppinglist/shoppinglist_controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 7d5dd5ca..02d0b36c 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -103,7 +103,8 @@ def addRecipeItems(args, id): else: con = ShoppinglistItems(description=description) con.item = item - shoppinglist.items.append(con) + con.shoppinglist = shoppinglist + con.save() shoppinglist.save() return jsonify(item.obj_to_dict()) From 04cc4fd6509cd84711ca080e7b8dcae8c6ab0886 Mon Sep 17 00:00:00 2001 From: cMensendiek <38719631+cMensendiek@users.noreply.github.com> Date: Mon, 15 Mar 2021 18:18:02 +0100 Subject: [PATCH 011/496] feat: Statistical analysis - ordering of the items in the current shopping list - using the frequency of the items to sort items elsewhere - storing association rules that can be used as suggestions: endpoint is `/shoppinglist//suggested-items` * feat: added flask_apscheduler for periodic jobs * feat: extended db to store statistical data * feat: finds shopping instances by clustering * feat: finds ordering of items * feat: stores frequent itemsets information * feat: improved search/ordering, added suggestions * chore: update styling --- backend/app/__init__.py | 4 +- backend/app/config.py | 6 ++ .../shoppinglist/shoppinglist_controller.py | 78 +++++++++++++++- backend/app/jobs/__init__.py | 1 + backend/app/jobs/clusterShoppings.py | 41 ++++++++ backend/app/jobs/itemOrdering.py | 93 +++++++++++++++++++ backend/app/jobs/itemSuggestions.py | 62 +++++++++++++ backend/app/jobs/jobs.py | 24 +++++ backend/app/models/__init__.py | 4 +- backend/app/models/association.py | 42 +++++++++ backend/app/models/history.py | 62 +++++++++++++ backend/app/models/item.py | 49 +++++++++- backend/migrations/versions/e11ed35a84ba_.py | 56 +++++++++++ backend/requirements.txt | 8 +- 14 files changed, 517 insertions(+), 13 deletions(-) create mode 100644 backend/app/jobs/__init__.py create mode 100644 backend/app/jobs/clusterShoppings.py create mode 100644 backend/app/jobs/itemOrdering.py create mode 100644 backend/app/jobs/itemSuggestions.py create mode 100644 backend/app/jobs/jobs.py create mode 100644 backend/app/models/association.py create mode 100644 backend/app/models/history.py create mode 100644 backend/migrations/versions/e11ed35a84ba_.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index d030779b..a606b245 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,3 +1,5 @@ from app.config import app from app.config import db -from app.controller import * \ No newline at end of file +from app.config import scheduler +from app.controller import * +from app.jobs import * diff --git a/backend/app/config.py b/backend/app/config.py index cdb7a3a6..902b63c3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,6 +4,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_bcrypt import Bcrypt from flask_jwt_extended import JWTManager +from flask_apscheduler import APScheduler import os MIN_FRONTEND_VERSION = 1 @@ -21,6 +22,11 @@ bcrypt = Bcrypt(app) jwt = JWTManager(app) +scheduler = APScheduler() +scheduler.api_enabled = False +scheduler.init_app(app) +scheduler.start() + @app.after_request def add_cors_headers(response): diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 02d0b36c..c121ebbc 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -3,10 +3,12 @@ from flask import jsonify from flask_jwt_extended import jwt_required from app import app, db -from app.models import Item, Shoppinglist +from app.models import Item, Shoppinglist, History, Status, Association from app.helpers import validate_args -from .schemas import RemoveItem, UpdateDescription, AddItemByName, CreateList, AddRecipeItems +from .schemas import (RemoveItem, UpdateDescription, + AddItemByName, CreateList, AddRecipeItems) from app.errors import InvalidUsage, NotFoundRequest +from datetime import datetime, timedelta @app.before_first_request @@ -22,8 +24,10 @@ def before_first_request(): @app.route('/shoppinglist//items', methods=['GET']) @jwt_required() def getAllShoppingListItems(id): - items = ShoppinglistItems.query.filter(ShoppinglistItems.shoppinglist_id == id).join(ShoppinglistItems.item).order_by( - Item.name).all() + items = ShoppinglistItems.query.filter( + ShoppinglistItems.shoppinglist_id == id).join( + ShoppinglistItems.item).order_by( + Item.ordering, Item.name).all() return jsonify([e.obj_to_item_dict() for e in items]) @@ -37,6 +41,61 @@ def getRecentItems(id): return jsonify([e.obj_to_dict() for e in q]) +def getSuggestionsBasedOnLastAddedItems(id, item_count): + suggestions = [] + + # subquery for item ids which are on the shoppinglist + subquery = db.session.query(ShoppinglistItems.item_id).filter( + ShoppinglistItems.shoppinglist_id == id).subquery() + + # suggestion based on recently added items + ten_minutes_back = datetime.now() - timedelta(minutes=10) + recently_added = History.query.filter( + History.shoppinglist_id == id, + History.status == Status.ADDED, + History.created_at > ten_minutes_back).order_by( + History.created_at.desc()).limit(3) + + for recent in recently_added: + assocs = Association.query.filter( + Association.antecedent_id == recent.id, + Association.consequent_id.notin_(subquery)).order_by( + Association.lift.desc()).limit(item_count) + for rule in assocs: + suggestions.append(rule.consequent) + item_count -= 1 + + return suggestions + + +def getSuggestionsBasedOnFrequency(id, item_count): + suggestions = [] + + # subquery for item ids which are on the shoppinglist + subquery = db.session.query(ShoppinglistItems.item_id).filter( + ShoppinglistItems.shoppinglist_id == id).subquery() + + # suggestion based on overall frequency + if item_count > 0: + suggestions = Item.query.filter(Item.id.notin_(subquery)).order_by( + Item.support.desc(), Item.name).limit(item_count) + return suggestions + + +@app.route('/shoppinglist//suggested-items', methods=['GET']) +@jwt_required() +def getSuggestedItems(id): + item_suggestion_count = 9 + suggestions = [] + + suggestions += getSuggestionsBasedOnLastAddedItems( + id, item_suggestion_count) + suggestions += getSuggestionsBasedOnFrequency( + id, item_suggestion_count - len(suggestions)) + + return jsonify([item.obj_to_dict() for item in suggestions]) + + @app.route('/shoppinglist//item', methods=['POST']) @jwt_required() @validate_args(AddItemByName) @@ -55,6 +114,9 @@ def addShoppinglistItemByName(args, id): con.item = item con.shoppinglist = shoppinglist con.save() + + History.create_added(shoppinglist, item) + return jsonify(item.obj_to_dict()) @@ -72,6 +134,9 @@ def removeShoppinglistItem(args, id): raise NotFoundRequest() con = ShoppinglistItems.find_by_ids(id, args['item_id']) con.delete() + + History.create_dropped(shoppinglist, item) + return jsonify({'msg': "DONE"}) @@ -97,7 +162,8 @@ def addRecipeItems(args, id): description = recipeItem['description'] con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if con: - con.description = description if not con.description else con.description + \ + con.description = \ + description if not con.description else con.description + \ ', ' + description con.save() else: @@ -106,6 +172,8 @@ def addRecipeItems(args, id): con.shoppinglist = shoppinglist con.save() + History.create_added(shoppinglist, item) + shoppinglist.save() return jsonify(item.obj_to_dict()) diff --git a/backend/app/jobs/__init__.py b/backend/app/jobs/__init__.py new file mode 100644 index 00000000..986f9ca9 --- /dev/null +++ b/backend/app/jobs/__init__.py @@ -0,0 +1 @@ +from . import jobs diff --git a/backend/app/jobs/clusterShoppings.py b/backend/app/jobs/clusterShoppings.py new file mode 100644 index 00000000..4fcbb51f --- /dev/null +++ b/backend/app/jobs/clusterShoppings.py @@ -0,0 +1,41 @@ +from app.models import History + +import time +from dbscan1d.core import DBSCAN1D +import numpy as np + + +def clusterShoppings(): + dropped = History.find_dropped_by_shoppinglist_id(1) + + # determine shopping instances via clustering + times = [int(time.mktime(d.created_at.timetuple())) for d in dropped] + + timestamps = np.array(times) + # time distance for items to be considered in one shopping action (in seconds) + eps = 600 + # minimum size for clusters to be accepted + min_samples = 5 + dbs = DBSCAN1D(eps=eps, min_samples=min_samples) + labels = dbs.fit_predict(timestamps) + + # extract indices of clusters into lists + cluster_count = max(labels) + 1 + clusters = [[] for i in range(cluster_count)] + for i in range(len(labels)): + label = labels[i] + if labels[i] > -1: + clusters[label].append(i) + + # indices to list of itemlists for each found shopping instance + shopping_instances = [[dropped[i].item_id for i in cluster] + for cluster in clusters] + + # remove duplicates in the instances + shopping_instances = [list(set(instance)) + for instance in shopping_instances] + + print('the found shopping instances are:') + print(shopping_instances) + + return shopping_instances diff --git a/backend/app/jobs/itemOrdering.py b/backend/app/jobs/itemOrdering.py new file mode 100644 index 00000000..b1bb2a3a --- /dev/null +++ b/backend/app/jobs/itemOrdering.py @@ -0,0 +1,93 @@ +from app import db +from app.models import Item +import copy + + +def findItemOrdering(shopping_instances): + # sort the items according to each shopping course + sorter = ItemSort() + for items in shopping_instances: + sorter.updateMatrix(items) + order = sorter.topologicalSort() + + # reset ordering for all items + for item in Item.query.all(): + item.ordering = 0 + + # store the ordering directly in each item + for ord in range(len(order)): + item_id = order[ord] + item = Item.find_by_id(item_id) + if item: + item.ordering = ord+1 + + # commit changes to db + db.session.commit() + + +class ItemSort: + + def __init__(self): + # stores the costs for ordering + self.matrix = [] + # gives all items an index + self.indices = [] + # stores index for each item (duplicates indices for faster access) + self.item_dict = {} + + # determines decay rate (must be between 0 and 1) + self.decay = 0.75 + + def updateMatrix(self, lst): + # extend matrix for unseed items + for item in lst: + if item not in self.indices: + self.item_dict[item] = len(self.indices) + self.indices.append(item) + for row in self.matrix: + row.append(0) + self.matrix.append([0 for i in range(len(self.indices))]) + + # cost of ranking in current list + cost = (1-self.decay) / len(lst) + + # iterate the current list + for i in range(len(lst)): + index = self.item_dict[lst[i]] + + # decay old costs with factor decay + self.matrix[index] = list( + map(lambda x: x * self.decay, self.matrix[index])) + + # increase incoming cost for all preceeding items in the current list + predecessors = lst[:i] + for pred in predecessors: + predIndex = self.item_dict[pred] + self.matrix[index][predIndex] += cost + + def topologicalSort(self): + mtx = copy.deepcopy(self.matrix) + order = [] + + for iter in range(len(mtx)): + + # cost of an item is the sum of its incoming costs + costs = list(map(sum, mtx)) + + # determine item minimal costs + minIndex = 0 + for i in range(1, len(costs)): + if costs[i] < costs[minIndex]: + minIndex = i + order.append(minIndex) + + # remove influence of minimal item + for row in mtx: + row[minIndex] = 0 + + # remove current minimal item from minimal spot + # (maximal normal cost is 1, thus 2 is larger than all unconsidered items) + mtx[minIndex][minIndex] = 2 + + # convert the indices to items + return list(map(lambda index: self.indices[index], order)) diff --git a/backend/app/jobs/itemSuggestions.py b/backend/app/jobs/itemSuggestions.py new file mode 100644 index 00000000..028d7289 --- /dev/null +++ b/backend/app/jobs/itemSuggestions.py @@ -0,0 +1,62 @@ +from app import db +from app.models import Item, Association + +import pandas as pd +from mlxtend.frequent_patterns import apriori +from mlxtend.preprocessing import TransactionEncoder +from mlxtend.frequent_patterns import association_rules as arule + + +def findItemSuggestions(shopping_instances): + + if not shopping_instances or len(shopping_instances) == 0: + return + + # prepare data set + te = TransactionEncoder() + te_ary = te.fit_transform(shopping_instances) + store = pd.DataFrame(te_ary, columns=te.columns_) + + # compute the frequent itemsets with minimal support 0.1 + frequent_itemsets = apriori(store, min_support=0.001, use_colnames=True) + + # extract support for single items + single_items = frequent_itemsets[frequent_itemsets['itemsets'].apply( + len) == 1] + single_items.insert(0, "single", [list(tup)[0] + for tup in single_items["itemsets"]], False) + + # reset ordering for all items + for item in Item.query.all(): + item.support = 0 + + # store support values + for index, row in single_items.iterrows(): + item_id = row["single"] + item = Item.find_by_id(item_id) + if item: + item.support = row["support"] + + # commit changes to db + db.session.commit() + + # compute all association rules with lift > 1.2 and confidence > 0.1 + association_rules = arule( + frequent_itemsets, metric='lift', min_threshold=1.2) + association_rules = association_rules[association_rules['confidence'] > 0.1] + + # extract rules with single antecedent and single consequent + single_rules = association_rules[(association_rules["antecedents"].apply( + len) == 1) & (association_rules["consequents"].apply(len) == 1)] + single_rules.insert(0, "antecedent", [list( + tup)[0] for tup in single_rules["antecedents"]], True) + single_rules.insert(1, "consequent", [list( + tup)[0] for tup in single_rules["consequents"]], True) + + # delete all previous associations + Association.delete_all() + + # store all new associations + for index, rule in single_rules.iterrows(): + Association.create(rule["antecedent"], rule["consequent"], + rule["support"], rule["confidence"], rule["lift"]) diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py new file mode 100644 index 00000000..e5c766a0 --- /dev/null +++ b/backend/app/jobs/jobs.py @@ -0,0 +1,24 @@ +from app import app, scheduler +from .itemOrdering import findItemOrdering +from .itemSuggestions import findItemSuggestions +from .clusterShoppings import clusterShoppings + + +@app.before_first_request +def load_jobs(): + # for debugging: + # @scheduler.task('interval', id='test', seconds=5) + # def test(): + # print("--- test analysis is starting ---") + # shopping_instances = clusterShoppings() + # findItemOrdering(shopping_instances) + # findItemSuggestions(shopping_instances) + # print("--- test analysis is completed ---") + + @scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') + def daily(): + print("--- daily analysis is starting ---") + shopping_instances = clusterShoppings() + findItemOrdering(shopping_instances) + findItemSuggestions(shopping_instances) + print("--- daily analysis is completed ---") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d8a85173..efc94c94 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,6 @@ from .user import User from .item import Item +from .association import Association +from .history import History, Status from .recipe import RecipeItems, Recipe -from .shoppinglist import ShoppinglistItems, Shoppinglist \ No newline at end of file +from .shoppinglist import ShoppinglistItems, Shoppinglist diff --git a/backend/app/models/association.py b/backend/app/models/association.py new file mode 100644 index 00000000..f6a9fbde --- /dev/null +++ b/backend/app/models/association.py @@ -0,0 +1,42 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class Association(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'association' + + id = db.Column(db.Integer, primary_key=True) + + antecedent_id = db.Column(db.Integer, db.ForeignKey('item.id')) + consequent_id = db.Column(db.Integer, db.ForeignKey('item.id')) + support = db.Column(db.Float) + confidence = db.Column(db.Float) + lift = db.Column(db.Float) + + antecedent = db.relationship("Item", uselist=False, foreign_keys=[ + antecedent_id], back_populates="antecedents") + consequent = db.relationship("Item", uselist=False, foreign_keys=[ + consequent_id], back_populates="consequents") + + @classmethod + def create(cls, antecedent_id, consequent_id, support, confidence, lift): + return cls( + antecedent_id=antecedent_id, + consequent_id=consequent_id, + support=support, + confidence=confidence, + lift=lift + ).save() + + @classmethod + def find_by_antecedent(cls, antecedent_id): + return cls.query.filter(cls.antecedent_id == antecedent_id).order_by(cls.lift.desc()) + + @classmethod + def find_all(cls): + return cls.query.all() + + @classmethod + def delete_all(cls): + cls.query.delete() + db.session.commit() diff --git a/backend/app/models/history.py b/backend/app/models/history.py new file mode 100644 index 00000000..584852fa --- /dev/null +++ b/backend/app/models/history.py @@ -0,0 +1,62 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from .item import Item +from .shoppinglist import Shoppinglist + +import enum + + +class Status(enum.Enum): + ADDED = 1 + DROPPED = -1 + + +class History(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'history' + + id = db.Column(db.Integer, primary_key=True) + + shoppinglist_id = db.Column(db.Integer, db.ForeignKey( + 'shoppinglist.id')) + item_id = db.Column(db.Integer, db.ForeignKey('item.id')) + + item = db.relationship("Item", uselist=False, back_populates="history") + + status = db.Column(db.Enum(Status)) + + @classmethod + def create_added(cls, shoppinglist, item): + return cls( + shoppinglist_id=shoppinglist.id, + item_id=item.id, + status=Status.ADDED + ).save() + + @classmethod + def create_dropped(cls, shoppinglist, item): + return cls( + shoppinglist_id=shoppinglist.id, + item_id=item.id, + status=Status.DROPPED + ).save() + + def obj_to_item_dict(self): + res = self.item.obj_to_dict() + res['timestamp'] = getattr(self, 'created_at') + return res + + @classmethod + def find_added_by_shoppinglist_id(cls, shoppinglist_id): + return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.status == Status.ADDED).all() + + @classmethod + def find_dropped_by_shoppinglist_id(cls, shoppinglist_id): + return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.status == Status.DROPPED).all() + + @classmethod + def find_by_shoppinglist_id(cls, shoppinglist_id): + return cls.query.filter(cls.shoppinglist_id == shoppinglist_id).all() + + @classmethod + def find_all(cls): + return cls.query.all() diff --git a/backend/app/models/item.py b/backend/app/models/item.py index dec96139..6b28173d 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -1,14 +1,28 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin + class Item(db.Model, DbModelMixin, TimestampMixin): __tablename__ = 'item' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128), unique=True) - recipes = db.relationship('RecipeItems', back_populates='item', cascade="all, delete-orphan") - shoppinglists = db.relationship('ShoppinglistItems', back_populates='item', cascade="all, delete-orphan") + recipes = db.relationship( + 'RecipeItems', back_populates='item', cascade="all, delete-orphan") + shoppinglists = db.relationship( + 'ShoppinglistItems', back_populates='item', cascade="all, delete-orphan") + + # determines order of items in the shoppinglist + ordering = db.Column(db.Integer, server_default='0') + # frequency of item, used for item suggestions + support = db.Column(db.Integer, server_default='0') + + history = db.relationship("History", back_populates="item") + antecedents = db.relationship( + "Association", back_populates="antecedent", foreign_keys='Association.antecedent_id') + consequents = db.relationship( + "Association", back_populates="consequent", foreign_keys='Association.consequent_id') @classmethod def create_by_name(cls, name): @@ -20,12 +34,37 @@ def create_by_name(cls, name): def find_by_name(cls, name): return cls.query.filter(cls.name == name).first() + @classmethod + def find_by_id(cls, id): + return cls.query.filter(cls.id == id).first() + @classmethod def search_name(cls, name): + item_count = 9 + found = [] + + # name is a regex if '*' in name or '_' in name: looking_for = name.replace('_', '__')\ .replace('*', '%')\ .replace('?', '_') - else: - looking_for = '%{0}%'.format(name) - return cls.query.filter(cls.name.ilike(looking_for)).limit(10) + return cls.query.filter(cls.name.ilike(looking_for)).order_by(cls.support.desc()).limit(item_count).all() + + # name is no regex + starts_with = '{0}%'.format(name) + contains = '%{0}%'.format(name) + one_error = [] + for index in range(len(name)): + name_one_error = name[:index]+'_'+name[index+1:] + one_error.append('%{0}%'.format(name_one_error)) + + for looking_for in [starts_with, contains] + one_error: + res = cls.query.filter(cls.name.ilike(looking_for)).order_by( + cls.support.desc(), cls.name).all() + for r in res: + if r not in found: + found.append(r) + item_count -= 1 + if item_count <= 0: + return found + return found diff --git a/backend/migrations/versions/e11ed35a84ba_.py b/backend/migrations/versions/e11ed35a84ba_.py new file mode 100644 index 00000000..4e1bc9b3 --- /dev/null +++ b/backend/migrations/versions/e11ed35a84ba_.py @@ -0,0 +1,56 @@ +"""empty message + +Revision ID: e11ed35a84ba +Revises: fffa4ab33d2a +Create Date: 2021-03-14 18:21:52.089867 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e11ed35a84ba' +down_revision = 'fffa4ab33d2a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('association', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('antecedent_id', sa.Integer(), nullable=True), + sa.Column('consequent_id', sa.Integer(), nullable=True), + sa.Column('support', sa.Float(), nullable=True), + sa.Column('confidence', sa.Float(), nullable=True), + sa.Column('lift', sa.Float(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['antecedent_id'], ['item.id'], ), + sa.ForeignKeyConstraint(['consequent_id'], ['item.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('shoppinglist_id', sa.Integer(), nullable=True), + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('ADDED', 'DROPPED', name='status'), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['item.id'], ), + sa.ForeignKeyConstraint(['shoppinglist_id'], ['shoppinglist.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('item', sa.Column('ordering', sa.Integer(), server_default='0', nullable=True)) + op.add_column('item', sa.Column('support', sa.Integer(), server_default='0', nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('item', 'support') + op.drop_column('item', 'ordering') + op.drop_table('history') + op.drop_table('association') + # ### end Alembic commands ### diff --git a/backend/requirements.txt b/backend/requirements.txt index 0c0881f4..62183f82 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,4 +2,10 @@ Flask==1.1.2 Flask-Migrate==2.7.0 Flask-Bcrypt==0.7.1 Flask-JWT-Extended==4.0.2 -marshmallow==3.10.0 \ No newline at end of file +Flask-APScheduler==1.11.0 +marshmallow==3.10.0 +dbscan1d==0.1.6 +numpy==1.17.4 +pandas==0.25.3 +mlxtend==0.18.0 +autopep8==0.10.2 \ No newline at end of file From 1931f53e5172f497f10392f01e482d8ce4c578b7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 15 Mar 2021 18:30:11 +0100 Subject: [PATCH 012/496] fix: requirements.txt autopep8 version --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 62183f82..28a2bf2d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,4 +8,4 @@ dbscan1d==0.1.6 numpy==1.17.4 pandas==0.25.3 mlxtend==0.18.0 -autopep8==0.10.2 \ No newline at end of file +autopep8==1.5.5 From 36b490313100791ee651a89192b89460be012697 Mon Sep 17 00:00:00 2001 From: cMensendiek Date: Mon, 15 Mar 2021 19:28:25 +0100 Subject: [PATCH 013/496] fix: support column is float, regex search --- backend/app/models/item.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 6b28173d..f06b526c 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -16,7 +16,7 @@ class Item(db.Model, DbModelMixin, TimestampMixin): # determines order of items in the shoppinglist ordering = db.Column(db.Integer, server_default='0') # frequency of item, used for item suggestions - support = db.Column(db.Integer, server_default='0') + support = db.Column(db.Float, server_default='0.0') history = db.relationship("History", back_populates="item") antecedents = db.relationship( @@ -44,11 +44,10 @@ def search_name(cls, name): found = [] # name is a regex - if '*' in name or '_' in name: - looking_for = name.replace('_', '__')\ - .replace('*', '%')\ - .replace('?', '_') - return cls.query.filter(cls.name.ilike(looking_for)).order_by(cls.support.desc()).limit(item_count).all() + if '*' in name or '?' in name or '%' in name or '_' in name: + looking_for = name.replace('*', '%').replace('?', '_') + found = cls.query.filter(cls.name.ilike(looking_for)).order_by(cls.support.desc()).limit(item_count).all() + return found # name is no regex starts_with = '{0}%'.format(name) From 3635be0268622e7ec41bdd2d879c049ca57bffc4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 15 Mar 2021 19:35:41 +0100 Subject: [PATCH 014/496] fix: update requirements.txt --- backend/requirements.txt | 52 +++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 28a2bf2d..6d268e98 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,11 +1,51 @@ +alembic==1.5.6 +appdirs==1.4.4 +APScheduler==3.7.0 +autopep8==1.5.5 +bcrypt==3.2.0 +black==20.8b1 +cffi==1.14.5 +click==7.1.2 +cycler==0.10.0 +dbscan1d==0.1.6 +flake8==3.9.0 Flask==1.1.2 -Flask-Migrate==2.7.0 -Flask-Bcrypt==0.7.1 -Flask-JWT-Extended==4.0.2 Flask-APScheduler==1.11.0 +Flask-Bcrypt==0.7.1 +Flask-JWT-Extended==4.0.2 +Flask-Migrate==2.7.0 +Flask-SQLAlchemy==2.4.4 +itsdangerous==1.1.0 +Jinja2==2.11.3 +joblib==1.0.1 +kiwisolver==1.3.1 +Mako==1.1.4 +MarkupSafe==1.1.1 marshmallow==3.10.0 -dbscan1d==0.1.6 +matplotlib==3.3.4 +mccabe==0.6.1 +mlxtend==0.18.0 +mypy-extensions==0.4.3 numpy==1.17.4 pandas==0.25.3 -mlxtend==0.18.0 -autopep8==1.5.5 +pathspec==0.8.1 +Pillow==8.1.2 +pycodestyle==2.7.0 +pycparser==2.20 +pyflakes==2.3.0 +PyJWT==2.0.1 +pyparsing==2.4.7 +python-dateutil==2.8.1 +python-editor==1.0.4 +pytz==2021.1 +regex==2020.11.13 +scikit-learn==0.24.1 +scipy==1.6.1 +six==1.15.0 +SQLAlchemy==1.3.23 +threadpoolctl==2.1.0 +toml==0.10.2 +typed-ast==1.4.2 +typing-extensions==3.7.4.3 +tzlocal==2.1 +Werkzeug==1.0.1 From b97479163246cf39acc387d006ed06ed1492514a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 15 Mar 2021 19:36:26 +0100 Subject: [PATCH 015/496] chore: update version --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 902b63c3..e6f8d19b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 1 -BACKEND_VERSION = 1 +BACKEND_VERSION = 2 app = Flask(__name__) From 44aa499cc58907b67de5541e7b061568c168b19c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 15 Mar 2021 19:42:46 +0100 Subject: [PATCH 016/496] fix: support column type migration --- .../versions/{e11ed35a84ba_.py => 6d6984e216ff_.py} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename backend/migrations/versions/{e11ed35a84ba_.py => 6d6984e216ff_.py} (90%) diff --git a/backend/migrations/versions/e11ed35a84ba_.py b/backend/migrations/versions/6d6984e216ff_.py similarity index 90% rename from backend/migrations/versions/e11ed35a84ba_.py rename to backend/migrations/versions/6d6984e216ff_.py index 4e1bc9b3..e0f72af3 100644 --- a/backend/migrations/versions/e11ed35a84ba_.py +++ b/backend/migrations/versions/6d6984e216ff_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: e11ed35a84ba +Revision ID: 6d6984e216ff Revises: fffa4ab33d2a -Create Date: 2021-03-14 18:21:52.089867 +Create Date: 2021-03-15 19:40:59.846065 """ from alembic import op @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. -revision = 'e11ed35a84ba' +revision = '6d6984e216ff' down_revision = 'fffa4ab33d2a' branch_labels = None depends_on = None @@ -43,7 +43,7 @@ def upgrade(): sa.PrimaryKeyConstraint('id') ) op.add_column('item', sa.Column('ordering', sa.Integer(), server_default='0', nullable=True)) - op.add_column('item', sa.Column('support', sa.Integer(), server_default='0', nullable=True)) + op.add_column('item', sa.Column('support', sa.Float(), server_default='0.0', nullable=True)) # ### end Alembic commands ### From 7affea14085c3a20f7b11d843d11d62f1bca139f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 9 Apr 2021 15:21:10 +0200 Subject: [PATCH 017/496] feat: Shopping list item description --- backend/app/config.py | 4 ++-- .../app/controller/item/item_controller.py | 11 ++++++++- .../app/controller/shoppinglist/schemas.py | 3 --- .../shoppinglist/shoppinglist_controller.py | 24 ++++++++++++++++++- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index e6f8d19b..7c3c75f4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -7,8 +7,8 @@ from flask_apscheduler import APScheduler import os -MIN_FRONTEND_VERSION = 1 -BACKEND_VERSION = 2 +MIN_FRONTEND_VERSION = 8 +BACKEND_VERSION = 3 app = Flask(__name__) diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 09fcabf2..a322b797 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -1,6 +1,6 @@ from app.helpers import validate_args -import json from flask import jsonify +from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required from app import app from app.models import Item @@ -13,6 +13,15 @@ def getAllItems(): return jsonify([e.obj_to_dict() for e in Item.all()]) +@app.route('/item/', methods=['GET']) +@jwt_required() +def getItem(id): + item = Item.find_by_id(id) + if not item: + raise NotFoundRequest() + return jsonify(item.obj_to_dict()) + + @app.route('/item/', methods=['DELETE']) @jwt_required() def deleteItemById(id): diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index 9876bd3b..9c7488bf 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -33,9 +33,6 @@ class CreateList(Schema): class UpdateDescription(Schema): - item_id = fields.Integer( - required=True, - ) description = fields.String( required=True ) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index c121ebbc..78979c71 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -21,6 +21,28 @@ def before_first_request(): sl.save() +@app.route('/shoppinglist//item/', methods=['GET']) +@jwt_required() +def getShoppingListItem(id, item_id): + item = Item.find_by_id(item_id) + if not item: + raise NotFoundRequest() + return jsonify(item.obj_to_dict()) + + +@app.route('/shoppinglist//item/', methods=['POST']) +@jwt_required() +@validate_args(UpdateDescription) +def updateItemDescription(args, id, item_id): + con = ShoppinglistItems.find_by_ids(id, item_id) + if not con: + raise NotFoundRequest() + + con.description = args['description'] or '' + con.save() + return jsonify(con.obj_to_item_dict()) + + @app.route('/shoppinglist//items', methods=['GET']) @jwt_required() def getAllShoppingListItems(id): @@ -96,7 +118,7 @@ def getSuggestedItems(id): return jsonify([item.obj_to_dict() for item in suggestions]) -@app.route('/shoppinglist//item', methods=['POST']) +@app.route('/shoppinglist//add-item-by-name', methods=['POST']) @jwt_required() @validate_args(AddItemByName) def addShoppinglistItemByName(args, id): From 30a7676e9298508df3868ed12ee27428f641cc76 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 11 Apr 2021 19:16:02 +0200 Subject: [PATCH 018/496] feat: meal plan, item recipes --- backend/app/config.py | 2 +- backend/app/controller/__init__.py | 1 + .../app/controller/item/item_controller.py | 12 +++++- backend/app/controller/planner/__init__.py | 1 + .../controller/planner/planner_controller.py | 37 +++++++++++++++++++ backend/app/controller/planner/schemas.py | 7 ++++ backend/app/models/recipe.py | 5 ++- backend/migrations/versions/23445ea65b2b_.py | 28 ++++++++++++++ 8 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 backend/app/controller/planner/__init__.py create mode 100644 backend/app/controller/planner/planner_controller.py create mode 100644 backend/app/controller/planner/schemas.py create mode 100644 backend/migrations/versions/23445ea65b2b_.py diff --git a/backend/app/config.py b/backend/app/config.py index 7c3c75f4..bbb8aa05 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 3 +BACKEND_VERSION = 4 app = Flask(__name__) diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 9b5a85aa..69f1c733 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -3,5 +3,6 @@ from . import user from . import recipe from . import shoppinglist +from . import planner from . import onboarding from . import health_controller \ No newline at end of file diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index a322b797..9f6f4c94 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -3,7 +3,7 @@ from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required from app import app -from app.models import Item +from app.models import Item, RecipeItems, Recipe from .schemas import SearchByNameRequest @@ -22,6 +22,16 @@ def getItem(id): return jsonify(item.obj_to_dict()) +@app.route('/item//recipes', methods=['GET']) +@jwt_required() +def getItemRecipes(id): + items = RecipeItems.query.filter( + RecipeItems.item_id == id, RecipeItems.optional == False).join( + RecipeItems.recipe).order_by( + Recipe.name).all() + return jsonify([e.recipe.obj_to_dict() for e in items]) + + @app.route('/item/', methods=['DELETE']) @jwt_required() def deleteItemById(id): diff --git a/backend/app/controller/planner/__init__.py b/backend/app/controller/planner/__init__.py new file mode 100644 index 00000000..bd860a81 --- /dev/null +++ b/backend/app/controller/planner/__init__.py @@ -0,0 +1 @@ +from . import planner_controller \ No newline at end of file diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py new file mode 100644 index 00000000..af452c99 --- /dev/null +++ b/backend/app/controller/planner/planner_controller.py @@ -0,0 +1,37 @@ +from app.errors import NotFoundRequest +from flask import jsonify +from flask_jwt_extended import jwt_required +from app import app +from app.helpers import validate_args +from app.models import Recipe +from .schemas import AddPlannedRecipe + + +@app.route('/planner/recipes', methods=['GET']) +@jwt_required() +def getAllPlannedRecipes(): + recipes = Recipe.query.filter(Recipe.planned).order_by(Recipe.name).all() + return jsonify([e.obj_to_dict() for e in recipes]) + + +@app.route('/planner/recipe', methods=['POST']) +@jwt_required() +@validate_args(AddPlannedRecipe) +def addPlannedRecipe(args): + recipe = Recipe.find_by_id(args['recipe_id']) + if not recipe: + raise NotFoundRequest() + recipe.planned = True + recipe.save() + return jsonify(recipe.obj_to_dict()) + + +@app.route('/planner/recipe/', methods=['DELETE']) +@jwt_required() +def removePlannedRecipeById(id): + recipe = Recipe.find_by_id(id) + if not recipe: + raise NotFoundRequest() + recipe.planned = False + recipe.save() + return jsonify(recipe.obj_to_dict()) diff --git a/backend/app/controller/planner/schemas.py b/backend/app/controller/planner/schemas.py new file mode 100644 index 00000000..a2fcbc50 --- /dev/null +++ b/backend/app/controller/planner/schemas.py @@ -0,0 +1,7 @@ +from marshmallow import fields, Schema + + +class AddPlannedRecipe(Schema): + recipe_id = fields.Integer( + required=True, + ) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 2ff17c0b..def187f3 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -9,7 +9,10 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): name = db.Column(db.String(128)) description = db.Column(db.String()) photo = db.Column(db.String()) - items = db.relationship('RecipeItems', back_populates='recipe', cascade="all, delete-orphan") + planned = db.Column(db.Boolean) + + items = db.relationship( + 'RecipeItems', back_populates='recipe', cascade="all, delete-orphan") def obj_to_full_dict(self): res = super().obj_to_dict() diff --git a/backend/migrations/versions/23445ea65b2b_.py b/backend/migrations/versions/23445ea65b2b_.py new file mode 100644 index 00000000..692c7f32 --- /dev/null +++ b/backend/migrations/versions/23445ea65b2b_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 23445ea65b2b +Revises: 6d6984e216ff +Create Date: 2021-04-09 16:40:19.462601 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '23445ea65b2b' +down_revision = '6d6984e216ff' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('planned', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'planned') + # ### end Alembic commands ### From da38c5e6d2a60a88dbeadd0b701818e97b84a699 Mon Sep 17 00:00:00 2001 From: Constantin Date: Tue, 13 Apr 2021 14:36:09 +0200 Subject: [PATCH 019/496] fix: backend memory explosion and error *problems with apriori fixed -> only consider itemsets of maximal size 2 *added logs for scheduled jobs *if no history to investigate stop analysis --- backend/app/config.py | 1 + backend/app/jobs/clusterShoppings.py | 8 ++++++++ backend/app/jobs/itemOrdering.py | 2 ++ backend/app/jobs/itemSuggestions.py | 5 ++++- backend/app/jobs/jobs.py | 5 +++-- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index bbb8aa05..13ab21f8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,6 +23,7 @@ jwt = JWTManager(app) scheduler = APScheduler() +# enable for debugging jobs: ../scheduler/jobs to see scheduled jobs scheduler.api_enabled = False scheduler.init_app(app) scheduler.start() diff --git a/backend/app/jobs/clusterShoppings.py b/backend/app/jobs/clusterShoppings.py index 4fcbb51f..f28397ff 100644 --- a/backend/app/jobs/clusterShoppings.py +++ b/backend/app/jobs/clusterShoppings.py @@ -8,6 +8,10 @@ def clusterShoppings(): dropped = History.find_dropped_by_shoppinglist_id(1) + if(len(dropped) == 0): + print("no history to investigate") + return None + # determine shopping instances via clustering times = [int(time.mktime(d.created_at.timetuple())) for d in dropped] @@ -19,6 +23,10 @@ def clusterShoppings(): dbs = DBSCAN1D(eps=eps, min_samples=min_samples) labels = dbs.fit_predict(timestamps) + if(len(labels) == 0): + print("no shopping instances identified") + return None + # extract indices of clusters into lists cluster_count = max(labels) + 1 clusters = [[] for i in range(cluster_count)] diff --git a/backend/app/jobs/itemOrdering.py b/backend/app/jobs/itemOrdering.py index b1bb2a3a..5ecb5a61 100644 --- a/backend/app/jobs/itemOrdering.py +++ b/backend/app/jobs/itemOrdering.py @@ -24,6 +24,8 @@ def findItemOrdering(shopping_instances): # commit changes to db db.session.commit() + print("new ordering was determined and stored in the database") + class ItemSort: diff --git a/backend/app/jobs/itemSuggestions.py b/backend/app/jobs/itemSuggestions.py index 028d7289..9a47f4f0 100644 --- a/backend/app/jobs/itemSuggestions.py +++ b/backend/app/jobs/itemSuggestions.py @@ -18,7 +18,8 @@ def findItemSuggestions(shopping_instances): store = pd.DataFrame(te_ary, columns=te.columns_) # compute the frequent itemsets with minimal support 0.1 - frequent_itemsets = apriori(store, min_support=0.001, use_colnames=True) + frequent_itemsets = apriori(store, min_support=0.001, use_colnames=True, max_len=2) + print("apriori finished") # extract support for single items single_items = frequent_itemsets[frequent_itemsets['itemsets'].apply( @@ -39,6 +40,7 @@ def findItemSuggestions(shopping_instances): # commit changes to db db.session.commit() + print("frequency of single items was stored") # compute all association rules with lift > 1.2 and confidence > 0.1 association_rules = arule( @@ -60,3 +62,4 @@ def findItemSuggestions(shopping_instances): for index, rule in single_rules.iterrows(): Association.create(rule["antecedent"], rule["consequent"], rule["support"], rule["confidence"], rule["lift"]) + print("associations rules of size 2 were updated") diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index e5c766a0..f19d7fad 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -11,8 +11,9 @@ def load_jobs(): # def test(): # print("--- test analysis is starting ---") # shopping_instances = clusterShoppings() - # findItemOrdering(shopping_instances) - # findItemSuggestions(shopping_instances) + # if(shopping_instances): + # findItemOrdering(shopping_instances) + # findItemSuggestions(shopping_instances) # print("--- test analysis is completed ---") @scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') From ec41ef5b95d3f79dfc621b0a9373c2a334f8a7ef Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 15 Apr 2021 18:50:18 +0200 Subject: [PATCH 020/496] fix: recipe items ordering --- backend/app/models/recipe.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index def187f3..6b843777 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -1,5 +1,6 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin +from .item import Item class Recipe(db.Model, DbModelMixin, TimestampMixin): @@ -16,7 +17,10 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): def obj_to_full_dict(self): res = super().obj_to_dict() - res['items'] = [e.obj_to_item_dict() for e in self.items] + items = RecipeItems.query.filter(RecipeItems.recipe_id == self.id).join( + RecipeItems.item).order_by( + Item.name).all() + res['items'] = [e.obj_to_item_dict() for e in items] return res @classmethod From fcf3699d18d7cdd32a8416a1c31d7d53ede7b0d8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 15 Apr 2021 18:51:31 +0200 Subject: [PATCH 021/496] chore: update version number --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 13ab21f8..27b295cd 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 4 +BACKEND_VERSION = 5 app = Flask(__name__) From 34f9137cf8b45cad94660e5522b0a461ab0e2f4e Mon Sep 17 00:00:00 2001 From: Constantin Date: Fri, 16 Apr 2021 23:42:25 +0200 Subject: [PATCH 022/496] Proper Logging of Messages --- backend/app/jobs/clusterShoppings.py | 9 +++++---- backend/app/jobs/itemOrdering.py | 4 ++-- backend/app/jobs/itemSuggestions.py | 11 ++++++----- backend/app/jobs/jobs.py | 8 ++++---- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/backend/app/jobs/clusterShoppings.py b/backend/app/jobs/clusterShoppings.py index f28397ff..1e380f5d 100644 --- a/backend/app/jobs/clusterShoppings.py +++ b/backend/app/jobs/clusterShoppings.py @@ -1,3 +1,4 @@ +from app import app from app.models import History import time @@ -9,7 +10,7 @@ def clusterShoppings(): dropped = History.find_dropped_by_shoppinglist_id(1) if(len(dropped) == 0): - print("no history to investigate") + app.logger.info("no history to investigate") return None # determine shopping instances via clustering @@ -24,7 +25,7 @@ def clusterShoppings(): labels = dbs.fit_predict(timestamps) if(len(labels) == 0): - print("no shopping instances identified") + app.logger.info("no shopping instances identified") return None # extract indices of clusters into lists @@ -43,7 +44,7 @@ def clusterShoppings(): shopping_instances = [list(set(instance)) for instance in shopping_instances] - print('the found shopping instances are:') - print(shopping_instances) + app.logger.info('the found shopping instances are:') + app.logger.info(shopping_instances) return shopping_instances diff --git a/backend/app/jobs/itemOrdering.py b/backend/app/jobs/itemOrdering.py index 5ecb5a61..9f4f566b 100644 --- a/backend/app/jobs/itemOrdering.py +++ b/backend/app/jobs/itemOrdering.py @@ -1,4 +1,4 @@ -from app import db +from app import app, db from app.models import Item import copy @@ -24,7 +24,7 @@ def findItemOrdering(shopping_instances): # commit changes to db db.session.commit() - print("new ordering was determined and stored in the database") + app.logger.info("new ordering was determined and stored in the database") class ItemSort: diff --git a/backend/app/jobs/itemSuggestions.py b/backend/app/jobs/itemSuggestions.py index 9a47f4f0..ec264c57 100644 --- a/backend/app/jobs/itemSuggestions.py +++ b/backend/app/jobs/itemSuggestions.py @@ -1,4 +1,4 @@ -from app import db +from app import app, db from app.models import Item, Association import pandas as pd @@ -18,8 +18,9 @@ def findItemSuggestions(shopping_instances): store = pd.DataFrame(te_ary, columns=te.columns_) # compute the frequent itemsets with minimal support 0.1 - frequent_itemsets = apriori(store, min_support=0.001, use_colnames=True, max_len=2) - print("apriori finished") + frequent_itemsets = apriori( + store, min_support=0.001, use_colnames=True, max_len=2) + app.logger.info("apriori finished") # extract support for single items single_items = frequent_itemsets[frequent_itemsets['itemsets'].apply( @@ -40,7 +41,7 @@ def findItemSuggestions(shopping_instances): # commit changes to db db.session.commit() - print("frequency of single items was stored") + app.logger.info("frequency of single items was stored") # compute all association rules with lift > 1.2 and confidence > 0.1 association_rules = arule( @@ -62,4 +63,4 @@ def findItemSuggestions(shopping_instances): for index, rule in single_rules.iterrows(): Association.create(rule["antecedent"], rule["consequent"], rule["support"], rule["confidence"], rule["lift"]) - print("associations rules of size 2 were updated") + app.logger.info("associations rules of size 2 were updated") diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index f19d7fad..f53f531b 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -9,17 +9,17 @@ def load_jobs(): # for debugging: # @scheduler.task('interval', id='test', seconds=5) # def test(): - # print("--- test analysis is starting ---") + # app.logger.info("--- test analysis is starting ---") # shopping_instances = clusterShoppings() # if(shopping_instances): # findItemOrdering(shopping_instances) # findItemSuggestions(shopping_instances) - # print("--- test analysis is completed ---") + # app.logger.info("--- test analysis is completed ---") @scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') def daily(): - print("--- daily analysis is starting ---") + app.logger.info("--- daily analysis is starting ---") shopping_instances = clusterShoppings() findItemOrdering(shopping_instances) findItemSuggestions(shopping_instances) - print("--- daily analysis is completed ---") + app.logger.info("--- daily analysis is completed ---") From a569d3aa21da7a38206c184ab11cb9a75dc4a8e5 Mon Sep 17 00:00:00 2001 From: Constantin Date: Fri, 16 Apr 2021 23:42:43 +0200 Subject: [PATCH 023/496] cascade item deletes to history --- backend/app/models/item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index f06b526c..e0b8c217 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -18,7 +18,7 @@ class Item(db.Model, DbModelMixin, TimestampMixin): # frequency of item, used for item suggestions support = db.Column(db.Float, server_default='0.0') - history = db.relationship("History", back_populates="item") + history = db.relationship("History", back_populates="item", cascade="all, delete-orphan") antecedents = db.relationship( "Association", back_populates="antecedent", foreign_keys='Association.antecedent_id') consequents = db.relationship( From 706d5938f472d8125a20a77425bf31ec6f2e5dce Mon Sep 17 00:00:00 2001 From: Constantin Date: Fri, 16 Apr 2021 23:43:01 +0200 Subject: [PATCH 024/496] added *.db --- backend/.gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/.gitignore b/backend/.gitignore index c41e2194..b1d2040a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,5 @@ -database.db +*.db +*.db-journal # Visual Studio Code related .classpath From 0c303897a372405cf435d2fa9222811c5320d72d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 16 Apr 2021 23:47:15 +0200 Subject: [PATCH 025/496] Update version number --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 27b295cd..948c0ce8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 5 +BACKEND_VERSION = 6 app = Flask(__name__) From b793dd9d437727a4163bc51954ed3424ac075800 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Jun 2021 20:02:11 +0200 Subject: [PATCH 026/496] chore(deps): bump pillow from 8.1.2 to 8.2.0 (TomBursch/kitchenowl-backend#2) Bumps [pillow](https://github.com/python-pillow/Pillow) from 8.1.2 to 8.2.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/master/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/8.1.2...8.2.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 6d268e98..7c2c7fac 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -29,7 +29,7 @@ mypy-extensions==0.4.3 numpy==1.17.4 pandas==0.25.3 pathspec==0.8.1 -Pillow==8.1.2 +Pillow==8.2.0 pycodestyle==2.7.0 pycparser==2.20 pyflakes==2.3.0 From 43ae8b5219811b9e8c06b737ef4f01bad4f50b14 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 17 Jul 2021 14:03:08 +0200 Subject: [PATCH 027/496] chore(deps): upgrade requirements --- backend/requirements.txt | 60 ++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7c2c7fac..408c2523 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,51 +1,51 @@ -alembic==1.5.6 +alembic==1.6.5 appdirs==1.4.4 APScheduler==3.7.0 -autopep8==1.5.5 +autopep8==1.5.7 bcrypt==3.2.0 black==20.8b1 -cffi==1.14.5 -click==7.1.2 +cffi==1.14.6 +click==8.0.1 cycler==0.10.0 dbscan1d==0.1.6 -flake8==3.9.0 -Flask==1.1.2 -Flask-APScheduler==1.11.0 +flake8==3.9.2 +Flask==2.0.1 +Flask-APScheduler==1.12.2 Flask-Bcrypt==0.7.1 -Flask-JWT-Extended==4.0.2 -Flask-Migrate==2.7.0 -Flask-SQLAlchemy==2.4.4 -itsdangerous==1.1.0 -Jinja2==2.11.3 +Flask-JWT-Extended==4.2.3 +Flask-Migrate==3.0.1 +Flask-SQLAlchemy==2.5.1 +itsdangerous==2.0.1 +Jinja2==3.0.1 joblib==1.0.1 kiwisolver==1.3.1 Mako==1.1.4 -MarkupSafe==1.1.1 -marshmallow==3.10.0 -matplotlib==3.3.4 +MarkupSafe==2.0.1 +marshmallow==3.12.2 +matplotlib==3.4.2 mccabe==0.6.1 mlxtend==0.18.0 mypy-extensions==0.4.3 -numpy==1.17.4 -pandas==0.25.3 +numpy==1.21.0 +pandas==1.3.0 pathspec==0.8.1 -Pillow==8.2.0 +Pillow==8.3.1 pycodestyle==2.7.0 pycparser==2.20 -pyflakes==2.3.0 -PyJWT==2.0.1 +pyflakes==2.3.1 +PyJWT==2.1.0 pyparsing==2.4.7 -python-dateutil==2.8.1 +python-dateutil==2.8.2 python-editor==1.0.4 pytz==2021.1 -regex==2020.11.13 -scikit-learn==0.24.1 -scipy==1.6.1 -six==1.15.0 -SQLAlchemy==1.3.23 -threadpoolctl==2.1.0 +regex==2021.7.6 +scikit-learn==0.24.2 +scipy==1.7.0 +six==1.16.0 +SQLAlchemy==1.4.21 +threadpoolctl==2.2.0 toml==0.10.2 -typed-ast==1.4.2 -typing-extensions==3.7.4.3 +typed-ast==1.4.3 +typing-extensions==3.10.0.0 tzlocal==2.1 -Werkzeug==1.0.1 +Werkzeug==2.0.1 From e941975e230849d8f1b8f942105828761d1a3f50 Mon Sep 17 00:00:00 2001 From: Constantin Date: Sat, 17 Jul 2021 16:06:30 +0200 Subject: [PATCH 028/496] history of recipes in planner is stored in new table 'recipe_history' --- .../controller/planner/planner_controller.py | 3 ++ backend/app/models/__init__.py | 1 + backend/app/models/recipe.py | 1 + backend/app/models/recipe_history.py | 54 +++++++++++++++++++ backend/migrations/versions/5a064f9c14d0_.py | 36 +++++++++++++ 5 files changed, 95 insertions(+) create mode 100644 backend/app/models/recipe_history.py create mode 100644 backend/migrations/versions/5a064f9c14d0_.py diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index af452c99..18374e14 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -1,3 +1,4 @@ +from app.models.recipe_history import RecipeHistory from app.errors import NotFoundRequest from flask import jsonify from flask_jwt_extended import jwt_required @@ -23,6 +24,7 @@ def addPlannedRecipe(args): raise NotFoundRequest() recipe.planned = True recipe.save() + RecipeHistory.create_added(recipe) return jsonify(recipe.obj_to_dict()) @@ -34,4 +36,5 @@ def removePlannedRecipeById(id): raise NotFoundRequest() recipe.planned = False recipe.save() + RecipeHistory.create_dropped(recipe) return jsonify(recipe.obj_to_dict()) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index efc94c94..6195b034 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,3 +4,4 @@ from .history import History, Status from .recipe import RecipeItems, Recipe from .shoppinglist import ShoppinglistItems, Shoppinglist +from .recipe_history import RecipeHistory diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 6b843777..0abb84a9 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -12,6 +12,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): photo = db.Column(db.String()) planned = db.Column(db.Boolean) + recipe_history = db.relationship("RecipeHistory", back_populates="recipe", cascade="all, delete-orphan") items = db.relationship( 'RecipeItems', back_populates='recipe', cascade="all, delete-orphan") diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py new file mode 100644 index 00000000..53db0b31 --- /dev/null +++ b/backend/app/models/recipe_history.py @@ -0,0 +1,54 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from .recipe import Recipe +from .shoppinglist import Shoppinglist + +import enum + + +class Status(enum.Enum): + ADDED = 1 + DROPPED = -1 + + +class RecipeHistory(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'recipe_history' + + id = db.Column(db.Integer, primary_key=True) + + recipe_id = db.Column(db.Integer, db.ForeignKey('recipe.id')) + + recipe = db.relationship("Recipe", uselist=False, back_populates="recipe_history") + + status = db.Column(db.Enum(Status)) + + @classmethod + def create_added(cls, recipe): + return cls( + recipe_id=recipe.id, + status=Status.ADDED + ).save() + + @classmethod + def create_dropped(cls, recipe): + return cls( + recipe_id=recipe.id, + status=Status.DROPPED + ).save() + + def obj_to_item_dict(self): + res = self.item.obj_to_dict() + res['timestamp'] = getattr(self, 'created_at') + return res + + @classmethod + def find_added(cls): + return cls.query.filter(cls.status == Status.ADDED).all() + + @classmethod + def find_dropped(cls): + return cls.query.filter(cls.status == Status.DROPPED).all() + + @classmethod + def find_all(cls): + return cls.query.all() diff --git a/backend/migrations/versions/5a064f9c14d0_.py b/backend/migrations/versions/5a064f9c14d0_.py new file mode 100644 index 00000000..1ed37107 --- /dev/null +++ b/backend/migrations/versions/5a064f9c14d0_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 5a064f9c14d0 +Revises: 23445ea65b2b +Create Date: 2021-07-17 15:11:13.654652 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5a064f9c14d0' +down_revision = '23445ea65b2b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('recipe_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('recipe_id', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('ADDED', 'DROPPED', name='status'), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['recipe_id'], ['recipe.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('recipe_history') + # ### end Alembic commands ### From 75626f2a6ff79197e6b0ec1e9ced5225774e8eb6 Mon Sep 17 00:00:00 2001 From: Constantin Date: Sat, 17 Jul 2021 17:06:55 +0200 Subject: [PATCH 029/496] change order of items in shoppinglist to alphabetic --- backend/app/controller/shoppinglist/shoppinglist_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 78979c71..1a48ea26 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -49,7 +49,7 @@ def getAllShoppingListItems(id): items = ShoppinglistItems.query.filter( ShoppinglistItems.shoppinglist_id == id).join( ShoppinglistItems.item).order_by( - Item.ordering, Item.name).all() + Item.name).all() return jsonify([e.obj_to_item_dict() for e in items]) From 239a1b6579a353431b4013070e3b122dd99d9f67 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 19 Jul 2021 00:10:13 +0200 Subject: [PATCH 030/496] feat: recent planned meals endpoint --- backend/app/controller/planner/planner_controller.py | 9 ++++++++- backend/app/models/recipe_history.py | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 18374e14..00f1d160 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -4,7 +4,7 @@ from flask_jwt_extended import jwt_required from app import app from app.helpers import validate_args -from app.models import Recipe +from app.models import Recipe, RecipeHistory from .schemas import AddPlannedRecipe @@ -38,3 +38,10 @@ def removePlannedRecipeById(id): recipe.save() RecipeHistory.create_dropped(recipe) return jsonify(recipe.obj_to_dict()) + + +@app.route('/planner/recent-recipes', methods=['GET']) +@jwt_required() +def getRecentRecipes(): + recipes = RecipeHistory.get_recent() + return jsonify([e.recipe.obj_to_dict() for e in recipes]) diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py index 53db0b31..5ac9e187 100644 --- a/backend/app/models/recipe_history.py +++ b/backend/app/models/recipe_history.py @@ -18,7 +18,8 @@ class RecipeHistory(db.Model, DbModelMixin, TimestampMixin): recipe_id = db.Column(db.Integer, db.ForeignKey('recipe.id')) - recipe = db.relationship("Recipe", uselist=False, back_populates="recipe_history") + recipe = db.relationship("Recipe", uselist=False, + back_populates="recipe_history") status = db.Column(db.Enum(Status)) @@ -52,3 +53,9 @@ def find_dropped(cls): @classmethod def find_all(cls): return cls.query.all() + + @classmethod + def get_recent(cls): + sq = db.session.query(Recipe.id).filter( + Recipe.planned).subquery() + return cls.query.filter(cls.status == Status.DROPPED).filter(cls.recipe_id.notin_(sq)).order_by(cls.id.desc()).group_by(cls.recipe_id).limit(9) From b767ff78bf2b7b0590ec8c817ff6db022cc1142b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 19 Jul 2021 00:13:30 +0200 Subject: [PATCH 031/496] update version number --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 948c0ce8..5ebde84a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 6 +BACKEND_VERSION = 7 app = Flask(__name__) From 305bce1a825587c8eebfb5498fe806ec7e747481 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 2 Aug 2021 10:01:16 +0200 Subject: [PATCH 032/496] Create ci_to_docker_hub.yml --- .github/workflows/ci_to_docker_hub.yml | 56 ++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/ci_to_docker_hub.yml diff --git a/.github/workflows/ci_to_docker_hub.yml b/.github/workflows/ci_to_docker_hub.yml new file mode 100644 index 00000000..305e56d6 --- /dev/null +++ b/.github/workflows/ci_to_docker_hub.yml @@ -0,0 +1,56 @@ +# This is a basic workflow to help you get started with Actions + +name: CI to Docker Hub + +# Controls when the workflow will run +on: + # Triggers the workflow on push events but only for the stable branch + push: + branches: [ stable ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: ./ + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:latest + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} From 22a0bca652e89d94cf2ab29361729a830cc77fb0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 2 Aug 2021 13:09:03 +0200 Subject: [PATCH 033/496] feat: export/import fix: CI, recent items, recent meals --- .github/workflows/ci_to_docker_hub.yml | 5 +- backend/app/config.py | 4 + backend/app/controller/__init__.py | 1 + .../app/controller/exportimport/__init__.py | 2 + .../exportimport/export_controller.py | 24 + .../exportimport/import_controller.py | 67 ++ .../app/controller/exportimport/schemas.py | 34 ++ .../shoppinglist/shoppinglist_controller.py | 9 +- backend/app/errors/__init__.py | 8 +- backend/app/models/history.py | 11 +- backend/app/models/item.py | 12 +- backend/app/models/recipe.py | 18 +- backend/app/models/recipe_history.py | 7 +- backend/templates/de.json | 574 ++++++++++++++++++ backend/templates/en.json | 466 ++++++++++++++ 15 files changed, 1223 insertions(+), 19 deletions(-) create mode 100644 backend/app/controller/exportimport/__init__.py create mode 100644 backend/app/controller/exportimport/export_controller.py create mode 100644 backend/app/controller/exportimport/import_controller.py create mode 100644 backend/app/controller/exportimport/schemas.py create mode 100644 backend/templates/de.json create mode 100644 backend/templates/en.json diff --git a/.github/workflows/ci_to_docker_hub.yml b/.github/workflows/ci_to_docker_hub.yml index 305e56d6..6e22085b 100644 --- a/.github/workflows/ci_to_docker_hub.yml +++ b/.github/workflows/ci_to_docker_hub.yml @@ -6,7 +6,7 @@ name: CI to Docker Hub on: # Triggers the workflow on push events but only for the stable branch push: - branches: [ stable ] + branches: [stable] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -21,7 +21,7 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - + - name: Cache Docker layers uses: actions/cache@v2 with: @@ -30,7 +30,6 @@ jobs: restore-keys: | ${{ runner.os }}-buildx- - - name: Login to Docker Hub uses: docker/login-action@v1 with: diff --git a/backend/app/config.py b/backend/app/config.py index 5ebde84a..c13ec34a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -10,6 +10,10 @@ MIN_FRONTEND_VERSION = 8 BACKEND_VERSION = 7 +APP_DIR = os.path.dirname(os.path.abspath(__file__)) + +SUPPORTED_LANGUAGES = ['en', 'de'] + app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + \ diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 69f1c733..99227221 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -5,4 +5,5 @@ from . import shoppinglist from . import planner from . import onboarding +from . import exportimport from . import health_controller \ No newline at end of file diff --git a/backend/app/controller/exportimport/__init__.py b/backend/app/controller/exportimport/__init__.py new file mode 100644 index 00000000..4cfa300b --- /dev/null +++ b/backend/app/controller/exportimport/__init__.py @@ -0,0 +1,2 @@ +from . import export_controller +from . import import_controller \ No newline at end of file diff --git a/backend/app/controller/exportimport/export_controller.py b/backend/app/controller/exportimport/export_controller.py new file mode 100644 index 00000000..fdf49f36 --- /dev/null +++ b/backend/app/controller/exportimport/export_controller.py @@ -0,0 +1,24 @@ +from app.helpers import validate_args +from flask import jsonify +from app.errors import NotFoundRequest +from flask_jwt_extended import jwt_required +from app import app +from app.models import Item, Recipe + + +@app.route('/export', methods=['GET']) +@jwt_required() +def getExportAll(): + return jsonify({"items": [e.obj_to_export_dict() for e in Item.all()], "recipes": [e.obj_to_export_dict() for e in Recipe.all()]}) + + +@app.route('/export/items', methods=['GET']) +@jwt_required() +def getExportItems(): + return jsonify({"items": [e.obj_to_export_dict() for e in Item.all()]}) + + +@app.route('/export/recipes', methods=['GET']) +@jwt_required() +def getExportRecipes(): + return jsonify({"recipes": [e.obj_to_export_dict() for e in Recipe.all()]}) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py new file mode 100644 index 00000000..ba1d2d8b --- /dev/null +++ b/backend/app/controller/exportimport/import_controller.py @@ -0,0 +1,67 @@ +from .schemas import ImportSchema +from app.helpers import validate_args +from flask import jsonify +from app.errors import NotFoundRequest +from flask_jwt_extended import jwt_required +from app import app +from app.config import APP_DIR, SUPPORTED_LANGUAGES +from app.models import Item, Recipe, RecipeItems +import json +from os.path import exists + + +@app.route('/import', methods=['POST']) +@jwt_required() +@validate_args(ImportSchema) +def importData(args): + _import(args) + return jsonify({'msg': 'DONE'}) + + +@app.route('/import/', methods=['GET']) +@jwt_required() +def importLang(lang): + file_path = f'{APP_DIR}/../templates/{lang}.json' + if not lang in SUPPORTED_LANGUAGES or not exists(file_path): + raise NotFoundRequest('Language code not supported') + with open(file_path, 'r') as f: + data = json.load(f) + _import(data) + return jsonify({'msg': 'DONE'}) + + +@app.route('/supported-languages', methods=['GET']) +@jwt_required() +def getSupportedLanguages(): + return jsonify(SUPPORTED_LANGUAGES) + + +def _import(args): + if "items" in args: + for importItem in args['items']: + if not Item.find_by_name(importItem['name']): + Item.create_by_name(importItem['name']) + if "recipes" in args: + for importRecipe in args['recipes']: + recipeNameCount = 0 + if Recipe.find_by_name(importRecipe['name']): + recipeNameCount = 1 + \ + Recipe.query.filter(Recipe.name.ilike( + importRecipe['name'] + " (_%)")).count() + recipe = Recipe() + recipe.name = importRecipe['name'] + \ + (f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "") + recipe.description = importRecipe['description'] + recipe.save() + if 'items' in importRecipe: + for recipeItem in importRecipe['items']: + item = Item.find_by_name(recipeItem['name']) + if not item: + item = Item.create_by_name(recipeItem['name']) + con = RecipeItems( + description=recipeItem['description'], + optional=recipeItem['optional'] + ) + con.item = item + con.recipe = recipe + con.save() diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py new file mode 100644 index 00000000..a57a9e4e --- /dev/null +++ b/backend/app/controller/exportimport/schemas.py @@ -0,0 +1,34 @@ +from marshmallow import fields, Schema + + +class ImportSchema(Schema): + class Item(Schema): + name = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + + class Recipe(Schema): + class RecipeItem(Schema): + name = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + optional = fields.Boolean( + default=False + ) + description = fields.String( + default='' + ) + + name = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) + description = fields.String( + default='' + ) + items = fields.List(fields.Nested(RecipeItem)) + + items = fields.List(fields.Nested(Item)) + recipes = fields.List(fields.Nested(Recipe)) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 1a48ea26..8a81fa9b 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -37,7 +37,7 @@ def updateItemDescription(args, id, item_id): con = ShoppinglistItems.find_by_ids(id, item_id) if not con: raise NotFoundRequest() - + con.description = args['description'] or '' con.save() return jsonify(con.obj_to_item_dict()) @@ -56,11 +56,8 @@ def getAllShoppingListItems(id): @app.route('/shoppinglist//recent-items', methods=['GET']) @jwt_required() def getRecentItems(id): - sq = db.session.query(ShoppinglistItems.item_id).filter( - ShoppinglistItems.shoppinglist_id == id).subquery() - q = Item.query.filter(Item.id.notin_(sq)).order_by( - Item.updated_at).limit(9) - return jsonify([e.obj_to_dict() for e in q]) + items = History.get_recent(id) + return jsonify([e.item.obj_to_dict() for e in items]) def getSuggestionsBasedOnLastAddedItems(id, item_count): diff --git a/backend/app/errors/__init__.py b/backend/app/errors/__init__.py index 2f67b7f1..53763a38 100644 --- a/backend/app/errors/__init__.py +++ b/backend/app/errors/__init__.py @@ -1,19 +1,19 @@ class InvalidUsage(Exception): - def __init__(self, message): + def __init__(self, message="Invalid usage"): super(InvalidUsage, self).__init__(message) self.message = message class UnauthorizedRequest(Exception): - def __init__(self, message): + def __init__(self, message="Request unauthorized"): super(UnauthorizedRequest, self).__init__(message) self.message = message class ForbiddenRequest(Exception): - def __init__(self, message): + def __init__(self, message="Request forbidden"): super(ForbiddenRequest, self).__init__(message) self.message = message class NotFoundRequest(Exception): - def __init__(self, message): + def __init__(self, message="Requested resource not found"): super(NotFoundRequest, self).__init__(message) self.message = message \ No newline at end of file diff --git a/backend/app/models/history.py b/backend/app/models/history.py index 584852fa..0c089847 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -1,7 +1,8 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin from .item import Item -from .shoppinglist import Shoppinglist +from .shoppinglist import Shoppinglist, ShoppinglistItems +from sqlalchemy import func import enum @@ -60,3 +61,11 @@ def find_by_shoppinglist_id(cls, shoppinglist_id): @classmethod def find_all(cls): return cls.query.all() + + @classmethod + def get_recent(cls, shoppinglist_id): + sq = db.session.query(ShoppinglistItems.item_id).filter( + ShoppinglistItems.shoppinglist_id == shoppinglist_id).subquery().select(ShoppinglistItems.item_id) + sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( + cls.item_id.notin_(sq)).group_by(cls.item_id).join(cls.item).subquery().select(cls.id) + return cls.query.filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index e0b8c217..17fc8c3d 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -18,12 +18,19 @@ class Item(db.Model, DbModelMixin, TimestampMixin): # frequency of item, used for item suggestions support = db.Column(db.Float, server_default='0.0') - history = db.relationship("History", back_populates="item", cascade="all, delete-orphan") + history = db.relationship( + "History", back_populates="item", cascade="all, delete-orphan") antecedents = db.relationship( "Association", back_populates="antecedent", foreign_keys='Association.antecedent_id') consequents = db.relationship( "Association", back_populates="consequent", foreign_keys='Association.consequent_id') + def obj_to_export_dict(self): + res = { + "name": self.name, + } + return res + @classmethod def create_by_name(cls, name): return cls( @@ -46,7 +53,8 @@ def search_name(cls, name): # name is a regex if '*' in name or '?' in name or '%' in name or '_' in name: looking_for = name.replace('*', '%').replace('?', '_') - found = cls.query.filter(cls.name.ilike(looking_for)).order_by(cls.support.desc()).limit(item_count).all() + found = cls.query.filter(cls.name.ilike(looking_for)).order_by( + cls.support.desc()).limit(item_count).all() return found # name is no regex diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 0abb84a9..a31f2809 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -12,7 +12,8 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): photo = db.Column(db.String()) planned = db.Column(db.Boolean) - recipe_history = db.relationship("RecipeHistory", back_populates="recipe", cascade="all, delete-orphan") + recipe_history = db.relationship( + "RecipeHistory", back_populates="recipe", cascade="all, delete-orphan") items = db.relationship( 'RecipeItems', back_populates='recipe', cascade="all, delete-orphan") @@ -24,6 +25,21 @@ def obj_to_full_dict(self): res['items'] = [e.obj_to_item_dict() for e in items] return res + def obj_to_export_dict(self): + items = RecipeItems.query.filter(RecipeItems.recipe_id == self.id).join( + RecipeItems.item).order_by( + Item.name).all() + res = { + "name": self.name, + "description": self.description, + "items": [{"name": e.item.name, "description": e.description, "optional": e.optional} for e in items], + } + return res + + @classmethod + def find_by_name(cls, name): + return cls.query.filter(cls.name == name).first() + @classmethod def search_name(cls, name): if '*' in name or '_' in name: diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py index 5ac9e187..000d7830 100644 --- a/backend/app/models/recipe_history.py +++ b/backend/app/models/recipe_history.py @@ -2,6 +2,7 @@ from app.helpers import DbModelMixin, TimestampMixin from .recipe import Recipe from .shoppinglist import Shoppinglist +from sqlalchemy import func import enum @@ -57,5 +58,7 @@ def find_all(cls): @classmethod def get_recent(cls): sq = db.session.query(Recipe.id).filter( - Recipe.planned).subquery() - return cls.query.filter(cls.status == Status.DROPPED).filter(cls.recipe_id.notin_(sq)).order_by(cls.id.desc()).group_by(cls.recipe_id).limit(9) + Recipe.planned).subquery().select(Recipe.id) + sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( + cls.recipe_id.notin_(sq)).group_by(cls.recipe_id).join(cls.recipe).subquery().select(cls.id) + return cls.query.filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) diff --git a/backend/templates/de.json b/backend/templates/de.json new file mode 100644 index 00000000..9e63a47c --- /dev/null +++ b/backend/templates/de.json @@ -0,0 +1,574 @@ +{ + "items": [ + { + "name": "Spülmittel" + }, + { + "name": "Milch" + }, + { + "name": "Müsli" + }, + { + "name": "Toast" + }, + { + "name": "Nudeln" + }, + { + "name": "Mozzarella" + }, + { + "name": "Tomaten" + }, + { + "name": "Rucola" + }, + { + "name": "Balsamico Essig" + }, + { + "name": "Olivenöl" + }, + { + "name": "Spaghetti" + }, + { + "name": "Babyspinat" + }, + { + "name": "Cherrytomaten" + }, + { + "name": "Zwiebel" + }, + { + "name": "Basilikum" + }, + { + "name": "Parmesan" + }, + { + "name": "Risotto Reis" + }, + { + "name": "Butter" + }, + { + "name": "Schalotte" + }, + { + "name": "Weißwein" + }, + { + "name": "Gemüsebrühe" + }, + { + "name": "Lasagnenudeln" + }, + { + "name": "Passierte Tomaten" + }, + { + "name": "Gehackte Tomaten" + }, + { + "name": "Kartoffeln" + }, + { + "name": "Kohlrabi" + }, + { + "name": "Feta" + }, + { + "name": "Möhren" + }, + { + "name": "Zucchini" + }, + { + "name": "Salatkopf" + }, + { + "name": "Gurke" + }, + { + "name": "Kidneybohnen" + }, + { + "name": "Haferflocken" + }, + { + "name": "Lauch" + }, + { + "name": "Käse" + }, + { + "name": "Apfel" + }, + { + "name": "Bananen" + }, + { + "name": "Magerquark" + }, + { + "name": "Brot" + }, + { + "name": "Bandnudeln" + }, + { + "name": "Dill" + }, + { + "name": "Knoblauch" + }, + { + "name": "Rote Chili" + }, + { + "name": "Zitronensaft" + }, + { + "name": "Rote Beete" + }, + { + "name": "Thymian" + }, + { + "name": "Kirschtomaten" + }, + { + "name": "Scheibenkäse" + }, + { + "name": "Sojasauce" + }, + { + "name": "Penne" + }, + { + "name": "Ricotta" + }, + { + "name": "Ganze Dosentomaten" + }, + { + "name": "Aubergine" + }, + { + "name": "Baguettes" + }, + { + "name": "Schmand" + }, + { + "name": "Streukäse" + }, + { + "name": "Champignons" + }, + { + "name": "Tunfisch" + }, + { + "name": "Blätterteig" + }, + { + "name": "Spinat" + }, + { + "name": "Pinienkerne" + }, + { + "name": "Tomatenmark" + }, + { + "name": "Salbei" + }, + { + "name": "Mais" + }, + { + "name": "Reis" + }, + { + "name": "Blumenkohl" + }, + { + "name": "Paprika" + }, + { + "name": "Joghurt" + }, + { + "name": "Harissa" + }, + { + "name": "Tahini" + }, + { + "name": "Couscous" + }, + { + "name": "Erdnuss-Butter" + }, + { + "name": "Asiatische Eiernudeln" + }, + { + "name": "Kokosnuss-Milch" + }, + { + "name": "Erbsen" + }, + { + "name": "Gnocchi" + }, + { + "name": "Tortellini" + }, + { + "name": "Pilze" + }, + { + "name": "Erdnüsse" + }, + { + "name": "Koriander" + }, + { + "name": "Currypaste" + }, + { + "name": "Süßkartoffel" + }, + { + "name": "Frühlingszwiebeln" + }, + { + "name": "Ananas" + }, + { + "name": "Orangensaft" + }, + { + "name": "Eier" + }, + { + "name": "Mehl" + }, + { + "name": "Konfitüre" + }, + { + "name": "Sahne" + }, + { + "name": "Räuchertofu" + }, + { + "name": "Bier" + }, + { + "name": "Tonic Water" + }, + { + "name": "Fanta" + }, + { + "name": "Sprite" + }, + { + "name": "Maultaschen" + }, + { + "name": "Radicchio" + }, + { + "name": "Gorgonzola" + }, + { + "name": "Pesto" + }, + { + "name": "Safranfäden" + }, + { + "name": "Petersilie" + }, + { + "name": "Grüne Chili" + }, + { + "name": "Kichererbsen" + }, + { + "name": "Brötchen" + }, + { + "name": "Linguine" + }, + { + "name": "Creme fraiche" + }, + { + "name": "Schnittlauch" + }, + { + "name": "Kokosöl" + }, + { + "name": "Rosenkohl" + }, + { + "name": "Avocado" + }, + { + "name": "Quinoa" + }, + { + "name": "Minze" + }, + { + "name": "Toilettenpapier" + }, + { + "name": "Brokkoli" + }, + { + "name": "Sojahack" + }, + { + "name": "Mayonnaise" + }, + { + "name": "Sushi Reis" + }, + { + "name": "Nori Blätter" + }, + { + "name": "Reis-Essig" + }, + { + "name": "Ciabatta" + }, + { + "name": "Schupfnudeln" + }, + { + "name": "Vodka" + }, + { + "name": "Berliner Luft" + }, + { + "name": "Cheddar" + }, + { + "name": "Rapsöl" + }, + { + "name": "Tofu" + }, + { + "name": "Hefe" + }, + { + "name": "Cannelloni" + }, + { + "name": "Backpapier" + }, + { + "name": "Spültabs" + }, + { + "name": "Haargel" + }, + { + "name": "Wraps" + }, + { + "name": "Zitrone" + }, + { + "name": "Wassereis" + }, + { + "name": "Shampoo" + }, + { + "name": "Zahnpasta" + }, + { + "name": "Linsen" + }, + { + "name": "Rhabarber" + }, + { + "name": "Limette" + }, + { + "name": "Honig" + }, + { + "name": "Sesam" + }, + { + "name": "Aioli" + }, + { + "name": "Oregano" + }, + { + "name": "Mundspülung" + }, + { + "name": "Grüner Spargel" + }, + { + "name": "Senf" + }, + { + "name": "Pflanzenöl" + }, + { + "name": "Rote Linsen" + }, + { + "name": "Müsli-Riegel" + }, + { + "name": "Sonnenblumenkerne" + }, + { + "name": "Dinkel" + }, + { + "name": "Zucker" + }, + { + "name": "Himbeersirup" + }, + { + "name": "Backhefe" + }, + { + "name": "Seife" + }, + { + "name": "Rotwein" + }, + { + "name": "Falafelpulver" + }, + { + "name": "Pitatasche" + }, + { + "name": "Tzatziki" + }, + { + "name": "Pizza" + }, + { + "name": "Kürbis" + }, + { + "name": "Müllsäcke" + }, + { + "name": "Badreiniger" + }, + { + "name": "Getrocknete Tomaten" + }, + { + "name": "Rosmarin" + }, + { + "name": "Thunfisch" + }, + { + "name": "Frischkäse" + }, + { + "name": "Lorbeerblatt" + }, + { + "name": "Rahmspinat" + }, + { + "name": "Schwämme" + }, + { + "name": "Pflaster" + }, + { + "name": "Schokolade" + }, + { + "name": "Sriracha" + }, + { + "name": "Spätzle" + }, + { + "name": "Deodorant" + }, + { + "name": "Salz" + }, + { + "name": "Schwammlappen" + }, + { + "name": "Snacks" + }, + { + "name": "Softdrinks" + }, + { + "name": "Kreppband" + }, + { + "name": "Sambal Olek" + }, + { + "name": "Muskatnuss" + }, + { + "name": "Zewa" + }, + { + "name": "Backfisch" + }, + { + "name": "Cocktailsauce" + }, + { + "name": "Wirsing" + }, + { + "name": "Waschpulver" + }, + { + "name": "Knopfzellen" + }, + { + "name": "Duschgel" + }, + { + "name": "Rote Zwiebeln" + }, + { + "name": "Knäckebrot" + } + ] +} diff --git a/backend/templates/en.json b/backend/templates/en.json new file mode 100644 index 00000000..66545e8c --- /dev/null +++ b/backend/templates/en.json @@ -0,0 +1,466 @@ +{ + "items": [ + { + "name": "Detergent" + }, + { + "name": "Milk" + }, + { + "name": "Cornflakes" + }, + { + "name": "Toast" + }, + { + "name": "Pasta" + }, + { + "name": "Mozzarella" + }, + { + "name": "Tomatoes" + }, + { + "name": "Rocket" + }, + { + "name": "Balsamic vinegar" + }, + { + "name": "Olive oil" + }, + { + "name": "Spaghetti" + }, + { + "name": "Spinach" + }, + { + "name": "Cherry tomatoes" + }, + { + "name": "Onions" + }, + { + "name": "Basil" + }, + { + "name": "Parmesan" + }, + { + "name": "Risotto rice" + }, + { + "name": "Butter" + }, + { + "name": "Scallion" + }, + { + "name": "White wine" + }, + { + "name": "Vegetable broth" + }, + { + "name": "Lasagna" + }, + { + "name": "Sieved tomatoes" + }, + { + "name": "Potatoes" + }, + { + "name": "Kohlrabi" + }, + { + "name": "Feta" + }, + { + "name": "Carrots" + }, + { + "name": "Zucchini" + }, + { + "name": "Lettuce" + }, + { + "name": "Cucumber" + }, + { + "name": "Kidney beans" + }, + { + "name": "Oatmeal" + }, + { + "name": "Leek" + }, + { + "name": "Cheese" + }, + { + "name": "Apple" + }, + { + "name": "Bananas" + }, + { + "name": "Quark" + }, + { + "name": "Bread" + }, + { + "name": "Tagliatelle" + }, + { + "name": "Dill" + }, + { + "name": "Garlic" + }, + { + "name": "Red chili" + }, + { + "name": "Lemonjuice" + }, + { + "name": "Beetroot" + }, + { + "name": "Thyme" + }, + { + "name": "Sliced cheese" + }, + { + "name": "Soy sauce" + }, + { + "name": "Penne pasta" + }, + { + "name": "Ricotta" + }, + { + "name": "Eggplant" + }, + { + "name": "Baguette" + }, + { + "name": "Shredded cheese" + }, + { + "name": "Mushrooms" + }, + { + "name": "Tuna" + }, + { + "name": "Pine nuts" + }, + { + "name": "Tomato paste" + }, + { + "name": "Sage" + }, + { + "name": "Corn" + }, + { + "name": "Rice" + }, + { + "name": "Cauliflower" + }, + { + "name": "Peppers" + }, + { + "name": "Yoghurt" + }, + { + "name": "Harissa" + }, + { + "name": "Tahini" + }, + { + "name": "Couscous" + }, + { + "name": "Peanutbutter" + }, + { + "name": "Coconut milk" + }, + { + "name": "Peas" + }, + { + "name": "Gnocchi" + }, + { + "name": "Tortellini" + }, + { + "name": "Peanuts" + }, + { + "name": "Cilantro" + }, + { + "name": "Curry paste" + }, + { + "name": "Sweet potatoes" + }, + { + "name": "Spring onions" + }, + { + "name": "Pineapple" + }, + { + "name": "Orange juice" + }, + { + "name": "Eggs" + }, + { + "name": "Flour" + }, + { + "name": "Jam" + }, + { + "name": "Cream" + }, + { + "name": "Smoked tofu" + }, + { + "name": "Beer" + }, + { + "name": "Tonic water" + }, + { + "name": "Fanta" + }, + { + "name": "Sprite" + }, + { + "name": "Radicchio" + }, + { + "name": "Gorgonzola" + }, + { + "name": "Pesto" + }, + { + "name": "Parsley" + }, + { + "name": "Green chili" + }, + { + "name": "Chickpeas" + }, + { + "name": "Buns" + }, + { + "name": "Linguine" + }, + { + "name": "Chives" + }, + { + "name": "Coconut oil" + }, + { + "name": "Sprouts" + }, + { + "name": "Avocado" + }, + { + "name": "Quinoa" + }, + { + "name": "Mint" + }, + { + "name": "Toilet paper" + }, + { + "name": "Broccoli" + }, + { + "name": "Mayonnaise" + }, + { + "name": "Nori sheets" + }, + { + "name": "Rice vinegar" + }, + { + "name": "Ciabatta" + }, + { + "name": "Vodka" + }, + { + "name": "Cheddar" + }, + { + "name": "Canola oil" + }, + { + "name": "Tofu" + }, + { + "name": "Yeast" + }, + { + "name": "Cannelloni" + }, + { + "name": "Baking paper" + }, + { + "name": "Dishwasher tabs" + }, + { + "name": "Hair gel" + }, + { + "name": "Wraps" + }, + { + "name": "Lemon" + }, + { + "name": "Shampoo" + }, + { + "name": "Toothpaste" + }, + { + "name": "Lentils" + }, + { + "name": "Lime" + }, + { + "name": "Honey" + }, + { + "name": "Sesame" + }, + { + "name": "Aioli" + }, + { + "name": "Oregano" + }, + { + "name": "Mouth wash" + }, + { + "name": "Aspargus" + }, + { + "name": "Mustard" + }, + { + "name": "Plant oil" + }, + { + "name": "Red lentils" + }, + { + "name": "Cereal bar" + }, + { + "name": "Sunflower seeds" + }, + { + "name": "Sugar" + }, + { + "name": "Soap" + }, + { + "name": "Red wine" + }, + { + "name": "Tzatziki" + }, + { + "name": "Pizza" + }, + { + "name": "Pumpkin" + }, + { + "name": "Garbage bags" + }, + { + "name": "Dried tomatoes" + }, + { + "name": "Rosemary" + }, + { + "name": "Cream cheese" + }, + { + "name": "Sponges" + }, + { + "name": "Plaster" + }, + { + "name": "Chocolate" + }, + { + "name": "Sriracha" + }, + { + "name": "Deodorant" + }, + { + "name": "Salt" + }, + { + "name": "Snacks" + }, + { + "name": "Softdrinks" + }, + { + "name": "Tape" + }, + { + "name": "Kitchen towels" + }, + { + "name": "Washing powder" + }, + { + "name": "Bodywash" + } + ] +} From 7529e94158ab52d173ec1cc98bf4dc37bdacf73a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 2 Aug 2021 13:55:25 +0200 Subject: [PATCH 034/496] update version number --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index c13ec34a..e22e0663 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 7 +BACKEND_VERSION = 8 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 6040ecc5220d875a807e81137bb563ede13f53c4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 2 Aug 2021 14:02:07 +0200 Subject: [PATCH 035/496] Update dockerfile for github actions --- backend/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 662e45ed..06801ff0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,7 +8,6 @@ ENV STORAGE_PATH='/data' ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' ENV DEBUG='False' RUN pip3 install -r requirements.txt && rm requirements.txt -RUN flask db upgrade RUN chmod u+x ./entrypoint.sh HEALTHCHECK --interval=5m --timeout=3s \ From fa84becb77f4735b5a093a55f1f45ecc1ea0fe99 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Aug 2021 12:57:44 +0200 Subject: [PATCH 036/496] update readme and docs --- backend/README.md | 79 +------------ backend/docs/_config.yml | 2 - backend/docs/about/app-privacy-policy.md | 134 ----------------------- backend/docs/icon.png | Bin 43354 -> 0 bytes backend/docs/index.md | 60 ---------- 5 files changed, 3 insertions(+), 272 deletions(-) delete mode 100644 backend/docs/_config.yml delete mode 100644 backend/docs/about/app-privacy-policy.md delete mode 100644 backend/docs/icon.png delete mode 100644 backend/docs/index.md diff --git a/backend/README.md b/backend/README.md index 17491a69..2f0273c9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,76 +1,3 @@ -

- - KitchenOwl - -

-

- - Stars - - - License - - - Docker pulls - -

-

- KitchenOwl -

- -

- A grocery list and recipe manager -

-

- KitchenOwl is a self-hosted grocery list and recipe manager. The backend is made with Flask and the frontend with Flutter. Easily add items to your shopping list before you go shopping. You can also create recipes and add items based on what you want to cook. -

- -

- 🍫 🥘 🍽 -

- -## ✨ Features - -The following features have been implemented: - -- Add items to your shopping list and sync it with multiple users -- Partial offline support so you don't lose track of what to buy even when there is no signal -- Manage recipes and add items directly from a recipe. -- Mobile/Web/Desktop apps - -This project is still in development, so some options may not be fully implemented yet. - -For a list of planned features, check out the [Roadmap](https://github.com/TomBursch/KitchenOwl/wiki/Roadmap)! - -## 🤖 Install - -You can either install only the backend or add the web-app to it. [Docker](https://docs.docker.com/engine/install/) is required. - -### Backend only -Using docker cli: -``` -docker volume create kitchenowl_data -``` -``` -docker run -d -p 5000:5000 --name=kitchenowl --restart=unless-stopped -v kitchenowl_data:/data tombursch/kitchenowl:latest -``` - -### Backend and Web-app -Recommended using [docker-compose](https://docs.docker.com/compose/): -1. Download the [docker-compose.yml](docker-compose.yml) -2. Change default values such as `JWT_SECRET_KEY` and the URLs (corresponding to the ones your instance will be running on) -3. Run `docker-compose up -d` - -## 🙌 Contributing - -From opening a bug report to creating a pull request: every contribution is appreciated and welcomed. If you're planning to implement a new feature or change the API please create an issue first. This way we can ensure your work is not in vain. For more information see [Contributing](https://github.com/TomBursch/KitchenOwl/blob/main/CONTRIBUTING.md) - -## 📚 Related -- [KitchenOwl App](https://github.com/TomBursch/KitchenOwl-app) Repository -- [DockerHub](https://hub.docker.com/repository/docker/tombursch/kitchenowl) -- Icons modified from [Those Icons](https://www.flaticon.com/authors/those-icons) and [Freepik](https://www.flaticon.com/authors/freepik) - -### 🔨 Built With -- [Flask](https://flask.palletsprojects.com/en/1.1.x/) -- [Flutter](https://flutter.dev/) -- [Docker](https://docs.docker.com/) +# KitchenOwl Backend +This is the backend repository of KitchenOwl. +Find more information at the main [KitchenOwl](https://github.com/TomBursch/kitchenowl) repository. \ No newline at end of file diff --git a/backend/docs/_config.yml b/backend/docs/_config.yml deleted file mode 100644 index 079f066b..00000000 --- a/backend/docs/_config.yml +++ /dev/null @@ -1,2 +0,0 @@ -theme: jekyll-theme-tactile -title: Welcome to KitchenOwl \ No newline at end of file diff --git a/backend/docs/about/app-privacy-policy.md b/backend/docs/about/app-privacy-policy.md deleted file mode 100644 index 1f35ee94..00000000 --- a/backend/docs/about/app-privacy-policy.md +++ /dev/null @@ -1,134 +0,0 @@ -

Android & iOS Privacy Policy

-

Last updated: March 12, 2021

-

This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.

-

We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.

-

Interpretation and Definitions

-

Interpretation

-

The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.

-

Definitions

-

For the purposes of this Privacy Policy:

-
    -
  • -

    Account means a unique account created for You to access our Service or parts of our Service.

    -
  • -
  • -

    Affiliate means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority.

    -
  • -
  • -

    Application means the software program provided by the Company downloaded by You on any electronic device, named KitchenOwl

    -
  • -
  • -

    Company (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to KitchenOwl.

    -
  • -
  • -

    Country refers to: Nordrhein-Westfalen, Germany

    -
  • -
  • -

    Device means any device that can access the Service such as a computer, a cellphone or a digital tablet.

    -
  • -
  • -

    Personal Data is any information that relates to an identified or identifiable individual.

    -
  • -
  • -

    Service refers to the Application.

    -
  • -
  • -

    Service Provider means any natural or legal person who processes the data on behalf of the Company. It refers to third-party companies or individuals employed by the Company to facilitate the Service, to provide the Service on behalf of the Company, to perform services related to the Service or to assist the Company in analyzing how the Service is used.

    -
  • -
  • -

    Third-party Social Media Service refers to any website or any social network website through which a User can log in or create an account to use the Service.

    -
  • -
  • -

    Usage Data refers to data collected automatically, either generated by the use of the Service or from the Service infrastructure itself (for example, the duration of a page visit).

    -
  • -
  • -

    You means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.

    -
  • -
-

Collecting and Using Your Personal Data

-

Types of Data Collected

-

Personal Data

-

While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:

-
    -
  • Usage Data
  • -
-

Usage Data

-

Usage Data is collected automatically when using the Service.

-

Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.

-

When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.

-

We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.

-

Use of Your Personal Data

-

The Company may use Personal Data for the following purposes:

-
    -
  • -

    To provide and maintain our Service, including to monitor the usage of our Service.

    -
  • -
  • -

    To manage Your Account: to manage Your registration as a user of the Service. The Personal Data You provide can give You access to different functionalities of the Service that are available to You as a registered user.

    -
  • -
  • -

    For the performance of a contract: the development, compliance and undertaking of the purchase contract for the products, items or services You have purchased or of any other contract with Us through the Service.

    -
  • -
  • -

    To contact You: To contact You by email, telephone calls, SMS, or other equivalent forms of electronic communication, such as a mobile application's push notifications regarding updates or informative communications related to the functionalities, products or contracted services, including the security updates, when necessary or reasonable for their implementation.

    -
  • -
  • -

    To provide You with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about unless You have opted not to receive such information.

    -
  • -
  • -

    To manage Your requests: To attend and manage Your requests to Us.

    -
  • -
  • -

    For business transfers: We may use Your information to evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or other sale or transfer of some or all of Our assets, whether as a going concern or as part of bankruptcy, liquidation, or similar proceeding, in which Personal Data held by Us about our Service users is among the assets transferred.

    -
  • -
  • -

    For other purposes: We may use Your information for other purposes, such as data analysis, identifying usage trends, determining the effectiveness of our promotional campaigns and to evaluate and improve our Service, products, services, marketing and your experience.

    -
  • -
-

We may share Your personal information in the following situations:

-
    -
  • With Service Providers: We may share Your personal information with Service Providers to monitor and analyze the use of our Service, to contact You.
  • -
  • For business transfers: We may share or transfer Your personal information in connection with, or during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a portion of Our business to another company.
  • -
  • With Affiliates: We may share Your information with Our affiliates, in which case we will require those affiliates to honor this Privacy Policy. Affiliates include Our parent company and any other subsidiaries, joint venture partners or other companies that We control or that are under common control with Us.
  • -
  • With business partners: We may share Your information with Our business partners to offer You certain products, services or promotions.
  • -
  • With other users: when You share personal information or otherwise interact in the public areas with other users, such information may be viewed by all users and may be publicly distributed outside. If You interact with other users or register through a Third-Party Social Media Service, Your contacts on the Third-Party Social Media Service may see Your name, profile, pictures and description of Your activity. Similarly, other users will be able to view descriptions of Your activity, communicate with You and view Your profile.
  • -
  • With Your consent: We may disclose Your personal information for any other purpose with Your consent.
  • -
-

Retention of Your Personal Data

-

The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.

-

The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.

-

Transfer of Your Personal Data

-

Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.

-

Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.

-

The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.

-

Disclosure of Your Personal Data

-

Business Transactions

-

If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.

-

Law enforcement

-

Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).

-

Other legal requirements

-

The Company may disclose Your Personal Data in the good faith belief that such action is necessary to:

-
    -
  • Comply with a legal obligation
  • -
  • Protect and defend the rights or property of the Company
  • -
  • Prevent or investigate possible wrongdoing in connection with the Service
  • -
  • Protect the personal safety of Users of the Service or the public
  • -
  • Protect against legal liability
  • -
-

Security of Your Personal Data

-

The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to use commercially acceptable means to protect Your Personal Data, We cannot guarantee its absolute security.

-

Children's Privacy

-

Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age of 13 without verification of parental consent, We take steps to remove that information from Our servers.

-

If We need to rely on consent as a legal basis for processing Your information and Your country requires consent from a parent, We may require Your parent's consent before We collect and use that information.

-

Links to Other Websites

-

Our Service may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that third party's site. We strongly advise You to review the Privacy Policy of every site You visit.

-

We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.

-

Changes to this Privacy Policy

-

We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.

-

We will let You know via email and/or a prominent notice on Our Service, prior to the change becoming effective and update the "Last updated" date at the top of this Privacy Policy.

-

You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.

-

Contact Us

-

If you have any questions about this Privacy Policy, You can contact us:

- \ No newline at end of file diff --git a/backend/docs/icon.png b/backend/docs/icon.png deleted file mode 100644 index 6d05cdf287e15e69f78eed841e39b21f95e5b754..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43354 zcmd42^;=Zm_da}P2!~QYI)@TaN?NIrMnqbX4h1Acl&%?&ZV*N34(UdamQY%{1(Z%< zh=G~;9$ugKbv^&W^Am7ghkedoYwxx0b+3Cx=sZ@VAY~#20DwY6U0DwRpy01i07eY{ zIQAOHfIlE__0$xB@*$Q@0DuD;$`1^@(so*W{S46QcswRe14{T=hoiQWoFs@-%uOXf zK8y3ot4W9EM9b4(Wu{II7%YykZf@_1%Dnq-IrPct>0FcP?1DLp(8W=uJyJ>?P?<|1 zJb?C*%Zo#J-Z8>7){ZV#=Xu}%84wiv>@CM0`&Q=d+S@mwM*%IunWt*-Ylq_k_bgz` zo)0j9D+%GP2S0^10-bW#WT8atKT_Py|&O4Am(|+m01>ITJv3Ad-+%C1nCN@> zqqs0iMcC#|euaegvG{*d?1J+;nVZYU%1=m9|7P=-g4(qKgXca1;49U0)UzHxJUpn{ zs%slQHp=boB)J|j*YIKI_6l4Q@=bgLSvG}t{ePsd9>FO;JXcKK zFlH;oOk8|sQc@*s?^~z4A+w+M4~~+7>wJp13w}BWqe_kvG_HYGB0*Gq{A~73l?#yub#kS^!7T}j}tGfyBMwbG%$5Wov;7SC< z5mQ4y%eS=YaMk$=t>qSkh-bUbVuYO955g;sdrv_RaS%|0meQf~Gia$7TCm8^yCD1N zn0(0){_XLqHu%xUfs_`3@f9DOpZPtVZM*I48}Q=EgY$^Pwou9?jXBS&@4Xjwxt~k+ zEoyAyiL@ZpvU3tIQ&b~(*+yyo>i)^q8h;4%R!&2d!VLTmVt8luTlSO4Pzl3=Y?8G~ z-}vC=kI=0opmqF|6!+w1cILlnuVb48KU`=P184unmRKe797ovU2NMQ+$>7_5*m3`n zNq^Xzwkm}m{$a2y_5y0@Hh9`g7ui>$XleT8L<4*wCF^-W7^Q_mtEHI;t*>STz;JZD z6vG!zvv-q{zQ7s@`X06!ODMl%Fr=le!VTYiiOS8y2e?}09lT&df?MKlfm!-J$UDdv zcERtBJU{)A26%z{*7f~EgfLCnL0rA#mlSlRx+D7mKI*A0Eq6`kcOq0UpUl00o2X|$ z$Wd2@IpiR0H4m`9+=t)P{@YBAwLJfme*?x5@M&Wm+%RP#q2ih5b<@RB9e7SVd!O+?cP6b#9UcrQzb)Qs?UA7R|+>3u~@$)1BIJ9+FxEBfxsY!C(~@t#@3&-v^+C! zcB3OS*I3B<&)Dqh{N0>~0RS5ur8`6&??J{og9E;$!=Mn@PYc(_D0hmMWHs0>$dg9HA*|;7(bE`%M6#Ll1-Dr?_2T7W)3-w*!jPPBAw^ z*={af-TR}5>+7+trNhoAwJeI2yM$0@-VwLkD1tHwVVh2Bw8jF?#0^k^aLj}x{oAMH z0i5s;TeKHWB$AUZFzyDy@r?&GaqY*yqQIr7X!CC_^RsG)xmFa?7X}e-ZB_Q zNuDXbL=zN*ig!|?s)=3vb|Qf-7KHvz{O59kR1S`BJ^0{NmE}ad7W+Bz?mPCFLND$w zy)nw@#yb$8qyFYJn;)~bzd=~RFhK!8si?&c!}50n9E=>)UujVszyLDhA0*#T^Y=}d zZc2nJmEw+9q?+Y}8FGUU!+lJ-`$?8_B;#z0A%(G%9Mc|99rEppg>7wqV%Vk>w=tMV zn9CI;aOjw_XMP`Y_*|ab8U#}!1m{!5d`RiEKiSZ1Qn)cGD1=gkdHl|g!-tQ2tu!MS z(L$)VQAb#ARfTMy5PYOTt>~32S=YRSacHavu>`5yw6!@@scAOczTtLh!4zdkt4Xy5 zv%z+-p8d^ErreUvWkNcpAesSP7-gbruqj}vU^%+<*W}|j;_%Om%rqR{>O#!83|P8; zNPDP~%U>UipKb#?xmd#37OxTDlDCF1#j zvOPAVoOV};7=`BqM2$}zX1e->5tiKMtV`-93l#=uEi@p_gA+cT0Ep`)zW zTKf5SVVxGn*Ij<_c`fArW|z3W_drQ^6J7Y?+g^@!X}UY_mcl)h@75#-gFMMH^+!em z2Y(VV2d= z8d&#}?S$X!wS$iP4O8d`a|K&3-Yx|PmfJ1wm)Vjqu$~kJ`Iz2C|Ld>$Pij#f+8hY= zUrq|fb+1})0}jb7^or3#$KkLa5`H}9vZC&~4-1cplmw?waOO_0rXzZ^lhs(eyJ z)j@)}^f_13mVI1$!0obw(R_pNE_T*>9~%{f-)@-+0kq znT>(5BP0lmC()3Ld(=W}_~PlUmQ2vsB_1xF^{1y_?)mk1%1Gw2J0DQE-22(5<}93( zayT8|l+eJ8`Lu$#GyTrsEr!g0YNh^8E6H%>pIqoRbA#1ileTxS`5l~5Ip67|gDHE} ziKZez(1w3Zb+-TLMrge0Rix2=vY6o3*Csy_Ugshg=e)lkv%6~|Yh^_eqofO`Vd!Ch zn3T{kRP&VZkuIeKK+-kcukN|OM0w4pyEQ4E3P=dAN{?0_U{|2f6mEum$oNAFV^+9; zEiIkDB^*wxRU_bo!ZctcUzMZF%>GqGQsO^tc2J6r9D=drFvT~K^!%EVU?cu7+M|oT ze{PDRnNvKsLZO~4Xis%9<2L1}JM>AOct0fP8!}b8lEKej3hnwmoP40|HPe$J<0J!R zxSLRPh_HMx7zhms8)@d2l;qe*A&P16{7ZV>))zm-P-h+m*OdAYx1O!CJKV789zsuF z?hR#7v`sYL=_C&`iTo4HbLjbB&GHkNdNH#MvnV-~&Sm8;4!el9`wGyY1gO>+Sh{9xa%d4{&+)%gA zx)4v@yHCvh?|k7Mj9jgFPJN%gr;{G1auf0}lRr>-h@azLT#}6P%txMm((oUj*~mDs zKaLaV2@nGXvKiT2SGlHWHTuwa!;~SWFm~DNj62AgH)g8jXT^7{K6T0Qjk>TZr|ngT zMs|_TW7XKDtr^GAp}pNZC%Zt6BoHnL#5X2Hy;LuAhoBrT1B=`c-`Qw4A`u=cA};e& zm9|5yBq{{P!*$CYn5C?fE-5&vDIyTMU#S;;&xIR|qmFZh&6Ny$IUMyz1^P>Vlt5*} zU%3lrT_h(dL82oFzmq0}fhq^S3}BiO}Sy^($8@e zQv}m2z@G$ve-|t(@Y5mLk0Y2N1-uT4d~bz51%0(Cu-x0|ul9M-qT|aDmFdsm&MtAh zg=Y5Nh3+oWb{*J#J*>(n%ZVh)uamCSGy#h41VvUn>clVyB)tx_L{fsu%lCkFM;a$- zejlgS3?ryk4X0W`*@E|gKY5muAU5(>gptmZ&uCSP!m7V~AD#W@cUGp}PgRfsa?q@& zO}!$Q1l@gZQKTD+S(?Jv$2lQpRA;T?bdIe7GJahBqW7Mw6^>y?u(Th6=bq3CIQix?%P#= zjlt{cCuz4dvAov~e$O$Fy4|J}F{48snhbt6T7r) z?UQdG*!?RaTV^weW;Cy(a>(}-z0B>&H{>fl9~1N1S+LtJW`nzM%5yho0dPiML(L7< zo8)y_dqxCX1QbOwlPE;dOl#m>cav$08q?rY0j##y!qCO@nUu?svtBJ1PRk#8-bT9T zX1y1r!6|pK^a_=kQAJ10=)WjJS)N>xu5C5&jaP7B^|3Q18~2`|vG5gZ(DM6*N!Q;l zsAFDmySe|Dh;L15D$sT4kkz-e(Wf$dq;Pn#THQSv4QhPrSGC*IP*bcpBMCA{f!grU6bKc zr{i35KdD^D{vV`C7gDnlvHLub1_;r^V)PH zU3A&5bCJ=-DXgyH+2qra9MO7O0;^IpOJ^G=<}icq_Fs2eBhFU~3%Lz-E;^VSeNT-w zo9%?0A7O4x9fTw+#BV-`zw9)VCTweRU|frB6)46MDhV3Z`HFpPJCn|ksU#Cz)-O0M z^Y&hg^p3Owf=6A4U&v40{)g`MG(8a<_-DH1C7|FJK+BXG=O%>{S~zHilfJx6x5jDh zAalo9t^Cf;UFY4b@^i7|LVTTHhpvyayHwX1OWIl(nFw#M_vZ^W=HWbOc0Kb*L)XL# zE3sWthuaD+z2K8xVR1peIxFep*!AS>mHuSQ3$=^U#ffb8Dm3!aKfbmD25dgP!f^Li zPx1;*LuR{-s@}>;wl(Yfci7(9$u=1$0Ec_&k=r&ALu_H;lAV zeta1~s%#bL|AjrK&Bj^<`rM9iyS(M*+g|f!d-ngVidRb8lg-UYR!p`%-Yo`-rEXPw zUFuz!zkQ)pM8@=NQo>03D#Vp7H3RClqZ->Y=^0 zvcYJL4z-=g{Ne&Ix1&3$zfye+`5X+SJZ9;xz<(pyO=mrbV+wmv-q{#elV{KOs;QE? zg6-m_@lY)^-l6BG&*g8I&^$VxF#`B`;H%4?y+_MH5ci{-o*Y_h#y(mk}iwJl=d)$=DSh`_8N34)x1KhbkHMRD7UIR8f#OCB6TwZR9?A*Z>D`#!oKgji~! zi0wfIKPS`fh;0E1WNBzuDgTD|ETaZ_>e*XcEh%XQ=W%uY@ zKvbXL&Nlve#JPiB+RbR&V>B-L$%xS=8!bl7b`24=ej<5HU$WsJCj>Ng;`ZU?%!2 zkRc+ocOBVMmSyMlOmhFHUcU3-?skZWzt_bmnC4@1X=4P{CR`PVv0YAdHg0Y=vraQ@ zYuolSJ}TK`E{PS4pUTwFo-0jqk>18+TW)|px@r;5rop2i;_*; zET2?H(2vko9dlaVMsZ&Ux6TjN^^Ife-{P-tbe(N;;GrsL4ElAOdAhU2<;VGeZv(8W z-|kq&)IkygmOVeKAdhil+HN$Knk>2~FrmEU2aXD)n4r{RMfJvzem9jlm%6@?g30g7 z?U5z8>)_fBhdCZK^+E+Ocmx81(EG`yPv32LJk@a6@CQRCpz?Hd;!=ZY7KE3v4GUoH z{WeXt-|9$pzm0tqHzX6=wtB|(fvxT3LGIfv4JJJkhjzVEdzySYK50r)>*XUnOET>0 ztWQkgT2i1J(F|gYMmG?NflyRlG>EiTMDu{kv(I5RO!$pG|H=ZOv_~P91*zRg7G{a* zi(++VCa)`sK>`;c8DH@My-Y-~P;Jg>k6)wfw_5@>H zFL8Olb=ij1rNkFMDfC7sCzt9NcR?#hO6fOusCHW}K#T}J!3Y7Ozxg z=Vig8qExjt%dY6*@oWnsEfLR63?7UW&?l~pqdgyUjZQjO@4m%hoi8Eo#3=nOf-gX2 z8QdO?gZSV-BVao(jyCF32{RclZ3L z{>nex(+OwE^%1hD!#HEF{I+NXpqC;F#3!Gq$TsxbEKQi6_;U$%akpe+2boz<2*8Y3 z$aERb_^$yCRez7&(I5m1^~$yMLe_n?IH*w}kti)>4Y$5>NEX%>njEFn52kiqU6KL|L z0e6a0&QM+87d?1-tZqN#?U~p{PV8y*tZv}W`Cfn4LSlZL5gZNzm0EZLfl&63wE#W# z2ce<`bGsEOP#F`U&>5<}i+44rIBb8mM^))om%mODHK34O@7ec$y>?oG`1X2AWncL5vD{ zUoKGy^(Zc3x&LfFoDv0YJRIbx>ArISPj>e>jw|d#Ep&HE)?a)+j8nD0hjfn)8pQ|3 z)-H;2KrP1!O6N~adNV6d27{W5rm2a-Q)d46Md5Z;nc$JqJz5u}a61n1GyVKoX}^{L zj@nynF&xo>Jiq3eb`4|>fiKAdAYauY2C=qM{vK?s)3hFZ)Exu3N7&fhLP?MUd1apx z{t$zf<5)SwO|_u?r1qEUW1A$&+Gm%AO{gSk;ZB~~6|Y$jSA2g^fsR3E{Z7mmB1=-#F$ED2k?OUEof6;K zVY@w?)BP3<8Z6Fs;t;5{*QF0PDhEDBIQRJeD`Eq7`^xppGBL>W6-@%($U3&)ZdbW% z_CwOP(2Uz_-uR2Rf8GS=i^Xe9_oT*X?(pDmjf$vL@U z``7Z{y3C=KE6i{tZLsMVuMeZkJg^ zR;HYHPtyNm5Z%|2DPQ_YrfZ4MqL*$~u4xZjqfNBIly5x{PX+VC6)&@rMa9<${dq1s z^oIimAtCXPSp7m)@#gqmMKcYWJ@tjB#R?bT4he|rVRBk$1U-UQAon-eacw&FF zhf)F5^fr;hp0#EmSx;cKY05#E2V?=zNT%B%wgkbI!#AFmvgO*!R`bO?*jav?4eNtS zB-*3~e4+2ze97NWnlxF*CmJAMw=|Klrc}f*(Qt5*l*BX`9lQweaZapGZ3AaFdFa5i zX)0}o9X7gmqYsz=HsnMB;_kpnGiGU2Qsmy1B_T{bxym^cLYY$Z?lzPLaT}^Q9Jg(+ z3*8vgE9*u*3!0rTa)JDIj}y&&-dIJ&$e;sI_I@9G#7)U0t+{U8qS{CU%l6gf+*&CP z`G9r{bEsoY?*o_j@XZhAP zV@UzHKKfgvxpIM$z$;LG9IAWrZkxl3%VI|~`HsivnVrl@zK2!l8ClNIOM~epO2~|2 zTGt_%3FIpo1ES#Y?@%s)TabYQg@RPMmB0Evp^h3U|2#?sxRKo$JF^qEeXb^>R<+i} ztd%}6{G9o))a5su0ZZ3G;R_?~Yggc(!>xP}7v%0>NN4`s-ANJQpWW-Meqtl{RPydf zzsHQKDL-(BdO;5eHUgQZmo{+W^wdG(KyFp}bp4ml){GG8q@NVjw9`77fR#_dPigvd z$%Hu=d#x|gF9Wx>E$^eI!IuQbn+EGcG}uAgseRwZHeaLA8dHb;5C*aS$_1iAAM!r@ zPeK})81sm1?o)Gc^ZQvaZF|j)JkvZB+?CxVAO+XldEIF(0j`ji~$UWXFZeR?(#+CH#}cy~RBAEa!dI^9DbbKPO} z#p%=Jxe487t7*Ua*#g1#8guUq*rTM_$8+$;y#-r+HYD2x`jDNoGght-5|ptuc}3Dy zM2&#&)K39f$wY7Pv)&Sp9D4(A)hyQR-y&b$+lrHVIb7W=xcUA=gQraS6@R(bOmez2 zhY}3){7i4)++x{cVkyZKo6)#0{HkLm%687YgfBh3n|`n-C3OAZD0KbJigd|O+usSr zQBD<`y$51FS3Rp6VWL)OLT8!H9xH?{hn4Gu_8+R=*#W7_T)Krye#``~7RE9Ke@BkZEt# zv^Q-po%iFulAl_fJgth;x1DkwVwLMYtdAKaaKutvZB*U#Dh9ilY#tJfye-Fs7}|co zp3=l2Kpx5emFN1ORQRwvG`(pwyvX`iqRO=~vb|0YBk|=X`34hN*VtX@7gO!`9|Kq1 zA!gq8@cX?3a!evtn(NmmsZ2pm%g3_NEFK5qcHkNE-Sg|D;FiGXe1&D8Ac5B*Ryux+ z;W9SSb9|dfzAByW8o>BmE!k&#N3TU~LE1K z6#pG^mF(pP!(KP7M&~-W?CBmPe=Knas2KQxRZPnAw$t`KyD=t6yF&FmAIp0xk{KSs z4A(UZRhREJwJ*LW(oeZOqhZ>Q@%pm%f_sahS>WYucy61PMZ}crnF52ugVjQ{o$P>a z`J1SCBM{A=Qu^GSpmSx=Q0hdZVgsv z)Ykn83$YDm8_jg)Ees+K4$lf&evW(_hx=>FV9sXUlMG>7={nd&Wm?i^4baDaG0eK);86-Ye z)L`QN0puN$cA+UNWhsTnYbP}qiJzN(@tFBhNQzD3uNil`?<$Ff& zFz`vM=Vv1y&tJXy<_zK$vC9f(WcQnzNdii0BG3_JTzN$>*~x>_)nUP7GqPdt`~o#b zGlowOJXnyuhxIe&G*$T3^EUN#alOeBh7>$@t6Z2TN}T69%1 zt!@~TrN4^HZFo?JpDh)17^rNeF1pM9S;i;QW}h9H+(WVw()#*-u5 zJ5#(ppF8W?v@YkJwt;oT-8)fhY@eI@bW3d04V$I1(N3w}EIoJm7UDwU*}&CEI0Tlv zB`Ag&9e>DJ;m%$fdHoQ`or&H_w56|C$3OXH62&CN@+M>Gph<1H{N;qMN(@0Rzm8DV zfTzCcBL2%lYIV7N`4=aU_GAdkrYnuny}UNKO)un2idt0IBmoI5X&*0LDzAgGrEc^n ztytSN{JG7<$0lR?`e^TwS+r~Kq324|{qjTpp@-EA9B71nF1_;`8n(|=@pGhRV@R>*pY+f_ll$9&puCavsP#$4q) zf)#>}uQnD2l&!`k(NGMwx~Z~JxlH?Y_ix(-4~RV#Uk61Yyms?!Q+J*4mYKJ$kLTOE zFTymN`AMVj;as5qJB^|sA;6P8JgXw>kngtdLaxqH*~PLDgLP>oXy}=;-m1dClqY^n zYBpJiV^{6cYR>coZBU=VQ(o!qpH-E5n)xH=p64>9Q`&iM<1hN`lgceDGn%Z@8}D;w zVYFy>mU(ke*!)EkJ!N=_@( z0N52|NTjLfLvdFA?Gti3{~gnKq6;!2hBY9^SKe{rJ|j4`{mWT7CP!7pQoEnys;IG6 zbGZFGZ0)wsWG8|NpLEk@qpe_>w(cK09mwf13>S$b?zh;2yZiQf0qe?)PYHcGfFqiu z3GFAxZtDU*`0xB62$x_WHt6`-5D--`9NUdP3`rhP9F*Gc76JR)=~!XtaS(rYc;gu3 zk#JgQ2)#wFDsb$EayOJC4NRhR>GlKV7PNBSc%g-hrWHVFR3q%mZo5#XE!mr%_iefd z_B+Jf^1k+blMg@@qiEvoyT8dfT^D=$3XL0cl9ub{q87#ZELvn9Rj7Rl7BbIKA#Xua zU-jDDVKwmLVhPk7?!aii86+gt7A&wVAV<_3g%YPH_RzN5i7kQaWX`~wKar|mI}F8b z6T~cZtu4I4Z%U=LQVW}ZVnt4~19VPJQ;Us#_h0DtV^gU>GjYp?O`L3ket0K?)bd?caMA75z%SKz zRxtzBWpP|1v-4`x^NVM^A42s9PnX_Z2xA{BW?F!>@YcgC4Ge5-gTAO?gHx3WNqqw} zAKFL{(gbVVuCw5-E%`YrKQ8D;PS#`ga{s2~VbPeij$m&k*b?56u2(3Jt7zTx06Wo= zGAS$+mVP{ojfI!a;1!iSc1ab^#8sXLRX_aZiXC1to*8~9wR$58#jgV=r3r`9E8)3c zUKz70A4=ozlNcn(eoF{CuhMPgkG@gqy{OQ+eu6O0g?wCTH(;DF+Kg`Ap5#0WIYM6t zvexEK!Ia3Es})Nu3Zk89uR%Fi|62?WRbe=*IQ4unr@cFe)~f@)2OHzvBq5i9Y|(9; zaMD_&CXtXKV6=`cJYY}{0Q00?Yy#;>KAilk@6@RBG(5Wm7iDEP#OUw}7k=Ufv>@) z#OrGgOGvqLtU>IdO$1UN#H1}hsG^`Y)n1)DjdGh;(S+yCMbRhi`U96CBK=C&H8~CD z0EF|u8q2Yk9;kvp6aG~T83Tql0t5iRRHdkXiwfI=`x@}wVz0t(m}nri|31BCb(EHA zxqW#}}iK#CY`dx?e-$0%q(z_VQjHkTi(4OwrV z7aI zJ$B(l&@@%HzLZ&;xMa}cm4Aq7zB=f4(G`QliWt*N^_2{d($5sV!)f0;>Dk^0K!XZR z#NSmE0GMVaOId54yt<)5n%*v6R%d!pC)IIl?Cex}p#pv$c@C2B)a5%3dhGV!(<}s~ z#%NLX0#(+yxe*ElaKB6y|(R-3wY-c>}T&I<~3z-GtB+hz=ciz#K| z!%>rPw2e_|QmkOgY;H5FbA$Cd#$4v_W;T+`B}2LU5^U$J;G#c(a+#7XNt=!yUn-b{ z=Mlzmcqt#7`uf=yteH5D+Zlx20bI6LSpgk)+#b-}Hmbq%56m6kD3UvH&|Bh>X@<)* zH8reiO|I6%7?!DH@3TtQvvlV+OBE9@>7~Liyl=J3T}-aJ^?qhs0!0+kZVO z#77U=#NeX{%37jmdDH)me?eMFWsxLCpIVHakrpVN`(J6l_RwTev>Fx$u25HC5DOXe3mx=z>l8 zL~qY5^T_v?*9sq#N$ko$-J8GJ#K))QLDd0r$CpXLdz0RNe@7(7z%AU^q({Px-jjn6 z9{5qL>}-h(eSmx^AiEw@>s*6<=Wld5&~^6vk1Jx7;>*@Olhk2YSzxTLM&@3MWcB+=bz?t+ZUS=(;bbdX zcDDKcz~PpvnF2nfY08D49+npz*;Be{=UVl_zy)URPwxR5XxHU-O zuZ_#MOSWih>nl%nF$5t@@<+nIjhwv~*NxwV#9u1IvYA*o3~aXa;K8+mo{^8yt++9Q zq5xYSwnmJv(fz-~b{1J7b1K|Wb-^*(1QaI(2I_KKi%2=f%~ZLAIpJh9ut6{4VZYG! zO}e^~;rybxjVB1*sFq;c=-}>VO_|V!(pau>S7LM~i;nM2HE(B zf1nWi6uVe3EdT2}>i%Q2^Y)T~vNx`HH;G<*u&)W*){7OcXka05QtZAjH)>pB34>r8 zDp;8jtJ=S=5dl9KFdr1U&=Qmu2@Qk+$&4=|IDn@0ybE44j^LD>QL?ph z8nW8E8sXuGPa7U(>px2PL}XseLFm5fQmi3zgr}=gugH83fNnEk!@Z^ki&b=N@@HcmrT{%Y~gwVtS>KH zFQ=ziovU-vxfCm)H?SYaQA(qE#O*IrLRA+nw%TLB9>pei7emcA!)2hp2-?2-<6fwQ zuW(YReHDWsP?bx^C~i>ITRbto9V%f=^1M&oT?VhmYC4ecJp1m%g*MDFxbxS0#8PPd zBQVcYGo^Mg0aHKs_{rNJ8O=5Hb1(ET%Bfl{N$O7zBR%k1HaYoVDu}^mJmkou=L29G zcNc{Ap81qD#E5D37K!QL3Q#<%R^pElPvVmQ2+tTk(YhKS2>^+ms~Hm|@ISymWrP2J z`DY#+RMQ-;f@Pux&((y7YnBA6ot}4x8P>1Ffw1PvA2_CjF;-p;Ht0prXUB?MuO%Lt zWvh2--`1H_f{37A(_in%A*rc4%b9siPsdy#l}`Fwrs7ftoCerZxW7ryvZl2HPQKhVMwJ7(@#^`5Wo>2hD8ad+#EaG2De@SqAT3LtJCpQkwbCBmZMk)#cx4AopvH&r+V(GU_93^HnCabSvwt*>abOtiYpR)IK8!4oBPfIn~? z)r;xNm`v+WqOj^3O^RM!LQ2JzyoTA_#dua9!YoZK6%P$G6QKxgybAa@A1>i<(br{j za(!aQhu@+$0mX0|u%8H)e?T-7_Y6-R4594H)%}iJ;{ikjP#@R1Q2Us>SAo245%H83 zQc403mTm_nVq`V@+I?pP0B1@sd&DQzr9|j&cV3w+pwIPTWT^E~%xO-J8>b4uk*5uI zi%*{IF7ZV`AqPHCU>4yO`6`tlFbdzJlQ znuhVk8Od$$FEi_gUR$~>8!ggAR7q_0eYnMHn7S8*Rei<6wrN-Y=YdTzSPVvj6`GhF z`L8%|hm4wSK5~~_KVQ!c^}cS}b{dLAHQ7*X5ny$4u50L!P(tB{50Rn=*+8GtZMBPo zl?L`sXQUT7h@pWL7$ramulqiNqKD2N!t7h{*D&BDgf+N_l^+l7C<4^?9zfy?y+9|` zyC?)-JGo?HzP_h(^d_?2=&|zkOCs6Q9t)}-D5B?(e0-IYTV?*ty zeqC1Q&}`sNYUG^x`Mt-U>AL#R$i9dq6nHG|5GcZ6yVI~AYFEOi=YLsaXQSTFheA(qI884{-Q|>PmAtro{1#kO&b=MPApwDx`IG zG(kV8+)ecU^>BIJ^q3#48ljv1g2X^YZIS9R`c{l z2p6^9&j5PpRfqc9ZeqU+s;zc@N0v^`Ab~NrbFRJdd|0>(?8wOATAY26F5~-TjHv-l zS*6r2rudqq;LE2w^PrM2_v+$ZO}YG|&s6tOxSB1(Vnm%%E^dPD;3XSz_<|xr`yS}s z`qkkia;C`Htq&m6@|2!uw8u$6(_u!+<=ZnC+_KH;URWC_)30Nvd6X-xEX4s=#t;Tp ze2xTxI#5skJ@&UuY=bbUP}*HO_Y}zRYxycrERopZPN_~4b;~r2-gb#S z83KaFFU!c11cTt0XJhuq5Q%x@dLHv(=gx(`Xp8Xw>AJ0tnq^*|y=4Fa&TU1sIN05? z+N;>_2~7ORumS{k}{*P=6mu1dC_$-*Epzpa0;Mh~z9 z024R@1QM8FgJ6JsjPt1Fx;lP^c`u~U>$#r)!Ih>bI^T-VSt{?vqz0%um_Ma&$g2&* z{|~o5&D`j*h_ZaW_UJ06L~Ue*az;mkGBlqcY={kNzH*7Mm{Uc*iY z$Ts3VVHxL->T*A(+5HSb*_KXyyAW9O5f0new7EGOgTE9#0}rHZ+!4Qdu<{rT&g-GTvvMKS!jqqV3Gx|NXM;+0}Sg-vbkSFdB|Ueqo8#p zv3zb;{#{X5U_a%tUs82Nn$!vD4zAzO%MD{4&FB{phlc!En+?g&ddLTDa6&R@WwL4x zKA@!L%{~$NBDv@&JB7?t)U9cr)CT^i1sG4&YS8Xg zzzhZe!HA-5@>ess3Ya^$#UFKb*$tI9Wp;BrR&SD_jln+pwHMrS$c2M8kUG-=CpRKv z5ib^SOSvGSQFVkBTJRoew*O`Nhv^aI54=BQrEW2TaHe3pGQGWI_ zHZVTJ9z20BOxV@_>yRNAY&`ha1`~oUKnvjj_(Rq-N;=`=-gH+w)Va7kc_!c!RUk(D`tK^wOno~FZYnBD z#ZO}tkoJ7=DPqqS9QT&Ot4@Na0m3{gvB4X`ph~vR_k9b0?8MofFAOSy);!! zon_+RH5WXw+I%6EHy^%LLkzV@yP88i7*AN(`tt5>Fz_2e4s(wDpjku#*s#Cj@wZYIc7x=G;bX*AXG7IQ!DJ^74%S@HenQ#@CF0C!E5s>TwBY&2 zS^Q}4RJ76_30)fh3|p3S;eKLzIA!G8ZIOEd(z>u#Fm&N^`3Y+e(R8$wm<#GJb}P_e zLO73Nx?L5ftgzC@MevL8S7I{WLB;&u%zvx!ubN=Pu+(?k+sPMM@lw3Zyp3na<>(W7 zq|G|C$Qm24ZYn}#_*d*#tL#2V%Pr%Qr{*Frn^@loS>_M|ISvfJ76#*w6=;C-@cits zds9-@%?K8Sx73>Eel#EGA9emLanaJC?=Uj3@S^=9{EZZZ@AcOfd%&|b?YkleA^|3A zc#~%4ZqOGPoRfpbO32K~OA@i0J*DIO+psVE`_(Y%y`F2xO=$q-d!XZ91y7s;M`~3z zoS4CTd^HwxrCTh)KveUSsBY6^zIj=N_DKvc9ei>hHU^nyC^!e#Ge4S~zp%00Tf6cX zT`$`vIPW4y92Fj@+nW_~i9Y6VMm`3yQXzx4Tk#?xP2H__nDcEEbw`Hw4ePx%Bm({2 z@wI1nXVTQQ4G^kR+ywxw!q{xw*gH@^3cL20=xV~des{9|sWuTJ%HBwiloDF4si@33 z9q>oX>HjhJ)o)RK(cUw33J6F^N_QhlOGtM~gVG(+%%GH{7<9KZBAwFG4bn(=cg(x_ z-h2Ot_wgqm;Ow(|txv3T=E8RI(O*1du^X=FhvoO4WRZNoMjGOCe0}9RXIC%9@G4wf$I!gZorIWRH5ceWoTY#%PYD z{jkWPwIr>Da}_`*fI!%t&VqJzN?6-KF$oA{i6BN;!?{=whhEmb7opBx$?rD#%JN~D zLrX3?#-Dc&gzrQi{rluWs~_7!w#4iqi-*@7U5l2NTW1>r02yZt!5_B5+O$iO`*jTu zi)29o7X?6zpA?;sn*y#mLFnEtK|J8a+{fw9p`X1#n2Rca`%>A7dUNd1vu{g|BET-Q zEE8&ddqRu_8D*OvMUwTGIFN!6{7`9l*@(wxe8;dJlO6<V@NFHi=^NQa`_dV-9FY zBCaJ~?V?GC@h>foH?R7vlh5w!9j#H*InSX2lJa(qe5yf&q=lNVUC9e1u6sWLB zH-N{Ho%ECS!4J1XRnN5Ff+e&=$9srCg&1l+kpL|prtHza_f7N{Kk)doIEUdtm~wvD zT(x5QZKQ@%UMTp&p3`M(E!q@^tt$ZqZ zO+MPbWM{&1A`XyBJs9Sx1)>R%cr!5nbJ=sh!I{MoGxfQG&znYAXH&N~=vrUOyFMt2 zx70DL1t5j7B=B$eK+ux=38E`ah%)*!Rr+6ir#sNAE2|GUET?{cR!{dj*xBnV^dKXlJw)mzAuS0M%{LZ_^T4H=VjsKS3 z7~p)pVSiuIu!@>SVM$Z?@hN9GScM^<`LpC!KmI(au8K4(?x%T|c0I6uVdGrYa!co4 zUDD9ak(Hj;{k+Riq~`^i+0h|%_Hl!hB(-cx3$*Sssg+9SvNk}yVrS~Vf-GdF_Vo9C!-yvDne@{F*D6o1y2YEPnc;D>K(RYv!k;$hADldUJ;HG zlNSQhyP|hetsyIejuV_AgOSyK!xJ3%?v?2LaQthUx>xM4?4_Zj=eg^tj+-1Y*`n8* zE`_Q#hw=z;w)_m=AAv^DK-iI5=a}=j%SXXq2uo9kJl`{+ZXdole1R+WhaWUhwDmqV zvT%j(bCbi81pVi^EGFRQJK#%`Yf-jP{fUPK;icKqCw=Yzai4lyoBnf32HvZb`?CB@ zCS-Jg%4(kaK;P3neyt+$O6m0?=xXD~u0VXLNL1$kO7f@z3;+~_@y0-fJXGij{r~g7 zL~nf{L2qAbD9FuX(vFHe{)dvrC6xjI*|3oRsXDVv_5umOU`>t`|5Iz``Me7npwl4i zAxyoc!^1h~z-bTC$5x>^Y)>15l=GaqVu|g9jhL6kUqli|Vh2b&!$ytWt;2 zx`0*IO;%O%!MiK>_FpHT`~TImd5}~29y;6}H;HF#omjx>Aed=vU);dbNqH-&&j8eG z3U&GE%-^#)C4nv(XXW?=#Q)hylh*Lg*43{rN1)VBYBL2N-KhC8h$7Bvi_}E-SwVDT zI?yom??<+Wh%WsGP%8<4d@y5t8)yY6iB;5cOqZ>pE#5(!#erDF%JNq@{&vnBvnpSF zRD|PYQEzGgshUp*ct;&=_Riz701CSW|0i6Oh-XriW|}ywf50#R6WkxnC(5;t06F(A z7=7#8Hg=iRS+H&{j~!^p#}4EK58~0EGlz27*nuDlv%9}#7kug? zFRDv~^cDhG0yKW0P}y%djSCne%m1={q-&9OfmM8U!Z+=F$RI%=NJ~@o-l)!04=qa# z*e2P#!rAW0-MS3{BDoL-M7|hM33XDfnC)SF2rpm-jlNz%#!K+yiFwjs8t#Eosw?G_Ehy z<;2`%&cIXmU_KPKxGj0e>mwF#t=Q|m1cCFHmo6|2CC7&=*L`k_(*USuUo>*x+^6u| z>%=J~g(_FVaMEXy|Nce&OUyGA-vJjfp@*5Gz;T#?3~;Gg(P=9l-}S#sr*co=Vkr}- zLG$k#s1wd>9z+{+nl(>=s>t#Ugp_+B7fc@z(lK+KT>e+_AhXh;dg{NMGCuFj7dV~G zPUc`8z5Xq~o0d-jH!LFdAO2 zU3)C)Q3SSl*M( z3NDWs9^B+-tF@++t{}vIOAQX)9T$MicO{A|=wk>!7~3x{%x7`0F z0Wa>I)qIi*6n)`<{(p#pK%E|U605eqSB2sWZ_|oI1;7f)22Z-h9OHqtoqh|e*^aPm z6UxG=vA@v;K5lG%QvwGfD19G3V3&C%zpuS!artGNn(a^|xEOjZkxkCa>HedJZck@P zqalgKywxyB!{)XIR*naJp`Sp7n>|Zub8`HCKXWemgrQ@#6T|1A#Oj~3k-mss- zL>ao)_NUUNK_LlzP8njPnYu{fE*y1jLF5=Eqi?4H03jOZ-|YYqv+fvo#h`AnTW-t2 zsh|GfEJy-DFwC*8oDg?|l6UAV>X3n<)QelE*%e$VD za|@~_VXIQfIvn6pu#?wb^8yap2av$#b+NGp&NK7X8Qc}tapj=qSmOE55`2t#W;VCG z&x*b7boHw8(N4Nm+voWl1FiJ_U2SUf!62^-=p^WN8vC?JA1X!tZ}p(0*hIYzwl^t% zjU!I{WVBGr_j;12-+NIv?yJ`pxzawlE>*sK4rqb$tLyZC)$vmWl+?LeG7?YZ9ADo* zP=1e_;Ot~8EkHQ%P^hPgBB0Y;x){wQ@M!u_$ERKgFs<2Gh60@V4TD+d!*&J2hpcfb zbx_ZtTGail4mzZw(km!EeYIb$qQDhuX)!+Z4>{sNu>nBehY>Uh6Ek_D&d9EgcQTi- zp+O9JG#I}o@&|+FS2w6CrguMzh?wxTtvF6H;BoRZCx9Ztc#E4Hg&n<@O~$2EFegzz z7k_wQNdJV~eaw9FfZXZ#lQ_ICPHU!${zZl;7_K+prOA7R`NODJKvhG4ojXcN+Fwm=KJQ6B;+$EDw0B1WG`?be&PKO5(kxo&fi3m zrZsst6W!EBJSypSYyNdYPj~I6S&Q1vR1S>fApZ{)a6_m{jZlEMw7>6Cb?KW71(n2- zzsFL1X(`8p{CV+(=l(&fGQ-vE#CI=sfnk*N+nMDOG3$GCSkU_tf$&dHM0x#IisZ3D z0*|En+mI4d7WxWSR@IX4@2BAN4xVgb)e}3{nAQ!8J2yoc^LzD+Rq&$4c@qx2nZ5VH zaJ-UIk=o8Tfe^a!q)%!iD3;}RNLU|HqO&GSH&TT8 zF8Z$;#qlmSECXbERj+?5+|D%hXvZZ8UHY3cT%QNbmPp@-U!JX8I^4wf3hiBfT192Q z8E7&4?S}U(p!!+%Mcw;-*B~~jB~jMTT4RIaL+EtMUonrQ(a8nHf^y45%rGzbx>|`! z2BBh>0fVDf3A`#k0g2zQQ)6`H>s`JyV_zc#?zW7^#r(N+4jlS7{bjqxP4|l5EzQdx zrqP=o97y0(Ng~0SnW>WBTgi5bk4$_DiNZ6RD2>u7GYJYglVUF#i9gswTg?B2(()cJ z-P|jMMTnNUqsbnAnr!~mgqNMVDMEPY9x^NLHFkDW!pzZ~HyaQZ+v}`}(j&`&M@OT^ zi!`)wX(~H$KO3{(y=xVB#yRfS|H63GDt{VLq_8~K~UM$=Dek5A3--|VBs?0ZbrgOpa z78g$=HnQpMp)ViR-F(n;tc>If%5BuSsxv09%c%5*fJNe5P=&>+ZePDpLY22H zZB5hT;@#U@-TgVZ%#e7}zcYiNH(crQYn8t!Zki(l#DyYgxSQF+Ie3@a7h)b|pD!M~ z>ic6+x_Hs{Nyz+n(2g>}XDz6llYng`6D3%Q-Tj?7J6I`g)4@`)CDpg?* zSWYkBN=?1Xw^~gJ_%w8!sNcmF1s($X&`RVud7viM1P|uA!$`(!+21~6Rp_g@&O4x6 zqA&{+EZY{s_<#ag7LF)O*?V!YIDJbV*L*B(+p}0}t>|shV*Vr`m+$r`Q_{Q06)aks zETk;qNGhKY$II&ON9KPf(`y4#CxrY-z&VxT8<(WlFm@IYVd6Dz-};SF!6Gd4@m=NN z4pmFoV9!E;ST|b@yb2>M^2cwZuNzRXk5+%2QJ};!y@ueYJ!Yran@P40 zmr-89`}0*RfvmcUB<$+g?s6|j8Z}{}yk`WS7yV4{en!=ZQkQDn z@T><6@y;Oq*Q6E33YUz{aKw~TdUWE!kpdeEPT-HI+2cjg;3n#NMRfM`X2|cKBQsbZ z&c|_OpkX3l!yOXUX!c}LI_TZDWp}w19qHmYnGktkmMwl+8OmN(m8*B=(@aQQIcJ+b z{>>>edWx;vZ6xLo{jg@iKctaH_L(93%O^d9>N9z}=|%Hf2X%7wXCsPly0AyFpu*%C zu$H*n3-VtnuDYbP+v^AVV-VTx5Dlnw01Z^at1G2K(~$TJL;Z^iz22*QzWZ{Ls6VLQ zR1^U(-?!7{b1vzYH@|yXT|?>Y(XTB88&X0(eLGc9OKcZ4P(Bk6PGBr@23O?=gl_0Fghr&@VFEF#n^s4 z;)*@qsywah#91#oJ zoZj;WTm}(6tBvKVg7+HDi_DE|N-JDr$!5YuzHyFvf)?G_LhFbt z?(Fb|(cSKDcHkH~qaPbd#;_J9Y)^VM^ioU(3`%{)axock4KQ$sCga}~OpL~kJpMb} ze&1<*i^>v9k$ygJ5rAs95HO-WZdr$KU&uMF#fm|HjEo7PJE%Ro*S)p$f8+F6Z0sw4 z+#Bt0S$vGVThfpdwrOXtP`2!!>=Ck^Q5V#z?xv}FBY2jZCeh=h69^gG+jqq)x&DYj z#-Wv1f5h@7Dd@;{*Pjmq^%}I2t5}aA(hGG>^$SPU$`u8mOy3b>)Ns+NaxcpVI)+q? z*Yx18?gpb2!0Rvd^g=1VoBp-dU4Xr*$Rp{~ej-o~V{q{FgpPSbkWOjdveb?r?wSX%NACsKMFa$ZNH$)jccN znI(&yXxt>VdtNZG5dF;hYPB}o|5YHXG^-u&6MK~9Cl9lhz&Gf9*`mHp z6F+*|ZH0Y@C*`Ek#mzb#*&9t5`t-nnBr?G`N3?7%#w=0Y`}@k&-}42rz1wR-yBXP! zu*RG`6$7#9HLpeQc-0}ja)Ka(huLaZJ~FX5QGw`uq?79D?=H?n(8BGt1{t}Y3$lWB zBNbRkL@;dPD6A(q@KM-J7nu|c&wCp! zoU$*9X)tl)+_77jCVOEzA$!DCE0gHCUa_};@lVs)*=0k>IQp|0WF8$lx>g%ONUYJV zaXN&DA0sa6{VyYtk<7DLT~n1GKTGiNXt>ZA3<=uqmS^#)h@~=ea5(ZUrd|Hw3pQRX zLUM-dTomfWnYu`y*IH`L;AK|>MKdv$mQ6@|oT?>!OM6B|R83=j)9<|FR1EhTN=jG< z^GB1Q>YT~KtT7#&{(%vLMLRP8DQ=8MWZ~HEm)~RLr2(%kuAn&8=oR}5??`sCk%RT$ z;f1GHur$jI408xLqK>7?=ax@f&UH@6nc5Ia=eHnQ>BIJV_9#DPyUEe)8P}b<^uo^5&pwS~SN|je z&ED0Me9}4?4H>h>rNI=OeOz~b2;+>}4Y%9h)cjSPaE%LsB3A)isosy|EC3@*<}d}$iajYWz+e76+UN;581eA8AylWLIrU) zMtjhaSC?$;KyfQoKXu7X0AkYlJtX%`!Jv7s+b7gjlulLmMh-2YxiWSh5pma%^D z%<=Yw=$?9G7&(my_9;?4mI1VY8k_)yyr#-^Awcf-7c41!6%z?Me|q z=r$rxUzS0+4|}rRgPU!WJus*4XLl`e(JaD~pW#@<7>k++sYmE#%_o%-pu$`;Eu%yG z2Cl-9Br3a`Z3ikg$ZHmj3_3!<)9gX)-6O2>Y8#Ep=UZvpd5R)hu&;#{?{9O7pI>#K zu#o5J-V=XO;lr2JQW)#WVy5j$B<(zLrSGeZnbv_#LD=Ubr$ z`GTtx1rTS1_v*3xY9T^|88Ve#vaB3&BSJBz)YbdALNcJmK(H;{g=O{Ahfr{RxV=$n zz|hC$gQs}@&^LH&|FiUw1X9N9=ga1gTry*8 z6C^XqKYPaTT8Qwo#wxL%S4tPO?ds_cuW0t?_yuEWmg9F~V#4WuD-*^_qW_IE#{A~9 z@%;~n9j25iJH8B&$U{by)eNyEO_4QVH=9ASiivP@uoX{f=ljE{GW9C#{jar4rR)6C zZQC-{KpOVX5Yo3wrQ`=^EICVsR(FTTT8U<60v=#Z@&@A z1o%7&GCbY&w1CV<#?wxV^vW~U4_UNRo9uZu088xpvi)_}1UWVptY&oiT@z+3#cK!= zCK<^nF^0-)hU_Uu$Xq4W>lSO}Qfpz#_K!D0r`L#)=xVKx|0FB2C_X+`tR;Ye)+tth zK)*zFC&MN-6nue-&Zd~x<~#v@CMh<_QQ#~X#mNwAOcPwLvZMTX6iHp$>=hXCuopNU zXd%RtT9hM@PMf|NXKm%)UoW^`WH?-zk~H7;VgV^!oXb83h&$)U!~Tk!kB3?Nn<@`4~T(tX)9D+`jo1AtQmlP!;XWxG?>Fsy*omlVWhGf`v8-SS+snhrO z0sU7O=;c4W^F0h#1;@PKjuvEO@3C{ltr2sdfW3?{@U5Ad+?oR}DVqaiz>t!6dmt{q zndc-bkm6R$_0BxcRr}uWGua?{I*o1t;(+IIyM7|?2*_5di1)Jki7e~Z`pTaZLyn|F z&+s0J^b)4#KP?H@BT2&9bvSLd61DKU#p5y^2;^8VgvmQpi8GSAaU4ot zij2KRmVm3A5?)ku{28OPV+Mvq?67Xwn2K8e5a+kLHk|35te=fyM6o&&D+}zwwxl?n zwUTD00jP&8a1$6O)y-*gmJmnpt0}2`lGTEz_xs)1M6$y+9Y0j4*tqBfuYFDF$XEEf zL$_9FyFx$leC^f}EEVqfbVBLzWo$$T@Ug1RpRime`A9}eUNL27G&HHP7)e2*= zh*KMmQ@j-Ml>RcuHGliit=K?$n87F2hAaOWH)>=enTEA#&1Am!;VTYNhb^sva$D{` zl#u>ZZX;jQnCaCL%AZ$td^W}XM;!>8md&dFGYOa>(KT z0#PJlm%89@Mj=$%qxtPyz6meQULdvio=Qnv)Eo7fY6H~}iRj?+rlwyy-0 zjw7M0`iRt@CowT?m@DCw%)d z(nar)k`orCDs7vQkB{}LSf5;wc=*e-BOPfRoh34;Hzs5aSUy6+ZJZR9+)5?XUu6vY+N5xslbuwb@3DnP1=uQroe=?*7>Q5Kf1FM^ zDRHq|(Dkz)|B-#?y&omMG?<~6hp0PZCUet9FQ;++>Y@MEV3l#o1M;50G%{X2-7Gp; zB(JMkIIG%~t}Q!-Y{)AMc_ijd3Ez{`HYHGnIkg`HniHb=bA&64CGr*SV@M~`Sl*~G zt%kq?&L5=7s*babD`Q2&@?4Mj6^Dc7bDe>u9Xbh-K{;~B=fZ!%BSc*?g_lD2aiMCw zOA8sKSFk{r(qi41(Xi>1=c~{6w5TI991W#~Zf$MH8S(Tqh3;bFMRsU)=ft-JyJy1# zP>&z?DFwPL7|$r>FV=mdf}rvS$G*k`2YZ{cs=tHJ6F~nUB|NFkb*Nc!*f8dy%#8h> z$D^*ilJ3+_*T~IC;mdQ8UeQQONW=FLsr8UqbFfM%MgwtUN%FzLdtQp;G<|#wQdk%v z2obsXfD2!{I}C1moX^ zimIYXzl6U8DdO>Dn_hDk$! zDsYsS-e-`)+pW}5>> zxhgbHqPUQamyQhBkbEw`aP-m!Z93Q!))NM9aC^D-B zuZ@wIo&|Ei3llyln_YW4I>!)ZD37_X+;tODPM=19Ly%ClxO~kLpyJLR)$jhp*heIr zlgAzQ;UgVH<^#90BWq<;M6-yvK^pmHuuz@}4i#)ern>g(3*7!>?)Iu9bogOGAGhaRmEQS=N%x1omQ~xuhSDbD*bxB{#WOy?;wPW(wyiN;)IXlS5_s4GCOD*7)CbVVb5wGsLNP&6^Uy8TPhz=ZCnk0NzgK z$hV?jHNW;e0Q-QM#V4lmhbV>N8EI}YF$MXbkC zR^U4*PnOkbo8DU-$~XX$Ab;oe06?)KZIb+b5LD!$5zUq~3bi7Fzu)^3)F9~TxCpwURTdZMLTk(FXB|D~XICsyEe2l(*F63!e=aqvV<5Ggh>LpL^M)L$I&*` zA&;^XRhoUbFT(eU!OmR?oVTOodQY$Cb*5BYfAmsk=w}$)07c=>kx8Wv4(ke60W*i( z$`n=G2h1tE&g730C~wVg35`%8x%k181-nTH*Le+9=w||J6)h8k@ zY;BAv&Y30pwn(Y|A{3{(^`9Tf+TTRLq$S84$5Du~)%E$8<0NBG4kE%wU5nGMjQ9+A|IL>B1%i255| zo#3$-_Iko=Je1L{@-@S}$lkIxk1>_3|B)xX{CbdrGJNacr$2O#?_KOIn z{(Cq)LwCKHgxHJwVIj`N5db`1@?GNP@MSTK-1BAWD?2UjvX?$D6+ zqO}~w15Lw26>@aJ246(i$x3KZPG>=C^g^(e)*(kQ_PhStd&q6VQ+Li@r)5QDyF?u! z2go2ezCf6zLEb{!W{oBn*;P7w|Mw`MYNsXCYlEc-!hGcIQ^BK?wlbG6BcwqV-Q|s- zSRR=mqvX3j;<>d(a8*qZYPuHA2pStlg0ssRk#nWzms=T8Oh}$=(hfpPvMW90$dv|hjZu+0%hu95SO&WyvG3zB zZMzY|vHV1Zd3af~nTL?pWDlQBzss!FixrE=9HxtuUVJzZq#0V0`CAi9-3n%%xxm<% z<_4T}Z(j(mV&vo@dBub~(}Z;yz``VK*1LFJDhOvSoR*I~19D}=*~h*dGod(^mAW-$ znK)szJFR|2Z9D7>(L8_t&SJ=vKpJO8^!e1&fWMa%s6L}^BTj*Kn!J?^DCX&D9Z5Cm z-EJkmr?+1B*+PKhsTKE|g*R|B*1vGM>7srUe&0Xba^LVZ;G=OU*zZwX;@g)m>+N^G zzJZ#~9JZ(u@&|AvEw9;zNFO|OA?P>GrJU)7LyXzmKNxa1B@44*6E{L6b9fV9u+)UT zR3A6W$v9u8o6Ya8k>w-56>LGEz3;#?>;Cl0n*a&4o*?=j3lLLmo*|3-dplQd?VASc z=89c1gOzMj1Q@43vSwb{1vihY^=|H!MFa+awfq-Yzl#}&8(L!lp7Gq}-eN}&Doj@P zH$C}-@Jim>+`FHmyG}g!Vg?H)F(_WC=LIDxVOCCF!z0yz1~k0mTI3&^=&SERQjFC{p`8<=Ko6BzMtlec z5y7)j$nRxXh|vkPTjSzSN(2cYHcL0QerdtgOLkmp2Tyr3r;rPle3VP(*ZX z*11tvDlt{cy-XnB*j%~$LnqO)p}|kXfRv$7#8=4r#8{ND$g0kOYzXrRM3mzJqyS-l zKn50+J7Q}Kgg$RuHPU5sw)cD?025_B_j3TC?*p>pKR1RF)R(M|uJ1;bi19jWC=P(i z3-QSZZOf-k;dD;i8-a=of^*ryWW?KRbAH2nhW{on;U;}?Dtra2+_YN*FT+(r75#9Z zem}gwz1`&c{ooMog7?p%_kDHTR9n{e0Rx8oXc}TBytiGoe_℞r54XW**NVt1T4R z^1oRjTJl8n+QrRVzIx7QQGair`@D1Njx|Pb>508yYU>~DefSPgtK!U8cQaVJ`PucI zSYJ5I_)CXLfh0sMj8P&iTUbh@4Zd73YSTQYvaLoQobTL1!ib+-!C0Qj9>+K&CqX^@V@GqgtejtIuXSkKMvz*i$`J_*ZN< z1uHQd9k>imm;mFKJP7F)bzojgfVqTBN~eU#NT3Xti0zXbNzTTgw3GO@`}ZpcNA7Yp z3$XUC&~iC;MA7Sm{Bd-daiTvTzA@_+5IYk*s~MZ0bwTkJf3fSe*Z>T|OAHpR+7p|( zPF_yN9E8Q~InGrs1hJfHVCE7Uu3%;-%TuB+yZwfz8^<0i2u8M;j9~W=v78o?dh4!V zkbRJ7=9#A;6k_U`rK4qFahrvODCq6r6`% z+G7o0BDcOu_iR3XtI2Nb9?Ng5*5Rob+sWjk+=(BIj0yh@00+P-cPH!OuyrHzJwcK$ zo!4?)cZLq#{Jh{q`h{&n=S|gTa)T&e3YHsoJx;LNNK{;9&{}87Cz|QgV_{x1(x%7M)r|+YFZGqAU(O7dAJK9wE`EQrRVL7>u2iueFBXr)dkIIA_@HbdK-axybGe$w~U`rg4zQ#t4tF zsW*1dnrOh>Yx@eCosaeLtLpLIz7c4&*-c!u8ayEhu10-(iwy2w*d<8M^H1(&w8B`z z11xlSq>1?HA!x{S2L;?B3OX}Bt{-)N`DnZZ!zdEyPQ zQmteVAXV_~%^(Zk9`I#Dl!|HMUpXzZYz4zV5N`N#<*+jbEvFxii-K>1WKSH<+VSOV zM(Q7NVOAk=-$TmzMHaQ?daKZlfBGOI|LXA254B9wK)LmXpFth%pnw(crxeE(`Qf?5 zR?ZB86q%U~=QWU>6bx|h=$|!mqU?8qJrI_@cX&OH-m$9Ko%~Nuuvm7Yk339m_vNU+};`+>Nj8~YxTbit*e5pu!hia7S6$a<6k zX^wcHuhkNSdlh0^_<~kX3~hL0{+0GX2p1|oDnvuHS|7)ghE z`om2AL8lSd9Ur-Y@yYcm!Jc(H*w_!Nq5B4ft&rx)aYfLZsJdHb42v(1j#6>rUZw|3 zfu9!7)j+c9MZ{kOPGf^r2?S@55tht-bPuDA9~abj)6~}T2NAQn>eH^ z9LDlrjuWnbbZ|HO?CHOYYm_kdEE9JF6;Rpi&d9p9P2U9mUo7sy^`!YS?EIsOE4R!< zz~)m_;z5bp_g{y5-+VveHlf5nETE`?QcxQb?(n?0KD}AXbhw4msX28?Z8){ zq6O`Jov6td|Jtmc%sY3lCWC@k0Vj84+EdI0JX;Z{E2;kIP}89!%ZKQ?%Bd+xO@??s z?LEb|)K`KZ#ebH`Z0YM?j1bug>S+DyZ|2_SHvQB^N_p7vO)1^n-IV_YkHQVnhh_9% z!!5~eEkK?Vss9M%R$8~Z?qCk+Nki1bK_$!GH{HU`*QO|nrFFzR3x8RIkF~f(Uw3?O ztAi&VXk!qeM!GnEPi*U~b&+`YyvVq9{aIP|(flG3t6a`cG>^Obq*JUuPnjFu+7EJ`*{M+hlC2<5Ja-;M{a(Z5i^!4JfcA-rU#1jYYNHzgZ^an$Aw8N0Slcd*l`rsGE5_GR1|Nj zEw%(;%U)CO_{fLd>B0fyxe_`^HWe`UbZH6oWaY_5>|T7 zSDQH80~ZOROkJH|fr{hTxNCtrJ;(PO zKIfvo`4!hw&7D8D*%3g9;Bs&p9J`R8zy37~VkK+gwMOreNlGH>XA{Je`#JCMyf|1T{M~)i8sojH z{mzO04e{p0()yy`rowv*$1{?rO=W5sSVxtV*1~@+8%gpcjvKN1miV=%R+8ginxJfs zEZ!lFJZkDu;)yDp=(*uMOa!HF&C8EW&o>YvhFbva31!Q6TtH|z=3zMRF>4La$%~2&y32AVK&wj!f4(J)(-U7Qj*6F*5%T;$Ex^4|c~0=IS%5)7i&~U8L`2&&eJA&GeDiUT`Yvlt zrl1(Q#b0NaSQbO5^eKpte@7k~`#q>S9+*DEJ!ADZ(Op+~v`ZAF+9M>182G1EUQr|c zGLMAgne&*ragjboap^v=`18$xfXv7u5}71Ii1@X? zEI?`t8y*hNnR(=7Upr zlN0L`3lo<))W_cf%1H?vLMX`-9t^+oF?t?cDrQB zvFipiFS451qjlrr)X$ST2`%e5ukFHRG$)12Ck=XVqnS^|NkPaGR>7{3h4@T8ea1&$ zKhF_YdAOaZ=dt41+VFKr^m-Q(%fnyk%E5*%oovqM*CmlWEr4ahCZAPEqpVqIWlKX zfbug_ej^i@EptU#cV^*wla8j&P4C;=g%&=+Ta;oT&O=j<&JD%vkmAdzaU+#$dl|39 zE9qrQ9|8U=@-p+R%zCOAWrJ0pmQIi)2;RmP-gAr)v))p;5A(N5M!QIU^|`L`^TSaS z-WC)3Gf_cjVT<4R76-^G%Y!PSY&>7XGCALC;3#v|;6&rVX#pYuKErTVTzN#g&(=wt z-SuzkV2?-SEmuCO7|#k-ket!F*A3YHHs2BF+?7;Wp%@bsntOBpWnh5>4*=%);fydm zsIeVl3pTdLi%dEfQ_^fnfkAA#l%Uozc|=3vaO#p zk?*~NY#IXOmzpQ{IO|VgEdeL$n=o!EBvWQHwr%g#0ff5|5ymFn(zS26kEO)#4Bx{< zcLzh!8{^2+nBh3*$uVe_vZGiRtr~Az)rfqeIpair*swTpVqG>ESk;O74clvmjDa@r zH7P=GSnL5w=a@@Hi(pF@Wb6*duuWR!J>T)8!khk`$P`iT?QZ&yA&;upDT7dyVU z^z3l&Oz2nw_c4B(x$|kn{@+z^_S-TXnO5y2j3s&`v=DxS2YlHmsnSw>i2Y#+%lsJ}>$2k4SukF^pOgVic9!h!e2ApFaCf;f%_iiaz4@Y(; zB2SOV&X?sjd*OH=<6C6w18w4X{7etXjTOmd(}HP7xxZadzW@uJHtff0=eX#e{^?r^ zz^j$i&oO|JPk?z-E{OXh$%$VLi#Yb$jdXTIzbni;N8i00YtZ9f_UUG;)&3qOb@)YL z*V=#VusoC7ttpA!dmQ1sSL3{5#q@GP45V&Oj`swQjlNtQ?7W*vr%M&`SJeQ5u|utS z|I*nTEbxWhQR^mqe$&zXo8QgHS;jGvn)hgc;|O7nqyBZD5SY0(UJ)tN%58$VZekqD zM>4pqcB6iBI0@S{|AMB-3Q1bt-L4BuToF6CB#rG&O5t3YQ!|RmUA`(kzv>2ebXgUZ z0t-K31RdFhYYXf;AZHl-n8|W(#a2c&Q7o!Om4`&^cn2G3D#W9@GeW?+>2h>$<&UXY z8|Ud5e9MRgF||A-A2)u13BszS-ASwF~0~!|%i{z=SOyRy4OV_fNUC2uXH}I|L@!n;Fc+0#C ze~(H_*P;^^BKo(|0KZnJXe^ z7JPuwS4|jTS$BY&x!mSLJSy{d?nyinTEFEB@4%r2GPfUetvw`mbf!1xPUiPP*O9M2 z6Us())-WzazM`g-x!skwQ-HVgd%6FP<<^8Aq+bytF}$m-cKxu@RMabLX1Pgzd%a0z zsPvxVQ7Q!i{{N74Vr|({rGtJCn(@MWO`pDBHk1(x1hs-T9>Stg@&I;WRznF*t zQaiNA5EOW`j70Yp-{l0gXLf)+>GyZpyf5y-a(kjwO9jwa>#NJ>;9`QPMxp5qy|bnp z!osg2Yl&)#s*CrBet(wY8>Ia%o15>_`{7mG>xiI<?yD846=HFhvQ|Jz zL3OxOe06s+?R=?C|7C;2n8qkb>3O^@94=xRo|YBmJVO04iHl3TA`fdxdZ#!Y*xGm>?+Zm0dPi zHaNVls2Op&S4xkCgLQ`xu#=G;8)&XulY8#7E&cymy6!-z|Ns9!JM)APg(Q0uGVX*> zXR8!(oU)RYY)58xpCTbjvQj8zZ#iXUBw6Q-aFV^<-S2gMfB)Y5b+6a+HQvwHb37i; zxBC>H>F|nQkK|VB*tbX2+_6lP?}N@I)mD&uTniY-CcMx6t1`f=MAi=mr$KT{jHZR} zbMqK``ghs2!I$2zZiafB9*VWUMMZ3WZ5GmL(Sh1jgUiKW( zrakP<-nGCJHKk6gc~d6RG$5Hj`;~v!S*1|YwKUY-mRHN_aqr-C8>aN*M({vt zsv}k94t~bAD&2BvBza%j``_zyoI{dBlQanMG*E4tnwjY<%PbVV0Q%A(QOYXXA%T%|Tb56Ae4D zMgR}F_+;x0lAhP^FvMERj#6E-oK^0;qKW}&i`e$@$~T@IeLFd3(sU88{0QGZ6S`D^ zb62LlrS$n|Js@j9o<|mYyWejy3BU5gAB5!P-p*EofV?b=%#EaGGvcS|>q(!xFZI(a zk)zpAlBR9tJZVzhhk)@&{H3cCPEDpX)}3Zk-M(?E86+>SI0Rj^ z{my9gbyt8z=2(=xj-L=e_6*N)C|XqJOa!gDKP+vo^THMk?_Jik51?G+#2Z-BN@Wy4 zU<=!8uZO%-&K2Z=7?KsAlWHY1mG52deA~z)1t182%#Fz|igjSn^<4Owx%DrIHMDZh$VKPz1iPHg}-O zDR|@glg-i7h1S&%0wQ77k*5%3nXUYRdD50oIA=A4!;JD?d=8yTwcy zshy%f@sDbsxgccG;sAsW0N$hH;dF-=QeMvA?>REd4k#f>B6vFhvJh3u#Ls~DjC%3v zV)xRl>%pSJ&I7^LM}1cGs^J$aVx;A|yZ0WYb0;%njjYbg+GE{Yh2{^B6#=liQm4gb zWI0#y;l$Z83X#M2B%6_;)uCfmP6`$G4)FZmpBEZVNJ5qks8*z0hH|G}%vv%0;cW~R zZ69bYQSL|r$Har9Avy*du6!n))??%>z`ni$;HR39F;6B2-zOtW$r(d9PSV-fzy;$! zo(K@0H4_1fVFeMw-;64ekZmtWfIpmn&8D~dW$%sb#*?C5NfgJbvT{#3URmgMD1yLu z9O{Tzk`{|7ILAyw<{aI8CvI+W^Y8W&4K(vYoD&hiOwG&`#J%L=)iilcqt1@_dCjx~ zK=|hh*19DDZ4*>>a+M=7Pe(2{MKTgs`M&#HU-{*HOc$@Xe@DxEQf)(y&KbDh8H>mn zC-MBERNKyy5|a0~@W}s^^mY86uWQ+yKiRsW$n^9n56b#C>ZiuA&i0F{mC%@K4uki9 zT}tc`$o=p(PJjt6OAF6{)c#&KrJMsvo6Q525HYZhjJuvKDSNuthuEm7h|wy8eN792 zCDvwwA`R0uYr{2%p${}ze*fDclpz4o6Nou}Y4*p()4fz!p!qN|)QUbXyx zPF!~A&{-bewY@y0v-$i>#N0O5J-{$?W)l@dCo)7E##C%eBQ~}sa(r=bDbbB9aj?#^ zNv5GdYw&Uh&URDgz6R~M?Y+^eeCcGP@!NjgfEs!QQBy0{0ANF#RNkS+U8%ayrLONe zMKa{o%qS!k`wT-hnP~k3=I(al%x`!0H$?^Jw~vh)I#}tVR--Q3BNP?Dop1wpnk{gr zaP{>+;Bz-;rJ31GoosiKJu!CoO(L{EbMVA15!`TL_WfSqavR<1GX=vrloGC^Xgw8K zlb(OcDs>;e3y3!myGk`SZReAW)5Qj?MzhS1h~Z>548Tn)?k4p7tQ1G>bYxYchnW2o z#TxSv9b?Q-m&HFs;Fr0C8HAD)y}ct8d-yefq_5)vD>Q4|)(1q8wZAII$?->d3m5P( z6LWdvbS4vM1t)60v)>otEsAez@?2f{xD~eAy=HqNgtJ)crP4b&y{nws{;bn>%EYC# zs^nqp(NxZIc(crq7Nfbp2}dAG<5CKuAzMFqcawXDup`l#y)XAkw29xHgvDO;g~X< zFQeIR(+KYkGJh?qdN|gow~&H-T`iUWqjG)~iIZqp>fLBiIs8iWy|6nLH;LCmOl{EaiG#xOIMgcpiENod! zAK^~VSFy6FBTJfD`xv-aXpjX#Ta^_C&Rxsm3{0|&sJSGnbuKkaStT#@em_q;rfG%& zTetU<)GsU0AfOsK6ZcHsROy| zqt7dFvOj*4>e3t$CvU}zjmegO<^IaF%P9FWoyqLPKzci04kl;mD!r1#EU4sE%s zULcU(QsFzCn)p(VTerYhi+J%d?8WYfcV_vD2RBP0iEV+mZ@)Tj#}b2&Kq4B4OMOr! za$xb+!pE!p^UuGQ$xYjSl`(Vk-y2BET(}Aq&%xyL3%{#O*w;CdRN@?KGIJRoTQd7{ zzRcVy!RyV(5*A9QST6F?nb+PpTk0E4lipdw6D91LV8H3=^(A+<*EK3EGu+I{K?#*EaG1WY#jvHwHbeY6Bh1jm{`dev@p&@@A;%Qn` zb~=MDOAJnbHU@yO6t=>_*R)tzMhz~gdfJ;=N!A_`qJ*Zp+n5Jh+Q*Zo1)!*5| z^o}GS@FOr1;)O>L za(-Id7$}K{?G)D8OG%fu%vNVn3q@m;)`QK`=z3b`l7Z{$U=^3G?RTXK_=$*TxII(hymZ5E+`(7sFDNK@mb z=G3QEth(yafTweHBA5cW%#KUaW;PAC+BfAm{c?HUr73&KVA;N&xsXhjy{pEeE zz&rq}3UzwK^T6v>MOs4s8^aH)AY`)%6)K~T6BaESf=j;3dAb+mrNk^vllZZ--{n%D zof}M>*&Zj-*rhtuRd&;FEJ){S3L+BY?g`2B*j#OT&~_+QGojyYqIG9 zcLEKZUvCI{R5oZH_TudqD_|u^%x62=_Z)$5h|Lka)1w|8eR8ii-oIV1CM9sLw}MGe z?gl4k@H2Y>ov+Jk=Z8X0dG{{Q2AiI?gK=Fm=F>E~G<-b2dVWNV(^>W842{>{XL5k1 z0y&SP<7Xe8h97XPC_ACD!(9c0GA_|=sfD}Y7eE{m$?iiT9 zjJx5#Iwr+{KFOlmxL(2C_-G;G4Y{}Loq*NS-K%R$8?*U$bP?Q`9u=p9$mjXZ5n5P> zF?`^A;U6um-^csKH#GR2kfFcPXu(*%X~906{0j*v?W07*{HCeuPD-a)sMiY3apchv zZghhzM}=A%7JY1ZAaW8jOH-=b>4ICV(1Bm?ONFkW+gtoR5c(3N@88&;Bb3)SJHjg1lM?yiahfHBy6}16 z%MXPjelfjJ_22xmqp9s`WmhkJ)&neWMZsW~;Bf}6K za4hq5A$c^DIFhZ9*50@@LW4H)3iQW#-NSuqHtzhoHMh+R@4R?)YJ(#B#g#j;b(PIy zSbxyY6jzS0c$WR-Jc2Tn6vi;?5*ok{U)7qIVy-%wFKC;oS>cZDy=_Ti(y5O@vAra&?ijeB!YA(n-7`Rh7hm& zuR8qDlqWXwYzaRa`gMv96k6a@0N`@~j7ykZ`PsBR7|}o>SMYInvib}>6RsFJtuU0P ztPTe9?RdaBlv>GmnV}gmnCwuVZ+iOU-Km2M_fB;7e|R6=RzdD!#~t?7&LlED&4^sK zLeINZXk3YdM;)hn1RxwL-cc@}3xK~~eNU1&a>-jf=&!&H!Lg?uRQs@X` zk`@r}mk3Z8m~J7T9r?q{n$<`hkQ)_+C%7r^G}n$p1k#m#pIW2T2CS1rK6bUbYUM#g zH^2;pxW$+}hT8bSu}T6tOFP+pkFtt$yq>@6$&YuiGOYboD8?$8RJO~sRNQJr z7oQq|0!(CD^a;uv6IdGP-eOLoC57XK0R(BYp&fXvh=IoNFtGhy@!keqKHJR$`(|?F zkNx!wU5DaKooO|rOM#bPK4z$peG81(HtOw{)FpAmu{E=weI^1-t=K7amnZg=c#a`V z|0I+%2t{iGRl-kDIm%lkqSz?o4Oq;+^#@XixgmuOlt5r|sqfbuiy4IU3&FD5ae=xb z>PPZP*z%Q|{I6#a57P#)9473xv~e_7ndnO;*pM}`pbSbD2yUfRdnTxQ#Dpp69o<}X z81S+_GKAAyQJg-u@DT>CcQEDmzyPV~Oom4N8g7K3A3+i$Opq>I5;(9kf#Y@PXds*i z5biyd?EiWNRLzKg;ie`{8F~ZT@)i%G(7kHi!s~;z2XQ6A6)WC^McD|keutRn48rj$ zRAC9{DERvg&p@ei4nKEcUHG|!RVT0d%?lCJU6((Kz<-V~Lg+F!B>Vw&5N|HB8Z!iY zi!-}LVQS;NR|tU0XU1qbVbc|597umC`V@$bNZTz?ZgT)C*vmD9?pCA`ZA~dHlZk1w z1e`xSpZ8xkSro4XgCRAF;}|Qnvih%G>|+M+b2LPK0Lr-T$OQX9(f0!42?7cQL7mbp zUU-$#-AL&@(V~z|fzu6$%NC_-j`a<)L?m8-N_e4Tur>w|B~c_;Jkyvu56s3L;Yn7A zbED`x5FvS71!-PN3ukc1I&K7nWc}ANA#K+4^~$?O0W6VZ1}B{f7m#(yF`=i60H)l~ zUScrximq#q>pYM*yIMG$fy`_P*n;5s{Qh@fjqJGYL$9ATyC&IW@D6@+*YV!@5y1RG#Q2W6Z=exq>d zZAV*Z=HufeVQQJ>jVxfp>}QGe#?XnLdKU`m?Cagx3j|IiY+ZV(I$_pR_-A>^+}P5svRMs>!5B>%+Q6xf;{xQl#?i zeRF{|DbO`5&Sb{K`Xuo|EocTIEuOyqhI81zO_LK47^c)Q-J-;nCLCWzxIiyF{qiQ% zM#G5k|Fe@v`pN<25K>u-w+;&8XBL13dvm}6(TBJy!WFG~oLBSKYWtgT2<63~@1sGJ zqY8@v!ARZSBLEw%8zdhvZ@!r;0{|<+6a#mB* zf_Z^{m-I!3HjHC8Q;$7dqa)&U3t~1qDq{q)I;@mw6jod&(V?%!#SOSsWL=!pm-E>q zb>UOM#RdTFf;d9ZJ_w1Ly+1OvH;a`3Re?+$O*YFLbl4sW3s$)AUh3$p@-XYL!(#fH zZL*`XAXa1UdH!yz{jZhu9=qOa92+5*^c5=zUF=%qINnC;-wx0}a!qVPro}cn@}=<6#sS+F+_LLBf>}GZyODS@HYDzl6GDUrh-U_O}w9d`Z=L zei5cKs~yKzsBWI8;jUY5vrV|I<4DN(hl$~IKi5|7qX}{~H|(Vn*)K*JC`=ww8#Q#g=VJ&+60J| zUw!RQWvoBelM-?n=-dC8DIs~^fTBCs!z!+n=X5fdKH{57B(gJ4xWVwql&A0*)NFRx zdot1YF+s@PyF#ewFr*wGoh5taGb}Uvqw<*q<}2LD`K*OJ>&UZ3;CkX_mgIzPRUB4X zLqQZhj=uGcO8Vt{S9Y;(qTqnUXfvljfrL?fwA?Ev+h4iza>jUYLPS+laN1;`q??f2 zjeAb6ug+fI*>MO97bvZs*gi*_ok0TQaNaAExHILKKCXp54x=NS>S@5#5vsw*d5M%) z^psn+?FRxOhxjQlih8?cXQlKZI5?sp3g>D?^Mx6M5?Rd?W@fJ)a|lLtVqG6Qe~Stu zVVm)>MA-n+f01vjIgaZIU}SyjC&fjmtEaZ+ycZ`YNSXSKo~%9`E9eIw{)pH=J!eO{ zK>O{(11J3fjGg(GrXKuzOsue094py8F0WC2Vi$?E)i}to_G((z?Ht9>A|$B?U)Yz$ zVQQoz+yrYYN%DjsT<0Mv%ocXn1_+t*kCNh~ClW&2GizqC37o-my$Do&Db zZR<4_AHTo_K`HV!6afg)yH`ei_^^vVXA`R#et0lxS{)z*K`-B3+Q&lV&xb?*`*Yk8 zVKfkLsis+T9Ln7JcyI4N2gbb{*AF@AsBbQpI<6>beV_V3?XVW~K1QB-e%^aW?cT2F zzp)R!J1KSBJKMO4zY8}Z2s!@Hzj;Q`?9K;y@ONGKugIu!ov8})8`Ms(5d^hVDjGhE z+C26AuNn1e2Q(duQ&e4#LIw2OK+}cz9W-jg1DfjD*e;vD|wXcQi8i17j3PuF$qBU@V)qjt#1QU8G(St8{oiccgzB&2O@1kmkr`*8hd%w3 z71!pQ8&b5{S-rEzsq&`9&hBdG;Ey}hkZ13nQd;)7WM4<}`WBZb8}=x2^63hOi`vRO zik32|joKfJt0t8xXXAQ%^9LCJN|v~pkTv-ZFPWzNo4g*ZPpx{r!-1_}tXa)h6uR$z z9={zRxTlEedDi_?gu3Ugbd15!(JF>*`E>UWk2!0wzT3a+m-&YmT1H*`NH?jAWbwy4 zg$uF`yxTgaT^sr$hXsQ6?#||aBlJ}Bv#R}`-KK6+J|Ce4D}FWH@O&aESX7<{?1a!Y z_uN!O$JH{ljOGx>G0@0Kp+&k zQg>PZ{j{Kb)H)CDLDPP>yg!Eef_z|D82>K`!R;~X-Lh=O z$^8_94{KEyWcZO&hfOm<3yC}G3(bzqhmPYb%R9kgau1j-A*NCR-k*vFwOrFIu=C%R zH~+0VnNXy$CKGkA`8l>|Hz@3Uqs4a`j_|7*&`6@CJ3d$ljz zC$x3!S)-1CICnCXRx%YDpxr__p45LE{iIPxh`O|kt*zOxyBPCo?hi$H0Ru_MS v!WedREeWgQVeZRu4%iF7w@dD1Q-qjXb#^Py$eaZl7cw?5)qj5x{qX+)uG85e diff --git a/backend/docs/index.md b/backend/docs/index.md deleted file mode 100644 index c2f0ca8c..00000000 --- a/backend/docs/index.md +++ /dev/null @@ -1,60 +0,0 @@ -

- - KitchenOwl - -

-

- - Stars - - - License - - - Docker pulls - -

-

- KitchenOwl -

- -

- A grocery list and recipe manager -

-

- KitchenOwl is a self-hosted grocery list and recipe manager. The backend is made with Flask and the frontend with Flutter. Easily add items to your shopping list before you go shopping. You can also create recipes and add items based on what you want to cook. -

- -

- 🍫 🥘 🍽 -

- -## ✨ Features - -The following features have been implemented: - -- Add items to your shopping list and sync it with multiple users -- Partial offline support so you don't lose track of what to buy even when there is no signal -- Manage recipes and add items directly from a recipe. -- Mobile/Web/Desktop apps - -This project is still in development, so some options may not be fully implemented yet. - -For a list of planned features, check out the [Roadmap](https://github.com/TomBursch/KitchenOwl/wiki/Roadmap)! - -## 📚 Related -- [Wiki](https://github.com/TomBursch/KitchenOwl/wiki) -- [Discussion](https://github.com/TomBursch/KitchenOwl/discussion) -- Android & iOS [Privacy Policy](https://tombursch.github.io/KitchenOwl/about/app-privacy-policy) -- [KitchenOwl App](https://github.com/TomBursch/KitchenOwl-app) Repository -- [DockerHub](https://hub.docker.com/repository/docker/tombursch/kitchenowl) -- Icons modified from [Those Icons](https://www.flaticon.com/authors/those-icons) and [Freepik](https://www.flaticon.com/authors/freepik) - - -### 🔨 Built With -- [Flask](https://flask.palletsprojects.com/en/1.1.x/) -- [Flutter](https://flutter.dev/) -- [Docker](https://docs.docker.com/) - -### Support or Contact -Having troubles? Check out the [discussions](https://github.com/TomBursch/KitchenOwl/discussions) or [issues](https://github.com/TomBursch/KitchenOwl/issues) and we’ll sort it out. \ No newline at end of file From 0ad4d8610bf37ce4a3f46262e8319d152b3510a5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 5 Aug 2021 00:46:09 +0200 Subject: [PATCH 037/496] feat: get full recipes --- backend/app/controller/recipe/recipe_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 53d9ded9..51f39bfd 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -12,7 +12,7 @@ @app.route('/recipe', methods=['GET']) @jwt_required() def getAllRecipes(): - return jsonify([e.obj_to_dict() for e in Recipe.all_by_name()]) + return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name()]) @app.route('/recipe/', methods=['GET']) From 97338372a362009e07acb09b2bf1cfebf5fdd2ce Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 5 Aug 2021 00:47:04 +0200 Subject: [PATCH 038/496] Prepare release 9 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index e22e0663..189dab64 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 8 +BACKEND_VERSION = 9 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 97c9735d98b3ef3a1160f1961acba2ab53aa0ac9 Mon Sep 17 00:00:00 2001 From: cMensendiek <38719631+cMensendiek@users.noreply.github.com> Date: Sat, 14 Aug 2021 12:48:15 +0200 Subject: [PATCH 039/496] Update CONTRIBUTING.md more detailed instructions for activating a python environment --- backend/CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md index 438ce10c..d4b8c300 100644 --- a/backend/CONTRIBUTING.md +++ b/backend/CONTRIBUTING.md @@ -28,7 +28,9 @@ The `description` is a descriptive summary of the change the PR will make. - One PR per fix or feature ### Setup & Install -- Create a new python environment and install dependencies `pip3 install -r requirements.txt` +- Create a python environment `python3 -m venv venv` +- Activate your python environment `source venv/bin/activate` (environment can be deactivated with `deactivate`) +- Install dependencies `pip3 install -r requirements.txt` - Initialize/Upgrade the sqlite database with `flask db upgrade` - Run debug server with `python3 wsgi.py` - The backend should be reachable at `localhost:5000` From ecd3e7686f6f86099179783153c9fe26680fa020 Mon Sep 17 00:00:00 2001 From: Constantin Date: Sat, 14 Aug 2021 18:08:24 +0200 Subject: [PATCH 040/496] added a suggestion_score to recipes --- backend/app/models/recipe.py | 5 ++++ backend/migrations/versions/6c1be50bb858_.py | 28 ++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 backend/migrations/versions/6c1be50bb858_.py diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index a31f2809..204ef72b 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -11,6 +11,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): description = db.Column(db.String()) photo = db.Column(db.String()) planned = db.Column(db.Boolean) + suggestion_score = db.Column(db.Integer, server_default='0') recipe_history = db.relationship( "RecipeHistory", back_populates="recipe", cascade="all, delete-orphan") @@ -40,6 +41,10 @@ def obj_to_export_dict(self): def find_by_name(cls, name): return cls.query.filter(cls.name == name).first() + @classmethod + def find_by_id(cls, id): + return cls.query.filter(cls.id == id).first() + @classmethod def search_name(cls, name): if '*' in name or '_' in name: diff --git a/backend/migrations/versions/6c1be50bb858_.py b/backend/migrations/versions/6c1be50bb858_.py new file mode 100644 index 00000000..171b018a --- /dev/null +++ b/backend/migrations/versions/6c1be50bb858_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 6c1be50bb858 +Revises: 5a064f9c14d0 +Create Date: 2021-08-14 16:15:45.794601 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6c1be50bb858' +down_revision = '5a064f9c14d0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('suggestion_score', sa.Integer(), server_default='0', nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'suggestion_score') + # ### end Alembic commands ### From 7045692554f9dfad1d6d0ab0143eac35f328a9cf Mon Sep 17 00:00:00 2001 From: Constantin Date: Sat, 14 Aug 2021 18:09:05 +0200 Subject: [PATCH 041/496] daily routine to compute the suggestion scores --- backend/app/jobs/jobs.py | 14 ++-- backend/app/jobs/recipeSuggestions.py | 89 ++++++++++++++++++++++ backend/app/jobs/test_recipeSuggestions.py | 64 ++++++++++++++++ 3 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 backend/app/jobs/recipeSuggestions.py create mode 100644 backend/app/jobs/test_recipeSuggestions.py diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index f53f531b..f3fdeb85 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -1,3 +1,4 @@ +from app.jobs.recipeSuggestions import findMealInstancesFromHistory, computeRecipeSuggestions, findMealInstancesFromHistory from app import app, scheduler from .itemOrdering import findItemOrdering from .itemSuggestions import findItemSuggestions @@ -6,20 +7,23 @@ @app.before_first_request def load_jobs(): - # for debugging: + #for debugging: # @scheduler.task('interval', id='test', seconds=5) # def test(): # app.logger.info("--- test analysis is starting ---") - # shopping_instances = clusterShoppings() - # if(shopping_instances): - # findItemOrdering(shopping_instances) - # findItemSuggestions(shopping_instances) + # # recipe planner tasks + # meal_instances = findMealInstancesFromHistory() + # computeRecipeSuggestions(meal_instances) # app.logger.info("--- test analysis is completed ---") @scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') def daily(): app.logger.info("--- daily analysis is starting ---") + # shopping tasks shopping_instances = clusterShoppings() findItemOrdering(shopping_instances) findItemSuggestions(shopping_instances) + # recipe planner tasks + meal_instances = findMealInstancesFromHistory() + computeRecipeSuggestions(meal_instances) app.logger.info("--- daily analysis is completed ---") diff --git a/backend/app/jobs/recipeSuggestions.py b/backend/app/jobs/recipeSuggestions.py new file mode 100644 index 00000000..7525f50c --- /dev/null +++ b/backend/app/jobs/recipeSuggestions.py @@ -0,0 +1,89 @@ +from app.models import Recipe,RecipeHistory +from app import app, db +import datetime + + +# minimum hours on planner until a recipe is considered to have been cooked +MEAL_THRESHOLD = 3 + +def findMealInstancesFromHistory(): + return findMealInstances( + RecipeHistory.find_added(), + RecipeHistory.find_dropped()) + +def findMealInstances(added, dropped): + # pointers for added and dropped recipes + # both lists are marched through together in chronological order + added_pointer = 0 + dropped_pointer = 0 + # key:recipe_id, value:created_at + added_recipes = dict() + + # meals that are considered to have been cooked + meals = list() + + while added_pointer < len(added) and dropped_pointer < len(dropped): + # add the currently added recipe to the dict added_recipes + current_added = added[added_pointer] + added_recipes[current_added.recipe_id] = current_added.created_at + added_pointer += 1 + + # look for whether the current dropped recipe has yet been added + current_dropped = dropped[dropped_pointer] + while (current_dropped.recipe_id in added_recipes): + # compute duration while recipe on the planner + added_time = added_recipes[current_dropped.recipe_id] + dropped_time = current_dropped.created_at + # if duration threshold is met, consider it as a cooked meal + if(dropped_time - added_time >= + datetime.timedelta(hours=MEAL_THRESHOLD)): + meal = { + "recipe_id":current_dropped.recipe_id, + "cooked_at":dropped_time} + meals.append(meal) + + # proceed to next dropped recipe + added_recipes.pop(current_dropped.recipe_id) + dropped_pointer += 1 + # break if no more dropped recipes + if not (dropped_pointer < len(dropped)): + break + current_dropped = dropped[dropped_pointer] + app.logger.info("meal instances are identified") + return meals + + +def computeRecipeSuggestions(meal_instances): + # group meals by their id + meal_hist = dict() + for m in meal_instances: + id = m["recipe_id"] + if id not in meal_hist: + meal_hist[id] = [] + meal_hist[id].append(m["cooked_at"]) + + # 0) reset all suggestion scores + for r in Recipe.all(): + r.suggestion_score = 0 + + # 1) count cooked instances in last six months + six_months_ago = datetime.datetime.now() - datetime.timedelta(days=182) + for id in meal_hist: + cooking_count = 0 + for cooked in meal_hist[id]: + if cooked > six_months_ago: + cooking_count += 1 + # set suggestion_score to cooking_count + Recipe.find_by_id(id).suggestion_score = cooking_count + + # 2) do not suggest recent meals + week_ago = datetime.datetime.now() - datetime.timedelta(days=7) + # find recently cooked meals + for id in meal_hist: + for cooked in meal_hist[id]: + if cooked < week_ago: + Recipe.find_by_id(id).suggestion_score = 0 + + # commit changes to db + db.session.commit() + app.logger.info("computed and stored new suggestion scores") diff --git a/backend/app/jobs/test_recipeSuggestions.py b/backend/app/jobs/test_recipeSuggestions.py new file mode 100644 index 00000000..b1df95c8 --- /dev/null +++ b/backend/app/jobs/test_recipeSuggestions.py @@ -0,0 +1,64 @@ +from .recipeSuggestions import findMealInstances + +import pytest +import datetime + + +#print(RecipeHistory.find_added()) + +start_time = datetime.datetime(2021,8,14,14) +def time_diff(h): + return datetime.timedelta(hours=h) + +Added = [ + {"recipe_id":1,"created_at":start_time}, + {"recipe_id":2,"created_at":start_time+time_diff(2)}, + {"recipe_id":3,"created_at":start_time+time_diff(2)}, + {"recipe_id":4,"created_at":start_time+time_diff(2)}, + {"recipe_id":5,"created_at":start_time+time_diff(2)}, + {"recipe_id":6,"created_at":start_time+time_diff(2)}, + {"recipe_id":7,"created_at":start_time+time_diff(2)}, + {"recipe_id":1,"created_at":start_time+time_diff(3)}, +] + +Dropped = [ + {"recipe_id":1,"created_at":start_time+time_diff(3)}, + {"recipe_id":2,"created_at":start_time+time_diff(3)}, + {"recipe_id":5,"created_at":start_time+time_diff(5)}, + {"recipe_id":4,"created_at":start_time+time_diff(5)}, + {"recipe_id":7,"created_at":start_time+time_diff(5)}, + {"recipe_id":1,"created_at":start_time+time_diff(6)}, +] + +ExpectedMeals = [ + {"recipe_id":1,"cooked_at":start_time+time_diff(3)}, + {"recipe_id":5,"cooked_at":start_time+time_diff(5)}, + {"recipe_id":4,"cooked_at":start_time+time_diff(5)}, + {"recipe_id":7,"cooked_at":start_time+time_diff(5)}, + {"recipe_id":1,"cooked_at":start_time+time_diff(6)}, +] + +# used to access dict with object syntax +class objectview(object): + def __init__(self, d): + self.__dict__ = d + +@pytest.mark.parametrize("added,dropped,expectedMeals",[ + # empty added list + ([],[],[]), + # empty dropped list + (Added[:1],[],[]), + # single meal + (Added[:1],Dropped[:1],ExpectedMeals[:1]), + # single meal but dropped recipes left + (Added[:1],Dropped[:1]+Dropped[:1],ExpectedMeals[:1]), + # no meal as duration too short + (Added[1:2],Dropped[1:2],[]), + # complete example + (Added,Dropped,ExpectedMeals), + ]) +def testFindMealInstances(added, dropped, expectedMeals): + actualMeals = findMealInstances( + [objectview(a) for a in added], + [objectview(d) for d in dropped]) + assert actualMeals == expectedMeals From c9764002544ed32a9f54f74f4d5229086c9dcb0c Mon Sep 17 00:00:00 2001 From: Constantin Date: Sat, 14 Aug 2021 18:09:33 +0200 Subject: [PATCH 042/496] added enpoint /planner/suggested-recipes --- .../controller/planner/planner_controller.py | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 00f1d160..91110e11 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -6,6 +6,7 @@ from app.helpers import validate_args from app.models import Recipe, RecipeHistory from .schemas import AddPlannedRecipe +from random import randint @app.route('/planner/recipes', methods=['GET']) @@ -22,9 +23,10 @@ def addPlannedRecipe(args): recipe = Recipe.find_by_id(args['recipe_id']) if not recipe: raise NotFoundRequest() - recipe.planned = True - recipe.save() - RecipeHistory.create_added(recipe) + if not recipe.planned: + recipe.planned = True + recipe.save() + RecipeHistory.create_added(recipe) return jsonify(recipe.obj_to_dict()) @@ -34,9 +36,10 @@ def removePlannedRecipeById(id): recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() - recipe.planned = False - recipe.save() - RecipeHistory.create_dropped(recipe) + if recipe.planned: + recipe.planned = False + recipe.save() + RecipeHistory.create_dropped(recipe) return jsonify(recipe.obj_to_dict()) @@ -45,3 +48,28 @@ def removePlannedRecipeById(id): def getRecentRecipes(): recipes = RecipeHistory.get_recent() return jsonify([e.recipe.obj_to_dict() for e in recipes]) + +@app.route('/planner/suggested-recipes', methods=['GET']) +def getSuggestedRecipes(): + # get all unplanned recipes with positive suggestion_score + recipes = Recipe.query.filter(Recipe.planned == False).filter(Recipe.suggestion_score != 0).all() + # compute the initial sum of all suggestion_scores + suggestion_sum = 0 + for r in recipes: + suggestion_sum += r.suggestion_score + # randomly suggest one recipe weighted by their score + suggested_recipes = [] + while len(suggested_recipes) < 9 and len(recipes) > 0: + choose = randint(1,suggestion_sum) + to_be_removed = -1 + for (i,r) in enumerate(recipes): + choose -= r.suggestion_score + if choose <= 0: + suggested_recipes.append(r) + suggestion_sum -= r.suggestion_score + to_be_removed = i + break + recipes.pop(to_be_removed) + # jsonfy suggestions + return jsonify([r.obj_to_dict() for r in suggested_recipes]) + From 4958eb66cf2173fdd141e6537c101f963739f3ac Mon Sep 17 00:00:00 2001 From: Constantin Date: Sat, 14 Aug 2021 18:16:28 +0200 Subject: [PATCH 043/496] updated requirements for pytest --- backend/requirements.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/requirements.txt b/backend/requirements.txt index 408c2523..637e0286 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,7 @@ alembic==1.6.5 appdirs==1.4.4 APScheduler==3.7.0 +attrs==21.2.0 autopep8==1.5.7 bcrypt==3.2.0 black==20.8b1 @@ -15,6 +16,8 @@ Flask-Bcrypt==0.7.1 Flask-JWT-Extended==4.2.3 Flask-Migrate==3.0.1 Flask-SQLAlchemy==2.5.1 +greenlet==1.1.1 +iniconfig==1.1.1 itsdangerous==2.0.1 Jinja2==3.0.1 joblib==1.0.1 @@ -27,14 +30,18 @@ mccabe==0.6.1 mlxtend==0.18.0 mypy-extensions==0.4.3 numpy==1.21.0 +packaging==21.0 pandas==1.3.0 pathspec==0.8.1 Pillow==8.3.1 +pluggy==0.13.1 +py==1.10.0 pycodestyle==2.7.0 pycparser==2.20 pyflakes==2.3.1 PyJWT==2.1.0 pyparsing==2.4.7 +pytest==6.2.4 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2021.1 From 25ec1380bff5f45dcf96d9182dc4f3bb71f1f0b5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 17 Aug 2021 12:08:51 +0200 Subject: [PATCH 044/496] chore: update dependencies --- backend/requirements.txt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 637e0286..38778c9d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,7 +14,7 @@ Flask==2.0.1 Flask-APScheduler==1.12.2 Flask-Bcrypt==0.7.1 Flask-JWT-Extended==4.2.3 -Flask-Migrate==3.0.1 +Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 greenlet==1.1.1 iniconfig==1.1.1 @@ -24,15 +24,15 @@ joblib==1.0.1 kiwisolver==1.3.1 Mako==1.1.4 MarkupSafe==2.0.1 -marshmallow==3.12.2 -matplotlib==3.4.2 +marshmallow==3.13.0 +matplotlib==3.4.3 mccabe==0.6.1 mlxtend==0.18.0 mypy-extensions==0.4.3 -numpy==1.21.0 +numpy==1.21.2 packaging==21.0 -pandas==1.3.0 -pathspec==0.8.1 +pandas==1.3.2 +pathspec==0.9.0 Pillow==8.3.1 pluggy==0.13.1 py==1.10.0 @@ -45,14 +45,14 @@ pytest==6.2.4 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2021.1 -regex==2021.7.6 +regex==2021.8.3 scikit-learn==0.24.2 -scipy==1.7.0 +scipy==1.7.1 six==1.16.0 -SQLAlchemy==1.4.21 +SQLAlchemy==1.4.22 threadpoolctl==2.2.0 toml==0.10.2 typed-ast==1.4.3 typing-extensions==3.10.0.0 -tzlocal==2.1 +tzlocal==3.0 Werkzeug==2.0.1 From 2754f095ca30b86800107f8c24133569a144e4fc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 17 Aug 2021 13:26:44 +0200 Subject: [PATCH 045/496] chore: cleanup --- backend/.flake8 | 3 + backend/app/config.py | 1 + backend/app/controller/__init__.py | 2 +- backend/app/controller/auth/__init__.py | 2 +- backend/app/controller/auth/schemas.py | 2 +- .../app/controller/exportimport/__init__.py | 2 +- .../exportimport/export_controller.py | 7 +- .../exportimport/import_controller.py | 2 +- .../app/controller/exportimport/schemas.py | 6 +- backend/app/controller/item/__init__.py | 2 +- .../app/controller/item/item_controller.py | 2 +- backend/app/controller/item/schemas.py | 3 +- backend/app/controller/onboarding/__init__.py | 2 +- .../onboarding/onboarding_controller.py | 3 +- backend/app/controller/onboarding/schemas.py | 2 +- backend/app/controller/planner/__init__.py | 2 +- .../controller/planner/planner_controller.py | 14 ++-- backend/app/controller/recipe/__init__.py | 2 +- .../controller/recipe/recipe_controller.py | 7 +- backend/app/controller/recipe/schemas.py | 8 ++- .../app/controller/shoppinglist/__init__.py | 2 +- .../app/controller/shoppinglist/schemas.py | 7 +- .../shoppinglist/shoppinglist_controller.py | 3 +- backend/app/controller/user/__init__.py | 2 +- backend/app/controller/user/schemas.py | 4 +- .../app/controller/user/user_controller.py | 1 - backend/app/errors/__init__.py | 5 +- backend/app/helpers/__init__.py | 2 +- backend/app/helpers/db_model_mixin.py | 2 +- backend/app/helpers/validate_args.py | 4 +- ...usterShoppings.py => cluster_shoppings.py} | 0 .../{itemOrdering.py => item_ordering.py} | 0 ...itemSuggestions.py => item_suggestions.py} | 0 backend/app/jobs/jobs.py | 10 +-- ...peSuggestions.py => recipe_suggestions.py} | 22 +++--- backend/app/jobs/test_recipeSuggestions.py | 64 ----------------- backend/app/models/history.py | 3 +- backend/app/models/recipe_history.py | 1 - backend/app/models/shoppinglist.py | 3 +- backend/tests/__init__.py | 1 + backend/tests/test_recipe_suggestions.py | 69 +++++++++++++++++++ 41 files changed, 147 insertions(+), 132 deletions(-) create mode 100644 backend/.flake8 rename backend/app/jobs/{clusterShoppings.py => cluster_shoppings.py} (100%) rename backend/app/jobs/{itemOrdering.py => item_ordering.py} (100%) rename backend/app/jobs/{itemSuggestions.py => item_suggestions.py} (100%) rename backend/app/jobs/{recipeSuggestions.py => recipe_suggestions.py} (89%) delete mode 100644 backend/app/jobs/test_recipeSuggestions.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_recipe_suggestions.py diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 00000000..b2113a84 --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,3 @@ +[flake8] +per-file-ignores = + __init__.py: F401, F403 \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 189dab64..6506f88b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,6 +19,7 @@ app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + \ os.getenv('STORAGE_PATH', '..') + '/database.db' app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 99227221..34108970 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -6,4 +6,4 @@ from . import planner from . import onboarding from . import exportimport -from . import health_controller \ No newline at end of file +from . import health_controller diff --git a/backend/app/controller/auth/__init__.py b/backend/app/controller/auth/__init__.py index 7d646274..758c2314 100644 --- a/backend/app/controller/auth/__init__.py +++ b/backend/app/controller/auth/__init__.py @@ -1 +1 @@ -from . import auth_controller \ No newline at end of file +from . import auth_controller diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py index ce8e1630..b0cb4035 100644 --- a/backend/app/controller/auth/schemas.py +++ b/backend/app/controller/auth/schemas.py @@ -1,5 +1,6 @@ from marshmallow import fields, Schema + class Login(Schema): username = fields.String( required=True, @@ -10,4 +11,3 @@ class Login(Schema): validate=lambda a: len(a) > 0, load_only=True, ) - diff --git a/backend/app/controller/exportimport/__init__.py b/backend/app/controller/exportimport/__init__.py index 4cfa300b..b513dfea 100644 --- a/backend/app/controller/exportimport/__init__.py +++ b/backend/app/controller/exportimport/__init__.py @@ -1,2 +1,2 @@ from . import export_controller -from . import import_controller \ No newline at end of file +from . import import_controller diff --git a/backend/app/controller/exportimport/export_controller.py b/backend/app/controller/exportimport/export_controller.py index fdf49f36..51fd1c9e 100644 --- a/backend/app/controller/exportimport/export_controller.py +++ b/backend/app/controller/exportimport/export_controller.py @@ -1,6 +1,4 @@ -from app.helpers import validate_args from flask import jsonify -from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required from app import app from app.models import Item, Recipe @@ -9,7 +7,10 @@ @app.route('/export', methods=['GET']) @jwt_required() def getExportAll(): - return jsonify({"items": [e.obj_to_export_dict() for e in Item.all()], "recipes": [e.obj_to_export_dict() for e in Recipe.all()]}) + return jsonify({ + "items": [e.obj_to_export_dict() for e in Item.all()], + "recipes": [e.obj_to_export_dict() for e in Recipe.all()] + }) @app.route('/export/items', methods=['GET']) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index ba1d2d8b..d8a12caf 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -22,7 +22,7 @@ def importData(args): @jwt_required() def importLang(lang): file_path = f'{APP_DIR}/../templates/{lang}.json' - if not lang in SUPPORTED_LANGUAGES or not exists(file_path): + if lang not in SUPPORTED_LANGUAGES or not exists(file_path): raise NotFoundRequest('Language code not supported') with open(file_path, 'r') as f: data = json.load(f) diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index a57a9e4e..eaa0f2bc 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -15,10 +15,10 @@ class RecipeItem(Schema): validate=lambda a: len(a) > 0 ) optional = fields.Boolean( - default=False + load_default=False ) description = fields.String( - default='' + load_default='' ) name = fields.String( @@ -26,7 +26,7 @@ class RecipeItem(Schema): validate=lambda a: len(a) > 0 ) description = fields.String( - default='' + load_default='' ) items = fields.List(fields.Nested(RecipeItem)) diff --git a/backend/app/controller/item/__init__.py b/backend/app/controller/item/__init__.py index dc326fe7..530df688 100644 --- a/backend/app/controller/item/__init__.py +++ b/backend/app/controller/item/__init__.py @@ -1 +1 @@ -from . import item_controller \ No newline at end of file +from . import item_controller diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 9f6f4c94..7dda7566 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -26,7 +26,7 @@ def getItem(id): @jwt_required() def getItemRecipes(id): items = RecipeItems.query.filter( - RecipeItems.item_id == id, RecipeItems.optional == False).join( + RecipeItems.item_id == id, RecipeItems.optional == False).join( # noqa RecipeItems.recipe).order_by( Recipe.name).all() return jsonify([e.recipe.obj_to_dict() for e in items]) diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py index a597b802..78b6bfe5 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -1,7 +1,8 @@ from marshmallow import fields, Schema + class SearchByNameRequest(Schema): query = fields.String( required=True, validate=lambda a: len(a) > 0 - ) \ No newline at end of file + ) diff --git a/backend/app/controller/onboarding/__init__.py b/backend/app/controller/onboarding/__init__.py index 12921e60..1f0b4d7d 100644 --- a/backend/app/controller/onboarding/__init__.py +++ b/backend/app/controller/onboarding/__init__.py @@ -1 +1 @@ -from . import onboarding_controller \ No newline at end of file +from . import onboarding_controller diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py index c7e0f8a9..cf263fdc 100644 --- a/backend/app/controller/onboarding/onboarding_controller.py +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -1,16 +1,17 @@ from app.helpers import validate_args -import json from flask import jsonify from flask_jwt_extended import create_access_token, create_refresh_token from app import app from app.models import User from .schemas import CreateUser + @app.route('/onboarding', methods=['GET']) def isOnboarding(): onboarding = User.count() == 0 return jsonify({"onboarding": onboarding}) + @app.route('/onboarding', methods=['POST']) @validate_args(CreateUser) def onboarding(args): diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py index 8a611864..d855d126 100644 --- a/backend/app/controller/onboarding/schemas.py +++ b/backend/app/controller/onboarding/schemas.py @@ -1,5 +1,6 @@ from marshmallow import fields, Schema + class CreateUser(Schema): name = fields.String( required=True, @@ -13,4 +14,3 @@ class CreateUser(Schema): required=True, validate=lambda a: len(a) > 0 ) - diff --git a/backend/app/controller/planner/__init__.py b/backend/app/controller/planner/__init__.py index bd860a81..149d0792 100644 --- a/backend/app/controller/planner/__init__.py +++ b/backend/app/controller/planner/__init__.py @@ -1 +1 @@ -from . import planner_controller \ No newline at end of file +from . import planner_controller diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 91110e11..1c2ddb9b 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -1,4 +1,3 @@ -from app.models.recipe_history import RecipeHistory from app.errors import NotFoundRequest from flask import jsonify from flask_jwt_extended import jwt_required @@ -48,21 +47,23 @@ def removePlannedRecipeById(id): def getRecentRecipes(): recipes = RecipeHistory.get_recent() return jsonify([e.recipe.obj_to_dict() for e in recipes]) - + + @app.route('/planner/suggested-recipes', methods=['GET']) def getSuggestedRecipes(): # get all unplanned recipes with positive suggestion_score - recipes = Recipe.query.filter(Recipe.planned == False).filter(Recipe.suggestion_score != 0).all() + recipes = Recipe.query.filter(Recipe.planned == False).filter( # noqa + Recipe.suggestion_score != 0).all() # compute the initial sum of all suggestion_scores suggestion_sum = 0 for r in recipes: suggestion_sum += r.suggestion_score - # randomly suggest one recipe weighted by their score + # randomly suggest one recipe weighted by their score suggested_recipes = [] while len(suggested_recipes) < 9 and len(recipes) > 0: - choose = randint(1,suggestion_sum) + choose = randint(1, suggestion_sum) to_be_removed = -1 - for (i,r) in enumerate(recipes): + for (i, r) in enumerate(recipes): choose -= r.suggestion_score if choose <= 0: suggested_recipes.append(r) @@ -72,4 +73,3 @@ def getSuggestedRecipes(): recipes.pop(to_be_removed) # jsonfy suggestions return jsonify([r.obj_to_dict() for r in suggested_recipes]) - diff --git a/backend/app/controller/recipe/__init__.py b/backend/app/controller/recipe/__init__.py index 7b3e0a99..c5b596dc 100644 --- a/backend/app/controller/recipe/__init__.py +++ b/backend/app/controller/recipe/__init__.py @@ -1 +1 @@ -from . import recipe_controller \ No newline at end of file +from . import recipe_controller diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 51f39bfd..6cf70cae 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -1,12 +1,11 @@ from app.errors import NotFoundRequest from app.models.recipe import RecipeItems -import json from flask import jsonify from flask_jwt_extended import jwt_required from app import app from app.helpers import validate_args from app.models import Recipe, Item -from .schemas import SearchByNameRequest, AddItemByName, RemoveItem, AddRecipe, UpdateRecipe +from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe @app.route('/recipe', methods=['GET']) @@ -50,7 +49,7 @@ def addRecipe(args): @app.route('/recipe/', methods=['POST']) @jwt_required() @validate_args(UpdateRecipe) -def updateRecipe(args, id): +def updateRecipe(args, id): # noqa: C901 recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() @@ -62,7 +61,7 @@ def updateRecipe(args, id): if 'items' in args: for con in recipe.items: item_names = [e['name'] for e in args['items']] - if not con.item.name in item_names: + if con.item.name not in item_names: con.delete() for recipeItem in args['items']: item = Item.find_by_name(recipeItem['name']) diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index 5257f754..39f287e1 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -8,10 +8,10 @@ class RecipeItem(Schema): validate=lambda a: len(a) > 0 ) description = fields.String( - default='' + load_default='' ) optional = fields.Boolean( - default=True + load_default=True ) name = fields.String( @@ -20,6 +20,7 @@ class RecipeItem(Schema): description = fields.String() items = fields.List(fields.Nested(RecipeItem())) + class UpdateRecipe(Schema): class RecipeItem(Schema): name = fields.String( @@ -27,12 +28,13 @@ class RecipeItem(Schema): validate=lambda a: len(a) > 0 ) description = fields.String() - optional = fields.Boolean(default=True) + optional = fields.Boolean(load_default=True) name = fields.String() description = fields.String() items = fields.List(fields.Nested(RecipeItem())) + class SearchByNameRequest(Schema): query = fields.String( required=True, diff --git a/backend/app/controller/shoppinglist/__init__.py b/backend/app/controller/shoppinglist/__init__.py index 904b2cb7..1b10a8e7 100644 --- a/backend/app/controller/shoppinglist/__init__.py +++ b/backend/app/controller/shoppinglist/__init__.py @@ -1 +1 @@ -from . import shoppinglist_controller \ No newline at end of file +from . import shoppinglist_controller diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index 9c7488bf..05727de2 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -1,5 +1,4 @@ -from marshmallow import fields, validates_schema, ValidationError, Schema -from app.models import Item +from marshmallow import fields, Schema class AddItemByName(Schema): @@ -17,10 +16,10 @@ class RecipeItem(Schema): validate=lambda a: len(a) > 0 ) description = fields.String( - default='' + load_default='' ) optional = fields.Boolean( - default=True + load_default=True ) items = fields.List(fields.Nested(RecipeItem)) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 8a81fa9b..f931a826 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -1,5 +1,4 @@ from app.models import ShoppinglistItems -import json from flask import jsonify from flask_jwt_extended import jwt_required from app import app, db @@ -7,7 +6,7 @@ from app.helpers import validate_args from .schemas import (RemoveItem, UpdateDescription, AddItemByName, CreateList, AddRecipeItems) -from app.errors import InvalidUsage, NotFoundRequest +from app.errors import NotFoundRequest from datetime import datetime, timedelta diff --git a/backend/app/controller/user/__init__.py b/backend/app/controller/user/__init__.py index 00a3b886..8a9102bb 100644 --- a/backend/app/controller/user/__init__.py +++ b/backend/app/controller/user/__init__.py @@ -1 +1 @@ -from . import user_controller \ No newline at end of file +from . import user_controller diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py index ea3a6041..e40c671e 100644 --- a/backend/app/controller/user/schemas.py +++ b/backend/app/controller/user/schemas.py @@ -1,5 +1,6 @@ from marshmallow import fields, Schema + class CreateUser(Schema): name = fields.String( required=True, @@ -16,6 +17,7 @@ class CreateUser(Schema): load_only=True, ) + class UpdateUser(Schema): name = fields.String( validate=lambda a: len(a) > 0 @@ -27,4 +29,4 @@ class UpdateUser(Schema): password = fields.String( validate=lambda a: len(a) > 0, load_only=True, - ) \ No newline at end of file + ) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 3724ccea..ea99be46 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -1,7 +1,6 @@ from app.errors import NotFoundRequest, UnauthorizedRequest from app.helpers.admin_required import admin_required from app.helpers import validate_args -import json from flask import jsonify from flask_jwt_extended import jwt_required, get_jwt_identity from app import app diff --git a/backend/app/errors/__init__.py b/backend/app/errors/__init__.py index 53763a38..60baef28 100644 --- a/backend/app/errors/__init__.py +++ b/backend/app/errors/__init__.py @@ -3,17 +3,20 @@ def __init__(self, message="Invalid usage"): super(InvalidUsage, self).__init__(message) self.message = message + class UnauthorizedRequest(Exception): def __init__(self, message="Request unauthorized"): super(UnauthorizedRequest, self).__init__(message) self.message = message + class ForbiddenRequest(Exception): def __init__(self, message="Request forbidden"): super(ForbiddenRequest, self).__init__(message) self.message = message + class NotFoundRequest(Exception): def __init__(self, message="Requested resource not found"): super(NotFoundRequest, self).__init__(message) - self.message = message \ No newline at end of file + self.message = message diff --git a/backend/app/helpers/__init__.py b/backend/app/helpers/__init__.py index be0fa287..f95e51f0 100644 --- a/backend/app/helpers/__init__.py +++ b/backend/app/helpers/__init__.py @@ -1,4 +1,4 @@ from .db_model_mixin import DbModelMixin from .timestamp_mixin import TimestampMixin from .validate_args import validate_args -from .admin_required import admin_required \ No newline at end of file +from .admin_required import admin_required diff --git a/backend/app/helpers/db_model_mixin.py b/backend/app/helpers/db_model_mixin.py index 0291b62f..0f69d9ef 100644 --- a/backend/app/helpers/db_model_mixin.py +++ b/backend/app/helpers/db_model_mixin.py @@ -142,7 +142,7 @@ def all(cls): Return all instances of model """ return cls.query.order_by(cls.id).all() - + @classmethod def all_by_name(cls): """ diff --git a/backend/app/helpers/validate_args.py b/backend/app/helpers/validate_args.py index bb54a3ce..39de88bc 100644 --- a/backend/app/helpers/validate_args.py +++ b/backend/app/helpers/validate_args.py @@ -1,16 +1,16 @@ from marshmallow.exceptions import ValidationError from app.errors import InvalidUsage from flask import request -from app.config import app from functools import wraps + def validate_args(schema_cls): def validate(func): @wraps(func) def func_wrapper(*args, **kwargs): if not schema_cls: raise Exception("Invalid usage. Schema class missing") - + if request.method == 'GET': request_data = request.args load_fn = schema_cls().load diff --git a/backend/app/jobs/clusterShoppings.py b/backend/app/jobs/cluster_shoppings.py similarity index 100% rename from backend/app/jobs/clusterShoppings.py rename to backend/app/jobs/cluster_shoppings.py diff --git a/backend/app/jobs/itemOrdering.py b/backend/app/jobs/item_ordering.py similarity index 100% rename from backend/app/jobs/itemOrdering.py rename to backend/app/jobs/item_ordering.py diff --git a/backend/app/jobs/itemSuggestions.py b/backend/app/jobs/item_suggestions.py similarity index 100% rename from backend/app/jobs/itemSuggestions.py rename to backend/app/jobs/item_suggestions.py diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index f3fdeb85..e096ea0b 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -1,13 +1,13 @@ -from app.jobs.recipeSuggestions import findMealInstancesFromHistory, computeRecipeSuggestions, findMealInstancesFromHistory +from app.jobs.recipe_suggestions import findMealInstancesFromHistory, computeRecipeSuggestions from app import app, scheduler -from .itemOrdering import findItemOrdering -from .itemSuggestions import findItemSuggestions -from .clusterShoppings import clusterShoppings +from .item_ordering import findItemOrdering +from .item_suggestions import findItemSuggestions +from .cluster_shoppings import clusterShoppings @app.before_first_request def load_jobs(): - #for debugging: + # for debugging: # @scheduler.task('interval', id='test', seconds=5) # def test(): # app.logger.info("--- test analysis is starting ---") diff --git a/backend/app/jobs/recipeSuggestions.py b/backend/app/jobs/recipe_suggestions.py similarity index 89% rename from backend/app/jobs/recipeSuggestions.py rename to backend/app/jobs/recipe_suggestions.py index 7525f50c..bc5d70e2 100644 --- a/backend/app/jobs/recipeSuggestions.py +++ b/backend/app/jobs/recipe_suggestions.py @@ -1,17 +1,19 @@ -from app.models import Recipe,RecipeHistory +from app.models import Recipe, RecipeHistory from app import app, db import datetime # minimum hours on planner until a recipe is considered to have been cooked -MEAL_THRESHOLD = 3 +MEAL_THRESHOLD = 3 + def findMealInstancesFromHistory(): return findMealInstances( - RecipeHistory.find_added(), + RecipeHistory.find_added(), RecipeHistory.find_dropped()) -def findMealInstances(added, dropped): + +def findMealInstances(added, dropped): # pointers for added and dropped recipes # both lists are marched through together in chronological order added_pointer = 0 @@ -34,12 +36,12 @@ def findMealInstances(added, dropped): # compute duration while recipe on the planner added_time = added_recipes[current_dropped.recipe_id] dropped_time = current_dropped.created_at - # if duration threshold is met, consider it as a cooked meal - if(dropped_time - added_time >= + # if duration threshold is met, consider it as a cooked meal + if(dropped_time - added_time >= datetime.timedelta(hours=MEAL_THRESHOLD)): meal = { - "recipe_id":current_dropped.recipe_id, - "cooked_at":dropped_time} + "recipe_id": current_dropped.recipe_id, + "cooked_at": dropped_time} meals.append(meal) # proceed to next dropped recipe @@ -47,7 +49,7 @@ def findMealInstances(added, dropped): dropped_pointer += 1 # break if no more dropped recipes if not (dropped_pointer < len(dropped)): - break + break current_dropped = dropped[dropped_pointer] app.logger.info("meal instances are identified") return meals @@ -75,7 +77,7 @@ def computeRecipeSuggestions(meal_instances): cooking_count += 1 # set suggestion_score to cooking_count Recipe.find_by_id(id).suggestion_score = cooking_count - + # 2) do not suggest recent meals week_ago = datetime.datetime.now() - datetime.timedelta(days=7) # find recently cooked meals diff --git a/backend/app/jobs/test_recipeSuggestions.py b/backend/app/jobs/test_recipeSuggestions.py deleted file mode 100644 index b1df95c8..00000000 --- a/backend/app/jobs/test_recipeSuggestions.py +++ /dev/null @@ -1,64 +0,0 @@ -from .recipeSuggestions import findMealInstances - -import pytest -import datetime - - -#print(RecipeHistory.find_added()) - -start_time = datetime.datetime(2021,8,14,14) -def time_diff(h): - return datetime.timedelta(hours=h) - -Added = [ - {"recipe_id":1,"created_at":start_time}, - {"recipe_id":2,"created_at":start_time+time_diff(2)}, - {"recipe_id":3,"created_at":start_time+time_diff(2)}, - {"recipe_id":4,"created_at":start_time+time_diff(2)}, - {"recipe_id":5,"created_at":start_time+time_diff(2)}, - {"recipe_id":6,"created_at":start_time+time_diff(2)}, - {"recipe_id":7,"created_at":start_time+time_diff(2)}, - {"recipe_id":1,"created_at":start_time+time_diff(3)}, -] - -Dropped = [ - {"recipe_id":1,"created_at":start_time+time_diff(3)}, - {"recipe_id":2,"created_at":start_time+time_diff(3)}, - {"recipe_id":5,"created_at":start_time+time_diff(5)}, - {"recipe_id":4,"created_at":start_time+time_diff(5)}, - {"recipe_id":7,"created_at":start_time+time_diff(5)}, - {"recipe_id":1,"created_at":start_time+time_diff(6)}, -] - -ExpectedMeals = [ - {"recipe_id":1,"cooked_at":start_time+time_diff(3)}, - {"recipe_id":5,"cooked_at":start_time+time_diff(5)}, - {"recipe_id":4,"cooked_at":start_time+time_diff(5)}, - {"recipe_id":7,"cooked_at":start_time+time_diff(5)}, - {"recipe_id":1,"cooked_at":start_time+time_diff(6)}, -] - -# used to access dict with object syntax -class objectview(object): - def __init__(self, d): - self.__dict__ = d - -@pytest.mark.parametrize("added,dropped,expectedMeals",[ - # empty added list - ([],[],[]), - # empty dropped list - (Added[:1],[],[]), - # single meal - (Added[:1],Dropped[:1],ExpectedMeals[:1]), - # single meal but dropped recipes left - (Added[:1],Dropped[:1]+Dropped[:1],ExpectedMeals[:1]), - # no meal as duration too short - (Added[1:2],Dropped[1:2],[]), - # complete example - (Added,Dropped,ExpectedMeals), - ]) -def testFindMealInstances(added, dropped, expectedMeals): - actualMeals = findMealInstances( - [objectview(a) for a in added], - [objectview(d) for d in dropped]) - assert actualMeals == expectedMeals diff --git a/backend/app/models/history.py b/backend/app/models/history.py index 0c089847..a8d85532 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -1,7 +1,6 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin -from .item import Item -from .shoppinglist import Shoppinglist, ShoppinglistItems +from .shoppinglist import ShoppinglistItems from sqlalchemy import func import enum diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py index 000d7830..3ef3d5f8 100644 --- a/backend/app/models/recipe_history.py +++ b/backend/app/models/recipe_history.py @@ -1,7 +1,6 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin from .recipe import Recipe -from .shoppinglist import Shoppinglist from sqlalchemy import func import enum diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py index 69e2ec69..bda5937a 100644 --- a/backend/app/models/shoppinglist.py +++ b/backend/app/models/shoppinglist.py @@ -1,6 +1,5 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin -from .item import Item class Shoppinglist(db.Model, DbModelMixin, TimestampMixin): @@ -36,4 +35,4 @@ def obj_to_item_dict(self): @classmethod def find_by_ids(cls, shoppinglist_id, item_id): - return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.item_id == item_id).first() \ No newline at end of file + return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.item_id == item_id).first() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..555f6837 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +import app diff --git a/backend/tests/test_recipe_suggestions.py b/backend/tests/test_recipe_suggestions.py new file mode 100644 index 00000000..69c89711 --- /dev/null +++ b/backend/tests/test_recipe_suggestions.py @@ -0,0 +1,69 @@ +from app.jobs.recipe_suggestions import findMealInstances +import pytest +import datetime + + +# print(RecipeHistory.find_added()) + +start_time = datetime.datetime(2021, 8, 14, 14) + + +def time_diff(h): + return datetime.timedelta(hours=h) + + +Added = [ + {"recipe_id": 1, "created_at": start_time}, + {"recipe_id": 2, "created_at": start_time+time_diff(2)}, + {"recipe_id": 3, "created_at": start_time+time_diff(2)}, + {"recipe_id": 4, "created_at": start_time+time_diff(2)}, + {"recipe_id": 5, "created_at": start_time+time_diff(2)}, + {"recipe_id": 6, "created_at": start_time+time_diff(2)}, + {"recipe_id": 7, "created_at": start_time+time_diff(2)}, + {"recipe_id": 1, "created_at": start_time+time_diff(3)}, +] + +Dropped = [ + {"recipe_id": 1, "created_at": start_time+time_diff(3)}, + {"recipe_id": 2, "created_at": start_time+time_diff(3)}, + {"recipe_id": 5, "created_at": start_time+time_diff(5)}, + {"recipe_id": 4, "created_at": start_time+time_diff(5)}, + {"recipe_id": 7, "created_at": start_time+time_diff(5)}, + {"recipe_id": 1, "created_at": start_time+time_diff(6)}, +] + +ExpectedMeals = [ + {"recipe_id": 1, "cooked_at": start_time+time_diff(3)}, + {"recipe_id": 5, "cooked_at": start_time+time_diff(5)}, + {"recipe_id": 4, "cooked_at": start_time+time_diff(5)}, + {"recipe_id": 7, "cooked_at": start_time+time_diff(5)}, + {"recipe_id": 1, "cooked_at": start_time+time_diff(6)}, +] + +# used to access dict with object syntax + + +class objectview(object): + def __init__(self, d): + self.__dict__ = d + + +@pytest.mark.parametrize("added,dropped,expectedMeals", [ + # empty added list + ([], [], []), + # empty dropped list + (Added[:1], [], []), + # single meal + (Added[:1], Dropped[:1], ExpectedMeals[:1]), + # single meal but dropped recipes left + (Added[:1], Dropped[:1]+Dropped[:1], ExpectedMeals[:1]), + # no meal as duration too short + (Added[1:2], Dropped[1:2], []), + # complete example + (Added, Dropped, ExpectedMeals), +]) +def testFindMealInstances(added, dropped, expectedMeals): + actualMeals = findMealInstances( + [objectview(a) for a in added], + [objectview(d) for d in dropped]) + assert actualMeals == expectedMeals From ef96852f50731d4c814a394ec6666f38294fd746 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 17 Aug 2021 13:29:02 +0200 Subject: [PATCH 046/496] feat: CI pytesting --- .github/workflows/pytest.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/pytest.yml diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 00000000..3a63ef49 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,36 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Pytesting + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 app tests --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 app tests --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest From d5e38018a2ad6d5ef31dbd61b7791bb012be7acf Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 17 Aug 2021 13:35:37 +0200 Subject: [PATCH 047/496] fix: downgrade tzlocal --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 38778c9d..a0f5a374 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -54,5 +54,5 @@ threadpoolctl==2.2.0 toml==0.10.2 typed-ast==1.4.3 typing-extensions==3.10.0.0 -tzlocal==3.0 +tzlocal==2.0 Werkzeug==2.0.1 From 7c4b9f392b8048cb61b4ba99b16198e9f8b45b97 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 17 Aug 2021 15:14:33 +0200 Subject: [PATCH 048/496] fix: endpoint authentication --- backend/app/controller/planner/planner_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 1c2ddb9b..764da3d7 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -50,6 +50,7 @@ def getRecentRecipes(): @app.route('/planner/suggested-recipes', methods=['GET']) +@jwt_required() def getSuggestedRecipes(): # get all unplanned recipes with positive suggestion_score recipes = Recipe.query.filter(Recipe.planned == False).filter( # noqa From fd473031a0427f94790c3cd797a1449f301a39cf Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 17 Aug 2021 15:31:47 +0200 Subject: [PATCH 049/496] change versioning to tag based --- .github/workflows/ci_to_docker_hub.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci_to_docker_hub.yml b/.github/workflows/ci_to_docker_hub.yml index 6e22085b..b5079a30 100644 --- a/.github/workflows/ci_to_docker_hub.yml +++ b/.github/workflows/ci_to_docker_hub.yml @@ -6,7 +6,8 @@ name: CI to Docker Hub on: # Triggers the workflow on push events but only for the stable branch push: - branches: [stable] + tags: + - "v*" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -47,7 +48,7 @@ jobs: context: ./ file: ./Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:latest + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:latest, ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:${{ github.ref }} cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache From 971b29b6b81dfc2aeb415344b15b615dd2a59ce4 Mon Sep 17 00:00:00 2001 From: Constantin Date: Wed, 18 Aug 2021 10:25:32 +0200 Subject: [PATCH 050/496] feat: stable suggestions in '/planner/suggested-recipes', new suggestions in '/planner/refresh-suggested-recipes' --- .../controller/planner/planner_controller.py | 33 ++++++----------- backend/app/jobs/recipe_suggestions.py | 4 +++ backend/app/models/recipe.py | 35 +++++++++++++++++++ backend/migrations/versions/3d3333ffb91e_.py | 28 +++++++++++++++ 4 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 backend/migrations/versions/3d3333ffb91e_.py diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 764da3d7..be78cad0 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -5,7 +5,6 @@ from app.helpers import validate_args from app.models import Recipe, RecipeHistory from .schemas import AddPlannedRecipe -from random import randint @app.route('/planner/recipes', methods=['GET']) @@ -52,25 +51,15 @@ def getRecentRecipes(): @app.route('/planner/suggested-recipes', methods=['GET']) @jwt_required() def getSuggestedRecipes(): - # get all unplanned recipes with positive suggestion_score - recipes = Recipe.query.filter(Recipe.planned == False).filter( # noqa - Recipe.suggestion_score != 0).all() - # compute the initial sum of all suggestion_scores - suggestion_sum = 0 - for r in recipes: - suggestion_sum += r.suggestion_score - # randomly suggest one recipe weighted by their score - suggested_recipes = [] - while len(suggested_recipes) < 9 and len(recipes) > 0: - choose = randint(1, suggestion_sum) - to_be_removed = -1 - for (i, r) in enumerate(recipes): - choose -= r.suggestion_score - if choose <= 0: - suggested_recipes.append(r) - suggestion_sum -= r.suggestion_score - to_be_removed = i - break - recipes.pop(to_be_removed) - # jsonfy suggestions + suggested_recipes = Recipe.find_suggestions() + return jsonify([r.obj_to_dict() for r in suggested_recipes]) + + +@app.route('/planner/refresh-suggested-recipes', methods=['GET']) +@jwt_required() +def getRefreshedSuggestedRecipes(): + # re-compute suggestion ranking + Recipe.compute_suggestion_ranking() + # return suggested recipes + suggested_recipes = Recipe.find_suggestions() return jsonify([r.obj_to_dict() for r in suggested_recipes]) diff --git a/backend/app/jobs/recipe_suggestions.py b/backend/app/jobs/recipe_suggestions.py index bc5d70e2..5c48e7b1 100644 --- a/backend/app/jobs/recipe_suggestions.py +++ b/backend/app/jobs/recipe_suggestions.py @@ -89,3 +89,7 @@ def computeRecipeSuggestions(meal_instances): # commit changes to db db.session.commit() app.logger.info("computed and stored new suggestion scores") + + # compute new suggestion ranking + Recipe.compute_suggestion_ranking() + app.logger.info("computed and stored new suggestion ranking") diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 204ef72b..5a1d7daf 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -1,6 +1,7 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin from .item import Item +from random import randint class Recipe(db.Model, DbModelMixin, TimestampMixin): @@ -12,6 +13,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): photo = db.Column(db.String()) planned = db.Column(db.Boolean) suggestion_score = db.Column(db.Integer, server_default='0') + suggestion_rank = db.Column(db.Integer, server_default='0') recipe_history = db.relationship( "RecipeHistory", back_populates="recipe", cascade="all, delete-orphan") @@ -37,6 +39,39 @@ def obj_to_export_dict(self): } return res + @classmethod + def compute_suggestion_ranking(cls): + # reset all suggestion ranks + for r in cls.all(): + r.suggestion_rank = 0 + # get all recipes with positive suggestion_score + recipes = cls.query.filter( # noqa + cls.suggestion_score != 0).all() + # compute the initial sum of all suggestion_scores + suggestion_sum = 0 + for r in recipes: + suggestion_sum += r.suggestion_score + # iteratively assign increasing suggestion rank to random recipes weighted by their score + current_rank = 1 + while len(recipes) > 0: + choose = randint(1, suggestion_sum) + to_be_removed = -1 + for (i, r) in enumerate(recipes): + choose -= r.suggestion_score + if choose <= 0: + r.suggestion_rank = current_rank + current_rank += 1 + suggestion_sum -= r.suggestion_score + to_be_removed = i + break + recipes.pop(to_be_removed) + db.session.commit() + + @classmethod + def find_suggestions(cls): + return cls.query.filter(cls.planned == False).filter( # noqa + cls.suggestion_rank > 0).order_by(cls.suggestion_rank).limit(9).all() + @classmethod def find_by_name(cls, name): return cls.query.filter(cls.name == name).first() diff --git a/backend/migrations/versions/3d3333ffb91e_.py b/backend/migrations/versions/3d3333ffb91e_.py new file mode 100644 index 00000000..9894aedb --- /dev/null +++ b/backend/migrations/versions/3d3333ffb91e_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 3d3333ffb91e +Revises: 6c1be50bb858 +Create Date: 2021-08-18 10:13:11.745182 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3d3333ffb91e' +down_revision = '6c1be50bb858' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('suggestion_rank', sa.Integer(), server_default='0', nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'suggestion_rank') + # ### end Alembic commands ### From 079f235a42f85536f06e19285276f7a68003f2fd Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 19 Aug 2021 11:17:16 +0200 Subject: [PATCH 051/496] fix: ignore additional recipe item information --- backend/app/controller/shoppinglist/schemas.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index 05727de2..bbb0ba8f 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -1,4 +1,4 @@ -from marshmallow import fields, Schema +from marshmallow import fields, Schema, EXCLUDE class AddItemByName(Schema): @@ -10,6 +10,8 @@ class AddItemByName(Schema): class AddRecipeItems(Schema): class RecipeItem(Schema): + class Meta: + unknown = EXCLUDE id = fields.Integer(required=True) name = fields.String( required=True, From 7852d1665d61fd0ee9595075eb2cb5522f79ca0c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 20 Aug 2021 12:05:29 +0200 Subject: [PATCH 052/496] Prepare release 10 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6506f88b..0d2ed23e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 9 +BACKEND_VERSION = 10 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 20456de4909258ffff1452d92ed7184eccc3ae46 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 20 Aug 2021 12:07:29 +0200 Subject: [PATCH 053/496] fix: github actions --- .github/workflows/ci_to_docker_hub.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci_to_docker_hub.yml b/.github/workflows/ci_to_docker_hub.yml index b5079a30..42ece0a0 100644 --- a/.github/workflows/ci_to_docker_hub.yml +++ b/.github/workflows/ci_to_docker_hub.yml @@ -48,7 +48,7 @@ jobs: context: ./ file: ./Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:latest, ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:${{ github.ref }} + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:latest cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache From de8f2ddab828b92de2d7175307d3c5129d2fda58 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 6 Sep 2021 10:56:17 +0200 Subject: [PATCH 054/496] Update action push to docker hub --- .github/workflows/ci_to_docker_hub.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci_to_docker_hub.yml b/.github/workflows/ci_to_docker_hub.yml index 42ece0a0..079cbb21 100644 --- a/.github/workflows/ci_to_docker_hub.yml +++ b/.github/workflows/ci_to_docker_hub.yml @@ -37,6 +37,9 @@ jobs: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v1 @@ -47,6 +50,7 @@ jobs: with: context: ./ file: ./Dockerfile + platforms: linux/amd64,linux/arm64/v8,linux/arm/v7 push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:latest cache-from: type=local,src=/tmp/.buildx-cache From cc6b0c76909c3bba6b29b0c03155855d1a788f40 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 6 Sep 2021 12:29:30 +0200 Subject: [PATCH 055/496] fix: github actions --- .github/workflows/ci_to_docker_hub.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci_to_docker_hub.yml b/.github/workflows/ci_to_docker_hub.yml index 079cbb21..22393be8 100644 --- a/.github/workflows/ci_to_docker_hub.yml +++ b/.github/workflows/ci_to_docker_hub.yml @@ -50,7 +50,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64/v8,linux/arm/v7 + platforms: linux/amd64,linux/arm64 push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:latest cache-from: type=local,src=/tmp/.buildx-cache From 5b68e4e37f8c229eaaf862b8e05a4af97473cd9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Sep 2021 16:13:54 +0200 Subject: [PATCH 056/496] chore(deps): bump pillow from 8.3.1 to 8.3.2 (TomBursch/kitchenowl-backend#4) Bumps [pillow](https://github.com/python-pillow/Pillow) from 8.3.1 to 8.3.2. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/master/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/8.3.1...8.3.2) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index a0f5a374..8d8e017d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -33,7 +33,7 @@ numpy==1.21.2 packaging==21.0 pandas==1.3.2 pathspec==0.9.0 -Pillow==8.3.1 +Pillow==8.3.2 pluggy==0.13.1 py==1.10.0 pycodestyle==2.7.0 From ad13e790aac18b92bdf5391edc01a1040b3272ac Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 16 Sep 2021 14:04:20 +0200 Subject: [PATCH 057/496] chore: update dependencies --- backend/requirements.txt | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 8d8e017d..bee72e65 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -alembic==1.6.5 +alembic==1.7.1 appdirs==1.4.4 APScheduler==3.7.0 attrs==21.2.0 @@ -13,7 +13,7 @@ flake8==3.9.2 Flask==2.0.1 Flask-APScheduler==1.12.2 Flask-Bcrypt==0.7.1 -Flask-JWT-Extended==4.2.3 +Flask-JWT-Extended==4.3.0 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 greenlet==1.1.1 @@ -21,38 +21,38 @@ iniconfig==1.1.1 itsdangerous==2.0.1 Jinja2==3.0.1 joblib==1.0.1 -kiwisolver==1.3.1 -Mako==1.1.4 +kiwisolver==1.3.2 +Mako==1.1.5 MarkupSafe==2.0.1 marshmallow==3.13.0 matplotlib==3.4.3 mccabe==0.6.1 -mlxtend==0.18.0 +mlxtend==0.19.0 mypy-extensions==0.4.3 numpy==1.21.2 packaging==21.0 -pandas==1.3.2 +pandas==1.3.3 pathspec==0.9.0 Pillow==8.3.2 -pluggy==0.13.1 +pluggy==1.0.0 py==1.10.0 pycodestyle==2.7.0 pycparser==2.20 pyflakes==2.3.1 PyJWT==2.1.0 pyparsing==2.4.7 -pytest==6.2.4 +pytest==6.2.5 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2021.1 -regex==2021.8.3 +regex==2021.8.28 scikit-learn==0.24.2 scipy==1.7.1 six==1.16.0 -SQLAlchemy==1.4.22 +SQLAlchemy==1.4.23 threadpoolctl==2.2.0 toml==0.10.2 typed-ast==1.4.3 -typing-extensions==3.10.0.0 -tzlocal==2.0 +typing-extensions==3.10.0.2 +tzlocal~=2.0 Werkzeug==2.0.1 From 7a33bb100186d1e3c21663f6d4cf797718cb92c9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 16 Sep 2021 14:06:07 +0200 Subject: [PATCH 058/496] Prepare release 11 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 0d2ed23e..dee3beca 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 10 +BACKEND_VERSION = 11 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From b1153bb300c41dfa473e47643555fc147775a621 Mon Sep 17 00:00:00 2001 From: Constantin Date: Tue, 28 Sep 2021 15:21:26 +0200 Subject: [PATCH 059/496] fixed small error: ignored all cooked meals older than one week wanted: other way around --- backend/app/jobs/recipe_suggestions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/jobs/recipe_suggestions.py b/backend/app/jobs/recipe_suggestions.py index 5c48e7b1..dea181ec 100644 --- a/backend/app/jobs/recipe_suggestions.py +++ b/backend/app/jobs/recipe_suggestions.py @@ -83,7 +83,7 @@ def computeRecipeSuggestions(meal_instances): # find recently cooked meals for id in meal_hist: for cooked in meal_hist[id]: - if cooked < week_ago: + if cooked > week_ago: Recipe.find_by_id(id).suggestion_score = 0 # commit changes to db From 03d97386f9748806b8688074ca6db226b43dd144 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 29 Sep 2021 13:58:38 +0200 Subject: [PATCH 060/496] feat: server settings --- backend/app/controller/__init__.py | 1 + backend/app/controller/health_controller.py | 16 ++++++++- backend/app/controller/settings/__init__.py | 1 + backend/app/controller/settings/schemas.py | 6 ++++ .../settings/settings_controller.py | 26 ++++++++++++++ backend/app/models/__init__.py | 1 + backend/app/models/settings.py | 17 ++++++++++ backend/migrations/versions/75e1eb3635c6_.py | 34 +++++++++++++++++++ 8 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 backend/app/controller/settings/__init__.py create mode 100644 backend/app/controller/settings/schemas.py create mode 100644 backend/app/controller/settings/settings_controller.py create mode 100644 backend/app/models/settings.py create mode 100644 backend/migrations/versions/75e1eb3635c6_.py diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 34108970..db029000 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -6,4 +6,5 @@ from . import planner from . import onboarding from . import exportimport +from . import settings from . import health_controller diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 6d60d36b..4b668ae1 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -1,11 +1,25 @@ from flask import jsonify from app import app from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION +from app.models import Settings +from flask_jwt_extended import jwt_required, get_jwt_identity @app.route( '/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V', methods=['GET'] ) +@jwt_required(optional=True) def get_health(): - return jsonify({'msg': "OK", 'version': BACKEND_VERSION, 'min_frontend_version': MIN_FRONTEND_VERSION}) + info = { + 'msg': "OK", + 'version': BACKEND_VERSION, + 'min_frontend_version': MIN_FRONTEND_VERSION, + } + if get_jwt_identity(): + settings = Settings.get() + info.update({ + 'planner_feature': settings.planner_feature, + 'expenses_feature': settings.expenses_feature, + }) + return jsonify(info) diff --git a/backend/app/controller/settings/__init__.py b/backend/app/controller/settings/__init__.py new file mode 100644 index 00000000..46de94ee --- /dev/null +++ b/backend/app/controller/settings/__init__.py @@ -0,0 +1 @@ +from . import settings_controller diff --git a/backend/app/controller/settings/schemas.py b/backend/app/controller/settings/schemas.py new file mode 100644 index 00000000..6b5b0845 --- /dev/null +++ b/backend/app/controller/settings/schemas.py @@ -0,0 +1,6 @@ +from marshmallow import fields, Schema + + +class SetSettingsSchema(Schema): + planner_feature = fields.List(fields.Boolean()) + expenses_feature = fields.List(fields.Boolean()) diff --git a/backend/app/controller/settings/settings_controller.py b/backend/app/controller/settings/settings_controller.py new file mode 100644 index 00000000..e5194d93 --- /dev/null +++ b/backend/app/controller/settings/settings_controller.py @@ -0,0 +1,26 @@ +from .schemas import SetSettingsSchema +from app.helpers import validate_args, admin_required +from flask import jsonify +from flask_jwt_extended import jwt_required +from app import app +from app.models import Settings + + +@app.route('/settings', methods=['POST']) +@jwt_required() +@admin_required +@validate_args(SetSettingsSchema) +def setSettings(args): + settings = Settings.get() + if 'planner_feature' in args: + settings.planner_feature = args['planner_feature'] + if 'expenses_feature' in args: + settings.expenses_feature = args['expenses_feature'] + settings.save() + return jsonify(settings.obj_to_dict()) + + +@app.route('/settings', methods=['GET']) +@jwt_required() +def getSettings(): + return jsonify(Settings.get().obj_to_dict()) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6195b034..825d7c6b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from .user import User from .item import Item from .association import Association +from .settings import Settings from .history import History, Status from .recipe import RecipeItems, Recipe from .shoppinglist import ShoppinglistItems, Shoppinglist diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 00000000..c6e7252b --- /dev/null +++ b/backend/app/models/settings.py @@ -0,0 +1,17 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class Settings(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'settings' + + planner_feature = db.Column(db.Boolean(), primary_key=True, default=True) + expenses_feature = db.Column(db.Boolean(), primary_key=True, default=True) + + @classmethod + def get(cls): + settings = cls.query.first() + if not settings: + settings = cls() + settings.save() + return settings diff --git a/backend/migrations/versions/75e1eb3635c6_.py b/backend/migrations/versions/75e1eb3635c6_.py new file mode 100644 index 00000000..e2c36a43 --- /dev/null +++ b/backend/migrations/versions/75e1eb3635c6_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 75e1eb3635c6 +Revises: 3d3333ffb91e +Create Date: 2021-09-29 12:27:21.777936 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '75e1eb3635c6' +down_revision = '3d3333ffb91e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('settings', + sa.Column('planner_feature', sa.Boolean(), nullable=False), + sa.Column('expenses_feature', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('planner_feature', 'expenses_feature') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('settings') + # ### end Alembic commands ### From 29efddb9e987498a0e4f293624c2e2e47f3fbc3c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 29 Sep 2021 15:20:36 +0200 Subject: [PATCH 061/496] feat: Initial expense tracking --- backend/app/controller/__init__.py | 1 + backend/app/controller/expense/__init__.py | 1 + .../controller/expense/expense_controller.py | 119 ++++++++++++++++++ backend/app/controller/expense/schemas.py | 43 +++++++ backend/app/models/__init__.py | 1 + backend/app/models/expense.py | 55 ++++++++ backend/app/models/user.py | 7 ++ backend/migrations/versions/681d624f0d5f_.py | 51 ++++++++ 8 files changed, 278 insertions(+) create mode 100644 backend/app/controller/expense/__init__.py create mode 100644 backend/app/controller/expense/expense_controller.py create mode 100644 backend/app/controller/expense/schemas.py create mode 100644 backend/app/models/expense.py create mode 100644 backend/migrations/versions/681d624f0d5f_.py diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index db029000..4eba45d3 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -7,4 +7,5 @@ from . import onboarding from . import exportimport from . import settings +from . import expense from . import health_controller diff --git a/backend/app/controller/expense/__init__.py b/backend/app/controller/expense/__init__.py new file mode 100644 index 00000000..1a76b658 --- /dev/null +++ b/backend/app/controller/expense/__init__.py @@ -0,0 +1 @@ +from . import expense_controller diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py new file mode 100644 index 00000000..17e8c1ff --- /dev/null +++ b/backend/app/controller/expense/expense_controller.py @@ -0,0 +1,119 @@ +from app.errors import NotFoundRequest +from flask import jsonify +from flask_jwt_extended import jwt_required +from app import app +from sqlalchemy import func +from app.helpers import validate_args, admin_required +from app.models import Expense, ExpensePaidFor, User +from .schemas import AddExpense, UpdateExpense + + +@app.route('/expense', methods=['GET']) +@jwt_required() +def getAllExpenses(): + return jsonify([e.obj_to_full_dict() for e in Expense.all()]) + + +@app.route('/expense/', methods=['GET']) +@jwt_required() +def getExpenseById(id): + expense = Expense.find_by_id(id) + if not expense: + raise NotFoundRequest() + return jsonify(expense.obj_to_full_dict()) + + +@app.route('/expense', methods=['POST']) +@jwt_required() +@validate_args(AddExpense) +def addExpense(args): + user = User.find_by_id(args['paid_by']['id']) + if not user: + raise NotFoundRequest() + expense = Expense() + expense.name = args['name'] + expense.amount = args['amount'] + expense.paid_by = user + expense.save() + user.expense_balance = (user.expense_balance or 0) + expense.amount + user.save() + factor_sum = 0 + for user_data in args['paid_for']: + if User.find_by_id(user_data['id']): + factor_sum += user_data['factor'] + for user_data in args['paid_for']: + user_for = User.find_by_id(user_data['id']) + if user_for: + con = ExpensePaidFor( + factor=user_data['factor'], + ) + con.user = user_for + con.expense = expense + con.save() + user_for.expense_balance = ( + user_for.expense_balance or 0) - (con.factor / factor_sum) * expense.amount + user_for.save() + return jsonify(expense.obj_to_dict()) + + +@app.route('/expense/', methods=['POST']) +@jwt_required() +@validate_args(UpdateExpense) +def updateExpense(args, id): # noqa: C901 + expense = Expense.find_by_id(id) + if not expense: + raise NotFoundRequest() + if 'name' in args and args['name']: + expense.name = args['name'] + if 'amount' in args and args['amount']: + expense.amount = args['amount'] + if 'paid_by' in args and args['paid_by']: + user = User.find_by_id(args['paid_by']['id']) + if user: + expense.paid_by = user + expense.save() + if 'paid_for' in args: + for con in expense.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']: + user = User.find_by_name(user_data['id']) + if user: + con = ExpensePaidFor.find_by_ids(expense.id, user.id) + if con: + if 'factor' in user_data and user_data['factor']: + con.factor = user_data['factor'] + else: + con = ExpensePaidFor( + factor=user_data['factor'], + ) + con.expense = expense + con.user = user + con.save() + calculateBalances() + return jsonify(expense.obj_to_dict()) + + +@app.route('/expense/', methods=['DELETE']) +@jwt_required() +def deleteExpenseById(id): + Expense.delete_by_id(id) + calculateBalances() + return jsonify({'msg': 'DONE'}) + + +@app.route('/expense/recalculate-balances') +@jwt_required() +@admin_required +def calculateBalances(): + for user in User.all(): + user.expense_balance = float(Expense.query.with_entities(func.sum( + Expense.amount).label("balance")).filter(Expense.paid_by == user).first().balance or 0) + for expense in ExpensePaidFor.query.filter(ExpensePaidFor.user_id == user.id).all(): + factor_sum = Expense.query.with_entities(func.sum( + ExpensePaidFor.factor).label("factor_sum"))\ + .filter(ExpensePaidFor.expense_id == expense.expense_id).first().factor_sum + user.expense_balance = user.expense_balance - \ + (expense.factor / factor_sum) * expense.expense.amount + user.save() diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py new file mode 100644 index 00000000..f9b6d866 --- /dev/null +++ b/backend/app/controller/expense/schemas.py @@ -0,0 +1,43 @@ +from marshmallow import fields, Schema + + +class AddExpense(Schema): + class User(Schema): + id = fields.Integer( + required=True, + validate=lambda a: a > 0 + ) + name = fields.String( + validate=lambda a: len(a) > 0 + ) + factor = fields.Integer( + load_default=1 + ) + + name = fields.String( + required=True + ) + amount = fields.Float( + required=True + ) + paid_by = fields.Nested(User(), required=True) + paid_for = fields.List(fields.Nested(User()), required=True, validate=lambda a: len(a) > 0) + + +class UpdateExpense(Schema): + class User(Schema): + id = fields.Integer( + required=True, + validate=lambda a: a > 0 + ) + name = fields.String( + validate=lambda a: len(a) > 0 + ) + factor = fields.Integer( + load_default=1 + ) + + name = fields.String() + amount = fields.Float() + paid_by = fields.Nested(User()) + paid_for = fields.List(fields.Nested(User())) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 825d7c6b..260116d5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from .user import User from .item import Item from .association import Association +from .expense import Expense, ExpensePaidFor from .settings import Settings from .history import History, Status from .recipe import RecipeItems, Recipe diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py new file mode 100644 index 00000000..b0fc3c89 --- /dev/null +++ b/backend/app/models/expense.py @@ -0,0 +1,55 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class Expense(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'expense' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + amount = db.Column(db.Float()) + photo = db.Column(db.String()) + paid_by_id = db.Column(db.Integer, db.ForeignKey('user.id')) + + paid_by = db.relationship("User") + paid_for = db.relationship( + 'ExpensePaidFor', back_populates='expense', cascade="all, delete-orphan") + + def obj_to_full_dict(self): + res = super().obj_to_dict() + paidFor = ExpensePaidFor.query.filter(ExpensePaidFor.expense_id == self.id).join( + ExpensePaidFor.user).order_by( + ExpensePaidFor.expense_id).all() + res['paid_for'] = [e.obj_to_dict() for e in paidFor] + return res + + @classmethod + def find_by_name(cls, name): + return cls.query.filter(cls.name == name).first() + + @classmethod + def find_by_id(cls, id): + return cls.query.filter(cls.id == id).first() + + +class ExpensePaidFor(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'expense_paid_for' + + expense_id = db.Column(db.Integer, db.ForeignKey( + 'expense.id'), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + factor = db.Column(db.Integer()) + + expense = db.relationship("Expense", back_populates='paid_for') + user = db.relationship("User", back_populates='expenses_paid_for') + + def obj_to_user_dict(self): + res = self.user.obj_to_dict() + res['factor'] = getattr(self, 'factor') + res['created_at'] = getattr(self, 'created_at') + res['updated_at'] = getattr(self, 'updated_at') + return res + + @classmethod + def find_by_ids(cls, expense_id, user_id): + return cls.query.filter(cls.expense_id == expense_id, cls.user_id == user_id).first() diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 4d29a575..1dac64ad 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,6 +13,13 @@ class User(db.Model, DbModelMixin, TimestampMixin): photo = db.Column(db.String()) owner = db.Column(db.Boolean(), default=False) + expense_balance = db.Column(db.Float(), default=0) + + expenses_paid = db.relationship( + 'Expense', back_populates='paid_by', cascade="all, delete-orphan") + expenses_paid_for = db.relationship( + 'ExpensePaidFor', back_populates='user', cascade="all, delete-orphan") + def check_password(self, password): return bcrypt.check_password_hash(self.password, password) diff --git a/backend/migrations/versions/681d624f0d5f_.py b/backend/migrations/versions/681d624f0d5f_.py new file mode 100644 index 00000000..0e25c6de --- /dev/null +++ b/backend/migrations/versions/681d624f0d5f_.py @@ -0,0 +1,51 @@ +"""empty message + +Revision ID: 681d624f0d5f +Revises: 75e1eb3635c6 +Create Date: 2021-09-29 13:03:57.587862 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '681d624f0d5f' +down_revision = '75e1eb3635c6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('expense', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('amount', sa.Float(), nullable=True), + sa.Column('photo', sa.String(), nullable=True), + sa.Column('paid_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['paid_by_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('expense_paid_for', + sa.Column('expense_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('factor', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['expense_id'], ['expense.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('expense_id', 'user_id') + ) + op.add_column('user', sa.Column('expense_balance', sa.Float(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'expense_balance') + op.drop_table('expense_paid_for') + op.drop_table('expense') + # ### end Alembic commands ### From aadc82e1016363299392f037a326bdc8dbcee606 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 30 Sep 2021 17:07:04 +0200 Subject: [PATCH 062/496] fix: bugs --- backend/app/controller/expense/expense_controller.py | 3 ++- backend/app/controller/health_controller.py | 12 +++++------- backend/app/controller/settings/schemas.py | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 17e8c1ff..3d440a98 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -1,3 +1,4 @@ +from sqlalchemy.sql.expression import desc from app.errors import NotFoundRequest from flask import jsonify from flask_jwt_extended import jwt_required @@ -11,7 +12,7 @@ @app.route('/expense', methods=['GET']) @jwt_required() def getAllExpenses(): - return jsonify([e.obj_to_full_dict() for e in Expense.all()]) + return jsonify([e.obj_to_full_dict() for e in Expense.query.order_by(desc(Expense.id)).limit(50).all()]) @app.route('/expense/', methods=['GET']) diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 4b668ae1..2a0d3f10 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -9,17 +9,15 @@ '/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V', methods=['GET'] ) -@jwt_required(optional=True) def get_health(): info = { 'msg': "OK", 'version': BACKEND_VERSION, 'min_frontend_version': MIN_FRONTEND_VERSION, } - if get_jwt_identity(): - settings = Settings.get() - info.update({ - 'planner_feature': settings.planner_feature, - 'expenses_feature': settings.expenses_feature, - }) + settings = Settings.get() + info.update({ + 'planner_feature': settings.planner_feature, + 'expenses_feature': settings.expenses_feature, + }) return jsonify(info) diff --git a/backend/app/controller/settings/schemas.py b/backend/app/controller/settings/schemas.py index 6b5b0845..27c0c475 100644 --- a/backend/app/controller/settings/schemas.py +++ b/backend/app/controller/settings/schemas.py @@ -2,5 +2,5 @@ class SetSettingsSchema(Schema): - planner_feature = fields.List(fields.Boolean()) - expenses_feature = fields.List(fields.Boolean()) + planner_feature = fields.Boolean() + expenses_feature = fields.Boolean() From 4401f021537dd7e49091b29ed477005b37a200f4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 3 Oct 2021 23:15:49 +0200 Subject: [PATCH 063/496] fix: expense update --- backend/app/controller/expense/expense_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 3d440a98..0cfa3603 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -79,7 +79,7 @@ def updateExpense(args, id): # noqa: C901 if con.user.id not in user_ids: con.delete() for user_data in args['paid_for']: - user = User.find_by_name(user_data['id']) + user = User.find_by_id(user_data['id']) if user: con = ExpensePaidFor.find_by_ids(expense.id, user.id) if con: From 08c9f69d61f26e73a5c2b426bf71f097c7794d97 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 4 Oct 2021 12:42:08 +0200 Subject: [PATCH 064/496] Prepare release 12 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index dee3beca..6b4684f3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 11 +BACKEND_VERSION = 12 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 4834725e0623a0fc6adfba150c03202100478ae7 Mon Sep 17 00:00:00 2001 From: Constantin Date: Wed, 13 Oct 2021 16:30:40 +0200 Subject: [PATCH 065/496] merge descriptions of items add ... at the end, if more than two items --- .../shoppinglist/shoppinglist_controller.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index f931a826..7cc3f9c2 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -180,9 +180,16 @@ def addRecipeItems(args, id): description = recipeItem['description'] con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if con: - con.description = \ - description if not con.description else con.description + \ - ', ' + description + # merge descriptions + if description and con.description: + con.description = description + ', ' + con.description + elif description: + con.description = description + ', ...' + elif con.description: + if not con.description.endswith('...'): + con.description = con.description + ', ...' + else: + con.description = '...' con.save() else: con = ShoppinglistItems(description=description) From cadd778557d50ed82d3626be5631ff0415ee96fc Mon Sep 17 00:00:00 2001 From: Constantin Date: Wed, 13 Oct 2021 16:31:29 +0200 Subject: [PATCH 066/496] fix: remove description of recipe items (when changing recipe) --- backend/app/controller/recipe/recipe_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 6cf70cae..b32087a6 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -69,7 +69,7 @@ def updateRecipe(args, id): # noqa: C901 item = Item.create_by_name(recipeItem['name']) con = RecipeItems.find_by_ids(recipe.id, item.id) if con: - if 'description' in recipeItem and recipeItem['description']: + if 'description' in recipeItem: con.description = recipeItem['description'] if 'optional' in recipeItem: con.optional = recipeItem['optional'] From 3599221f503394793e41ab1b2de1a7534d42e169 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Dec 2021 13:47:30 +0100 Subject: [PATCH 067/496] feat: admin support fix: bugs --- backend/app/controller/user/schemas.py | 3 ++ .../app/controller/user/user_controller.py | 4 ++- backend/app/helpers/admin_required.py | 12 ++++++++ backend/app/models/user.py | 1 + backend/migrations/versions/718193c0581a_.py | 28 +++++++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/718193c0581a_.py diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py index e40c671e..e9d9844d 100644 --- a/backend/app/controller/user/schemas.py +++ b/backend/app/controller/user/schemas.py @@ -30,3 +30,6 @@ class UpdateUser(Schema): validate=lambda a: len(a) > 0, load_only=True, ) + admin = fields.Boolean( + load_only=True, + ) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index ea99be46..ef13fff0 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -1,5 +1,5 @@ from app.errors import NotFoundRequest, UnauthorizedRequest -from app.helpers.admin_required import admin_required +from app.helpers.admin_required import admin_required, owner_required from app.helpers import validate_args from flask import jsonify from flask_jwt_extended import jwt_required, get_jwt_identity @@ -70,6 +70,8 @@ def updateUserById(args, id): user.name = args['name'] if 'password' in args and args['password']: user.set_password(args['password']) + if 'admin' in args: + user.admin = args['admin'] or user.owner user.save() return jsonify({'msg': 'DONE'}) diff --git a/backend/app/helpers/admin_required.py b/backend/app/helpers/admin_required.py index 32d5d7ef..90b292c5 100644 --- a/backend/app/helpers/admin_required.py +++ b/backend/app/helpers/admin_required.py @@ -5,6 +5,18 @@ def admin_required(func): + @wraps(func) + def func_wrapper(*args, **kwargs): + user = User.find_by_username(get_jwt_identity()) + if not user.owner and not user.admin: + raise UnauthorizedRequest( + message='Elevated rights required' + ) + return func(*args, **kwargs) + + return func_wrapper + +def owner_required(func): @wraps(func) def func_wrapper(*args, **kwargs): if not User.find_by_username(get_jwt_identity()).owner: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 1dac64ad..41d8d462 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -12,6 +12,7 @@ class User(db.Model, DbModelMixin, TimestampMixin): password = db.Column(db.String(256), nullable=False) photo = db.Column(db.String()) owner = db.Column(db.Boolean(), default=False) + admin = db.Column(db.Boolean(), default=False) expense_balance = db.Column(db.Float(), default=0) diff --git a/backend/migrations/versions/718193c0581a_.py b/backend/migrations/versions/718193c0581a_.py new file mode 100644 index 00000000..07b6cfbf --- /dev/null +++ b/backend/migrations/versions/718193c0581a_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 718193c0581a +Revises: 681d624f0d5f +Create Date: 2021-12-04 14:32:12.860932 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '718193c0581a' +down_revision = '681d624f0d5f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('admin', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'admin') + # ### end Alembic commands ### From 0a73e6554ba4aeec9c159f28dbafbcf1243c9cc5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Dec 2021 15:46:19 +0100 Subject: [PATCH 068/496] chore: update dependencies --- backend/requirements.txt | 68 ++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index bee72e65..5d8c2715 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,58 +1,58 @@ -alembic==1.7.1 +alembic==1.7.5 appdirs==1.4.4 -APScheduler==3.7.0 +APScheduler==3.8.1 attrs==21.2.0 -autopep8==1.5.7 +autopep8==1.6.0 bcrypt==3.2.0 black==20.8b1 -cffi==1.14.6 -click==8.0.1 -cycler==0.10.0 +cffi==1.15.0 +click==8.0.3 +cycler==0.11.0 dbscan1d==0.1.6 -flake8==3.9.2 -Flask==2.0.1 +flake8==4.0.1 +Flask==2.0.2 Flask-APScheduler==1.12.2 Flask-Bcrypt==0.7.1 -Flask-JWT-Extended==4.3.0 +Flask-JWT-Extended==4.3.1 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -greenlet==1.1.1 +greenlet==1.1.2 iniconfig==1.1.1 itsdangerous==2.0.1 -Jinja2==3.0.1 -joblib==1.0.1 +Jinja2==3.0.3 +joblib==1.1.0 kiwisolver==1.3.2 -Mako==1.1.5 +Mako==1.1.6 MarkupSafe==2.0.1 -marshmallow==3.13.0 -matplotlib==3.4.3 +marshmallow==3.14.1 +matplotlib==3.5.0 mccabe==0.6.1 mlxtend==0.19.0 mypy-extensions==0.4.3 -numpy==1.21.2 -packaging==21.0 -pandas==1.3.3 +numpy==1.21.4 +packaging==21.3 +pandas==1.3.4 pathspec==0.9.0 -Pillow==8.3.2 +Pillow==8.4.0 pluggy==1.0.0 -py==1.10.0 -pycodestyle==2.7.0 -pycparser==2.20 -pyflakes==2.3.1 -PyJWT==2.1.0 -pyparsing==2.4.7 +py==1.11.0 +pycodestyle==2.8.0 +pycparser==2.21 +pyflakes==2.4.0 +PyJWT==2.3.0 +pyparsing==3.0.6 pytest==6.2.5 python-dateutil==2.8.2 python-editor==1.0.4 -pytz==2021.1 -regex==2021.8.28 -scikit-learn==0.24.2 -scipy==1.7.1 +pytz==2021.3 +regex==2021.11.10 +scikit-learn==1.0.1 +scipy==1.7.3 six==1.16.0 -SQLAlchemy==1.4.23 -threadpoolctl==2.2.0 +SQLAlchemy==1.4.27 +threadpoolctl==3.0.0 toml==0.10.2 -typed-ast==1.4.3 -typing-extensions==3.10.0.2 +typed-ast==1.5.1 +typing-extensions==4.0.1 tzlocal~=2.0 -Werkzeug==2.0.1 +Werkzeug==2.0.2 From 28fc93ed07e8b28ebdbaf58566847140db627198 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Dec 2021 15:47:13 +0100 Subject: [PATCH 069/496] Prepare release 14 --- backend/app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6b4684f3..94c72544 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -7,8 +7,8 @@ from flask_apscheduler import APScheduler import os -MIN_FRONTEND_VERSION = 8 -BACKEND_VERSION = 12 +MIN_FRONTEND_VERSION = 17 +BACKEND_VERSION = 14 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 5cc7f357d18d272655d59d13caf7d6aef397fe8e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 6 Dec 2021 09:21:55 +0100 Subject: [PATCH 070/496] Prepare release 15 --- backend/app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 94c72544..44505806 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -7,8 +7,8 @@ from flask_apscheduler import APScheduler import os -MIN_FRONTEND_VERSION = 17 -BACKEND_VERSION = 14 +MIN_FRONTEND_VERSION = 10 +BACKEND_VERSION = 15 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 83d9f7c004497bfa620615052d5625c1863aeffb Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 7 Jan 2022 02:40:30 +0100 Subject: [PATCH 071/496] chore: update requirements --- backend/requirements.txt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5d8c2715..b7e870e5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,7 @@ alembic==1.7.5 appdirs==1.4.4 APScheduler==3.8.1 -attrs==21.2.0 +attrs==21.4.0 autopep8==1.6.0 bcrypt==3.2.0 black==20.8b1 @@ -11,7 +11,7 @@ cycler==0.11.0 dbscan1d==0.1.6 flake8==4.0.1 Flask==2.0.2 -Flask-APScheduler==1.12.2 +Flask-APScheduler==1.12.3 Flask-Bcrypt==0.7.1 Flask-JWT-Extended==4.3.1 Flask-Migrate==3.1.0 @@ -25,15 +25,15 @@ kiwisolver==1.3.2 Mako==1.1.6 MarkupSafe==2.0.1 marshmallow==3.14.1 -matplotlib==3.5.0 +matplotlib==3.5.1 mccabe==0.6.1 mlxtend==0.19.0 mypy-extensions==0.4.3 -numpy==1.21.4 +numpy==1.22.0 packaging==21.3 -pandas==1.3.4 +pandas==1.3.5 pathspec==0.9.0 -Pillow==8.4.0 +Pillow==9.0.0 pluggy==1.0.0 py==1.11.0 pycodestyle==2.8.0 @@ -46,10 +46,10 @@ python-dateutil==2.8.2 python-editor==1.0.4 pytz==2021.3 regex==2021.11.10 -scikit-learn==1.0.1 +scikit-learn==1.0.2 scipy==1.7.3 six==1.16.0 -SQLAlchemy==1.4.27 +SQLAlchemy==1.4.29 threadpoolctl==3.0.0 toml==0.10.2 typed-ast==1.5.1 From ee0f357583cb5f2619d6a6900739fd53e6d0eec9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 7 Jan 2022 02:43:06 +0100 Subject: [PATCH 072/496] feat: recipe tags and time - Initial tags and recipe time - Update --- backend/app/controller/__init__.py | 1 + .../exportimport/import_controller.py | 11 ++++- .../controller/recipe/recipe_controller.py | 41 +++++++++++++++-- backend/app/controller/recipe/schemas.py | 8 ++++ backend/app/controller/tag/__init__.py | 1 + backend/app/controller/tag/schemas.py | 8 ++++ backend/app/controller/tag/tag_controller.py | 46 +++++++++++++++++++ backend/app/models/__init__.py | 3 +- backend/app/models/recipe.py | 40 ++++++++++++++++ backend/app/models/tag.py | 30 ++++++++++++ backend/migrations/versions/9be38fc16ce9_.py | 46 +++++++++++++++++++ 11 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 backend/app/controller/tag/__init__.py create mode 100644 backend/app/controller/tag/schemas.py create mode 100644 backend/app/controller/tag/tag_controller.py create mode 100644 backend/app/models/tag.py create mode 100644 backend/migrations/versions/9be38fc16ce9_.py diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 4eba45d3..4a0f783c 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -8,4 +8,5 @@ from . import exportimport from . import settings from . import expense +from . import tag from . import health_controller diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index d8a12caf..0860d86f 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -5,7 +5,7 @@ from flask_jwt_extended import jwt_required from app import app from app.config import APP_DIR, SUPPORTED_LANGUAGES -from app.models import Item, Recipe, RecipeItems +from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags import json from os.path import exists @@ -65,3 +65,12 @@ def _import(args): con.item = item con.recipe = recipe con.save() + if 'tags' in args: + for tagName in args['tags']: + tag = Tag.find_by_name(tagName) + if not tag: + tag = Tag.create_by_name(recipeItem['name']) + con = RecipeTags() + con.tag = tag + con.recipe = recipe + con.save() diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index b32087a6..9b3854ae 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -1,17 +1,21 @@ from app.errors import NotFoundRequest -from app.models.recipe import RecipeItems +from app.models.recipe import RecipeItems, RecipeTags from flask import jsonify from flask_jwt_extended import jwt_required from app import app from app.helpers import validate_args -from app.models import Recipe, Item -from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe +from app.models import Recipe, Item, Tag +from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllRequest @app.route('/recipe', methods=['GET']) @jwt_required() -def getAllRecipes(): - return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name()]) +@validate_args(GetAllRequest) +def getAllRecipes(args): + if "filter" in args: + return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(args["filter"])]) + else: + return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name()]) @app.route('/recipe/', methods=['GET']) @@ -30,6 +34,8 @@ def addRecipe(args): recipe = Recipe() recipe.name = args['name'] recipe.description = args['description'] + if 'time' in args: + recipe.time = args['time'] recipe.save() if 'items' in args: for recipeItem in args['items']: @@ -43,6 +49,15 @@ def addRecipe(args): con.item = item con.recipe = recipe con.save() + if 'tags' in args: + for tagName in args['tags']: + tag = Tag.find_by_name(tagName) + if not tag: + tag = Tag.create_by_name(tagName) + con = RecipeTags() + con.tag = tag + con.recipe = recipe + con.save() return jsonify(recipe.obj_to_dict()) @@ -57,6 +72,8 @@ def updateRecipe(args, id): # noqa: C901 recipe.name = args['name'] if 'description' in args and args['description']: recipe.description = args['description'] + if 'time' in args: + recipe.time = args['time'] recipe.save() if 'items' in args: for con in recipe.items: @@ -81,6 +98,20 @@ def updateRecipe(args, id): # noqa: C901 con.item = item con.recipe = recipe con.save() + if 'tags' in args: + for con in recipe.tags: + if con.tag.name not in args['tags']: + con.delete() + for recipeTag in args['tags']: + tag = Tag.find_by_name(recipeTag) + if not tag: + tag = Tag.create_by_name(recipeTag) + con = RecipeTags.find_by_ids(recipe.id, tag.id) + if not con: + con = RecipeTags() + con.tag = tag + con.recipe = recipe + con.save() return jsonify(recipe.obj_to_dict()) diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index 39f287e1..891415af 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -1,6 +1,10 @@ from marshmallow import fields, Schema +class GetAllRequest(Schema): + filter = fields.List(fields.String()) + + class AddRecipe(Schema): class RecipeItem(Schema): name = fields.String( @@ -18,7 +22,9 @@ class RecipeItem(Schema): required=True ) description = fields.String() + time = fields.Integer() items = fields.List(fields.Nested(RecipeItem())) + tags = fields.List(fields.String()) class UpdateRecipe(Schema): @@ -32,7 +38,9 @@ class RecipeItem(Schema): name = fields.String() description = fields.String() + time = fields.Integer() items = fields.List(fields.Nested(RecipeItem())) + tags = fields.List(fields.String()) class SearchByNameRequest(Schema): diff --git a/backend/app/controller/tag/__init__.py b/backend/app/controller/tag/__init__.py new file mode 100644 index 00000000..076828eb --- /dev/null +++ b/backend/app/controller/tag/__init__.py @@ -0,0 +1 @@ +from . import tag_controller \ No newline at end of file diff --git a/backend/app/controller/tag/schemas.py b/backend/app/controller/tag/schemas.py new file mode 100644 index 00000000..78b6bfe5 --- /dev/null +++ b/backend/app/controller/tag/schemas.py @@ -0,0 +1,8 @@ +from marshmallow import fields, Schema + + +class SearchByNameRequest(Schema): + query = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py new file mode 100644 index 00000000..df59ab31 --- /dev/null +++ b/backend/app/controller/tag/tag_controller.py @@ -0,0 +1,46 @@ +from app.helpers import validate_args +from flask import jsonify +from app.errors import NotFoundRequest +from flask_jwt_extended import jwt_required +from app import app +from app.models import Tag, RecipeTags, Recipe +from .schemas import SearchByNameRequest + + +@app.route('/tag', methods=['GET']) +@jwt_required() +def getAllTags(): + return jsonify([e.obj_to_dict() for e in Tag.all()]) + + +@app.route('/tag/', methods=['GET']) +@jwt_required() +def getTag(id): + tag = Tag.find_by_id(id) + if not tag: + raise NotFoundRequest() + return jsonify(tag.obj_to_dict()) + + +@app.route('/tag//recipes', methods=['GET']) +@jwt_required() +def getTagRecipes(id): + tags = RecipeTags.query.filter( + RecipeTags.tag_id == id).join( + RecipeTags.recipe).order_by( + Recipe.name).all() + return jsonify([e.recipe.obj_to_dict() for e in tags]) + + +@app.route('/tag/', methods=['DELETE']) +@jwt_required() +def deleteTagById(id): + Tag.delete_by_id(id) + return jsonify({'msg': 'DONE'}) + + +@app.route('/tag/search', methods=['GET']) +@jwt_required() +@validate_args(SearchByNameRequest) +def searchTagByName(args): + return jsonify([e.obj_to_dict() for e in Tag.search_name(args['query'])]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 260116d5..bca5b70e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ from .expense import Expense, ExpensePaidFor from .settings import Settings from .history import History, Status -from .recipe import RecipeItems, Recipe +from .recipe import RecipeTags, RecipeItems, Recipe +from .tag import Tag from .shoppinglist import ShoppinglistItems, Shoppinglist from .recipe_history import RecipeHistory diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 5a1d7daf..0a2f320d 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -1,6 +1,7 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin from .item import Item +from .tag import Tag from random import randint @@ -12,6 +13,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): description = db.Column(db.String()) photo = db.Column(db.String()) planned = db.Column(db.Boolean) + time = db.Column(db.Integer) suggestion_score = db.Column(db.Integer, server_default='0') suggestion_rank = db.Column(db.Integer, server_default='0') @@ -19,6 +21,8 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): "RecipeHistory", back_populates="recipe", cascade="all, delete-orphan") items = db.relationship( 'RecipeItems', back_populates='recipe', cascade="all, delete-orphan") + tags = db.relationship( + 'RecipeTags', back_populates='recipe', cascade="all, delete-orphan") def obj_to_full_dict(self): res = super().obj_to_dict() @@ -26,16 +30,25 @@ def obj_to_full_dict(self): RecipeItems.item).order_by( Item.name).all() res['items'] = [e.obj_to_item_dict() for e in items] + tags = RecipeTags.query.filter(RecipeTags.recipe_id == self.id).join( + RecipeTags.tag).order_by( + Tag.name).all() + res['tags'] = [e.obj_to_item_dict() for e in tags] return res def obj_to_export_dict(self): items = RecipeItems.query.filter(RecipeItems.recipe_id == self.id).join( RecipeItems.item).order_by( Item.name).all() + tags = RecipeTags.query.filter(RecipeTags.recipe_id == self.id).join( + RecipeTags.tag).order_by( + Tag.name).all() res = { "name": self.name, "description": self.description, + "time": self.time, "items": [{"name": e.item.name, "description": e.description, "optional": e.optional} for e in items], + "tags": [e.tag.name for e in tags], } return res @@ -90,6 +103,12 @@ def search_name(cls, name): looking_for = '%{0}%'.format(name) return cls.query.filter(cls.name.ilike(looking_for)).all() + @classmethod + def all_by_name_with_filter(cls, filter): + sq = db.session.query(RecipeTags.recipe_id).join(RecipeTags.tag).filter( + Tag.name.in_(filter)).subquery() + return db.session.query(cls).filter(cls.id.in_(sq)).order_by(cls.name).all() + class RecipeItems(db.Model, DbModelMixin, TimestampMixin): __tablename__ = 'recipe_items' @@ -114,3 +133,24 @@ def obj_to_item_dict(self): @classmethod def find_by_ids(cls, recipe_id, item_id): return cls.query.filter(cls.recipe_id == recipe_id, cls.item_id == item_id).first() + + +class RecipeTags(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'recipe_tags' + + recipe_id = db.Column(db.Integer, db.ForeignKey( + 'recipe.id'), primary_key=True) + tag_id = db.Column(db.Integer, db.ForeignKey('tag.id'), primary_key=True) + + tag = db.relationship("Tag", back_populates='recipes') + recipe = db.relationship("Recipe", back_populates='tags') + + def obj_to_item_dict(self): + res = self.tag.obj_to_dict() + res['created_at'] = getattr(self, 'created_at') + res['updated_at'] = getattr(self, 'updated_at') + return res + + @classmethod + def find_by_ids(cls, recipe_id, tag_id): + return cls.query.filter(cls.recipe_id == recipe_id, cls.tag_id == tag_id).first() diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py new file mode 100644 index 00000000..0c4f86fe --- /dev/null +++ b/backend/app/models/tag.py @@ -0,0 +1,30 @@ +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class Tag(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'tag' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + + recipes = db.relationship( + 'RecipeTags', back_populates='tag', cascade="all, delete-orphan") + + def obj_to_full_dict(self): + res = super().obj_to_dict() + return res + + @classmethod + def create_by_name(cls, name): + return cls( + name=name, + ).save() + + @classmethod + def find_by_name(cls, name): + return cls.query.filter(cls.name == name).first() + + @classmethod + def find_by_id(cls, id): + return cls.query.filter(cls.id == id).first() diff --git a/backend/migrations/versions/9be38fc16ce9_.py b/backend/migrations/versions/9be38fc16ce9_.py new file mode 100644 index 00000000..52e4d108 --- /dev/null +++ b/backend/migrations/versions/9be38fc16ce9_.py @@ -0,0 +1,46 @@ +"""empty message + +Revision ID: 9be38fc16ce9 +Revises: 718193c0581a +Create Date: 2021-12-27 16:13:02.262090 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9be38fc16ce9' +down_revision = '718193c0581a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tag', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('recipe_tags', + sa.Column('recipe_id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['recipe_id'], ['recipe.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ), + sa.PrimaryKeyConstraint('recipe_id', 'tag_id') + ) + op.add_column('recipe', sa.Column('time', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'time') + op.drop_table('recipe_tags') + op.drop_table('tag') + # ### end Alembic commands ### From 9822d5e9f833d9dc8abbe80c2fd5d4b70364a1e1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 7 Jan 2022 02:47:24 +0100 Subject: [PATCH 073/496] Prepare release 16 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 44505806..1dfa1410 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 10 -BACKEND_VERSION = 15 +BACKEND_VERSION = 16 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 2aac49396159a3c45f29c99037ec2b63ea64dceb Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 7 Jan 2022 17:05:40 +0100 Subject: [PATCH 074/496] feat: tags and fixes --- backend/.gitignore | 1 + .../exportimport/import_controller.py | 2 +- backend/app/controller/health_controller.py | 1 - .../app/controller/recipe/recipe_controller.py | 17 ++++++++++------- backend/app/controller/recipe/schemas.py | 8 ++++---- backend/app/controller/tag/__init__.py | 2 +- backend/app/controller/tag/schemas.py | 6 ++++++ backend/app/controller/tag/tag_controller.py | 12 +++++++++++- backend/app/controller/user/user_controller.py | 2 +- backend/app/helpers/admin_required.py | 1 + 10 files changed, 36 insertions(+), 16 deletions(-) diff --git a/backend/.gitignore b/backend/.gitignore index b1d2040a..3d5cefdd 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -6,6 +6,7 @@ .project .settings/ .vscode/ +*.code-workspace # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index 0860d86f..d681d876 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -36,7 +36,7 @@ def getSupportedLanguages(): return jsonify(SUPPORTED_LANGUAGES) -def _import(args): +def _import(args): # noqa if "items" in args: for importItem in args['items']: if not Item.find_by_name(importItem['name']): diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 2a0d3f10..803a771e 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -2,7 +2,6 @@ from app import app from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION from app.models import Settings -from flask_jwt_extended import jwt_required, get_jwt_identity @app.route( diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 9b3854ae..2f63f026 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -5,17 +5,13 @@ from app import app from app.helpers import validate_args from app.models import Recipe, Item, Tag -from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllRequest +from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest @app.route('/recipe', methods=['GET']) @jwt_required() -@validate_args(GetAllRequest) -def getAllRecipes(args): - if "filter" in args: - return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(args["filter"])]) - else: - return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name()]) +def getAllRecipes(): + return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name()]) @app.route('/recipe/', methods=['GET']) @@ -129,6 +125,13 @@ def searchRecipeByName(args): return jsonify([e.obj_to_dict() for e in Recipe.search_name(args['query'])]) +@app.route('/recipe/filter', methods=['POST']) +@jwt_required() +@validate_args(GetAllFilterRequest) +def getAllFiltered(args): + return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(args["filter"])]) + + # @app.route('/recipe//item', methods=['POST']) # @jwt_required() # @validate_args(AddItemByName) diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index 891415af..2ed39ff3 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -1,10 +1,6 @@ from marshmallow import fields, Schema -class GetAllRequest(Schema): - filter = fields.List(fields.String()) - - class AddRecipe(Schema): class RecipeItem(Schema): name = fields.String( @@ -50,6 +46,10 @@ class SearchByNameRequest(Schema): ) +class GetAllFilterRequest(Schema): + filter = fields.List(fields.String()) + + class AddItemByName(Schema): name = fields.String( required=True diff --git a/backend/app/controller/tag/__init__.py b/backend/app/controller/tag/__init__.py index 076828eb..3cce0de8 100644 --- a/backend/app/controller/tag/__init__.py +++ b/backend/app/controller/tag/__init__.py @@ -1 +1 @@ -from . import tag_controller \ No newline at end of file +from . import tag_controller diff --git a/backend/app/controller/tag/schemas.py b/backend/app/controller/tag/schemas.py index 78b6bfe5..59be71d2 100644 --- a/backend/app/controller/tag/schemas.py +++ b/backend/app/controller/tag/schemas.py @@ -6,3 +6,9 @@ class SearchByNameRequest(Schema): required=True, validate=lambda a: len(a) > 0 ) + + +class AddTag(Schema): + name = fields.String( + required=True + ) diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py index df59ab31..f1e1015a 100644 --- a/backend/app/controller/tag/tag_controller.py +++ b/backend/app/controller/tag/tag_controller.py @@ -4,7 +4,7 @@ from flask_jwt_extended import jwt_required from app import app from app.models import Tag, RecipeTags, Recipe -from .schemas import SearchByNameRequest +from .schemas import SearchByNameRequest, AddTag @app.route('/tag', methods=['GET']) @@ -32,6 +32,16 @@ def getTagRecipes(id): return jsonify([e.recipe.obj_to_dict() for e in tags]) +@app.route('/tag', methods=['POST']) +@jwt_required() +@validate_args(AddTag) +def addTag(args): + tag = Tag() + tag.name = args['name'] + tag.save() + return jsonify(tag.obj_to_dict()) + + @app.route('/tag/', methods=['DELETE']) @jwt_required() def deleteTagById(id): diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index ef13fff0..a77abef1 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -1,5 +1,5 @@ from app.errors import NotFoundRequest, UnauthorizedRequest -from app.helpers.admin_required import admin_required, owner_required +from app.helpers.admin_required import admin_required from app.helpers import validate_args from flask import jsonify from flask_jwt_extended import jwt_required, get_jwt_identity diff --git a/backend/app/helpers/admin_required.py b/backend/app/helpers/admin_required.py index 90b292c5..8dacb61a 100644 --- a/backend/app/helpers/admin_required.py +++ b/backend/app/helpers/admin_required.py @@ -16,6 +16,7 @@ def func_wrapper(*args, **kwargs): return func_wrapper + def owner_required(func): @wraps(func) def func_wrapper(*args, **kwargs): From 43f01f03eeda7d6a8fa9a5644b5476408df6cb3b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 7 Jan 2022 17:07:01 +0100 Subject: [PATCH 075/496] Prepare release 17 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 1dfa1410..8b562364 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 10 -BACKEND_VERSION = 16 +BACKEND_VERSION = 17 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From b68effb8d379753cee4bc600d58e766a59d62a25 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 7 Jan 2022 18:56:19 +0100 Subject: [PATCH 076/496] fix: tag order --- backend/app/controller/tag/tag_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py index f1e1015a..66b2641c 100644 --- a/backend/app/controller/tag/tag_controller.py +++ b/backend/app/controller/tag/tag_controller.py @@ -10,7 +10,7 @@ @app.route('/tag', methods=['GET']) @jwt_required() def getAllTags(): - return jsonify([e.obj_to_dict() for e in Tag.all()]) + return jsonify([e.obj_to_dict() for e in Tag.all_by_name()]) @app.route('/tag/', methods=['GET']) From 052830ee6d7bade25643fbe6c1591a952bd9800b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 8 Jan 2022 02:23:51 +0100 Subject: [PATCH 077/496] Prepare release 18 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 8b562364..73e72a2c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 10 -BACKEND_VERSION = 17 +BACKEND_VERSION = 18 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From db441cdf2177dbbe6c0bbf3400c187e84fca4672 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 13 Jan 2022 18:14:28 +0100 Subject: [PATCH 078/496] Update LICENSE --- backend/LICENSE | 862 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 661 insertions(+), 201 deletions(-) diff --git a/backend/LICENSE b/backend/LICENSE index 261eeb9e..0ad25db4 100644 --- a/backend/LICENSE +++ b/backend/LICENSE @@ -1,201 +1,661 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. From 62f02c7fdd6aba4f8ecf86ff9c67ad789cdceabc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 29 Jan 2022 00:39:18 +0100 Subject: [PATCH 079/496] chore: update requirements --- backend/requirements.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index b7e870e5..7cbf5c88 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -29,9 +29,9 @@ matplotlib==3.5.1 mccabe==0.6.1 mlxtend==0.19.0 mypy-extensions==0.4.3 -numpy==1.22.0 +numpy==1.22.1 packaging==21.3 -pandas==1.3.5 +pandas==1.4.0 pathspec==0.9.0 Pillow==9.0.0 pluggy==1.0.0 @@ -40,19 +40,19 @@ pycodestyle==2.8.0 pycparser==2.21 pyflakes==2.4.0 PyJWT==2.3.0 -pyparsing==3.0.6 +pyparsing==3.0.7 pytest==6.2.5 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2021.3 -regex==2021.11.10 +regex==2022.1.18 scikit-learn==1.0.2 scipy==1.7.3 six==1.16.0 -SQLAlchemy==1.4.29 +SQLAlchemy==1.4.31 threadpoolctl==3.0.0 toml==0.10.2 -typed-ast==1.5.1 +typed-ast==1.5.2 typing-extensions==4.0.1 tzlocal~=2.0 Werkzeug==2.0.2 From 2a8a088ab84fc07ee60f4a391eccbac72e39d850 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 29 Jan 2022 01:31:18 +0100 Subject: [PATCH 080/496] feat: recipe scraping --- .../controller/recipe/recipe_controller.py | 15 ++++++++++- backend/app/controller/recipe/schemas.py | 6 +++++ backend/requirements.txt | 25 +++++++++++++++++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 2f63f026..4ff6c581 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -5,7 +5,8 @@ from app import app from app.helpers import validate_args from app.models import Recipe, Item, Tag -from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest +from recipe_scrapers import scrape_me +from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest, ScrapeRecipe @app.route('/recipe', methods=['GET']) @@ -131,6 +132,18 @@ def searchRecipeByName(args): def getAllFiltered(args): return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(args["filter"])]) +@app.route('/recipe/scrape', methods=['GET']) +@jwt_required() +@validate_args(ScrapeRecipe) +def scrapeRecipe(args): + scraper = scrape_me(args['url'], wild_mode=True) + recipe = Recipe() + recipe.name = scraper.title() + recipe.time = scraper.total_time() + recipe.description = scraper.description() + "\n\n" + scraper.instructions() + recipe.photo = scraper.image() + return jsonify(recipe.obj_to_dict()) + # @app.route('/recipe//item', methods=['POST']) # @jwt_required() diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index 2ed39ff3..d95503c3 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -61,3 +61,9 @@ class RemoveItem(Schema): item_id = fields.Integer( required=True, ) + +class ScrapeRecipe(Schema): + url = fields.String( + required=True, + validate=lambda a: len(a) > 0 + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7cbf5c88..7d8788de 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,11 +4,15 @@ APScheduler==3.8.1 attrs==21.4.0 autopep8==1.6.0 bcrypt==3.2.0 +beautifulsoup4==4.10.0 black==20.8b1 +certifi==2021.10.8 cffi==1.15.0 +charset-normalizer==2.0.10 click==8.0.3 cycler==0.11.0 dbscan1d==0.1.6 +extruct==0.13.0 flake8==4.0.1 Flask==2.0.2 Flask-APScheduler==1.12.3 @@ -16,17 +20,25 @@ Flask-Bcrypt==0.7.1 Flask-JWT-Extended==4.3.1 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 +fonttools==4.28.5 greenlet==1.1.2 +html-text==0.5.2 +html5lib==1.1 +idna==3.3 iniconfig==1.1.1 +isodate==0.6.1 itsdangerous==2.0.1 Jinja2==3.0.3 joblib==1.1.0 +jstyleson==0.0.2 kiwisolver==1.3.2 +lxml==4.7.1 Mako==1.1.6 MarkupSafe==2.0.1 marshmallow==3.14.1 matplotlib==3.5.1 mccabe==0.6.1 +mf2py==1.1.2 mlxtend==0.19.0 mypy-extensions==0.4.3 numpy==1.22.1 @@ -41,18 +53,27 @@ pycparser==2.21 pyflakes==2.4.0 PyJWT==2.3.0 pyparsing==3.0.7 +pyRdfa3==3.5.3 pytest==6.2.5 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2021.3 +rdflib==6.1.1 +rdflib-jsonld==0.6.2 +recipe-scrapers==13.12.1 regex==2022.1.18 +requests==2.27.1 scikit-learn==1.0.2 scipy==1.7.3 six==1.16.0 +soupsieve==2.3.1 SQLAlchemy==1.4.31 threadpoolctl==3.0.0 toml==0.10.2 typed-ast==1.5.2 -typing-extensions==4.0.1 -tzlocal~=2.0 +typing_extensions==4.0.1 +tzlocal==2.1 +urllib3==1.26.8 +w3lib==1.22.0 +webencodings==0.5.1 Werkzeug==2.0.2 From 847243625d7df3c238243638c3a98804477af359 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 29 Jan 2022 01:32:03 +0100 Subject: [PATCH 081/496] Prepare release 19 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 73e72a2c..916322a9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 10 -BACKEND_VERSION = 18 +BACKEND_VERSION = 19 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 0e55030c81a0fb58c43bdb6af3fcfba7b4b2c672 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Mar 2022 11:00:22 +0100 Subject: [PATCH 082/496] chore(deps): bump pillow from 9.0.0 to 9.0.1 (TomBursch/kitchenowl-backend#5) Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.0.0 to 9.0.1. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.0.0...9.0.1) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7d8788de..7cf7a283 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -45,7 +45,7 @@ numpy==1.22.1 packaging==21.3 pandas==1.4.0 pathspec==0.9.0 -Pillow==9.0.0 +Pillow==9.0.1 pluggy==1.0.0 py==1.11.0 pycodestyle==2.8.0 From 337ea6c8268d88c20072c3edd175189595a79bc1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 14 Mar 2022 11:09:53 +0100 Subject: [PATCH 083/496] chore: update requirements --- backend/requirements.txt | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7cf7a283..15b14de9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,49 +1,49 @@ -alembic==1.7.5 +alembic==1.7.6 appdirs==1.4.4 -APScheduler==3.8.1 +APScheduler==3.9.1 attrs==21.4.0 autopep8==1.6.0 bcrypt==3.2.0 beautifulsoup4==4.10.0 -black==20.8b1 +black==21.12b0 certifi==2021.10.8 cffi==1.15.0 -charset-normalizer==2.0.10 -click==8.0.3 +charset-normalizer==2.0.12 +click==8.0.4 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.13.0 flake8==4.0.1 -Flask==2.0.2 +Flask==2.0.3 Flask-APScheduler==1.12.3 Flask-Bcrypt==0.7.1 Flask-JWT-Extended==4.3.1 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -fonttools==4.28.5 +fonttools==4.30.0 greenlet==1.1.2 html-text==0.5.2 html5lib==1.1 idna==3.3 iniconfig==1.1.1 isodate==0.6.1 -itsdangerous==2.0.1 +itsdangerous==2.1.1 Jinja2==3.0.3 joblib==1.1.0 jstyleson==0.0.2 kiwisolver==1.3.2 -lxml==4.7.1 -Mako==1.1.6 -MarkupSafe==2.0.1 -marshmallow==3.14.1 +lxml==4.8.0 +Mako==1.2.0 +MarkupSafe==2.1.0 +marshmallow==3.15.0 matplotlib==3.5.1 mccabe==0.6.1 mf2py==1.1.2 mlxtend==0.19.0 mypy-extensions==0.4.3 -numpy==1.22.1 +numpy==1.22.3 packaging==21.3 -pandas==1.4.0 +pandas==1.4.1 pathspec==0.9.0 Pillow==9.0.1 pluggy==1.0.0 @@ -54,26 +54,26 @@ pyflakes==2.4.0 PyJWT==2.3.0 pyparsing==3.0.7 pyRdfa3==3.5.3 -pytest==6.2.5 +pytest==7.1.0 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2021.3 rdflib==6.1.1 rdflib-jsonld==0.6.2 -recipe-scrapers==13.12.1 -regex==2022.1.18 +recipe-scrapers==13.20.0 +regex==2022.3.2 requests==2.27.1 scikit-learn==1.0.2 -scipy==1.7.3 +scipy==1.8.0 six==1.16.0 soupsieve==2.3.1 -SQLAlchemy==1.4.31 -threadpoolctl==3.0.0 +SQLAlchemy==1.4.32 +threadpoolctl==3.1.0 toml==0.10.2 typed-ast==1.5.2 -typing_extensions==4.0.1 -tzlocal==2.1 +typing_extensions==4.1.1 +tzlocal==4.1 urllib3==1.26.8 w3lib==1.22.0 webencodings==0.5.1 -Werkzeug==2.0.2 +Werkzeug==2.0.3 From b09b37f37b6a19840925560672d90ca66fb7945b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 17 Mar 2022 16:15:40 +0100 Subject: [PATCH 084/496] feat: update image & refactor BREAKING CHANGES --- API now available at /api/* --- .github/workflows/publish_dev.yml | 59 +++++++++++++++++++ ...i_to_docker_hub.yml => publish_latest.yml} | 2 +- backend/CONTRIBUTING.md | 2 +- backend/Dockerfile | 25 +++++--- backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + backend/app/api/register_controller.py | 18 ++++++ backend/app/controller/__init__.py | 24 ++++---- backend/app/controller/auth/__init__.py | 2 +- .../app/controller/auth/auth_controller.py | 11 ++-- backend/app/controller/expense/__init__.py | 2 +- .../controller/expense/expense_controller.py | 17 +++--- .../app/controller/exportimport/__init__.py | 4 +- .../exportimport/export_controller.py | 11 ++-- .../exportimport/import_controller.py | 13 ++-- backend/app/controller/health_controller.py | 10 ++-- backend/app/controller/item/__init__.py | 2 +- .../app/controller/item/item_controller.py | 15 ++--- backend/app/controller/onboarding/__init__.py | 2 +- .../onboarding/onboarding_controller.py | 11 ++-- backend/app/controller/planner/__init__.py | 2 +- .../controller/planner/planner_controller.py | 17 +++--- backend/app/controller/recipe/__init__.py | 2 +- .../controller/recipe/recipe_controller.py | 26 ++++---- backend/app/controller/settings/__init__.py | 2 +- .../settings/settings_controller.py | 9 +-- .../app/controller/shoppinglist/__init__.py | 2 +- .../shoppinglist/shoppinglist_controller.py | 33 ++++++----- backend/app/controller/tag/__init__.py | 2 +- backend/app/controller/tag/tag_controller.py | 17 +++--- backend/app/controller/user/__init__.py | 2 +- .../app/controller/user/user_controller.py | 20 ++++--- backend/docker-compose.yml | 11 ++-- backend/entrypoint.sh | 4 +- backend/requirements.txt | 8 ++- backend/wsgi.ini | 11 ++++ backend/wsgi.py | 1 - 37 files changed, 261 insertions(+), 140 deletions(-) create mode 100644 .github/workflows/publish_dev.yml rename .github/workflows/{ci_to_docker_hub.yml => publish_latest.yml} (98%) create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/register_controller.py create mode 100644 backend/wsgi.ini diff --git a/.github/workflows/publish_dev.yml b/.github/workflows/publish_dev.yml new file mode 100644 index 00000000..e10c8e0e --- /dev/null +++ b/.github/workflows/publish_dev.yml @@ -0,0 +1,59 @@ +# This is a basic workflow to help you get started with Actions + +name: CI Dev to Docker Hub + +# Controls when the workflow will run +on: + # Triggers the workflow on push events but only for the stable branch + push: + branches: [ main ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: ./ + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:dev + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/ci_to_docker_hub.yml b/.github/workflows/publish_latest.yml similarity index 98% rename from .github/workflows/ci_to_docker_hub.yml rename to .github/workflows/publish_latest.yml index 22393be8..14e220a2 100644 --- a/.github/workflows/ci_to_docker_hub.yml +++ b/.github/workflows/publish_latest.yml @@ -1,6 +1,6 @@ # This is a basic workflow to help you get started with Actions -name: CI to Docker Hub +name: CI Latest to Docker Hub # Controls when the workflow will run on: diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md index d4b8c300..a12b0973 100644 --- a/backend/CONTRIBUTING.md +++ b/backend/CONTRIBUTING.md @@ -32,7 +32,7 @@ The `description` is a descriptive summary of the change the PR will make. - Activate your python environment `source venv/bin/activate` (environment can be deactivated with `deactivate`) - Install dependencies `pip3 install -r requirements.txt` - Initialize/Upgrade the sqlite database with `flask db upgrade` -- Run debug server with `python3 wsgi.py` +- Run debug server with `python3 wsgi.py` or without debugging `flask run` - The backend should be reachable at `localhost:5000` ### Git Commit Message Style diff --git a/backend/Dockerfile b/backend/Dockerfile index 06801ff0..43d846de 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,19 +1,30 @@ -FROM python:3.8 +FROM python:3.10-slim -## Setup Shoppy -COPY . /usr/src/kitchenowl/ +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + gcc g++ libffi-dev + +## Setup KitchenOwl +COPY requirements.txt wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ +COPY app /usr/src/kitchenowl/app +COPY templates /usr/src/kitchenowl/templates +COPY migrations /usr/src/kitchenowl/migrations WORKDIR /usr/src/kitchenowl VOLUME ["/data"] + ENV STORAGE_PATH='/data' ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' ENV DEBUG='False' + RUN pip3 install -r requirements.txt && rm requirements.txt RUN chmod u+x ./entrypoint.sh -HEALTHCHECK --interval=5m --timeout=3s \ - CMD curl -f http://localhost:5000/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V || exit 1 +# Cleanup +RUN apt-get autoremove --yes gcc g++ libffi-dev \ + && rm -rf /var/lib/apt/lists/* -ENV FRONT_URL='http://localhost' +EXPOSE 80 -EXPOSE 5000 +USER 1000 +CMD ["wsgi.ini"] ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py index a606b245..108b9a9c 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -3,3 +3,4 @@ from app.config import scheduler from app.controller import * from app.jobs import * +from app.api import * diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 00000000..2086528a --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +from . import register_controller \ No newline at end of file diff --git a/backend/app/api/register_controller.py b/backend/app/api/register_controller.py new file mode 100644 index 00000000..ad8e8023 --- /dev/null +++ b/backend/app/api/register_controller.py @@ -0,0 +1,18 @@ +from app.config import app +import app.controller as api + +# Register Endpoints +app.register_blueprint( + api.health, url_prefix='/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V') +app.register_blueprint(api.auth, url_prefix='/api/auth') +app.register_blueprint(api.expense, url_prefix='/api/expense') +app.register_blueprint(api.export, url_prefix='/api/export') +app.register_blueprint(api.importBP, url_prefix='/api/import') +app.register_blueprint(api.item, url_prefix='/api/item') +app.register_blueprint(api.onboarding, url_prefix='/api/onboarding') +app.register_blueprint(api.planner, url_prefix='/api/planner') +app.register_blueprint(api.recipe, url_prefix='/api/recipe') +app.register_blueprint(api.settings, url_prefix='/api/settings') +app.register_blueprint(api.shoppinglist, url_prefix='/api/shoppinglist') +app.register_blueprint(api.tag, url_prefix='/api/tag') +app.register_blueprint(api.user, url_prefix='/api/user') diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 4a0f783c..2c46a903 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -1,12 +1,12 @@ -from . import auth -from . import item -from . import user -from . import recipe -from . import shoppinglist -from . import planner -from . import onboarding -from . import exportimport -from . import settings -from . import expense -from . import tag -from . import health_controller +from .auth import * +from .item import * +from .user import * +from .recipe import * +from .shoppinglist import * +from .planner import * +from .onboarding import * +from .exportimport import * +from .settings import * +from .expense import * +from .tag import * +from .health_controller import health diff --git a/backend/app/controller/auth/__init__.py b/backend/app/controller/auth/__init__.py index 758c2314..c17007ec 100644 --- a/backend/app/controller/auth/__init__.py +++ b/backend/app/controller/auth/__init__.py @@ -1 +1 @@ -from . import auth_controller +from .auth_controller import auth diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 5c30407d..3cc9a8a1 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -1,13 +1,14 @@ from app.helpers import validate_args -from flask import jsonify +from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required, create_access_token, create_refresh_token, get_jwt_identity -from app.config import app from app.models import User from app.errors import UnauthorizedRequest from .schemas import Login +auth = Blueprint('auth', __name__) -@app.route('/auth', methods=['POST']) + +@auth.route('', methods=['POST']) @validate_args(Login) def login(args): username = args['username'].lower() @@ -21,7 +22,7 @@ def login(args): return jsonify(ret) -@app.route('/auth/fresh-login', methods=['POST']) +@auth.route('/fresh-login', methods=['POST']) @validate_args(Login) def fresh_login(args): username = args['username'].lower() @@ -32,7 +33,7 @@ def fresh_login(args): return jsonify(ret), 200 -@app.route('/auth/refresh', methods=['GET']) +@auth.route('/refresh', methods=['GET']) @jwt_required(refresh=True) def refresh(): current_user = get_jwt_identity() diff --git a/backend/app/controller/expense/__init__.py b/backend/app/controller/expense/__init__.py index 1a76b658..2727e921 100644 --- a/backend/app/controller/expense/__init__.py +++ b/backend/app/controller/expense/__init__.py @@ -1 +1 @@ -from . import expense_controller +from .expense_controller import expense diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 0cfa3603..850602fd 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -1,21 +1,22 @@ from sqlalchemy.sql.expression import desc from app.errors import NotFoundRequest -from flask import jsonify +from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required -from app import app from sqlalchemy import func from app.helpers import validate_args, admin_required from app.models import Expense, ExpensePaidFor, User from .schemas import AddExpense, UpdateExpense +expense = Blueprint('expense', __name__) -@app.route('/expense', methods=['GET']) + +@expense.route('', methods=['GET']) @jwt_required() def getAllExpenses(): return jsonify([e.obj_to_full_dict() for e in Expense.query.order_by(desc(Expense.id)).limit(50).all()]) -@app.route('/expense/', methods=['GET']) +@expense.route('/', methods=['GET']) @jwt_required() def getExpenseById(id): expense = Expense.find_by_id(id) @@ -24,7 +25,7 @@ def getExpenseById(id): return jsonify(expense.obj_to_full_dict()) -@app.route('/expense', methods=['POST']) +@expense.route('', methods=['POST']) @jwt_required() @validate_args(AddExpense) def addExpense(args): @@ -57,7 +58,7 @@ def addExpense(args): return jsonify(expense.obj_to_dict()) -@app.route('/expense/', methods=['POST']) +@expense.route('/', methods=['POST']) @jwt_required() @validate_args(UpdateExpense) def updateExpense(args, id): # noqa: C901 @@ -96,7 +97,7 @@ def updateExpense(args, id): # noqa: C901 return jsonify(expense.obj_to_dict()) -@app.route('/expense/', methods=['DELETE']) +@expense.route('/', methods=['DELETE']) @jwt_required() def deleteExpenseById(id): Expense.delete_by_id(id) @@ -104,7 +105,7 @@ def deleteExpenseById(id): return jsonify({'msg': 'DONE'}) -@app.route('/expense/recalculate-balances') +@expense.route('/recalculate-balances') @jwt_required() @admin_required def calculateBalances(): diff --git a/backend/app/controller/exportimport/__init__.py b/backend/app/controller/exportimport/__init__.py index b513dfea..a155c887 100644 --- a/backend/app/controller/exportimport/__init__.py +++ b/backend/app/controller/exportimport/__init__.py @@ -1,2 +1,2 @@ -from . import export_controller -from . import import_controller +from .export_controller import export +from .import_controller import importBP diff --git a/backend/app/controller/exportimport/export_controller.py b/backend/app/controller/exportimport/export_controller.py index 51fd1c9e..ae0d6b26 100644 --- a/backend/app/controller/exportimport/export_controller.py +++ b/backend/app/controller/exportimport/export_controller.py @@ -1,10 +1,11 @@ -from flask import jsonify +from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required -from app import app from app.models import Item, Recipe +export = Blueprint('export', __name__) -@app.route('/export', methods=['GET']) + +@export.route('', methods=['GET']) @jwt_required() def getExportAll(): return jsonify({ @@ -13,13 +14,13 @@ def getExportAll(): }) -@app.route('/export/items', methods=['GET']) +@export.route('/items', methods=['GET']) @jwt_required() def getExportItems(): return jsonify({"items": [e.obj_to_export_dict() for e in Item.all()]}) -@app.route('/export/recipes', methods=['GET']) +@export.route('/recipes', methods=['GET']) @jwt_required() def getExportRecipes(): return jsonify({"recipes": [e.obj_to_export_dict() for e in Recipe.all()]}) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index d681d876..2c24df1e 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -1,16 +1,17 @@ from .schemas import ImportSchema from app.helpers import validate_args -from flask import jsonify +from flask import jsonify, Blueprint from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required -from app import app from app.config import APP_DIR, SUPPORTED_LANGUAGES from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags import json from os.path import exists +importBP = Blueprint('import', __name__) -@app.route('/import', methods=['POST']) + +@importBP.route('', methods=['POST']) @jwt_required() @validate_args(ImportSchema) def importData(args): @@ -18,7 +19,7 @@ def importData(args): return jsonify({'msg': 'DONE'}) -@app.route('/import/', methods=['GET']) +@importBP.route('/', methods=['GET']) @jwt_required() def importLang(lang): file_path = f'{APP_DIR}/../templates/{lang}.json' @@ -30,13 +31,13 @@ def importLang(lang): return jsonify({'msg': 'DONE'}) -@app.route('/supported-languages', methods=['GET']) +@importBP.route('/supported-languages', methods=['GET']) @jwt_required() def getSupportedLanguages(): return jsonify(SUPPORTED_LANGUAGES) -def _import(args): # noqa +def _import(args): # noqa if "items" in args: for importItem in args['items']: if not Item.find_by_name(importItem['name']): diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 803a771e..87abd7e6 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -1,13 +1,11 @@ -from flask import jsonify -from app import app +from flask import jsonify, Blueprint from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION from app.models import Settings +health = Blueprint('health', __name__) -@app.route( - '/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V', - methods=['GET'] -) + +@health.route('', methods=['GET']) def get_health(): info = { 'msg': "OK", diff --git a/backend/app/controller/item/__init__.py b/backend/app/controller/item/__init__.py index 530df688..37598a42 100644 --- a/backend/app/controller/item/__init__.py +++ b/backend/app/controller/item/__init__.py @@ -1 +1 @@ -from . import item_controller +from .item_controller import item diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 7dda7566..e2f5d517 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -1,19 +1,20 @@ from app.helpers import validate_args -from flask import jsonify +from flask import jsonify, Blueprint from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required -from app import app from app.models import Item, RecipeItems, Recipe from .schemas import SearchByNameRequest +item = Blueprint('item', __name__) -@app.route('/item', methods=['GET']) + +@item.route('', methods=['GET']) @jwt_required() def getAllItems(): return jsonify([e.obj_to_dict() for e in Item.all()]) -@app.route('/item/', methods=['GET']) +@item.route('/', methods=['GET']) @jwt_required() def getItem(id): item = Item.find_by_id(id) @@ -22,7 +23,7 @@ def getItem(id): return jsonify(item.obj_to_dict()) -@app.route('/item//recipes', methods=['GET']) +@item.route('//recipes', methods=['GET']) @jwt_required() def getItemRecipes(id): items = RecipeItems.query.filter( @@ -32,14 +33,14 @@ def getItemRecipes(id): return jsonify([e.recipe.obj_to_dict() for e in items]) -@app.route('/item/', methods=['DELETE']) +@item.route('/', methods=['DELETE']) @jwt_required() def deleteItemById(id): Item.delete_by_id(id) return jsonify({'msg': 'DONE'}) -@app.route('/item/search', methods=['GET']) +@item.route('/search', methods=['GET']) @jwt_required() @validate_args(SearchByNameRequest) def searchItemByName(args): diff --git a/backend/app/controller/onboarding/__init__.py b/backend/app/controller/onboarding/__init__.py index 1f0b4d7d..1334d479 100644 --- a/backend/app/controller/onboarding/__init__.py +++ b/backend/app/controller/onboarding/__init__.py @@ -1 +1 @@ -from . import onboarding_controller +from .onboarding_controller import onboarding diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py index cf263fdc..dece72cb 100644 --- a/backend/app/controller/onboarding/onboarding_controller.py +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -1,20 +1,21 @@ from app.helpers import validate_args -from flask import jsonify +from flask import jsonify, Blueprint from flask_jwt_extended import create_access_token, create_refresh_token -from app import app from app.models import User from .schemas import CreateUser +onboarding = Blueprint('onboarding', __name__) -@app.route('/onboarding', methods=['GET']) + +@onboarding.route('', methods=['GET']) def isOnboarding(): onboarding = User.count() == 0 return jsonify({"onboarding": onboarding}) -@app.route('/onboarding', methods=['POST']) +@onboarding.route('', methods=['POST']) @validate_args(CreateUser) -def onboarding(args): +def onboard(args): if User.count() == 0: username = args['username'].lower() User.create(username, args['password'], args['name'], owner=True) diff --git a/backend/app/controller/planner/__init__.py b/backend/app/controller/planner/__init__.py index 149d0792..4e2980c8 100644 --- a/backend/app/controller/planner/__init__.py +++ b/backend/app/controller/planner/__init__.py @@ -1 +1 @@ -from . import planner_controller +from .planner_controller import planner diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index be78cad0..f3b14001 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -1,20 +1,21 @@ from app.errors import NotFoundRequest -from flask import jsonify +from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required -from app import app from app.helpers import validate_args from app.models import Recipe, RecipeHistory from .schemas import AddPlannedRecipe +planner = Blueprint('planner', __name__) -@app.route('/planner/recipes', methods=['GET']) + +@planner.route('/recipes', methods=['GET']) @jwt_required() def getAllPlannedRecipes(): recipes = Recipe.query.filter(Recipe.planned).order_by(Recipe.name).all() return jsonify([e.obj_to_dict() for e in recipes]) -@app.route('/planner/recipe', methods=['POST']) +@planner.route('/recipe', methods=['POST']) @jwt_required() @validate_args(AddPlannedRecipe) def addPlannedRecipe(args): @@ -28,7 +29,7 @@ def addPlannedRecipe(args): return jsonify(recipe.obj_to_dict()) -@app.route('/planner/recipe/', methods=['DELETE']) +@planner.route('/recipe/', methods=['DELETE']) @jwt_required() def removePlannedRecipeById(id): recipe = Recipe.find_by_id(id) @@ -41,21 +42,21 @@ def removePlannedRecipeById(id): return jsonify(recipe.obj_to_dict()) -@app.route('/planner/recent-recipes', methods=['GET']) +@planner.route('/recent-recipes', methods=['GET']) @jwt_required() def getRecentRecipes(): recipes = RecipeHistory.get_recent() return jsonify([e.recipe.obj_to_dict() for e in recipes]) -@app.route('/planner/suggested-recipes', methods=['GET']) +@planner.route('/suggested-recipes', methods=['GET']) @jwt_required() def getSuggestedRecipes(): suggested_recipes = Recipe.find_suggestions() return jsonify([r.obj_to_dict() for r in suggested_recipes]) -@app.route('/planner/refresh-suggested-recipes', methods=['GET']) +@planner.route('/refresh-suggested-recipes', methods=['GET']) @jwt_required() def getRefreshedSuggestedRecipes(): # re-compute suggestion ranking diff --git a/backend/app/controller/recipe/__init__.py b/backend/app/controller/recipe/__init__.py index c5b596dc..6c91a762 100644 --- a/backend/app/controller/recipe/__init__.py +++ b/backend/app/controller/recipe/__init__.py @@ -1 +1 @@ -from . import recipe_controller +from .recipe_controller import recipe diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 4ff6c581..615eb902 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -1,21 +1,22 @@ from app.errors import NotFoundRequest from app.models.recipe import RecipeItems, RecipeTags -from flask import jsonify +from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required -from app import app from app.helpers import validate_args from app.models import Recipe, Item, Tag from recipe_scrapers import scrape_me from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest, ScrapeRecipe +recipe = Blueprint('recipe', __name__) -@app.route('/recipe', methods=['GET']) + +@recipe.route('', methods=['GET']) @jwt_required() def getAllRecipes(): return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name()]) -@app.route('/recipe/', methods=['GET']) +@recipe.route('/', methods=['GET']) @jwt_required() def getRecipeById(id): recipe = Recipe.find_by_id(id) @@ -24,7 +25,7 @@ def getRecipeById(id): return jsonify(recipe.obj_to_full_dict()) -@app.route('/recipe', methods=['POST']) +@recipe.route('', methods=['POST']) @jwt_required() @validate_args(AddRecipe) def addRecipe(args): @@ -58,7 +59,7 @@ def addRecipe(args): return jsonify(recipe.obj_to_dict()) -@app.route('/recipe/', methods=['POST']) +@recipe.route('/', methods=['POST']) @jwt_required() @validate_args(UpdateRecipe) def updateRecipe(args, id): # noqa: C901 @@ -112,27 +113,28 @@ def updateRecipe(args, id): # noqa: C901 return jsonify(recipe.obj_to_dict()) -@app.route('/recipe/', methods=['DELETE']) +@recipe.route('/', methods=['DELETE']) @jwt_required() def deleteRecipeById(id): Recipe.delete_by_id(id) return jsonify({'msg': 'DONE'}) -@app.route('/recipe/search', methods=['GET']) +@recipe.route('/search', methods=['GET']) @jwt_required() @validate_args(SearchByNameRequest) def searchRecipeByName(args): return jsonify([e.obj_to_dict() for e in Recipe.search_name(args['query'])]) -@app.route('/recipe/filter', methods=['POST']) +@recipe.route('/filter', methods=['POST']) @jwt_required() @validate_args(GetAllFilterRequest) def getAllFiltered(args): return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(args["filter"])]) -@app.route('/recipe/scrape', methods=['GET']) + +@recipe.route('/scrape', methods=['GET']) @jwt_required() @validate_args(ScrapeRecipe) def scrapeRecipe(args): @@ -145,7 +147,7 @@ def scrapeRecipe(args): return jsonify(recipe.obj_to_dict()) -# @app.route('/recipe//item', methods=['POST']) +# @recipe.route('//item', methods=['POST']) # @jwt_required() # @validate_args(AddItemByName) # def addRecipeItemByName(args, id): @@ -164,7 +166,7 @@ def scrapeRecipe(args): # return jsonify(item.obj_to_dict()) -# @app.route('/recipe//item', methods=['DELETE']) +# @recipe.route('//item', methods=['DELETE']) # @jwt_required() # @validate_args(RemoveItem) # def removeRecipeItem(args, id): diff --git a/backend/app/controller/settings/__init__.py b/backend/app/controller/settings/__init__.py index 46de94ee..8f434e83 100644 --- a/backend/app/controller/settings/__init__.py +++ b/backend/app/controller/settings/__init__.py @@ -1 +1 @@ -from . import settings_controller +from .settings_controller import settings diff --git a/backend/app/controller/settings/settings_controller.py b/backend/app/controller/settings/settings_controller.py index e5194d93..c5e256a7 100644 --- a/backend/app/controller/settings/settings_controller.py +++ b/backend/app/controller/settings/settings_controller.py @@ -1,12 +1,13 @@ from .schemas import SetSettingsSchema from app.helpers import validate_args, admin_required -from flask import jsonify +from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required -from app import app from app.models import Settings +settings = Blueprint('settings', __name__) -@app.route('/settings', methods=['POST']) + +@settings.route('', methods=['POST']) @jwt_required() @admin_required @validate_args(SetSettingsSchema) @@ -20,7 +21,7 @@ def setSettings(args): return jsonify(settings.obj_to_dict()) -@app.route('/settings', methods=['GET']) +@settings.route('', methods=['GET']) @jwt_required() def getSettings(): return jsonify(Settings.get().obj_to_dict()) diff --git a/backend/app/controller/shoppinglist/__init__.py b/backend/app/controller/shoppinglist/__init__.py index 1b10a8e7..d8256911 100644 --- a/backend/app/controller/shoppinglist/__init__.py +++ b/backend/app/controller/shoppinglist/__init__.py @@ -1 +1 @@ -from . import shoppinglist_controller +from .shoppinglist_controller import shoppinglist diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 7cc3f9c2..30801bb6 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -1,7 +1,7 @@ -from app.models import ShoppinglistItems -from flask import jsonify +from app.models import ShoppinglistItems, shoppinglist +from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required -from app import app, db +from app import db from app.models import Item, Shoppinglist, History, Status, Association from app.helpers import validate_args from .schemas import (RemoveItem, UpdateDescription, @@ -10,7 +10,10 @@ from datetime import datetime, timedelta -@app.before_first_request +shoppinglist = Blueprint('shoppinglist', __name__) + + +@shoppinglist.before_app_first_request def before_first_request(): # Add default shoppinglist if(not Shoppinglist.find_by_id(1)): @@ -20,7 +23,7 @@ def before_first_request(): sl.save() -@app.route('/shoppinglist//item/', methods=['GET']) +@shoppinglist.route('//item/', methods=['GET']) @jwt_required() def getShoppingListItem(id, item_id): item = Item.find_by_id(item_id) @@ -29,7 +32,7 @@ def getShoppingListItem(id, item_id): return jsonify(item.obj_to_dict()) -@app.route('/shoppinglist//item/', methods=['POST']) +@shoppinglist.route('//item/', methods=['POST']) @jwt_required() @validate_args(UpdateDescription) def updateItemDescription(args, id, item_id): @@ -42,7 +45,7 @@ def updateItemDescription(args, id, item_id): return jsonify(con.obj_to_item_dict()) -@app.route('/shoppinglist//items', methods=['GET']) +@shoppinglist.route('//items', methods=['GET']) @jwt_required() def getAllShoppingListItems(id): items = ShoppinglistItems.query.filter( @@ -52,7 +55,7 @@ def getAllShoppingListItems(id): return jsonify([e.obj_to_item_dict() for e in items]) -@app.route('/shoppinglist//recent-items', methods=['GET']) +@shoppinglist.route('//recent-items', methods=['GET']) @jwt_required() def getRecentItems(id): items = History.get_recent(id) @@ -100,7 +103,7 @@ def getSuggestionsBasedOnFrequency(id, item_count): return suggestions -@app.route('/shoppinglist//suggested-items', methods=['GET']) +@shoppinglist.route('//suggested-items', methods=['GET']) @jwt_required() def getSuggestedItems(id): item_suggestion_count = 9 @@ -114,7 +117,7 @@ def getSuggestedItems(id): return jsonify([item.obj_to_dict() for item in suggestions]) -@app.route('/shoppinglist//add-item-by-name', methods=['POST']) +@shoppinglist.route('//add-item-by-name', methods=['POST']) @jwt_required() @validate_args(AddItemByName) def addShoppinglistItemByName(args, id): @@ -138,7 +141,7 @@ def addShoppinglistItemByName(args, id): return jsonify(item.obj_to_dict()) -@app.route('/shoppinglist//item', methods=['DELETE']) +@shoppinglist.route('//item', methods=['DELETE']) @jwt_required() @validate_args(RemoveItem) def removeShoppinglistItem(args, id): @@ -158,7 +161,7 @@ def removeShoppinglistItem(args, id): return jsonify({'msg': "DONE"}) -@app.route('/shoppinglist', methods=['POST']) +@shoppinglist.route('', methods=['POST']) @jwt_required() @validate_args(CreateList) def createList(args): @@ -166,7 +169,7 @@ def createList(args): args['name']).save().obj_to_dict()) -@app.route('/shoppinglist//recipeitems', methods=['POST']) +@shoppinglist.route('//recipeitems', methods=['POST']) @jwt_required() @validate_args(AddRecipeItems) def addRecipeItems(args, id): @@ -202,7 +205,7 @@ def addRecipeItems(args, id): shoppinglist.save() return jsonify(item.obj_to_dict()) -# @app.route('/shoppinglist//item', methods=['POST']) +# @shoppinglist.route('//item', methods=['POST']) # @jwt_required() # @validate_args(UpdateDescription) # def updateDescription(args, id): @@ -214,7 +217,7 @@ def addRecipeItems(args, id): # return jsonify(item.obj_to_dict()) -@app.route('/shoppinglist/', methods=['GET']) +@shoppinglist.route('/', methods=['GET']) @jwt_required() def getShoppinglist(id): shoppinglist = Shoppinglist.find_by_id(id) diff --git a/backend/app/controller/tag/__init__.py b/backend/app/controller/tag/__init__.py index 3cce0de8..b4d358ee 100644 --- a/backend/app/controller/tag/__init__.py +++ b/backend/app/controller/tag/__init__.py @@ -1 +1 @@ -from . import tag_controller +from .tag_controller import tag diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py index 66b2641c..1d3593f4 100644 --- a/backend/app/controller/tag/tag_controller.py +++ b/backend/app/controller/tag/tag_controller.py @@ -1,19 +1,20 @@ from app.helpers import validate_args -from flask import jsonify +from flask import jsonify, Blueprint from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required -from app import app from app.models import Tag, RecipeTags, Recipe from .schemas import SearchByNameRequest, AddTag +tag = Blueprint('tag', __name__) -@app.route('/tag', methods=['GET']) + +@tag.route('', methods=['GET']) @jwt_required() def getAllTags(): return jsonify([e.obj_to_dict() for e in Tag.all_by_name()]) -@app.route('/tag/', methods=['GET']) +@tag.route('/', methods=['GET']) @jwt_required() def getTag(id): tag = Tag.find_by_id(id) @@ -22,7 +23,7 @@ def getTag(id): return jsonify(tag.obj_to_dict()) -@app.route('/tag//recipes', methods=['GET']) +@tag.route('//recipes', methods=['GET']) @jwt_required() def getTagRecipes(id): tags = RecipeTags.query.filter( @@ -32,7 +33,7 @@ def getTagRecipes(id): return jsonify([e.recipe.obj_to_dict() for e in tags]) -@app.route('/tag', methods=['POST']) +@tag.route('', methods=['POST']) @jwt_required() @validate_args(AddTag) def addTag(args): @@ -42,14 +43,14 @@ def addTag(args): return jsonify(tag.obj_to_dict()) -@app.route('/tag/', methods=['DELETE']) +@tag.route('/', methods=['DELETE']) @jwt_required() def deleteTagById(id): Tag.delete_by_id(id) return jsonify({'msg': 'DONE'}) -@app.route('/tag/search', methods=['GET']) +@tag.route('/search', methods=['GET']) @jwt_required() @validate_args(SearchByNameRequest) def searchTagByName(args): diff --git a/backend/app/controller/user/__init__.py b/backend/app/controller/user/__init__.py index 8a9102bb..0c152bcb 100644 --- a/backend/app/controller/user/__init__.py +++ b/backend/app/controller/user/__init__.py @@ -1 +1 @@ -from . import user_controller +from .user_controller import user diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index a77abef1..dd57509c 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -1,26 +1,28 @@ from app.errors import NotFoundRequest, UnauthorizedRequest from app.helpers.admin_required import admin_required from app.helpers import validate_args -from flask import jsonify +from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required, get_jwt_identity -from app import app from app.models import User from .schemas import CreateUser, UpdateUser -@app.route('/users', methods=['GET']) +user = Blueprint('user', __name__) + + +@user.route('/all', methods=['GET']) @jwt_required() def getAllUsers(): return jsonify([e.obj_to_dict(skip_columns=['password']) for e in User.all_by_name()]) -@app.route('/user', methods=['GET']) +@user.route('', methods=['GET']) @jwt_required() def getLoggedInUser(): return jsonify(User.find_by_username(get_jwt_identity()).obj_to_dict(skip_columns=['password'])) -@app.route('/user/', methods=['GET']) +@user.route('/', methods=['GET']) @jwt_required() @admin_required def getUserById(id): @@ -30,7 +32,7 @@ def getUserById(id): return jsonify(user.obj_to_dict(skip_columns=['password'])) -@app.route('/user/', methods=['DELETE']) +@user.route('/', methods=['DELETE']) @jwt_required() @admin_required def deleteUserById(id): @@ -43,7 +45,7 @@ def deleteUserById(id): return jsonify({'msg': 'DONE'}) -@app.route('/user', methods=['POST']) +@user.route('', methods=['POST']) @jwt_required() @validate_args(UpdateUser) def updateUser(args): @@ -58,7 +60,7 @@ def updateUser(args): return jsonify({'msg': 'DONE'}) -@app.route('/user/', methods=['POST']) +@user.route('/', methods=['POST']) @jwt_required() @admin_required @validate_args(UpdateUser) @@ -76,7 +78,7 @@ def updateUserById(args, id): return jsonify({'msg': 'DONE'}) -@app.route('/new-user', methods=['POST']) +@user.route('/new', methods=['POST']) @jwt_required() @admin_required @validate_args(CreateUser) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index dd8a34c7..45747fef 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -2,24 +2,25 @@ version: "3" services: front: image: tombursch/kitchenowl-web:latest + environment: + - FRONT_URL=http://localhost + # - BACK_URL=back:5000 # Optional should not be changed unless you know what youre doing ports: - "80:80" depends_on: - back networks: - default - environment: - - BACK_URL=http://localhost:5000 back: image: tombursch/kitchenowl:latest restart: unless-stopped - ports: - - "5000:5000" + # ports: # Optional and only needed when only hosting the backend + # - "80:80" networks: - default environment: - JWT_SECRET_KEY=PLEASE_CHANGE_ME - - FRONT_URL=http://localhost + # - FRONT_URL=http://localhost # Optional and only needed if volumes: - kitchenowl_data:/data diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index a289aeae..6c7c0e89 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,3 +1,3 @@ -#!/usr/bin/env bash +#!/bin/sh flask db upgrade -flask run --host=0.0.0.0 "$@" \ No newline at end of file +uwsgi "$@" \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 15b14de9..a6329e2e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -alembic==1.7.6 +alembic==1.7.7 appdirs==1.4.4 APScheduler==3.9.1 attrs==21.4.0 @@ -46,6 +46,7 @@ packaging==21.3 pandas==1.4.1 pathspec==0.9.0 Pillow==9.0.1 +platformdirs==2.5.1 pluggy==1.0.0 py==1.11.0 pycodestyle==2.8.0 @@ -58,6 +59,7 @@ pytest==7.1.0 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2021.3 +pytz-deprecation-shim==0.1.0.post0 rdflib==6.1.1 rdflib-jsonld==0.6.2 recipe-scrapers==13.20.0 @@ -65,15 +67,19 @@ regex==2022.3.2 requests==2.27.1 scikit-learn==1.0.2 scipy==1.8.0 +setuptools-scm==6.4.2 six==1.16.0 soupsieve==2.3.1 SQLAlchemy==1.4.32 threadpoolctl==3.1.0 toml==0.10.2 +tomli==1.2.3 typed-ast==1.5.2 typing_extensions==4.1.1 +tzdata==2021.5 tzlocal==4.1 urllib3==1.26.8 +uWSGI==2.0.20 w3lib==1.22.0 webencodings==0.5.1 Werkzeug==2.0.3 diff --git a/backend/wsgi.ini b/backend/wsgi.ini new file mode 100644 index 00000000..f8d1e030 --- /dev/null +++ b/backend/wsgi.ini @@ -0,0 +1,11 @@ +[uwsgi] +wsgi-file = wsgi.py +callable = app +http = 0.0.0.0:80 +socket = 0.0.0.0:5000 +processes = 1 +threads = 1 +master = true +chmod-socket = 664 +vacuum = true +die-on-term = true \ No newline at end of file diff --git a/backend/wsgi.py b/backend/wsgi.py index 30ae1fde..6b03f794 100644 --- a/backend/wsgi.py +++ b/backend/wsgi.py @@ -1,4 +1,3 @@ -import os from app import app if __name__ == "__main__": From d081fc88e008ab7df0d51bcfe52a35700cd18279 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 17 Mar 2022 16:29:11 +0100 Subject: [PATCH 085/496] chore: cleanup GitHub actions --- .github/workflows/publish_dev.yml | 4 +--- .github/workflows/publish_latest.yml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish_dev.yml b/.github/workflows/publish_dev.yml index e10c8e0e..6e70f149 100644 --- a/.github/workflows/publish_dev.yml +++ b/.github/workflows/publish_dev.yml @@ -1,10 +1,8 @@ -# This is a basic workflow to help you get started with Actions - name: CI Dev to Docker Hub # Controls when the workflow will run on: - # Triggers the workflow on push events but only for the stable branch + # Triggers the workflow on push events but for the main branch push: branches: [ main ] diff --git a/.github/workflows/publish_latest.yml b/.github/workflows/publish_latest.yml index 14e220a2..11faefe0 100644 --- a/.github/workflows/publish_latest.yml +++ b/.github/workflows/publish_latest.yml @@ -1,10 +1,8 @@ -# This is a basic workflow to help you get started with Actions - name: CI Latest to Docker Hub # Controls when the workflow will run on: - # Triggers the workflow on push events but only for the stable branch + # Triggers the workflow on push events but only for tags push: tags: - "v*" From 8ef38c305ac180419bcaf634d2a9b8d842259b51 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 17 Mar 2022 19:22:43 +0100 Subject: [PATCH 086/496] feat: RecipeItem to recipe dict --- backend/app/controller/item/item_controller.py | 2 +- backend/app/models/recipe.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index e2f5d517..0ff2f462 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -30,7 +30,7 @@ def getItemRecipes(id): RecipeItems.item_id == id, RecipeItems.optional == False).join( # noqa RecipeItems.recipe).order_by( Recipe.name).all() - return jsonify([e.recipe.obj_to_dict() for e in items]) + return jsonify([e.obj_to_recipe_dict() for e in items]) @item.route('/', methods=['DELETE']) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 0a2f320d..5c289995 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -130,6 +130,17 @@ def obj_to_item_dict(self): res['updated_at'] = getattr(self, 'updated_at') return res + def obj_to_recipe_dict(self): + res = self.recipe.obj_to_dict() + res['items'] = [ + { + 'id': getattr(self, 'item_id'), + 'description': getattr(self, 'description'), + 'optional': getattr(self, 'optional'), + } + ] + return res + @classmethod def find_by_ids(cls, recipe_id, item_id): return cls.query.filter(cls.recipe_id == recipe_id, cls.item_id == item_id).first() From 22b83b930aca0df48e84cfb4c37773b680a668de Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 18 Mar 2022 12:33:58 +0100 Subject: [PATCH 087/496] fix: max recipe search result count --- backend/app/models/recipe.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 5c289995..46586715 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -95,13 +95,14 @@ def find_by_id(cls, id): @classmethod def search_name(cls, name): + recipe_count = 12 if '*' in name or '_' in name: looking_for = name.replace('_', '__')\ .replace('*', '%')\ .replace('?', '_') else: looking_for = '%{0}%'.format(name) - return cls.query.filter(cls.name.ilike(looking_for)).all() + return cls.query.filter(cls.name.ilike(looking_for)).limit(recipe_count).all() @classmethod def all_by_name_with_filter(cls, filter): From 35d99f358513660e6a3d37d1ab30baf3c8f1dda9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 18 Mar 2022 18:34:31 +0100 Subject: [PATCH 088/496] feat: recipe day planning --- .../controller/planner/planner_controller.py | 23 +++++++++----- backend/app/controller/planner/schemas.py | 9 ++++++ backend/app/helpers/__init__.py | 1 + backend/app/helpers/db_model_mixin.py | 19 ++++++------ backend/app/helpers/db_set_type.py | 18 +++++++++++ backend/app/helpers/timestamp_mixin.py | 4 +-- backend/app/models/recipe.py | 18 +++++++---- backend/migrations/versions/29381d24ec31_.py | 30 +++++++++++++++++++ 8 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 backend/app/helpers/db_set_type.py create mode 100644 backend/migrations/versions/29381d24ec31_.py diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index f3b14001..544fc217 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -3,7 +3,7 @@ from flask_jwt_extended import jwt_required from app.helpers import validate_args from app.models import Recipe, RecipeHistory -from .schemas import AddPlannedRecipe +from .schemas import AddPlannedRecipe, RemovePlannedRecipe planner = Blueprint('planner', __name__) @@ -22,21 +22,30 @@ def addPlannedRecipe(args): recipe = Recipe.find_by_id(args['recipe_id']) if not recipe: raise NotFoundRequest() - if not recipe.planned: - recipe.planned = True - recipe.save() - RecipeHistory.create_added(recipe) + if 'day' in args: + recipe.planned_days = recipe.planned_days.copy() + recipe.planned_days.add(args['day']) + else: + recipe.planned_days = set() + recipe.planned = True + recipe.save() + RecipeHistory.create_added(recipe) return jsonify(recipe.obj_to_dict()) @planner.route('/recipe/', methods=['DELETE']) @jwt_required() -def removePlannedRecipeById(id): +@validate_args(RemovePlannedRecipe) +def removePlannedRecipeById(args, id): recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() if recipe.planned: - recipe.planned = False + if 'day' in args: + recipe.planned_days.discard(args['day']) + else: + recipe.planned_days = {} + recipe.planned = len(recipe.planned_days) > 0 recipe.save() RecipeHistory.create_dropped(recipe) return jsonify(recipe.obj_to_dict()) diff --git a/backend/app/controller/planner/schemas.py b/backend/app/controller/planner/schemas.py index a2fcbc50..92c59900 100644 --- a/backend/app/controller/planner/schemas.py +++ b/backend/app/controller/planner/schemas.py @@ -1,7 +1,16 @@ +import imp from marshmallow import fields, Schema +from marshmallow.validate import Range class AddPlannedRecipe(Schema): recipe_id = fields.Integer( required=True, ) + day = fields.Integer(validate=Range( + min=0, min_inclusive=True, max=6, max_inclusive=True)) + + +class RemovePlannedRecipe(Schema): + day = fields.Integer(validate=Range( + min=0, min_inclusive=True, max=6, max_inclusive=True)) diff --git a/backend/app/helpers/__init__.py b/backend/app/helpers/__init__.py index f95e51f0..9a9a08ca 100644 --- a/backend/app/helpers/__init__.py +++ b/backend/app/helpers/__init__.py @@ -2,3 +2,4 @@ from .timestamp_mixin import TimestampMixin from .validate_args import validate_args from .admin_required import admin_required +from .db_set_type import DbSetType diff --git a/backend/app/helpers/db_model_mixin.py b/backend/app/helpers/db_model_mixin.py index 0f69d9ef..e7f02689 100644 --- a/backend/app/helpers/db_model_mixin.py +++ b/backend/app/helpers/db_model_mixin.py @@ -1,10 +1,11 @@ +from __future__ import annotations from sqlalchemy import asc, desc from app import db class DbModelMixin(object): - def save(self): + def save(self) -> DbModelMixin: """ Persist changes to current instance in db """ @@ -17,7 +18,7 @@ def save(self): return self - def assign(self, **kwargs): + def assign(self, **kwargs) -> DbModelMixin: """ Update an entry """ @@ -48,7 +49,7 @@ def update_attr(self, key, value): self.save() @classmethod - def get_column_names(cls): + def get_column_names(cls) -> list[str]: return list(cls.__table__.columns.keys()) @classmethod @@ -82,7 +83,7 @@ def delete(self): db.session.delete(self) db.session.commit() - def obj_to_dict(self, skip_columns=None, include_columns=None): + def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: d = {} for column in self.__table__.columns: d[column.name] = getattr(self, column.name) @@ -99,7 +100,7 @@ def obj_to_dict(self, skip_columns=None, include_columns=None): return d - def clone(self, overrides): + def clone(self, overrides) -> DbModelMixin: new_self = self.__class__() new_self.assign_columns(self.obj_to_dict()) @@ -110,7 +111,7 @@ def clone(self, overrides): return new_self @classmethod - def find_by_id(cls, target_id): + def find_by_id(cls, target_id) -> DbModelMixin: """ Find the row with specified id """ @@ -152,7 +153,7 @@ def all_by_name(cls): return cls.query.order_by(cls.name).all() @classmethod - def first(cls): + def first(cls) -> DbModelMixin: """ Returns the first entry of database """ @@ -163,7 +164,7 @@ def first(cls): return None @classmethod - def last(cls): + def last(cls) -> DbModelMixin: """ Return the last entry of table in database """ @@ -174,5 +175,5 @@ def last(cls): return None @classmethod - def count(cls): + def count(cls) -> int: return cls.query.count() diff --git a/backend/app/helpers/db_set_type.py b/backend/app/helpers/db_set_type.py new file mode 100644 index 00000000..74522126 --- /dev/null +++ b/backend/app/helpers/db_set_type.py @@ -0,0 +1,18 @@ +from sqlalchemy.types import String, TypeDecorator +import json + + +class DbSetType(TypeDecorator): + impl = String + + def process_bind_param(self, value, dialect): + print(value) + if type(value) is set: + return json.dumps(list(value)) + else: + return '[]' + + def process_result_value(self, value, dialect) -> set: + if type(value) is str: + return set(json.loads(value)) + return set() diff --git a/backend/app/helpers/timestamp_mixin.py b/backend/app/helpers/timestamp_mixin.py index d3752723..18dc4394 100644 --- a/backend/app/helpers/timestamp_mixin.py +++ b/backend/app/helpers/timestamp_mixin.py @@ -9,7 +9,7 @@ class Query(BaseQuery): Extends flask.ext.sqlalchemy.BaseQuery to add additional helper methods. """ - def notempty(self): + def notempty(self) -> bool: """ Returns the equivalent of ``bool(query.count())`` but using an efficient SQL EXISTS function, so the database stops counting @@ -17,7 +17,7 @@ def notempty(self): """ return self.session.query(self.exists()).first()[0] - def isempty(self): + def isempty(self) -> bool: """ Returns the equivalent of ``not bool(query.count())`` but using an efficient SQL EXISTS function, so the database stops diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 46586715..e8c3a01d 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -1,5 +1,7 @@ +from __future__ import annotations from app import db from app.helpers import DbModelMixin, TimestampMixin +from app.helpers.db_set_type import DbSetType from .item import Item from .tag import Tag from random import randint @@ -13,6 +15,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): description = db.Column(db.String()) photo = db.Column(db.String()) planned = db.Column(db.Boolean) + planned_days = db.Column(DbSetType(), default=set()) time = db.Column(db.Integer) suggestion_score = db.Column(db.Integer, server_default='0') suggestion_rank = db.Column(db.Integer, server_default='0') @@ -23,9 +26,14 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): 'RecipeItems', back_populates='recipe', cascade="all, delete-orphan") tags = db.relationship( 'RecipeTags', back_populates='recipe', cascade="all, delete-orphan") - - def obj_to_full_dict(self): + + def obj_to_dict(self): res = super().obj_to_dict() + res['planned_days'] = list(self.planned_days) + return res + + def obj_to_full_dict(self) -> dict: + res = self.obj_to_dict() items = RecipeItems.query.filter(RecipeItems.recipe_id == self.id).join( RecipeItems.item).order_by( Item.name).all() @@ -36,7 +44,7 @@ def obj_to_full_dict(self): res['tags'] = [e.obj_to_item_dict() for e in tags] return res - def obj_to_export_dict(self): + def obj_to_export_dict(self) -> dict: items = RecipeItems.query.filter(RecipeItems.recipe_id == self.id).join( RecipeItems.item).order_by( Item.name).all() @@ -86,11 +94,11 @@ def find_suggestions(cls): cls.suggestion_rank > 0).order_by(cls.suggestion_rank).limit(9).all() @classmethod - def find_by_name(cls, name): + def find_by_name(cls, name) -> Recipe: return cls.query.filter(cls.name == name).first() @classmethod - def find_by_id(cls, id): + def find_by_id(cls, id) -> Recipe: return cls.query.filter(cls.id == id).first() @classmethod diff --git a/backend/migrations/versions/29381d24ec31_.py b/backend/migrations/versions/29381d24ec31_.py new file mode 100644 index 00000000..1773f425 --- /dev/null +++ b/backend/migrations/versions/29381d24ec31_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 29381d24ec31 +Revises: 9be38fc16ce9 +Create Date: 2022-03-18 12:35:47.300705 + +""" +from alembic import op +import sqlalchemy as sa + +import app.helpers.db_set_type + + +# revision identifiers, used by Alembic. +revision = '29381d24ec31' +down_revision = '9be38fc16ce9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('planned_days', app.helpers.db_set_type.DbSetType(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'planned_days') + # ### end Alembic commands ### From be2d0785d70d87dd60405669e5836ccf8c27890e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 19 Mar 2022 21:58:54 +0100 Subject: [PATCH 089/496] feat: recipe source --- .../controller/recipe/recipe_controller.py | 5 ++++ backend/app/controller/recipe/schemas.py | 2 ++ backend/app/models/recipe.py | 2 ++ backend/migrations/versions/e209fcb83993_.py | 28 +++++++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 backend/migrations/versions/e209fcb83993_.py diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 615eb902..cc04008f 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -34,6 +34,8 @@ def addRecipe(args): recipe.description = args['description'] if 'time' in args: recipe.time = args['time'] + if 'source' in args: + recipe.source = args['source'] recipe.save() if 'items' in args: for recipeItem in args['items']: @@ -72,6 +74,8 @@ def updateRecipe(args, id): # noqa: C901 recipe.description = args['description'] if 'time' in args: recipe.time = args['time'] + if 'source' in args: + recipe.source = args['source'] recipe.save() if 'items' in args: for con in recipe.items: @@ -144,6 +148,7 @@ def scrapeRecipe(args): recipe.time = scraper.total_time() recipe.description = scraper.description() + "\n\n" + scraper.instructions() recipe.photo = scraper.image() + recipe.source = args['url'] return jsonify(recipe.obj_to_dict()) diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index d95503c3..cd913704 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -19,6 +19,7 @@ class RecipeItem(Schema): ) description = fields.String() time = fields.Integer() + source = fields.String() items = fields.List(fields.Nested(RecipeItem())) tags = fields.List(fields.String()) @@ -35,6 +36,7 @@ class RecipeItem(Schema): name = fields.String() description = fields.String() time = fields.Integer() + source = fields.String() items = fields.List(fields.Nested(RecipeItem())) tags = fields.List(fields.String()) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index e8c3a01d..a45ba291 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -17,6 +17,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): planned = db.Column(db.Boolean) planned_days = db.Column(DbSetType(), default=set()) time = db.Column(db.Integer) + source = db.Column(db.String()) suggestion_score = db.Column(db.Integer, server_default='0') suggestion_rank = db.Column(db.Integer, server_default='0') @@ -55,6 +56,7 @@ def obj_to_export_dict(self) -> dict: "name": self.name, "description": self.description, "time": self.time, + "source": self.source, "items": [{"name": e.item.name, "description": e.description, "optional": e.optional} for e in items], "tags": [e.tag.name for e in tags], } diff --git a/backend/migrations/versions/e209fcb83993_.py b/backend/migrations/versions/e209fcb83993_.py new file mode 100644 index 00000000..0f10b590 --- /dev/null +++ b/backend/migrations/versions/e209fcb83993_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: e209fcb83993 +Revises: 29381d24ec31 +Create Date: 2022-03-19 13:39:54.518323 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e209fcb83993' +down_revision = '29381d24ec31' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('recipe', sa.Column('source', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('recipe', 'source') + # ### end Alembic commands ### From c93bec1849010d315b36cea9d8e02412f3f3c88f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 20 Mar 2022 13:26:24 +0100 Subject: [PATCH 090/496] fix: planner remove recipe --- backend/app/controller/planner/planner_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 544fc217..93c8fdd9 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -42,6 +42,7 @@ def removePlannedRecipeById(args, id): raise NotFoundRequest() if recipe.planned: if 'day' in args: + recipe.planned_days = recipe.planned_days.copy() recipe.planned_days.discard(args['day']) else: recipe.planned_days = {} From 6c2c4cd863ba48a3968e32a85b79bb46c6295e1b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 21 Mar 2022 15:46:16 +0100 Subject: [PATCH 091/496] fix: update deploy action --- .../{publish_latest.yml => publish.yml} | 21 ++++++- .github/workflows/publish_dev.yml | 57 ------------------- 2 files changed, 19 insertions(+), 59 deletions(-) rename .github/workflows/{publish_latest.yml => publish.yml} (74%) delete mode 100644 .github/workflows/publish_dev.yml diff --git a/.github/workflows/publish_latest.yml b/.github/workflows/publish.yml similarity index 74% rename from .github/workflows/publish_latest.yml rename to .github/workflows/publish.yml index 11faefe0..24874f92 100644 --- a/.github/workflows/publish_latest.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,13 @@ -name: CI Latest to Docker Hub +name: CI to Docker Hub # Controls when the workflow will run on: # Triggers the workflow on push events but only for tags push: + branches: [ main ] tags: - "v*" + - "beta-v*" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -21,6 +23,21 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 + - name: decide docker tag + id: dockertag + run: | + if [$REF == refs/tags/v*] + then + echo "::set-output name=tag::latest" + elif [$REF == /refs/tags/beta-v*] + then + echo "::set-output name=tag::beta" + else + echo "::set-output name=tag::dev" + fi + env: + REF: ${{ github.ref }} + - name: Cache Docker layers uses: actions/cache@v2 with: @@ -50,7 +67,7 @@ jobs: file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:latest + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:${{ steps.dockertag.outputs.tag }} cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache diff --git a/.github/workflows/publish_dev.yml b/.github/workflows/publish_dev.yml deleted file mode 100644 index 6e70f149..00000000 --- a/.github/workflows/publish_dev.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI Dev to Docker Hub - -# Controls when the workflow will run -on: - # Triggers the workflow on push events but for the main branch - push: - branches: [ main ] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -jobs: - # This workflow contains a single job called "build" - build: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - - - name: Cache Docker layers - uses: actions/cache@v2 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - - - name: Login to Docker Hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v1 - - - name: Build and push - id: docker_build - uses: docker/build-push-action@v2 - with: - context: ./ - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:dev - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} From cc3ba7a2bd3298598da51a3f90afa91481092e41 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 21 Mar 2022 15:55:12 +0100 Subject: [PATCH 092/496] fix: docker tag selection --- .github/workflows/publish.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 24874f92..e967dd56 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,14 +26,14 @@ jobs: - name: decide docker tag id: dockertag run: | - if [$REF == refs/tags/v*] + if [[ $REF == "refs/tags/v"* ]] then - echo "::set-output name=tag::latest" - elif [$REF == /refs/tags/beta-v*] + echo "::set-output name=tag::latest" + elif [[ $REF == "/refs/tags/beta-v"* ]] then - echo "::set-output name=tag::beta" + echo "::set-output name=tag::beta" else - echo "::set-output name=tag::dev" + echo "::set-output name=tag::dev" fi env: REF: ${{ github.ref }} From ee43b024ca81f6a4baaa74e6c944e445b02c2ab6 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 21 Mar 2022 16:07:20 +0100 Subject: [PATCH 093/496] fix: update string validation --- backend/app/controller/auth/schemas.py | 4 ++-- backend/app/controller/expense/schemas.py | 4 ++-- .../app/controller/exportimport/schemas.py | 6 ++--- backend/app/controller/item/schemas.py | 2 +- backend/app/controller/onboarding/schemas.py | 6 ++--- .../controller/recipe/recipe_controller.py | 4 ++-- backend/app/controller/recipe/schemas.py | 23 ++++++++++++------- .../app/controller/shoppinglist/schemas.py | 2 +- backend/app/controller/tag/schemas.py | 2 +- backend/app/controller/user/schemas.py | 12 +++++----- .../app/controller/user/user_controller.py | 8 +++---- 11 files changed, 40 insertions(+), 33 deletions(-) diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py index b0cb4035..13570023 100644 --- a/backend/app/controller/auth/schemas.py +++ b/backend/app/controller/auth/schemas.py @@ -4,10 +4,10 @@ class Login(Schema): username = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) password = fields.String( required=True, - validate=lambda a: len(a) > 0, + validate=lambda a: a and not a.isspace(), load_only=True, ) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index f9b6d866..9c83f989 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -8,7 +8,7 @@ class User(Schema): validate=lambda a: a > 0 ) name = fields.String( - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) factor = fields.Integer( load_default=1 @@ -31,7 +31,7 @@ class User(Schema): validate=lambda a: a > 0 ) name = fields.String( - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) factor = fields.Integer( load_default=1 diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index eaa0f2bc..6063cf7f 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -5,14 +5,14 @@ class ImportSchema(Schema): class Item(Schema): name = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) class Recipe(Schema): class RecipeItem(Schema): name = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) optional = fields.Boolean( load_default=False @@ -23,7 +23,7 @@ class RecipeItem(Schema): name = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) description = fields.String( load_default='' diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py index 78b6bfe5..475324ac 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -4,5 +4,5 @@ class SearchByNameRequest(Schema): query = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py index d855d126..bd4cc85e 100644 --- a/backend/app/controller/onboarding/schemas.py +++ b/backend/app/controller/onboarding/schemas.py @@ -4,13 +4,13 @@ class CreateUser(Schema): name = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) username = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) password = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index cc04008f..9da13364 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -68,9 +68,9 @@ def updateRecipe(args, id): # noqa: C901 recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() - if 'name' in args and args['name']: + if 'name' in args: recipe.name = args['name'] - if 'description' in args and args['description']: + if 'description' in args: recipe.description = args['description'] if 'time' in args: recipe.time = args['time'] diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index cd913704..ff81529e 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -5,7 +5,7 @@ class AddRecipe(Schema): class RecipeItem(Schema): name = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) description = fields.String( load_default='' @@ -15,9 +15,12 @@ class RecipeItem(Schema): ) name = fields.String( - required=True + required=True, + validate=lambda a: a and not a.isspace() + ) + description = fields.String( + validate=lambda a: a is not None ) - description = fields.String() time = fields.Integer() source = fields.String() items = fields.List(fields.Nested(RecipeItem())) @@ -28,13 +31,17 @@ class UpdateRecipe(Schema): class RecipeItem(Schema): name = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) description = fields.String() optional = fields.Boolean(load_default=True) - name = fields.String() - description = fields.String() + name = fields.String( + validate=lambda a: a and not a.isspace() + ) + description = fields.String( + validate=lambda a: a is not None + ) time = fields.Integer() source = fields.String() items = fields.List(fields.Nested(RecipeItem())) @@ -44,7 +51,7 @@ class RecipeItem(Schema): class SearchByNameRequest(Schema): query = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) @@ -67,5 +74,5 @@ class RemoveItem(Schema): class ScrapeRecipe(Schema): url = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index bbb0ba8f..4004c8be 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -15,7 +15,7 @@ class Meta: id = fields.Integer(required=True) name = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) description = fields.String( load_default='' diff --git a/backend/app/controller/tag/schemas.py b/backend/app/controller/tag/schemas.py index 59be71d2..66b8c5a3 100644 --- a/backend/app/controller/tag/schemas.py +++ b/backend/app/controller/tag/schemas.py @@ -4,7 +4,7 @@ class SearchByNameRequest(Schema): query = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py index e9d9844d..568383eb 100644 --- a/backend/app/controller/user/schemas.py +++ b/backend/app/controller/user/schemas.py @@ -4,30 +4,30 @@ class CreateUser(Schema): name = fields.String( required=True, - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) username = fields.String( required=True, - validate=lambda a: len(a) > 0, + validate=lambda a: a and not a.isspace(), load_only=True, ) password = fields.String( required=True, - validate=lambda a: len(a) > 0, + validate=lambda a: a and not a.isspace(), load_only=True, ) class UpdateUser(Schema): name = fields.String( - validate=lambda a: len(a) > 0 + validate=lambda a: a and not a.isspace() ) username = fields.String( - validate=lambda a: len(a) > 0, + validate=lambda a: a and not a.isspace(), load_only=True, ) password = fields.String( - validate=lambda a: len(a) > 0, + validate=lambda a: a and not a.isspace(), load_only=True, ) admin = fields.Boolean( diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index dd57509c..464f5d96 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -52,9 +52,9 @@ def updateUser(args): user = User.find_by_username(get_jwt_identity()) if not user: raise NotFoundRequest() - if 'name' in args and args['name']: + if 'name' in args: user.name = args['name'] - if 'password' in args and args['password']: + if 'password' in args: user.set_password(args['password']) user.save() return jsonify({'msg': 'DONE'}) @@ -68,9 +68,9 @@ def updateUserById(args, id): user = User.find_by_id(id) if not user: raise NotFoundRequest() - if 'name' in args and args['name']: + if 'name' in args: user.name = args['name'] - if 'password' in args and args['password']: + if 'password' in args: user.set_password(args['password']) if 'admin' in args: user.admin = args['admin'] or user.owner From 4b13e11317323e06cd1a2de316702c8b79af1cc5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 21 Mar 2022 16:08:24 +0100 Subject: [PATCH 094/496] Prepare beta 20 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 916322a9..167ce0c6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 10 -BACKEND_VERSION = 19 +BACKEND_VERSION = 20 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 7c88c56040ec2e0a79383b209a9a9be298270bb3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 21 Mar 2022 16:26:38 +0100 Subject: [PATCH 095/496] fix: docker tag selection --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e967dd56..45f666ac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,7 +29,7 @@ jobs: if [[ $REF == "refs/tags/v"* ]] then echo "::set-output name=tag::latest" - elif [[ $REF == "/refs/tags/beta-v"* ]] + elif [[ $REF == "refs/tags/beta-v"* ]] then echo "::set-output name=tag::beta" else From d27a0dbf94ee77c35c46bae96e1e1b50925d4a59 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 23 Mar 2022 14:17:49 +0100 Subject: [PATCH 096/496] Prepare release 21 --- backend/app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 167ce0c6..1ea9f88b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -7,8 +7,8 @@ from flask_apscheduler import APScheduler import os -MIN_FRONTEND_VERSION = 10 -BACKEND_VERSION = 20 +MIN_FRONTEND_VERSION = 34 +BACKEND_VERSION = 21 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From 78dd87b92560db5dd561a7e9f4bdc50cc5ed8799 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 23 Mar 2022 15:15:56 +0100 Subject: [PATCH 097/496] fix: error --- backend/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 43d846de..c5293cd4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,6 +25,5 @@ RUN apt-get autoremove --yes gcc g++ libffi-dev \ EXPOSE 80 -USER 1000 CMD ["wsgi.ini"] ENTRYPOINT ["./entrypoint.sh"] From c4d1973607b0e27d1ceceeb8b9a62b8675e4524c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 23 Mar 2022 15:16:42 +0100 Subject: [PATCH 098/496] Prepare release 22 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 1ea9f88b..da4c5e7d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ import os MIN_FRONTEND_VERSION = 34 -BACKEND_VERSION = 21 +BACKEND_VERSION = 22 APP_DIR = os.path.dirname(os.path.abspath(__file__)) From f3e95d68f0f09e23c638743c56885fde621c3e01 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 12 Apr 2022 21:19:18 +0200 Subject: [PATCH 099/496] chore: update requirements --- backend/requirements.txt | 48 ++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index a6329e2e..59fd83c2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,48 +4,48 @@ APScheduler==3.9.1 attrs==21.4.0 autopep8==1.6.0 bcrypt==3.2.0 -beautifulsoup4==4.10.0 +beautifulsoup4==4.11.1 black==21.12b0 certifi==2021.10.8 cffi==1.15.0 charset-normalizer==2.0.12 -click==8.0.4 +click==8.1.2 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.13.0 flake8==4.0.1 -Flask==2.0.3 +Flask==2.1.1 Flask-APScheduler==1.12.3 -Flask-Bcrypt==0.7.1 +Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.3.1 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -fonttools==4.30.0 +fonttools==4.32.0 greenlet==1.1.2 html-text==0.5.2 html5lib==1.1 idna==3.3 iniconfig==1.1.1 isodate==0.6.1 -itsdangerous==2.1.1 -Jinja2==3.0.3 +itsdangerous==2.1.2 +Jinja2==3.1.1 joblib==1.1.0 jstyleson==0.0.2 -kiwisolver==1.3.2 +kiwisolver==1.4.2 lxml==4.8.0 Mako==1.2.0 -MarkupSafe==2.1.0 +MarkupSafe==2.1.1 marshmallow==3.15.0 matplotlib==3.5.1 -mccabe==0.6.1 +mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.19.0 mypy-extensions==0.4.3 numpy==1.22.3 packaging==21.3 -pandas==1.4.1 +pandas==1.4.2 pathspec==0.9.0 -Pillow==9.0.1 +Pillow==9.1.0 platformdirs==2.5.1 pluggy==1.0.0 py==1.11.0 @@ -53,33 +53,33 @@ pycodestyle==2.8.0 pycparser==2.21 pyflakes==2.4.0 PyJWT==2.3.0 -pyparsing==3.0.7 +pyparsing==3.0.8 pyRdfa3==3.5.3 -pytest==7.1.0 +pytest==7.1.1 python-dateutil==2.8.2 python-editor==1.0.4 -pytz==2021.3 +pytz==2022.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.1.1 rdflib-jsonld==0.6.2 -recipe-scrapers==13.20.0 -regex==2022.3.2 +recipe-scrapers==13.31.0 +regex==2022.3.15 requests==2.27.1 scikit-learn==1.0.2 scipy==1.8.0 setuptools-scm==6.4.2 six==1.16.0 -soupsieve==2.3.1 -SQLAlchemy==1.4.32 +soupsieve==2.3.2 +SQLAlchemy==1.4.35 threadpoolctl==3.1.0 toml==0.10.2 -tomli==1.2.3 +tomli==2.0.1 typed-ast==1.5.2 typing_extensions==4.1.1 -tzdata==2021.5 -tzlocal==4.1 -urllib3==1.26.8 +tzdata==2022.1 +tzlocal==4.2 +urllib3==1.26.9 uWSGI==2.0.20 w3lib==1.22.0 webencodings==0.5.1 -Werkzeug==2.0.3 +Werkzeug==2.1.1 From 4515c045705be9c8469233f20ddfffe62525bd55 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 12 Apr 2022 21:19:49 +0200 Subject: [PATCH 100/496] feat: planner send full recipes --- backend/app/controller/planner/planner_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 93c8fdd9..94a30557 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -12,7 +12,7 @@ @jwt_required() def getAllPlannedRecipes(): recipes = Recipe.query.filter(Recipe.planned).order_by(Recipe.name).all() - return jsonify([e.obj_to_dict() for e in recipes]) + return jsonify([e.obj_to_full_dict() for e in recipes]) @planner.route('/recipe', methods=['POST']) From 577a58bbb7e9426d4cc8678eedb2084e4ec0ae01 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 12 Apr 2022 22:07:44 +0200 Subject: [PATCH 101/496] chore: code cleanup --- backend/app/api/__init__.py | 2 +- backend/app/controller/planner/schemas.py | 1 - backend/app/controller/recipe/schemas.py | 1 + backend/app/controller/shoppinglist/shoppinglist_controller.py | 3 +-- backend/app/models/recipe.py | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 2086528a..8e794351 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1 +1 @@ -from . import register_controller \ No newline at end of file +from . import register_controller diff --git a/backend/app/controller/planner/schemas.py b/backend/app/controller/planner/schemas.py index 92c59900..c5ed1efd 100644 --- a/backend/app/controller/planner/schemas.py +++ b/backend/app/controller/planner/schemas.py @@ -1,4 +1,3 @@ -import imp from marshmallow import fields, Schema from marshmallow.validate import Range diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index ff81529e..28cc9fe4 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -71,6 +71,7 @@ class RemoveItem(Schema): required=True, ) + class ScrapeRecipe(Schema): url = fields.String( required=True, diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 30801bb6..2e2a4d67 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -1,8 +1,7 @@ -from app.models import ShoppinglistItems, shoppinglist from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required from app import db -from app.models import Item, Shoppinglist, History, Status, Association +from app.models import Item, Shoppinglist, History, Status, Association, ShoppinglistItems from app.helpers import validate_args from .schemas import (RemoveItem, UpdateDescription, AddItemByName, CreateList, AddRecipeItems) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index a45ba291..0bd69efe 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -27,7 +27,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): 'RecipeItems', back_populates='recipe', cascade="all, delete-orphan") tags = db.relationship( 'RecipeTags', back_populates='recipe', cascade="all, delete-orphan") - + def obj_to_dict(self): res = super().obj_to_dict() res['planned_days'] = list(self.planned_days) From 345934f74fedfc50ae7c7c7d719c0ff2dd6a90c3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 14 Apr 2022 22:43:33 +0200 Subject: [PATCH 102/496] chore: fix requirements --- backend/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 59fd83c2..662cc5eb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -37,7 +37,7 @@ Mako==1.2.0 MarkupSafe==2.1.1 marshmallow==3.15.0 matplotlib==3.5.1 -mccabe==0.7.0 +mccabe==0.6.1 mf2py==1.1.2 mlxtend==0.19.0 mypy-extensions==0.4.3 @@ -73,7 +73,7 @@ soupsieve==2.3.2 SQLAlchemy==1.4.35 threadpoolctl==3.1.0 toml==0.10.2 -tomli==2.0.1 +tomli==1.2.3 typed-ast==1.5.2 typing_extensions==4.1.1 tzdata==2022.1 From 35c1b6692e8aec50d155d038ef35cff582173c19 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 16 Apr 2022 14:52:11 +0200 Subject: [PATCH 103/496] Feature file upload (TomBursch/kitchenowl-backend#6) * Initial setup * Create Upload Folder --- backend/.gitignore | 1 + backend/app/api/register_controller.py | 1 + backend/app/config.py | 8 +++- backend/app/controller/__init__.py | 1 + .../controller/recipe/recipe_controller.py | 4 ++ backend/app/controller/recipe/schemas.py | 2 + backend/app/controller/upload/__init__.py | 1 + .../controller/upload/upload_controller.py | 40 +++++++++++++++++++ backend/entrypoint.sh | 1 + backend/wsgi.py | 5 +++ 10 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 backend/app/controller/upload/__init__.py create mode 100644 backend/app/controller/upload/upload_controller.py diff --git a/backend/.gitignore b/backend/.gitignore index 3d5cefdd..acf90311 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,5 +1,6 @@ *.db *.db-journal +upload/ # Visual Studio Code related .classpath diff --git a/backend/app/api/register_controller.py b/backend/app/api/register_controller.py index ad8e8023..2e890e09 100644 --- a/backend/app/api/register_controller.py +++ b/backend/app/api/register_controller.py @@ -16,3 +16,4 @@ app.register_blueprint(api.shoppinglist, url_prefix='/api/shoppinglist') app.register_blueprint(api.tag, url_prefix='/api/tag') app.register_blueprint(api.user, url_prefix='/api/user') +app.register_blueprint(api.upload, url_prefix='/api/upload') diff --git a/backend/app/config.py b/backend/app/config.py index da4c5e7d..3c960ca1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,13 +11,19 @@ BACKEND_VERSION = 22 APP_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_DIR = os.path.dirname(APP_DIR) + +UPLOAD_FOLDER = os.getenv('STORAGE_PATH', PROJECT_DIR) + '/upload' +ALLOWED_FILE_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} SUPPORTED_LANGUAGES = ['en', 'de'] app = Flask(__name__) +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +app.config['MAX_CONTENT_LENGTH'] = 64 * 1000 * 1000 # 64MB max upload app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + \ - os.getenv('STORAGE_PATH', '..') + '/database.db' + os.getenv('STORAGE_PATH', PROJECT_DIR) + '/database.db' app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 2c46a903..1eb55c2f 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -9,4 +9,5 @@ from .settings import * from .expense import * from .tag import * +from .upload import * from .health_controller import health diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 9da13364..841c3732 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -36,6 +36,8 @@ def addRecipe(args): recipe.time = args['time'] if 'source' in args: recipe.source = args['source'] + if 'photo' in args: + recipe.photo = args['photo'] recipe.save() if 'items' in args: for recipeItem in args['items']: @@ -76,6 +78,8 @@ def updateRecipe(args, id): # noqa: C901 recipe.time = args['time'] if 'source' in args: recipe.source = args['source'] + if 'photo' in args: + recipe.photo = args['photo'] recipe.save() if 'items' in args: for con in recipe.items: diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index 28cc9fe4..acf150af 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -23,6 +23,7 @@ class RecipeItem(Schema): ) time = fields.Integer() source = fields.String() + photo = fields.String() items = fields.List(fields.Nested(RecipeItem())) tags = fields.List(fields.String()) @@ -44,6 +45,7 @@ class RecipeItem(Schema): ) time = fields.Integer() source = fields.String() + photo = fields.String() items = fields.List(fields.Nested(RecipeItem())) tags = fields.List(fields.String()) diff --git a/backend/app/controller/upload/__init__.py b/backend/app/controller/upload/__init__.py new file mode 100644 index 00000000..e191530d --- /dev/null +++ b/backend/app/controller/upload/__init__.py @@ -0,0 +1 @@ +from .upload_controller import upload \ No newline at end of file diff --git a/backend/app/controller/upload/upload_controller.py b/backend/app/controller/upload/upload_controller.py new file mode 100644 index 00000000..ec542d36 --- /dev/null +++ b/backend/app/controller/upload/upload_controller.py @@ -0,0 +1,40 @@ +from app.config import ALLOWED_FILE_EXTENSIONS, UPLOAD_FOLDER +from app.helpers import validate_args +from flask import jsonify, Blueprint, send_from_directory, request, url_for +from flask_jwt_extended import jwt_required +from werkzeug.utils import secure_filename +import os +import uuid + +upload = Blueprint('upload', __name__) + + +@upload.route('', methods=['POST']) +@jwt_required() +def upload_file(): + if 'file' not in request.files: + return jsonify({'msg': 'missing file'}) + + file = request.files['file'] + # If the user does not select a file, the browser submits an + # empty file without a filename. + if file.filename == '': + return jsonify({'msg': 'missing filename'}) + + if file and allowed_file(file.filename): + filename = str(uuid.uuid4()) + '.' + file.filename.rsplit('.', 1)[1].lower() + file.save(os.path.join(UPLOAD_FOLDER, filename)) + return jsonify({'name': filename}) + + raise Exception("Invalid usage.") + + +@upload.route('', methods=['GET']) +@jwt_required() +def download_file(name): + return send_from_directory(UPLOAD_FOLDER, name) + + +def allowed_file(filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_FILE_EXTENSIONS diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 6c7c0e89..4f9cb3a1 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,3 +1,4 @@ #!/bin/sh flask db upgrade +mkdir -p $STORAGE_PATH/upload uwsgi "$@" \ No newline at end of file diff --git a/backend/wsgi.py b/backend/wsgi.py index 6b03f794..f78dae05 100644 --- a/backend/wsgi.py +++ b/backend/wsgi.py @@ -1,4 +1,9 @@ from app import app +import os + +from app.config import UPLOAD_FOLDER if __name__ == "__main__": + if not os.path.exists(UPLOAD_FOLDER): + os.makedirs(UPLOAD_FOLDER) app.run(debug=True) From 9293c8292cf7e7745478988a959780f44b4e6969 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 17 Apr 2022 13:23:21 +0200 Subject: [PATCH 104/496] feat: improved onboarding --- backend/app/config.py | 5 +- .../exportimport/import_controller.py | 56 ++----------------- .../onboarding/onboarding_controller.py | 16 +++++- backend/app/controller/onboarding/schemas.py | 8 ++- backend/app/service/__init__.py | 0 backend/app/service/export_import.py | 53 ++++++++++++++++++ 6 files changed, 82 insertions(+), 56 deletions(-) create mode 100644 backend/app/service/__init__.py create mode 100644 backend/app/service/export_import.py diff --git a/backend/app/config.py b/backend/app/config.py index 3c960ca1..293dd28d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -16,7 +16,10 @@ UPLOAD_FOLDER = os.getenv('STORAGE_PATH', PROJECT_DIR) + '/upload' ALLOWED_FILE_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} -SUPPORTED_LANGUAGES = ['en', 'de'] +SUPPORTED_LANGUAGES = { + 'en': 'English', + 'de': 'Deutsch' +} app = Flask(__name__) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index 2c24df1e..953db36e 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -1,12 +1,9 @@ +from app.service.export_import import importFromDict, importFromLanguage from .schemas import ImportSchema from app.helpers import validate_args from flask import jsonify, Blueprint -from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required -from app.config import APP_DIR, SUPPORTED_LANGUAGES -from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags -import json -from os.path import exists +from app.config import SUPPORTED_LANGUAGES importBP = Blueprint('import', __name__) @@ -15,63 +12,20 @@ @jwt_required() @validate_args(ImportSchema) def importData(args): - _import(args) + importFromDict(args) return jsonify({'msg': 'DONE'}) @importBP.route('/', methods=['GET']) @jwt_required() def importLang(lang): - file_path = f'{APP_DIR}/../templates/{lang}.json' - if lang not in SUPPORTED_LANGUAGES or not exists(file_path): - raise NotFoundRequest('Language code not supported') - with open(file_path, 'r') as f: - data = json.load(f) - _import(data) + importFromLanguage(lang) return jsonify({'msg': 'DONE'}) @importBP.route('/supported-languages', methods=['GET']) -@jwt_required() def getSupportedLanguages(): return jsonify(SUPPORTED_LANGUAGES) -def _import(args): # noqa - if "items" in args: - for importItem in args['items']: - if not Item.find_by_name(importItem['name']): - Item.create_by_name(importItem['name']) - if "recipes" in args: - for importRecipe in args['recipes']: - recipeNameCount = 0 - if Recipe.find_by_name(importRecipe['name']): - recipeNameCount = 1 + \ - Recipe.query.filter(Recipe.name.ilike( - importRecipe['name'] + " (_%)")).count() - recipe = Recipe() - recipe.name = importRecipe['name'] + \ - (f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "") - recipe.description = importRecipe['description'] - recipe.save() - if 'items' in importRecipe: - for recipeItem in importRecipe['items']: - item = Item.find_by_name(recipeItem['name']) - if not item: - item = Item.create_by_name(recipeItem['name']) - con = RecipeItems( - description=recipeItem['description'], - optional=recipeItem['optional'] - ) - con.item = item - con.recipe = recipe - con.save() - if 'tags' in args: - for tagName in args['tags']: - tag = Tag.find_by_name(tagName) - if not tag: - tag = Tag.create_by_name(recipeItem['name']) - con = RecipeTags() - con.tag = tag - con.recipe = recipe - con.save() + diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py index dece72cb..1869918c 100644 --- a/backend/app/controller/onboarding/onboarding_controller.py +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -1,8 +1,9 @@ from app.helpers import validate_args from flask import jsonify, Blueprint from flask_jwt_extended import create_access_token, create_refresh_token -from app.models import User -from .schemas import CreateUser +from app.models import User, Settings +from app.service.export_import import importFromLanguage +from .schemas import OnboardSchema onboarding = Blueprint('onboarding', __name__) @@ -14,9 +15,18 @@ def isOnboarding(): @onboarding.route('', methods=['POST']) -@validate_args(CreateUser) +@validate_args(OnboardSchema) def onboard(args): if User.count() == 0: + if 'planner_feature' in args or 'expenses_feature' in args: + settings = Settings.get() + if 'planner_feature' in args: + settings.planner_feature = args['planner_feature'] + if 'expenses_feature' in args: + settings.expenses_feature = args['expenses_feature'] + settings.save() + if 'language' in args: + importFromLanguage(args['language']) username = args['username'].lower() User.create(username, args['password'], args['name'], owner=True) ret = { diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py index bd4cc85e..db99e752 100644 --- a/backend/app/controller/onboarding/schemas.py +++ b/backend/app/controller/onboarding/schemas.py @@ -1,7 +1,8 @@ from marshmallow import fields, Schema +from app.config import SUPPORTED_LANGUAGES -class CreateUser(Schema): +class OnboardSchema(Schema): name = fields.String( required=True, validate=lambda a: a and not a.isspace() @@ -14,3 +15,8 @@ class CreateUser(Schema): required=True, validate=lambda a: a and not a.isspace() ) + planner_feature = fields.Boolean() + expenses_feature = fields.Boolean() + language = fields.String( + validate=lambda a: a and not a.isspace() and a in SUPPORTED_LANGUAGES + ) diff --git a/backend/app/service/__init__.py b/backend/app/service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py new file mode 100644 index 00000000..00605e2b --- /dev/null +++ b/backend/app/service/export_import.py @@ -0,0 +1,53 @@ +from app.config import APP_DIR, SUPPORTED_LANGUAGES +from os.path import exists +import json + +from app.errors import NotFoundRequest +from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags + +def importFromLanguage(lang): + file_path = f'{APP_DIR}/../templates/{lang}.json' + if lang not in SUPPORTED_LANGUAGES or not exists(file_path): + raise NotFoundRequest('Language code not supported') + with open(file_path, 'r') as f: + data = json.load(f) + importFromDict(data) + +def importFromDict(args): # noqa + if "items" in args: + for importItem in args['items']: + if not Item.find_by_name(importItem['name']): + Item.create_by_name(importItem['name']) + if "recipes" in args: + for importRecipe in args['recipes']: + recipeNameCount = 0 + if Recipe.find_by_name(importRecipe['name']): + recipeNameCount = 1 + \ + Recipe.query.filter(Recipe.name.ilike( + importRecipe['name'] + " (_%)")).count() + recipe = Recipe() + recipe.name = importRecipe['name'] + \ + (f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "") + recipe.description = importRecipe['description'] + recipe.save() + if 'items' in importRecipe: + for recipeItem in importRecipe['items']: + item = Item.find_by_name(recipeItem['name']) + if not item: + item = Item.create_by_name(recipeItem['name']) + con = RecipeItems( + description=recipeItem['description'], + optional=recipeItem['optional'] + ) + con.item = item + con.recipe = recipe + con.save() + if 'tags' in args: + for tagName in args['tags']: + tag = Tag.find_by_name(tagName) + if not tag: + tag = Tag.create_by_name(recipeItem['name']) + con = RecipeTags() + con.tag = tag + con.recipe = recipe + con.save() \ No newline at end of file From 6d97bf78f660635b6915597f7154ed099ef2e1c8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 17 Apr 2022 13:23:33 +0200 Subject: [PATCH 105/496] fix: authentication --- backend/app/controller/auth/auth_controller.py | 6 ++++-- backend/app/controller/user/user_controller.py | 5 ++++- backend/app/helpers/admin_required.py | 5 +++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 3cc9a8a1..cde9feb2 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -36,8 +36,10 @@ def fresh_login(args): @auth.route('/refresh', methods=['GET']) @jwt_required(refresh=True) def refresh(): - current_user = get_jwt_identity() + user = User.find_by_username(get_jwt_identity()) + if not user: + raise UnauthorizedRequest(message='Unauthorized') ret = { - 'access_token': create_access_token(identity=current_user) + 'access_token': create_access_token(identity=user.username) } return jsonify(ret) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 464f5d96..adb6123b 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -19,7 +19,10 @@ def getAllUsers(): @user.route('', methods=['GET']) @jwt_required() def getLoggedInUser(): - return jsonify(User.find_by_username(get_jwt_identity()).obj_to_dict(skip_columns=['password'])) + user = User.find_by_username(get_jwt_identity()) + if not user: + raise UnauthorizedRequest(message='Unauthorized') + return jsonify(user.obj_to_dict(skip_columns=['password'])) @user.route('/', methods=['GET']) diff --git a/backend/app/helpers/admin_required.py b/backend/app/helpers/admin_required.py index 8dacb61a..8263431a 100644 --- a/backend/app/helpers/admin_required.py +++ b/backend/app/helpers/admin_required.py @@ -8,7 +8,7 @@ def admin_required(func): @wraps(func) def func_wrapper(*args, **kwargs): user = User.find_by_username(get_jwt_identity()) - if not user.owner and not user.admin: + if not user or not (user.owner or user.admin): raise UnauthorizedRequest( message='Elevated rights required' ) @@ -20,7 +20,8 @@ def func_wrapper(*args, **kwargs): def owner_required(func): @wraps(func) def func_wrapper(*args, **kwargs): - if not User.find_by_username(get_jwt_identity()).owner: + user = User.find_by_username(get_jwt_identity()) + if not user or not user.owner: raise UnauthorizedRequest( message='Elevated rights required' ) From bcfa213dcdd2ee903039cc03b1ac553cff7a27dd Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 18 Apr 2022 15:11:14 +0200 Subject: [PATCH 106/496] fix: db migration settings --- backend/app/config.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 293dd28d..9c85ddc2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,3 +1,4 @@ +from sqlalchemy import MetaData from app.errors import NotFoundRequest from flask import Flask, jsonify, request from flask_migrate import Migrate @@ -30,9 +31,18 @@ app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +convention = { + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" +} + +metadata = MetaData(naming_convention=convention) -db = SQLAlchemy(app) -migrate = Migrate(app, db) +db = SQLAlchemy(app, metadata=metadata) +migrate = Migrate(app, db, render_as_batch=True) bcrypt = Bcrypt(app) jwt = JWTManager(app) From 13c64edf3ad25ac478a41ed40286dd00c4f75a6b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 19 Apr 2022 12:10:39 +0200 Subject: [PATCH 107/496] feat: expense categories --- .../controller/expense/expense_controller.py | 42 +++++++++++-- backend/app/controller/expense/schemas.py | 18 +++++- backend/app/models/__init__.py | 1 + backend/app/models/expense.py | 6 +- backend/app/models/expense_category.py | 37 ++++++++++++ backend/migrations/versions/11c15698c8bf_.py | 60 +++++++++++++++++++ 6 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 backend/app/models/expense_category.py create mode 100644 backend/migrations/versions/11c15698c8bf_.py diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 850602fd..8920e6e4 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -4,8 +4,8 @@ from flask_jwt_extended import jwt_required from sqlalchemy import func from app.helpers import validate_args, admin_required -from app.models import Expense, ExpensePaidFor, User -from .schemas import AddExpense, UpdateExpense +from app.models import Expense, ExpensePaidFor, User, ExpenseCategory +from .schemas import AddExpense, UpdateExpense, DeleteExpenseCategory expense = Blueprint('expense', __name__) @@ -13,7 +13,7 @@ @expense.route('', methods=['GET']) @jwt_required() def getAllExpenses(): - return jsonify([e.obj_to_full_dict() for e in Expense.query.order_by(desc(Expense.id)).limit(50).all()]) + return jsonify([e.obj_to_full_dict() for e in Expense.query.order_by(desc(Expense.id)).join(Expense.category, isouter=True).limit(50).all()]) @expense.route('/', methods=['GET']) @@ -35,6 +35,14 @@ def addExpense(args): expense = Expense() expense.name = args['name'] expense.amount = args['amount'] + if 'category' in args: + if not args['category']: + expense.category = None + else: + category = ExpenseCategory.find_by_name(args['category']) + if not category: + category = ExpenseCategory.create_by_name(args['category']) + expense.category = category expense.paid_by = user expense.save() user.expense_balance = (user.expense_balance or 0) + expense.amount @@ -65,11 +73,19 @@ def updateExpense(args, id): # noqa: C901 expense = Expense.find_by_id(id) if not expense: raise NotFoundRequest() - if 'name' in args and args['name']: + if 'name' in args: expense.name = args['name'] - if 'amount' in args and args['amount']: + if 'amount' in args: expense.amount = args['amount'] - if 'paid_by' in args and args['paid_by']: + if 'category' in args: + if not args['category']: + expense.category = None + else: + category = ExpenseCategory.find_by_name(args['category']) + if not category: + category = ExpenseCategory.create_by_name(args['category']) + expense.category = category + if 'paid_by' in args: user = User.find_by_id(args['paid_by']['id']) if user: expense.paid_by = user @@ -119,3 +135,17 @@ def calculateBalances(): user.expense_balance = user.expense_balance - \ (expense.factor / factor_sum) * expense.expense.amount user.save() + + +@expense.route('/categories', methods=['GET']) +@jwt_required() +def getExpenseCategories(): + return jsonify([e.name for e in ExpenseCategory.all_by_name()]) + +@expense.route('/categories', methods=['DELETE']) +@jwt_required() +@admin_required +@validate_args(DeleteExpenseCategory) +def deleteExpenseCategoryById(args): + ExpenseCategory.delete_by_name(args['name']) + return jsonify({'msg': 'DONE'}) \ No newline at end of file diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 9c83f989..5ee745b9 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -20,8 +20,13 @@ class User(Schema): amount = fields.Float( required=True ) + category = fields.String( + validate=lambda a: not a or ( + a and not a.isspace()), 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) + paid_for = fields.List(fields.Nested( + User()), required=True, validate=lambda a: len(a) > 0) class UpdateExpense(Schema): @@ -39,5 +44,16 @@ class User(Schema): name = fields.String() amount = fields.Float() + category = fields.String( + validate=lambda a: not a or ( + a and not a.isspace()), allow_none=True + ) paid_by = fields.Nested(User()) paid_for = fields.List(fields.Nested(User())) + + +class DeleteExpenseCategory(Schema): + name = fields.String( + required=True, + validate=lambda a: a and not a.isspace() + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bca5b70e..bc236b0b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,3 +8,4 @@ from .tag import Tag from .shoppinglist import ShoppinglistItems, Shoppinglist from .recipe_history import RecipeHistory +from .expense_category import ExpenseCategory diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index b0fc3c89..e5e6f8ef 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -8,9 +8,11 @@ class Expense(db.Model, DbModelMixin, TimestampMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) amount = db.Column(db.Float()) + category_id = db.Column(db.Integer, db.ForeignKey('expense_category.id')) photo = db.Column(db.String()) paid_by_id = db.Column(db.Integer, db.ForeignKey('user.id')) + category = db.relationship("ExpenseCategory") paid_by = db.relationship("User") paid_for = db.relationship( 'ExpensePaidFor', back_populates='expense', cascade="all, delete-orphan") @@ -21,6 +23,8 @@ def obj_to_full_dict(self): ExpensePaidFor.user).order_by( ExpensePaidFor.expense_id).all() res['paid_for'] = [e.obj_to_dict() for e in paidFor] + if (self.category): + res['category'] = self.category.name return res @classmethod @@ -29,7 +33,7 @@ def find_by_name(cls, name): @classmethod def find_by_id(cls, id): - return cls.query.filter(cls.id == id).first() + return cls.query.filter(cls.id == id).join(Expense.category, isouter=True).first() class ExpensePaidFor(db.Model, DbModelMixin, TimestampMixin): diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py new file mode 100644 index 00000000..ca1e8845 --- /dev/null +++ b/backend/app/models/expense_category.py @@ -0,0 +1,37 @@ +from __future__ import annotations +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class ExpenseCategory(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'expense_category' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + + expenses = db.relationship( + 'Expense', back_populates='category', cascade="all, delete-orphan") + + def obj_to_full_dict(self) -> dict: + res = super().obj_to_dict() + return res + + @classmethod + def create_by_name(cls, name) -> ExpenseCategory: + return cls( + name=name, + ).save() + + @classmethod + def find_by_name(cls, name) -> ExpenseCategory: + return cls.query.filter(cls.name == name).first() + + @classmethod + def find_by_id(cls, id) -> ExpenseCategory: + return cls.query.filter(cls.id == id).first() + + @classmethod + def delete_by_name(cls, name): + mc = cls.find_by_name(name) + if mc: + mc.delete() diff --git a/backend/migrations/versions/11c15698c8bf_.py b/backend/migrations/versions/11c15698c8bf_.py new file mode 100644 index 00000000..056e6dae --- /dev/null +++ b/backend/migrations/versions/11c15698c8bf_.py @@ -0,0 +1,60 @@ +"""empty message + +Revision ID: 11c15698c8bf +Revises: e209fcb83993 +Create Date: 2022-04-18 15:12:24.971186 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '11c15698c8bf' +down_revision = 'e209fcb83993' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('expense_category', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_expense_category')) + ) + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.add_column(sa.Column('category_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_expense_category_id_expense_category'), 'expense_category', ['category_id'], ['id']) + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_item_name'), ['name']) + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_shoppinglist_name'), ['name']) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_user_username'), ['username']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_user_username'), type_='unique') + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_shoppinglist_name'), type_='unique') + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_item_name'), type_='unique') + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_expense_category_id_expense_category'), type_='foreignkey') + batch_op.drop_column('category_id') + + op.drop_table('expense_category') + # ### end Alembic commands ### From 6600c58133ec6feff9a3b53a8b9f5fcd9b8a0134 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 20 Apr 2022 09:43:49 +0200 Subject: [PATCH 108/496] feat: create expense category endpoint --- backend/app/controller/expense/expense_controller.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 8920e6e4..fd47bba8 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -142,10 +142,19 @@ def calculateBalances(): def getExpenseCategories(): return jsonify([e.name for e in ExpenseCategory.all_by_name()]) + +@expense.route('/categories', methods=['POST']) +@jwt_required() +@validate_args(AddExpenseCategory) +def addExpenseCategory(args): + ExpenseCategory.create_by_name(args['name']) + return jsonify(ExpenseCategory.obj_to_dict()) + + @expense.route('/categories', methods=['DELETE']) @jwt_required() @admin_required @validate_args(DeleteExpenseCategory) def deleteExpenseCategoryById(args): ExpenseCategory.delete_by_name(args['name']) - return jsonify({'msg': 'DONE'}) \ No newline at end of file + return jsonify({'msg': 'DONE'}) From 08a2f0b7389fa09ed672f5bba4add7f21172c618 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 20 Apr 2022 09:43:55 +0200 Subject: [PATCH 109/496] chore: cleanup --- backend/app/controller/expense/expense_controller.py | 7 +++++-- backend/app/controller/expense/schemas.py | 7 +++++++ backend/app/controller/exportimport/import_controller.py | 3 --- backend/app/controller/upload/__init__.py | 2 +- backend/app/controller/upload/upload_controller.py | 6 +++--- backend/app/service/export_import.py | 4 +++- 6 files changed, 19 insertions(+), 10 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index fd47bba8..4ea8632f 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -5,7 +5,7 @@ from sqlalchemy import func from app.helpers import validate_args, admin_required from app.models import Expense, ExpensePaidFor, User, ExpenseCategory -from .schemas import AddExpense, UpdateExpense, DeleteExpenseCategory +from .schemas import AddExpense, UpdateExpense, AddExpenseCategory, DeleteExpenseCategory expense = Blueprint('expense', __name__) @@ -13,7 +13,10 @@ @expense.route('', methods=['GET']) @jwt_required() def getAllExpenses(): - return jsonify([e.obj_to_full_dict() for e in Expense.query.order_by(desc(Expense.id)).join(Expense.category, isouter=True).limit(50).all()]) + return jsonify([e.obj_to_full_dict() for e + in Expense.query.order_by(desc(Expense.id)) + .join(Expense.category, isouter=True).limit(50).all() + ]) @expense.route('/', methods=['GET']) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 5ee745b9..8998403b 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -52,6 +52,13 @@ class User(Schema): paid_for = fields.List(fields.Nested(User())) +class AddExpenseCategory(Schema): + name = fields.String( + required=True, + validate=lambda a: a and not a.isspace() + ) + + class DeleteExpenseCategory(Schema): name = fields.String( required=True, diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index 953db36e..1cdc51a7 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -26,6 +26,3 @@ def importLang(lang): @importBP.route('/supported-languages', methods=['GET']) def getSupportedLanguages(): return jsonify(SUPPORTED_LANGUAGES) - - - diff --git a/backend/app/controller/upload/__init__.py b/backend/app/controller/upload/__init__.py index e191530d..96ccd2d8 100644 --- a/backend/app/controller/upload/__init__.py +++ b/backend/app/controller/upload/__init__.py @@ -1 +1 @@ -from .upload_controller import upload \ No newline at end of file +from .upload_controller import upload diff --git a/backend/app/controller/upload/upload_controller.py b/backend/app/controller/upload/upload_controller.py index ec542d36..265d4a1b 100644 --- a/backend/app/controller/upload/upload_controller.py +++ b/backend/app/controller/upload/upload_controller.py @@ -1,6 +1,5 @@ from app.config import ALLOWED_FILE_EXTENSIONS, UPLOAD_FOLDER -from app.helpers import validate_args -from flask import jsonify, Blueprint, send_from_directory, request, url_for +from flask import jsonify, Blueprint, send_from_directory, request from flask_jwt_extended import jwt_required from werkzeug.utils import secure_filename import os @@ -22,7 +21,8 @@ def upload_file(): return jsonify({'msg': 'missing filename'}) if file and allowed_file(file.filename): - filename = str(uuid.uuid4()) + '.' + file.filename.rsplit('.', 1)[1].lower() + filename = secure_filename(str(uuid.uuid4()) + '.' + + file.filename.rsplit('.', 1)[1].lower()) file.save(os.path.join(UPLOAD_FOLDER, filename)) return jsonify({'name': filename}) diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index 00605e2b..71fb1e95 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -5,6 +5,7 @@ from app.errors import NotFoundRequest from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags + def importFromLanguage(lang): file_path = f'{APP_DIR}/../templates/{lang}.json' if lang not in SUPPORTED_LANGUAGES or not exists(file_path): @@ -13,6 +14,7 @@ def importFromLanguage(lang): data = json.load(f) importFromDict(data) + def importFromDict(args): # noqa if "items" in args: for importItem in args['items']: @@ -50,4 +52,4 @@ def importFromDict(args): # noqa con = RecipeTags() con.tag = tag con.recipe = recipe - con.save() \ No newline at end of file + con.save() From 332d8cab3b6d0adaff4cfb8bc67cb2c3d0cfa31f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 20 Apr 2022 09:45:56 +0200 Subject: [PATCH 110/496] Prepare beta 23 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 9c85ddc2..bf816bbf 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,7 +9,7 @@ import os MIN_FRONTEND_VERSION = 34 -BACKEND_VERSION = 22 +BACKEND_VERSION = 23 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From db0b5285504cf52aa3dfdc55eea6271dad49574d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 20 Apr 2022 16:49:17 +0200 Subject: [PATCH 111/496] fix: max upload size --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index bf816bbf..7749bc06 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,7 +25,7 @@ app = Flask(__name__) app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER -app.config['MAX_CONTENT_LENGTH'] = 64 * 1000 * 1000 # 64MB max upload +app.config['MAX_CONTENT_LENGTH'] = 32 * 1000 * 1000 # 32MB max upload app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + \ os.getenv('STORAGE_PATH', PROJECT_DIR) + '/database.db' app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') From 48235bfedae5d4eb5ed44f7f2f5c40954f1b70ba Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 21 Apr 2022 15:30:01 +0200 Subject: [PATCH 112/496] Prepare release 24 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 7749bc06..e3627984 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,7 +9,7 @@ import os MIN_FRONTEND_VERSION = 34 -BACKEND_VERSION = 23 +BACKEND_VERSION = 24 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 221520b4b25b6245136aafd8e320b03ce56592a8 Mon Sep 17 00:00:00 2001 From: cMensendiek Date: Wed, 27 Apr 2022 12:59:35 +0200 Subject: [PATCH 113/496] removed recent recipes form suggested recipes --- backend/app/controller/planner/planner_controller.py | 10 ++++++++-- backend/app/models/recipe.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 94a30557..7d48dec5 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -62,7 +62,14 @@ def getRecentRecipes(): @planner.route('/suggested-recipes', methods=['GET']) @jwt_required() def getSuggestedRecipes(): + # all suggestions suggested_recipes = Recipe.find_suggestions() + # remove recipes on recent list + recents = [e.recipe.id for e in RecipeHistory.get_recent()] + suggested_recipes = [s for s in suggested_recipes if s.id not in recents] + # limit suggestions number to maximally 9 + if len(suggested_recipes) > 9: + suggested_recipes = suggested_recipes[:9] return jsonify([r.obj_to_dict() for r in suggested_recipes]) @@ -72,5 +79,4 @@ def getRefreshedSuggestedRecipes(): # re-compute suggestion ranking Recipe.compute_suggestion_ranking() # return suggested recipes - suggested_recipes = Recipe.find_suggestions() - return jsonify([r.obj_to_dict() for r in suggested_recipes]) + return getSuggestedRecipes() \ No newline at end of file diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 0bd69efe..275db0e9 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -93,7 +93,7 @@ def compute_suggestion_ranking(cls): @classmethod def find_suggestions(cls): return cls.query.filter(cls.planned == False).filter( # noqa - cls.suggestion_rank > 0).order_by(cls.suggestion_rank).limit(9).all() + cls.suggestion_rank > 0).order_by(cls.suggestion_rank).all() @classmethod def find_by_name(cls, name) -> Recipe: From a014066c6f19598d13f6754c9cb372c8f525a670 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 9 May 2022 19:11:05 +0200 Subject: [PATCH 114/496] chore: cleanup code --- backend/app/controller/planner/planner_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 7d48dec5..03a14638 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -67,7 +67,7 @@ def getSuggestedRecipes(): # remove recipes on recent list recents = [e.recipe.id for e in RecipeHistory.get_recent()] suggested_recipes = [s for s in suggested_recipes if s.id not in recents] - # limit suggestions number to maximally 9 + # limit suggestions number to maximally 9 if len(suggested_recipes) > 9: suggested_recipes = suggested_recipes[:9] return jsonify([r.obj_to_dict() for r in suggested_recipes]) @@ -79,4 +79,4 @@ def getRefreshedSuggestedRecipes(): # re-compute suggestion ranking Recipe.compute_suggestion_ranking() # return suggested recipes - return getSuggestedRecipes() \ No newline at end of file + return getSuggestedRecipes() From e9b2c278c3783fc45d1459af1f960d6ae903d9e1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 18 May 2022 17:11:56 +0200 Subject: [PATCH 115/496] chore: upgrade requirements --- backend/requirements.txt | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 662cc5eb..36308bf2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,24 +3,24 @@ appdirs==1.4.4 APScheduler==3.9.1 attrs==21.4.0 autopep8==1.6.0 -bcrypt==3.2.0 +bcrypt==3.2.2 beautifulsoup4==4.11.1 black==21.12b0 certifi==2021.10.8 cffi==1.15.0 charset-normalizer==2.0.12 -click==8.1.2 +click==8.1.3 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.13.0 flake8==4.0.1 -Flask==2.1.1 +Flask==2.1.2 Flask-APScheduler==1.12.3 Flask-Bcrypt==1.0.1 -Flask-JWT-Extended==4.3.1 +Flask-JWT-Extended==4.4.0 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -fonttools==4.32.0 +fonttools==4.33.3 greenlet==1.1.2 html-text==0.5.2 html5lib==1.1 @@ -28,7 +28,7 @@ idna==3.3 iniconfig==1.1.1 isodate==0.6.1 itsdangerous==2.1.2 -Jinja2==3.1.1 +Jinja2==3.1.2 joblib==1.1.0 jstyleson==0.0.2 kiwisolver==1.4.2 @@ -36,7 +36,7 @@ lxml==4.8.0 Mako==1.2.0 MarkupSafe==2.1.1 marshmallow==3.15.0 -matplotlib==3.5.1 +matplotlib==3.5.2 mccabe==0.6.1 mf2py==1.1.2 mlxtend==0.19.0 @@ -45,41 +45,41 @@ numpy==1.22.3 packaging==21.3 pandas==1.4.2 pathspec==0.9.0 -Pillow==9.1.0 -platformdirs==2.5.1 +Pillow==9.1.1 +platformdirs==2.5.2 pluggy==1.0.0 py==1.11.0 pycodestyle==2.8.0 pycparser==2.21 pyflakes==2.4.0 -PyJWT==2.3.0 -pyparsing==3.0.8 +PyJWT==2.4.0 +pyparsing==3.0.9 pyRdfa3==3.5.3 -pytest==7.1.1 +pytest==7.1.2 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2022.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.1.1 rdflib-jsonld==0.6.2 -recipe-scrapers==13.31.0 -regex==2022.3.15 +recipe-scrapers==13.33.0 +regex==2022.4.24 requests==2.27.1 -scikit-learn==1.0.2 -scipy==1.8.0 +scikit-learn==1.1.0 +scipy==1.8.1 setuptools-scm==6.4.2 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.35 +SQLAlchemy==1.4.36 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 -typed-ast==1.5.2 -typing_extensions==4.1.1 +typed-ast==1.5.3 +typing_extensions==4.2.0 tzdata==2022.1 tzlocal==4.2 urllib3==1.26.9 uWSGI==2.0.20 w3lib==1.22.0 webencodings==0.5.1 -Werkzeug==2.1.1 +Werkzeug==2.1.2 From 1df15c6bffdb688b70ac76fc3c2bab0b686a26f4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 18 May 2022 20:06:31 +0200 Subject: [PATCH 116/496] Item categories (TomBursch/kitchenowl-backend#7) * Initial setup * Fix bugs * cleanup rebase * Update categories * fix: style --- backend/app/api/register_controller.py | 1 + backend/app/controller/__init__.py | 1 + backend/app/controller/category/__init__.py | 1 + .../category/category_controller.py | 52 ++ backend/app/controller/category/schemas.py | 13 + .../exportimport/export_controller.py | 4 +- .../app/controller/item/item_controller.py | 23 +- backend/app/controller/item/schemas.py | 11 +- backend/app/models/__init__.py | 1 + backend/app/models/category.py | 33 + backend/app/models/expense_category.py | 2 +- backend/app/models/item.py | 29 +- backend/app/service/export_import.py | 19 +- backend/migrations/versions/6d3027e07dc4_.py | 45 ++ backend/templates/de.json | 626 +++++++++++++----- backend/templates/en.json | 304 ++++----- 16 files changed, 818 insertions(+), 347 deletions(-) create mode 100644 backend/app/controller/category/__init__.py create mode 100644 backend/app/controller/category/category_controller.py create mode 100644 backend/app/controller/category/schemas.py create mode 100644 backend/app/models/category.py create mode 100644 backend/migrations/versions/6d3027e07dc4_.py diff --git a/backend/app/api/register_controller.py b/backend/app/api/register_controller.py index 2e890e09..5d177dc9 100644 --- a/backend/app/api/register_controller.py +++ b/backend/app/api/register_controller.py @@ -5,6 +5,7 @@ app.register_blueprint( api.health, url_prefix='/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V') app.register_blueprint(api.auth, url_prefix='/api/auth') +app.register_blueprint(api.category, url_prefix='/api/category') app.register_blueprint(api.expense, url_prefix='/api/expense') app.register_blueprint(api.export, url_prefix='/api/export') app.register_blueprint(api.importBP, url_prefix='/api/import') diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 1eb55c2f..896ea023 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -10,4 +10,5 @@ from .expense import * from .tag import * from .upload import * +from .category import * from .health_controller import health diff --git a/backend/app/controller/category/__init__.py b/backend/app/controller/category/__init__.py new file mode 100644 index 00000000..76734239 --- /dev/null +++ b/backend/app/controller/category/__init__.py @@ -0,0 +1 @@ +from .category_controller import category diff --git a/backend/app/controller/category/category_controller.py b/backend/app/controller/category/category_controller.py new file mode 100644 index 00000000..01d70680 --- /dev/null +++ b/backend/app/controller/category/category_controller.py @@ -0,0 +1,52 @@ +from app.helpers import validate_args +from flask import jsonify, Blueprint +from app.errors import NotFoundRequest +from flask_jwt_extended import jwt_required +from app.models import Category +from .schemas import AddCategory, DeleteCategory + +category = Blueprint('category', __name__) + + +@category.route('', methods=['GET']) +@jwt_required() +def getAllCategories(): + return jsonify([e.obj_to_dict() for e in Category.all_by_name()]) + + +@category.route('/', methods=['GET']) +@jwt_required() +def getCategory(id): + category = Category.find_by_id(id) + if not category: + raise NotFoundRequest() + return jsonify(category.obj_to_dict()) + + +@category.route('', methods=['POST']) +@jwt_required() +@validate_args(AddCategory) +def addCategory(args): + category = Category() + category.name = args['name'] + category.save() + return jsonify(category.obj_to_dict()) + + +@category.route('/', methods=['DELETE']) +@jwt_required() +def deleteCategoryById(id): + Category.delete_by_id(id) + return jsonify({'msg': 'DONE'}) + + +@category.route('', methods=['DELETE']) +@jwt_required() +@validate_args(DeleteCategory) +def deleteExpenseCategoryById(args): + if "name" in args: + category = Category.find_by_name(args['name']) + if category: + category.delete() + return jsonify({'msg': 'DONE'}) + raise NotFoundRequest() diff --git a/backend/app/controller/category/schemas.py b/backend/app/controller/category/schemas.py new file mode 100644 index 00000000..ebd80d89 --- /dev/null +++ b/backend/app/controller/category/schemas.py @@ -0,0 +1,13 @@ +from marshmallow import fields, Schema + + +class AddCategory(Schema): + name = fields.String( + required=True + ) + + +class DeleteCategory(Schema): + name = fields.String( + required=True + ) diff --git a/backend/app/controller/exportimport/export_controller.py b/backend/app/controller/exportimport/export_controller.py index ae0d6b26..b20a5b7c 100644 --- a/backend/app/controller/exportimport/export_controller.py +++ b/backend/app/controller/exportimport/export_controller.py @@ -9,7 +9,7 @@ @jwt_required() def getExportAll(): return jsonify({ - "items": [e.obj_to_export_dict() for e in Item.all()], + "items": [e.obj_to_export_dict() for e in Item.allByName()], "recipes": [e.obj_to_export_dict() for e in Recipe.all()] }) @@ -17,7 +17,7 @@ def getExportAll(): @export.route('/items', methods=['GET']) @jwt_required() def getExportItems(): - return jsonify({"items": [e.obj_to_export_dict() for e in Item.all()]}) + return jsonify({"items": [e.obj_to_export_dict() for e in Item.allByName()]}) @export.route('/recipes', methods=['GET']) diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 0ff2f462..8a072503 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -2,8 +2,8 @@ from flask import jsonify, Blueprint from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required -from app.models import Item, RecipeItems, Recipe -from .schemas import SearchByNameRequest +from app.models import Item, RecipeItems, Recipe, Category +from .schemas import SearchByNameRequest, UpdateItem item = Blueprint('item', __name__) @@ -45,3 +45,22 @@ def deleteItemById(id): @validate_args(SearchByNameRequest) def searchItemByName(args): return jsonify([e.obj_to_dict() for e in Item.search_name(args['query'])]) + + +@item.route('/', methods=['POST']) +@jwt_required() +@validate_args(UpdateItem) +def updateItem(args, id): + item = Item.find_by_id(id) + if not item: + raise NotFoundRequest() + if 'category' in args: + if not args['category']: + item.category = None + else: + category = Category.find_by_name(args['category']) + if not category: + category = Category.create_by_name(args['category']) + item.category = category + item.save() + return jsonify(item.obj_to_dict()) diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py index 475324ac..c9d2ce9c 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -1,4 +1,4 @@ -from marshmallow import fields, Schema +from marshmallow import fields, Schema, EXCLUDE class SearchByNameRequest(Schema): @@ -6,3 +6,12 @@ class SearchByNameRequest(Schema): required=True, validate=lambda a: a and not a.isspace() ) + + +class UpdateItem(Schema): + class Meta: + unknown = EXCLUDE + category = fields.String( + allow_none=True, + validate=lambda a: not a or a and not a.isspace() + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bc236b0b..1ab372d6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,3 +9,4 @@ from .shoppinglist import ShoppinglistItems, Shoppinglist from .recipe_history import RecipeHistory from .expense_category import ExpenseCategory +from .category import Category diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 00000000..a534e358 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class Category(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'category' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + default = db.Column(db.Boolean, default=False) + + items = db.relationship( + 'Item', back_populates='category') + + def obj_to_full_dict(self) -> dict: + res = super().obj_to_dict() + return res + + @classmethod + def create_by_name(cls, name, default=False) -> Category: + return cls( + name=name, + default=default, + ).save() + + @classmethod + def find_by_name(cls, name) -> Category: + return cls.query.filter(cls.name == name).first() + + @classmethod + def find_by_id(cls, id) -> Category: + return cls.query.filter(cls.id == id).first() diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py index ca1e8845..26e27df3 100644 --- a/backend/app/models/expense_category.py +++ b/backend/app/models/expense_category.py @@ -10,7 +10,7 @@ class ExpenseCategory(db.Model, DbModelMixin, TimestampMixin): name = db.Column(db.String(128)) expenses = db.relationship( - 'Expense', back_populates='category', cascade="all, delete-orphan") + 'Expense', back_populates='category') def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 17fc8c3d..72454411 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -1,5 +1,7 @@ +from __future__ import annotations from app import db from app.helpers import DbModelMixin, TimestampMixin +from app.models.category import Category class Item(db.Model, DbModelMixin, TimestampMixin): @@ -7,6 +9,10 @@ class Item(db.Model, DbModelMixin, TimestampMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128), unique=True) + category_id = db.Column(db.Integer, db.ForeignKey('category.id')) + default = db.Column(db.Boolean, default=False) + + category = db.relationship("Category") recipes = db.relationship( 'RecipeItems', back_populates='item', cascade="all, delete-orphan") @@ -25,24 +31,41 @@ class Item(db.Model, DbModelMixin, TimestampMixin): consequents = db.relationship( "Association", back_populates="consequent", foreign_keys='Association.consequent_id') + def obj_to_dict(self): + res = super().obj_to_dict() + if self.category_id: + category = Category.find_by_id(self.category_id) + res['category'] = category.obj_to_dict() + return res + def obj_to_export_dict(self): res = { "name": self.name, } + if self.category: + res["category"] = self.category.name return res @classmethod - def create_by_name(cls, name): + def create_by_name(cls, name, default=False) -> Item: return cls( name=name, + default=default, ).save() @classmethod - def find_by_name(cls, name): + def allByName(cls): + """ + Return all instances of Item ordered by name + """ + return cls.query.order_by(cls.name).all() + + @classmethod + def find_by_name(cls, name) -> Item: return cls.query.filter(cls.name == name).first() @classmethod - def find_by_id(cls, id): + def find_by_id(cls, id) -> Item: return cls.query.filter(cls.id == id).first() @classmethod diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index 71fb1e95..7f3d4a5d 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -3,7 +3,7 @@ import json from app.errors import NotFoundRequest -from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags +from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags, Category def importFromLanguage(lang): @@ -12,14 +12,23 @@ def importFromLanguage(lang): raise NotFoundRequest('Language code not supported') with open(file_path, 'r') as f: data = json.load(f) - importFromDict(data) + importFromDict(data, True) -def importFromDict(args): # noqa +def importFromDict(args, default=False): # noqa if "items" in args: for importItem in args['items']: if not Item.find_by_name(importItem['name']): - Item.create_by_name(importItem['name']) + item = Item() + item.name = importItem['name'] + item.default = default + if "category" in importItem: + category = Category.find_by_name(importItem['category']) + if not category: + category = Category.create_by_name( + importItem['category'], default) + item.category = category + item.save() if "recipes" in args: for importRecipe in args['recipes']: recipeNameCount = 0 @@ -36,7 +45,7 @@ def importFromDict(args): # noqa for recipeItem in importRecipe['items']: item = Item.find_by_name(recipeItem['name']) if not item: - item = Item.create_by_name(recipeItem['name']) + item = Item.create_by_name(recipeItem['name'], default) con = RecipeItems( description=recipeItem['description'], optional=recipeItem['optional'] diff --git a/backend/migrations/versions/6d3027e07dc4_.py b/backend/migrations/versions/6d3027e07dc4_.py new file mode 100644 index 00000000..1cd03a4e --- /dev/null +++ b/backend/migrations/versions/6d3027e07dc4_.py @@ -0,0 +1,45 @@ +"""empty message + +Revision ID: 6d3027e07dc4 +Revises: 11c15698c8bf +Create Date: 2022-05-18 19:53:39.773740 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6d3027e07dc4' +down_revision = '11c15698c8bf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('category', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('default', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_category')) + ) + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.add_column(sa.Column('category_id', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('default', sa.Boolean(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_item_category_id_category'), 'category', ['category_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_item_category_id_category'), type_='foreignkey') + batch_op.drop_column('default') + batch_op.drop_column('category_id') + + op.drop_table('category') + # ### end Alembic commands ### diff --git a/backend/templates/de.json b/backend/templates/de.json index 9e63a47c..8922e26d 100644 --- a/backend/templates/de.json +++ b/backend/templates/de.json @@ -1,574 +1,838 @@ { "items": [ { - "name": "Spülmittel" + "name": "Aioli" }, { - "name": "Milch" + "name": "Ananas" }, { - "name": "Müsli" + "name": "Apfel" }, { - "name": "Toast" + "name": "Apfelmus" }, { - "name": "Nudeln" + "name": "Asiatische Eiernudeln" }, { - "name": "Mozzarella" + "name": "Aspirin" }, { - "name": "Tomaten" + "name": "Aubergine" }, { - "name": "Rucola" + "name": "Avocado" }, { - "name": "Balsamico Essig" + "name": "Babyspinat" }, { - "name": "Olivenöl" + "name": "Backfisch" }, { - "name": "Spaghetti" + "name": "Backhefe" }, { - "name": "Babyspinat" + "name": "Backpapier" }, { - "name": "Cherrytomaten" + "name": "Backpulver" }, { - "name": "Zwiebel" + "name": "Badreiniger" + }, + { + "name": "Baguettes" + }, + { + "name": "Balsamico Essig" + }, + { + "name": "Bananen" + }, + { + "name": "Bandnudeln" }, { "name": "Basilikum" }, { - "name": "Parmesan" + "name": "Basmati Reis" }, { - "name": "Risotto Reis" + "name": "Berliner Luft" + }, + { + "name": "Bier" + }, + { + "name": "Birnen" + }, + { + "name": "Blattspinat" + }, + { + "name": "Blumenerde" + }, + { + "name": "Blumenkohl" + }, + { + "name": "Blätterteig" + }, + { + "name": "Brokkoli" + }, + { + "name": "Brot" + }, + { + "name": "Brötchen" + }, + { + "name": "Buko" + }, + { + "name": "Burgerbuns" + }, + { + "name": "Burgersaucen" }, { "name": "Butter" }, { - "name": "Schalotte" + "name": "Cannelloni" }, { - "name": "Weißwein" + "name": "Champignons" }, { - "name": "Gemüsebrühe" + "name": "Cheddar" }, { - "name": "Lasagnenudeln" + "name": "Chili-Öl" }, { - "name": "Passierte Tomaten" + "name": "Ciabatta" }, { - "name": "Gehackte Tomaten" + "name": "Cocktailsauce" }, { - "name": "Kartoffeln" + "name": "Cocktailtomaten" }, { - "name": "Kohlrabi" + "name": "Couscous" + }, + { + "name": "Creme fraiche" + }, + { + "name": "Crepesband" + }, + { + "name": "Currypaste" + }, + { + "name": "Deodorant" + }, + { + "name": "Desinfektionsspray" + }, + { + "name": "Dill" + }, + { + "name": "Dinkel" + }, + { + "name": "Duschgel" + }, + { + "name": "Eier" + }, + { + "name": "Eisbergsalat" + }, + { + "name": "Eistee" + }, + { + "name": "Eiswürfel " + }, + { + "name": "Erbsen" + }, + { + "name": "Erdnuss-Butter" + }, + { + "name": "Erdnüsse" + }, + { + "name": "Essig" + }, + { + "name": "Falafel" + }, + { + "name": "Falafelpulver" + }, + { + "name": "Fanta" }, { "name": "Feta" }, { - "name": "Möhren" + "name": "Frischkäse" }, { - "name": "Zucchini" + "name": "Frühlingszwiebeln" }, { - "name": "Salatkopf" + "name": "Ganze Dosentomaten" + }, + { + "name": "Garam Masala" + }, + { + "name": "Gehackte Tomaten" + }, + { + "name": "Gemüse" + }, + { + "name": "Gemüsebrühe" + }, + { + "name": "Getrocknete Tomaten" + }, + { + "name": "Gewürzgurken" + }, + { + "name": "Glühwein" + }, + { + "name": "Gnocchi" + }, + { + "name": "Gochujang" + }, + { + "name": "Gorgonzola" + }, + { + "name": "Gouda" + }, + { + "name": "Griechischer Joghurt" + }, + { + "name": "Grillbutter-Gewürz" + }, + { + "name": "Grüne Chili" + }, + { + "name": "Grüner Spargel" }, { "name": "Gurke" }, { - "name": "Kidneybohnen" + "name": "Haargel" }, { "name": "Haferflocken" }, + { + "name": "Haferkekse" + }, + { + "name": "Hafermilch" + }, + { + "name": "Haribo" + }, + { + "name": "Harissa" + }, + { + "name": "Haselnüsse" + }, + { + "name": "Hefe" + }, + { + "name": "Himbeersirup" + }, + { + "name": "Honig" + }, + { + "name": "Ingwer" + }, + { + "name": "Joghurt" + }, + { + "name": "Kardamom" + }, + { + "name": "Kartoffeln" + }, + { + "name": "Katjes" + }, + { + "name": "Kekse" + }, + { + "name": "Ketchup" + }, + { + "name": "Kichererbsen" + }, + { + "name": "Kidneybohnen" + }, + { + "name": "Kirschtomaten" + }, + { + "name": "Knoblauch" + }, + { + "name": "Knoblauch Dip" + }, + { + "name": "Knopfzellen" + }, + { + "name": "Knäckebrot" + }, + { + "name": "Kohlrabi" + }, + { + "name": "Kokosnuss-Milch" + }, + { + "name": "Kokosraspel" + }, + { + "name": "Kokosöl" + }, + { + "name": "Konfitüre" + }, + { + "name": "Koriander" + }, + { + "name": "Kreuzkümmel" + }, + { + "name": "Kräuterbaguettes" + }, + { + "name": "Kräuterfrischkäse" + }, + { + "name": "Kuchen" + }, + { + "name": "Kurkuma" + }, + { + "name": "Käse" + }, + { + "name": "Kürbis" + }, + { + "name": "Kürbiskerne" + }, + { + "name": "Lasagnenudeln" + }, { "name": "Lauch" }, { - "name": "Käse" + "name": "Limette" + }, + { + "name": "Linguine" }, { - "name": "Apfel" + "name": "Linsen" }, { - "name": "Bananen" + "name": "Lorbeerblatt" }, { "name": "Magerquark" }, { - "name": "Brot" + "name": "Mais" }, { - "name": "Bandnudeln" + "name": "Majoran" }, { - "name": "Dill" + "name": "Mandarinen" }, { - "name": "Knoblauch" + "name": "Mango" }, { - "name": "Rote Chili" + "name": "Marmelade" }, { - "name": "Zitronensaft" + "name": "Maultaschen" }, { - "name": "Rote Beete" + "name": "Mayonnaise" }, { - "name": "Thymian" + "name": "Mehl" }, { - "name": "Kirschtomaten" + "name": "Mikrofasertuch" }, { - "name": "Scheibenkäse" + "name": "Milch" }, { - "name": "Sojasauce" + "name": "Minze" }, { - "name": "Penne" + "name": "Mozzarella" }, { - "name": "Ricotta" + "name": "Mundspülung" }, { - "name": "Ganze Dosentomaten" + "name": "Muskatnuss" }, { - "name": "Aubergine" + "name": "Möhren" }, { - "name": "Baguettes" + "name": "Müllsäcke" }, { - "name": "Schmand" + "name": "Müsli" }, { - "name": "Streukäse" + "name": "Müsli-Riegel" }, { - "name": "Champignons" + "name": "Nori Blätter" }, { - "name": "Tunfisch" + "name": "Nudeln" }, { - "name": "Blätterteig" + "name": "Oliven" }, { - "name": "Spinat" + "name": "Olivenöl" }, { - "name": "Pinienkerne" + "name": "Orangen" }, { - "name": "Tomatenmark" + "name": "Orangensaft" }, { - "name": "Salbei" + "name": "Oregano" }, { - "name": "Mais" + "name": "Pak Choi" }, { - "name": "Reis" + "name": "Paniermehl" }, { - "name": "Blumenkohl" + "name": "Paprika" }, { - "name": "Paprika" + "name": "Parmesan" }, { - "name": "Joghurt" + "name": "Passierte Tomaten" }, { - "name": "Harissa" + "name": "Penne" }, { - "name": "Tahini" + "name": "Pesto" }, { - "name": "Couscous" + "name": "Petersilie" }, { - "name": "Erdnuss-Butter" + "name": "Pfirsich" }, { - "name": "Asiatische Eiernudeln" + "name": "Pflanzenmagarine" }, { - "name": "Kokosnuss-Milch" + "name": "Pflanzenöl" }, { - "name": "Erbsen" + "name": "Pflaster" }, { - "name": "Gnocchi" + "name": "Pilze" }, { - "name": "Tortellini" + "name": "Pinienkerne" }, { - "name": "Pilze" + "name": "Pitatasche" }, { - "name": "Erdnüsse" + "name": "Quinoa" }, { - "name": "Koriander" + "name": "Radicchio" }, { - "name": "Currypaste" + "name": "Radieschen" }, { - "name": "Süßkartoffel" + "name": "Rahmspinat" }, { - "name": "Frühlingszwiebeln" + "name": "Ramen" }, { - "name": "Ananas" + "name": "Rapsöl" }, { - "name": "Orangensaft" + "name": "Red Bull" }, { - "name": "Eier" + "name": "Reis" }, { - "name": "Mehl" + "name": "Reis-Essig" }, { - "name": "Konfitüre" + "name": "Reisbandnudeln" }, { - "name": "Sahne" + "name": "Reiswaffeln" }, { - "name": "Räuchertofu" + "name": "Rhabarber" }, { - "name": "Bier" + "name": "Ricotta" }, { - "name": "Tonic Water" + "name": "Risotto Reis" }, { - "name": "Fanta" + "name": "Rohrzucker" }, { - "name": "Sprite" + "name": "Rosenkohl" }, { - "name": "Maultaschen" + "name": "Rosmarin" }, { - "name": "Radicchio" + "name": "Rote Beete" }, { - "name": "Gorgonzola" + "name": "Rote Chili" }, { - "name": "Pesto" + "name": "Rote Linsen" }, { - "name": "Safranfäden" + "name": "Rote Zwiebeln" }, { - "name": "Petersilie" + "name": "Rotwein" }, { - "name": "Grüne Chili" + "name": "Rotweinessig" }, { - "name": "Kichererbsen" + "name": "Rucola" }, { - "name": "Brötchen" + "name": "Räuchertofu" }, { - "name": "Linguine" + "name": "Safranfäden" }, { - "name": "Creme fraiche" + "name": "Sahne" }, { - "name": "Schnittlauch" + "name": "Saitan-Pulver" }, { - "name": "Kokosöl" + "name": "Salat Mix" }, { - "name": "Rosenkohl" + "name": "Salatkerne Mix" }, { - "name": "Avocado" + "name": "Salatkopf" }, { - "name": "Quinoa" + "name": "Salbei" }, { - "name": "Minze" + "name": "Salz" }, { - "name": "Toilettenpapier" + "name": "Sambal Olek" }, { - "name": "Brokkoli" + "name": "Sambal Oelek" }, { - "name": "Sojahack" + "name": "Schalotte" }, { - "name": "Mayonnaise" + "name": "Scheibenkäse" }, { - "name": "Sushi Reis" + "name": "Schmand" }, { - "name": "Nori Blätter" + "name": "Schmelzkäse" }, { - "name": "Reis-Essig" + "name": "Schnittlauch" }, { - "name": "Ciabatta" + "name": "Schokolade" }, { "name": "Schupfnudeln" }, { - "name": "Vodka" + "name": "Schwammlappen" }, { - "name": "Berliner Luft" + "name": "Schwarze Bohnen" }, { - "name": "Cheddar" + "name": "Schwämme" }, { - "name": "Rapsöl" + "name": "Seife" }, { - "name": "Tofu" + "name": "Sellerie" }, { - "name": "Hefe" + "name": "Senf" }, { - "name": "Cannelloni" + "name": "Sesam" }, { - "name": "Backpapier" + "name": "Sesamöl" }, { - "name": "Spültabs" + "name": "Shampoo" }, { - "name": "Haargel" + "name": "Shiitakepilz" }, { - "name": "Wraps" + "name": "Snacks" }, { - "name": "Zitrone" + "name": "Softdrinks" }, { - "name": "Wassereis" + "name": "Sojahack" }, { - "name": "Shampoo" + "name": "Sojasauce" }, { - "name": "Zahnpasta" + "name": "Sonnenblumenkerne" }, { - "name": "Linsen" + "name": "Soße" }, { - "name": "Rhabarber" + "name": "Spaghetti" }, { - "name": "Limette" + "name": "Speck" }, { - "name": "Honig" + "name": "Speisestärke" }, { - "name": "Sesam" + "name": "Spinat" }, { - "name": "Aioli" + "name": "Sprite" }, { - "name": "Oregano" + "name": "Spätzle" }, { - "name": "Mundspülung" + "name": "Spüli" }, { - "name": "Grüner Spargel" + "name": "Spülmaschinensalz" }, { - "name": "Senf" + "name": "Spültabs" }, { - "name": "Pflanzenöl" + "name": "Sriracha" }, { - "name": "Rote Linsen" + "name": "Staudensellerie" }, { - "name": "Müsli-Riegel" + "name": "Steinpilze" }, { - "name": "Sonnenblumenkerne" + "name": "Streukäse" }, { - "name": "Dinkel" + "name": "Sushi Reis" }, { - "name": "Zucker" + "name": "Süßkartoffel" }, { - "name": "Himbeersirup" + "name": "Tafelsalz" }, { - "name": "Backhefe" + "name": "Tahini" }, { - "name": "Seife" + "name": "Taschentuchbox" }, { - "name": "Rotwein" + "name": "Taschentücher" }, { - "name": "Falafelpulver" + "name": "Tee" }, { - "name": "Pitatasche" + "name": "Thunfisch" }, { - "name": "Tzatziki" + "name": "Thymian" }, { - "name": "Pizza" + "name": "Toast" }, { - "name": "Kürbis" + "name": "Tofu" }, { - "name": "Müllsäcke" + "name": "Toilettenpapier" }, { - "name": "Badreiniger" + "name": "Tomaten" }, { - "name": "Getrocknete Tomaten" + "name": "Tomatenmark" }, { - "name": "Rosmarin" + "name": "Tomatensoße" }, { - "name": "Thunfisch" + "name": "Tonic Water" }, { - "name": "Frischkäse" + "name": "Tortellini" }, { - "name": "Lorbeerblatt" + "name": "Tunfisch" }, { - "name": "Rahmspinat" + "name": "Tzatziki" }, { - "name": "Schwämme" + "name": "Vodka" }, { - "name": "Pflaster" + "name": "WC-Reiniger" }, { - "name": "Schokolade" + "name": "Waschpulver" }, { - "name": "Sriracha" + "name": "Wasser" }, { - "name": "Spätzle" + "name": "Wassereis" }, { - "name": "Deodorant" + "name": "Weißwein" }, { - "name": "Salz" + "name": "Weißweinessig" }, { - "name": "Schwammlappen" + "name": "Wirsing" }, { - "name": "Snacks" + "name": "Wraps" }, { - "name": "Softdrinks" + "name": "Wurst" }, { - "name": "Kreppband" + "name": "Würstchen" }, { - "name": "Sambal Olek" + "name": "Zahnpasta" }, { - "name": "Muskatnuss" + "name": "Zahnseide" }, { "name": "Zewa" }, { - "name": "Backfisch" + "name": "Zimt" }, { - "name": "Cocktailsauce" + "name": "Zimtstange" }, { - "name": "Wirsing" + "name": "Zitrone" }, { - "name": "Waschpulver" + "name": "Zitronengras" }, { - "name": "Knopfzellen" + "name": "Zitronensaft" }, { - "name": "Duschgel" + "name": "Zucchini" }, { - "name": "Rote Zwiebeln" + "name": "Zucker" }, { - "name": "Knäckebrot" + "name": "Zwiebel" } ] } diff --git a/backend/templates/en.json b/backend/templates/en.json index 66545e8c..bbad7de8 100644 --- a/backend/templates/en.json +++ b/backend/templates/en.json @@ -1,466 +1,466 @@ { "items": [ { - "name": "Detergent" + "name": "Aioli" }, { - "name": "Milk" + "name": "Apple" }, { - "name": "Cornflakes" + "name": "Aspargus" }, { - "name": "Toast" + "name": "Avocado" }, { - "name": "Pasta" + "name": "Baguette" }, { - "name": "Mozzarella" + "name": "Baking paper" }, { - "name": "Tomatoes" + "name": "Balsamic vinegar" }, { - "name": "Rocket" + "name": "Bananas" }, { - "name": "Balsamic vinegar" + "name": "Basil" }, { - "name": "Olive oil" + "name": "Beer" }, { - "name": "Spaghetti" + "name": "Beetroot" }, { - "name": "Spinach" + "name": "Bodywash" }, { - "name": "Cherry tomatoes" + "name": "Bread" }, { - "name": "Onions" + "name": "Broccoli" }, { - "name": "Basil" + "name": "Buns" }, { - "name": "Parmesan" + "name": "Butter" }, { - "name": "Risotto rice" + "name": "Cannelloni" }, { - "name": "Butter" + "name": "Canola oil" }, { - "name": "Scallion" + "name": "Carrots" }, { - "name": "White wine" + "name": "Cauliflower" }, { - "name": "Vegetable broth" + "name": "Cereal bar" }, { - "name": "Lasagna" + "name": "Cheddar" }, { - "name": "Sieved tomatoes" + "name": "Cheese" }, { - "name": "Potatoes" + "name": "Cherry tomatoes" }, { - "name": "Kohlrabi" + "name": "Chickpeas" }, { - "name": "Feta" + "name": "Chives" }, { - "name": "Carrots" + "name": "Chocolate" }, { - "name": "Zucchini" + "name": "Ciabatta" }, { - "name": "Lettuce" + "name": "Cilantro" }, { - "name": "Cucumber" + "name": "Coconut milk" }, { - "name": "Kidney beans" + "name": "Coconut oil" }, { - "name": "Oatmeal" + "name": "Corn" }, { - "name": "Leek" + "name": "Cornflakes" }, { - "name": "Cheese" + "name": "Couscous" }, { - "name": "Apple" + "name": "Cream" }, { - "name": "Bananas" + "name": "Cream cheese" }, { - "name": "Quark" + "name": "Cucumber" }, { - "name": "Bread" + "name": "Curry paste" }, { - "name": "Tagliatelle" + "name": "Deodorant" }, { - "name": "Dill" + "name": "Detergent" }, { - "name": "Garlic" + "name": "Dill" }, { - "name": "Red chili" + "name": "Dishwasher tabs" }, { - "name": "Lemonjuice" + "name": "Dried tomatoes" }, { - "name": "Beetroot" + "name": "Eggplant" }, { - "name": "Thyme" + "name": "Eggs" }, { - "name": "Sliced cheese" + "name": "Fanta" }, { - "name": "Soy sauce" + "name": "Feta" }, { - "name": "Penne pasta" + "name": "Flour" }, { - "name": "Ricotta" + "name": "Garbage bags" }, { - "name": "Eggplant" + "name": "Garlic" }, { - "name": "Baguette" + "name": "Gnocchi" }, { - "name": "Shredded cheese" + "name": "Gorgonzola" }, { - "name": "Mushrooms" + "name": "Green chili" }, { - "name": "Tuna" + "name": "Hair gel" }, { - "name": "Pine nuts" + "name": "Harissa" }, { - "name": "Tomato paste" + "name": "Honey" }, { - "name": "Sage" + "name": "Jam" }, { - "name": "Corn" + "name": "Kidney beans" }, { - "name": "Rice" + "name": "Kitchen towels" }, { - "name": "Cauliflower" + "name": "Kohlrabi" }, { - "name": "Peppers" + "name": "Lasagna" }, { - "name": "Yoghurt" + "name": "Leek" }, { - "name": "Harissa" + "name": "Lemon" }, { - "name": "Tahini" + "name": "Lemonjuice" }, { - "name": "Couscous" + "name": "Lentils" }, { - "name": "Peanutbutter" + "name": "Lettuce" }, { - "name": "Coconut milk" + "name": "Lime" }, { - "name": "Peas" + "name": "Linguine" }, { - "name": "Gnocchi" + "name": "Mayonnaise" }, { - "name": "Tortellini" + "name": "Milk" }, { - "name": "Peanuts" + "name": "Mint" }, { - "name": "Cilantro" + "name": "Mouth wash" }, { - "name": "Curry paste" + "name": "Mozzarella" }, { - "name": "Sweet potatoes" + "name": "Mushrooms" }, { - "name": "Spring onions" + "name": "Mustard" }, { - "name": "Pineapple" + "name": "Nori sheets" }, { - "name": "Orange juice" + "name": "Oatmeal" }, { - "name": "Eggs" + "name": "Olive oil" }, { - "name": "Flour" + "name": "Onions" }, { - "name": "Jam" + "name": "Orange juice" }, { - "name": "Cream" + "name": "Oregano" }, { - "name": "Smoked tofu" + "name": "Parmesan" }, { - "name": "Beer" + "name": "Parsley" }, { - "name": "Tonic water" + "name": "Pasta" }, { - "name": "Fanta" + "name": "Peanutbutter" }, { - "name": "Sprite" + "name": "Peanuts" }, { - "name": "Radicchio" + "name": "Peas" }, { - "name": "Gorgonzola" + "name": "Penne pasta" }, { - "name": "Pesto" + "name": "Peppers" }, { - "name": "Parsley" + "name": "Pesto" }, { - "name": "Green chili" + "name": "Pine nuts" }, { - "name": "Chickpeas" + "name": "Pineapple" }, { - "name": "Buns" + "name": "Pizza" }, { - "name": "Linguine" + "name": "Plant oil" }, { - "name": "Chives" + "name": "Plaster" }, { - "name": "Coconut oil" + "name": "Potatoes" }, { - "name": "Sprouts" + "name": "Pumpkin" }, { - "name": "Avocado" + "name": "Quark" }, { "name": "Quinoa" }, { - "name": "Mint" + "name": "Radicchio" }, { - "name": "Toilet paper" + "name": "Red chili" }, { - "name": "Broccoli" + "name": "Red lentils" }, { - "name": "Mayonnaise" + "name": "Red wine" }, { - "name": "Nori sheets" + "name": "Rice" }, { "name": "Rice vinegar" }, { - "name": "Ciabatta" + "name": "Ricotta" }, { - "name": "Vodka" + "name": "Risotto rice" }, { - "name": "Cheddar" + "name": "Rocket" }, { - "name": "Canola oil" + "name": "Rosemary" }, { - "name": "Tofu" + "name": "Sage" }, { - "name": "Yeast" + "name": "Salt" }, { - "name": "Cannelloni" + "name": "Scallion" }, { - "name": "Baking paper" + "name": "Sesame" }, { - "name": "Dishwasher tabs" + "name": "Shampoo" }, { - "name": "Hair gel" + "name": "Shredded cheese" }, { - "name": "Wraps" + "name": "Sieved tomatoes" }, { - "name": "Lemon" + "name": "Sliced cheese" }, { - "name": "Shampoo" + "name": "Smoked tofu" }, { - "name": "Toothpaste" + "name": "Snacks" }, { - "name": "Lentils" + "name": "Soap" }, { - "name": "Lime" + "name": "Softdrinks" }, { - "name": "Honey" + "name": "Soy sauce" }, { - "name": "Sesame" + "name": "Spaghetti" }, { - "name": "Aioli" + "name": "Spinach" }, { - "name": "Oregano" + "name": "Sponges" }, { - "name": "Mouth wash" + "name": "Spring onions" }, { - "name": "Aspargus" + "name": "Sprite" }, { - "name": "Mustard" + "name": "Sprouts" }, { - "name": "Plant oil" + "name": "Sriracha" }, { - "name": "Red lentils" + "name": "Sugar" }, { - "name": "Cereal bar" + "name": "Sunflower seeds" }, { - "name": "Sunflower seeds" + "name": "Sweet potatoes" }, { - "name": "Sugar" + "name": "Tagliatelle" }, { - "name": "Soap" + "name": "Tahini" }, { - "name": "Red wine" + "name": "Tape" }, { - "name": "Tzatziki" + "name": "Thyme" }, { - "name": "Pizza" + "name": "Toast" }, { - "name": "Pumpkin" + "name": "Tofu" }, { - "name": "Garbage bags" + "name": "Toilet paper" }, { - "name": "Dried tomatoes" + "name": "Tomato paste" }, { - "name": "Rosemary" + "name": "Tomatoes" }, { - "name": "Cream cheese" + "name": "Tonic water" }, { - "name": "Sponges" + "name": "Toothpaste" }, { - "name": "Plaster" + "name": "Tortellini" }, { - "name": "Chocolate" + "name": "Tuna" }, { - "name": "Sriracha" + "name": "Tzatziki" }, { - "name": "Deodorant" + "name": "Vegetable broth" }, { - "name": "Salt" + "name": "Vodka" }, { - "name": "Snacks" + "name": "Washing powder" }, { - "name": "Softdrinks" + "name": "White wine" }, { - "name": "Tape" + "name": "Wraps" }, { - "name": "Kitchen towels" + "name": "Yeast" }, { - "name": "Washing powder" + "name": "Yoghurt" }, { - "name": "Bodywash" + "name": "Zucchini" } ] } From ed1f9d97c0a322858c3e4d521831b9b7b95f2947 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 18 May 2022 21:23:18 +0200 Subject: [PATCH 117/496] Prepare beta 25 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index e3627984..6428b1a9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,7 +9,7 @@ import os MIN_FRONTEND_VERSION = 34 -BACKEND_VERSION = 24 +BACKEND_VERSION = 25 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From bbade5a6f9bc6365be42dc598d02286c1ff3d04a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 30 May 2022 17:58:46 +0200 Subject: [PATCH 118/496] chore: upgrade requirements --- backend/requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 36308bf2..f2364f8d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,7 +6,7 @@ autopep8==1.6.0 bcrypt==3.2.2 beautifulsoup4==4.11.1 black==21.12b0 -certifi==2021.10.8 +certifi==2022.5.18.1 cffi==1.15.0 charset-normalizer==2.0.12 click==8.1.3 @@ -35,13 +35,13 @@ kiwisolver==1.4.2 lxml==4.8.0 Mako==1.2.0 MarkupSafe==2.1.1 -marshmallow==3.15.0 +marshmallow==3.16.0 matplotlib==3.5.2 mccabe==0.6.1 mf2py==1.1.2 -mlxtend==0.19.0 +mlxtend==0.20.0 mypy-extensions==0.4.3 -numpy==1.22.3 +numpy==1.22.4 packaging==21.3 pandas==1.4.2 pathspec==0.9.0 @@ -62,10 +62,10 @@ pytz==2022.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.1.1 rdflib-jsonld==0.6.2 -recipe-scrapers==13.33.0 +recipe-scrapers==14.1.0 regex==2022.4.24 requests==2.27.1 -scikit-learn==1.1.0 +scikit-learn==1.1.1 scipy==1.8.1 setuptools-scm==6.4.2 six==1.16.0 @@ -74,7 +74,7 @@ SQLAlchemy==1.4.36 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 -typed-ast==1.5.3 +typed-ast==1.5.4 typing_extensions==4.2.0 tzdata==2022.1 tzlocal==4.2 From 9ad05db9c72260d4191cac222ae2f30f7e0f915c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 2 Jun 2022 14:40:49 +0200 Subject: [PATCH 119/496] feat: update recipe scraping --- backend/app/controller/recipe/recipe_controller.py | 10 ++++++++-- backend/app/models/recipe.py | 2 +- backend/requirements.txt | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 841c3732..f46ca59a 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -149,11 +149,17 @@ def scrapeRecipe(args): scraper = scrape_me(args['url'], wild_mode=True) recipe = Recipe() recipe.name = scraper.title() - recipe.time = scraper.total_time() + recipe.time = int(scraper.total_time()) recipe.description = scraper.description() + "\n\n" + scraper.instructions() recipe.photo = scraper.image() recipe.source = args['url'] - return jsonify(recipe.obj_to_dict()) + items = {} + for ingredient in scraper.ingredients(): + items[ingredient] = None + return jsonify({ + 'recipe': recipe.obj_to_dict(), + 'items': items, + }) # @recipe.route('//item', methods=['POST']) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 275db0e9..73d9b1f7 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -30,7 +30,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): def obj_to_dict(self): res = super().obj_to_dict() - res['planned_days'] = list(self.planned_days) + res['planned_days'] = list(self.planned_days or set()) return res def obj_to_full_dict(self) -> dict: diff --git a/backend/requirements.txt b/backend/requirements.txt index f2364f8d..8da21cfb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -62,7 +62,7 @@ pytz==2022.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.1.1 rdflib-jsonld==0.6.2 -recipe-scrapers==14.1.0 +recipe-scrapers==14.2.0 regex==2022.4.24 requests==2.27.1 scikit-learn==1.1.1 From ae05dd3ec58d69da4d95c52c76022d3f6a876fe7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 2 Jun 2022 15:18:58 +0200 Subject: [PATCH 120/496] Prepare beta 26 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6428b1a9..6b9dec0e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,7 +9,7 @@ import os MIN_FRONTEND_VERSION = 34 -BACKEND_VERSION = 25 +BACKEND_VERSION = 26 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From c3e7892ee527a4117a6f7e7f557ca0b1e12d65d2 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 3 Jun 2022 12:27:16 +0200 Subject: [PATCH 121/496] temp fix: scraper until solved upstream --- backend/app/controller/recipe/recipe_controller.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index f46ca59a..3f074383 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -150,7 +150,12 @@ def scrapeRecipe(args): recipe = Recipe() recipe.name = scraper.title() recipe.time = int(scraper.total_time()) - recipe.description = scraper.description() + "\n\n" + scraper.instructions() + description = '' + try: + description = scraper.description() + except NotImplementedError: + pass + recipe.description = description + "\n\n" + scraper.instructions() recipe.photo = scraper.image() recipe.source = args['url'] items = {} From 9ee520339b41d7202cd26e0d049e30cf9eabef04 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 3 Jun 2022 12:30:35 +0200 Subject: [PATCH 122/496] Prepare beta 27 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6b9dec0e..799e0c7a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,7 +9,7 @@ import os MIN_FRONTEND_VERSION = 34 -BACKEND_VERSION = 26 +BACKEND_VERSION = 27 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From d65743b94b68dc19a2fa2e96ca0fade73efd00db Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Jun 2022 01:16:09 +0200 Subject: [PATCH 123/496] update preset items --- backend/templates/de.json | 173 +++++++++++++++++++++++++++++++++++++- backend/templates/en.json | 18 ++++ 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/backend/templates/de.json b/backend/templates/de.json index 8922e26d..54a8b0e4 100644 --- a/backend/templates/de.json +++ b/backend/templates/de.json @@ -4,9 +4,14 @@ "name": "Aioli" }, { + "name": "Amaretto" + }, + { + "category": "🥬 Obst & Gemüse", "name": "Ananas" }, { + "category": "🥬 Obst & Gemüse", "name": "Apfel" }, { @@ -43,12 +48,14 @@ "name": "Badreiniger" }, { + "category": "🍞 Brotwaren", "name": "Baguettes" }, { "name": "Balsamico Essig" }, { + "category": "🥬 Obst & Gemüse", "name": "Bananen" }, { @@ -66,6 +73,10 @@ { "name": "Bier" }, + { + "category": "🥬 Obst & Gemüse", + "name": "Bio-Zitrone" + }, { "name": "Birnen" }, @@ -82,33 +93,48 @@ "name": "Blätterteig" }, { + "category": "🥬 Obst & Gemüse", "name": "Brokkoli" }, { + "category": "🍞 Brotwaren", "name": "Brot" }, { + "category": "🍞 Brotwaren", "name": "Brötchen" }, { + "category": "🥛 Milchprodukte", "name": "Buko" }, { + "category": "🍞 Brotwaren", "name": "Burgerbuns" }, { "name": "Burgersaucen" }, { + "category": "🥛 Milchprodukte", "name": "Butter" }, + { + "category": "🥛 Milchprodukte", + "name": "Börek Käse" + }, + { + "name": "Büffelmozzarella" + }, { "name": "Cannelloni" }, { + "category": "🥬 Obst & Gemüse", "name": "Champignons" }, { + "category": "🥛 Milchprodukte", "name": "Cheddar" }, { @@ -121,21 +147,27 @@ "name": "Cocktailsauce" }, { + "category": "🥬 Obst & Gemüse", "name": "Cocktailtomaten" }, { "name": "Couscous" }, { + "category": "🥛 Milchprodukte", "name": "Creme fraiche" }, { "name": "Crepesband" }, + { + "name": "Cumin" + }, { "name": "Currypaste" }, { + "category": "🚽 Hygiene", "name": "Deodorant" }, { @@ -151,9 +183,11 @@ "name": "Duschgel" }, { + "category": "🥛 Milchprodukte", "name": "Eier" }, { + "category": "🥬 Obst & Gemüse", "name": "Eisbergsalat" }, { @@ -169,6 +203,10 @@ "name": "Erdnuss-Butter" }, { + "name": "Erdnussöl" + }, + { + "category": "🥜 Snacks", "name": "Erdnüsse" }, { @@ -186,13 +224,21 @@ { "name": "Feta" }, + { + "name": "Filoteig" + }, + { + "name": "Fleischersatzprodukt" + }, { "name": "Frischkäse" }, { + "category": "🥬 Obst & Gemüse", "name": "Frühlingszwiebeln" }, { + "category": "🥫 Konserven", "name": "Ganze Dosentomaten" }, { @@ -202,21 +248,39 @@ "name": "Gehackte Tomaten" }, { + "name": "Gemischtes Gemüse" + }, + { + "category": "🥬 Obst & Gemüse", "name": "Gemüse" }, { "name": "Gemüsebrühe" }, + { + "category": "🥬 Obst & Gemüse", + "name": "Gemüsezwiebel" + }, + { + "name": "Geschenkpapier" + }, { "name": "Getrocknete Tomaten" }, + { + "name": "Gewürze" + }, { "name": "Gewürzgurken" }, + { + "name": "Gluten" + }, { "name": "Glühwein" }, { + "category": "💧 Kühltheke", "name": "Gnocchi" }, { @@ -238,9 +302,11 @@ "name": "Grüne Chili" }, { + "category": "🥬 Obst & Gemüse", "name": "Grüner Spargel" }, { + "category": "🥬 Obst & Gemüse", "name": "Gurke" }, { @@ -253,6 +319,7 @@ "name": "Haferkekse" }, { + "category": "🥛 Milchprodukte", "name": "Hafermilch" }, { @@ -267,12 +334,18 @@ { "name": "Hefe" }, + { + "name": "Hefeflocken" + }, { "name": "Himbeersirup" }, { "name": "Honig" }, + { + "name": "Hustenbonbons" + }, { "name": "Ingwer" }, @@ -283,9 +356,14 @@ "name": "Kardamom" }, { + "name": "Kartoffelkloßteig" + }, + { + "category": "🥬 Obst & Gemüse", "name": "Kartoffeln" }, { + "category": "🥜 Snacks", "name": "Katjes" }, { @@ -301,21 +379,28 @@ "name": "Kidneybohnen" }, { + "category": "🥬 Obst & Gemüse", "name": "Kirschtomaten" }, { + "category": "🥬 Obst & Gemüse", "name": "Knoblauch" }, { "name": "Knoblauch Dip" }, + { + "name": "Knoblauch Granulat" + }, { "name": "Knopfzellen" }, { + "category": "🍞 Brotwaren", "name": "Knäckebrot" }, { + "category": "🥬 Obst & Gemüse", "name": "Kohlrabi" }, { @@ -357,10 +442,15 @@ { "name": "Kürbiskerne" }, + { + "category": "🥜 Snacks", + "name": "Lachgummi" + }, { "name": "Lasagnenudeln" }, { + "category": "🥬 Obst & Gemüse", "name": "Lauch" }, { @@ -372,13 +462,19 @@ { "name": "Linsen" }, + { + "category": "🥜 Snacks", + "name": "Linsenchips" + }, { "name": "Lorbeerblatt" }, { + "category": "🥛 Milchprodukte", "name": "Magerquark" }, { + "category": "🥫 Konserven", "name": "Mais" }, { @@ -394,6 +490,10 @@ "name": "Marmelade" }, { + "name": "Maske" + }, + { + "category": "💧 Kühltheke", "name": "Maultaschen" }, { @@ -406,12 +506,17 @@ "name": "Mikrofasertuch" }, { + "category": "🥛 Milchprodukte", "name": "Milch" }, + { + "name": "Milram Scheibenkäse" + }, { "name": "Minze" }, { + "category": "🥛 Milchprodukte", "name": "Mozzarella" }, { @@ -421,6 +526,7 @@ "name": "Muskatnuss" }, { + "category": "🥬 Obst & Gemüse", "name": "Möhren" }, { @@ -432,10 +538,18 @@ { "name": "Müsli-Riegel" }, + { + "category": "🥜 Snacks", + "name": "Nachos" + }, + { + "name": "Natron" + }, { "name": "Nori Blätter" }, { + "category": "🥟 Teigwaren", "name": "Nudeln" }, { @@ -445,6 +559,7 @@ "name": "Olivenöl" }, { + "category": "🥬 Obst & Gemüse", "name": "Orangen" }, { @@ -460,9 +575,14 @@ "name": "Paniermehl" }, { + "category": "🥬 Obst & Gemüse", "name": "Paprika" }, { + "name": "Paprikapulver" + }, + { + "category": "🥛 Milchprodukte", "name": "Parmesan" }, { @@ -475,6 +595,7 @@ "name": "Pesto" }, { + "category": "❄️ Tk", "name": "Petersilie" }, { @@ -498,6 +619,13 @@ { "name": "Pitatasche" }, + { + "name": "Pommersche" + }, + { + "category": "🥛 Milchprodukte", + "name": "Quark" + }, { "name": "Quinoa" }, @@ -508,6 +636,7 @@ "name": "Radieschen" }, { + "category": "❄️ Tk", "name": "Rahmspinat" }, { @@ -559,6 +688,7 @@ "name": "Rote Linsen" }, { + "category": "🥬 Obst & Gemüse", "name": "Rote Zwiebeln" }, { @@ -568,6 +698,7 @@ "name": "Rotweinessig" }, { + "category": "🥬 Obst & Gemüse", "name": "Rucola" }, { @@ -577,6 +708,7 @@ "name": "Safranfäden" }, { + "category": "🥛 Milchprodukte", "name": "Sahne" }, { @@ -601,21 +733,29 @@ "name": "Sambal Olek" }, { - "name": "Sambal Oelek" + "category": "🥬 Obst & Gemüse", + "name": "Schalotte" }, { - "name": "Schalotte" + "name": "Schawarma-Gewürz" }, { + "category": "🥛 Milchprodukte", "name": "Scheibenkäse" }, { + "name": "Schlemmerfilet" + }, + { + "category": "🥛 Milchprodukte", "name": "Schmand" }, { + "category": "🥛 Milchprodukte", "name": "Schmelzkäse" }, { + "category": "🥬 Obst & Gemüse", "name": "Schnittlauch" }, { @@ -634,6 +774,7 @@ "name": "Schwämme" }, { + "category": "🚽 Hygiene", "name": "Seife" }, { @@ -642,6 +783,9 @@ { "name": "Senf" }, + { + "name": "Senfsaat" + }, { "name": "Sesam" }, @@ -649,17 +793,25 @@ "name": "Sesamöl" }, { + "category": "🚽 Hygiene", "name": "Shampoo" }, { "name": "Shiitakepilz" }, { + "name": "Smoked Paprika" + }, + { + "category": "🥜 Snacks", "name": "Snacks" }, { "name": "Softdrinks" }, + { + "name": "Soja Schnetzel" + }, { "name": "Sojahack" }, @@ -682,6 +834,7 @@ "name": "Speisestärke" }, { + "category": "❄️ Tk", "name": "Spinat" }, { @@ -709,12 +862,14 @@ "name": "Steinpilze" }, { + "category": "🥛 Milchprodukte", "name": "Streukäse" }, { "name": "Sushi Reis" }, { + "category": "🥬 Obst & Gemüse", "name": "Süßkartoffel" }, { @@ -739,15 +894,18 @@ "name": "Thymian" }, { + "category": "🍞 Brotwaren", "name": "Toast" }, { "name": "Tofu" }, { + "category": "🚽 Hygiene", "name": "Toilettenpapier" }, { + "category": "🥬 Obst & Gemüse", "name": "Tomaten" }, { @@ -760,6 +918,7 @@ "name": "Tonic Water" }, { + "category": "💧 Kühltheke", "name": "Tortellini" }, { @@ -793,6 +952,7 @@ "name": "Wirsing" }, { + "category": "🍞 Brotwaren", "name": "Wraps" }, { @@ -802,9 +962,11 @@ "name": "Würstchen" }, { + "category": "🚽 Hygiene", "name": "Zahnpasta" }, { + "category": "🚽 Hygiene", "name": "Zahnseide" }, { @@ -817,6 +979,7 @@ "name": "Zimtstange" }, { + "category": "🥬 Obst & Gemüse", "name": "Zitrone" }, { @@ -826,13 +989,19 @@ "name": "Zitronensaft" }, { + "category": "🥬 Obst & Gemüse", "name": "Zucchini" }, { "name": "Zucker" }, { + "category": "🥬 Obst & Gemüse", "name": "Zwiebel" + }, + { + "category": "💧 Kühltheke", + "name": "vegetarischer Aufschnitt" } ] } diff --git a/backend/templates/en.json b/backend/templates/en.json index bbad7de8..df7117d8 100644 --- a/backend/templates/en.json +++ b/backend/templates/en.json @@ -31,6 +31,7 @@ "name": "Beer" }, { + "category": "🥬 Fruits & Vegetables", "name": "Beetroot" }, { @@ -43,6 +44,7 @@ "name": "Broccoli" }, { + "category": "🍞 Bread Goods", "name": "Buns" }, { @@ -64,6 +66,7 @@ "name": "Cereal bar" }, { + "category": "🥛 Dairy", "name": "Cheddar" }, { @@ -103,6 +106,7 @@ "name": "Couscous" }, { + "category": "🥛 Dairy", "name": "Cream" }, { @@ -115,9 +119,11 @@ "name": "Curry paste" }, { + "category": "🚽 Hygiene", "name": "Deodorant" }, { + "category": "🚽 Hygiene", "name": "Detergent" }, { @@ -127,6 +133,7 @@ "name": "Dishwasher tabs" }, { + "category": "🥫 Canned Food", "name": "Dried tomatoes" }, { @@ -151,6 +158,7 @@ "name": "Garlic" }, { + "category": "💧 Refrigerated", "name": "Gnocchi" }, { @@ -160,6 +168,7 @@ "name": "Green chili" }, { + "category": "🚽 Hygiene", "name": "Hair gel" }, { @@ -181,6 +190,7 @@ "name": "Kohlrabi" }, { + "category": "🥟 Pasta", "name": "Lasagna" }, { @@ -202,12 +212,14 @@ "name": "Lime" }, { + "category": "🥟 Pasta", "name": "Linguine" }, { "name": "Mayonnaise" }, { + "category": "🥛 Dairy", "name": "Milk" }, { @@ -217,6 +229,7 @@ "name": "Mouth wash" }, { + "category": "🥛 Dairy", "name": "Mozzarella" }, { @@ -256,12 +269,14 @@ "name": "Peanutbutter" }, { + "category": "🥜 Snacks", "name": "Peanuts" }, { "name": "Peas" }, { + "category": "🥟 Pasta", "name": "Penne pasta" }, { @@ -370,6 +385,7 @@ "name": "Spaghetti" }, { + "category": "❄️ Freezer", "name": "Spinach" }, { @@ -397,6 +413,7 @@ "name": "Sweet potatoes" }, { + "category": "🥟 Pasta", "name": "Tagliatelle" }, { @@ -430,6 +447,7 @@ "name": "Toothpaste" }, { + "category": "🥟 Pasta", "name": "Tortellini" }, { From 380539daff4114de73edc3120167e67c7f7d0adc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Jun 2022 01:18:10 +0200 Subject: [PATCH 124/496] fix: scraper new line --- backend/app/controller/recipe/recipe_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 3f074383..55825f48 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -152,10 +152,10 @@ def scrapeRecipe(args): recipe.time = int(scraper.total_time()) description = '' try: - description = scraper.description() + description = scraper.description() + "\n\n" except NotImplementedError: pass - recipe.description = description + "\n\n" + scraper.instructions() + recipe.description = description + scraper.instructions() recipe.photo = scraper.image() recipe.source = args['url'] items = {} From 7dfacc7357614456166e2a9ec6cda3279d5ccb99 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Jun 2022 02:38:03 +0200 Subject: [PATCH 125/496] Log import time --- backend/app/service/export_import.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index 7f3d4a5d..2d312932 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -1,4 +1,5 @@ -from app.config import APP_DIR, SUPPORTED_LANGUAGES +import time +from app.config import app, APP_DIR, SUPPORTED_LANGUAGES from os.path import exists import json @@ -16,6 +17,7 @@ def importFromLanguage(lang): def importFromDict(args, default=False): # noqa + t0 = time.time() if "items" in args: for importItem in args['items']: if not Item.find_by_name(importItem['name']): @@ -62,3 +64,4 @@ def importFromDict(args, default=False): # noqa con.tag = tag con.recipe = recipe con.save() + app.logger.info(f"Import took: {(time.time() - t0):.3f}s") From c1091ea5c87e031f7256689c93a5f9bdfe898994 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Jun 2022 02:43:28 +0200 Subject: [PATCH 126/496] Prepare release 28 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 799e0c7a..4f7e6670 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,7 +9,7 @@ import os MIN_FRONTEND_VERSION = 34 -BACKEND_VERSION = 27 +BACKEND_VERSION = 28 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From cb6cf73debeb24450adf3ba9435495651e5116a3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 9 Jun 2022 00:06:41 +0200 Subject: [PATCH 127/496] feat: track tokens --- backend/app/__init__.py | 2 +- backend/app/config.py | 11 +- .../app/controller/auth/auth_controller.py | 133 +++++++++++++++--- backend/app/controller/auth/schemas.py | 13 ++ .../onboarding/onboarding_controller.py | 51 ++++--- backend/app/controller/onboarding/schemas.py | 5 + .../app/controller/user/user_controller.py | 6 +- backend/app/jobs/jobs.py | 7 + backend/app/models/__init__.py | 1 + backend/app/models/token.py | 85 +++++++++++ backend/app/models/user.py | 18 ++- backend/migrations/versions/ae608469ef8b_.py | 47 +++++++ 12 files changed, 331 insertions(+), 48 deletions(-) create mode 100644 backend/app/models/token.py create mode 100644 backend/migrations/versions/ae608469ef8b_.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 108b9a9c..072894c8 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,4 +1,4 @@ -from app.config import app +from app.config import app, jwt from app.config import db from app.config import scheduler from app.controller import * diff --git a/backend/app/config.py b/backend/app/config.py index 4f7e6670..6830fcb7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,3 +1,4 @@ +from datetime import timedelta from sqlalchemy import MetaData from app.errors import NotFoundRequest from flask import Flask, jsonify, request @@ -8,6 +9,7 @@ from flask_apscheduler import APScheduler import os + MIN_FRONTEND_VERSION = 34 BACKEND_VERSION = 28 @@ -17,6 +19,9 @@ UPLOAD_FOLDER = os.getenv('STORAGE_PATH', PROJECT_DIR) + '/upload' ALLOWED_FILE_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} +JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) +JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) + SUPPORTED_LANGUAGES = { 'en': 'English', 'de': 'Deutsch' @@ -26,10 +31,14 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['MAX_CONTENT_LENGTH'] = 32 * 1000 * 1000 # 32MB max upload +# SQLAlchemy app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + \ os.getenv('STORAGE_PATH', PROJECT_DIR) + '/database.db' -app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +# JWT +app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') +app.config["JWT_ACCESS_TOKEN_EXPIRES"] = JWT_ACCESS_TOKEN_EXPIRES +app.config["JWT_REFRESH_TOKEN_EXPIRES"] = JWT_REFRESH_TOKEN_EXPIRES convention = { "ix": 'ix_%(column_0_label)s', diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index cde9feb2..25839003 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -1,13 +1,44 @@ +from datetime import datetime from app.helpers import validate_args from flask import jsonify, Blueprint -from flask_jwt_extended import jwt_required, create_access_token, create_refresh_token, get_jwt_identity -from app.models import User +from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt +from app.models import User, Token from app.errors import UnauthorizedRequest -from .schemas import Login +from .schemas import Login, CreateLongLivedToken +from app.config import jwt auth = Blueprint('auth', __name__) +# Callback function to check if a JWT exists in the database blocklist +@jwt.token_in_blocklist_loader +def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool: + jti = jwt_payload["jti"] + token = Token.find_by_jti(jti) + if (token is not None): + token.last_used_at = datetime.utcnow() + token.save() + + return token is None + + +# Register a callback function that takes whatever object is passed in as the +# identity when creating JWTs and converts it to a JSON serializable format. +@jwt.user_identity_loader +def user_identity_lookup(user: User): + return user.username + + +# Register a callback function that loads a user from your database whenever +# a protected route is accessed. This should return any python object on a +# successful lookup, or None if the lookup failed for any reason (for example +# if the user has been deleted from the database). +@jwt.user_lookup_loader +def user_lookup_callback(_jwt_header, jwt_data) -> User: + identity = jwt_data["sub"] + return User.find_by_username(identity) + + @auth.route('', methods=['POST']) @validate_args(Login) def login(args): @@ -15,22 +46,31 @@ def login(args): user = User.find_by_username(username) if not user or not user.check_password(args['password']): raise UnauthorizedRequest(message='Unauthorized') - ret = { - 'access_token': create_access_token(identity=username), - 'refresh_token': create_refresh_token(identity=username) - } - return jsonify(ret) + device = "Unkown" + if "device" in args: + device = args['device'] + # Create refresh token + refreshToken, refreshModel = Token.create_refresh_token(user, device) -@auth.route('/fresh-login', methods=['POST']) -@validate_args(Login) -def fresh_login(args): - username = args['username'].lower() - user = User.find_by_username(username.lower()) - if not user or not user.check_password(args['password']): - raise UnauthorizedRequest(message='Unauthorized') - ret = {'access_token': create_access_token(identity=username, fresh=True)} - return jsonify(ret), 200 + # Create first access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({ + 'access_token': accesssToken, + 'refresh_token': refreshToken + }) + +# Not in use as we are using the refresh token pattern +# @auth.route('/fresh-login', methods=['POST']) +# @validate_args(Login) +# def fresh_login(args): +# username = args['username'].lower() +# user = User.find_by_username(username.lower()) +# if not user or not user.check_password(args['password']): +# raise UnauthorizedRequest(message='Unauthorized') +# ret = {'access_token': create_access_token(identity=username, fresh=True)} +# return jsonify(ret), 200 @auth.route('/refresh', methods=['GET']) @@ -39,7 +79,58 @@ def refresh(): user = User.find_by_username(get_jwt_identity()) if not user: raise UnauthorizedRequest(message='Unauthorized') - ret = { - 'access_token': create_access_token(identity=user.username) - } - return jsonify(ret) + + refreshModel = Token.find_by_jti(get_jwt()['jti']) + # Create access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({ + 'access_token': accesssToken + }) + + +@auth.route('', methods=['DELETE']) +@jwt_required() +def logout(): + jwt = get_jwt() + token = Token.find_by_jti(jwt['jti']) + if not token: + raise UnauthorizedRequest(message='Unauthorized') + + if token.type == 'access': + token.refresh_token.delete() + else: + token.delete() + + return jsonify({'msg': 'DONE'}) + + +@auth.route('llt', methods=['POST']) +@jwt_required() +@validate_args(CreateLongLivedToken) +def createLongLivedToken(args): + user = User.find_by_username(get_jwt_identity()) + if not user: + raise UnauthorizedRequest(message='Unauthorized') + + llToken, _ = Token.create_longlived_token(user, args['device']) + + return jsonify({ + 'longlived_token': llToken + }) + + +@auth.route('llt/', methods=['DELETE']) +@jwt_required() +def deleteLongLivedToken(id): + user = User.find_by_username(get_jwt_identity()) + if not user: + raise UnauthorizedRequest(message='Unauthorized') + + token = Token.find_by_id(id) + if (token.user_id != user.id or token.type != 'llt'): + raise UnauthorizedRequest(message='Unauthorized') + + token.delete() + + return jsonify({'msg': 'DONE'}) diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py index 13570023..533b323f 100644 --- a/backend/app/controller/auth/schemas.py +++ b/backend/app/controller/auth/schemas.py @@ -11,3 +11,16 @@ class Login(Schema): 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 CreateLongLivedToken(Schema): + device = fields.String( + required=True, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py index 1869918c..3ddb71b4 100644 --- a/backend/app/controller/onboarding/onboarding_controller.py +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -1,7 +1,6 @@ from app.helpers import validate_args from flask import jsonify, Blueprint -from flask_jwt_extended import create_access_token, create_refresh_token -from app.models import User, Settings +from app.models import User, Settings, Token from app.service.export_import import importFromLanguage from .schemas import OnboardSchema @@ -17,22 +16,32 @@ def isOnboarding(): @onboarding.route('', methods=['POST']) @validate_args(OnboardSchema) def onboard(args): - if User.count() == 0: - if 'planner_feature' in args or 'expenses_feature' in args: - settings = Settings.get() - if 'planner_feature' in args: - settings.planner_feature = args['planner_feature'] - if 'expenses_feature' in args: - settings.expenses_feature = args['expenses_feature'] - settings.save() - if 'language' in args: - importFromLanguage(args['language']) - username = args['username'].lower() - User.create(username, args['password'], args['name'], owner=True) - ret = { - 'access_token': create_access_token(identity=username), - 'refresh_token': create_refresh_token(identity=username) - } - return jsonify(ret) - - return jsonify({'msg': "Onboarding not allowed"}), 403 + if User.count() > 0: + return jsonify({'msg': "Onboarding not allowed"}), 403 + + if 'planner_feature' in args or 'expenses_feature' in args: + settings = Settings.get() + if 'planner_feature' in args: + settings.planner_feature = args['planner_feature'] + if 'expenses_feature' in args: + settings.expenses_feature = args['expenses_feature'] + settings.save() + if 'language' in args: + importFromLanguage(args['language']) + username = args['username'].lower() + user = User.create(username, args['password'], args['name'], owner=True) + + device = "Unkown" + if "device" in args: + device = args['device'] + + # Create refresh token + refreshToken, refreshModel = Token.create_refresh_token(user, device) + + # Create first access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({ + 'access_token': accesssToken, + 'refresh_token': refreshToken + }) diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py index db99e752..e4cc3e53 100644 --- a/backend/app/controller/onboarding/schemas.py +++ b/backend/app/controller/onboarding/schemas.py @@ -20,3 +20,8 @@ class OnboardSchema(Schema): language = fields.String( validate=lambda a: a and not a.isspace() and a in SUPPORTED_LANGUAGES ) + device = fields.String( + required=False, + validate=lambda a: a and not a.isspace(), + load_only=True, + ) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index adb6123b..414dd8c4 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -13,7 +13,7 @@ @user.route('/all', methods=['GET']) @jwt_required() def getAllUsers(): - return jsonify([e.obj_to_dict(skip_columns=['password']) for e in User.all_by_name()]) + return jsonify([e.obj_to_dict() for e in User.all_by_name()]) @user.route('', methods=['GET']) @@ -22,7 +22,7 @@ def getLoggedInUser(): user = User.find_by_username(get_jwt_identity()) if not user: raise UnauthorizedRequest(message='Unauthorized') - return jsonify(user.obj_to_dict(skip_columns=['password'])) + return jsonify(user.obj_to_full_dict()) @user.route('/', methods=['GET']) @@ -32,7 +32,7 @@ def getUserById(id): user = User.find_by_id(id) if not user: raise NotFoundRequest() - return jsonify(user.obj_to_dict(skip_columns=['password'])) + return jsonify(user.obj_to_dict()) @user.route('/', methods=['DELETE']) diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index e096ea0b..741c58e9 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -1,5 +1,6 @@ from app.jobs.recipe_suggestions import findMealInstancesFromHistory, computeRecipeSuggestions from app import app, scheduler +from app.models import Token from .item_ordering import findItemOrdering from .item_suggestions import findItemSuggestions from .cluster_shoppings import clusterShoppings @@ -27,3 +28,9 @@ def daily(): meal_instances = findMealInstancesFromHistory() computeRecipeSuggestions(meal_instances) app.logger.info("--- daily analysis is completed ---") + + @scheduler.task('interval', id='every30min', minutes=30) + def halfHourly(): + # Remove expired Tokens + Token.delete_expired_access() + Token.delete_expired_refresh() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1ab372d6..052d2f55 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,3 +10,4 @@ from .recipe_history import RecipeHistory from .expense_category import ExpenseCategory from .category import Category +from .token import Token diff --git a/backend/app/models/token.py b/backend/app/models/token.py new file mode 100644 index 00000000..4ec26b31 --- /dev/null +++ b/backend/app/models/token.py @@ -0,0 +1,85 @@ +from __future__ import annotations +from datetime import datetime +from typing import Tuple +from app import db +from app.config import JWT_REFRESH_TOKEN_EXPIRES, JWT_ACCESS_TOKEN_EXPIRES +from app.helpers import DbModelMixin, TimestampMixin +from flask_jwt_extended import create_access_token, create_refresh_token, get_jti +from app.models.user import User + + +class Token(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'token' + + id = db.Column(db.Integer, primary_key=True) + jti = db.Column(db.String(36), nullable=False, index=True) + type = db.Column(db.String(16), nullable=False) + name = db.Column(db.String(), nullable=False) + last_used_at = db.Column(db.DateTime) + refresh_token_id = db.Column( + db.Integer, db.ForeignKey('token.id'), nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + created_tokens = db.relationship( + "Token", back_populates='refresh_token', cascade="all, delete-orphan") + refresh_token = db.relationship("Token", remote_side=[id]) + user = db.relationship("User") + + def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: + if skip_columns: + skip_columns = skip_columns + ['jti'] + else: + skip_columns = ['jti'] + return super().obj_to_dict(skip_columns=skip_columns, include_columns=include_columns) + + @classmethod + def find_by_jti(cls, jti) -> Token: + return cls.query.filter(cls.jti == jti).first() + + @classmethod + def delete_expired_refresh(cls): + filter_before = datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES + db.session.query(cls).filter(cls.created_at <= + filter_before, cls.type == 'refresh').delete() + db.session.commit() + + @classmethod + def delete_expired_access(cls): + filter_before = datetime.utcnow() - JWT_ACCESS_TOKEN_EXPIRES + db.session.query(cls).filter(cls.created_at <= + filter_before, cls.type == 'access').delete() + db.session.commit() + + @classmethod + def create_access_token(cls, user: User, refreshTokenModel: Token) -> Tuple[any, Token]: + accesssToken = create_access_token(identity=user) + model = Token() + model.jti = get_jti(accesssToken) + model.type = 'access' + model.name = refreshTokenModel.name + model.user = user + model.refresh_token = refreshTokenModel + model.save() + return accesssToken, model + + @classmethod + def create_refresh_token(cls, user: User, device: str) -> Tuple[any, Token]: + refreshToken = create_refresh_token(identity=user) + model = Token() + model.jti = get_jti(refreshToken) + model.type = 'refresh' + model.name = device + model.user = user + model.save() + return refreshToken, model + + @classmethod + def create_longlived_token(cls, user: User, device: str) -> Tuple[any, Token]: + accesssToken = create_access_token(identity=user, expires_delta=False) + model = Token() + model.jti = get_jti(accesssToken) + model.type = 'llt' + model.name = device + model.user = user + model.save() + return accesssToken, model diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 41d8d462..249281e7 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -16,6 +16,9 @@ class User(db.Model, DbModelMixin, TimestampMixin): expense_balance = db.Column(db.Float(), default=0) + tokens = db.relationship( + 'Token', back_populates='user', cascade="all, delete-orphan") + expenses_paid = db.relationship( 'Expense', back_populates='paid_by', cascade="all, delete-orphan") expenses_paid_for = db.relationship( @@ -27,13 +30,26 @@ def check_password(self, password): def set_password(self, password): self.password = bcrypt.generate_password_hash(password).decode('utf-8') + def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: + if skip_columns: + skip_columns = skip_columns + ['password'] + else: + skip_columns = ['password'] + return super().obj_to_dict(skip_columns=skip_columns, include_columns=include_columns) + + def obj_to_full_dict(self) -> dict: + res = self.obj_to_dict() + tokens = self.tokens + res['tokens'] = [e.obj_to_dict(skip_columns=['user_id']) for e in tokens] + return res + @classmethod def find_by_username(cls, username): return cls.query.filter(cls.username == username).first() @classmethod def create(cls, username, password, name, owner=False): - cls( + return cls( username=username, password=bcrypt.generate_password_hash(password).decode('utf-8'), name=name, diff --git a/backend/migrations/versions/ae608469ef8b_.py b/backend/migrations/versions/ae608469ef8b_.py new file mode 100644 index 00000000..a3f98554 --- /dev/null +++ b/backend/migrations/versions/ae608469ef8b_.py @@ -0,0 +1,47 @@ +"""empty message + +Revision ID: ae608469ef8b +Revises: 6d3027e07dc4 +Create Date: 2022-06-08 23:27:18.639974 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ae608469ef8b' +down_revision = '6d3027e07dc4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('token', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('jti', sa.String(length=36), nullable=False), + sa.Column('type', sa.String(length=16), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('refresh_token_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['refresh_token_id'], ['token.id'], name=op.f('fk_token_refresh_token_id_token')), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_token_user_id_user')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_token')) + ) + with op.batch_alter_table('token', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_token_jti'), ['jti'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('token', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_token_jti')) + + op.drop_table('token') + # ### end Alembic commands ### From 948ea6574fe3ebaef284fc54636276794af7d48f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 9 Jun 2022 15:06:58 +0200 Subject: [PATCH 128/496] feat: refresh token rotation --- backend/app/config.py | 12 ++++-- .../app/controller/auth/auth_controller.py | 6 ++- backend/app/models/token.py | 38 +++++++++++++++++-- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6830fcb7..4ecf9856 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,6 +1,6 @@ from datetime import timedelta from sqlalchemy import MetaData -from app.errors import NotFoundRequest +from app.errors import NotFoundRequest, UnauthorizedRequest from flask import Flask, jsonify, request from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy @@ -10,7 +10,7 @@ import os -MIN_FRONTEND_VERSION = 34 +MIN_FRONTEND_VERSION = 46 BACKEND_VERSION = 28 APP_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -83,8 +83,12 @@ def add_cors_headers(response): @app.errorhandler(Exception) def unhandled_exception(e): - if e is NotFoundRequest: - return "Requested resource not found", 404 + if type(e) is NotFoundRequest: + app.logger.info(e) + return jsonify(message="Requested resource not found"), 404 + if type(e) is UnauthorizedRequest: + app.logger.warn(e) + return jsonify(message="Request unauthorized"), 401 app.logger.error(e) return jsonify(message="Something went wrong"), 500 diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 25839003..8ba5f254 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -81,11 +81,15 @@ def refresh(): raise UnauthorizedRequest(message='Unauthorized') refreshModel = Token.find_by_jti(get_jwt()['jti']) + # Refresh token rotation + refreshToken, refreshModel = Token.create_refresh_token(user, oldRefreshToken=refreshModel) + # Create access token accesssToken, _ = Token.create_access_token(user, refreshModel) return jsonify({ - 'access_token': accesssToken + 'access_token': accesssToken, + 'refresh_token': refreshToken }) diff --git a/backend/app/models/token.py b/backend/app/models/token.py index 4ec26b31..2c7721cf 100644 --- a/backend/app/models/token.py +++ b/backend/app/models/token.py @@ -3,6 +3,7 @@ from typing import Tuple from app import db from app.config import JWT_REFRESH_TOKEN_EXPIRES, JWT_ACCESS_TOKEN_EXPIRES +from app.errors import UnauthorizedRequest from app.helpers import DbModelMixin, TimestampMixin from flask_jwt_extended import create_access_token, create_refresh_token, get_jti from app.models.user import User @@ -39,8 +40,7 @@ def find_by_jti(cls, jti) -> Token: @classmethod def delete_expired_refresh(cls): filter_before = datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES - db.session.query(cls).filter(cls.created_at <= - filter_before, cls.type == 'refresh').delete() + db.session.query(cls).filter(cls.created_at <= filter_before, cls.type == 'refresh', ~cls.created_tokens.any()).delete(synchronize_session=False) db.session.commit() @classmethod @@ -50,6 +50,28 @@ def delete_expired_access(cls): filter_before, cls.type == 'access').delete() db.session.commit() + # Delete oldest refresh token -> log out device + # Used e.g. when a refresh token is used twice + def delete_token_familiy(self): + if (self.type != 'refresh'): + return + token = self + while token: + if token.refresh_token: + token = token.refresh_token + else: + token.delete() + token = None + + def has_created_refresh_token(self) -> bool: + return db.session.query(Token).filter(Token.refresh_token_id == self.id, Token.type == 'refresh').count() > 0 + + def delete_created_access_tokens(self): + if (self.type != 'refresh'): + return + db.session.query(Token).filter(Token.refresh_token_id == self.id, Token.type == 'access').delete() + db.session.commit() + @classmethod def create_access_token(cls, user: User, refreshTokenModel: Token) -> Tuple[any, Token]: accesssToken = create_access_token(identity=user) @@ -63,13 +85,21 @@ def create_access_token(cls, user: User, refreshTokenModel: Token) -> Tuple[any, return accesssToken, model @classmethod - def create_refresh_token(cls, user: User, device: str) -> Tuple[any, Token]: + def create_refresh_token(cls, user: User, device: str = None, oldRefreshToken: Token = None) -> Tuple[any, Token]: + assert device or oldRefreshToken + if (oldRefreshToken and (oldRefreshToken.type != 'refresh' or oldRefreshToken.has_created_refresh_token())): + oldRefreshToken.delete_token_familiy() + raise UnauthorizedRequest() + refreshToken = create_refresh_token(identity=user) model = Token() model.jti = get_jti(refreshToken) model.type = 'refresh' - model.name = device + model.name = device or oldRefreshToken.name model.user = user + if (oldRefreshToken): + oldRefreshToken.delete_created_access_tokens() + model.refresh_token = oldRefreshToken model.save() return refreshToken, model From cb142205425798933ab7d666c0e70405aa592194 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 9 Jun 2022 16:10:01 +0200 Subject: [PATCH 129/496] fix: token improvements --- backend/app/config.py | 2 +- backend/app/models/user.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 4ecf9856..018227a3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -87,7 +87,7 @@ def unhandled_exception(e): app.logger.info(e) return jsonify(message="Requested resource not found"), 404 if type(e) is UnauthorizedRequest: - app.logger.warn(e) + app.logger.warning(e) return jsonify(message="Request unauthorized"), 401 app.logger.error(e) return jsonify(message="Something went wrong"), 500 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 249281e7..395aed1d 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -38,9 +38,12 @@ def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: return super().obj_to_dict(skip_columns=skip_columns, include_columns=include_columns) def obj_to_full_dict(self) -> dict: + from .token import Token res = self.obj_to_dict() - tokens = self.tokens - res['tokens'] = [e.obj_to_dict(skip_columns=['user_id']) for e in tokens] + tokens = Token.query.filter(Token.user_id == self.id, Token.type != + 'access', ~Token.created_tokens.any(Token.type == 'refresh')).all() + res['tokens'] = [e.obj_to_dict( + skip_columns=['user_id']) for e in tokens] return res @classmethod From 83439afad144d70056a792da26fd52bb25d81d8b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 10 Jun 2022 11:19:50 +0200 Subject: [PATCH 130/496] Prepare beta 29 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 018227a3..721b059c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,7 +11,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 28 +BACKEND_VERSION = 29 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From e16505e35f3d7e5a4852f002dbc121993706f3f2 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 12 Jun 2022 15:31:05 +0200 Subject: [PATCH 131/496] chore: upgrade requirements --- backend/requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 8da21cfb..d42ba642 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -alembic==1.7.7 +alembic==1.8.0 appdirs==1.4.4 APScheduler==3.9.1 attrs==21.4.0 @@ -17,7 +17,7 @@ flake8==4.0.1 Flask==2.1.2 Flask-APScheduler==1.12.3 Flask-Bcrypt==1.0.1 -Flask-JWT-Extended==4.4.0 +Flask-JWT-Extended==4.4.1 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 fonttools==4.33.3 @@ -32,7 +32,7 @@ Jinja2==3.1.2 joblib==1.1.0 jstyleson==0.0.2 kiwisolver==1.4.2 -lxml==4.8.0 +lxml==4.9.0 Mako==1.2.0 MarkupSafe==2.1.1 marshmallow==3.16.0 @@ -62,15 +62,15 @@ pytz==2022.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.1.1 rdflib-jsonld==0.6.2 -recipe-scrapers==14.2.0 -regex==2022.4.24 -requests==2.27.1 +recipe-scrapers==14.3.0 +regex==2022.6.2 +requests==2.28.0 scikit-learn==1.1.1 scipy==1.8.1 setuptools-scm==6.4.2 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.36 +SQLAlchemy==1.4.37 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 From dc91635f62fae60ea9c764959b3487cfcabe13a0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 12 Jun 2022 15:43:17 +0200 Subject: [PATCH 132/496] fix: item name remove spaces --- backend/app/models/item.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 72454411..9b91780d 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -47,9 +47,9 @@ def obj_to_export_dict(self): return res @classmethod - def create_by_name(cls, name, default=False) -> Item: + def create_by_name(cls, name: str, default=False) -> Item: return cls( - name=name, + name=name.strip(), default=default, ).save() @@ -61,7 +61,8 @@ def allByName(cls): return cls.query.order_by(cls.name).all() @classmethod - def find_by_name(cls, name) -> Item: + def find_by_name(cls, name: str) -> Item: + name = name.strip() return cls.query.filter(cls.name == name).first() @classmethod @@ -69,7 +70,7 @@ def find_by_id(cls, id) -> Item: return cls.query.filter(cls.id == id).first() @classmethod - def search_name(cls, name): + def search_name(cls, name: str): item_count = 9 found = [] From 30a84a59e68563a550017c0f9c09ccf9453fc6ff Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 12 Jun 2022 16:43:56 +0200 Subject: [PATCH 133/496] fix: publish latest to beta tag too --- .github/workflows/publish.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 45f666ac..fff2ba90 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,20 +23,21 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - - name: decide docker tag + - name: decide docker tags id: dockertag run: | if [[ $REF == "refs/tags/v"* ]] then - echo "::set-output name=tag::latest" + echo "::set-output name=tags::$BASE_TAG:latest, $BASE_TAG:beta" elif [[ $REF == "refs/tags/beta-v"* ]] then - echo "::set-output name=tag::beta" + echo "::set-output name=tags::$BASE_TAG:beta" else - echo "::set-output name=tag::dev" + echo "::set-output name=tags::$BASE_TAG:dev" fi env: REF: ${{ github.ref }} + BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl - name: Cache Docker layers uses: actions/cache@v2 @@ -67,7 +68,7 @@ jobs: file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:${{ steps.dockertag.outputs.tag }} + tags: ${{ steps.dockertag.outputs.tags }} cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache From 728b0042f0771b386ffeac668151ba95fe963aa4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 12 Jun 2022 16:45:10 +0200 Subject: [PATCH 134/496] Prepare release 30 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 721b059c..ffa6d166 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,7 +11,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 29 +BACKEND_VERSION = 30 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 58c3f9242e8c74ff8f14a702b3678bffc6913925 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 12 Jul 2022 18:54:52 +0200 Subject: [PATCH 135/496] chore: upgrade requirements --- backend/requirements.txt | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index d42ba642..ab6722d0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,21 +6,21 @@ autopep8==1.6.0 bcrypt==3.2.2 beautifulsoup4==4.11.1 black==21.12b0 -certifi==2022.5.18.1 -cffi==1.15.0 -charset-normalizer==2.0.12 +certifi==2022.6.15 +cffi==1.15.1 +charset-normalizer==2.1.0 click==8.1.3 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.13.0 flake8==4.0.1 Flask==2.1.2 -Flask-APScheduler==1.12.3 +Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 -Flask-JWT-Extended==4.4.1 +Flask-JWT-Extended==4.4.2 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -fonttools==4.33.3 +fonttools==4.34.4 greenlet==1.1.2 html-text==0.5.2 html5lib==1.1 @@ -31,21 +31,21 @@ itsdangerous==2.1.2 Jinja2==3.1.2 joblib==1.1.0 jstyleson==0.0.2 -kiwisolver==1.4.2 -lxml==4.9.0 -Mako==1.2.0 +kiwisolver==1.4.3 +lxml==4.9.1 +Mako==1.2.1 MarkupSafe==2.1.1 -marshmallow==3.16.0 +marshmallow==3.17.0 matplotlib==3.5.2 mccabe==0.6.1 mf2py==1.1.2 mlxtend==0.20.0 mypy-extensions==0.4.3 -numpy==1.22.4 +numpy==1.23.1 packaging==21.3 -pandas==1.4.2 +pandas==1.4.3 pathspec==0.9.0 -Pillow==9.1.1 +Pillow==9.2.0 platformdirs==2.5.2 pluggy==1.0.0 py==1.11.0 @@ -62,23 +62,23 @@ pytz==2022.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.1.1 rdflib-jsonld==0.6.2 -recipe-scrapers==14.3.0 -regex==2022.6.2 -requests==2.28.0 +recipe-scrapers==14.8.0 +regex==2022.7.9 +requests==2.28.1 scikit-learn==1.1.1 scipy==1.8.1 -setuptools-scm==6.4.2 +setuptools-scm==7.0.5 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.37 +SQLAlchemy==1.4.39 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 typed-ast==1.5.4 -typing_extensions==4.2.0 +typing_extensions==4.3.0 tzdata==2022.1 tzlocal==4.2 -urllib3==1.26.9 +urllib3==1.26.10 uWSGI==2.0.20 w3lib==1.22.0 webencodings==0.5.1 From b625f1d3b53735393229c5fa2278ce9d991fdfb4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 29 Jul 2022 15:58:28 +0200 Subject: [PATCH 136/496] Planner return full recipe --- backend/app/controller/planner/planner_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 03a14638..e82dd357 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -56,7 +56,7 @@ def removePlannedRecipeById(args, id): @jwt_required() def getRecentRecipes(): recipes = RecipeHistory.get_recent() - return jsonify([e.recipe.obj_to_dict() for e in recipes]) + return jsonify([e.recipe.obj_to_full_dict() for e in recipes]) @planner.route('/suggested-recipes', methods=['GET']) @@ -70,7 +70,7 @@ def getSuggestedRecipes(): # limit suggestions number to maximally 9 if len(suggested_recipes) > 9: suggested_recipes = suggested_recipes[:9] - return jsonify([r.obj_to_dict() for r in suggested_recipes]) + return jsonify([r.obj_to_full_dict() for r in suggested_recipes]) @planner.route('/refresh-suggested-recipes', methods=['GET']) From e8cd7ace186876f23e4590f29e2439067f0df123 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 29 Jul 2022 16:21:23 +0200 Subject: [PATCH 137/496] Allow setting http port (TomBursch/kitchenowl-backend#9) --- backend/Dockerfile | 3 +-- backend/wsgi.ini | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index c5293cd4..38b24b47 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,6 +15,7 @@ VOLUME ["/data"] ENV STORAGE_PATH='/data' ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' ENV DEBUG='False' +ENV HTTP_PORT=80 RUN pip3 install -r requirements.txt && rm requirements.txt RUN chmod u+x ./entrypoint.sh @@ -23,7 +24,5 @@ RUN chmod u+x ./entrypoint.sh RUN apt-get autoremove --yes gcc g++ libffi-dev \ && rm -rf /var/lib/apt/lists/* -EXPOSE 80 - CMD ["wsgi.ini"] ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/wsgi.ini b/backend/wsgi.ini index f8d1e030..5e8bd8d2 100644 --- a/backend/wsgi.ini +++ b/backend/wsgi.ini @@ -1,11 +1,11 @@ [uwsgi] wsgi-file = wsgi.py callable = app -http = 0.0.0.0:80 +http = 0.0.0.0:$(HTTP_PORT) socket = 0.0.0.0:5000 processes = 1 threads = 1 master = true chmod-socket = 664 vacuum = true -die-on-term = true \ No newline at end of file +die-on-term = true From c304f120dbad849f03e67be261fa639b142653b4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 29 Jul 2022 16:28:24 +0200 Subject: [PATCH 138/496] chore: upgrade dependencies --- backend/requirements.txt | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index ab6722d0..43ad7f83 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,7 @@ -alembic==1.8.0 +alembic==1.8.1 appdirs==1.4.4 APScheduler==3.9.1 -attrs==21.4.0 +attrs==22.1.0 autopep8==1.6.0 bcrypt==3.2.2 beautifulsoup4==4.11.1 @@ -14,10 +14,10 @@ cycler==0.11.0 dbscan1d==0.1.6 extruct==0.13.0 flake8==4.0.1 -Flask==2.1.2 +Flask==2.1.3 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 -Flask-JWT-Extended==4.4.2 +Flask-JWT-Extended==4.4.3 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 fonttools==4.34.4 @@ -31,7 +31,7 @@ itsdangerous==2.1.2 Jinja2==3.1.2 joblib==1.1.0 jstyleson==0.0.2 -kiwisolver==1.4.3 +kiwisolver==1.4.4 lxml==4.9.1 Mako==1.2.1 MarkupSafe==2.1.1 @@ -60,13 +60,13 @@ python-dateutil==2.8.2 python-editor==1.0.4 pytz==2022.1 pytz-deprecation-shim==0.1.0.post0 -rdflib==6.1.1 +rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.8.0 -regex==2022.7.9 +recipe-scrapers==14.11.0 +regex==2022.7.25 requests==2.28.1 scikit-learn==1.1.1 -scipy==1.8.1 +scipy==1.9.0 setuptools-scm==7.0.5 six==1.16.0 soupsieve==2.3.2 @@ -78,8 +78,8 @@ typed-ast==1.5.4 typing_extensions==4.3.0 tzdata==2022.1 tzlocal==4.2 -urllib3==1.26.10 +urllib3==1.26.11 uWSGI==2.0.20 w3lib==1.22.0 webencodings==0.5.1 -Werkzeug==2.1.2 +Werkzeug==2.2.1 From cb58b97b084207aef3d01e4bdf93b5c3ea9e706c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 29 Jul 2022 16:29:04 +0200 Subject: [PATCH 139/496] Prepare beta 31 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index ffa6d166..ecd15f6d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,7 +11,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 30 +BACKEND_VERSION = 31 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 4d1e3e6904164ddd389a982fef7528bd9f6df467 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 2 Aug 2022 18:22:30 +0200 Subject: [PATCH 140/496] fix: jsonify datetime to epoch --- backend/app/config.py | 3 +++ backend/app/util/__init__.py | 1 + backend/app/util/kitchenowl_json_encoder.py | 10 ++++++++++ 3 files changed, 14 insertions(+) create mode 100644 backend/app/util/__init__.py create mode 100644 backend/app/util/kitchenowl_json_encoder.py diff --git a/backend/app/config.py b/backend/app/config.py index ecd15f6d..57246b2f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,6 +1,7 @@ from datetime import timedelta from sqlalchemy import MetaData from app.errors import NotFoundRequest, UnauthorizedRequest +from app.util import KitchenOwlJSONEncoder from flask import Flask, jsonify, request from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy @@ -40,6 +41,8 @@ app.config["JWT_ACCESS_TOKEN_EXPIRES"] = JWT_ACCESS_TOKEN_EXPIRES app.config["JWT_REFRESH_TOKEN_EXPIRES"] = JWT_REFRESH_TOKEN_EXPIRES +app.json_encoder = KitchenOwlJSONEncoder + convention = { "ix": 'ix_%(column_0_label)s', "uq": "uq_%(table_name)s_%(column_0_name)s", diff --git a/backend/app/util/__init__.py b/backend/app/util/__init__.py new file mode 100644 index 00000000..0fb2f896 --- /dev/null +++ b/backend/app/util/__init__.py @@ -0,0 +1 @@ +from .kitchenowl_json_encoder import KitchenOwlJSONEncoder \ No newline at end of file diff --git a/backend/app/util/kitchenowl_json_encoder.py b/backend/app/util/kitchenowl_json_encoder.py new file mode 100644 index 00000000..f061271d --- /dev/null +++ b/backend/app/util/kitchenowl_json_encoder.py @@ -0,0 +1,10 @@ +from flask.json import JSONEncoder +from datetime import date + + +class KitchenOwlJSONEncoder(JSONEncoder): + def default(self, o): + if isinstance(o, date): + return int(round(o.timestamp() * 1000)) + + return super().default(o) \ No newline at end of file From ee4b35fd2cc781ac2ee54bd3e7810a556ede5d61 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 2 Aug 2022 18:23:09 +0200 Subject: [PATCH 141/496] Prepare release 32 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 57246b2f..a0f00538 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 31 +BACKEND_VERSION = 32 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 19afd0fab5230bf5f2e5f78ed668f25dded671fc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 3 Aug 2022 11:37:28 +0200 Subject: [PATCH 142/496] fix: timezone jsonify --- backend/app/util/kitchenowl_json_encoder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/util/kitchenowl_json_encoder.py b/backend/app/util/kitchenowl_json_encoder.py index f061271d..3e9195b6 100644 --- a/backend/app/util/kitchenowl_json_encoder.py +++ b/backend/app/util/kitchenowl_json_encoder.py @@ -1,10 +1,10 @@ from flask.json import JSONEncoder -from datetime import date +from datetime import date, timezone class KitchenOwlJSONEncoder(JSONEncoder): def default(self, o): if isinstance(o, date): - return int(round(o.timestamp() * 1000)) + return int(round(o.replace(tzinfo=timezone.utc).timestamp() * 1000)) return super().default(o) \ No newline at end of file From 26e86628850505e92dab19cfee5eb4d38f4e5aed Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 3 Aug 2022 11:45:48 +0200 Subject: [PATCH 143/496] chore: upgrade requirements --- backend/requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 43ad7f83..7210c065 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,8 +13,8 @@ click==8.1.3 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.13.0 -flake8==4.0.1 -Flask==2.1.3 +flake8==5.0.3 +Flask==2.2.0 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.3 @@ -37,7 +37,7 @@ Mako==1.2.1 MarkupSafe==2.1.1 marshmallow==3.17.0 matplotlib==3.5.2 -mccabe==0.6.1 +mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.20.0 mypy-extensions==0.4.3 @@ -49,9 +49,9 @@ Pillow==9.2.0 platformdirs==2.5.2 pluggy==1.0.0 py==1.11.0 -pycodestyle==2.8.0 +pycodestyle==2.9.0 pycparser==2.21 -pyflakes==2.4.0 +pyflakes==2.5.0 PyJWT==2.4.0 pyparsing==3.0.9 pyRdfa3==3.5.3 From 6bbbf75a474a7ea86dae216d27b5725cd9ad6df9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 3 Aug 2022 11:46:00 +0200 Subject: [PATCH 144/496] Prepare release 33 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index a0f00538..efc238de 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 32 +BACKEND_VERSION = 33 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 964c6dcc1dfea7ab9fef35d2f9f9c977e5c25dc1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 17 Aug 2022 14:20:43 +0200 Subject: [PATCH 145/496] chore: upgrade requirements --- backend/requirements.txt | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7210c065..3f611b37 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,7 +2,7 @@ alembic==1.8.1 appdirs==1.4.4 APScheduler==3.9.1 attrs==22.1.0 -autopep8==1.6.0 +autopep8==1.7.0 bcrypt==3.2.2 beautifulsoup4==4.11.1 black==21.12b0 @@ -13,14 +13,14 @@ click==8.1.3 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.13.0 -flake8==5.0.3 -Flask==2.2.0 +flake8==5.0.4 +Flask==2.2.2 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 -Flask-JWT-Extended==4.4.3 +Flask-JWT-Extended==4.4.4 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -fonttools==4.34.4 +fonttools==4.35.0 greenlet==1.1.2 html-text==0.5.2 html5lib==1.1 @@ -36,12 +36,12 @@ lxml==4.9.1 Mako==1.2.1 MarkupSafe==2.1.1 marshmallow==3.17.0 -matplotlib==3.5.2 +matplotlib==3.5.3 mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.20.0 mypy-extensions==0.4.3 -numpy==1.23.1 +numpy==1.23.2 packaging==21.3 pandas==1.4.3 pathspec==0.9.0 @@ -49,7 +49,7 @@ Pillow==9.2.0 platformdirs==2.5.2 pluggy==1.0.0 py==1.11.0 -pycodestyle==2.9.0 +pycodestyle==2.9.1 pycparser==2.21 pyflakes==2.5.0 PyJWT==2.4.0 @@ -58,28 +58,28 @@ pyRdfa3==3.5.3 pytest==7.1.2 python-dateutil==2.8.2 python-editor==1.0.4 -pytz==2022.1 +pytz==2022.2.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.11.0 +recipe-scrapers==14.12.0 regex==2022.7.25 requests==2.28.1 -scikit-learn==1.1.1 +scikit-learn==1.1.2 scipy==1.9.0 setuptools-scm==7.0.5 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.39 +SQLAlchemy==1.4.40 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 typed-ast==1.5.4 typing_extensions==4.3.0 -tzdata==2022.1 +tzdata==2022.2 tzlocal==4.2 urllib3==1.26.11 uWSGI==2.0.20 -w3lib==1.22.0 +w3lib==2.0.1 webencodings==0.5.1 -Werkzeug==2.2.1 +Werkzeug==2.2.2 From c75b93499baf947b125d17f0022154a779a1a153 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 17 Aug 2022 15:20:29 +0200 Subject: [PATCH 146/496] chore: migrate flask deprecated features --- backend/app/config.py | 5 +- backend/app/jobs/jobs.py | 50 +++++++++---------- backend/app/util/__init__.py | 2 +- ...encoder.py => kitchenowl_json_provider.py} | 4 +- 4 files changed, 30 insertions(+), 31 deletions(-) rename backend/app/util/{kitchenowl_json_encoder.py => kitchenowl_json_provider.py} (67%) diff --git a/backend/app/config.py b/backend/app/config.py index efc238de..378b2c7d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,7 +1,7 @@ from datetime import timedelta from sqlalchemy import MetaData from app.errors import NotFoundRequest, UnauthorizedRequest -from app.util import KitchenOwlJSONEncoder +from app.util import KitchenOwlJSONProvider from flask import Flask, jsonify, request from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy @@ -28,6 +28,8 @@ 'de': 'Deutsch' } +Flask.json_provider_class = KitchenOwlJSONProvider + app = Flask(__name__) app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER @@ -41,7 +43,6 @@ app.config["JWT_ACCESS_TOKEN_EXPIRES"] = JWT_ACCESS_TOKEN_EXPIRES app.config["JWT_REFRESH_TOKEN_EXPIRES"] = JWT_REFRESH_TOKEN_EXPIRES -app.json_encoder = KitchenOwlJSONEncoder convention = { "ix": 'ix_%(column_0_label)s', diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index 741c58e9..e6b72be8 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -6,31 +6,29 @@ from .cluster_shoppings import clusterShoppings -@app.before_first_request -def load_jobs(): - # for debugging: - # @scheduler.task('interval', id='test', seconds=5) - # def test(): - # app.logger.info("--- test analysis is starting ---") - # # recipe planner tasks - # meal_instances = findMealInstancesFromHistory() - # computeRecipeSuggestions(meal_instances) - # app.logger.info("--- test analysis is completed ---") +# for debugging: +# @scheduler.task('interval', id='test', seconds=5) +# def test(): +# app.logger.info("--- test analysis is starting ---") +# # recipe planner tasks +# meal_instances = findMealInstancesFromHistory() +# computeRecipeSuggestions(meal_instances) +# app.logger.info("--- test analysis is completed ---") - @scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') - def daily(): - app.logger.info("--- daily analysis is starting ---") - # shopping tasks - shopping_instances = clusterShoppings() - findItemOrdering(shopping_instances) - findItemSuggestions(shopping_instances) - # recipe planner tasks - meal_instances = findMealInstancesFromHistory() - computeRecipeSuggestions(meal_instances) - app.logger.info("--- daily analysis is completed ---") +@scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') +def daily(): + app.logger.info("--- daily analysis is starting ---") + # shopping tasks + shopping_instances = clusterShoppings() + findItemOrdering(shopping_instances) + findItemSuggestions(shopping_instances) + # recipe planner tasks + meal_instances = findMealInstancesFromHistory() + computeRecipeSuggestions(meal_instances) + app.logger.info("--- daily analysis is completed ---") - @scheduler.task('interval', id='every30min', minutes=30) - def halfHourly(): - # Remove expired Tokens - Token.delete_expired_access() - Token.delete_expired_refresh() +@scheduler.task('interval', id='every30min', minutes=30) +def halfHourly(): + # Remove expired Tokens + Token.delete_expired_access() + Token.delete_expired_refresh() diff --git a/backend/app/util/__init__.py b/backend/app/util/__init__.py index 0fb2f896..91fc286d 100644 --- a/backend/app/util/__init__.py +++ b/backend/app/util/__init__.py @@ -1 +1 @@ -from .kitchenowl_json_encoder import KitchenOwlJSONEncoder \ No newline at end of file +from .kitchenowl_json_provider import KitchenOwlJSONProvider \ No newline at end of file diff --git a/backend/app/util/kitchenowl_json_encoder.py b/backend/app/util/kitchenowl_json_provider.py similarity index 67% rename from backend/app/util/kitchenowl_json_encoder.py rename to backend/app/util/kitchenowl_json_provider.py index 3e9195b6..b91ddc9c 100644 --- a/backend/app/util/kitchenowl_json_encoder.py +++ b/backend/app/util/kitchenowl_json_provider.py @@ -1,8 +1,8 @@ -from flask.json import JSONEncoder +from flask.json.provider import DefaultJSONProvider from datetime import date, timezone -class KitchenOwlJSONEncoder(JSONEncoder): +class KitchenOwlJSONProvider(DefaultJSONProvider): def default(self, o): if isinstance(o, date): return int(round(o.replace(tzinfo=timezone.utc).timestamp() * 1000)) From d7703ad1138c7573636ee5e36a9c46b222ecb35f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 19 Aug 2022 10:49:19 +0200 Subject: [PATCH 147/496] feat: expense overview --- .../controller/expense/expense_controller.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 4ea8632f..193b0cc7 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -1,3 +1,5 @@ +import calendar +from datetime import datetime from sqlalchemy.sql.expression import desc from app.errors import NotFoundRequest from flask import jsonify, Blueprint @@ -146,6 +148,30 @@ def getExpenseCategories(): return jsonify([e.name for e in ExpenseCategory.all_by_name()]) +@expense.route('/overview', methods=['GET']) +@jwt_required() +def getExpenseOverview(): + categories = list(map(lambda x: x.name, ExpenseCategory.all_by_name())) + categories.append("") + thisMonthStart = datetime.utcnow().date().replace(day=1) + + def getOverviewForMonthAgo(monthAgo: int): + monthStart = thisMonthStart.replace( + month=(thisMonthStart.month - monthAgo)) + monthEnd = monthStart.replace(day=calendar.monthrange( + monthStart.year, monthStart.month)[1]) + return { + (e.name or ""): (float(e.balance) or 0) for e in Expense.query.with_entities(ExpenseCategory.name.label("name"), func.sum( + Expense.amount).label("balance")).group_by(Expense.category_id).join(Expense.category, isouter=True).filter(Expense.created_at >= monthStart, Expense.created_at <= monthEnd).all() + } + + value = [getOverviewForMonthAgo(i) for i in range(0, 5)] + + return jsonify({category: { + i: (value[i][category] if category in value[i] else 0.0) for i in range(0, 5) + } for category in categories}) + + @expense.route('/categories', methods=['POST']) @jwt_required() @validate_args(AddExpenseCategory) From e64e8dc971711980a8f7668bf6b60257dc176307 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 13 Sep 2022 14:32:31 +0200 Subject: [PATCH 148/496] feat: save recent description --- backend/CONTRIBUTING.md | 3 ++ backend/README.md | 3 +- .../shoppinglist/shoppinglist_controller.py | 9 +++--- backend/app/models/history.py | 11 ++++--- backend/docker-compose.yml | 4 +-- backend/migrations/versions/144524c5cf79_.py | 32 +++++++++++++++++++ 6 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/versions/144524c5cf79_.py diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md index a12b0973..0d324a2c 100644 --- a/backend/CONTRIBUTING.md +++ b/backend/CONTRIBUTING.md @@ -27,6 +27,9 @@ The `description` is a descriptive summary of the change the PR will make. - All PRs should be rebased (with main) and commits squashed prior to the final merge process - One PR per fix or feature +### Requirements +- Python 3.9+ + ### Setup & Install - Create a python environment `python3 -m venv venv` - Activate your python environment `source venv/bin/activate` (environment can be deactivated with `deactivate`) diff --git a/backend/README.md b/backend/README.md index 2f0273c9..29dc673c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,3 +1,4 @@ # KitchenOwl Backend This is the backend repository of KitchenOwl. -Find more information at the main [KitchenOwl](https://github.com/TomBursch/kitchenowl) repository. \ No newline at end of file +Find more information at the main [KitchenOwl](https://github.com/TomBursch/kitchenowl) repository. +Take a look at the [contributing](./CONTRIBUTING.md) file for local setup instructions. \ No newline at end of file diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 2e2a4d67..42252833 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -15,7 +15,7 @@ @shoppinglist.before_app_first_request def before_first_request(): # Add default shoppinglist - if(not Shoppinglist.find_by_id(1)): + if (not Shoppinglist.find_by_id(1)): sl = Shoppinglist( name='Default' ) @@ -58,7 +58,7 @@ def getAllShoppingListItems(id): @jwt_required() def getRecentItems(id): items = History.get_recent(id) - return jsonify([e.item.obj_to_dict() for e in items]) + return jsonify([e.item.obj_to_dict() | {'description': e.description} for e in items]) def getSuggestionsBasedOnLastAddedItems(id, item_count): @@ -135,7 +135,7 @@ def addShoppinglistItemByName(args, id): con.shoppinglist = shoppinglist con.save() - History.create_added(shoppinglist, item) + History.create_added(shoppinglist, item, description) return jsonify(item.obj_to_dict()) @@ -153,9 +153,10 @@ def removeShoppinglistItem(args, id): if not item: raise NotFoundRequest() con = ShoppinglistItems.find_by_ids(id, args['item_id']) + description = con.description con.delete() - History.create_dropped(shoppinglist, item) + History.create_dropped(shoppinglist, item, description) return jsonify({'msg': "DONE"}) diff --git a/backend/app/models/history.py b/backend/app/models/history.py index a8d85532..f1935693 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -23,21 +23,24 @@ class History(db.Model, DbModelMixin, TimestampMixin): item = db.relationship("Item", uselist=False, back_populates="history") status = db.Column(db.Enum(Status)) + description = db.Column('description', db.String()) @classmethod - def create_added(cls, shoppinglist, item): + def create_added(cls, shoppinglist, item, description=''): return cls( shoppinglist_id=shoppinglist.id, item_id=item.id, - status=Status.ADDED + status=Status.ADDED, + description=description ).save() @classmethod - def create_dropped(cls, shoppinglist, item): + def create_dropped(cls, shoppinglist, item, description=''): return cls( shoppinglist_id=shoppinglist.id, item_id=item.id, - status=Status.DROPPED + status=Status.DROPPED, + description=description ).save() def obj_to_item_dict(self): diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 45747fef..eabb49e7 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -14,13 +14,13 @@ services: back: image: tombursch/kitchenowl:latest restart: unless-stopped - # ports: # Optional and only needed when only hosting the backend + # ports: # Optional and only needed when only hosting the http backend # - "80:80" networks: - default environment: - JWT_SECRET_KEY=PLEASE_CHANGE_ME - # - FRONT_URL=http://localhost # Optional and only needed if + - FRONT_URL=http://localhost volumes: - kitchenowl_data:/data diff --git a/backend/migrations/versions/144524c5cf79_.py b/backend/migrations/versions/144524c5cf79_.py new file mode 100644 index 00000000..b23a1d26 --- /dev/null +++ b/backend/migrations/versions/144524c5cf79_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 144524c5cf79 +Revises: ae608469ef8b +Create Date: 2022-09-13 13:41:30.316063 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '144524c5cf79' +down_revision = 'ae608469ef8b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('history', schema=None) as batch_op: + batch_op.add_column(sa.Column('description', sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('history', schema=None) as batch_op: + batch_op.drop_column('description') + + # ### end Alembic commands ### From 81724aa78f83291a85dff05041eb6d1acbfc1352 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 13 Sep 2022 14:38:03 +0200 Subject: [PATCH 149/496] chore: upgrade requirements --- backend/requirements.txt | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 3f611b37..2c05e35d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,12 +3,12 @@ appdirs==1.4.4 APScheduler==3.9.1 attrs==22.1.0 autopep8==1.7.0 -bcrypt==3.2.2 +bcrypt==4.0.0 beautifulsoup4==4.11.1 black==21.12b0 -certifi==2022.6.15 +certifi==2022.6.15.1 cffi==1.15.1 -charset-normalizer==2.1.0 +charset-normalizer==2.1.1 click==8.1.3 cycler==0.11.0 dbscan1d==0.1.6 @@ -20,8 +20,8 @@ Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -fonttools==4.35.0 -greenlet==1.1.2 +fonttools==4.37.1 +greenlet==1.1.3 html-text==0.5.2 html5lib==1.1 idna==3.3 @@ -33,18 +33,18 @@ joblib==1.1.0 jstyleson==0.0.2 kiwisolver==1.4.4 lxml==4.9.1 -Mako==1.2.1 +Mako==1.2.2 MarkupSafe==2.1.1 -marshmallow==3.17.0 +marshmallow==3.17.1 matplotlib==3.5.3 mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.20.0 mypy-extensions==0.4.3 -numpy==1.23.2 +numpy==1.23.3 packaging==21.3 -pandas==1.4.3 -pathspec==0.9.0 +pandas==1.4.4 +pathspec==0.10.1 Pillow==9.2.0 platformdirs==2.5.2 pluggy==1.0.0 @@ -55,22 +55,22 @@ pyflakes==2.5.0 PyJWT==2.4.0 pyparsing==3.0.9 pyRdfa3==3.5.3 -pytest==7.1.2 +pytest==7.1.3 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2022.2.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.12.0 -regex==2022.7.25 +recipe-scrapers==14.14.0 +regex==2022.9.13 requests==2.28.1 scikit-learn==1.1.2 -scipy==1.9.0 +scipy==1.9.1 setuptools-scm==7.0.5 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.40 +SQLAlchemy==1.4.41 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 @@ -78,7 +78,7 @@ typed-ast==1.5.4 typing_extensions==4.3.0 tzdata==2022.2 tzlocal==4.2 -urllib3==1.26.11 +urllib3==1.26.12 uWSGI==2.0.20 w3lib==2.0.1 webencodings==0.5.1 From bca52fcc441aaeaa33538a0a72633411a97dfcd1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 14 Sep 2022 16:42:39 +0200 Subject: [PATCH 150/496] Prepare beta 34 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 378b2c7d..798b309e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 33 +BACKEND_VERSION = 34 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 1f6ecbcd527ef7dbc012d1990ca230c96f0635f1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 16 Sep 2022 12:49:00 +0200 Subject: [PATCH 151/496] feat: category and tags renaming --- .../category/category_controller.py | 16 +++++++++++++- backend/app/controller/category/schemas.py | 12 ++++++++-- .../controller/expense/expense_controller.py | 22 ++++++++++++++++--- backend/app/controller/expense/schemas.py | 9 +++++++- backend/app/controller/tag/schemas.py | 9 +++++++- backend/app/controller/tag/tag_controller.py | 17 +++++++++++++- 6 files changed, 76 insertions(+), 9 deletions(-) diff --git a/backend/app/controller/category/category_controller.py b/backend/app/controller/category/category_controller.py index 01d70680..d54a19c5 100644 --- a/backend/app/controller/category/category_controller.py +++ b/backend/app/controller/category/category_controller.py @@ -3,7 +3,7 @@ from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required from app.models import Category -from .schemas import AddCategory, DeleteCategory +from .schemas import AddCategory, DeleteCategory, UpdateCategory category = Blueprint('category', __name__) @@ -33,6 +33,20 @@ def addCategory(args): return jsonify(category.obj_to_dict()) +@category.route('/', methods=['POST']) +@jwt_required() +@validate_args(UpdateCategory) +def updateCategory(args, id): + category = Category.find_by_id(id) + if not category: + raise NotFoundRequest() + + if 'name' in args: + category.name = args['name'] + category.save() + return jsonify(category.obj_to_dict()) + + @category.route('/', methods=['DELETE']) @jwt_required() def deleteCategoryById(id): diff --git a/backend/app/controller/category/schemas.py b/backend/app/controller/category/schemas.py index ebd80d89..ad39b52a 100644 --- a/backend/app/controller/category/schemas.py +++ b/backend/app/controller/category/schemas.py @@ -3,11 +3,19 @@ class AddCategory(Schema): name = fields.String( - required=True + required=True, + validate=lambda a: a and not a.isspace() + ) + + +class UpdateCategory(Schema): + name = fields.String( + validate=lambda a: a and not a.isspace() ) class DeleteCategory(Schema): name = fields.String( - required=True + required=True, + validate=lambda a: a and not a.isspace() ) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 193b0cc7..f3b99ccd 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -7,7 +7,7 @@ from sqlalchemy import func from app.helpers import validate_args, admin_required from app.models import Expense, ExpensePaidFor, User, ExpenseCategory -from .schemas import AddExpense, UpdateExpense, AddExpenseCategory, DeleteExpenseCategory +from .schemas import AddExpense, UpdateExpense, AddExpenseCategory, DeleteExpenseCategory, UpdateExpenseCategory expense = Blueprint('expense', __name__) @@ -176,8 +176,8 @@ def getOverviewForMonthAgo(monthAgo: int): @jwt_required() @validate_args(AddExpenseCategory) def addExpenseCategory(args): - ExpenseCategory.create_by_name(args['name']) - return jsonify(ExpenseCategory.obj_to_dict()) + category = ExpenseCategory.create_by_name(args['name']) + return jsonify(category.obj_to_dict()) @expense.route('/categories', methods=['DELETE']) @@ -187,3 +187,19 @@ def addExpenseCategory(args): def deleteExpenseCategoryById(args): ExpenseCategory.delete_by_name(args['name']) return jsonify({'msg': 'DONE'}) + + +@expense.route('/categories/', methods=['POST']) +@jwt_required() +@validate_args(UpdateExpenseCategory) +def renameExpenseCategory(args, name): + category = ExpenseCategory.find_by_name(name) + + if not category: + raise NotFoundRequest() + + if 'name' in args: + category.name = args['name'] + + category.save() + return jsonify(category.obj_to_dict()) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 8998403b..343ea0fe 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -46,7 +46,8 @@ class User(Schema): amount = fields.Float() category = fields.String( validate=lambda a: not a or ( - a and not a.isspace()), allow_none=True + a and not a.isspace()), + allow_none=True ) paid_by = fields.Nested(User()) paid_for = fields.List(fields.Nested(User())) @@ -59,6 +60,12 @@ class AddExpenseCategory(Schema): ) +class UpdateExpenseCategory(Schema): + name = fields.String( + validate=lambda a: a and not a.isspace() + ) + + class DeleteExpenseCategory(Schema): name = fields.String( required=True, diff --git a/backend/app/controller/tag/schemas.py b/backend/app/controller/tag/schemas.py index 66b8c5a3..a1aa826c 100644 --- a/backend/app/controller/tag/schemas.py +++ b/backend/app/controller/tag/schemas.py @@ -10,5 +10,12 @@ class SearchByNameRequest(Schema): class AddTag(Schema): name = fields.String( - required=True + required=True, + validate=lambda a: a and not a.isspace() + ) + + +class UpdateTag(Schema): + name = fields.String( + validate=lambda a: a and not a.isspace() ) diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py index 1d3593f4..ef8bb0d1 100644 --- a/backend/app/controller/tag/tag_controller.py +++ b/backend/app/controller/tag/tag_controller.py @@ -3,7 +3,7 @@ from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required from app.models import Tag, RecipeTags, Recipe -from .schemas import SearchByNameRequest, AddTag +from .schemas import SearchByNameRequest, AddTag, UpdateTag tag = Blueprint('tag', __name__) @@ -43,6 +43,21 @@ def addTag(args): return jsonify(tag.obj_to_dict()) +@tag.route('/', methods=['POST']) +@jwt_required() +@validate_args(UpdateTag) +def updateTag(args, id): + tag = Tag.find_by_id(id) + if not tag: + raise NotFoundRequest() + + if 'name' in args: + tag.name = args['name'] + + tag.save() + return jsonify(tag.obj_to_dict()) + + @tag.route('/', methods=['DELETE']) @jwt_required() def deleteTagById(id): From 59e2ef0f40f92f814f57fe5721ab5faaeefa131c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 17 Sep 2022 17:46:36 +0200 Subject: [PATCH 152/496] feat: category reordering --- .../category/category_controller.py | 4 ++- backend/app/controller/category/schemas.py | 7 +++- backend/app/helpers/db_set_type.py | 2 +- backend/app/models/category.py | 29 +++++++++++++++++ backend/migrations/versions/ade6487fe28a_.py | 32 +++++++++++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/versions/ade6487fe28a_.py diff --git a/backend/app/controller/category/category_controller.py b/backend/app/controller/category/category_controller.py index d54a19c5..0541f7c3 100644 --- a/backend/app/controller/category/category_controller.py +++ b/backend/app/controller/category/category_controller.py @@ -11,7 +11,7 @@ @category.route('', methods=['GET']) @jwt_required() def getAllCategories(): - return jsonify([e.obj_to_dict() for e in Category.all_by_name()]) + return jsonify([e.obj_to_dict() for e in Category.all_by_ordering()]) @category.route('/', methods=['GET']) @@ -43,6 +43,8 @@ def updateCategory(args, id): if 'name' in args: category.name = args['name'] + if 'ordering' in args and category.ordering != args['ordering']: + category.reorder(args['ordering']) category.save() return jsonify(category.obj_to_dict()) diff --git a/backend/app/controller/category/schemas.py b/backend/app/controller/category/schemas.py index ad39b52a..19e46341 100644 --- a/backend/app/controller/category/schemas.py +++ b/backend/app/controller/category/schemas.py @@ -1,7 +1,9 @@ -from marshmallow import fields, Schema +from marshmallow import fields, Schema, EXCLUDE class AddCategory(Schema): + class Meta: + unknown = EXCLUDE name = fields.String( required=True, validate=lambda a: a and not a.isspace() @@ -12,6 +14,9 @@ class UpdateCategory(Schema): name = fields.String( validate=lambda a: a and not a.isspace() ) + ordering = fields.Integer( + validate=lambda i: i >= 0 + ) class DeleteCategory(Schema): diff --git a/backend/app/helpers/db_set_type.py b/backend/app/helpers/db_set_type.py index 74522126..3fe2ac2b 100644 --- a/backend/app/helpers/db_set_type.py +++ b/backend/app/helpers/db_set_type.py @@ -2,11 +2,11 @@ import json +# Represents a Set in the DataBase (i.e. {e1, e2, e3, ...}) class DbSetType(TypeDecorator): impl = String def process_bind_param(self, value, dialect): - print(value) if type(value) is set: return json.dumps(list(value)) else: diff --git a/backend/app/models/category.py b/backend/app/models/category.py index a534e358..c0ca39f2 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -1,4 +1,5 @@ from __future__ import annotations +from unicodedata import category from app import db from app.helpers import DbModelMixin, TimestampMixin @@ -9,6 +10,7 @@ class Category(db.Model, DbModelMixin, TimestampMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) default = db.Column(db.Boolean, default=False) + ordering = db.Column(db.Integer, default=0) items = db.relationship( 'Item', back_populates='category') @@ -17,6 +19,10 @@ def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() return res + @classmethod + def all_by_ordering(cls): + return cls.query.order_by(cls.ordering, cls.name).all() + @classmethod def create_by_name(cls, name, default=False) -> Category: return cls( @@ -31,3 +37,26 @@ def find_by_name(cls, name) -> Category: @classmethod def find_by_id(cls, id) -> Category: return cls.query.filter(cls.id == id).first() + + def reorder(self, newIndex: int): + cls = self.__class__ + self.ordering = newIndex + + l = cls.query.order_by(cls.ordering, cls.name).all() + + oldIndex = list(map(lambda x: x.id, l)).index(self.id) + if oldIndex < 0: + raise Exception() # Something went wrong + e = l.pop(oldIndex) + + l.insert(newIndex, e) + + for i, category in enumerate(l): + category.ordering = i + + try: + db.session.add_all(l) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e diff --git a/backend/migrations/versions/ade6487fe28a_.py b/backend/migrations/versions/ade6487fe28a_.py new file mode 100644 index 00000000..621484d8 --- /dev/null +++ b/backend/migrations/versions/ade6487fe28a_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: ade6487fe28a +Revises: 144524c5cf79 +Create Date: 2022-09-17 16:57:53.855716 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ade6487fe28a' +down_revision = '144524c5cf79' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.add_column(sa.Column('ordering', sa.Integer(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.drop_column('ordering') + + # ### end Alembic commands ### From cce38aa65faa35f36ad79debfc1d7c833667adcd Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 17 Sep 2022 17:50:40 +0200 Subject: [PATCH 153/496] chore: upgrade requirements --- backend/requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 2c05e35d..e7ced3a9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,7 +6,7 @@ autopep8==1.7.0 bcrypt==4.0.0 beautifulsoup4==4.11.1 black==21.12b0 -certifi==2022.6.15.1 +certifi==2022.9.14 cffi==1.15.1 charset-normalizer==2.1.1 click==8.1.3 @@ -20,23 +20,23 @@ Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -fonttools==4.37.1 +fonttools==4.37.2 greenlet==1.1.3 html-text==0.5.2 html5lib==1.1 -idna==3.3 +idna==3.4 iniconfig==1.1.1 isodate==0.6.1 itsdangerous==2.1.2 Jinja2==3.1.2 -joblib==1.1.0 +joblib==1.2.0 jstyleson==0.0.2 kiwisolver==1.4.4 lxml==4.9.1 Mako==1.2.2 MarkupSafe==2.1.1 -marshmallow==3.17.1 -matplotlib==3.5.3 +marshmallow==3.18.0 +matplotlib==3.6.0 mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.20.0 @@ -52,7 +52,7 @@ py==1.11.0 pycodestyle==2.9.1 pycparser==2.21 pyflakes==2.5.0 -PyJWT==2.4.0 +PyJWT==2.5.0 pyparsing==3.0.9 pyRdfa3==3.5.3 pytest==7.1.3 From f04bcc59b2adec8de859fd4a41312bde5abe5d31 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 17 Sep 2022 17:51:31 +0200 Subject: [PATCH 154/496] Prepare beta 35 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 798b309e..0d029327 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 34 +BACKEND_VERSION = 35 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From baa4e3c8b34243e4ce0bf540cb3342f1a80e4865 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 19 Sep 2022 12:16:18 +0200 Subject: [PATCH 155/496] Prepare release 36 --- backend/app/config.py | 2 +- backend/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 0d029327..29a762d1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 35 +BACKEND_VERSION = 36 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) diff --git a/backend/requirements.txt b/backend/requirements.txt index e7ced3a9..7259060c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -39,7 +39,7 @@ marshmallow==3.18.0 matplotlib==3.6.0 mccabe==0.7.0 mf2py==1.1.2 -mlxtend==0.20.0 +mlxtend==0.21.0 mypy-extensions==0.4.3 numpy==1.23.3 packaging==21.3 From 32c4d7913c0f36ceb83fe381b16350dea15575de Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 20 Sep 2022 13:12:16 +0200 Subject: [PATCH 156/496] fix: recalculate balances as non admin --- backend/app/controller/expense/expense_controller.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index f3b99ccd..d2d50ff5 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -114,7 +114,7 @@ def updateExpense(args, id): # noqa: C901 con.expense = expense con.user = user con.save() - calculateBalances() + recalculateBalances() return jsonify(expense.obj_to_dict()) @@ -122,7 +122,7 @@ def updateExpense(args, id): # noqa: C901 @jwt_required() def deleteExpenseById(id): Expense.delete_by_id(id) - calculateBalances() + recalculateBalances() return jsonify({'msg': 'DONE'}) @@ -130,6 +130,9 @@ def deleteExpenseById(id): @jwt_required() @admin_required def calculateBalances(): + recalculateBalances() + +def recalculateBalances(): for user in User.all(): user.expense_balance = float(Expense.query.with_entities(func.sum( Expense.amount).label("balance")).filter(Expense.paid_by == user).first().balance or 0) From c6deb5a61c51289e5031400a1d208255010d047e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 20 Sep 2022 14:49:15 +0200 Subject: [PATCH 157/496] Prepare release 37 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 29a762d1..6da54b4d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 36 +BACKEND_VERSION = 37 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 6b1af64bb61fb3c12795f330fb90a2afe56ddfc9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 22 Sep 2022 15:09:11 +0200 Subject: [PATCH 158/496] feat: improved setup --- .../onboarding/onboarding_controller.py | 2 +- backend/app/service/export_import.py | 51 ++++++++---- backend/templates/de.json | 83 ++++++++++++++++++- 3 files changed, 120 insertions(+), 16 deletions(-) diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py index 3ddb71b4..830c2162 100644 --- a/backend/app/controller/onboarding/onboarding_controller.py +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -27,7 +27,7 @@ def onboard(args): settings.expenses_feature = args['expenses_feature'] settings.save() if 'language' in args: - importFromLanguage(args['language']) + importFromLanguage(args['language'], bulkSave=True) username = args['username'].lower() user = User.create(username, args['password'], args['name'], owner=True) diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index 2d312932..cbcce350 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -1,5 +1,5 @@ import time -from app.config import app, APP_DIR, SUPPORTED_LANGUAGES +from app.config import app, APP_DIR, SUPPORTED_LANGUAGES, db from os.path import exists import json @@ -7,30 +7,35 @@ from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags, Category -def importFromLanguage(lang): +def importFromLanguage(lang, bulkSave=False): file_path = f'{APP_DIR}/../templates/{lang}.json' if lang not in SUPPORTED_LANGUAGES or not exists(file_path): raise NotFoundRequest('Language code not supported') with open(file_path, 'r') as f: data = json.load(f) - importFromDict(data, True) + importFromDict(data, True, bulkSave=bulkSave) -def importFromDict(args, default=False): # noqa +def importFromDict(args, default=False, bulkSave=False): # noqa t0 = time.time() + models = [] if "items" in args: for importItem in args['items']: - if not Item.find_by_name(importItem['name']): + item = Item.find_by_name(importItem['name']) + if not item: item = Item() item.name = importItem['name'] item.default = default - if "category" in importItem: - category = Category.find_by_name(importItem['category']) - if not category: - category = Category.create_by_name( - importItem['category'], default) - item.category = category + if "category" in importItem and not item.category_id: + category = Category.find_by_name(importItem['category']) + if not category: + category = Category.create_by_name( + importItem['category'], default) + item.category = category + if not bulkSave: item.save() + else: + models.append(item) if "recipes" in args: for importRecipe in args['recipes']: recipeNameCount = 0 @@ -42,7 +47,11 @@ def importFromDict(args, default=False): # noqa recipe.name = importRecipe['name'] + \ (f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "") recipe.description = importRecipe['description'] - recipe.save() + if not bulkSave: + recipe.save() + else: + models.append(recipe) + if 'items' in importRecipe: for recipeItem in importRecipe['items']: item = Item.find_by_name(recipeItem['name']) @@ -54,7 +63,10 @@ def importFromDict(args, default=False): # noqa ) con.item = item con.recipe = recipe - con.save() + if not bulkSave: + con.save() + else: + models.append(con) if 'tags' in args: for tagName in args['tags']: tag = Tag.find_by_name(tagName) @@ -63,5 +75,16 @@ def importFromDict(args, default=False): # noqa con = RecipeTags() con.tag = tag con.recipe = recipe - con.save() + if not bulkSave: + con.save() + else: + models.append(con) + + if bulkSave: + try: + db.session.add_all(models) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e app.logger.info(f"Import took: {(time.time() - t0):.3f}s") diff --git a/backend/templates/de.json b/backend/templates/de.json index 54a8b0e4..9c3d11ea 100644 --- a/backend/templates/de.json +++ b/backend/templates/de.json @@ -15,6 +15,7 @@ "name": "Apfel" }, { + "category": "🥫 Konserven", "name": "Apfelmus" }, { @@ -24,6 +25,7 @@ "name": "Aspirin" }, { + "category": "🥬 Obst & Gemüse", "name": "Aubergine" }, { @@ -59,6 +61,7 @@ "name": "Bananen" }, { + "category": "🥟 Teigwaren", "name": "Bandnudeln" }, { @@ -81,6 +84,7 @@ "name": "Birnen" }, { + "category": "🥬 Obst & Gemüse", "name": "Blattspinat" }, { @@ -92,6 +96,9 @@ { "name": "Blätterteig" }, + { + "name": "Bohnen" + }, { "category": "🥬 Obst & Gemüse", "name": "Brokkoli" @@ -166,6 +173,15 @@ { "name": "Currypaste" }, + { + "name": "Currypulver" + }, + { + "name": "Currysoße" + }, + { + "name": "Datteln" + }, { "category": "🚽 Hygiene", "name": "Deodorant" @@ -186,6 +202,10 @@ "category": "🥛 Milchprodukte", "name": "Eier" }, + { + "category": "❄️ Tk", + "name": "Eis" + }, { "category": "🥬 Obst & Gemüse", "name": "Eisbergsalat" @@ -197,8 +217,12 @@ "name": "Eiswürfel " }, { + "category": "❄️ Tk", "name": "Erbsen" }, + { + "name": "Erdbeeren" + }, { "name": "Erdnuss-Butter" }, @@ -222,6 +246,7 @@ "name": "Fanta" }, { + "category": "🥛 Milchprodukte", "name": "Feta" }, { @@ -230,6 +255,9 @@ { "name": "Fleischersatzprodukt" }, + { + "name": "Frischhaltefolie" + }, { "name": "Frischkäse" }, @@ -245,6 +273,7 @@ "name": "Garam Masala" }, { + "category": "🥫 Konserven", "name": "Gehackte Tomaten" }, { @@ -265,6 +294,7 @@ "name": "Geschenkpapier" }, { + "category": "🥫 Konserven", "name": "Getrocknete Tomaten" }, { @@ -322,6 +352,10 @@ "category": "🥛 Milchprodukte", "name": "Hafermilch" }, + { + "category": "🚽 Hygiene", + "name": "Handseife" + }, { "name": "Haribo" }, @@ -343,10 +377,14 @@ { "name": "Honig" }, + { + "name": "Hummus" + }, { "name": "Hustenbonbons" }, { + "category": "🥬 Obst & Gemüse", "name": "Ingwer" }, { @@ -373,9 +411,11 @@ "name": "Ketchup" }, { + "category": "🥫 Konserven", "name": "Kichererbsen" }, { + "category": "🥫 Konserven", "name": "Kidneybohnen" }, { @@ -404,6 +444,7 @@ "name": "Kohlrabi" }, { + "category": "🥫 Konserven", "name": "Kokosnuss-Milch" }, { @@ -416,6 +457,7 @@ "name": "Konfitüre" }, { + "category": "❄️ Tk", "name": "Koriander" }, { @@ -425,6 +467,7 @@ "name": "Kräuterbaguettes" }, { + "category": "🥛 Milchprodukte", "name": "Kräuterfrischkäse" }, { @@ -437,6 +480,7 @@ "name": "Käse" }, { + "category": "🥬 Obst & Gemüse", "name": "Kürbis" }, { @@ -447,6 +491,7 @@ "name": "Lachgummi" }, { + "category": "🥟 Teigwaren", "name": "Lasagnenudeln" }, { @@ -502,6 +547,9 @@ { "name": "Mehl" }, + { + "name": "Melone" + }, { "name": "Mikrofasertuch" }, @@ -523,6 +571,7 @@ "name": "Mundspülung" }, { + "category": "🌶️ Gewürze", "name": "Muskatnuss" }, { @@ -533,6 +582,7 @@ "name": "Müllsäcke" }, { + "category": "🍞 Brotwaren", "name": "Müsli" }, { @@ -563,6 +613,7 @@ "name": "Orangen" }, { + "category": "🍹 Getränke", "name": "Orangensaft" }, { @@ -586,6 +637,7 @@ "name": "Parmesan" }, { + "category": "🥫 Konserven", "name": "Passierte Tomaten" }, { @@ -602,6 +654,7 @@ "name": "Pfirsich" }, { + "category": "💧 Kühltheke", "name": "Pflanzenmagarine" }, { @@ -611,6 +664,7 @@ "name": "Pflaster" }, { + "category": "🥬 Obst & Gemüse", "name": "Pilze" }, { @@ -633,6 +687,7 @@ "name": "Radicchio" }, { + "category": "🥬 Obst & Gemüse", "name": "Radieschen" }, { @@ -649,6 +704,7 @@ "name": "Red Bull" }, { + "category": "🍚 Reisprodukte", "name": "Reis" }, { @@ -667,6 +723,7 @@ "name": "Ricotta" }, { + "category": "🍚 Reisprodukte", "name": "Risotto Reis" }, { @@ -679,6 +736,7 @@ "name": "Rosmarin" }, { + "category": "🥬 Obst & Gemüse", "name": "Rote Beete" }, { @@ -702,6 +760,7 @@ "name": "Rucola" }, { + "category": "💧 Kühltheke", "name": "Räuchertofu" }, { @@ -721,6 +780,7 @@ "name": "Salatkerne Mix" }, { + "category": "🥬 Obst & Gemüse", "name": "Salatkopf" }, { @@ -730,7 +790,7 @@ "name": "Salz" }, { - "name": "Sambal Olek" + "name": "Sambal Oelek" }, { "category": "🥬 Obst & Gemüse", @@ -762,12 +822,14 @@ "name": "Schokolade" }, { + "category": "💧 Kühltheke", "name": "Schupfnudeln" }, { "name": "Schwammlappen" }, { + "category": "🥫 Konserven", "name": "Schwarze Bohnen" }, { @@ -815,6 +877,10 @@ { "name": "Sojahack" }, + { + "category": "🥛 Milchprodukte", + "name": "Sojamilch" + }, { "name": "Sojasauce" }, @@ -850,6 +916,7 @@ "name": "Spülmaschinensalz" }, { + "category": "🚽 Hygiene", "name": "Spültabs" }, { @@ -873,6 +940,7 @@ "name": "Süßkartoffel" }, { + "category": "🌶️ Gewürze", "name": "Tafelsalz" }, { @@ -882,6 +950,7 @@ "name": "Taschentuchbox" }, { + "category": "🚽 Hygiene", "name": "Taschentücher" }, { @@ -898,6 +967,7 @@ "name": "Toast" }, { + "category": "💧 Kühltheke", "name": "Tofu" }, { @@ -909,6 +979,7 @@ "name": "Tomaten" }, { + "category": "🥫 Konserven", "name": "Tomatenmark" }, { @@ -934,6 +1005,10 @@ "name": "WC-Reiniger" }, { + "name": "Walnusskerne" + }, + { + "category": "🚽 Hygiene", "name": "Waschpulver" }, { @@ -942,6 +1017,9 @@ { "name": "Wassereis" }, + { + "name": "Weizengluten" + }, { "name": "Weißwein" }, @@ -961,6 +1039,9 @@ { "name": "Würstchen" }, + { + "name": "Yum Yum" + }, { "category": "🚽 Hygiene", "name": "Zahnpasta" From c17e0211a335a45b8be36332a3eca25dfbabedf4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 22 Sep 2022 15:38:17 +0200 Subject: [PATCH 159/496] Prepare release 38 --- backend/app/config.py | 2 +- backend/requirements.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6da54b4d..337aea6d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 37 +BACKEND_VERSION = 38 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7259060c..fea662a4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,7 +20,7 @@ Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -fonttools==4.37.2 +fonttools==4.37.3 greenlet==1.1.3 html-text==0.5.2 html5lib==1.1 @@ -43,7 +43,7 @@ mlxtend==0.21.0 mypy-extensions==0.4.3 numpy==1.23.3 packaging==21.3 -pandas==1.4.4 +pandas==1.5.0 pathspec==0.10.1 Pillow==9.2.0 platformdirs==2.5.2 From 44faf783b67b2655e3efb48b2904aec5946f5165 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 4 Oct 2022 10:27:52 +0200 Subject: [PATCH 160/496] chore: upgrade requirements --- backend/requirements.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index fea662a4..17f5a080 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,7 +6,7 @@ autopep8==1.7.0 bcrypt==4.0.0 beautifulsoup4==4.11.1 black==21.12b0 -certifi==2022.9.14 +certifi==2022.9.24 cffi==1.15.1 charset-normalizer==2.1.1 click==8.1.3 @@ -20,7 +20,7 @@ Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 Flask-Migrate==3.1.0 Flask-SQLAlchemy==2.5.1 -fonttools==4.37.3 +fonttools==4.37.4 greenlet==1.1.3 html-text==0.5.2 html5lib==1.1 @@ -33,7 +33,7 @@ joblib==1.2.0 jstyleson==0.0.2 kiwisolver==1.4.4 lxml==4.9.1 -Mako==1.2.2 +Mako==1.2.3 MarkupSafe==2.1.1 marshmallow==3.18.0 matplotlib==3.6.0 @@ -58,11 +58,11 @@ pyRdfa3==3.5.3 pytest==7.1.3 python-dateutil==2.8.2 python-editor==1.0.4 -pytz==2022.2.1 +pytz==2022.4 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.14.0 +recipe-scrapers==14.14.1 regex==2022.9.13 requests==2.28.1 scikit-learn==1.1.2 @@ -76,7 +76,7 @@ toml==0.10.2 tomli==1.2.3 typed-ast==1.5.4 typing_extensions==4.3.0 -tzdata==2022.2 +tzdata==2022.4 tzlocal==4.2 urllib3==1.26.12 uWSGI==2.0.20 From a4ffbb976aa08899eac316154f470062a1d70943 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 13 Oct 2022 13:51:54 +0200 Subject: [PATCH 161/496] feat: more docker platform support --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fff2ba90..90be37a2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,7 +66,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm64,linux/riscv64,linux/386,linux/arm/v7,linux/arm/v6 push: true tags: ${{ steps.dockertag.outputs.tags }} cache-from: type=local,src=/tmp/.buildx-cache From 7c4f77279ad64f2b60f0b23b5f6f5fba273e289c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 13 Oct 2022 13:53:39 +0200 Subject: [PATCH 162/496] fix: remove riscv --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 90be37a2..bd760776 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,7 +66,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/riscv64,linux/386,linux/arm/v7,linux/arm/v6 + platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/arm/v6 push: true tags: ${{ steps.dockertag.outputs.tags }} cache-from: type=local,src=/tmp/.buildx-cache From 4596b50a47509c34c08d7835969015874d894298 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 13 Oct 2022 14:10:19 +0200 Subject: [PATCH 163/496] chore: upgrade requirements --- backend/requirements.txt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 17f5a080..620cbec5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ appdirs==1.4.4 APScheduler==3.9.1 attrs==22.1.0 autopep8==1.7.0 -bcrypt==4.0.0 +bcrypt==4.0.1 beautifulsoup4==4.11.1 black==21.12b0 certifi==2022.9.24 @@ -19,7 +19,7 @@ Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 Flask-Migrate==3.1.0 -Flask-SQLAlchemy==2.5.1 +Flask-SQLAlchemy==3.0.1 fonttools==4.37.4 greenlet==1.1.3 html-text==0.5.2 @@ -36,12 +36,12 @@ lxml==4.9.1 Mako==1.2.3 MarkupSafe==2.1.1 marshmallow==3.18.0 -matplotlib==3.6.0 +matplotlib==3.6.1 mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.21.0 mypy-extensions==0.4.3 -numpy==1.23.3 +numpy==1.23.4 packaging==21.3 pandas==1.5.0 pathspec==0.10.1 @@ -62,11 +62,11 @@ pytz==2022.4 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.14.1 +recipe-scrapers==14.17.0 regex==2022.9.13 requests==2.28.1 scikit-learn==1.1.2 -scipy==1.9.1 +scipy==1.9.2 setuptools-scm==7.0.5 six==1.16.0 soupsieve==2.3.2 @@ -75,8 +75,8 @@ threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 typed-ast==1.5.4 -typing_extensions==4.3.0 -tzdata==2022.4 +typing_extensions==4.4.0 +tzdata==2022.5 tzlocal==4.2 urllib3==1.26.12 uWSGI==2.0.20 From 34e47cecf36636bc824f1965dace465fe768c594 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 13 Oct 2022 14:27:16 +0200 Subject: [PATCH 164/496] fix: arm build --- backend/Dockerfile | 2 +- backend/requirements.txt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 38b24b47..c2030eb9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10-slim RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev + gcc g++ libffi-dev libxml2-dev libxslt-dev ## Setup KitchenOwl COPY requirements.txt wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ diff --git a/backend/requirements.txt b/backend/requirements.txt index 620cbec5..83fa1c6e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,6 +10,7 @@ certifi==2022.9.24 cffi==1.15.1 charset-normalizer==2.1.1 click==8.1.3 +contourpy==1.0.5 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.13.0 @@ -75,6 +76,9 @@ threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 typed-ast==1.5.4 +types-beautifulsoup4==4.11.6 +types-requests==2.28.11.1 +types-urllib3==1.26.25 typing_extensions==4.4.0 tzdata==2022.5 tzlocal==4.2 From 6a0624afe7e8eec598e3988f68d06f55995cf1be Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 16 Oct 2022 16:58:57 +0200 Subject: [PATCH 165/496] fix: 32bit docker build --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index c2030eb9..bbe9b740 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10-slim RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libxml2-dev libxslt-dev + gcc g++ libffi-dev libxml2-dev libxslt-dev python3-scipy ## Setup KitchenOwl COPY requirements.txt wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ From bd1faa01645eed6f143f189b71a3e15ec7acf31c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 16 Oct 2022 17:25:29 +0200 Subject: [PATCH 166/496] fix: docker build --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index bbe9b740..7e7bb15f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10-slim RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libxml2-dev libxslt-dev python3-scipy + gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran ## Setup KitchenOwl COPY requirements.txt wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ From 967a4db9df554e40e9651bed93f4130a0d5de40f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 16 Oct 2022 17:56:27 +0200 Subject: [PATCH 167/496] fix: 32bit docker build dependencies --- backend/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 7e7bb15f..23e6a7b6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10-slim RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran + gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libopenblas-dev ## Setup KitchenOwl COPY requirements.txt wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ @@ -21,7 +21,7 @@ RUN pip3 install -r requirements.txt && rm requirements.txt RUN chmod u+x ./entrypoint.sh # Cleanup -RUN apt-get autoremove --yes gcc g++ libffi-dev \ +RUN apt-get autoremove --yes gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libopenblas-dev \ && rm -rf /var/lib/apt/lists/* CMD ["wsgi.ini"] From 725872b89d58fa8dfb90cf0a6eb929ece3220c8b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 16 Oct 2022 18:04:38 +0200 Subject: [PATCH 168/496] fix: dockerfile --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 23e6a7b6..20e22d24 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.10-slim RUN apt-get update \ - && apt-get install --yes --no-install-recommends \ + && apt-get install --yes --ignore-missing --no-install-recommends \ gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libopenblas-dev ## Setup KitchenOwl From 58740e2c92bbc418ba951d4ecdc5ac100402e1f8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 17 Oct 2022 23:32:51 +0200 Subject: [PATCH 169/496] fix: dockerfile --- backend/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 20e22d24..322844d8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,8 +1,8 @@ FROM python:3.10-slim RUN apt-get update \ - && apt-get install --yes --ignore-missing --no-install-recommends \ - gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libopenblas-dev + && apt-get install --yes --no-install-recommends \ + gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libblas-dev ## Setup KitchenOwl COPY requirements.txt wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ @@ -21,7 +21,7 @@ RUN pip3 install -r requirements.txt && rm requirements.txt RUN chmod u+x ./entrypoint.sh # Cleanup -RUN apt-get autoremove --yes gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libopenblas-dev \ +RUN apt-get autoremove --yes gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libblas-dev \ && rm -rf /var/lib/apt/lists/* CMD ["wsgi.ini"] From 33df6c8a03d12b20b9098c515e978e790c3479e5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 00:15:14 +0200 Subject: [PATCH 170/496] fix: dockerfile --- backend/Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 322844d8..8b24f10c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10-slim RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libblas-dev + gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libblas-dev pkg-config cmake ## Setup KitchenOwl COPY requirements.txt wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ @@ -21,7 +21,8 @@ RUN pip3 install -r requirements.txt && rm requirements.txt RUN chmod u+x ./entrypoint.sh # Cleanup -RUN apt-get autoremove --yes gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libblas-dev \ +RUN apt-get autoremove --yes \ + gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libblas-dev pkg-config cmake \ && rm -rf /var/lib/apt/lists/* CMD ["wsgi.ini"] From a5ed1f2dddbd2827a60ddd6748e6b6f87efed231 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 00:45:34 +0200 Subject: [PATCH 171/496] fix: dockerfile --- backend/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 8b24f10c..7c5b035f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.10-slim RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libblas-dev pkg-config cmake + gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libopenblas-dev pkg-config cmake ## Setup KitchenOwl COPY requirements.txt wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ @@ -22,7 +22,7 @@ RUN chmod u+x ./entrypoint.sh # Cleanup RUN apt-get autoremove --yes \ - gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libblas-dev pkg-config cmake \ + gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libopenblas-dev pkg-config cmake \ && rm -rf /var/lib/apt/lists/* CMD ["wsgi.ini"] From 12abb26ec4cbe06c78e3b6df197a28ec178e6845 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 00:52:51 +0200 Subject: [PATCH 172/496] remove arm/v6 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd760776..098b4200 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,7 +66,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/arm/v6 + platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7 push: true tags: ${{ steps.dockertag.outputs.tags }} cache-from: type=local,src=/tmp/.buildx-cache From 1611c552e75a90aa174e855eabf85e3ba37871ea Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 16:06:53 +0200 Subject: [PATCH 173/496] fix: deprecated settings --- backend/app/jobs/jobs.py | 39 +++++++++++++++++++++------------------ backend/wsgi.ini | 16 ++++++++++------ 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index e6b72be8..713d9d78 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -6,29 +6,32 @@ from .cluster_shoppings import clusterShoppings -# for debugging: +# # for debugging: # @scheduler.task('interval', id='test', seconds=5) # def test(): -# app.logger.info("--- test analysis is starting ---") -# # recipe planner tasks -# meal_instances = findMealInstancesFromHistory() -# computeRecipeSuggestions(meal_instances) -# app.logger.info("--- test analysis is completed ---") +# with app.app_context(): +# app.logger.info("--- test analysis is starting ---") +# # recipe planner tasks +# meal_instances = findMealInstancesFromHistory() +# computeRecipeSuggestions(meal_instances) +# app.logger.info("--- test analysis is completed ---") @scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') def daily(): - app.logger.info("--- daily analysis is starting ---") - # shopping tasks - shopping_instances = clusterShoppings() - findItemOrdering(shopping_instances) - findItemSuggestions(shopping_instances) - # recipe planner tasks - meal_instances = findMealInstancesFromHistory() - computeRecipeSuggestions(meal_instances) - app.logger.info("--- daily analysis is completed ---") + with app.app_context(): + app.logger.info("--- daily analysis is starting ---") + # shopping tasks + shopping_instances = clusterShoppings() + findItemOrdering(shopping_instances) + findItemSuggestions(shopping_instances) + # recipe planner tasks + meal_instances = findMealInstancesFromHistory() + computeRecipeSuggestions(meal_instances) + app.logger.info("--- daily analysis is completed ---") @scheduler.task('interval', id='every30min', minutes=30) def halfHourly(): - # Remove expired Tokens - Token.delete_expired_access() - Token.delete_expired_refresh() + with app.app_context(): + # Remove expired Tokens + Token.delete_expired_access() + Token.delete_expired_refresh() diff --git a/backend/wsgi.ini b/backend/wsgi.ini index 5e8bd8d2..1adc7622 100644 --- a/backend/wsgi.ini +++ b/backend/wsgi.ini @@ -1,11 +1,15 @@ [uwsgi] +strict = true +master = true +enable-threads = true +vacuum = true +single-interpreter = true +die-on-term = true +need-app = true +chmod-socket = 664 + wsgi-file = wsgi.py callable = app http = 0.0.0.0:$(HTTP_PORT) socket = 0.0.0.0:5000 -processes = 1 -threads = 1 -master = true -chmod-socket = 664 -vacuum = true -die-on-term = true +processes = 2 From 3f9ed51e6e06f76856c23a77b358717535556b75 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 16:15:10 +0200 Subject: [PATCH 174/496] feat: decrease docker image size --- backend/Dockerfile | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 7c5b035f..b2962c15 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,11 +1,30 @@ -FROM python:3.10-slim +# ------------ +# BUILDER +# ------------ +FROM python:3.10-slim as builder RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libopenblas-dev pkg-config cmake + gcc g++ libffi-dev libpcre3-dev build-essential cargo -## Setup KitchenOwl -COPY requirements.txt wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ +# Create virtual enviroment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY requirements.txt . +RUN pip install -r requirements.txt + +# ------------ +# RUNNER +# ------------ +FROM python:3.10-slim as runner + +# Use virtual enviroment +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Setup KitchenOwl +COPY wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ COPY app /usr/src/kitchenowl/app COPY templates /usr/src/kitchenowl/templates COPY migrations /usr/src/kitchenowl/migrations @@ -17,13 +36,7 @@ ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' ENV DEBUG='False' ENV HTTP_PORT=80 -RUN pip3 install -r requirements.txt && rm requirements.txt RUN chmod u+x ./entrypoint.sh -# Cleanup -RUN apt-get autoremove --yes \ - gcc g++ libffi-dev libxml2-dev libxslt-dev gfortran libopenblas-dev pkg-config cmake \ - && rm -rf /var/lib/apt/lists/* - CMD ["wsgi.ini"] ENTRYPOINT ["./entrypoint.sh"] From 7a9cee6ae20c446b97262bee3ce944025ee38370 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 16:30:51 +0200 Subject: [PATCH 175/496] fix: build with wheel --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index b2962c15..5209cc1d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,7 +12,7 @@ RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . -RUN pip install -r requirements.txt +RUN pip install wheel && pip install -r requirements.txt # ------------ # RUNNER From d7ebffd1d7c3a98979a5b1faca202001404d95d5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 16:42:23 +0200 Subject: [PATCH 176/496] fix: build --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 5209cc1d..a4466326 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,7 +12,7 @@ RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . -RUN pip install wheel && pip install -r requirements.txt +RUN pip install -U pip wheel setuptools && pip install -r requirements.txt # ------------ # RUNNER From df47534ba44f844daabc61146f6cc9d8185727b9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 16:50:02 +0200 Subject: [PATCH 177/496] fix: CI --- .github/workflows/publish.yml | 2 +- backend/Dockerfile | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 098b4200..fff2ba90 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,7 +66,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7 + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.dockertag.outputs.tags }} cache-from: type=local,src=/tmp/.buildx-cache diff --git a/backend/Dockerfile b/backend/Dockerfile index a4466326..13f13fa0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,7 +12,8 @@ RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . -RUN pip install -U pip wheel setuptools && pip install -r requirements.txt +RUN pip3 install -U pip wheel setuptools +RUN pip3 install -r requirements.txt # ------------ # RUNNER From a3a91ef291f8289288d50561da6b02c8388b9a23 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 17:02:39 +0200 Subject: [PATCH 178/496] chore: upgrade CI actions --- .github/workflows/publish.yml | 24 ++++++++---------------- .github/workflows/pytest.yml | 9 +++++---- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fff2ba90..d9a0cf01 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,7 +21,7 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: decide docker tags id: dockertag @@ -39,38 +39,30 @@ jobs: REF: ${{ github.ref }} BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl - - name: Cache Docker layers - uses: actions/cache@v2 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - - name: Login to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Build and push id: docker_build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7 push: true tags: ${{ steps.dockertag.outputs.tags }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache + cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:dev + cache-to: type=inline - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 3a63ef49..5646a9e1 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -15,11 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.10 + cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip From 15bd470574f2233ae18d20cc6fb246b3e13677b0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 17:04:50 +0200 Subject: [PATCH 179/496] fix: testing CI --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 5646a9e1..294812f7 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.10 + python-version: '3.10' cache: 'pip' - name: Install dependencies run: | From 3184e5033735049960054eb3801cb2ef8dada742 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 17:13:04 +0200 Subject: [PATCH 180/496] fix: remove 32bit --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d9a0cf01..fa125c30 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,7 +58,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7 + platforms: linux/amd64,linux/arm64 #,linux/386,linux/arm/v7 push: true tags: ${{ steps.dockertag.outputs.tags }} cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:dev From addb918d5beae49be5012ff3c8da7ae176feac76 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 18:33:52 +0200 Subject: [PATCH 181/496] feat: more recipe times and yields --- .../controller/recipe/recipe_controller.py | 15 ++++++++ backend/app/controller/recipe/schemas.py | 10 ++++-- backend/app/models/recipe.py | 6 ++++ backend/app/service/export_import.py | 19 ++++++++-- backend/migrations/versions/fe3a5c9ac84c_.py | 36 +++++++++++++++++++ 5 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/versions/fe3a5c9ac84c_.py diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 55825f48..65427d35 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -34,6 +34,12 @@ def addRecipe(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 'photo' in args: @@ -76,6 +82,12 @@ def updateRecipe(args, id): # noqa: C901 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 'photo' in args: @@ -150,6 +162,9 @@ def scrapeRecipe(args): recipe = Recipe() recipe.name = scraper.title() recipe.time = int(scraper.total_time()) + recipe.cook_time = scraper.cook_time + recipe.prep_time = scraper.prep_time + recipe.yields = scraper.yields description = '' try: description = scraper.description() + "\n\n" diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index acf150af..f02225a2 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -21,7 +21,10 @@ class RecipeItem(Schema): description = fields.String( validate=lambda a: a is not None ) - time = fields.Integer() + 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() photo = fields.String() items = fields.List(fields.Nested(RecipeItem())) @@ -43,7 +46,10 @@ class RecipeItem(Schema): description = fields.String( validate=lambda a: a is not None ) - time = fields.Integer() + 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() photo = fields.String() items = fields.List(fields.Nested(RecipeItem())) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 73d9b1f7..ec5a2a2c 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -17,6 +17,9 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): planned = db.Column(db.Boolean) planned_days = db.Column(DbSetType(), default=set()) time = db.Column(db.Integer) + cook_time = db.Column(db.Integer) + prep_time = db.Column(db.Integer) + yields = db.Column(db.Integer) source = db.Column(db.String()) suggestion_score = db.Column(db.Integer, server_default='0') suggestion_rank = db.Column(db.Integer, server_default='0') @@ -56,6 +59,9 @@ def obj_to_export_dict(self) -> dict: "name": self.name, "description": self.description, "time": self.time, + "cook_time": self.cook_time, + "prep_time": self.prep_time, + "yields": self.yields, "source": self.source, "items": [{"name": e.item.name, "description": e.description, "optional": e.optional} for e in items], "tags": [e.tag.name for e in tags], diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index cbcce350..a080a580 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -16,7 +16,7 @@ def importFromLanguage(lang, bulkSave=False): importFromDict(data, True, bulkSave=bulkSave) -def importFromDict(args, default=False, bulkSave=False): # noqa +def importFromDict(args, default=False, bulkSave=False, override=False): # noqa t0 = time.time() models = [] if "items" in args: @@ -39,14 +39,27 @@ def importFromDict(args, default=False, bulkSave=False): # noqa if "recipes" in args: for importRecipe in args['recipes']: recipeNameCount = 0 - if Recipe.find_by_name(importRecipe['name']): + recipe = Recipe.find_by_name(importRecipe['name']) + if recipe and not override: recipeNameCount = 1 + \ Recipe.query.filter(Recipe.name.ilike( importRecipe['name'] + " (_%)")).count() - recipe = Recipe() + if not recipe: + recipe = Recipe() recipe.name = importRecipe['name'] + \ (f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "") recipe.description = importRecipe['description'] + if 'time' in importRecipe: + recipe.time = importRecipe['time'] + if 'cook_time' in importRecipe: + recipe.cook_time = importRecipe['cook_time'] + if 'prep_time' in importRecipe: + recipe.prep_time = importRecipe['prep_time'] + if 'yields' in importRecipe: + recipe.yields = importRecipe['yields'] + if 'source' in importRecipe: + recipe.source = importRecipe['source'] + if not bulkSave: recipe.save() else: diff --git a/backend/migrations/versions/fe3a5c9ac84c_.py b/backend/migrations/versions/fe3a5c9ac84c_.py new file mode 100644 index 00000000..ec2383bd --- /dev/null +++ b/backend/migrations/versions/fe3a5c9ac84c_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: fe3a5c9ac84c +Revises: ade6487fe28a +Create Date: 2022-10-18 17:26:44.087997 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fe3a5c9ac84c' +down_revision = 'ade6487fe28a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.add_column(sa.Column('cook_time', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('prep_time', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('yields', sa.Integer(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_column('yields') + batch_op.drop_column('prep_time') + batch_op.drop_column('cook_time') + + # ### end Alembic commands ### From e88d15f4c5315bd93d2e852b0cb95a2760749ef7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Oct 2022 18:34:31 +0200 Subject: [PATCH 182/496] Prepare beta 39 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 337aea6d..ada15202 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 38 +BACKEND_VERSION = 39 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 51ca899422ea6b59eb8dc1aad23e6773e45e43b1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 20 Oct 2022 23:01:25 +0200 Subject: [PATCH 183/496] chore: upgrade requirements --- backend/requirements.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 83fa1c6e..5a64678b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,7 +20,7 @@ Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 Flask-Migrate==3.1.0 -Flask-SQLAlchemy==3.0.1 +Flask-SQLAlchemy==3.0.2 fonttools==4.37.4 greenlet==1.1.3 html-text==0.5.2 @@ -44,7 +44,7 @@ mlxtend==0.21.0 mypy-extensions==0.4.3 numpy==1.23.4 packaging==21.3 -pandas==1.5.0 +pandas==1.5.1 pathspec==0.10.1 Pillow==9.2.0 platformdirs==2.5.2 @@ -53,32 +53,32 @@ py==1.11.0 pycodestyle==2.9.1 pycparser==2.21 pyflakes==2.5.0 -PyJWT==2.5.0 +PyJWT==2.6.0 pyparsing==3.0.9 pyRdfa3==3.5.3 pytest==7.1.3 python-dateutil==2.8.2 python-editor==1.0.4 -pytz==2022.4 +pytz==2022.5 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.17.0 +recipe-scrapers==14.20.0 regex==2022.9.13 requests==2.28.1 scikit-learn==1.1.2 -scipy==1.9.2 +scipy==1.9.3 setuptools-scm==7.0.5 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.41 +SQLAlchemy==1.4.42 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 typed-ast==1.5.4 types-beautifulsoup4==4.11.6 -types-requests==2.28.11.1 -types-urllib3==1.26.25 +types-requests==2.28.11.2 +types-urllib3==1.26.25.1 typing_extensions==4.4.0 tzdata==2022.5 tzlocal==4.2 From c08f0b5a7f5d13c1c64b3d04dcac8c3ef529fc8d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 21 Oct 2022 11:09:18 +0200 Subject: [PATCH 184/496] chore: update CI --- .github/workflows/publish.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fa125c30..8cd185c3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,12 +28,12 @@ jobs: run: | if [[ $REF == "refs/tags/v"* ]] then - echo "::set-output name=tags::$BASE_TAG:latest, $BASE_TAG:beta" + echo "tags=$BASE_TAG:latest, $BASE_TAG:beta" >> $GITHUB_ENV elif [[ $REF == "refs/tags/beta-v"* ]] then - echo "::set-output name=tags::$BASE_TAG:beta" + echo "tags=$BASE_TAG:beta" >> $GITHUB_ENV else - echo "::set-output name=tags::$BASE_TAG:dev" + echo "tags=$BASE_TAG:dev" >> $GITHUB_ENV fi env: REF: ${{ github.ref }} @@ -58,9 +58,9 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64 #,linux/386,linux/arm/v7 + platforms: linux/amd64,linux/arm64 #,linux/386,linux/arm/v7,linux/arm/v6 push: true - tags: ${{ steps.dockertag.outputs.tags }} + tags: ${{ env.tags }} cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:dev cache-to: type=inline From 1d0646251483206a955b93f078be646d82aa59ce Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 21 Oct 2022 11:11:49 +0200 Subject: [PATCH 185/496] Prepare beta 40 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index ada15202..cf8044cb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 39 +BACKEND_VERSION = 40 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 18a8586255b34cb7b12ad985aff6d76e47b9a3f7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 25 Oct 2022 10:40:17 +0200 Subject: [PATCH 186/496] feat: allow setting the expense image --- backend/app/controller/expense/expense_controller.py | 4 ++++ backend/app/controller/expense/schemas.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index d2d50ff5..c1b02c3c 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -40,6 +40,8 @@ def addExpense(args): expense = Expense() expense.name = args['name'] expense.amount = args['amount'] + if 'photo' in args: + expense.photo = args['photo'] if 'category' in args: if not args['category']: expense.category = None @@ -82,6 +84,8 @@ def updateExpense(args, id): # noqa: C901 expense.name = args['name'] if 'amount' in args: expense.amount = args['amount'] + if 'photo' in args: + expense.photo = args['photo'] if 'category' in args: if not args['category']: expense.category = None diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 343ea0fe..9cb8f1cc 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -20,6 +20,7 @@ class User(Schema): amount = fields.Float( required=True ) + photo = fields.String() category = fields.String( validate=lambda a: not a or ( a and not a.isspace()), allow_none=True @@ -44,6 +45,7 @@ class User(Schema): name = fields.String() amount = fields.Float() + photo = fields.String() category = fields.String( validate=lambda a: not a or ( a and not a.isspace()), From d09dc4a61fc0731b69eec3343e1534f30121e2fd Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 25 Oct 2022 14:41:25 +0200 Subject: [PATCH 187/496] fix: CI update docker cache --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8cd185c3..cce2a828 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -61,8 +61,8 @@ jobs: platforms: linux/amd64,linux/arm64 #,linux/386,linux/arm/v7,linux/arm/v6 push: true tags: ${{ env.tags }} - cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:dev - cache-to: type=inline + cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache + cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache,mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} From 4b13b4cb4bdd5845da84f669b458f3c9927cc6c8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 25 Oct 2022 15:05:22 +0200 Subject: [PATCH 188/496] fix: remove docker caching --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cce2a828..ab42f9c8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -61,7 +61,7 @@ jobs: platforms: linux/amd64,linux/arm64 #,linux/386,linux/arm/v7,linux/arm/v6 push: true tags: ${{ env.tags }} - cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache + # cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache,mode=max - name: Image digest From e6dd613c94885905b7f4b25ee0dddab40774abe4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 25 Oct 2022 15:26:35 +0200 Subject: [PATCH 189/496] fix: remove docker caching --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ab42f9c8..0b83f373 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,7 +62,7 @@ jobs: push: true tags: ${{ env.tags }} # cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache - cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache,mode=max + # cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache,mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} From 065f351fb9ed683dbeecdaaf2fceebec5c15d46b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 26 Oct 2022 16:41:02 +0200 Subject: [PATCH 190/496] chore: upgrade requirements --- backend/requirements.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5a64678b..29fd7e13 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,7 +13,7 @@ click==8.1.3 contourpy==1.0.5 cycler==0.11.0 dbscan1d==0.1.6 -extruct==0.13.0 +extruct==0.14.0 flake8==5.0.4 Flask==2.2.2 Flask-APScheduler==1.12.4 @@ -21,7 +21,7 @@ Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 Flask-Migrate==3.1.0 Flask-SQLAlchemy==3.0.2 -fonttools==4.37.4 +fonttools==4.38.0 greenlet==1.1.3 html-text==0.5.2 html5lib==1.1 @@ -56,17 +56,17 @@ pyflakes==2.5.0 PyJWT==2.6.0 pyparsing==3.0.9 pyRdfa3==3.5.3 -pytest==7.1.3 +pytest==7.2.0 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2022.5 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.20.0 +recipe-scrapers==14.21.0 regex==2022.9.13 requests==2.28.1 -scikit-learn==1.1.2 +scikit-learn==1.1.3 scipy==1.9.3 setuptools-scm==7.0.5 six==1.16.0 @@ -83,7 +83,7 @@ typing_extensions==4.4.0 tzdata==2022.5 tzlocal==4.2 urllib3==1.26.12 -uWSGI==2.0.20 +uWSGI==2.0.21 w3lib==2.0.1 webencodings==0.5.1 Werkzeug==2.2.2 From b73614a4fbeed3a728a41cec264ff6c9762c4804 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 26 Oct 2022 17:10:20 +0200 Subject: [PATCH 191/496] fix: update docker file --- backend/Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 13f13fa0..042e4e79 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,12 +8,11 @@ RUN apt-get update \ gcc g++ libffi-dev libpcre3-dev build-essential cargo # Create virtual enviroment -RUN python -m venv /opt/venv +RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . -RUN pip3 install -U pip wheel setuptools -RUN pip3 install -r requirements.txt +RUN pip3 install --no-cache-dir -r requirements.txt && find /opt/venv ( -type d -a -name test -o -name tests \) -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ # ------------ # RUNNER From 4405595889f9a305ebcb7baf348db42be5d73a0d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 26 Oct 2022 22:06:28 +0200 Subject: [PATCH 192/496] fix: Dockerfile --- backend/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 042e4e79..d12964f0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,11 +8,11 @@ RUN apt-get update \ gcc g++ libffi-dev libpcre3-dev build-essential cargo # Create virtual enviroment -RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools +RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . -RUN pip3 install --no-cache-dir -r requirements.txt && find /opt/venv ( -type d -a -name test -o -name tests \) -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ +RUN pip3 install --no-cache-dir -r requirements.txt && find /opt/venv \( -type d -a -name test -o -name tests \) -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ # ------------ # RUNNER From c311a896a526f5d8bcb0bdc73d40b8d4b11d8568 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Oct 2022 00:15:42 +0200 Subject: [PATCH 193/496] feat: Log IP on failed auth events (TomBursch/kitchenowl-backend#10) --- backend/app/controller/auth/auth_controller.py | 14 +++++++------- backend/app/controller/user/user_controller.py | 6 +++--- backend/app/helpers/admin_required.py | 5 +++-- backend/app/models/token.py | 4 +++- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 8ba5f254..a841dd70 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -1,6 +1,6 @@ from datetime import datetime from app.helpers import validate_args -from flask import jsonify, Blueprint +from flask import jsonify, Blueprint, request from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from app.models import User, Token from app.errors import UnauthorizedRequest @@ -45,7 +45,7 @@ def login(args): username = args['username'].lower() user = User.find_by_username(username) if not user or not user.check_password(args['password']): - raise UnauthorizedRequest(message='Unauthorized') + raise UnauthorizedRequest(message='Unauthorized: IP {} login attemp with wrong username or password'.format(request.remote_addr)) device = "Unkown" if "device" in args: device = args['device'] @@ -78,7 +78,7 @@ def login(args): def refresh(): user = User.find_by_username(get_jwt_identity()) if not user: - raise UnauthorizedRequest(message='Unauthorized') + raise UnauthorizedRequest(message='Unauthorized: IP {} refresh attemp with wrong username or password'.format(request.remote_addr)) refreshModel = Token.find_by_jti(get_jwt()['jti']) # Refresh token rotation @@ -99,7 +99,7 @@ def logout(): jwt = get_jwt() token = Token.find_by_jti(jwt['jti']) if not token: - raise UnauthorizedRequest(message='Unauthorized') + raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) if token.type == 'access': token.refresh_token.delete() @@ -115,7 +115,7 @@ def logout(): def createLongLivedToken(args): user = User.find_by_username(get_jwt_identity()) if not user: - raise UnauthorizedRequest(message='Unauthorized') + raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) llToken, _ = Token.create_longlived_token(user, args['device']) @@ -129,11 +129,11 @@ def createLongLivedToken(args): def deleteLongLivedToken(id): user = User.find_by_username(get_jwt_identity()) if not user: - raise UnauthorizedRequest(message='Unauthorized') + raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) token = Token.find_by_id(id) if (token.user_id != user.id or token.type != 'llt'): - raise UnauthorizedRequest(message='Unauthorized') + raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) token.delete() diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 414dd8c4..6e10bd5d 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -1,7 +1,7 @@ from app.errors import NotFoundRequest, UnauthorizedRequest from app.helpers.admin_required import admin_required from app.helpers import validate_args -from flask import jsonify, Blueprint +from flask import jsonify, Blueprint, request from flask_jwt_extended import jwt_required, get_jwt_identity from app.models import User from .schemas import CreateUser, UpdateUser @@ -21,7 +21,7 @@ def getAllUsers(): def getLoggedInUser(): user = User.find_by_username(get_jwt_identity()) if not user: - raise UnauthorizedRequest(message='Unauthorized') + raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) return jsonify(user.obj_to_full_dict()) @@ -42,7 +42,7 @@ def deleteUserById(id): user = User.find_by_id(id) if not user or user.owner: raise UnauthorizedRequest( - message='user_not_allowed' + message='Cannot delete this user' ) User.delete_by_id(id) return jsonify({'msg': 'DONE'}) diff --git a/backend/app/helpers/admin_required.py b/backend/app/helpers/admin_required.py index 8263431a..6f97f9f4 100644 --- a/backend/app/helpers/admin_required.py +++ b/backend/app/helpers/admin_required.py @@ -1,3 +1,4 @@ +from flask import request from app.models import User from functools import wraps from flask_jwt_extended import get_jwt_identity @@ -10,7 +11,7 @@ def func_wrapper(*args, **kwargs): user = User.find_by_username(get_jwt_identity()) if not user or not (user.owner or user.admin): raise UnauthorizedRequest( - message='Elevated rights required' + message='Elevated rights required. IP {}'.format(request.remote_addr) ) return func(*args, **kwargs) @@ -23,7 +24,7 @@ def func_wrapper(*args, **kwargs): user = User.find_by_username(get_jwt_identity()) if not user or not user.owner: raise UnauthorizedRequest( - message='Elevated rights required' + message='Elevated rights required. IP {}'.format(request.remote_addr) ) return func(*args, **kwargs) diff --git a/backend/app/models/token.py b/backend/app/models/token.py index 2c7721cf..4a344f2d 100644 --- a/backend/app/models/token.py +++ b/backend/app/models/token.py @@ -1,6 +1,8 @@ from __future__ import annotations from datetime import datetime from typing import Tuple + +from flask import request from app import db from app.config import JWT_REFRESH_TOKEN_EXPIRES, JWT_ACCESS_TOKEN_EXPIRES from app.errors import UnauthorizedRequest @@ -89,7 +91,7 @@ def create_refresh_token(cls, user: User, device: str = None, oldRefreshToken: T assert device or oldRefreshToken if (oldRefreshToken and (oldRefreshToken.type != 'refresh' or oldRefreshToken.has_created_refresh_token())): oldRefreshToken.delete_token_familiy() - raise UnauthorizedRequest() + raise UnauthorizedRequest(message='Unauthorized: IP {} reused the same refresh token, loging out user'.format(request.remote_addr)) refreshToken = create_refresh_token(identity=user) model = Token() From 75c785fbb31f7fc03dd71a06bde4d7e096c6ae08 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Oct 2022 01:11:35 +0200 Subject: [PATCH 194/496] docs: update docker-compose --- backend/docker-compose.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index eabb49e7..c402dd8f 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -2,9 +2,8 @@ version: "3" services: front: image: tombursch/kitchenowl-web:latest - environment: - - FRONT_URL=http://localhost - # - BACK_URL=back:5000 # Optional should not be changed unless you know what youre doing + # environment: + # - BACK_URL=back:5000 # Optional should not be changed unless you know what youre doing ports: - "80:80" depends_on: @@ -14,13 +13,14 @@ services: back: image: tombursch/kitchenowl:latest restart: unless-stopped - # ports: # Optional and only needed when only hosting the http backend - # - "80:80" + # ports: # Optional + # - "80:80" # http protocol + # - "5000:5000" # uwsgi protocol networks: - default environment: - JWT_SECRET_KEY=PLEASE_CHANGE_ME - - FRONT_URL=http://localhost + # - FRONT_URL=http://localhost # Optional should not be changed unless you know what youre doing volumes: - kitchenowl_data:/data From 3fd2ac1377cfdf7c296c484ccc5b120c8f53ce50 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Oct 2022 01:40:05 +0200 Subject: [PATCH 195/496] feat: enable arm/v7 --- .github/workflows/publish.yml | 2 +- backend/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0b83f373..c9f96626 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,7 +58,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64 #,linux/386,linux/arm/v7,linux/arm/v6 + platforms: linux/amd64,linux/arm64,linux/arm/v7 #,linux/386,linux/arm/v6 push: true tags: ${{ env.tags }} # cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache diff --git a/backend/Dockerfile b/backend/Dockerfile index d12964f0..ea8f95e8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,7 +5,7 @@ FROM python:3.10-slim as builder RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libpcre3-dev build-essential cargo + gcc g++ libffi-dev libpcre3-dev build-essential cargo libxml2-dev libxslt-dev cmake # Create virtual enviroment RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel From ab69fa3b66720079c5875bdc29ca3d3b85277704 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Oct 2022 10:19:56 +0200 Subject: [PATCH 196/496] fix: arm/v7 build --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index ea8f95e8..8f357707 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,7 +5,7 @@ FROM python:3.10-slim as builder RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libpcre3-dev build-essential cargo libxml2-dev libxslt-dev cmake + gcc g++ libffi-dev libpcre3-dev build-essential cargo libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev # Create virtual enviroment RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel From b56cc131cc84a6628a68e81d662ba140cd9ca51d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Oct 2022 13:29:58 +0200 Subject: [PATCH 197/496] fix: arm/v7 build --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 8f357707..b157128e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,7 +5,7 @@ FROM python:3.10-slim as builder RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libpcre3-dev build-essential cargo libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev + gcc g++ libffi-dev libpcre3-dev build-essential cargo libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config # Create virtual enviroment RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel From db0f0b902324d6937ee7681dd98c696d1a2add86 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Oct 2022 15:36:19 +0200 Subject: [PATCH 198/496] fix: arm/v7 build --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index b157128e..be3f998c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,7 +5,7 @@ FROM python:3.10-slim as builder RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libpcre3-dev build-essential cargo libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config + gcc g++ libffi-dev libpcre3-dev build-essential cargo libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build # Create virtual enviroment RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel From 794be6ade0f0678b4d028242fe00f123f302f61d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Oct 2022 21:06:08 +0200 Subject: [PATCH 199/496] fix: disable arm/v7 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c9f96626..719c5191 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,7 +58,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/arm/v7 #,linux/386,linux/arm/v6 + platforms: linux/amd64,linux/arm64 #,linux/arm/v7 #,linux/386,linux/arm/v6 push: true tags: ${{ env.tags }} # cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache From a61058edd2d9e62fda58658f49a4f8bb11fce314 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Oct 2022 21:07:35 +0200 Subject: [PATCH 200/496] Prepare release 41 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index cf8044cb..cf9c31db 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 40 +BACKEND_VERSION = 41 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From b189f825161a63fde2de208d5753bc14ab3701c3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 28 Oct 2022 10:57:46 +0200 Subject: [PATCH 201/496] fix: lib error --- backend/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index be3f998c..6016fb23 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,7 +5,8 @@ FROM python:3.10-slim as builder RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libpcre3-dev build-essential cargo libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build + gcc g++ libffi-dev libpcre3-dev build-essential cargo + # libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build # Create virtual enviroment RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel From 6a4dab267dbf934e3b2d3f2ceafb636c81c4f6cb Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 28 Oct 2022 10:58:23 +0200 Subject: [PATCH 202/496] Prepare release 32 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index cf9c31db..b6657927 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 41 +BACKEND_VERSION = 42 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From c74c62c774b26328b9dd3740ee5299f197ba550d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 6 Nov 2022 14:36:56 +0100 Subject: [PATCH 203/496] fix: recipe scraper (TomBursch/kitchenowl-backend#11) --- .../controller/recipe/recipe_controller.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 65427d35..27e7c2c4 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -161,10 +161,22 @@ def scrapeRecipe(args): scraper = scrape_me(args['url'], wild_mode=True) recipe = Recipe() recipe.name = scraper.title() - recipe.time = int(scraper.total_time()) - recipe.cook_time = scraper.cook_time - recipe.prep_time = scraper.prep_time - recipe.yields = scraper.yields + try: + recipe.time = int(scraper.total_time()) + except NotImplementedError: + pass + try: + recipe.cook_time = int(scraper.cook_time()) + except NotImplementedError: + pass + try: + recipe.prep_time = int(scraper.prep_time()) + except NotImplementedError: + pass + try: + recipe.yields = scraper.yields() + except NotImplementedError: + pass description = '' try: description = scraper.description() + "\n\n" From d35cc54df4f2caf0cccb234b4304d191926e2e5d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 6 Nov 2022 14:44:00 +0100 Subject: [PATCH 204/496] chore: upgrade requirements --- backend/requirements.txt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 29fd7e13..1e62e2b5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,7 +2,7 @@ alembic==1.8.1 appdirs==1.4.4 APScheduler==3.9.1 attrs==22.1.0 -autopep8==1.7.0 +autopep8==2.0.0 bcrypt==4.0.1 beautifulsoup4==4.11.1 black==21.12b0 @@ -10,7 +10,7 @@ certifi==2022.9.24 cffi==1.15.1 charset-normalizer==2.1.1 click==8.1.3 -contourpy==1.0.5 +contourpy==1.0.6 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.14.0 @@ -22,7 +22,7 @@ Flask-JWT-Extended==4.4.4 Flask-Migrate==3.1.0 Flask-SQLAlchemy==3.0.2 fonttools==4.38.0 -greenlet==1.1.3 +greenlet==2.0.0 html-text==0.5.2 html5lib==1.1 idna==3.4 @@ -37,7 +37,7 @@ lxml==4.9.1 Mako==1.2.3 MarkupSafe==2.1.1 marshmallow==3.18.0 -matplotlib==3.6.1 +matplotlib==3.6.2 mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.21.0 @@ -46,7 +46,7 @@ numpy==1.23.4 packaging==21.3 pandas==1.5.1 pathspec==0.10.1 -Pillow==9.2.0 +Pillow==9.3.0 platformdirs==2.5.2 pluggy==1.0.0 py==1.11.0 @@ -59,19 +59,19 @@ pyRdfa3==3.5.3 pytest==7.2.0 python-dateutil==2.8.2 python-editor==1.0.4 -pytz==2022.5 +pytz==2022.6 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.21.0 -regex==2022.9.13 +recipe-scrapers==14.23.0 +regex==2022.10.31 requests==2.28.1 scikit-learn==1.1.3 scipy==1.9.3 setuptools-scm==7.0.5 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.42 +SQLAlchemy==1.4.43 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 @@ -80,7 +80,7 @@ types-beautifulsoup4==4.11.6 types-requests==2.28.11.2 types-urllib3==1.26.25.1 typing_extensions==4.4.0 -tzdata==2022.5 +tzdata==2022.6 tzlocal==4.2 urllib3==1.26.12 uWSGI==2.0.21 From 1d87635933ee8c1e48d45e62e5842411adc06f1f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 6 Nov 2022 14:53:43 +0100 Subject: [PATCH 205/496] feat: recipe scrape accept post --- backend/app/controller/recipe/recipe_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 27e7c2c4..d1a57839 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -154,7 +154,7 @@ def getAllFiltered(args): return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(args["filter"])]) -@recipe.route('/scrape', methods=['GET']) +@recipe.route('/scrape', methods=['GET', 'POST']) @jwt_required() @validate_args(ScrapeRecipe) def scrapeRecipe(args): From cef5d83ee39f54be0afbf9d14644c5a6dd109e96 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 6 Nov 2022 14:54:42 +0100 Subject: [PATCH 206/496] Prepare release 43 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index b6657927..55144d79 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 42 +BACKEND_VERSION = 43 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 19432bca2c2023a6ec38601b4b7ce329c6fc862a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 6 Nov 2022 15:05:33 +0100 Subject: [PATCH 207/496] fix: recipe scrape yields --- backend/app/controller/recipe/recipe_controller.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index d1a57839..9bbc5212 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -1,3 +1,4 @@ +import re from app.errors import NotFoundRequest from app.models.recipe import RecipeItems, RecipeTags from flask import jsonify, Blueprint @@ -174,7 +175,9 @@ def scrapeRecipe(args): except NotImplementedError: pass try: - recipe.yields = scraper.yields() + yields = re.search(r"\d*", scraper.yields()) + if yields: + recipe.yields = int(yields.group()) except NotImplementedError: pass description = '' From 7e31b69e4a21a940cb1619991992539fe44e6d9f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 6 Nov 2022 15:05:51 +0100 Subject: [PATCH 208/496] Prepare release 44 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 55144d79..5506474f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 43 +BACKEND_VERSION = 44 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From f08af66d7d5f547955abf08e9fa0d0a798d69d2c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 7 Nov 2022 00:39:15 +0100 Subject: [PATCH 209/496] fix: update schema for app compatibility --- backend/app/controller/item/item_controller.py | 10 +++++----- backend/app/controller/item/schemas.py | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 8a072503..ad246238 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -1,6 +1,6 @@ from app.helpers import validate_args from flask import jsonify, Blueprint -from app.errors import NotFoundRequest +from app.errors import InvalidUsage, NotFoundRequest from flask_jwt_extended import jwt_required from app.models import Item, RecipeItems, Recipe, Category from .schemas import SearchByNameRequest, UpdateItem @@ -55,12 +55,12 @@ def updateItem(args, id): if not item: raise NotFoundRequest() if 'category' in args: + print(args) if not args['category']: item.category = None + elif 'id' in args['category']: + item.category = Category.find_by_id(args['category']['id']) else: - category = Category.find_by_name(args['category']) - if not category: - category = Category.create_by_name(args['category']) - item.category = category + raise InvalidUsage() item.save() return jsonify(item.obj_to_dict()) diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py index c9d2ce9c..07ea29e4 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -11,7 +11,16 @@ class SearchByNameRequest(Schema): class UpdateItem(Schema): class Meta: unknown = EXCLUDE - category = fields.String( - allow_none=True, - validate=lambda a: not a or a and not a.isspace() - ) + + 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) From 1b43d148a5a4c73d33135f012a2d2130a0cfc23c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 7 Nov 2022 00:40:30 +0100 Subject: [PATCH 210/496] Prepare release 45 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 5506474f..9b5c89af 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 44 +BACKEND_VERSION = 45 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 0703ce4ac7b26a9b20f83d3b2e889c697d5c2eab Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 7 Nov 2022 12:37:27 +0100 Subject: [PATCH 211/496] fix: Improve scraper robustness (TomBursch/kitchenowl-backend#12) --- .../app/controller/recipe/recipe_controller.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 9bbc5212..d3f6b829 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -6,6 +6,7 @@ from app.helpers import validate_args from app.models import Recipe, Item, Tag from recipe_scrapers import scrape_me +from recipe_scrapers._exceptions import SchemaOrgException from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest, ScrapeRecipe recipe = Blueprint('recipe', __name__) @@ -164,28 +165,32 @@ def scrapeRecipe(args): recipe.name = scraper.title() try: recipe.time = int(scraper.total_time()) - except NotImplementedError: + except (NotImplementedError, ValueError, SchemaOrgException): pass try: recipe.cook_time = int(scraper.cook_time()) - except NotImplementedError: + except (NotImplementedError, ValueError, SchemaOrgException): pass try: recipe.prep_time = int(scraper.prep_time()) - except NotImplementedError: + except (NotImplementedError, ValueError, SchemaOrgException): pass try: yields = re.search(r"\d*", scraper.yields()) if yields: recipe.yields = int(yields.group()) - except NotImplementedError: + except (NotImplementedError, ValueError, SchemaOrgException): pass description = '' try: description = scraper.description() + "\n\n" - except NotImplementedError: + except (NotImplementedError, ValueError, SchemaOrgException): pass - recipe.description = description + scraper.instructions() + try: + description = description + scraper.instructions() + except (NotImplementedError, ValueError, SchemaOrgException): + pass + recipe.description = description recipe.photo = scraper.image() recipe.source = args['url'] items = {} From 73a308bedad6103cac7a2f08feaae04c14ae2760 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 7 Nov 2022 12:38:11 +0100 Subject: [PATCH 212/496] Prepare release 46 --- backend/app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 9b5c89af..e871ddc4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,8 +11,8 @@ import os -MIN_FRONTEND_VERSION = 46 -BACKEND_VERSION = 45 +MIN_FRONTEND_VERSION = 62 +BACKEND_VERSION = 46 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From a4f3d0059071dfea4cc911dce49e0fe3bb31fddf Mon Sep 17 00:00:00 2001 From: Christoph Loy Date: Wed, 16 Nov 2022 00:29:45 +0100 Subject: [PATCH 213/496] fix: Store scraped recipe images locally (TomBursch/kitchenowl-backend#14) * fix: avoid cors issue on images by uploading them Avoid having CORS issues when fetching images from imported recipes. TO achieve this, this commit just uploads all imported images directly to its own image store. * cleanup Co-authored-by: Tom Bursch --- .../controller/recipe/recipe_controller.py | 58 +++++++------------ .../controller/upload/upload_controller.py | 14 ++--- backend/app/util/filename_validator.py | 6 ++ 3 files changed, 34 insertions(+), 44 deletions(-) create mode 100644 backend/app/util/filename_validator.py diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index d3f6b829..8876f51a 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -1,4 +1,10 @@ +import os import re +import uuid + +import requests +from app.util.filename_validator import allowed_file +from app.config import UPLOAD_FOLDER from app.errors import NotFoundRequest from app.models.recipe import RecipeItems, RecipeTags from flask import jsonify, Blueprint @@ -7,6 +13,7 @@ from app.models import Recipe, Item, Tag from recipe_scrapers import scrape_me from recipe_scrapers._exceptions import SchemaOrgException +from werkzeug.utils import secure_filename from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest, ScrapeRecipe recipe = Blueprint('recipe', __name__) @@ -45,7 +52,7 @@ def addRecipe(args): if 'source' in args: recipe.source = args['source'] if 'photo' in args: - recipe.photo = args['photo'] + recipe.photo = upload_file_if_needed(args['photo']) recipe.save() if 'items' in args: for recipeItem in args['items']: @@ -93,7 +100,7 @@ def updateRecipe(args, id): # noqa: C901 if 'source' in args: recipe.source = args['source'] if 'photo' in args: - recipe.photo = args['photo'] + recipe.photo = upload_file_if_needed(args['photo']) recipe.save() if 'items' in args: for con in recipe.items: @@ -157,7 +164,6 @@ def getAllFiltered(args): @recipe.route('/scrape', methods=['GET', 'POST']) -@jwt_required() @validate_args(ScrapeRecipe) def scrapeRecipe(args): scraper = scrape_me(args['url'], wild_mode=True) @@ -202,36 +208,16 @@ def scrapeRecipe(args): }) -# @recipe.route('//item', methods=['POST']) -# @jwt_required() -# @validate_args(AddItemByName) -# def addRecipeItemByName(args, id): -# recipe = Recipe.find_by_id(id) -# if not recipe: -# return jsonify(), 404 -# item = Item.find_by_name(args['name']) -# if not item: -# item = Item.create_by_name(args['name']) - -# description = args['description'] if 'description' in args else '' -# con = RecipeItems(description=description) -# con.item = item -# recipe.items.append(con) -# recipe.save() -# return jsonify(item.obj_to_dict()) - - -# @recipe.route('//item', methods=['DELETE']) -# @jwt_required() -# @validate_args(RemoveItem) -# def removeRecipeItem(args, id): -# recipe = Recipe.find_by_id(id) -# if not recipe: -# return jsonify(), 404 -# item = Item.find_by_id(args['item_id']) -# if not item: -# item = Item.create_by_name(args['name']) - -# con = RecipeItems.find_by_ids(id, args['item_id']) -# con.delete() -# return jsonify({'msg': "DONE"}) +def upload_file_if_needed(url: str): + if url is not None and '/' in url: + from mimetypes import guess_extension + resp = requests.get(url) + ext = guess_extension(resp.headers['content-type']) + if allowed_file('file' + ext): + filename = secure_filename(str(uuid.uuid4()) + '.' + ext) + with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as o: + o.write(resp.content) + return filename + elif url is not None: + return url + return None diff --git a/backend/app/controller/upload/upload_controller.py b/backend/app/controller/upload/upload_controller.py index 265d4a1b..579af95a 100644 --- a/backend/app/controller/upload/upload_controller.py +++ b/backend/app/controller/upload/upload_controller.py @@ -1,9 +1,12 @@ -from app.config import ALLOWED_FILE_EXTENSIONS, UPLOAD_FOLDER +import os +import uuid + from flask import jsonify, Blueprint, send_from_directory, request from flask_jwt_extended import jwt_required from werkzeug.utils import secure_filename -import os -import uuid + +from app.config import UPLOAD_FOLDER +from app.util.filename_validator import allowed_file upload = Blueprint('upload', __name__) @@ -33,8 +36,3 @@ def upload_file(): @jwt_required() def download_file(name): return send_from_directory(UPLOAD_FOLDER, name) - - -def allowed_file(filename): - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_FILE_EXTENSIONS diff --git a/backend/app/util/filename_validator.py b/backend/app/util/filename_validator.py new file mode 100644 index 00000000..bd593dec --- /dev/null +++ b/backend/app/util/filename_validator.py @@ -0,0 +1,6 @@ +from app.config import ALLOWED_FILE_EXTENSIONS + + +def allowed_file(filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_FILE_EXTENSIONS From 3c9b1fab82b6bdd27bc48dda33b38c998a0de242 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 16 Nov 2022 02:01:39 +0100 Subject: [PATCH 214/496] feat: allow shoppinglist sorting --- backend/app/controller/shoppinglist/schemas.py | 3 +++ .../shoppinglist/shoppinglist_controller.py | 15 +++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index 4004c8be..6dc67b39 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -33,6 +33,9 @@ class CreateList(Schema): ) +class GetItems(Schema): + orderby = fields.Integer() + class UpdateDescription(Schema): description = fields.String( required=True diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 42252833..dcc915a2 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -4,7 +4,7 @@ from app.models import Item, Shoppinglist, History, Status, Association, ShoppinglistItems from app.helpers import validate_args from .schemas import (RemoveItem, UpdateDescription, - AddItemByName, CreateList, AddRecipeItems) + AddItemByName, CreateList, AddRecipeItems, GetItems) from app.errors import NotFoundRequest from datetime import datetime, timedelta @@ -46,11 +46,18 @@ def updateItemDescription(args, id, item_id): @shoppinglist.route('//items', methods=['GET']) @jwt_required() -def getAllShoppingListItems(id): +@validate_args(GetItems) +def getAllShoppingListItems(args, id): + orderby = [Item.name] + if ('orderby' in args): + if (args['orderby'] == 1): + orderby = [Item.ordering==0, Item.ordering] + elif (args['orderby'] == 2): + orderby = [Item.name] + items = ShoppinglistItems.query.filter( ShoppinglistItems.shoppinglist_id == id).join( - ShoppinglistItems.item).order_by( - Item.name).all() + ShoppinglistItems.item).order_by(*orderby, Item.name).all() return jsonify([e.obj_to_item_dict() for e in items]) From 8498f1e8bbcf7930211f54bd92a838a3f2721a22 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 16 Nov 2022 02:07:40 +0100 Subject: [PATCH 215/496] fix: recipe scrape store --- backend/app/controller/recipe/recipe_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 8876f51a..9f21b75d 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -214,7 +214,7 @@ def upload_file_if_needed(url: str): resp = requests.get(url) ext = guess_extension(resp.headers['content-type']) if allowed_file('file' + ext): - filename = secure_filename(str(uuid.uuid4()) + '.' + ext) + filename = secure_filename(str(uuid.uuid4()) + ext) with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as o: o.write(resp.content) return filename From 6662132a48eb563ab039a29d63af6151d666db2b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 16 Nov 2022 02:07:51 +0100 Subject: [PATCH 216/496] Prepare release 47 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index e871ddc4..9db4a533 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 62 -BACKEND_VERSION = 46 +BACKEND_VERSION = 47 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 8ff3c7c9d20f22b8f2c1e86b856928edc5d718e7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 16 Nov 2022 23:53:05 +0100 Subject: [PATCH 217/496] chore: upgrade requirements --- backend/requirements.txt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 1e62e2b5..e71cb7a2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,10 +19,10 @@ Flask==2.2.2 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 -Flask-Migrate==3.1.0 +Flask-Migrate==4.0.0 Flask-SQLAlchemy==3.0.2 fonttools==4.38.0 -greenlet==2.0.0 +greenlet==2.0.1 html-text==0.5.2 html5lib==1.1 idna==3.4 @@ -34,9 +34,9 @@ joblib==1.2.0 jstyleson==0.0.2 kiwisolver==1.4.4 lxml==4.9.1 -Mako==1.2.3 +Mako==1.2.4 MarkupSafe==2.1.1 -marshmallow==3.18.0 +marshmallow==3.19.0 matplotlib==3.6.2 mccabe==0.7.0 mf2py==1.1.2 @@ -45,9 +45,9 @@ mypy-extensions==0.4.3 numpy==1.23.4 packaging==21.3 pandas==1.5.1 -pathspec==0.10.1 +pathspec==0.10.2 Pillow==9.3.0 -platformdirs==2.5.2 +platformdirs==2.5.4 pluggy==1.0.0 py==1.11.0 pycodestyle==2.9.1 @@ -71,14 +71,14 @@ scipy==1.9.3 setuptools-scm==7.0.5 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.43 +SQLAlchemy==1.4.44 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 typed-ast==1.5.4 -types-beautifulsoup4==4.11.6 -types-requests==2.28.11.2 -types-urllib3==1.26.25.1 +types-beautifulsoup4==4.11.6.1 +types-requests==2.28.11.5 +types-urllib3==1.26.25.4 typing_extensions==4.4.0 tzdata==2022.6 tzlocal==4.2 From bd5de9d2feaad845a225564fa894c5b673b31c0e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 17 Nov 2022 00:32:07 +0100 Subject: [PATCH 218/496] feat: set time of item removal --- backend/app/controller/shoppinglist/schemas.py | 1 + .../controller/shoppinglist/shoppinglist_controller.py | 9 +++++++-- backend/app/models/history.py | 6 ++++-- backend/app/models/item.py | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index 6dc67b39..cb5c030c 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -50,6 +50,7 @@ class RemoveItem(Schema): item_id = fields.Integer( required=True, ) + removed_at = fields.Integer() # def validate_id(self, args): # if not ShoppinglistItem.find_by_id(args['id'], args['item_id']): diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index dcc915a2..fc504f7f 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -6,7 +6,7 @@ from .schemas import (RemoveItem, UpdateDescription, AddItemByName, CreateList, AddRecipeItems, GetItems) from app.errors import NotFoundRequest -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone shoppinglist = Blueprint('shoppinglist', __name__) @@ -163,7 +163,12 @@ def removeShoppinglistItem(args, id): description = con.description con.delete() - History.create_dropped(shoppinglist, item, description) + removed_at = None + if 'removed_at' in args: + removed_at = datetime.fromtimestamp(args['removed_at']/1000, timezone.utc) + print(removed_at) + + History.create_dropped(shoppinglist, item, description, removed_at) return jsonify({'msg': "DONE"}) diff --git a/backend/app/models/history.py b/backend/app/models/history.py index f1935693..822d46ae 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -1,3 +1,4 @@ +from datetime import datetime from app import db from app.helpers import DbModelMixin, TimestampMixin from .shoppinglist import ShoppinglistItems @@ -35,12 +36,13 @@ def create_added(cls, shoppinglist, item, description=''): ).save() @classmethod - def create_dropped(cls, shoppinglist, item, description=''): + def create_dropped(cls, shoppinglist, item, description='', created_at=None): return cls( shoppinglist_id=shoppinglist.id, item_id=item.id, status=Status.DROPPED, - description=description + description=description, + created_at=created_at or datetime.utcnow ).save() def obj_to_item_dict(self): diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 9b91780d..8a022cfe 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -71,7 +71,7 @@ def find_by_id(cls, id) -> Item: @classmethod def search_name(cls, name: str): - item_count = 9 + item_count = 11 found = [] # name is a regex From 46edb53e1905e9fba3e4886e47d4fcd6789f4627 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 17 Nov 2022 00:42:27 +0100 Subject: [PATCH 219/496] Prepare release 48 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 9db4a533..641dde97 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 62 -BACKEND_VERSION = 47 +BACKEND_VERSION = 48 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 498a18ff856f9dea7d2a63fd536b5f356bba7508 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 22 Nov 2022 15:35:47 +0100 Subject: [PATCH 220/496] feat: update expense api --- .../controller/expense/expense_controller.py | 44 ++++++++++++++----- backend/app/controller/expense/schemas.py | 14 ++++++ .../app/controller/user/user_controller.py | 9 ++-- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index c1b02c3c..dc8890ca 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -3,21 +3,32 @@ from sqlalchemy.sql.expression import desc from app.errors import NotFoundRequest from flask import jsonify, Blueprint -from flask_jwt_extended import jwt_required +from flask_jwt_extended import current_user, jwt_required from sqlalchemy import func +from app import db from app.helpers import validate_args, admin_required from app.models import Expense, ExpensePaidFor, User, ExpenseCategory -from .schemas import AddExpense, UpdateExpense, AddExpenseCategory, DeleteExpenseCategory, UpdateExpenseCategory +from .schemas import GetExpenses, AddExpense, UpdateExpense, AddExpenseCategory, DeleteExpenseCategory, UpdateExpenseCategory, GetExpenseOverview expense = Blueprint('expense', __name__) @expense.route('', methods=['GET']) @jwt_required() -def getAllExpenses(): +@validate_args(GetExpenses) +def getAllExpenses(args): + filter = [] + if ('startAfterId' in args): + filter.append(Expense.id < args['startAfterId']) + + if ('view' in args and args['view'] == 1): + subquery = db.session.query(ExpensePaidFor.expense_id).filter( + ExpensePaidFor.user_id == current_user.id).scalar_subquery() + filter.append(Expense.id.in_(subquery)) + return jsonify([e.obj_to_full_dict() for e - in Expense.query.order_by(desc(Expense.id)) - .join(Expense.category, isouter=True).limit(50).all() + in Expense.query.order_by(desc(Expense.created_at)).filter(*filter) + .join(Expense.category, isouter=True).limit(30).all() ]) @@ -136,6 +147,7 @@ def deleteExpenseById(id): def calculateBalances(): recalculateBalances() + def recalculateBalances(): for user in User.all(): user.expense_balance = float(Expense.query.with_entities(func.sum( @@ -157,11 +169,21 @@ def getExpenseCategories(): @expense.route('/overview', methods=['GET']) @jwt_required() -def getExpenseOverview(): +@validate_args(GetExpenseOverview) +def getExpenseOverview(args): categories = list(map(lambda x: x.name, ExpenseCategory.all_by_name())) categories.append("") thisMonthStart = datetime.utcnow().date().replace(day=1) + months = args['months'] if 'months' in args else 5 + + filter = [] + + if ('view' in args and args['view'] == 1): + subquery = db.session.query(ExpensePaidFor.expense_id).filter( + ExpensePaidFor.user_id == current_user.id).scalar_subquery() + filter.append(Expense.id.in_(subquery)) + def getOverviewForMonthAgo(monthAgo: int): monthStart = thisMonthStart.replace( month=(thisMonthStart.month - monthAgo)) @@ -169,14 +191,14 @@ def getOverviewForMonthAgo(monthAgo: int): monthStart.year, monthStart.month)[1]) return { (e.name or ""): (float(e.balance) or 0) for e in Expense.query.with_entities(ExpenseCategory.name.label("name"), func.sum( - Expense.amount).label("balance")).group_by(Expense.category_id).join(Expense.category, isouter=True).filter(Expense.created_at >= monthStart, Expense.created_at <= monthEnd).all() + Expense.amount).label("balance")).group_by(Expense.category_id).join(Expense.category, isouter=True).filter(Expense.created_at >= monthStart, Expense.created_at <= monthEnd, *filter).all() } - value = [getOverviewForMonthAgo(i) for i in range(0, 5)] + value = [getOverviewForMonthAgo(i) for i in range(0, months)] + + byMonth = {i: {category: (value[i][category] if category in value[i] else 0.0) for category in categories } for i in range(0, months)} - return jsonify({category: { - i: (value[i][category] if category in value[i] else 0.0) for i in range(0, 5) - } for category in categories}) + return jsonify(byMonth) @expense.route('/categories', methods=['POST']) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 9cb8f1cc..524dbc42 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -1,6 +1,13 @@ from marshmallow import fields, Schema +class GetExpenses(Schema): + view = fields.Integer() + startAfterId = fields.Integer( + validate=lambda a: a >= 0 + ) + + class AddExpense(Schema): class User(Schema): id = fields.Integer( @@ -73,3 +80,10 @@ class DeleteExpenseCategory(Schema): required=True, validate=lambda a: a and not a.isspace() ) + + +class GetExpenseOverview(Schema): + view = fields.Integer() + months = fields.Integer( + validate=lambda a: a > 0 + ) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 6e10bd5d..482b0084 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -1,8 +1,8 @@ from app.errors import NotFoundRequest, UnauthorizedRequest from app.helpers.admin_required import admin_required from app.helpers import validate_args -from flask import jsonify, Blueprint, request -from flask_jwt_extended import jwt_required, get_jwt_identity +from flask import jsonify, Blueprint +from flask_jwt_extended import current_user, jwt_required, get_jwt_identity from app.models import User from .schemas import CreateUser, UpdateUser @@ -19,10 +19,7 @@ def getAllUsers(): @user.route('', methods=['GET']) @jwt_required() def getLoggedInUser(): - user = User.find_by_username(get_jwt_identity()) - if not user: - raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) - return jsonify(user.obj_to_full_dict()) + return jsonify(current_user.obj_to_full_dict()) @user.route('/', methods=['GET']) From d805a428ecd3d622835e3135dd082c9ec64e1046 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 22 Nov 2022 16:03:35 +0100 Subject: [PATCH 221/496] Prepare beta 49 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 641dde97..ac0097ad 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 62 -BACKEND_VERSION = 48 +BACKEND_VERSION = 49 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 9b16275e3b10188a49fb61559ba569d5928bea71 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 24 Nov 2022 02:29:10 +0100 Subject: [PATCH 222/496] fix: overview view values --- .../controller/expense/expense_controller.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index dc8890ca..bf72c30d 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -177,12 +177,23 @@ def getExpenseOverview(args): months = args['months'] if 'months' in args else 5 - filter = [] + factor = 1 + query = Expense.query\ + .group_by(Expense.category_id)\ + .join(Expense.category, isouter=True) if ('view' in args and args['view'] == 1): - subquery = db.session.query(ExpensePaidFor.expense_id).filter( + filterQuery = db.session.query(ExpensePaidFor.expense_id).filter( ExpensePaidFor.user_id == current_user.id).scalar_subquery() - filter.append(Expense.id.in_(subquery)) + + s1 = ExpensePaidFor.query.with_entities(ExpensePaidFor.expense_id.label("expense_id"), func.sum( + ExpensePaidFor.factor).label('total')).group_by(ExpensePaidFor.expense_id).subquery() + s2 = ExpensePaidFor.query.with_entities(ExpensePaidFor.expense_id.label("expense_id"), (ExpensePaidFor.factor.cast( + db.Float) / s1.c.total).label('factor')).filter(ExpensePaidFor.user_id == current_user.id).join(s1, ExpensePaidFor.expense_id == s1.c.expense_id).subquery() + + factor = s2.c.factor + + query = query.filter(Expense.id.in_(filterQuery)).join(s2) def getOverviewForMonthAgo(monthAgo: int): monthStart = thisMonthStart.replace( @@ -190,13 +201,17 @@ def getOverviewForMonthAgo(monthAgo: int): monthEnd = monthStart.replace(day=calendar.monthrange( monthStart.year, monthStart.month)[1]) return { - (e.name or ""): (float(e.balance) or 0) for e in Expense.query.with_entities(ExpenseCategory.name.label("name"), func.sum( - Expense.amount).label("balance")).group_by(Expense.category_id).join(Expense.category, isouter=True).filter(Expense.created_at >= monthStart, Expense.created_at <= monthEnd, *filter).all() + (e.name or ""): (float(e.balance) or 0) for e in + query + .with_entities(ExpenseCategory.name.label("name"), func.sum(Expense.amount * factor).label("balance")) + .filter(Expense.created_at >= monthStart, Expense.created_at <= monthEnd) + .all() } value = [getOverviewForMonthAgo(i) for i in range(0, months)] - byMonth = {i: {category: (value[i][category] if category in value[i] else 0.0) for category in categories } for i in range(0, months)} + byMonth = {i: {category: (value[i][category] if category in value[i] else 0.0) + for category in categories} for i in range(0, months)} return jsonify(byMonth) From e1f77354eed3a86674ca03334b1192f001cc64d0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 29 Nov 2022 16:00:56 +0100 Subject: [PATCH 223/496] feat: expense category colors breaking changes --- .../controller/expense/expense_controller.py | 49 +++++++++---------- backend/app/controller/expense/schemas.py | 19 +++---- backend/app/models/expense.py | 2 +- backend/app/models/expense_category.py | 7 +-- backend/migrations/versions/a9824159e4e5_.py | 32 ++++++++++++ 5 files changed, 64 insertions(+), 45 deletions(-) create mode 100644 backend/migrations/versions/a9824159e4e5_.py diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index bf72c30d..4b0ef641 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -8,7 +8,7 @@ from app import db from app.helpers import validate_args, admin_required from app.models import Expense, ExpensePaidFor, User, ExpenseCategory -from .schemas import GetExpenses, AddExpense, UpdateExpense, AddExpenseCategory, DeleteExpenseCategory, UpdateExpenseCategory, GetExpenseOverview +from .schemas import GetExpenses, AddExpense, UpdateExpense, AddExpenseCategory, UpdateExpenseCategory, GetExpenseOverview expense = Blueprint('expense', __name__) @@ -54,12 +54,8 @@ def addExpense(args): if 'photo' in args: expense.photo = args['photo'] if 'category' in args: - if not args['category']: - expense.category = None - else: - category = ExpenseCategory.find_by_name(args['category']) - if not category: - category = ExpenseCategory.create_by_name(args['category']) + if args['category'] is not None: + category = ExpenseCategory.find_by_id(args['category']) expense.category = category expense.paid_by = user expense.save() @@ -98,13 +94,11 @@ def updateExpense(args, id): # noqa: C901 if 'photo' in args: expense.photo = args['photo'] if 'category' in args: - if not args['category']: - expense.category = None - else: - category = ExpenseCategory.find_by_name(args['category']) - if not category: - category = ExpenseCategory.create_by_name(args['category']) + if args['category'] is not None: + category = ExpenseCategory.find_by_id(args['category']) expense.category = category + else: + expense.category = None if 'paid_by' in args: user = User.find_by_id(args['paid_by']['id']) if user: @@ -164,15 +158,15 @@ def recalculateBalances(): @expense.route('/categories', methods=['GET']) @jwt_required() def getExpenseCategories(): - return jsonify([e.name for e in ExpenseCategory.all_by_name()]) + return jsonify([e.obj_to_dict() for e in ExpenseCategory.all_by_name()]) @expense.route('/overview', methods=['GET']) @jwt_required() @validate_args(GetExpenseOverview) def getExpenseOverview(args): - categories = list(map(lambda x: x.name, ExpenseCategory.all_by_name())) - categories.append("") + categories = list(map(lambda x: x.id, ExpenseCategory.all_by_name())) + categories.append(-1) thisMonthStart = datetime.utcnow().date().replace(day=1) months = args['months'] if 'months' in args else 5 @@ -201,9 +195,9 @@ def getOverviewForMonthAgo(monthAgo: int): monthEnd = monthStart.replace(day=calendar.monthrange( monthStart.year, monthStart.month)[1]) return { - (e.name or ""): (float(e.balance) or 0) for e in + (e.id or -1): (float(e.balance) or 0) for e in query - .with_entities(ExpenseCategory.name.label("name"), func.sum(Expense.amount * factor).label("balance")) + .with_entities(ExpenseCategory.id.label("id"), func.sum(Expense.amount * factor).label("balance")) .filter(Expense.created_at >= monthStart, Expense.created_at <= monthEnd) .all() } @@ -220,30 +214,33 @@ def getOverviewForMonthAgo(monthAgo: int): @jwt_required() @validate_args(AddExpenseCategory) def addExpenseCategory(args): - category = ExpenseCategory.create_by_name(args['name']) + category = ExpenseCategory() + category.name = args['name'] + category.color = args['color'] return jsonify(category.obj_to_dict()) -@expense.route('/categories', methods=['DELETE']) +@expense.route('/categories/', methods=['DELETE']) @jwt_required() @admin_required -@validate_args(DeleteExpenseCategory) -def deleteExpenseCategoryById(args): - ExpenseCategory.delete_by_name(args['name']) +def deleteExpenseCategoryById(id): + ExpenseCategory.delete_by_id(id) return jsonify({'msg': 'DONE'}) -@expense.route('/categories/', methods=['POST']) +@expense.route('/categories/', methods=['POST']) @jwt_required() @validate_args(UpdateExpenseCategory) -def renameExpenseCategory(args, name): - category = ExpenseCategory.find_by_name(name) +def updateExpenseCategory(args, id): + category = ExpenseCategory.find_by_id(id) if not category: raise NotFoundRequest() if 'name' in args: category.name = args['name'] + if 'color' in args: + category.color = args['color'] category.save() return jsonify(category.obj_to_dict()) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 524dbc42..d826cea0 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -28,9 +28,8 @@ class User(Schema): required=True ) photo = fields.String() - category = fields.String( - validate=lambda a: not a or ( - a and not a.isspace()), allow_none=True + category = fields.Integer( + allow_none=True ) paid_by = fields.Nested(User(), required=True) paid_for = fields.List(fields.Nested( @@ -53,9 +52,7 @@ class User(Schema): name = fields.String() amount = fields.Float() photo = fields.String() - category = fields.String( - validate=lambda a: not a or ( - a and not a.isspace()), + category = fields.Integer( allow_none=True ) paid_by = fields.Nested(User()) @@ -67,18 +64,16 @@ class AddExpenseCategory(Schema): required=True, validate=lambda a: a and not a.isspace() ) + color = fields.Integer() class UpdateExpenseCategory(Schema): name = fields.String( validate=lambda a: a and not a.isspace() ) - - -class DeleteExpenseCategory(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 ) diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index e5e6f8ef..c2b62115 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -24,7 +24,7 @@ def obj_to_full_dict(self): ExpensePaidFor.expense_id).all() res['paid_for'] = [e.obj_to_dict() for e in paidFor] if (self.category): - res['category'] = self.category.name + res['category'] = self.category.obj_to_full_dict() return res @classmethod diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py index 26e27df3..96c11b5f 100644 --- a/backend/app/models/expense_category.py +++ b/backend/app/models/expense_category.py @@ -8,6 +8,7 @@ class ExpenseCategory(db.Model, DbModelMixin, TimestampMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) + color = db.Column(db.Integer) expenses = db.relationship( 'Expense', back_populates='category') @@ -16,12 +17,6 @@ def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() return res - @classmethod - def create_by_name(cls, name) -> ExpenseCategory: - return cls( - name=name, - ).save() - @classmethod def find_by_name(cls, name) -> ExpenseCategory: return cls.query.filter(cls.name == name).first() diff --git a/backend/migrations/versions/a9824159e4e5_.py b/backend/migrations/versions/a9824159e4e5_.py new file mode 100644 index 00000000..36df80e4 --- /dev/null +++ b/backend/migrations/versions/a9824159e4e5_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: a9824159e4e5 +Revises: fe3a5c9ac84c +Create Date: 2022-11-29 13:24:03.377245 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a9824159e4e5' +down_revision = 'fe3a5c9ac84c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.add_column(sa.Column('color', sa.Integer(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.drop_column('color') + + # ### end Alembic commands ### From c59f05d84337dfa4ea473ec47e71774be7ebc3b1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 29 Nov 2022 16:21:50 +0100 Subject: [PATCH 224/496] Prepare beta 50 --- backend/app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index ac0097ad..e9ca66c9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,8 +11,8 @@ import os -MIN_FRONTEND_VERSION = 62 -BACKEND_VERSION = 49 +MIN_FRONTEND_VERSION = 65 +BACKEND_VERSION = 50 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 4754ebb376c2300330d5e6a9688cb2f660ea6e33 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 6 Dec 2022 18:06:20 +0100 Subject: [PATCH 225/496] chore: upgrade dependencies and to python 11 --- backend/Dockerfile | 4 ++-- backend/requirements.txt | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 6016fb23..5e1b1efc 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,7 @@ # ------------ # BUILDER # ------------ -FROM python:3.10-slim as builder +FROM python:3.11-slim as builder RUN apt-get update \ && apt-get install --yes --no-install-recommends \ @@ -18,7 +18,7 @@ RUN pip3 install --no-cache-dir -r requirements.txt && find /opt/venv \( -type d # ------------ # RUNNER # ------------ -FROM python:3.10-slim as runner +FROM python:3.11-slim as runner # Use virtual enviroment COPY --from=builder /opt/venv /opt/venv diff --git a/backend/requirements.txt b/backend/requirements.txt index e71cb7a2..667ccd23 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,7 +14,7 @@ contourpy==1.0.6 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.14.0 -flake8==5.0.4 +flake8==6.0.0 Flask==2.2.2 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 @@ -42,17 +42,17 @@ mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.21.0 mypy-extensions==0.4.3 -numpy==1.23.4 +numpy==1.23.5 packaging==21.3 -pandas==1.5.1 +pandas==1.5.2 pathspec==0.10.2 Pillow==9.3.0 platformdirs==2.5.4 pluggy==1.0.0 py==1.11.0 -pycodestyle==2.9.1 +pycodestyle==2.10.0 pycparser==2.21 -pyflakes==2.5.0 +pyflakes==3.0.1 PyJWT==2.6.0 pyparsing==3.0.9 pyRdfa3==3.5.3 @@ -63,7 +63,7 @@ pytz==2022.6 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.23.0 +recipe-scrapers==14.24.0 regex==2022.10.31 requests==2.28.1 scikit-learn==1.1.3 @@ -80,10 +80,10 @@ types-beautifulsoup4==4.11.6.1 types-requests==2.28.11.5 types-urllib3==1.26.25.4 typing_extensions==4.4.0 -tzdata==2022.6 +tzdata==2022.7 tzlocal==4.2 -urllib3==1.26.12 +urllib3==1.26.13 uWSGI==2.0.21 -w3lib==2.0.1 +w3lib==2.1.0 webencodings==0.5.1 Werkzeug==2.2.2 From 08d55180b83080ca3762eca5ab85010a7a076078 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Dec 2022 15:46:38 +0100 Subject: [PATCH 226/496] chore(deps): bump certifi from 2022.9.24 to 2022.12.7 (TomBursch/kitchenowl-backend#15) Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.9.24 to 2022.12.7. - [Release notes](https://github.com/certifi/python-certifi/releases) - [Commits](https://github.com/certifi/python-certifi/compare/2022.09.24...2022.12.07) --- updated-dependencies: - dependency-name: certifi dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 667ccd23..656b1e84 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,7 +6,7 @@ autopep8==2.0.0 bcrypt==4.0.1 beautifulsoup4==4.11.1 black==21.12b0 -certifi==2022.9.24 +certifi==2022.12.7 cffi==1.15.1 charset-normalizer==2.1.1 click==8.1.3 From 76d30cd4013be6ed78c094b66342f1b5de6f4034 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 8 Dec 2022 22:16:52 +0100 Subject: [PATCH 227/496] feat: custom homepage order --- backend/app/controller/health_controller.py | 1 + backend/app/controller/settings/schemas.py | 1 + .../settings/settings_controller.py | 2 ++ backend/app/helpers/__init__.py | 1 - backend/app/helpers/db_list_type.py | 18 ++++++++++ backend/app/models/settings.py | 3 ++ backend/migrations/versions/55fe25bdf42b_.py | 33 +++++++++++++++++++ 7 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 backend/app/helpers/db_list_type.py create mode 100644 backend/migrations/versions/55fe25bdf42b_.py diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 87abd7e6..35fe7201 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -16,5 +16,6 @@ def get_health(): info.update({ 'planner_feature': settings.planner_feature, 'expenses_feature': settings.expenses_feature, + 'view_ordering': settings.view_ordering, }) return jsonify(info) diff --git a/backend/app/controller/settings/schemas.py b/backend/app/controller/settings/schemas.py index 27c0c475..caf1d657 100644 --- a/backend/app/controller/settings/schemas.py +++ b/backend/app/controller/settings/schemas.py @@ -4,3 +4,4 @@ class SetSettingsSchema(Schema): planner_feature = fields.Boolean() expenses_feature = fields.Boolean() + view_ordering = fields.List(fields.String) diff --git a/backend/app/controller/settings/settings_controller.py b/backend/app/controller/settings/settings_controller.py index c5e256a7..5116b7f7 100644 --- a/backend/app/controller/settings/settings_controller.py +++ b/backend/app/controller/settings/settings_controller.py @@ -17,6 +17,8 @@ def setSettings(args): settings.planner_feature = args['planner_feature'] if 'expenses_feature' in args: settings.expenses_feature = args['expenses_feature'] + if 'view_ordering' in args: + settings.view_ordering = args['view_ordering'] settings.save() return jsonify(settings.obj_to_dict()) diff --git a/backend/app/helpers/__init__.py b/backend/app/helpers/__init__.py index 9a9a08ca..f95e51f0 100644 --- a/backend/app/helpers/__init__.py +++ b/backend/app/helpers/__init__.py @@ -2,4 +2,3 @@ from .timestamp_mixin import TimestampMixin from .validate_args import validate_args from .admin_required import admin_required -from .db_set_type import DbSetType diff --git a/backend/app/helpers/db_list_type.py b/backend/app/helpers/db_list_type.py new file mode 100644 index 00000000..d1704a17 --- /dev/null +++ b/backend/app/helpers/db_list_type.py @@ -0,0 +1,18 @@ +from sqlalchemy.types import String, TypeDecorator +import json + + +# Represents a List in the DataBase (i.e. [e1, e2, e3, ...]) +class DbListType(TypeDecorator): + impl = String + + def process_bind_param(self, value, dialect): + if type(value) is list: + return json.dumps(value) + else: + return '[]' + + def process_result_value(self, value, dialect) -> set: + if type(value) is str: + return json.loads(value) + return list() diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index c6e7252b..2d224c89 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,5 +1,6 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin +from app.helpers.db_list_type import DbListType class Settings(db.Model, DbModelMixin, TimestampMixin): @@ -8,6 +9,8 @@ class Settings(db.Model, DbModelMixin, TimestampMixin): planner_feature = db.Column(db.Boolean(), primary_key=True, default=True) expenses_feature = db.Column(db.Boolean(), primary_key=True, default=True) + view_ordering = db.Column(DbListType(), default = list()) + @classmethod def get(cls): settings = cls.query.first() diff --git a/backend/migrations/versions/55fe25bdf42b_.py b/backend/migrations/versions/55fe25bdf42b_.py new file mode 100644 index 00000000..019ee3ed --- /dev/null +++ b/backend/migrations/versions/55fe25bdf42b_.py @@ -0,0 +1,33 @@ +"""empty message + +Revision ID: 55fe25bdf42b +Revises: a9824159e4e5 +Create Date: 2022-12-08 17:41:24.521923 + +""" +from alembic import op +import sqlalchemy as sa + +import app.helpers.db_set_type + +# revision identifiers, used by Alembic. +revision = '55fe25bdf42b' +down_revision = 'a9824159e4e5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.add_column(sa.Column('view_ordering', app.helpers.db_list_type.DbListType(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_column('view_ordering') + + # ### end Alembic commands ### From f3d34fed1703f9b60aab30cfca887879dc358021 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 9 Dec 2022 16:31:32 +0100 Subject: [PATCH 228/496] fix: recipe search --- backend/app/controller/recipe/recipe_controller.py | 2 ++ backend/app/controller/recipe/schemas.py | 3 +++ backend/app/models/recipe.py | 3 +-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 9f21b75d..aada8e3d 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -153,6 +153,8 @@ def deleteRecipeById(id): @jwt_required() @validate_args(SearchByNameRequest) def searchRecipeByName(args): + if 'only_ids' in args and args['only_ids']: + return jsonify([e.id for e in Recipe.search_name(args['query'])]) return jsonify([e.obj_to_dict() for e in Recipe.search_name(args['query'])]) diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index f02225a2..2b46dd49 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -61,6 +61,9 @@ class SearchByNameRequest(Schema): required=True, validate=lambda a: a and not a.isspace() ) + only_ids = fields.Boolean( + default=False, + ) class GetAllFilterRequest(Schema): diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index ec5a2a2c..411db662 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -111,14 +111,13 @@ def find_by_id(cls, id) -> Recipe: @classmethod def search_name(cls, name): - recipe_count = 12 if '*' in name or '_' in name: looking_for = name.replace('_', '__')\ .replace('*', '%')\ .replace('?', '_') else: looking_for = '%{0}%'.format(name) - return cls.query.filter(cls.name.ilike(looking_for)).limit(recipe_count).all() + return cls.query.filter(cls.name.ilike(looking_for)).order_by(cls.name).all() @classmethod def all_by_name_with_filter(cls, filter): From 9270d6b839eaf20e497730541a4729a27fad89b4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 16 Dec 2022 23:00:05 +0100 Subject: [PATCH 229/496] fix: create expense category --- backend/app/controller/expense/expense_controller.py | 1 + backend/app/controller/expense/schemas.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 4b0ef641..b3b6639a 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -217,6 +217,7 @@ def addExpenseCategory(args): category = ExpenseCategory() category.name = args['name'] category.color = args['color'] + category.save() return jsonify(category.obj_to_dict()) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index d826cea0..afc0cb20 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -64,7 +64,10 @@ class AddExpenseCategory(Schema): required=True, validate=lambda a: a and not a.isspace() ) - color = fields.Integer() + color = fields.Integer( + validate=lambda i: i >= 0, + allow_none=True + ) class UpdateExpenseCategory(Schema): From a89304e56d9b2381ef909f751bc0c08376ea6f2f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 17 Dec 2022 00:12:18 +0100 Subject: [PATCH 230/496] chore: add type hints & update styling --- .../app/controller/auth/auth_controller.py | 21 ++++++++----- .../controller/expense/expense_controller.py | 3 +- .../app/controller/item/item_controller.py | 1 - .../app/controller/shoppinglist/schemas.py | 1 + .../shoppinglist/shoppinglist_controller.py | 6 ++-- backend/app/helpers/db_model_mixin.py | 31 ++++++++++--------- backend/app/helpers/timestamp_mixin.py | 30 ++---------------- backend/app/jobs/cluster_shoppings.py | 4 +-- backend/app/jobs/jobs.py | 1 + backend/app/jobs/recipe_suggestions.py | 2 +- backend/app/models/association.py | 3 +- backend/app/models/category.py | 10 +++--- backend/app/models/expense.py | 9 +++--- backend/app/models/expense_category.py | 5 +-- backend/app/models/history.py | 13 ++++---- backend/app/models/item.py | 15 ++++----- backend/app/models/recipe.py | 23 +++++++------- backend/app/models/recipe_history.py | 15 ++++----- backend/app/models/settings.py | 5 +-- backend/app/models/shoppinglist.py | 7 +++-- backend/app/models/tag.py | 9 +++--- backend/app/models/token.py | 25 ++++++++------- backend/app/models/user.py | 11 ++++--- backend/app/util/__init__.py | 2 +- backend/app/util/kitchenowl_json_provider.py | 2 +- 25 files changed, 126 insertions(+), 128 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index a841dd70..51b0962e 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -45,7 +45,8 @@ def login(args): username = args['username'].lower() user = User.find_by_username(username) if not user or not user.check_password(args['password']): - raise UnauthorizedRequest(message='Unauthorized: IP {} login attemp with wrong username or password'.format(request.remote_addr)) + raise UnauthorizedRequest( + message='Unauthorized: IP {} login attemp with wrong username or password'.format(request.remote_addr)) device = "Unkown" if "device" in args: device = args['device'] @@ -78,11 +79,13 @@ def login(args): def refresh(): user = User.find_by_username(get_jwt_identity()) if not user: - raise UnauthorizedRequest(message='Unauthorized: IP {} refresh attemp with wrong username or password'.format(request.remote_addr)) + raise UnauthorizedRequest( + message='Unauthorized: IP {} refresh attemp with wrong username or password'.format(request.remote_addr)) refreshModel = Token.find_by_jti(get_jwt()['jti']) # Refresh token rotation - refreshToken, refreshModel = Token.create_refresh_token(user, oldRefreshToken=refreshModel) + refreshToken, refreshModel = Token.create_refresh_token( + user, oldRefreshToken=refreshModel) # Create access token accesssToken, _ = Token.create_access_token(user, refreshModel) @@ -99,7 +102,8 @@ def logout(): jwt = get_jwt() token = Token.find_by_jti(jwt['jti']) if not token: - raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) + raise UnauthorizedRequest( + message='Unauthorized: IP {}'.format(request.remote_addr)) if token.type == 'access': token.refresh_token.delete() @@ -115,7 +119,8 @@ def logout(): def createLongLivedToken(args): user = User.find_by_username(get_jwt_identity()) if not user: - raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) + raise UnauthorizedRequest( + message='Unauthorized: IP {}'.format(request.remote_addr)) llToken, _ = Token.create_longlived_token(user, args['device']) @@ -129,11 +134,13 @@ def createLongLivedToken(args): def deleteLongLivedToken(id): user = User.find_by_username(get_jwt_identity()) if not user: - raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) + raise UnauthorizedRequest( + message='Unauthorized: IP {}'.format(request.remote_addr)) token = Token.find_by_id(id) if (token.user_id != user.id or token.type != 'llt'): - raise UnauthorizedRequest(message='Unauthorized: IP {}'.format(request.remote_addr)) + raise UnauthorizedRequest( + message='Unauthorized: IP {}'.format(request.remote_addr)) token.delete() diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index b3b6639a..152d5712 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -183,7 +183,8 @@ def getExpenseOverview(args): s1 = ExpensePaidFor.query.with_entities(ExpensePaidFor.expense_id.label("expense_id"), func.sum( ExpensePaidFor.factor).label('total')).group_by(ExpensePaidFor.expense_id).subquery() s2 = ExpensePaidFor.query.with_entities(ExpensePaidFor.expense_id.label("expense_id"), (ExpensePaidFor.factor.cast( - db.Float) / s1.c.total).label('factor')).filter(ExpensePaidFor.user_id == current_user.id).join(s1, ExpensePaidFor.expense_id == s1.c.expense_id).subquery() + db.Float) / s1.c.total).label('factor')).filter(ExpensePaidFor.user_id == current_user.id)\ + .join(s1, ExpensePaidFor.expense_id == s1.c.expense_id).subquery() factor = s2.c.factor diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index ad246238..89bf64ac 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -55,7 +55,6 @@ def updateItem(args, id): if not item: raise NotFoundRequest() if 'category' in args: - print(args) if not args['category']: item.category = None elif 'id' in args['category']: diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index cb5c030c..e3cbe5dc 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -36,6 +36,7 @@ class CreateList(Schema): class GetItems(Schema): orderby = fields.Integer() + class UpdateDescription(Schema): description = fields.String( required=True diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index fc504f7f..9469eedd 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -51,7 +51,7 @@ def getAllShoppingListItems(args, id): orderby = [Item.name] if ('orderby' in args): if (args['orderby'] == 1): - orderby = [Item.ordering==0, Item.ordering] + orderby = [Item.ordering == 0, Item.ordering] elif (args['orderby'] == 2): orderby = [Item.name] @@ -165,8 +165,8 @@ def removeShoppinglistItem(args, id): removed_at = None if 'removed_at' in args: - removed_at = datetime.fromtimestamp(args['removed_at']/1000, timezone.utc) - print(removed_at) + removed_at = datetime.fromtimestamp( + args['removed_at']/1000, timezone.utc) History.create_dropped(shoppinglist, item, description, removed_at) diff --git a/backend/app/helpers/db_model_mixin.py b/backend/app/helpers/db_model_mixin.py index e7f02689..52f0255c 100644 --- a/backend/app/helpers/db_model_mixin.py +++ b/backend/app/helpers/db_model_mixin.py @@ -1,11 +1,12 @@ from __future__ import annotations +from typing import Self from sqlalchemy import asc, desc from app import db class DbModelMixin(object): - def save(self) -> DbModelMixin: + def save(self) -> Self: """ Persist changes to current instance in db """ @@ -18,7 +19,7 @@ def save(self) -> DbModelMixin: return self - def assign(self, **kwargs) -> DbModelMixin: + def assign(self, **kwargs) -> Self: """ Update an entry """ @@ -27,7 +28,7 @@ def assign(self, **kwargs) -> DbModelMixin: return self - def assign_columns(self, args): + def assign_columns(self, args: dict): model_columns = list(self.__class__.__table__.columns.keys()) for k, v in args.items(): if k in model_columns: @@ -35,14 +36,14 @@ def assign_columns(self, args): return self - def update(self, details): + def update(self, details: dict): model_columns = list(self.__class__.__table__.columns.keys()) for k, v in details.items(): if k in model_columns and (v or v == ''): setattr(self, k, v) self.save() - def update_attr(self, key, value): + def update_attr(self, key: str, value): model_columns = list(self.__class__.__table__.columns.keys()) if key in model_columns: setattr(self, key, value) @@ -83,7 +84,7 @@ def delete(self): db.session.delete(self) db.session.commit() - def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: + def obj_to_dict(self, skip_columns: list[str] = None, include_columns: list[str] = None) -> dict: d = {} for column in self.__table__.columns: d[column.name] = getattr(self, column.name) @@ -100,7 +101,7 @@ def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: return d - def clone(self, overrides) -> DbModelMixin: + def clone(self, overrides) -> Self: new_self = self.__class__() new_self.assign_columns(self.obj_to_dict()) @@ -111,41 +112,41 @@ def clone(self, overrides) -> DbModelMixin: return new_self @classmethod - def find_by_id(cls, target_id) -> DbModelMixin: + def find_by_id(cls, target_id: int) -> Self: """ Find the row with specified id """ return cls.query.filter(cls.id == target_id).first() @classmethod - def find_all_by_id(cls, target_id): + def find_all_by_id(cls, target_id: int) -> list[Self]: """ Find all the rows with specified id """ return cls.query.filter(cls.id == target_id).all() @classmethod - def find_all_by_ids(cls, target_ids): + def find_all_by_ids(cls, target_ids: list[int]) -> list[Self]: """ Find all the rows with specified id """ return cls.query.filter(cls.id.in_(target_ids)).all() @classmethod - def delete_by_id(cls, target_id): + def delete_by_id(cls, target_id: int): mc = cls.find_by_id(target_id) if mc: mc.delete() @classmethod - def all(cls): + def all(cls) -> list[Self]: """ Return all instances of model """ return cls.query.order_by(cls.id).all() @classmethod - def all_by_name(cls): + def all_by_name(cls) -> list[Self]: """ Return all instances of model ordered by name IMPORTANT: requires name column @@ -153,7 +154,7 @@ def all_by_name(cls): return cls.query.order_by(cls.name).all() @classmethod - def first(cls) -> DbModelMixin: + def first(cls) -> Self: """ Returns the first entry of database """ @@ -164,7 +165,7 @@ def first(cls) -> DbModelMixin: return None @classmethod - def last(cls) -> DbModelMixin: + def last(cls) -> Self: """ Return the last entry of table in database """ diff --git a/backend/app/helpers/timestamp_mixin.py b/backend/app/helpers/timestamp_mixin.py index 18dc4394..ab93e0d9 100644 --- a/backend/app/helpers/timestamp_mixin.py +++ b/backend/app/helpers/timestamp_mixin.py @@ -1,41 +1,15 @@ from datetime import datetime - -from flask_sqlalchemy import BaseQuery from sqlalchemy import Column, DateTime -class Query(BaseQuery): - """ - Extends flask.ext.sqlalchemy.BaseQuery to add additional helper methods. - """ - - def notempty(self) -> bool: - """ - Returns the equivalent of ``bool(query.count())`` but using an - efficient SQL EXISTS function, so the database stops counting - after the first result is found. - """ - return self.session.query(self.exists()).first()[0] - - def isempty(self) -> bool: - """ - Returns the equivalent of ``not bool(query.count())`` but - using an efficient SQL EXISTS function, so the database stops - counting after the first result is found. - """ - return not self.session.query(self.exists()).first()[0] - - class TimestampMixin(object): """ Provides the :attr:`created_at` and :attr:`updated_at` audit timestamps """ - query_class = Query - - #: Timestamp for when this instance was created, in UTC + #: Timestamp for when this instance was created in UTC created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - #: Timestamp for when this instance was last updated (via the app), in UTC + #: Timestamp for when this instance was last updated in UTC updated_at = Column( DateTime, default=datetime.utcnow, diff --git a/backend/app/jobs/cluster_shoppings.py b/backend/app/jobs/cluster_shoppings.py index 1e380f5d..fcb422f6 100644 --- a/backend/app/jobs/cluster_shoppings.py +++ b/backend/app/jobs/cluster_shoppings.py @@ -9,7 +9,7 @@ def clusterShoppings(): dropped = History.find_dropped_by_shoppinglist_id(1) - if(len(dropped) == 0): + if (len(dropped) == 0): app.logger.info("no history to investigate") return None @@ -24,7 +24,7 @@ def clusterShoppings(): dbs = DBSCAN1D(eps=eps, min_samples=min_samples) labels = dbs.fit_predict(timestamps) - if(len(labels) == 0): + if (len(labels) == 0): app.logger.info("no shopping instances identified") return None diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index 713d9d78..bdd8fd91 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -29,6 +29,7 @@ def daily(): computeRecipeSuggestions(meal_instances) app.logger.info("--- daily analysis is completed ---") + @scheduler.task('interval', id='every30min', minutes=30) def halfHourly(): with app.app_context(): diff --git a/backend/app/jobs/recipe_suggestions.py b/backend/app/jobs/recipe_suggestions.py index dea181ec..ad563848 100644 --- a/backend/app/jobs/recipe_suggestions.py +++ b/backend/app/jobs/recipe_suggestions.py @@ -37,7 +37,7 @@ def findMealInstances(added, dropped): added_time = added_recipes[current_dropped.recipe_id] dropped_time = current_dropped.created_at # if duration threshold is met, consider it as a cooked meal - if(dropped_time - added_time >= + if (dropped_time - added_time >= datetime.timedelta(hours=MEAL_THRESHOLD)): meal = { "recipe_id": current_dropped.recipe_id, diff --git a/backend/app/models/association.py b/backend/app/models/association.py index f6a9fbde..903863a4 100644 --- a/backend/app/models/association.py +++ b/backend/app/models/association.py @@ -1,3 +1,4 @@ +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin @@ -33,7 +34,7 @@ def find_by_antecedent(cls, antecedent_id): return cls.query.filter(cls.antecedent_id == antecedent_id).order_by(cls.lift.desc()) @classmethod - def find_all(cls): + def find_all(cls) -> list[Self]: return cls.query.all() @classmethod diff --git a/backend/app/models/category.py b/backend/app/models/category.py index c0ca39f2..f48d1ffa 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -1,5 +1,5 @@ from __future__ import annotations -from unicodedata import category +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin @@ -24,25 +24,25 @@ def all_by_ordering(cls): return cls.query.order_by(cls.ordering, cls.name).all() @classmethod - def create_by_name(cls, name, default=False) -> Category: + def create_by_name(cls, name, default=False) -> Self: return cls( name=name, default=default, ).save() @classmethod - def find_by_name(cls, name) -> Category: + def find_by_name(cls, name) -> Self: return cls.query.filter(cls.name == name).first() @classmethod - def find_by_id(cls, id) -> Category: + def find_by_id(cls, id) -> Self: return cls.query.filter(cls.id == id).first() def reorder(self, newIndex: int): cls = self.__class__ self.ordering = newIndex - l = cls.query.order_by(cls.ordering, cls.name).all() + l: list[cls] = cls.query.order_by(cls.ordering, cls.name).all() oldIndex = list(map(lambda x: x.id, l)).index(self.id) if oldIndex < 0: diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index c2b62115..09e61331 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -1,3 +1,4 @@ +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin @@ -17,7 +18,7 @@ class Expense(db.Model, DbModelMixin, TimestampMixin): paid_for = db.relationship( 'ExpensePaidFor', back_populates='expense', cascade="all, delete-orphan") - def obj_to_full_dict(self): + def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() paidFor = ExpensePaidFor.query.filter(ExpensePaidFor.expense_id == self.id).join( ExpensePaidFor.user).order_by( @@ -28,11 +29,11 @@ def obj_to_full_dict(self): return res @classmethod - def find_by_name(cls, name): + def find_by_name(cls, name) -> Self: return cls.query.filter(cls.name == name).first() @classmethod - def find_by_id(cls, id): + def find_by_id(cls, id) -> Self: return cls.query.filter(cls.id == id).join(Expense.category, isouter=True).first() @@ -55,5 +56,5 @@ def obj_to_user_dict(self): return res @classmethod - def find_by_ids(cls, expense_id, user_id): + def find_by_ids(cls, expense_id, user_id) -> list[Self]: return cls.query.filter(cls.expense_id == expense_id, cls.user_id == user_id).first() diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py index 96c11b5f..1bf2d4ae 100644 --- a/backend/app/models/expense_category.py +++ b/backend/app/models/expense_category.py @@ -1,4 +1,5 @@ from __future__ import annotations +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin @@ -18,11 +19,11 @@ def obj_to_full_dict(self) -> dict: return res @classmethod - def find_by_name(cls, name) -> ExpenseCategory: + def find_by_name(cls, name) -> Self: return cls.query.filter(cls.name == name).first() @classmethod - def find_by_id(cls, id) -> ExpenseCategory: + def find_by_id(cls, id) -> Self: return cls.query.filter(cls.id == id).first() @classmethod diff --git a/backend/app/models/history.py b/backend/app/models/history.py index 822d46ae..ac0354c6 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin from .shoppinglist import ShoppinglistItems @@ -45,29 +46,29 @@ def create_dropped(cls, shoppinglist, item, description='', created_at=None): created_at=created_at or datetime.utcnow ).save() - def obj_to_item_dict(self): + def obj_to_item_dict(self) -> dict: res = self.item.obj_to_dict() res['timestamp'] = getattr(self, 'created_at') return res @classmethod - def find_added_by_shoppinglist_id(cls, shoppinglist_id): + def find_added_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]: return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.status == Status.ADDED).all() @classmethod - def find_dropped_by_shoppinglist_id(cls, shoppinglist_id): + def find_dropped_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]: return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.status == Status.DROPPED).all() @classmethod - def find_by_shoppinglist_id(cls, shoppinglist_id): + def find_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]: return cls.query.filter(cls.shoppinglist_id == shoppinglist_id).all() @classmethod - def find_all(cls): + def find_all(cls) -> list[Self]: return cls.query.all() @classmethod - def get_recent(cls, shoppinglist_id): + def get_recent(cls, shoppinglist_id: int) -> list[Self]: sq = db.session.query(ShoppinglistItems.item_id).filter( ShoppinglistItems.shoppinglist_id == shoppinglist_id).subquery().select(ShoppinglistItems.item_id) sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 8a022cfe..0937792d 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -1,4 +1,5 @@ from __future__ import annotations +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin from app.models.category import Category @@ -31,14 +32,14 @@ class Item(db.Model, DbModelMixin, TimestampMixin): consequents = db.relationship( "Association", back_populates="consequent", foreign_keys='Association.consequent_id') - def obj_to_dict(self): + def obj_to_dict(self) -> dict: res = super().obj_to_dict() if self.category_id: category = Category.find_by_id(self.category_id) res['category'] = category.obj_to_dict() return res - def obj_to_export_dict(self): + def obj_to_export_dict(self) -> dict: res = { "name": self.name, } @@ -47,30 +48,30 @@ def obj_to_export_dict(self): return res @classmethod - def create_by_name(cls, name: str, default=False) -> Item: + def create_by_name(cls, name: str, default=False) -> Self: return cls( name=name.strip(), default=default, ).save() @classmethod - def allByName(cls): + def allByName(cls) -> list[Self]: """ Return all instances of Item ordered by name """ return cls.query.order_by(cls.name).all() @classmethod - def find_by_name(cls, name: str) -> Item: + def find_by_name(cls, name: str) -> Self: name = name.strip() return cls.query.filter(cls.name == name).first() @classmethod - def find_by_id(cls, id) -> Item: + def find_by_id(cls, id) -> Self: return cls.query.filter(cls.id == id).first() @classmethod - def search_name(cls, name: str): + def search_name(cls, name: str) -> list[Self]: item_count = 11 found = [] diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 411db662..2f922385 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -1,4 +1,5 @@ from __future__ import annotations +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin from app.helpers.db_set_type import DbSetType @@ -31,7 +32,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): tags = db.relationship( 'RecipeTags', back_populates='recipe', cascade="all, delete-orphan") - def obj_to_dict(self): + def obj_to_dict(self) -> dict: res = super().obj_to_dict() res['planned_days'] = list(self.planned_days or set()) return res @@ -97,20 +98,20 @@ def compute_suggestion_ranking(cls): db.session.commit() @classmethod - def find_suggestions(cls): + def find_suggestions(cls) -> list[Self]: return cls.query.filter(cls.planned == False).filter( # noqa cls.suggestion_rank > 0).order_by(cls.suggestion_rank).all() @classmethod - def find_by_name(cls, name) -> Recipe: + def find_by_name(cls, name: str) -> Self: return cls.query.filter(cls.name == name).first() @classmethod - def find_by_id(cls, id) -> Recipe: + def find_by_id(cls, id: int) -> Self: return cls.query.filter(cls.id == id).first() @classmethod - def search_name(cls, name): + def search_name(cls, name: str) -> list[Self]: if '*' in name or '_' in name: looking_for = name.replace('_', '__')\ .replace('*', '%')\ @@ -120,7 +121,7 @@ def search_name(cls, name): return cls.query.filter(cls.name.ilike(looking_for)).order_by(cls.name).all() @classmethod - def all_by_name_with_filter(cls, filter): + def all_by_name_with_filter(cls, filter: list[str]) -> list[Self]: sq = db.session.query(RecipeTags.recipe_id).join(RecipeTags.tag).filter( Tag.name.in_(filter)).subquery() return db.session.query(cls).filter(cls.id.in_(sq)).order_by(cls.name).all() @@ -138,7 +139,7 @@ class RecipeItems(db.Model, DbModelMixin, TimestampMixin): item = db.relationship("Item", back_populates='recipes') recipe = db.relationship("Recipe", back_populates='items') - def obj_to_item_dict(self): + def obj_to_item_dict(self) -> dict: res = self.item.obj_to_dict() res['description'] = getattr(self, 'description') res['optional'] = getattr(self, 'optional') @@ -146,7 +147,7 @@ def obj_to_item_dict(self): res['updated_at'] = getattr(self, 'updated_at') return res - def obj_to_recipe_dict(self): + def obj_to_recipe_dict(self) -> dict: res = self.recipe.obj_to_dict() res['items'] = [ { @@ -158,7 +159,7 @@ def obj_to_recipe_dict(self): return res @classmethod - def find_by_ids(cls, recipe_id, item_id): + def find_by_ids(cls, recipe_id: int, item_id: int) -> Self: return cls.query.filter(cls.recipe_id == recipe_id, cls.item_id == item_id).first() @@ -172,12 +173,12 @@ class RecipeTags(db.Model, DbModelMixin, TimestampMixin): tag = db.relationship("Tag", back_populates='recipes') recipe = db.relationship("Recipe", back_populates='tags') - def obj_to_item_dict(self): + def obj_to_item_dict(self) -> dict: res = self.tag.obj_to_dict() res['created_at'] = getattr(self, 'created_at') res['updated_at'] = getattr(self, 'updated_at') return res @classmethod - def find_by_ids(cls, recipe_id, tag_id): + def find_by_ids(cls, recipe_id: int, tag_id: int) -> Self: return cls.query.filter(cls.recipe_id == recipe_id, cls.tag_id == tag_id).first() diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py index 3ef3d5f8..f6948dae 100644 --- a/backend/app/models/recipe_history.py +++ b/backend/app/models/recipe_history.py @@ -1,3 +1,4 @@ +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin from .recipe import Recipe @@ -24,38 +25,38 @@ class RecipeHistory(db.Model, DbModelMixin, TimestampMixin): status = db.Column(db.Enum(Status)) @classmethod - def create_added(cls, recipe): + def create_added(cls, recipe: Recipe) -> Self: return cls( recipe_id=recipe.id, status=Status.ADDED ).save() @classmethod - def create_dropped(cls, recipe): + def create_dropped(cls, recipe: Recipe) -> Self: return cls( recipe_id=recipe.id, status=Status.DROPPED ).save() - def obj_to_item_dict(self): + def obj_to_item_dict(self) -> dict: res = self.item.obj_to_dict() res['timestamp'] = getattr(self, 'created_at') return res @classmethod - def find_added(cls): + def find_added(cls) -> list[Self]: return cls.query.filter(cls.status == Status.ADDED).all() @classmethod - def find_dropped(cls): + def find_dropped(cls) -> list[Self]: return cls.query.filter(cls.status == Status.DROPPED).all() @classmethod - def find_all(cls): + def find_all(cls) -> list[Self]: return cls.query.all() @classmethod - def get_recent(cls): + def get_recent(cls) -> list[Self]: sq = db.session.query(Recipe.id).filter( Recipe.planned).subquery().select(Recipe.id) sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 2d224c89..d7c5d432 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,3 +1,4 @@ +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin from app.helpers.db_list_type import DbListType @@ -9,10 +10,10 @@ class Settings(db.Model, DbModelMixin, TimestampMixin): planner_feature = db.Column(db.Boolean(), primary_key=True, default=True) expenses_feature = db.Column(db.Boolean(), primary_key=True, default=True) - view_ordering = db.Column(DbListType(), default = list()) + view_ordering = db.Column(DbListType(), default=list()) @classmethod - def get(cls): + def get(cls) -> Self: settings = cls.query.first() if not settings: settings = cls() diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py index bda5937a..dd15d057 100644 --- a/backend/app/models/shoppinglist.py +++ b/backend/app/models/shoppinglist.py @@ -1,3 +1,4 @@ +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin @@ -11,7 +12,7 @@ class Shoppinglist(db.Model, DbModelMixin, TimestampMixin): items = db.relationship('ShoppinglistItems') @classmethod - def create(cls, name): + def create(cls, name) -> Self: return cls(name=name).save() @@ -26,7 +27,7 @@ class ShoppinglistItems(db.Model, DbModelMixin, TimestampMixin): item = db.relationship("Item", back_populates='shoppinglists') shoppinglist = db.relationship("Shoppinglist", back_populates='items') - def obj_to_item_dict(self): + def obj_to_item_dict(self) -> dict: res = self.item.obj_to_dict() res['description'] = getattr(self, 'description') res['created_at'] = getattr(self, 'created_at') @@ -34,5 +35,5 @@ def obj_to_item_dict(self): return res @classmethod - def find_by_ids(cls, shoppinglist_id, item_id): + def find_by_ids(cls, shoppinglist_id: int, item_id: int) -> Self: return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.item_id == item_id).first() diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py index 0c4f86fe..0b6da8d4 100644 --- a/backend/app/models/tag.py +++ b/backend/app/models/tag.py @@ -1,3 +1,4 @@ +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin @@ -11,20 +12,20 @@ class Tag(db.Model, DbModelMixin, TimestampMixin): recipes = db.relationship( 'RecipeTags', back_populates='tag', cascade="all, delete-orphan") - def obj_to_full_dict(self): + def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() return res @classmethod - def create_by_name(cls, name): + def create_by_name(cls, name: str) -> Self: return cls( name=name, ).save() @classmethod - def find_by_name(cls, name): + def find_by_name(cls, name: str) -> Self: return cls.query.filter(cls.name == name).first() @classmethod - def find_by_id(cls, id): + def find_by_id(cls, id: int) -> Self: return cls.query.filter(cls.id == id).first() diff --git a/backend/app/models/token.py b/backend/app/models/token.py index 4a344f2d..0d1e870b 100644 --- a/backend/app/models/token.py +++ b/backend/app/models/token.py @@ -1,6 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import Tuple +from typing import Self, Tuple from flask import request from app import db @@ -36,13 +36,14 @@ def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: return super().obj_to_dict(skip_columns=skip_columns, include_columns=include_columns) @classmethod - def find_by_jti(cls, jti) -> Token: + def find_by_jti(cls, jti: str) -> Self: return cls.query.filter(cls.jti == jti).first() @classmethod def delete_expired_refresh(cls): filter_before = datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES - db.session.query(cls).filter(cls.created_at <= filter_before, cls.type == 'refresh', ~cls.created_tokens.any()).delete(synchronize_session=False) + db.session.query(cls).filter(cls.created_at <= filter_before, cls.type == + 'refresh', ~cls.created_tokens.any()).delete(synchronize_session=False) db.session.commit() @classmethod @@ -71,13 +72,14 @@ def has_created_refresh_token(self) -> bool: def delete_created_access_tokens(self): if (self.type != 'refresh'): return - db.session.query(Token).filter(Token.refresh_token_id == self.id, Token.type == 'access').delete() + db.session.query(Token).filter(Token.refresh_token_id == + self.id, Token.type == 'access').delete() db.session.commit() @classmethod - def create_access_token(cls, user: User, refreshTokenModel: Token) -> Tuple[any, Token]: + def create_access_token(cls, user: User, refreshTokenModel: Self) -> Tuple[any, Self]: accesssToken = create_access_token(identity=user) - model = Token() + model = cls() model.jti = get_jti(accesssToken) model.type = 'access' model.name = refreshTokenModel.name @@ -87,14 +89,15 @@ def create_access_token(cls, user: User, refreshTokenModel: Token) -> Tuple[any, return accesssToken, model @classmethod - def create_refresh_token(cls, user: User, device: str = None, oldRefreshToken: Token = None) -> Tuple[any, Token]: + def create_refresh_token(cls, user: User, device: str = None, oldRefreshToken: Self = None) -> Tuple[any, Self]: assert device or oldRefreshToken if (oldRefreshToken and (oldRefreshToken.type != 'refresh' or oldRefreshToken.has_created_refresh_token())): oldRefreshToken.delete_token_familiy() - raise UnauthorizedRequest(message='Unauthorized: IP {} reused the same refresh token, loging out user'.format(request.remote_addr)) + raise UnauthorizedRequest( + message='Unauthorized: IP {} reused the same refresh token, loging out user'.format(request.remote_addr)) refreshToken = create_refresh_token(identity=user) - model = Token() + model = cls() model.jti = get_jti(refreshToken) model.type = 'refresh' model.name = device or oldRefreshToken.name @@ -106,9 +109,9 @@ def create_refresh_token(cls, user: User, device: str = None, oldRefreshToken: T return refreshToken, model @classmethod - def create_longlived_token(cls, user: User, device: str) -> Tuple[any, Token]: + def create_longlived_token(cls, user: User, device: str) -> Tuple[any, Self]: accesssToken = create_access_token(identity=user, expires_delta=False) - model = Token() + model = cls() model.jti = get_jti(accesssToken) model.type = 'llt' model.name = device diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 395aed1d..c28b715e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,3 +1,4 @@ +from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin from app.config import bcrypt @@ -24,13 +25,13 @@ class User(db.Model, DbModelMixin, TimestampMixin): expenses_paid_for = db.relationship( 'ExpensePaidFor', back_populates='user', cascade="all, delete-orphan") - def check_password(self, password): + def check_password(self, password: str) -> bool: return bcrypt.check_password_hash(self.password, password) - def set_password(self, password): + def set_password(self, password: str): self.password = bcrypt.generate_password_hash(password).decode('utf-8') - def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: + def obj_to_dict(self, skip_columns: list[str] = None, include_columns: list[str] = None) -> dict: if skip_columns: skip_columns = skip_columns + ['password'] else: @@ -47,11 +48,11 @@ def obj_to_full_dict(self) -> dict: return res @classmethod - def find_by_username(cls, username): + def find_by_username(cls, username: str) -> Self: return cls.query.filter(cls.username == username).first() @classmethod - def create(cls, username, password, name, owner=False): + def create(cls, username: str, password: str, name: str, owner=False) -> Self: return cls( username=username, password=bcrypt.generate_password_hash(password).decode('utf-8'), diff --git a/backend/app/util/__init__.py b/backend/app/util/__init__.py index 91fc286d..79ac2f91 100644 --- a/backend/app/util/__init__.py +++ b/backend/app/util/__init__.py @@ -1 +1 @@ -from .kitchenowl_json_provider import KitchenOwlJSONProvider \ No newline at end of file +from .kitchenowl_json_provider import KitchenOwlJSONProvider diff --git a/backend/app/util/kitchenowl_json_provider.py b/backend/app/util/kitchenowl_json_provider.py index b91ddc9c..9add79b3 100644 --- a/backend/app/util/kitchenowl_json_provider.py +++ b/backend/app/util/kitchenowl_json_provider.py @@ -7,4 +7,4 @@ def default(self, o): if isinstance(o, date): return int(round(o.replace(tzinfo=timezone.utc).timestamp() * 1000)) - return super().default(o) \ No newline at end of file + return super().default(o) From fefe662cb6fb6ebb52e92a8feeaf7f804a738aec Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 17 Dec 2022 02:29:33 +0100 Subject: [PATCH 231/496] fix: Python 11 arm build --- backend/Dockerfile | 10 ++++++++-- backend/wsgi.ini | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 5e1b1efc..8a445b34 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,8 +5,9 @@ FROM python:3.11-slim as builder RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - gcc g++ libffi-dev libpcre3-dev build-essential cargo - # libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build + gcc g++ libffi-dev libpcre3-dev build-essential cargo \ + libxml2-dev libxslt-dev + # cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build # Create virtual enviroment RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel @@ -20,6 +21,11 @@ RUN pip3 install --no-cache-dir -r requirements.txt && find /opt/venv \( -type d # ------------ FROM python:3.11-slim as runner +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + libxml2 \ + && rm -rf /var/lib/apt/lists/* + # Use virtual enviroment COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" diff --git a/backend/wsgi.ini b/backend/wsgi.ini index 1adc7622..41871f26 100644 --- a/backend/wsgi.ini +++ b/backend/wsgi.ini @@ -2,6 +2,7 @@ strict = true master = true enable-threads = true +lazy-apps=true vacuum = true single-interpreter = true die-on-term = true @@ -11,5 +12,6 @@ chmod-socket = 664 wsgi-file = wsgi.py callable = app http = 0.0.0.0:$(HTTP_PORT) +http-keepalive = true socket = 0.0.0.0:5000 -processes = 2 +procname-prefix-spaced = kitchenowl \ No newline at end of file From 65ff93f8e13f9efec81ed723185980d69839b82c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 17 Dec 2022 03:44:34 +0100 Subject: [PATCH 232/496] fix: python 11 build and tests --- .github/workflows/pytest.yml | 2 +- backend/Dockerfile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 294812f7..7d83582c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: '3.11' cache: 'pip' - name: Install dependencies run: | diff --git a/backend/Dockerfile b/backend/Dockerfile index 8a445b34..ef451070 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,8 +6,8 @@ FROM python:3.11-slim as builder RUN apt-get update \ && apt-get install --yes --no-install-recommends \ gcc g++ libffi-dev libpcre3-dev build-essential cargo \ - libxml2-dev libxslt-dev - # cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build + libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build \ + autoconf automake zlib1g-dev libjpeg62-turbo-dev # Create virtual enviroment RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel From d1a86a146fbddcddd14734d97f5ddb4b406d33bf Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 17 Dec 2022 04:28:29 +0100 Subject: [PATCH 233/496] chore: upgrade requirements --- backend/requirements.txt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 656b1e84..15865bee 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,8 +1,8 @@ -alembic==1.8.1 +alembic==1.9.0 appdirs==1.4.4 APScheduler==3.9.1 attrs==22.1.0 -autopep8==2.0.0 +autopep8==2.0.1 bcrypt==4.0.1 beautifulsoup4==4.11.1 black==21.12b0 @@ -33,7 +33,7 @@ Jinja2==3.1.2 joblib==1.2.0 jstyleson==0.0.2 kiwisolver==1.4.4 -lxml==4.9.1 +lxml==4.9.2 Mako==1.2.4 MarkupSafe==2.1.1 marshmallow==3.19.0 @@ -43,11 +43,11 @@ mf2py==1.1.2 mlxtend==0.21.0 mypy-extensions==0.4.3 numpy==1.23.5 -packaging==21.3 +packaging==22.0 pandas==1.5.2 -pathspec==0.10.2 +pathspec==0.10.3 Pillow==9.3.0 -platformdirs==2.5.4 +platformdirs==2.6.0 pluggy==1.0.0 py==1.11.0 pycodestyle==2.10.0 @@ -63,15 +63,15 @@ pytz==2022.6 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.24.0 +recipe-scrapers==14.26.0 regex==2022.10.31 requests==2.28.1 -scikit-learn==1.1.3 +scikit-learn==1.2.0 scipy==1.9.3 setuptools-scm==7.0.5 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.44 +SQLAlchemy==1.4.45 threadpoolctl==3.1.0 toml==0.10.2 tomli==1.2.3 @@ -84,6 +84,6 @@ tzdata==2022.7 tzlocal==4.2 urllib3==1.26.13 uWSGI==2.0.21 -w3lib==2.1.0 +w3lib==2.1.1 webencodings==0.5.1 Werkzeug==2.2.2 From c939d809d86588e0a23cb1d1b54b6782c7955b5e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 17 Dec 2022 04:28:43 +0100 Subject: [PATCH 234/496] Prepare beta v51 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index e9ca66c9..9657d768 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 65 -BACKEND_VERSION = 50 +BACKEND_VERSION = 51 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 3fc986cbeaf8279375ecbf740f53845773553e3a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 18 Dec 2022 23:43:58 +0100 Subject: [PATCH 235/496] feat: custom expense date --- .../controller/expense/expense_controller.py | 10 +++- backend/app/controller/expense/schemas.py | 2 + backend/app/models/expense.py | 2 + backend/migrations/versions/4b4823a384e7_.py | 50 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/versions/4b4823a384e7_.py diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 152d5712..20c68c95 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -1,5 +1,5 @@ import calendar -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.sql.expression import desc from app.errors import NotFoundRequest from flask import jsonify, Blueprint @@ -27,7 +27,7 @@ def getAllExpenses(args): filter.append(Expense.id.in_(subquery)) return jsonify([e.obj_to_full_dict() for e - in Expense.query.order_by(desc(Expense.created_at)).filter(*filter) + in Expense.query.order_by(desc(Expense.date)).filter(*filter) .join(Expense.category, isouter=True).limit(30).all() ]) @@ -51,6 +51,9 @@ def addExpense(args): expense = Expense() expense.name = args['name'] expense.amount = args['amount'] + if 'date' in args: + expense.date = datetime.fromtimestamp( + args['date']/1000, timezone.utc) if 'photo' in args: expense.photo = args['photo'] if 'category' in args: @@ -91,6 +94,9 @@ def updateExpense(args, id): # noqa: C901 expense.name = args['name'] if 'amount' in args: expense.amount = args['amount'] + if 'date' in args: + expense.date = datetime.fromtimestamp( + args['date']/1000, timezone.utc) if 'photo' in args: expense.photo = args['photo'] if 'category' in args: diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index afc0cb20..0d745dc6 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -27,6 +27,7 @@ class User(Schema): amount = fields.Float( required=True ) + date = fields.Integer() photo = fields.String() category = fields.Integer( allow_none=True @@ -51,6 +52,7 @@ class User(Schema): name = fields.String() amount = fields.Float() + date = fields.Integer() photo = fields.String() category = fields.Integer( allow_none=True diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index 09e61331..5baaef34 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin @@ -9,6 +10,7 @@ class Expense(db.Model, DbModelMixin, TimestampMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) amount = db.Column(db.Float()) + date = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) category_id = db.Column(db.Integer, db.ForeignKey('expense_category.id')) photo = db.Column(db.String()) paid_by_id = db.Column(db.Integer, db.ForeignKey('user.id')) diff --git a/backend/migrations/versions/4b4823a384e7_.py b/backend/migrations/versions/4b4823a384e7_.py new file mode 100644 index 00000000..2aa105d1 --- /dev/null +++ b/backend/migrations/versions/4b4823a384e7_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: 4b4823a384e7 +Revises: 55fe25bdf42b +Create Date: 2022-12-18 23:01:04.874862 + +""" +from alembic import op +import sqlalchemy as sa +from app import db + +from app.models.expense import Expense + + +# revision identifiers, used by Alembic. +revision = '4b4823a384e7' +down_revision = '55fe25bdf42b' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.add_column(sa.Column('date', sa.DateTime())) + + + + expenses = Expense.all() + for expense in expenses: + expense.date = expense.created_at + + try: + db.session.bulk_save_objects(expenses) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.alter_column('date', nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_column('date') + + # ### end Alembic commands ### From 7af08fdd15f9dedce697a305000e8ed614502d3a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 29 Dec 2022 18:27:42 +0100 Subject: [PATCH 236/496] chore: upgrade dependencies --- backend/requirements.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 15865bee..f2583100 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,11 +1,11 @@ -alembic==1.9.0 +alembic==1.9.1 appdirs==1.4.4 APScheduler==3.9.1 -attrs==22.1.0 +attrs==22.2.0 autopep8==2.0.1 bcrypt==4.0.1 beautifulsoup4==4.11.1 -black==21.12b0 +black==23.1a1 certifi==2022.12.7 cffi==1.15.1 charset-normalizer==2.1.1 @@ -42,12 +42,12 @@ mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.21.0 mypy-extensions==0.4.3 -numpy==1.23.5 +numpy==1.24.1 packaging==22.0 pandas==1.5.2 pathspec==0.10.3 Pillow==9.3.0 -platformdirs==2.6.0 +platformdirs==2.6.2 pluggy==1.0.0 py==1.11.0 pycodestyle==2.10.0 @@ -59,7 +59,7 @@ pyRdfa3==3.5.3 pytest==7.2.0 python-dateutil==2.8.2 python-editor==1.0.4 -pytz==2022.6 +pytz==2022.7 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 @@ -68,16 +68,16 @@ regex==2022.10.31 requests==2.28.1 scikit-learn==1.2.0 scipy==1.9.3 -setuptools-scm==7.0.5 +setuptools-scm==7.1.0 six==1.16.0 soupsieve==2.3.2 SQLAlchemy==1.4.45 threadpoolctl==3.1.0 toml==0.10.2 -tomli==1.2.3 +tomli==2.0.1 typed-ast==1.5.4 types-beautifulsoup4==4.11.6.1 -types-requests==2.28.11.5 +types-requests==2.28.11.7 types-urllib3==1.26.25.4 typing_extensions==4.4.0 tzdata==2022.7 From 852fe3ee5c8fd02eb3e4319b601881b90da14189 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 29 Dec 2022 22:11:49 +0100 Subject: [PATCH 237/496] Prepare release 52 --- backend/app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 9657d768..7ba08b69 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,8 +11,8 @@ import os -MIN_FRONTEND_VERSION = 65 -BACKEND_VERSION = 51 +MIN_FRONTEND_VERSION = 67 +BACKEND_VERSION = 52 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 5a80dbdc1e74a1b6e23c5dc1b96111026d42b25d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Jan 2023 12:54:53 +0100 Subject: [PATCH 238/496] fix: expense statistics --- backend/app/controller/expense/expense_controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 20c68c95..9e3a6089 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -1,5 +1,6 @@ import calendar from datetime import datetime, timezone +from dateutil.relativedelta import relativedelta from sqlalchemy.sql.expression import desc from app.errors import NotFoundRequest from flask import jsonify, Blueprint @@ -53,7 +54,7 @@ def addExpense(args): expense.amount = args['amount'] if 'date' in args: expense.date = datetime.fromtimestamp( - args['date']/1000, timezone.utc) + args['date']/1000, timezone.utc) if 'photo' in args: expense.photo = args['photo'] if 'category' in args: @@ -197,8 +198,7 @@ def getExpenseOverview(args): query = query.filter(Expense.id.in_(filterQuery)).join(s2) def getOverviewForMonthAgo(monthAgo: int): - monthStart = thisMonthStart.replace( - month=(thisMonthStart.month - monthAgo)) + monthStart = thisMonthStart - relativedelta(months=monthAgo) monthEnd = monthStart.replace(day=calendar.monthrange( monthStart.year, monthStart.month)[1]) return { From 18d05e6145a9a2bdf851e78b8f58462a0b5b6467 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Jan 2023 12:55:32 +0100 Subject: [PATCH 239/496] Prepare release 53 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 7ba08b69..6ad336e2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 67 -BACKEND_VERSION = 52 +BACKEND_VERSION = 53 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From d14890824637035715d52b91c5a75819e7cdeef0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 4 Jan 2023 16:53:49 +0100 Subject: [PATCH 240/496] feat: Parse and merge item descriptions (TomBursch/kitchenowl-backend#16) --- .../shoppinglist/shoppinglist_controller.py | 46 ++--- backend/app/models/history.py | 11 +- backend/app/util/description_merger.py | 178 ++++++++++++++++++ backend/requirements.txt | 1 + backend/tests/util/__init__.py | 0 backend/tests/util/test_description_merger.py | 45 +++++ 6 files changed, 255 insertions(+), 26 deletions(-) create mode 100644 backend/app/util/description_merger.py create mode 100644 backend/tests/util/__init__.py create mode 100644 backend/tests/util/test_description_merger.py diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 9469eedd..3c3ad2e3 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -7,6 +7,7 @@ AddItemByName, CreateList, AddRecipeItems, GetItems) from app.errors import NotFoundRequest from datetime import datetime, timedelta, timezone +import app.util.description_merger as description_merger shoppinglist = Blueprint('shoppinglist', __name__) @@ -189,32 +190,31 @@ def addRecipeItems(args, id): if not shoppinglist: raise NotFoundRequest() - for recipeItem in args['items']: - item = Item.find_by_id(recipeItem['id']) - if item: - description = recipeItem['description'] - con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) - if con: - # merge descriptions - if description and con.description: - con.description = description + ', ' + con.description - elif description: - con.description = description + ', ...' - elif con.description: - if not con.description.endswith('...'): - con.description = con.description + ', ...' + try: + for recipeItem in args['items']: + item = Item.find_by_id(recipeItem['id']) + if item: + description = recipeItem['description'] + con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) + if con: + # merge descriptions + con.description = description_merger.merge( + con.description, description) + db.session.add(con) else: - con.description = '...' - con.save() - else: - con = ShoppinglistItems(description=description) - con.item = item - con.shoppinglist = shoppinglist - con.save() + con = ShoppinglistItems(description=description) + con.item = item + con.shoppinglist = shoppinglist + db.session.add(con) - History.create_added(shoppinglist, item) + db.session.add( + History.create_added_without_save(shoppinglist, item)) + + db.session.commit() + except Exception as e: + db.session.rollback() + raise e - shoppinglist.save() return jsonify(item.obj_to_dict()) # @shoppinglist.route('//item', methods=['POST']) diff --git a/backend/app/models/history.py b/backend/app/models/history.py index ac0354c6..b3864c7b 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -28,16 +28,21 @@ class History(db.Model, DbModelMixin, TimestampMixin): description = db.Column('description', db.String()) @classmethod - def create_added(cls, shoppinglist, item, description=''): + def create_added_without_save(cls, shoppinglist, item, description='') -> Self: return cls( shoppinglist_id=shoppinglist.id, item_id=item.id, status=Status.ADDED, description=description - ).save() + ) + + @classmethod + def create_added(cls, shoppinglist, item, description='') -> Self: + return cls.create_added_without_save(shoppinglist, item, description).save() + @classmethod - def create_dropped(cls, shoppinglist, item, description='', created_at=None): + def create_dropped(cls, shoppinglist, item, description='', created_at=None) -> Self: return cls( shoppinglist_id=shoppinglist.id, item_id=item.id, diff --git a/backend/app/util/description_merger.py b/backend/app/util/description_merger.py new file mode 100644 index 00000000..88acdcd3 --- /dev/null +++ b/backend/app/util/description_merger.py @@ -0,0 +1,178 @@ +from typing import Self +from lark import Lark, Transformer, Tree, Token +from lark.visitors import Interpreter +import re + +grammar = r''' +start: ","* item (","+ item)* + +item: NUMBER? unit? +unit: COUNT | SI_WEIGHT | SI_VOLUME | DESCRIPTION +COUNT.5: "x"i +SI_WEIGHT.5: "mg"i | "g"i | "kg"i +SI_VOLUME.5: "ml"i | "l"i +DESCRIPTION: /[^0-9, ][^,]*/ + +DECIMAL: INT "." INT? | "." INT | INT "," INT +FLOAT: INT _EXP | DECIMAL _EXP? +NUMBER.10: FLOAT | INT + +%ignore WS +%import common (_EXP, INT, WS) +''' + + +class TreeItem(Tree): + # Quick and dirty class to not build an AST + def __init__(self, data: str, children) -> None: + self.data = data + self.children = children + self.number: Token = None + self.unit: Tree = None + for c in children: + if isinstance(c, Token) and c.type == "NUMBER": + self.number = c + else: + self.unit = c + + def unitIsCount(self) -> bool: + return not self.unit or self.unit.children[0].type == "COUNT" + + def sameUnit(self, other: Self) -> bool: + return (self.unitIsCount() and other.unitIsCount()) or (self.unit and other.unit and + (self.unit.children[0].type == other.unit.children[0].type and not other.unit.children[0].type == "DESCRIPTION" + or self.unit.children[0].lower().strip() == other.unit.children[0].lower().strip())) + + +class T(Transformer): + def NUMBER(self, number: Token): + return number.update(value=float(number.replace(",", "."))) + + def item(self, children): + return TreeItem("item", children) + + +class Printer(Interpreter): + def item(self, item: Tree): + res = "" + for child in item.children: + if isinstance(child, Tree): + if res and child.children[0].type == "DESCRIPTION": + res += " " + res += self.visit(child) + elif child.type == 'NUMBER': + value = round(child.value, 5) + res += str(int(value)) if value.is_integer() else f"{value}" + return res + + def unit(self, unit: Tree): + return unit.children[0] + + def start(self, start: Tree): + return ", ".join([s for s in self.visit_children(start) if s]) + + +# Objects +parser = Lark(grammar) +transformer = T() + + +def merge(description: str, added: str) -> str: + if not description: + description = "1x" + if not added: + added = "1x" + description = clean(description) + added = clean(added) + desTree = transformer.transform(parser.parse(description)) + addTree = transformer.transform(parser.parse(added)) + + for item in addTree.children: + targetItem: TreeItem = next(desTree.find_pred(lambda t: t.data == "item" and item.sameUnit(t)), None) + + if not targetItem: # No item with same unit + desTree.children.append(item) + else: # Found item with same unit + if not targetItem.number: # Add number if not present and space behind it if description + targetItem.number = Token("NUMBER", 1) + targetItem.children.insert(0, targetItem.number) + + # Add up numbers + unit: Tree = item.unit + if unit and unit.children[0].type == "SI_WEIGHT": + merge_SI_Weight(targetItem, item) + elif unit and unit.children[0].type == "SI_VOLUME": + merge_SI_Volume(targetItem, item) + else: + targetItem.number.value = targetItem.number.value + \ + (item.number.value if item.number else 1.0) + + return Printer().visit(desTree) + + +def clean(input: str) -> str: + input = re.sub( + '¼|½|¾|⅐|⅑|⅒|⅓|⅔|⅕|⅖|⅗|⅘|⅙|⅚|⅛|⅜|⅝|⅞', + lambda match: { + '¼': '0.25', + '½': '0.5', + '¾': '0.75', + '⅐': '0.142857142857', + '⅑': '0.111111111111', + '⅒': '0.1', + '⅓': '0.333333333333', + '⅔': '0.666666666667', + '⅕': '0.2', + '⅖': '0.4', + '⅗': '0.6', + '⅘': '0.8', + '⅙': '0.166666666667', + '⅚': '0.833333333333', + '⅛': '0.125', + '⅜': '0.375', + '⅝': '0.625', + '⅞': '0.875', + }.get(match.group(), match.group), + input + ) + + # replace 1/2 with .5 + input = re.sub( + r'(\d+((\.)\d+)?)\/(\d+((\.)\d+)?)', + lambda match: str(float(match.group(1)) / + float(match.group(4))), + input + ) + + return input + + +def merge_SI_Volume(base: TreeItem, add: TreeItem) -> None: + def toMl(x: float, unit: str): + return {'ml': x, 'l': 1000*x}.get(unit.lower()) + + base.number.value = toMl(base.number.value, base.unit.children[0]) + \ + toMl(add.number.value if add.number else 1.0, add.unit.children[0]) + base.unit.children[0] = base.unit.children[0].update(value='ml') + + # Simplify if possible + if (base.number.value/1000).is_integer(): + base.number.value = base.number.value/1000 + base.unit.children[0] = base.unit.children[0].update(value='L') + + +def merge_SI_Weight(base: TreeItem, add: TreeItem) -> None: + def toG(x: float, unit: str): + return {'mg': x/1000, 'g': x, 'kg': 1000*x}.get(unit.lower()) + + base.number.value = toG(base.number.value, base.unit.children[0]) + \ + toG(add.number.value if add.number else 1.0, add.unit.children[0]) + base.unit.children[0] = base.unit.children[0].update(value='g') + + # Simplify when possible + if base.number.value < 1: + base.number.value = base.number.value*1000 + base.unit.children[0] = base.unit.children[0].update(value='mg') + elif (base.number.value/1000).is_integer(): + base.number.value = base.number.value/1000 + base.unit.children[0] = base.unit.children[0].update(value='kg') diff --git a/backend/requirements.txt b/backend/requirements.txt index f2583100..25031ac5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -33,6 +33,7 @@ Jinja2==3.1.2 joblib==1.2.0 jstyleson==0.0.2 kiwisolver==1.4.4 +lark==1.1.5 lxml==4.9.2 Mako==1.2.4 MarkupSafe==2.1.1 diff --git a/backend/tests/util/__init__.py b/backend/tests/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/util/test_description_merger.py b/backend/tests/util/test_description_merger.py new file mode 100644 index 00000000..f3a3f5e4 --- /dev/null +++ b/backend/tests/util/test_description_merger.py @@ -0,0 +1,45 @@ +import pytest +import app.util.description_merger as description_merger + + +@pytest.mark.parametrize("des,added,result", [ + ("", "", "2x"), + ("", "300ml", "1x, 300ml"), + ("300ml", "1", "300ml, 1"), + ("300ml, 1x", "2", "300ml, 3x"), + ("300ml, 1", "5ml", "305ml, 1"), + ("300ml, 1", "2 halves", "300ml, 1, 2 halves"), + ("300ml, 1", "Gouda", "300ml, 1, Gouda"), + ("½", "1/2", "1"), + ("500g", "1kg", "1500g"), + ("Gouda", "Gouda", "2 Gouda"), + ("Gouda", "Emmentaler", "Gouda, Emmentaler"), + ("Gouda", "", "Gouda, 1x"), + ("1 bag of Kartoffeln", "1 bag of Kartoffeln", "2 bag of Kartoffeln"), + (",500ml,", "500ml", "1L"), + ("2,5ml,", "1,5ml", "4ml"), + ("ml", "1L", "1001ml"), + ("1L", "10ml", "1010ml"), + ("1L", "2L", "3L"), + ("1 cup of 2ml sugar", "other", "1 cup of 2ml sugar, other"), + ("1 TL", "1tl", "2 TL"), + ("1", "1X", "2"), + (".2233", "1/5", "0.4233"), + ("1x", "1/3", "1.33333x"), + ("1", "1, 1, 2", "5"), + ("1, 2", "1", "2, 2"), + ("1,2", "1", "2.2") + # ("1-2", "3-4", "1-2, 3-4"), + # ("100g fresh", "100g fresh", "200g fresh") +]) +def testDescriptionMerge(des, added, result): + assert description_merger.merge(des, added) == result + + +@pytest.mark.parametrize("input,result", [ + ("½", "0.5"), + ("1/2", "0.5"), + ("500/1000", "0.5") +]) +def testClean(input, result): + assert description_merger.clean(input) == result From 73d65a408520c74b80b975c06f40e70c61c6f153 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 4 Jan 2023 16:57:52 +0100 Subject: [PATCH 241/496] Prepare release 54 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6ad336e2..103882b5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 67 -BACKEND_VERSION = 53 +BACKEND_VERSION = 54 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 0f10bd4f17bb669daf72e2f6b35aafd2f4920a39 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 5 Jan 2023 23:55:54 +0100 Subject: [PATCH 242/496] chore: update CI --- .github/workflows/publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 719c5191..957128d6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,10 +28,10 @@ jobs: run: | if [[ $REF == "refs/tags/v"* ]] then - echo "tags=$BASE_TAG:latest, $BASE_TAG:beta" >> $GITHUB_ENV + echo "tags=$BASE_TAG:latest, $BASE_TAG:beta, $BASE_TAG:${REF#refs/tags/}" >> $GITHUB_ENV elif [[ $REF == "refs/tags/beta-v"* ]] then - echo "tags=$BASE_TAG:beta" >> $GITHUB_ENV + echo "tags=$BASE_TAG:beta, $BASE_TAG:${REF#refs/tags/}" >> $GITHUB_ENV else echo "tags=$BASE_TAG:dev" >> $GITHUB_ENV fi @@ -58,7 +58,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64 #,linux/arm/v7 #,linux/386,linux/arm/v6 + platforms: linux/amd64,linux/arm64,linux/arm/v7 #,linux/386,linux/arm/v6 push: true tags: ${{ env.tags }} # cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache From 38494b3c7805efcc7f3f54c298a3e932b116a7a5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 6 Jan 2023 15:39:44 +0100 Subject: [PATCH 243/496] CI: deactivate 32bit --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 957128d6..f6ab1a76 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,7 +58,7 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/arm/v7 #,linux/386,linux/arm/v6 + platforms: linux/amd64,linux/arm64 #,linux/arm/v7 #,linux/386,linux/arm/v6 push: true tags: ${{ env.tags }} # cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache From bda0b2673aeeee66bdb8050b315cde23dc7fa903 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 8 Jan 2023 17:30:54 +0100 Subject: [PATCH 244/496] feat: Multiple shopping lists --- backend/app/config.py | 8 +- .../app/controller/shoppinglist/schemas.py | 8 +- .../shoppinglist/shoppinglist_controller.py | 80 ++++++++++--------- backend/app/models/history.py | 10 +-- backend/app/models/shoppinglist.py | 9 ++- 5 files changed, 67 insertions(+), 48 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 103882b5..117fe31a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,6 +1,6 @@ from datetime import timedelta from sqlalchemy import MetaData -from app.errors import NotFoundRequest, UnauthorizedRequest +from app.errors import NotFoundRequest, UnauthorizedRequest, ForbiddenRequest, InvalidUsage from app.util import KitchenOwlJSONProvider from flask import Flask, jsonify, request from flask_migrate import Migrate @@ -90,6 +90,12 @@ def unhandled_exception(e): if type(e) is NotFoundRequest: app.logger.info(e) return jsonify(message="Requested resource not found"), 404 + if type(e) is ForbiddenRequest: + app.logger.warning(e) + return jsonify(message="Request forbidden"), 403 + if type(e) is InvalidUsage: + app.logger.warning(e) + return jsonify(message="Request invalid"), 400 if type(e) is UnauthorizedRequest: app.logger.warning(e) return jsonify(message="Request unauthorized"), 401 diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index e3cbe5dc..39e55b1c 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -29,7 +29,13 @@ class Meta: class CreateList(Schema): name = fields.String( - required=True + required=True, + validate=lambda a: a and not a.isspace() + ) + +class UpdateList(Schema): + name = fields.String( + validate=lambda a: a and not a.isspace() ) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 3c3ad2e3..53ae0886 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -4,8 +4,8 @@ from app.models import Item, Shoppinglist, History, Status, Association, ShoppinglistItems from app.helpers import validate_args from .schemas import (RemoveItem, UpdateDescription, - AddItemByName, CreateList, AddRecipeItems, GetItems) -from app.errors import NotFoundRequest + AddItemByName, CreateList, AddRecipeItems, GetItems, UpdateList) +from app.errors import NotFoundRequest, ForbiddenRequest from datetime import datetime, timedelta, timezone import app.util.description_merger as description_merger @@ -17,19 +17,51 @@ def before_first_request(): # Add default shoppinglist if (not Shoppinglist.find_by_id(1)): - sl = Shoppinglist( + Shoppinglist( name='Default' - ) - sl.save() + ).save() -@shoppinglist.route('//item/', methods=['GET']) +@shoppinglist.route('', methods=['POST']) @jwt_required() -def getShoppingListItem(id, item_id): - item = Item.find_by_id(item_id) - if not item: +@validate_args(CreateList) +def createShoppinglist(args): + return jsonify(Shoppinglist(name=args['name']).save().obj_to_dict()) + + +@shoppinglist.route('', methods=['GET']) +@jwt_required() +def getShoppinglists(): + shoppinglists = Shoppinglist.all() + return jsonify([e.obj_to_dict() for e in shoppinglists]) + + +@shoppinglist.route('/', methods=['POST']) +@jwt_required() +@validate_args(UpdateList) +def updateShoppinglist(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: raise NotFoundRequest() - return jsonify(item.obj_to_dict()) + + if 'name' in args: + shoppinglist.name = args['name'] + + shoppinglist.save() + return jsonify(shoppinglist.obj_to_dict()) + + +@shoppinglist.route('/', methods=['DELETE']) +@jwt_required() +def deleteShoppinglist(id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + if shoppinglist.isDefault(): + return ForbiddenRequest() + shoppinglist.delete() + + return jsonify({'msg': 'DONE'}) @shoppinglist.route('//item/', methods=['POST']) @@ -174,14 +206,6 @@ def removeShoppinglistItem(args, id): return jsonify({'msg': "DONE"}) -@shoppinglist.route('', methods=['POST']) -@jwt_required() -@validate_args(CreateList) -def createList(args): - return jsonify(Shoppinglist.create( - args['name']).save().obj_to_dict()) - - @shoppinglist.route('//recipeitems', methods=['POST']) @jwt_required() @validate_args(AddRecipeItems) @@ -216,23 +240,3 @@ def addRecipeItems(args, id): raise e return jsonify(item.obj_to_dict()) - -# @shoppinglist.route('//item', methods=['POST']) -# @jwt_required() -# @validate_args(UpdateDescription) -# def updateDescription(args, id): -# item = ShoppinglistItem.find_by_ids(id, args['item_id']) -# if (not item): -# raise Exception() -# item.desciption = args['description'] -# item.save() -# return jsonify(item.obj_to_dict()) - - -@shoppinglist.route('/', methods=['GET']) -@jwt_required() -def getShoppinglist(id): - shoppinglist = Shoppinglist.find_by_id(id) - if not shoppinglist: - raise NotFoundRequest() - return jsonify(shoppinglist.obj_to_dict()) diff --git a/backend/app/models/history.py b/backend/app/models/history.py index b3864c7b..62ada5b0 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -35,11 +35,10 @@ def create_added_without_save(cls, shoppinglist, item, description='') -> Self: status=Status.ADDED, description=description ) - + @classmethod def create_added(cls, shoppinglist, item, description='') -> Self: return cls.create_added_without_save(shoppinglist, item, description).save() - @classmethod def create_dropped(cls, shoppinglist, item, description='', created_at=None) -> Self: @@ -74,8 +73,9 @@ def find_all(cls) -> list[Self]: @classmethod def get_recent(cls, shoppinglist_id: int) -> list[Self]: - sq = db.session.query(ShoppinglistItems.item_id).filter( - ShoppinglistItems.shoppinglist_id == shoppinglist_id).subquery().select(ShoppinglistItems.item_id) + sq = db.session.query( + ShoppinglistItems.item_id).subquery().select(cls.item_id) sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( cls.item_id.notin_(sq)).group_by(cls.item_id).join(cls.item).subquery().select(cls.id) - return cls.query.filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) + return cls.query.filter( + cls.shoppinglist_id == shoppinglist_id).filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py index dd15d057..1f835034 100644 --- a/backend/app/models/shoppinglist.py +++ b/backend/app/models/shoppinglist.py @@ -9,11 +9,14 @@ class Shoppinglist(db.Model, DbModelMixin, TimestampMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128), unique=True) - items = db.relationship('ShoppinglistItems') + items = db.relationship('ShoppinglistItems', cascade="all, delete-orphan") @classmethod - def create(cls, name) -> Self: - return cls(name=name).save() + def getDefault(cls) -> Self: + return cls.find_by_id(1) + + def isDefault(self) -> bool: + return self.id == 1 class ShoppinglistItems(db.Model, DbModelMixin, TimestampMixin): From e05f52da543980e95d2ed5dfe9fa98ff3d707cc3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 8 Jan 2023 17:33:06 +0100 Subject: [PATCH 245/496] Prepare beta v55 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 117fe31a..7825950b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ MIN_FRONTEND_VERSION = 67 -BACKEND_VERSION = 54 +BACKEND_VERSION = 55 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 69cd1b2d26c5f4dcc5d75599864546d921e9195a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 8 Jan 2023 17:42:29 +0100 Subject: [PATCH 246/496] fix: missing import fields --- backend/app/controller/exportimport/schemas.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index 6063cf7f..01a4832a 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -28,6 +28,11 @@ class RecipeItem(Schema): description = fields.String( load_default='' ) + time = fields.Integer() + cook_time = fields.Integer() + prep_time = fields.Integer() + yields = fields.Integer() + source = fields.String() items = fields.List(fields.Nested(RecipeItem)) items = fields.List(fields.Nested(Item)) From eee5ad68ae6c5c17d558a5c246125c0298514e32 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 12 Jan 2023 00:38:11 +0100 Subject: [PATCH 247/496] feat: update template items --- backend/app/service/export_import.py | 45 +- backend/templates/add_items_from_server.py | 77 ++ backend/templates/attributes.json | 464 +++++++ backend/templates/de.json | 1088 ----------------- backend/templates/en.json | 484 -------- .../fill_attributes_with_missing_items.py | 25 + backend/templates/l10n/de.json | 401 ++++++ backend/templates/l10n/en.json | 433 +++++++ 8 files changed, 1438 insertions(+), 1579 deletions(-) create mode 100644 backend/templates/add_items_from_server.py create mode 100644 backend/templates/attributes.json delete mode 100644 backend/templates/de.json delete mode 100644 backend/templates/en.json create mode 100644 backend/templates/fill_attributes_with_missing_items.py create mode 100644 backend/templates/l10n/de.json create mode 100644 backend/templates/l10n/en.json diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index a080a580..cffa6c88 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -8,15 +8,47 @@ def importFromLanguage(lang, bulkSave=False): - file_path = f'{APP_DIR}/../templates/{lang}.json' + file_path = f'{APP_DIR}/../templates/l10n/{lang}.json' if lang not in SUPPORTED_LANGUAGES or not exists(file_path): raise NotFoundRequest('Language code not supported') with open(file_path, 'r') as f: data = json.load(f) - importFromDict(data, True, bulkSave=bulkSave) + with open(f'{APP_DIR}/../templates/attributes.json', 'r') as f: + attributes = json.load(f) + + t0 = time.time() + models = [] + for key, name in data["items"].items(): + item = Item.find_by_name(name) + if not item: + item = Item() + item.name = name + item.default = True + + # Category not already set for existing item and category set for template and category translation exist for language + if not item.category_id and key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: + category_name = data["categories"][attributes["items"] + [key]["category"]] + category = Category.find_by_name(category_name) + if not category: + category = Category.create_by_name(category_name, True) + item.category = category + if not bulkSave: + item.save() + else: + models.append(item) + + if bulkSave: + try: + db.session.add_all(models) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + app.logger.info(f"Import took: {(time.time() - t0):.3f}s") -def importFromDict(args, default=False, bulkSave=False, override=False): # noqa +def importFromDict(args, bulkSave=False, override=False): # noqa t0 = time.time() models = [] if "items" in args: @@ -25,12 +57,11 @@ def importFromDict(args, default=False, bulkSave=False, override=False): # noqa if not item: item = Item() item.name = importItem['name'] - item.default = default if "category" in importItem and not item.category_id: category = Category.find_by_name(importItem['category']) if not category: category = Category.create_by_name( - importItem['category'], default) + importItem['category']) item.category = category if not bulkSave: item.save() @@ -69,7 +100,7 @@ def importFromDict(args, default=False, bulkSave=False, override=False): # noqa for recipeItem in importRecipe['items']: item = Item.find_by_name(recipeItem['name']) if not item: - item = Item.create_by_name(recipeItem['name'], default) + item = Item.create_by_name(recipeItem['name']) con = RecipeItems( description=recipeItem['description'], optional=recipeItem['optional'] @@ -84,7 +115,7 @@ def importFromDict(args, default=False, bulkSave=False, override=False): # noqa for tagName in args['tags']: tag = Tag.find_by_name(tagName) if not tag: - tag = Tag.create_by_name(recipeItem['name']) + tag = Tag.create_by_name(tagName) con = RecipeTags() con.tag = tag con.recipe = recipe diff --git a/backend/templates/add_items_from_server.py b/backend/templates/add_items_from_server.py new file mode 100644 index 00000000..b96d585e --- /dev/null +++ b/backend/templates/add_items_from_server.py @@ -0,0 +1,77 @@ +import requests +import json +import os + + +SERVER_URL = "http://localhost:5000" +TOKEN = "" + +DEEPL_AUTH_KEY = "" +SOURCE_LANG_CODE = "" + +BASE_PATH = os.path.dirname(os.path.abspath(__file__)) + + +def nameToKey(name: str) -> str: + return name.lower().strip().replace(" ", "_") + + +def main(): + if not SOURCE_LANG_CODE: + print("Missing source language") + return + if SOURCE_LANG_CODE != "en" and not DEEPL_AUTH_KEY: + print("For languages other than english an deepl token is required! Make sure the source languages is supported: https://www.deepl.com/docs-api/translate-text/translate-text/") + return + if SOURCE_LANG_CODE != "en" and not DEEPL_AUTH_KEY: + print("For languages other than english an deepl token is required! Make sure the source languages is supported: https://www.deepl.com/docs-api/translate-text/translate-text/") + return + if not SERVER_URL or not TOKEN: + print("Server is not configured") + return + + # Get item export from server + add_items: list = json.loads(requests.get( + SERVER_URL + "/api/export/items", headers={'Authorization': 'Bearer ' + TOKEN}).content)["items"] + + # read en file + with open(BASE_PATH + "/l10n/en.json", encoding="utf8") as f: + en = json.load(f) + + # translate original file (used as the key) and write to file + if SOURCE_LANG_CODE != "en": + if os.path.exists(BASE_PATH + "/l10n/" + SOURCE_LANG_CODE + ".json"): + with open(BASE_PATH + "/l10n/" + SOURCE_LANG_CODE + ".json", "r", encoding="utf8") as f: + content = f.read() + if content: + source = json.loads(content) + else: + source = {} + else: + source = {} + + if "items" not in source: + source["items"] = {} + + for item in add_items: + item["original"] = item["name"] + item["name"] = json.loads(requests.post("https://api-free.deepl.com/v2/translate", {"target_lang": "EN-US", "source_lang": SOURCE_LANG_CODE.upper(), "text": item["name"]}, + headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)['translations'][0]["text"] + + if (nameToKey(item["name"]) not in source["items"]): + source["items"][nameToKey(item["name"])] = item["original"] + + with open(BASE_PATH + "/l10n/" + SOURCE_LANG_CODE + ".json", "w", encoding="utf8") as f: + f.write(json.dumps(source, ensure_ascii=False, + indent=2, sort_keys=True)) + + for item in add_items: + if (nameToKey(item["name"]) not in en["items"]): + en["items"][nameToKey(item["name"])] = item["name"] + + with open(BASE_PATH + "/l10n/en.json", "w", encoding="utf8") as f: + f.write(json.dumps(en, ensure_ascii=False, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json new file mode 100644 index 00000000..d69bac70 --- /dev/null +++ b/backend/templates/attributes.json @@ -0,0 +1,464 @@ +{ + "items": { + "aioli": {}, + "amaretto": {}, + "apple": { + "category": "fruits_vegetables" + }, + "apple_pulp": {}, + "applesauce": {}, + "apérol": {}, + "arugula": {}, + "asian_egg_noodles": {}, + "aspargus": {}, + "aspirin": {}, + "avocado": {}, + "baby_spinach": {}, + "bacon": {}, + "baguette": {}, + "baguettes": {}, + "bakefish": {}, + "baking_cocoa": {}, + "baking_mix": {}, + "baking_paper": {}, + "baking_powder": {}, + "baking_soda": {}, + "baking_yeast": {}, + "balsamic_vinegar": {}, + "bananas": {}, + "basil": {}, + "basmati_rice": {}, + "bathroom_cleaner": {}, + "batteries": {}, + "bay_leaf": {}, + "beans": {}, + "beer": {}, + "beet": {}, + "beetroot": {}, + "birthday_card": {}, + "black_beans": {}, + "bockwurst": {}, + "bodywash": {}, + "bread": { + "category": "bread" + }, + "breadcrumbs": {}, + "broccoli": {}, + "brown_sugar": {}, + "brussels_sprouts": {}, + "buffalo_mozzarella": {}, + "buko": {}, + "buns": {}, + "burger_buns": {}, + "burger_patties": {}, + "burger_sauces": {}, + "butter": {}, + "butter_cookies": {}, + "button_cells": {}, + "börek_cheese": {}, + "cake": {}, + "cake_icing": {}, + "cane_sugar": {}, + "cannelloni": {}, + "canola_oil": {}, + "cardamom": {}, + "carrots": {}, + "cashews": {}, + "cat_treats": {}, + "cauliflower": {}, + "celeriac": {}, + "celery": {}, + "cereal_bar": {}, + "cheddar": { + "category": "dairy" + }, + "cheese": { + "category": "dairy" + }, + "cherry_tomatoes": {}, + "chickpeas": {}, + "chili_oil": {}, + "chips": {}, + "chives": {}, + "chocolate": {}, + "chopped_tomatoes": {}, + "ciabatta": {}, + "cider_vinegar": {}, + "cilantro": {}, + "cinnamon": {}, + "cinnamon_stick": {}, + "cocktail_sauce": {}, + "cocktail_tomatoes": {}, + "coconut_flakes": {}, + "coconut_milk": { + "category": "canned" + }, + "coconut_oil": {}, + "colorful_sprinkles": {}, + "concealer": {}, + "cookies": {}, + "coriander": {}, + "corn": {}, + "cornflakes": {}, + "cornstarch": {}, + "cornys": {}, + "cough_drops": {}, + "couscous": {}, + "covid_rapid_test": {}, + "cow's_milk": { + "category": "dairy" + }, + "cream": { + "category": "dairy" + }, + "cream_cheese": { + "category": "dairy" + }, + "creamed_spinach": {}, + "creme_fraiche": {}, + "crepe_tape": {}, + "crispbread": {}, + "cucumber": {}, + "cumin": {}, + "curry_paste": {}, + "curry_powder": {}, + "curry_sauce": {}, + "dates": {}, + "dental_floss": {}, + "deodorant": { + "category": "hygiene" + }, + "detergent": { + "category": "hygiene" + }, + "dill": {}, + "dishwasher_salt": {}, + "dishwasher_tabs": { + "category": "hygiene" + }, + "disinfection_spray": {}, + "dried_tomatoes": {}, + "edamame": {}, + "eggplant": {}, + "eggs": {}, + "falafel": {}, + "falafel_powder": {}, + "fanta": { + "category": "drinks" + }, + "feta": {}, + "ffp2": {}, + "fish_sticks": {}, + "flour": {}, + "flushing": {}, + "fresh_cili_pepper": {}, + "frozen_berries": { + "category": "freezer" + }, + "frozen_fruit": { + "category": "freezer" + }, + "frozen_pizza": { + "category": "freezer" + }, + "frozen_spinach": { + "category": "freezer" + }, + "garam_masala": {}, + "garbage_bag": {}, + "garbage_bags": {}, + "garlic": {}, + "garlic_dip": {}, + "garlic_granules": {}, + "gherkins": {}, + "ginger": {}, + "glass_noodles": {}, + "gluten": {}, + "gnocchi": {}, + "gochujang": {}, + "gorgonzola": {}, + "gouda": {}, + "grapes": {}, + "greek_yogurt": {}, + "green_asparagus": {}, + "green_chili": {}, + "green_pesto": {}, + "hair_gel": {}, + "hair_wax": {}, + "handkerchief_box": {}, + "handkerchiefs": {}, + "haribo": {}, + "harissa": {}, + "hazelnuts": {}, + "head_of_lettuce": {}, + "herb_baguettes": {}, + "herb_cream_cheese": {}, + "honey": {}, + "honey_wafers": {}, + "hot_dog_bun": {}, + "ice_cream": {}, + "ice_cube": {}, + "iceberg_lettuce": {}, + "iced_tea": {}, + "instant_soups": {}, + "jam": {}, + "katjes": {}, + "ketchup": {}, + "kidney_beans": {}, + "kitchen_roll": {}, + "kitchen_towels": {}, + "kohlrabi": {}, + "lasagna": {}, + "lasagna_noodles": {}, + "lasagna_plates": {}, + "leaf_spinach": {}, + "leek": {}, + "lemon": {}, + "lemon_juice": {}, + "lemonade": {}, + "lemongrass": {}, + "lemonjuice": {}, + "lenses": {}, + "lenses_red": {}, + "lentils": {}, + "lettuce": {}, + "lillet": {}, + "lime": {}, + "linguine": {}, + "low-fat_curd_cheese": {}, + "magnesium": {}, + "mango": {}, + "margarine": {}, + "marjoram": {}, + "marshmallows": {}, + "mask": {}, + "mayonnaise": {}, + "meat_substitute_product": {}, + "microfiber_cloth": {}, + "milk": {}, + "mint": {}, + "mint_candy": {}, + "mixed_vegetables": {}, + "mochis": {}, + "mountain_cheese": {}, + "mouth_wash": {}, + "mouthwash": {}, + "mozzarella": {}, + "muesli": {}, + "muesli_bar": {}, + "mulled_wine": {}, + "mushrooms": {}, + "mustard": {}, + "neutral_oil": {}, + "nori_leaves": {}, + "nori_sheets": {}, + "nutmeg": {}, + "oat_milk": {}, + "oatmeal": {}, + "oatmeal_cookies": {}, + "oatsome": {}, + "obatzda": { + "category": "refrigerated" + }, + "olive_oil": {}, + "olives": {}, + "onion": {}, + "onions": {}, + "orange_juice": {}, + "oranges": {}, + "oregano": {}, + "organic_lemon": {}, + "organic_waste_bags": {}, + "pak_choi": {}, + "paprika": {}, + "pardina_lentils_dried": {}, + "parmesan": {}, + "parsley": {}, + "pasta": { + "category": "grain" + }, + "peach": {}, + "peanut_butter": {}, + "peanut_flips": {}, + "peanut_oil": {}, + "peanutbutter": {}, + "peanuts": {}, + "pears": {}, + "peas": {}, + "penne": {}, + "pepper": {}, + "pepper_mill": {}, + "peppers": {}, + "persian_rice": {}, + "pesto": {}, + "pilsner": {}, + "pine_nuts": {}, + "pineapple": {}, + "pita_bag": {}, + "pizza": {}, + "pizza_dough": {}, + "plant_magarine": {}, + "plant_oil": {}, + "plaster": {}, + "porcini_mushrooms": {}, + "potato_dumpling_dough": {}, + "potatoes": {}, + "potting_soil": {}, + "powder": {}, + "powdered_sugar": {}, + "processed_cheese": {}, + "prosecco": {}, + "puff_pastry": {}, + "pumpkin": {}, + "pumpkin_seeds": {}, + "quark": {}, + "quinoa": {}, + "radicchio": {}, + "radish": {}, + "ramen": {}, + "rapeseed_oil": {}, + "raspberries": {}, + "raspberry_syrup": {}, + "red_bull": {}, + "red_chili": {}, + "red_lentils": {}, + "red_onions": {}, + "red_pesto": {}, + "red_wine": {}, + "red_wine_vinegar": {}, + "rhubarb": {}, + "ribbon_noodles": {}, + "rice": {}, + "rice_cakes": {}, + "rice_ribbon_noodles": {}, + "rice_vinegar": {}, + "ricotta": {}, + "rinse_tabs": {}, + "rinsing_agent": {}, + "risotto_rice": {}, + "rocket": {}, + "roll": {}, + "rosemary": {}, + "saffron_threads": {}, + "sage": {}, + "saitan_powder": {}, + "salad_mix": {}, + "salad_seeds_mix": {}, + "salt": {}, + "salt_mill": {}, + "sambal_oelek": {}, + "sauce": {}, + "sausage": {}, + "sausages": {}, + "savoy_cabbage": {}, + "scallion": {}, + "scattered_cheese": {}, + "schlemmerfilet": {}, + "schupfnudeln": {}, + "sckocolate_chips": {}, + "semolina_porridge": {}, + "sesame": {}, + "sesame_oil": {}, + "shallot": {}, + "shampoo": {}, + "shawarma_spice": {}, + "shiitake_mushroom": {}, + "shoe_insoles": {}, + "shower_gel": {}, + "shredded_cheese": {}, + "sieved_tomatoes": {}, + "slice_cheese": {}, + "sliced_cheese": {}, + "smoked_paprika": {}, + "smoked_tofu": {}, + "snacks": { + "category": "snacks" + }, + "soap": {}, + "soft_drinks": {}, + "softdrinks": {}, + "sour_cream": {}, + "sour_cucumbers": {}, + "soy_hack": {}, + "soy_sauce": {}, + "soy_shred": {}, + "spaetzle": {}, + "spaghetti": {}, + "sparkling_water": {}, + "spelt": {}, + "spinach": {}, + "sponge_cloth": {}, + "sponge_wipes": {}, + "sponges": {}, + "spreading_cream": {}, + "spring_onions": {}, + "sprite": {}, + "sprouts": {}, + "sriracha": {}, + "strained_tomatoes": {}, + "sugar": {}, + "summer_roll_paper": {}, + "sunflower_seeds": {}, + "sushi_rice": {}, + "swabian_ravioli": {}, + "sweet_potato": {}, + "sweet_potatoes": {}, + "table_salt": {}, + "tagliatelle": {}, + "tahini": {}, + "tangerines": {}, + "tape": {}, + "tea": {}, + "teriyaki_sauce": {}, + "thyme": {}, + "tk_potato_wedges": {}, + "toast": {}, + "tofu": {}, + "toilet_paper": {}, + "tomato_juice": {}, + "tomato_paste": {}, + "tomato_sauce": {}, + "tomatoes": {}, + "tonic_water": {}, + "toothpaste": {}, + "tortellini": {}, + "tortilla_chips": {}, + "tuna": {}, + "turmeric": {}, + "tzatziki": {}, + "udon_noodles": {}, + "uht_milk": {}, + "vanilla_sugar": {}, + "vegetable broth": {}, + "vegetable_bouillon_cube": {}, + "vegetable_broth": {}, + "vegetable_oil": {}, + "vegetable_onion": {}, + "vegetables": {}, + "vegetarian_cold_cuts": {}, + "vinegar": {}, + "vodka": {}, + "washing powder": {}, + "washing_powder": {}, + "water": {}, + "water_ice": {}, + "watermelon": {}, + "wc_cleaner": {}, + "whipped_cream": {}, + "white_wine": {}, + "white_wine_vinegar": {}, + "whole_canned_tomatoes": { + "category": "canned" + }, + "wild_berries": {}, + "wrapping_paper": {}, + "wraps": {}, + "yeast": {}, + "yoghurt": {}, + "yogurt": {}, + "yum_yum": {}, + "zewa": {}, + "zinc_cream": {}, + "zucchini": {} + } +} \ No newline at end of file diff --git a/backend/templates/de.json b/backend/templates/de.json deleted file mode 100644 index 9c3d11ea..00000000 --- a/backend/templates/de.json +++ /dev/null @@ -1,1088 +0,0 @@ -{ - "items": [ - { - "name": "Aioli" - }, - { - "name": "Amaretto" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Ananas" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Apfel" - }, - { - "category": "🥫 Konserven", - "name": "Apfelmus" - }, - { - "name": "Asiatische Eiernudeln" - }, - { - "name": "Aspirin" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Aubergine" - }, - { - "name": "Avocado" - }, - { - "name": "Babyspinat" - }, - { - "name": "Backfisch" - }, - { - "name": "Backhefe" - }, - { - "name": "Backpapier" - }, - { - "name": "Backpulver" - }, - { - "name": "Badreiniger" - }, - { - "category": "🍞 Brotwaren", - "name": "Baguettes" - }, - { - "name": "Balsamico Essig" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Bananen" - }, - { - "category": "🥟 Teigwaren", - "name": "Bandnudeln" - }, - { - "name": "Basilikum" - }, - { - "name": "Basmati Reis" - }, - { - "name": "Berliner Luft" - }, - { - "name": "Bier" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Bio-Zitrone" - }, - { - "name": "Birnen" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Blattspinat" - }, - { - "name": "Blumenerde" - }, - { - "name": "Blumenkohl" - }, - { - "name": "Blätterteig" - }, - { - "name": "Bohnen" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Brokkoli" - }, - { - "category": "🍞 Brotwaren", - "name": "Brot" - }, - { - "category": "🍞 Brotwaren", - "name": "Brötchen" - }, - { - "category": "🥛 Milchprodukte", - "name": "Buko" - }, - { - "category": "🍞 Brotwaren", - "name": "Burgerbuns" - }, - { - "name": "Burgersaucen" - }, - { - "category": "🥛 Milchprodukte", - "name": "Butter" - }, - { - "category": "🥛 Milchprodukte", - "name": "Börek Käse" - }, - { - "name": "Büffelmozzarella" - }, - { - "name": "Cannelloni" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Champignons" - }, - { - "category": "🥛 Milchprodukte", - "name": "Cheddar" - }, - { - "name": "Chili-Öl" - }, - { - "name": "Ciabatta" - }, - { - "name": "Cocktailsauce" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Cocktailtomaten" - }, - { - "name": "Couscous" - }, - { - "category": "🥛 Milchprodukte", - "name": "Creme fraiche" - }, - { - "name": "Crepesband" - }, - { - "name": "Cumin" - }, - { - "name": "Currypaste" - }, - { - "name": "Currypulver" - }, - { - "name": "Currysoße" - }, - { - "name": "Datteln" - }, - { - "category": "🚽 Hygiene", - "name": "Deodorant" - }, - { - "name": "Desinfektionsspray" - }, - { - "name": "Dill" - }, - { - "name": "Dinkel" - }, - { - "name": "Duschgel" - }, - { - "category": "🥛 Milchprodukte", - "name": "Eier" - }, - { - "category": "❄️ Tk", - "name": "Eis" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Eisbergsalat" - }, - { - "name": "Eistee" - }, - { - "name": "Eiswürfel " - }, - { - "category": "❄️ Tk", - "name": "Erbsen" - }, - { - "name": "Erdbeeren" - }, - { - "name": "Erdnuss-Butter" - }, - { - "name": "Erdnussöl" - }, - { - "category": "🥜 Snacks", - "name": "Erdnüsse" - }, - { - "name": "Essig" - }, - { - "name": "Falafel" - }, - { - "name": "Falafelpulver" - }, - { - "name": "Fanta" - }, - { - "category": "🥛 Milchprodukte", - "name": "Feta" - }, - { - "name": "Filoteig" - }, - { - "name": "Fleischersatzprodukt" - }, - { - "name": "Frischhaltefolie" - }, - { - "name": "Frischkäse" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Frühlingszwiebeln" - }, - { - "category": "🥫 Konserven", - "name": "Ganze Dosentomaten" - }, - { - "name": "Garam Masala" - }, - { - "category": "🥫 Konserven", - "name": "Gehackte Tomaten" - }, - { - "name": "Gemischtes Gemüse" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Gemüse" - }, - { - "name": "Gemüsebrühe" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Gemüsezwiebel" - }, - { - "name": "Geschenkpapier" - }, - { - "category": "🥫 Konserven", - "name": "Getrocknete Tomaten" - }, - { - "name": "Gewürze" - }, - { - "name": "Gewürzgurken" - }, - { - "name": "Gluten" - }, - { - "name": "Glühwein" - }, - { - "category": "💧 Kühltheke", - "name": "Gnocchi" - }, - { - "name": "Gochujang" - }, - { - "name": "Gorgonzola" - }, - { - "name": "Gouda" - }, - { - "name": "Griechischer Joghurt" - }, - { - "name": "Grillbutter-Gewürz" - }, - { - "name": "Grüne Chili" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Grüner Spargel" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Gurke" - }, - { - "name": "Haargel" - }, - { - "name": "Haferflocken" - }, - { - "name": "Haferkekse" - }, - { - "category": "🥛 Milchprodukte", - "name": "Hafermilch" - }, - { - "category": "🚽 Hygiene", - "name": "Handseife" - }, - { - "name": "Haribo" - }, - { - "name": "Harissa" - }, - { - "name": "Haselnüsse" - }, - { - "name": "Hefe" - }, - { - "name": "Hefeflocken" - }, - { - "name": "Himbeersirup" - }, - { - "name": "Honig" - }, - { - "name": "Hummus" - }, - { - "name": "Hustenbonbons" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Ingwer" - }, - { - "name": "Joghurt" - }, - { - "name": "Kardamom" - }, - { - "name": "Kartoffelkloßteig" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Kartoffeln" - }, - { - "category": "🥜 Snacks", - "name": "Katjes" - }, - { - "name": "Kekse" - }, - { - "name": "Ketchup" - }, - { - "category": "🥫 Konserven", - "name": "Kichererbsen" - }, - { - "category": "🥫 Konserven", - "name": "Kidneybohnen" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Kirschtomaten" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Knoblauch" - }, - { - "name": "Knoblauch Dip" - }, - { - "name": "Knoblauch Granulat" - }, - { - "name": "Knopfzellen" - }, - { - "category": "🍞 Brotwaren", - "name": "Knäckebrot" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Kohlrabi" - }, - { - "category": "🥫 Konserven", - "name": "Kokosnuss-Milch" - }, - { - "name": "Kokosraspel" - }, - { - "name": "Kokosöl" - }, - { - "name": "Konfitüre" - }, - { - "category": "❄️ Tk", - "name": "Koriander" - }, - { - "name": "Kreuzkümmel" - }, - { - "name": "Kräuterbaguettes" - }, - { - "category": "🥛 Milchprodukte", - "name": "Kräuterfrischkäse" - }, - { - "name": "Kuchen" - }, - { - "name": "Kurkuma" - }, - { - "name": "Käse" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Kürbis" - }, - { - "name": "Kürbiskerne" - }, - { - "category": "🥜 Snacks", - "name": "Lachgummi" - }, - { - "category": "🥟 Teigwaren", - "name": "Lasagnenudeln" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Lauch" - }, - { - "name": "Limette" - }, - { - "name": "Linguine" - }, - { - "name": "Linsen" - }, - { - "category": "🥜 Snacks", - "name": "Linsenchips" - }, - { - "name": "Lorbeerblatt" - }, - { - "category": "🥛 Milchprodukte", - "name": "Magerquark" - }, - { - "category": "🥫 Konserven", - "name": "Mais" - }, - { - "name": "Majoran" - }, - { - "name": "Mandarinen" - }, - { - "name": "Mango" - }, - { - "name": "Marmelade" - }, - { - "name": "Maske" - }, - { - "category": "💧 Kühltheke", - "name": "Maultaschen" - }, - { - "name": "Mayonnaise" - }, - { - "name": "Mehl" - }, - { - "name": "Melone" - }, - { - "name": "Mikrofasertuch" - }, - { - "category": "🥛 Milchprodukte", - "name": "Milch" - }, - { - "name": "Milram Scheibenkäse" - }, - { - "name": "Minze" - }, - { - "category": "🥛 Milchprodukte", - "name": "Mozzarella" - }, - { - "name": "Mundspülung" - }, - { - "category": "🌶️ Gewürze", - "name": "Muskatnuss" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Möhren" - }, - { - "name": "Müllsäcke" - }, - { - "category": "🍞 Brotwaren", - "name": "Müsli" - }, - { - "name": "Müsli-Riegel" - }, - { - "category": "🥜 Snacks", - "name": "Nachos" - }, - { - "name": "Natron" - }, - { - "name": "Nori Blätter" - }, - { - "category": "🥟 Teigwaren", - "name": "Nudeln" - }, - { - "name": "Oliven" - }, - { - "name": "Olivenöl" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Orangen" - }, - { - "category": "🍹 Getränke", - "name": "Orangensaft" - }, - { - "name": "Oregano" - }, - { - "name": "Pak Choi" - }, - { - "name": "Paniermehl" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Paprika" - }, - { - "name": "Paprikapulver" - }, - { - "category": "🥛 Milchprodukte", - "name": "Parmesan" - }, - { - "category": "🥫 Konserven", - "name": "Passierte Tomaten" - }, - { - "name": "Penne" - }, - { - "name": "Pesto" - }, - { - "category": "❄️ Tk", - "name": "Petersilie" - }, - { - "name": "Pfirsich" - }, - { - "category": "💧 Kühltheke", - "name": "Pflanzenmagarine" - }, - { - "name": "Pflanzenöl" - }, - { - "name": "Pflaster" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Pilze" - }, - { - "name": "Pinienkerne" - }, - { - "name": "Pitatasche" - }, - { - "name": "Pommersche" - }, - { - "category": "🥛 Milchprodukte", - "name": "Quark" - }, - { - "name": "Quinoa" - }, - { - "name": "Radicchio" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Radieschen" - }, - { - "category": "❄️ Tk", - "name": "Rahmspinat" - }, - { - "name": "Ramen" - }, - { - "name": "Rapsöl" - }, - { - "name": "Red Bull" - }, - { - "category": "🍚 Reisprodukte", - "name": "Reis" - }, - { - "name": "Reis-Essig" - }, - { - "name": "Reisbandnudeln" - }, - { - "name": "Reiswaffeln" - }, - { - "name": "Rhabarber" - }, - { - "name": "Ricotta" - }, - { - "category": "🍚 Reisprodukte", - "name": "Risotto Reis" - }, - { - "name": "Rohrzucker" - }, - { - "name": "Rosenkohl" - }, - { - "name": "Rosmarin" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Rote Beete" - }, - { - "name": "Rote Chili" - }, - { - "name": "Rote Linsen" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Rote Zwiebeln" - }, - { - "name": "Rotwein" - }, - { - "name": "Rotweinessig" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Rucola" - }, - { - "category": "💧 Kühltheke", - "name": "Räuchertofu" - }, - { - "name": "Safranfäden" - }, - { - "category": "🥛 Milchprodukte", - "name": "Sahne" - }, - { - "name": "Saitan-Pulver" - }, - { - "name": "Salat Mix" - }, - { - "name": "Salatkerne Mix" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Salatkopf" - }, - { - "name": "Salbei" - }, - { - "name": "Salz" - }, - { - "name": "Sambal Oelek" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Schalotte" - }, - { - "name": "Schawarma-Gewürz" - }, - { - "category": "🥛 Milchprodukte", - "name": "Scheibenkäse" - }, - { - "name": "Schlemmerfilet" - }, - { - "category": "🥛 Milchprodukte", - "name": "Schmand" - }, - { - "category": "🥛 Milchprodukte", - "name": "Schmelzkäse" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Schnittlauch" - }, - { - "name": "Schokolade" - }, - { - "category": "💧 Kühltheke", - "name": "Schupfnudeln" - }, - { - "name": "Schwammlappen" - }, - { - "category": "🥫 Konserven", - "name": "Schwarze Bohnen" - }, - { - "name": "Schwämme" - }, - { - "category": "🚽 Hygiene", - "name": "Seife" - }, - { - "name": "Sellerie" - }, - { - "name": "Senf" - }, - { - "name": "Senfsaat" - }, - { - "name": "Sesam" - }, - { - "name": "Sesamöl" - }, - { - "category": "🚽 Hygiene", - "name": "Shampoo" - }, - { - "name": "Shiitakepilz" - }, - { - "name": "Smoked Paprika" - }, - { - "category": "🥜 Snacks", - "name": "Snacks" - }, - { - "name": "Softdrinks" - }, - { - "name": "Soja Schnetzel" - }, - { - "name": "Sojahack" - }, - { - "category": "🥛 Milchprodukte", - "name": "Sojamilch" - }, - { - "name": "Sojasauce" - }, - { - "name": "Sonnenblumenkerne" - }, - { - "name": "Soße" - }, - { - "name": "Spaghetti" - }, - { - "name": "Speck" - }, - { - "name": "Speisestärke" - }, - { - "category": "❄️ Tk", - "name": "Spinat" - }, - { - "name": "Sprite" - }, - { - "name": "Spätzle" - }, - { - "name": "Spüli" - }, - { - "name": "Spülmaschinensalz" - }, - { - "category": "🚽 Hygiene", - "name": "Spültabs" - }, - { - "name": "Sriracha" - }, - { - "name": "Staudensellerie" - }, - { - "name": "Steinpilze" - }, - { - "category": "🥛 Milchprodukte", - "name": "Streukäse" - }, - { - "name": "Sushi Reis" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Süßkartoffel" - }, - { - "category": "🌶️ Gewürze", - "name": "Tafelsalz" - }, - { - "name": "Tahini" - }, - { - "name": "Taschentuchbox" - }, - { - "category": "🚽 Hygiene", - "name": "Taschentücher" - }, - { - "name": "Tee" - }, - { - "name": "Thunfisch" - }, - { - "name": "Thymian" - }, - { - "category": "🍞 Brotwaren", - "name": "Toast" - }, - { - "category": "💧 Kühltheke", - "name": "Tofu" - }, - { - "category": "🚽 Hygiene", - "name": "Toilettenpapier" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Tomaten" - }, - { - "category": "🥫 Konserven", - "name": "Tomatenmark" - }, - { - "name": "Tomatensoße" - }, - { - "name": "Tonic Water" - }, - { - "category": "💧 Kühltheke", - "name": "Tortellini" - }, - { - "name": "Tunfisch" - }, - { - "name": "Tzatziki" - }, - { - "name": "Vodka" - }, - { - "name": "WC-Reiniger" - }, - { - "name": "Walnusskerne" - }, - { - "category": "🚽 Hygiene", - "name": "Waschpulver" - }, - { - "name": "Wasser" - }, - { - "name": "Wassereis" - }, - { - "name": "Weizengluten" - }, - { - "name": "Weißwein" - }, - { - "name": "Weißweinessig" - }, - { - "name": "Wirsing" - }, - { - "category": "🍞 Brotwaren", - "name": "Wraps" - }, - { - "name": "Wurst" - }, - { - "name": "Würstchen" - }, - { - "name": "Yum Yum" - }, - { - "category": "🚽 Hygiene", - "name": "Zahnpasta" - }, - { - "category": "🚽 Hygiene", - "name": "Zahnseide" - }, - { - "name": "Zewa" - }, - { - "name": "Zimt" - }, - { - "name": "Zimtstange" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Zitrone" - }, - { - "name": "Zitronengras" - }, - { - "name": "Zitronensaft" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Zucchini" - }, - { - "name": "Zucker" - }, - { - "category": "🥬 Obst & Gemüse", - "name": "Zwiebel" - }, - { - "category": "💧 Kühltheke", - "name": "vegetarischer Aufschnitt" - } - ] -} diff --git a/backend/templates/en.json b/backend/templates/en.json deleted file mode 100644 index df7117d8..00000000 --- a/backend/templates/en.json +++ /dev/null @@ -1,484 +0,0 @@ -{ - "items": [ - { - "name": "Aioli" - }, - { - "name": "Apple" - }, - { - "name": "Aspargus" - }, - { - "name": "Avocado" - }, - { - "name": "Baguette" - }, - { - "name": "Baking paper" - }, - { - "name": "Balsamic vinegar" - }, - { - "name": "Bananas" - }, - { - "name": "Basil" - }, - { - "name": "Beer" - }, - { - "category": "🥬 Fruits & Vegetables", - "name": "Beetroot" - }, - { - "name": "Bodywash" - }, - { - "name": "Bread" - }, - { - "name": "Broccoli" - }, - { - "category": "🍞 Bread Goods", - "name": "Buns" - }, - { - "name": "Butter" - }, - { - "name": "Cannelloni" - }, - { - "name": "Canola oil" - }, - { - "name": "Carrots" - }, - { - "name": "Cauliflower" - }, - { - "name": "Cereal bar" - }, - { - "category": "🥛 Dairy", - "name": "Cheddar" - }, - { - "name": "Cheese" - }, - { - "name": "Cherry tomatoes" - }, - { - "name": "Chickpeas" - }, - { - "name": "Chives" - }, - { - "name": "Chocolate" - }, - { - "name": "Ciabatta" - }, - { - "name": "Cilantro" - }, - { - "name": "Coconut milk" - }, - { - "name": "Coconut oil" - }, - { - "name": "Corn" - }, - { - "name": "Cornflakes" - }, - { - "name": "Couscous" - }, - { - "category": "🥛 Dairy", - "name": "Cream" - }, - { - "name": "Cream cheese" - }, - { - "name": "Cucumber" - }, - { - "name": "Curry paste" - }, - { - "category": "🚽 Hygiene", - "name": "Deodorant" - }, - { - "category": "🚽 Hygiene", - "name": "Detergent" - }, - { - "name": "Dill" - }, - { - "name": "Dishwasher tabs" - }, - { - "category": "🥫 Canned Food", - "name": "Dried tomatoes" - }, - { - "name": "Eggplant" - }, - { - "name": "Eggs" - }, - { - "name": "Fanta" - }, - { - "name": "Feta" - }, - { - "name": "Flour" - }, - { - "name": "Garbage bags" - }, - { - "name": "Garlic" - }, - { - "category": "💧 Refrigerated", - "name": "Gnocchi" - }, - { - "name": "Gorgonzola" - }, - { - "name": "Green chili" - }, - { - "category": "🚽 Hygiene", - "name": "Hair gel" - }, - { - "name": "Harissa" - }, - { - "name": "Honey" - }, - { - "name": "Jam" - }, - { - "name": "Kidney beans" - }, - { - "name": "Kitchen towels" - }, - { - "name": "Kohlrabi" - }, - { - "category": "🥟 Pasta", - "name": "Lasagna" - }, - { - "name": "Leek" - }, - { - "name": "Lemon" - }, - { - "name": "Lemonjuice" - }, - { - "name": "Lentils" - }, - { - "name": "Lettuce" - }, - { - "name": "Lime" - }, - { - "category": "🥟 Pasta", - "name": "Linguine" - }, - { - "name": "Mayonnaise" - }, - { - "category": "🥛 Dairy", - "name": "Milk" - }, - { - "name": "Mint" - }, - { - "name": "Mouth wash" - }, - { - "category": "🥛 Dairy", - "name": "Mozzarella" - }, - { - "name": "Mushrooms" - }, - { - "name": "Mustard" - }, - { - "name": "Nori sheets" - }, - { - "name": "Oatmeal" - }, - { - "name": "Olive oil" - }, - { - "name": "Onions" - }, - { - "name": "Orange juice" - }, - { - "name": "Oregano" - }, - { - "name": "Parmesan" - }, - { - "name": "Parsley" - }, - { - "name": "Pasta" - }, - { - "name": "Peanutbutter" - }, - { - "category": "🥜 Snacks", - "name": "Peanuts" - }, - { - "name": "Peas" - }, - { - "category": "🥟 Pasta", - "name": "Penne pasta" - }, - { - "name": "Peppers" - }, - { - "name": "Pesto" - }, - { - "name": "Pine nuts" - }, - { - "name": "Pineapple" - }, - { - "name": "Pizza" - }, - { - "name": "Plant oil" - }, - { - "name": "Plaster" - }, - { - "name": "Potatoes" - }, - { - "name": "Pumpkin" - }, - { - "name": "Quark" - }, - { - "name": "Quinoa" - }, - { - "name": "Radicchio" - }, - { - "name": "Red chili" - }, - { - "name": "Red lentils" - }, - { - "name": "Red wine" - }, - { - "name": "Rice" - }, - { - "name": "Rice vinegar" - }, - { - "name": "Ricotta" - }, - { - "name": "Risotto rice" - }, - { - "name": "Rocket" - }, - { - "name": "Rosemary" - }, - { - "name": "Sage" - }, - { - "name": "Salt" - }, - { - "name": "Scallion" - }, - { - "name": "Sesame" - }, - { - "name": "Shampoo" - }, - { - "name": "Shredded cheese" - }, - { - "name": "Sieved tomatoes" - }, - { - "name": "Sliced cheese" - }, - { - "name": "Smoked tofu" - }, - { - "name": "Snacks" - }, - { - "name": "Soap" - }, - { - "name": "Softdrinks" - }, - { - "name": "Soy sauce" - }, - { - "name": "Spaghetti" - }, - { - "category": "❄️ Freezer", - "name": "Spinach" - }, - { - "name": "Sponges" - }, - { - "name": "Spring onions" - }, - { - "name": "Sprite" - }, - { - "name": "Sprouts" - }, - { - "name": "Sriracha" - }, - { - "name": "Sugar" - }, - { - "name": "Sunflower seeds" - }, - { - "name": "Sweet potatoes" - }, - { - "category": "🥟 Pasta", - "name": "Tagliatelle" - }, - { - "name": "Tahini" - }, - { - "name": "Tape" - }, - { - "name": "Thyme" - }, - { - "name": "Toast" - }, - { - "name": "Tofu" - }, - { - "name": "Toilet paper" - }, - { - "name": "Tomato paste" - }, - { - "name": "Tomatoes" - }, - { - "name": "Tonic water" - }, - { - "name": "Toothpaste" - }, - { - "category": "🥟 Pasta", - "name": "Tortellini" - }, - { - "name": "Tuna" - }, - { - "name": "Tzatziki" - }, - { - "name": "Vegetable broth" - }, - { - "name": "Vodka" - }, - { - "name": "Washing powder" - }, - { - "name": "White wine" - }, - { - "name": "Wraps" - }, - { - "name": "Yeast" - }, - { - "name": "Yoghurt" - }, - { - "name": "Zucchini" - } - ] -} diff --git a/backend/templates/fill_attributes_with_missing_items.py b/backend/templates/fill_attributes_with_missing_items.py new file mode 100644 index 00000000..25a63683 --- /dev/null +++ b/backend/templates/fill_attributes_with_missing_items.py @@ -0,0 +1,25 @@ +import requests +import json +import os + + +BASE_PATH = os.path.dirname(os.path.abspath(__file__)) + +def main(): + # read files + with open(BASE_PATH + "/l10n/en.json", encoding="utf8") as f: + en:dict = json.load(f) + with open(BASE_PATH + "/attributes.json", encoding="utf8") as f: + attr:dict = json.load(f) + + for key in en["items"].keys(): + if key not in attr["items"]: + attr["items"][key] = {} + + + with open(BASE_PATH + "/attributes.json", "w", encoding="utf8") as f: + f.write(json.dumps(attr, ensure_ascii=False, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json new file mode 100644 index 00000000..cd840ff2 --- /dev/null +++ b/backend/templates/l10n/de.json @@ -0,0 +1,401 @@ +{ + "categories": { + "bread": "🍞 Brotwaren", + "canned": "🥫 Konserven", + "dairy": "🥛 Milch", + "drinks": "🍹 Getränke", + "freezer": "❄️ Tiefgekühlt", + "fruits_vegetables": "🥬 Obst & Gemüse", + "grain": "🥟 Teigwaren", + "hygiene": "🚽 Hygiene", + "refrigerated": "💧 Kühltheke", + "snacks": "🥜 Snacks" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Apfel", + "apple_pulp": "Apfelmark", + "applesauce": "Apfelmus", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Asiatische Eiernudeln", + "aspargus": "Spargel", + "aspirin": "Aspirin", + "avocado": "Avocado", + "baby_spinach": "Babyspinat", + "bacon": "Speck", + "baguettes": "Baguettes", + "bakefish": "Backfisch", + "baking_cocoa": "Backkakao", + "baking_mix": "Backmischung", + "baking_paper": "Backpapier", + "baking_powder": "Backpulver", + "baking_soda": "Natron", + "baking_yeast": "Backhefe", + "balsamic_vinegar": "Balsamico Essig", + "bananas": "Bananen", + "barbecue_butter_spice": "Grillbutter-Gewürz", + "basil": "Basilikum", + "basmati_rice": "Basmati Reis", + "bathroom_cleaner": "Badreiniger", + "batteries": "Batterien", + "bay_leaf": "Lorbeerblatt", + "beans": "Bohnen", + "beer": "Bier", + "beet": "Rote Beete", + "birthday_card": "Geburtstagskarte", + "black_beans": "Schwarze Bohnen", + "bockwurst": "Bockwurst", + "bread": "Brot", + "breadcrumbs": "Paniermehl", + "broccoli": "Brokkoli", + "brown_sugar": "Brauner Zucker", + "brussels_sprouts": "Rosenkohl", + "buffalo_mozzarella": "Büffelmozzarella", + "buko": "Buko", + "buns": "Buns", + "burger_buns": "Burger Buns", + "burger_patties": "Burger Patties", + "burger_sauces": "Burgersaucen", + "butter": "Butter", + "butter_cookies": "Butterkekse", + "button_cells": "Knopfzellen", + "börek_cheese": "Börek Käse", + "cake": "Kuchen", + "cake_icing": "Kuchenglasur", + "cane_sugar": "Rohrzucker", + "cannelloni": "Cannelloni", + "cardamom": "Kardamom", + "carrots": "Möhren", + "cashews": "Cashewkerne", + "cat_treats": "Katzenleckerlis", + "cauliflower": "Blumenkohl", + "celeriac": "Knollensellerie", + "celery": "Sellerie", + "cheddar": "Cheddar", + "cheese": "Käse", + "cherry_tomatoes": "Kirschtomaten", + "chickpeas": "Kichererbsen", + "chili_oil": "Chili-Öl", + "chips": "Chips", + "chives": "Schnittlauch", + "chocolate": "Schokolade", + "chopped_tomatoes": "Gehackte Tomaten", + "ciabatta": "Ciabatta", + "cider_vinegar": "Apfelessig", + "cinnamon": "Zimt", + "cinnamon_stick": "Zimtstange", + "cocktail_sauce": "Cocktailsauce", + "cocktail_tomatoes": "Cocktailtomaten", + "coconut_flakes": "Kokosraspel", + "coconut_milk": "Kokosnuss-Milch", + "coconut_oil": "Kokosöl", + "colorful_sprinkles": "Bunte Streusel", + "concealer": "Concealer", + "cookies": "Kekse", + "coriander": "Koriander", + "corn": "Mais", + "cornstarch": "Speisestärke", + "cornys": "Cornys", + "cough_drops": "Hustenbonbons", + "couscous": "Couscous", + "covid_rapid_test": "COVID Schnelltest", + "cow's_milk": "Kuhmilch", + "cream": "Sahne", + "cream_cheese": "Frischkäse", + "creamed_spinach": "Rahmspinat", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Crepesband", + "crispbread": "Knäckebrot", + "cucumber": "Gurke", + "cumin": "Cumin", + "curry_paste": "Currypaste", + "curry_powder": "Currypulver", + "curry_sauce": "Currysoße", + "dates": "Datteln", + "dental_floss": "Zahnseide", + "deodorant": "Deodorant", + "dill": "Dill", + "dishwasher_salt": "Spülmaschinensalz", + "disinfection_spray": "Desinfektionsspray", + "dried_tomatoes": "Getrocknete Tomaten", + "edamame": "Edamame", + "eggplant": "Aubergine", + "eggs": "Eier", + "falafel": "Falafel", + "falafel_powder": "Falafelpulver", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fischstäbchen", + "flour": "Mehl", + "flushing": "Spülung", + "fresh_cili_pepper": "Frische Cilischote", + "frozen_berries": "TK Beeren", + "frozen_fruit": "TK Obst", + "frozen_pizza": "Tiefkühlpizza", + "frozen_spinach": "TK Spinat", + "garam_masala": "Garam Masala", + "garbage_bag": "Müllbeutel", + "garlic": "Knoblauch", + "garlic_dip": "Knoblauch Dip", + "garlic_granules": "Knoblauch Granulat", + "gherkins": "Gewürzgurken", + "ginger": "Ingwer", + "glass_noodles": "Glasnudeln", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Trauben", + "greek_yogurt": "Griechischer Joghurt", + "green_asparagus": "Grüner Spargel", + "green_chili": "Grüne Chili", + "green_pesto": "grünes Pesto", + "hair_gel": "Haargel", + "hair_wax": "Haar-Wachs", + "handkerchief_box": "Taschentuchbox", + "handkerchiefs": "Taschentücher", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Haselnüsse", + "head_of_lettuce": "Salatkopf", + "herb_baguettes": "Kräuterbaguettes", + "herb_cream_cheese": "Kräuterfrischkäse", + "honey": "Honig", + "honey_wafers": "Honigwaffeln", + "hot_dog_bun": "Hot Dog Brötchen", + "ice_cream": "Eis", + "ice_cube": "Eiswürfel ", + "iceberg_lettuce": "Eisbergsalat", + "iced_tea": "Eistee", + "instant_soups": "Instant Suppen", + "jam": "Konfitüre", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidneybohnen", + "kitchen_roll": "Küchenrolle", + "kohlrabi": "Kohlrabi", + "lasagna_noodles": "Lasagnenudeln", + "lasagna_plates": "Lasagne Platten", + "leaf_spinach": "Blattspinat", + "leek": "Lauch", + "lemon": "Zitrone", + "lemon_juice": "Zitronensaft", + "lemonade": "Limonade", + "lemongrass": "Zitronengras", + "lenses": "Linsen", + "lenses_red": "Linsen rot", + "lillet": "Lillet", + "lime": "Limette", + "linguine": "Linguine", + "low-fat_curd_cheese": "Magerquark", + "magnesium": "Magnesium", + "mango": "Mango", + "margarine": "Margarine", + "marjoram": "Majoran", + "marshmallows": "Marshmallows", + "mask": "Maske", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Fleischersatzprodukt", + "microfiber_cloth": "Mikrofasertuch", + "milk": "Milch", + "mint": "Minze", + "mint_candy": "Minz-Bonbon", + "mixed_vegetables": "Gemischtes Gemüse", + "mochis": "Mochis", + "mountain_cheese": "Bergkäse", + "mouthwash": "Mundspülung", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müsli-Riegel", + "mulled_wine": "Glühwein", + "mushrooms": "Champignons", + "mustard": "Senf", + "neutral_oil": "Neutrales Öl", + "nori_leaves": "Nori Blätter", + "nutmeg": "Muskatnuss", + "oat_milk": "Hafermilch", + "oatmeal": "Haferflocken", + "oatmeal_cookies": "Haferkekse", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "olive_oil": "Olivenöl", + "olives": "Oliven", + "onion": "Zwiebel", + "orange_juice": "Orangensaft", + "oranges": "Orangen", + "oregano": "Oregano", + "organic_lemon": "Bio-Zitrone", + "organic_waste_bags": "Biomülltüten", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Pardina Linsen getrocknet", + "parmesan": "Parmesan", + "parsley": "Petersilie", + "pasta": "Nudeln", + "peach": "Pfirsich", + "peanut_butter": "Erdnuss-Butter", + "peanut_flips": "Erdnussflips", + "peanut_oil": "Erdnussöl", + "peanuts": "Erdnüsse", + "pears": "Birnen", + "peas": "Erbsen", + "penne": "Penne", + "pepper": "Pfeffer", + "pepper_mill": "Pfeffermühle", + "persian_rice": "Persischer Reis", + "pesto": "Pesto", + "pilsner": "Pils", + "pine_nuts": "Pinienkerne", + "pineapple": "Ananas", + "pita_bag": "Pitatasche", + "pizza_dough": "Pizzateig", + "plant_magarine": "Pflanzenmagarine", + "plaster": "Pflaster", + "porcini_mushrooms": "Steinpilze", + "potato_dumpling_dough": "Kartoffelkloßteig", + "potato_wedges": "Kartoffelecken", + "potatoes": "Kartoffeln", + "potting_soil": "Blumenerde", + "powder": "Puder", + "powdered_sugar": "Puderzucker", + "processed_cheese": "Schmelzkäse", + "prosecco": "Prosecco", + "puff_pastry": "Blätterteig", + "pumpkin": "Kürbis", + "pumpkin_seeds": "Kürbiskerne", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Radieschen", + "ramen": "Ramen", + "rapeseed_oil": "Rapsöl", + "raspberries": "Himbeeren", + "raspberry_syrup": "Himbeersirup", + "red_bull": "Red Bull", + "red_chili": "Rote Chili", + "red_lentils": "Rote Linsen", + "red_onions": "Rote Zwiebeln", + "red_pesto": "rotes Pesto", + "red_wine": "Rotwein", + "red_wine_vinegar": "Rotweinessig", + "rhubarb": "Rhabarber", + "ribbon_noodles": "Bandnudeln", + "rice": "Reis", + "rice_cakes": "Reiswaffeln", + "rice_ribbon_noodles": "Reisbandnudeln", + "rice_vinegar": "Reis-Essig", + "ricotta": "Ricotta", + "rinse_tabs": "Spültabs", + "rinsing_agent": "Spüli", + "risotto_rice": "Risotto Reis", + "roll": "Brötchen", + "rosemary": "Rosmarin ", + "saffron_threads": "Safranfäden", + "sage": "Salbei", + "saitan_powder": "Saitan-Pulver", + "salad_mix": "Salat Mix", + "salad_seeds_mix": "Salatkerne Mix", + "salt": "Salz", + "salt_mill": "Salzmühle", + "sambal_oelek": "Sambal oelek", + "sauce": "Soße ", + "sausage": "Wurst", + "sausages": "Würstchen", + "savoy_cabbage": "Wirsing", + "scattered_cheese": "Streukäse", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "sckocolate_chips": "Sckokoladenstückchen", + "semolina_porridge": "Grießbrei", + "sesame": "Sesam", + "sesame_oil": "Sesamöl", + "shallot": "Schalotte", + "shampoo": "Shampoo", + "shawarma_spice": "Schawarma-Gewürz", + "shiitake_mushroom": "Shiitakepilz", + "shoe_insoles": "Schuheinlagen", + "shower_gel": "Duschgel", + "slice_cheese": "Scheibenkäse", + "smoked_paprika": "Smoked Paprika", + "smoked_tofu": "Räuchertofu", + "snacks": "Snacks", + "soap": "Seife", + "soft_drinks": "Softdrinks", + "sour_cream": "Schmand", + "sour_cucumbers": "Saure Gurken", + "soy_hack": "Sojahack", + "soy_sauce": "Sojasauce", + "soy_shred": "Soja Schnetzel", + "spaetzle": "Spätzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Sprudelwasser", + "spelt": "Dinkel", + "spinach": "Spinat", + "sponge_cloth": "Schwammlappen", + "sponge_wipes": "Schwammtücher", + "sponges": "Schwämme", + "spreading_cream": "Streichcreme", + "spring_onions": "Frühlingszwiebeln", + "sprite": "Sprite", + "sriracha": "Sriracha", + "strained_tomatoes": "Passierte Tomaten", + "sugar": "Zucker", + "summer_roll_paper": "Sommerrollen-Papier", + "sunflower_seeds": "Sonnenblumenkerne", + "sushi_rice": "Sushi Reis", + "swabian_ravioli": "Maultaschen", + "sweet_potato": "Süßkartoffel", + "table_salt": "Tafelsalz", + "tahini": "Tahini", + "tangerines": "Mandarinen", + "tea": "Tee", + "teriyaki_sauce": "Teriyaki-Soße", + "thyme": "Thymian", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Klopapier", + "tomato_juice": "Tomatensaft", + "tomato_paste": "Tomatenmark", + "tomato_sauce": "Tomatensoße", + "tomatoes": "Tomaten", + "tonic_water": "Tonic Water", + "toothpaste": "Zahnpasta", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla Chips", + "tuna": "Thunfisch", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Udonnudeln", + "uht_milk": "H-Milch", + "vanilla_sugar": "Vanillezucker", + "vegetable_bouillon_cube": "Gemüsebrühwürfel", + "vegetable_broth": "Gemüsebrühe", + "vegetable_oil": "Pflanzenöl", + "vegetable_onion": "Gemüsezwiebel", + "vegetables": "Gemüse", + "vegetarian_cold_cuts": "vegetarischer Aufschnitt", + "vinegar": "Essig", + "vodka": "Vodka", + "washing_powder": "Waschpulver", + "water": "Wasser", + "water_ice": "Wassereis", + "watermelon": "Wassermelone", + "wc_cleaner": "WC-Reiniger", + "whipped_cream": "Schlagsahne", + "white_wine": "Weißwein", + "white_wine_vinegar": "Weißweinessig", + "whole_canned_tomatoes": "Ganze Dosentomaten", + "wild_berries": "Waldbeeren", + "wrapping_paper": "Geschenkpapier", + "wraps": "Wraps", + "yeast": "Hefe", + "yogurt": "Joghurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Zinkcreme", + "zucchini": "Zucchini" + } +} \ No newline at end of file diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json new file mode 100644 index 00000000..fab79359 --- /dev/null +++ b/backend/templates/l10n/en.json @@ -0,0 +1,433 @@ +{ + "categories": { + "bread": "🍞 Bread Goods", + "canned": "🥫 Canned Food", + "dairy": "🥛 Dairy", + "drinks": "🍹 Drinks", + "freezer": "❄️ Freezer", + "fruits_vegetables": "🥬 Fruits & Vegetables", + "grain": "🥟 Grain Products", + "hygiene": "🚽 Hygiene", + "refrigerated": "💧 Refrigerated", + "snacks": "🥜 Snacks" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Apple", + "apple_pulp": "Apple pulp", + "applesauce": "Applesauce", + "apérol": "Apérol", + "arugula": "Arugula", + "asian_egg_noodles": "Asian egg noodles", + "aspargus": "Aspargus", + "aspirin": "Aspirin", + "avocado": "Avocado", + "baby_spinach": "Baby spinach", + "bacon": "Bacon", + "baguette": "Baguette", + "baguettes": "Baguettes", + "bakefish": "Bakefish", + "baking_cocoa": "Baking cocoa", + "baking_mix": "Baking mix", + "baking_paper": "Baking paper", + "baking_powder": "Baking powder", + "baking_soda": "Baking soda", + "baking_yeast": "Baking yeast", + "balsamic_vinegar": "Balsamic vinegar", + "bananas": "Bananas", + "basil": "Basil", + "basmati_rice": "Basmati rice", + "bathroom_cleaner": "Bathroom cleaner", + "batteries": "Batteries", + "bay_leaf": "Bay leaf", + "beans": "Beans", + "beer": "Beer", + "beet": "Beet", + "beetroot": "Beetroot", + "birthday_card": "Birthday card", + "black_beans": "Black beans", + "bockwurst": "Bockwurst", + "bodywash": "Bodywash", + "bread": "Bread", + "breadcrumbs": "Breadcrumbs", + "broccoli": "Broccoli", + "brown_sugar": "Brown sugar", + "brussels_sprouts": "Brussels sprouts", + "buffalo_mozzarella": "Buffalo mozzarella", + "buko": "Buko", + "buns": "Buns", + "burger_buns": "Burger Buns", + "burger_patties": "Burger Patties", + "burger_sauces": "Burger sauces", + "butter": "Butter", + "butter_cookies": "Butter cookies", + "button_cells": "Button cells", + "börek_cheese": "Börek cheese", + "cake": "Cake", + "cake_icing": "Cake icing", + "cane_sugar": "Cane sugar", + "cannelloni": "Cannelloni", + "canola_oil": "Canola oil", + "cardamom": "Cardamom", + "carrots": "Carrots", + "cashews": "Cashews", + "cat_treats": "Cat treats", + "cauliflower": "Cauliflower", + "celeriac": "Celeriac", + "celery": "Celery", + "cereal_bar": "Cereal bar", + "cheddar": "Cheddar", + "cheese": "Cheese", + "cherry_tomatoes": "Cherry tomatoes", + "chickpeas": "Chickpeas", + "chili_oil": "Chili oil", + "chips": "Chips", + "chives": "Chives", + "chocolate": "Chocolate", + "chopped_tomatoes": "Chopped tomatoes", + "ciabatta": "Ciabatta", + "cider_vinegar": "Cider vinegar", + "cilantro": "Cilantro", + "cinnamon": "Cinnamon", + "cinnamon_stick": "Cinnamon stick", + "cocktail_sauce": "Cocktail sauce", + "cocktail_tomatoes": "Cocktail tomatoes", + "coconut_flakes": "Coconut flakes", + "coconut_milk": "Coconut milk", + "coconut_oil": "Coconut oil", + "colorful_sprinkles": "Colorful sprinkles", + "concealer": "Concealer", + "cookies": "Cookies", + "coriander": "Coriander", + "corn": "Corn", + "cornflakes": "Cornflakes", + "cornstarch": "Cornstarch", + "cornys": "Cornys", + "cough_drops": "Cough drops", + "couscous": "Couscous", + "covid_rapid_test": "COVID rapid test", + "cow's_milk": "Cow's milk", + "cream": "Cream", + "cream_cheese": "Cream cheese", + "creamed_spinach": "Creamed spinach", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Crepe tape", + "crispbread": "Crispbread", + "cucumber": "Cucumber", + "cumin": "Cumin", + "curry_paste": "Curry paste", + "curry_powder": "Curry powder", + "curry_sauce": "Curry sauce", + "dates": "Dates", + "dental_floss": "Dental floss", + "deodorant": "Deodorant", + "detergent": "Detergent", + "dill": "Dill", + "dishwasher_salt": "Dishwasher salt", + "dishwasher_tabs": "Dishwasher tabs", + "disinfection_spray": "Disinfection spray", + "dried_tomatoes": "Dried tomatoes", + "edamame": "Edamame", + "eggplant": "Eggplant", + "eggs": "Eggs", + "falafel": "Falafel", + "falafel_powder": "Falafel powder", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fish sticks", + "flour": "Flour", + "flushing": "Flushing", + "fresh_cili_pepper": "Fresh cili pepper", + "frozen_berries": "Frozen berries", + "frozen_fruit": "Frozen fruit", + "frozen_pizza": "Frozen pizza", + "frozen_spinach": "Frozen spinach", + "garam_masala": "Garam Masala", + "garbage_bag": "Garbage bag", + "garlic": "Garlic", + "garlic_dip": "Garlic dip", + "garlic_granules": "Garlic granules", + "gherkins": "Gherkins", + "ginger": "Ginger", + "glass_noodles": "Glass noodles", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Grapes", + "greek_yogurt": "Greek yogurt", + "green_asparagus": "Green asparagus", + "green_chili": "Green chili", + "green_pesto": "green pesto", + "hair_gel": "Hair gel", + "hair_wax": "Hair Wax", + "handkerchief_box": "Handkerchief box", + "handkerchiefs": "Handkerchiefs", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hazelnuts", + "head_of_lettuce": "Head of lettuce", + "herb_baguettes": "Herb baguettes", + "herb_cream_cheese": "Herb cream cheese", + "honey": "Honey", + "honey_wafers": "Honey wafers", + "hot_dog_bun": "Hot dog bun", + "ice_cream": "Ice cream", + "ice_cube": "Ice cube", + "iceberg_lettuce": "Iceberg lettuce", + "iced_tea": "Iced tea", + "instant_soups": "Instant soups", + "jam": "Jam", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidney beans", + "kitchen_roll": "Kitchen roll", + "kitchen_towels": "Kitchen towels", + "kohlrabi": "Kohlrabi", + "lasagna": "Lasagna", + "lasagna_noodles": "Lasagna noodles", + "lasagna_plates": "Lasagna plates", + "leaf_spinach": "Leaf spinach", + "leek": "Leek", + "lemon": "Lemon", + "lemon_juice": "Lemon juice", + "lemonade": "Lemonade", + "lemongrass": "Lemongrass", + "lemonjuice": "Lemonjuice", + "lenses": "Lenses", + "lenses_red": "Red Lenses", + "lentils": "Lentils", + "lettuce": "Lettuce", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "low-fat_curd_cheese": "Low-fat curd cheese", + "magnesium": "Magnesium", + "mango": "Mango", + "margarine": "Margarine", + "marjoram": "Marjoram", + "marshmallows": "Marshmallows", + "mask": "Mask", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Meat substitute product", + "microfiber_cloth": "Microfiber cloth", + "milk": "Milk", + "mint": "Mint", + "mint_candy": "Mint candy", + "mixed_vegetables": "Mixed vegetables", + "mochis": "Mochis", + "mountain_cheese": "Mountain cheese", + "mouth_wash": "Mouth wash", + "mouthwash": "Mouthwash", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Muesli bar", + "mulled_wine": "Mulled wine", + "mushrooms": "Mushrooms", + "mustard": "Mustard", + "neutral_oil": "Neutral oil", + "nori_leaves": "Nori leaves", + "nori_sheets": "Nori sheets", + "nutmeg": "Nutmeg", + "oat_milk": "Oat milk", + "oatmeal": "Oatmeal", + "oatmeal_cookies": "Oatmeal cookies", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "olive_oil": "Olive oil", + "olives": "Olives", + "onion": "Onion", + "orange_juice": "Orange juice", + "oranges": "Oranges", + "oregano": "Oregano", + "organic_lemon": "Organic lemon", + "organic_waste_bags": "Organic waste bags", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Pardina lentils dried", + "parmesan": "Parmesan", + "parsley": "Parsley", + "pasta": "Pasta", + "peach": "Peach", + "peanut_butter": "Peanut butter", + "peanut_flips": "Peanut Flips", + "peanut_oil": "Peanut oil", + "peanutbutter": "Peanutbutter", + "peanuts": "Peanuts", + "pears": "Pears", + "peas": "Peas", + "penne": "Penne", + "pepper": "Pepper", + "pepper_mill": "Pepper mill", + "peppers": "Peppers", + "persian_rice": "Persian rice", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pine nuts", + "pineapple": "Pineapple", + "pita_bag": "Pita bag", + "pizza": "Pizza", + "pizza_dough": "Pizza dough", + "plant_magarine": "Plant Magarine", + "plant_oil": "Plant oil", + "plaster": "Plaster", + "porcini_mushrooms": "Porcini mushrooms", + "potato_dumpling_dough": "Potato dumpling dough", + "potatoes": "Potatoes", + "potting_soil": "Potting soil", + "powder": "Powder", + "powdered_sugar": "Powdered sugar", + "processed_cheese": "Processed cheese", + "prosecco": "Prosecco", + "puff_pastry": "Puff pastry", + "pumpkin": "Pumpkin", + "pumpkin_seeds": "Pumpkin seeds", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Radish", + "ramen": "Ramen", + "rapeseed_oil": "Rapeseed oil", + "raspberries": "Raspberries", + "raspberry_syrup": "Raspberry syrup", + "red_bull": "Red Bull", + "red_chili": "Red chili", + "red_lentils": "Red lentils", + "red_onions": "Red onions", + "red_pesto": "Red pesto", + "red_wine": "Red wine", + "red_wine_vinegar": "Red wine vinegar", + "rhubarb": "Rhubarb", + "ribbon_noodles": "Ribbon noodles", + "rice": "Rice", + "rice_cakes": "Rice cakes", + "rice_ribbon_noodles": "Rice ribbon noodles", + "rice_vinegar": "Rice vinegar", + "ricotta": "Ricotta", + "rinse_tabs": "Rinse tabs", + "rinsing_agent": "Rinsing agent", + "risotto_rice": "Risotto rice", + "rocket": "Rocket", + "roll": "Roll", + "rosemary": "Rosemary", + "saffron_threads": "Saffron threads", + "sage": "Sage", + "saitan_powder": "Saitan powder", + "salad_mix": "Salad Mix", + "salad_seeds_mix": "Salad seeds mix", + "salt": "Salt", + "salt_mill": "Salt mill", + "sambal_oelek": "Sambal oelek", + "sauce": "Sauce", + "sausage": "Sausage", + "sausages": "Sausages", + "savoy_cabbage": "Savoy cabbage", + "scallion": "Scallion", + "scattered_cheese": "Scattered cheese", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "sckocolate_chips": "Sckocolate chips", + "semolina_porridge": "Semolina porridge", + "sesame": "Sesame", + "sesame_oil": "Sesame oil", + "shallot": "Shallot", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarma spice", + "shiitake_mushroom": "Shiitake mushroom", + "shoe_insoles": "Shoe insoles", + "shower_gel": "Shower gel", + "shredded_cheese": "Shredded cheese", + "sieved_tomatoes": "Sieved tomatoes", + "slice_cheese": "Slice cheese", + "sliced_cheese": "Sliced cheese", + "smoked_paprika": "Smoked paprika", + "smoked_tofu": "Smoked tofu", + "snacks": "Snacks", + "soap": "Soap", + "soft_drinks": "Soft drinks", + "softdrinks": "Softdrinks", + "sour_cream": "Sour cream", + "sour_cucumbers": "Sour cucumbers", + "soy_hack": "Soy hack", + "soy_sauce": "Soy sauce", + "soy_shred": "Soy shred", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Sparkling water", + "spelt": "Spelt", + "spinach": "Spinach", + "sponge_cloth": "Sponge cloth", + "sponge_wipes": "Sponge wipes", + "sponges": "Sponges", + "spreading_cream": "Spreading cream", + "spring_onions": "Spring onions", + "sprite": "Sprite", + "sprouts": "Sprouts", + "sriracha": "Sriracha", + "strained_tomatoes": "Strained tomatoes", + "sugar": "Sugar", + "summer_roll_paper": "Summer roll paper", + "sunflower_seeds": "Sunflower seeds", + "sushi_rice": "Sushi rice", + "swabian_ravioli": "Swabian ravioli", + "sweet_potato": "Sweet potato", + "sweet_potatoes": "Sweet potatoes", + "table_salt": "Table salt", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Tangerines", + "tape": "Tape", + "tea": "Tea", + "teriyaki_sauce": "Teriyaki sauce", + "thyme": "Thyme", + "tk_potato_wedges": "Potato wedges", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Toilet paper", + "tomato_juice": "Tomato juice", + "tomato_paste": "Tomato paste", + "tomato_sauce": "Tomato sauce", + "tomatoes": "Tomatoes", + "tonic_water": "Tonic water", + "toothpaste": "Toothpaste", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla Chips", + "tuna": "Tuna", + "turmeric": "Turmeric", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon noodles", + "uht_milk": "UHT milk", + "vanilla_sugar": "Vanilla sugar", + "vegetable broth": "Vegetable broth", + "vegetable_bouillon_cube": "Vegetable bouillon cube", + "vegetable_broth": "Vegetable broth", + "vegetable_oil": "Vegetable oil", + "vegetable_onion": "Vegetable onion", + "vegetables": "Vegetables", + "vegetarian_cold_cuts": "vegetarian cold cuts", + "vinegar": "Vinegar", + "vodka": "Vodka", + "washing_powder": "Washing powder", + "water": "Water", + "water_ice": "Water ice", + "watermelon": "Watermelon", + "wc_cleaner": "WC cleaner", + "whipped_cream": "Whipped cream", + "white_wine": "White wine", + "white_wine_vinegar": "White wine vinegar", + "whole_canned_tomatoes": "Whole canned tomatoes", + "wild_berries": "Wild berries", + "wrapping_paper": "Wrapping paper", + "wraps": "Wraps", + "yeast": "Yeast", + "yoghurt": "Yoghurt", + "yogurt": "Yogurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Zinc cream", + "zucchini": "Zucchini" + } +} \ No newline at end of file From 9c5057febac22e6518f203d6d74c7eb49b26d9f5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 12 Jan 2023 01:03:25 +0100 Subject: [PATCH 248/496] l10n: Update localization --- backend/templates/l10n/de.json | 15 +++++++-------- backend/templates/l10n/en.json | 8 +------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index cd840ff2..4beb6df8 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -25,7 +25,7 @@ "avocado": "Avocado", "baby_spinach": "Babyspinat", "bacon": "Speck", - "baguettes": "Baguettes", + "baguette": "Baguette", "bakefish": "Backfisch", "baking_cocoa": "Backkakao", "baking_mix": "Backmischung", @@ -186,8 +186,7 @@ "lemon_juice": "Zitronensaft", "lemonade": "Limonade", "lemongrass": "Zitronengras", - "lenses": "Linsen", - "lenses_red": "Linsen rot", + "lentils_red": "Linsen rot", "lillet": "Lillet", "lime": "Limette", "linguine": "Linguine", @@ -207,15 +206,15 @@ "mixed_vegetables": "Gemischtes Gemüse", "mochis": "Mochis", "mountain_cheese": "Bergkäse", - "mouthwash": "Mundspülung", + "mouth_wash": "Mundspülung", "mozzarella": "Mozzarella", "muesli": "Müsli", - "muesli_bar": "Müsli-Riegel", + "muesli_bar": "Müsliriegel", "mulled_wine": "Glühwein", "mushrooms": "Champignons", "mustard": "Senf", "neutral_oil": "Neutrales Öl", - "nori_leaves": "Nori Blätter", + "nori_sheets": "Nori Blätter", "nutmeg": "Muskatnuss", "oat_milk": "Hafermilch", "oatmeal": "Haferflocken", @@ -237,7 +236,7 @@ "parsley": "Petersilie", "pasta": "Nudeln", "peach": "Pfirsich", - "peanut_butter": "Erdnuss-Butter", + "peanut_butter": "Erdnussbutter", "peanut_flips": "Erdnussflips", "peanut_oil": "Erdnussöl", "peanuts": "Erdnüsse", @@ -318,7 +317,7 @@ "shiitake_mushroom": "Shiitakepilz", "shoe_insoles": "Schuheinlagen", "shower_gel": "Duschgel", - "slice_cheese": "Scheibenkäse", + "sliced_cheese": "Scheibenkäse", "smoked_paprika": "Smoked Paprika", "smoked_tofu": "Räuchertofu", "snacks": "Snacks", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index fab79359..712ad69e 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -26,7 +26,6 @@ "baby_spinach": "Baby spinach", "bacon": "Bacon", "baguette": "Baguette", - "baguettes": "Baguettes", "bakefish": "Bakefish", "baking_cocoa": "Baking cocoa", "baking_mix": "Baking mix", @@ -196,9 +195,7 @@ "lemon_juice": "Lemon juice", "lemonade": "Lemonade", "lemongrass": "Lemongrass", - "lemonjuice": "Lemonjuice", - "lenses": "Lenses", - "lenses_red": "Red Lenses", + "lentils_red": "Red lentils", "lentils": "Lentils", "lettuce": "Lettuce", "lillet": "Lillet", @@ -221,7 +218,6 @@ "mochis": "Mochis", "mountain_cheese": "Mountain cheese", "mouth_wash": "Mouth wash", - "mouthwash": "Mouthwash", "mozzarella": "Mozzarella", "muesli": "Muesli", "muesli_bar": "Muesli bar", @@ -229,7 +225,6 @@ "mushrooms": "Mushrooms", "mustard": "Mustard", "neutral_oil": "Neutral oil", - "nori_leaves": "Nori leaves", "nori_sheets": "Nori sheets", "nutmeg": "Nutmeg", "oat_milk": "Oat milk", @@ -255,7 +250,6 @@ "peanut_butter": "Peanut butter", "peanut_flips": "Peanut Flips", "peanut_oil": "Peanut oil", - "peanutbutter": "Peanutbutter", "peanuts": "Peanuts", "pears": "Pears", "peas": "Peas", From 5a8603a78a0ec545078319fbfdd908c52779c878 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 12 Jan 2023 01:05:25 +0100 Subject: [PATCH 249/496] l10n: Update localization --- backend/templates/l10n/en.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index 712ad69e..24534f2f 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -335,7 +335,6 @@ "shower_gel": "Shower gel", "shredded_cheese": "Shredded cheese", "sieved_tomatoes": "Sieved tomatoes", - "slice_cheese": "Slice cheese", "sliced_cheese": "Sliced cheese", "smoked_paprika": "Smoked paprika", "smoked_tofu": "Smoked tofu", @@ -377,7 +376,7 @@ "tea": "Tea", "teriyaki_sauce": "Teriyaki sauce", "thyme": "Thyme", - "tk_potato_wedges": "Potato wedges", + "potato_wedges": "Potato wedges", "toast": "Toast", "tofu": "Tofu", "toilet_paper": "Toilet paper", From c15357154c0317654f41e2becded8251eaf2a5ca Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 15 Jan 2023 22:01:23 +0100 Subject: [PATCH 250/496] fix: localization duplicate key --- backend/templates/l10n/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index 24534f2f..4fbb5d90 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -394,7 +394,6 @@ "udon_noodles": "Udon noodles", "uht_milk": "UHT milk", "vanilla_sugar": "Vanilla sugar", - "vegetable broth": "Vegetable broth", "vegetable_bouillon_cube": "Vegetable bouillon cube", "vegetable_broth": "Vegetable broth", "vegetable_oil": "Vegetable oil", From e7ba8dec199ad47e0f234065c904dcfa81ba1106 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 15 Jan 2023 22:34:03 +0100 Subject: [PATCH 251/496] fix: import with duplicate item names --- backend/app/service/export_import.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index cffa6c88..63442eb1 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -17,10 +17,12 @@ def importFromLanguage(lang, bulkSave=False): attributes = json.load(f) t0 = time.time() - models = [] + models: list[Item] = [] for key, name in data["items"].items(): item = Item.find_by_name(name) if not item: + if bulkSave and any(i.name == name for i in models): # slow but needed to filter out duplicate names + continue item = Item() item.name = name item.default = True @@ -55,6 +57,8 @@ def importFromDict(args, bulkSave=False, override=False): # noqa for importItem in args['items']: item = Item.find_by_name(importItem['name']) if not item: + if bulkSave and any(i.name == importItem['name'] for i in models): # slow but needed to filter out duplicate names + continue item = Item() item.name = importItem['name'] if "category" in importItem and not item.category_id: From 8ece049f20bc77e27920e73fed98d26e671e9fb7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 15 Jan 2023 22:51:23 +0100 Subject: [PATCH 252/496] feat: Custom DB support --- backend/app/config.py | 12 +++++-- backend/docker-compose-postgres.yml | 51 +++++++++++++++++++++++++++++ backend/docker-compose.yml | 4 +-- 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 backend/docker-compose-postgres.yml diff --git a/backend/app/config.py b/backend/app/config.py index 7825950b..60ce3446 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,5 +1,6 @@ from datetime import timedelta from sqlalchemy import MetaData +from sqlalchemy.engine import URL from app.errors import NotFoundRequest, UnauthorizedRequest, ForbiddenRequest, InvalidUsage from app.util import KitchenOwlJSONProvider from flask import Flask, jsonify, request @@ -20,6 +21,14 @@ UPLOAD_FOLDER = os.getenv('STORAGE_PATH', PROJECT_DIR) + '/upload' ALLOWED_FILE_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} +DB_URL = URL.create( + os.getenv('DB_DRIVER', "sqlite"), + username=os.getenv('DB_USER', ""), + password=os.getenv('DB_PASSWORD', ""), + host=os.getenv('DB_HOST', ""), + database=os.getenv('DB_NAME', os.getenv('STORAGE_PATH', PROJECT_DIR) + "/database.db"), +) + JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) @@ -35,8 +44,7 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['MAX_CONTENT_LENGTH'] = 32 * 1000 * 1000 # 32MB max upload # SQLAlchemy -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + \ - os.getenv('STORAGE_PATH', PROJECT_DIR) + '/database.db' +app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # JWT app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') diff --git a/backend/docker-compose-postgres.yml b/backend/docker-compose-postgres.yml new file mode 100644 index 00000000..a5752217 --- /dev/null +++ b/backend/docker-compose-postgres.yml @@ -0,0 +1,51 @@ +version: "3" +services: + db: + image: postgres:15 + restart: unless-stopped + environment: + POSTGRES_DB: kitchenowl + POSTGRES_USER: kitchenowl + POSTGRES_PASSWORD: example + volumes: + - kitchenowl_data:/var/lib/postgresql/data + networks: + - default + front: + image: tombursch/kitchenowl-web:latest + # environment: + # - BACK_URL=back:5000 # Optional should not be changed unless you know what youre doing + ports: + - "80:80" + depends_on: + - back + networks: + - default + back: + image: tombursch/kitchenowl:latest + restart: unless-stopped + # ports: # Optional + # - "5000:5000" # uwsgi protocol + networks: + - default + environment: + JWT_SECRET_KEY: PLEASE_CHANGE_ME + DB_DRIVER: postgresql + DB_HOST: db + DB_NAME: kitchenowl + DB_USER: kitchenowl + DB_PASSWORD: example + # FRONT_URL: http://localhost # Optional should not be changed unless you know what youre doing + depends_on: + - db + volumes: + - kitchenowl_data:/data + +volumes: + kitchenowl_files: + driver: local + kitchenowl_db: + driver: local + +networks: + default: diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index c402dd8f..6d456eb0 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -22,10 +22,10 @@ services: - JWT_SECRET_KEY=PLEASE_CHANGE_ME # - FRONT_URL=http://localhost # Optional should not be changed unless you know what youre doing volumes: - - kitchenowl_data:/data + - kitchenowl_data:/data volumes: kitchenowl_data: networks: - default: \ No newline at end of file + default: From 12e907ec48a8834a10e38d0aae85ae37a2afa46e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 15 Jan 2023 22:54:36 +0100 Subject: [PATCH 253/496] chore: upgrade requirements --- backend/CONTRIBUTING.md | 2 +- backend/requirements.txt | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md index 0d324a2c..facedd97 100644 --- a/backend/CONTRIBUTING.md +++ b/backend/CONTRIBUTING.md @@ -28,7 +28,7 @@ The `description` is a descriptive summary of the change the PR will make. - One PR per fix or feature ### Requirements -- Python 3.9+ +- Python 3.11+ ### Setup & Install - Create a python environment `python3 -m venv venv` diff --git a/backend/requirements.txt b/backend/requirements.txt index 25031ac5..9a8edd2b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -alembic==1.9.1 +alembic==1.9.2 appdirs==1.4.4 APScheduler==3.9.1 attrs==22.2.0 @@ -8,9 +8,9 @@ beautifulsoup4==4.11.1 black==23.1a1 certifi==2022.12.7 cffi==1.15.1 -charset-normalizer==2.1.1 +charset-normalizer==3.0.1 click==8.1.3 -contourpy==1.0.6 +contourpy==1.0.7 cycler==0.11.0 dbscan1d==0.1.6 extruct==0.14.0 @@ -19,14 +19,14 @@ Flask==2.2.2 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 -Flask-Migrate==4.0.0 +Flask-Migrate==4.0.1 Flask-SQLAlchemy==3.0.2 fonttools==4.38.0 greenlet==2.0.1 html-text==0.5.2 html5lib==1.1 idna==3.4 -iniconfig==1.1.1 +iniconfig==2.0.0 isodate==0.6.1 itsdangerous==2.1.2 Jinja2==3.1.2 @@ -38,16 +38,16 @@ lxml==4.9.2 Mako==1.2.4 MarkupSafe==2.1.1 marshmallow==3.19.0 -matplotlib==3.6.2 +matplotlib==3.6.3 mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.21.0 mypy-extensions==0.4.3 numpy==1.24.1 -packaging==22.0 +packaging==23.0 pandas==1.5.2 pathspec==0.10.3 -Pillow==9.3.0 +Pillow==9.4.0 platformdirs==2.6.2 pluggy==1.0.0 py==1.11.0 @@ -57,33 +57,33 @@ pyflakes==3.0.1 PyJWT==2.6.0 pyparsing==3.0.9 pyRdfa3==3.5.3 -pytest==7.2.0 +pytest==7.2.1 python-dateutil==2.8.2 python-editor==1.0.4 -pytz==2022.7 +pytz==2022.7.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.26.0 +recipe-scrapers==14.28.0 regex==2022.10.31 -requests==2.28.1 +requests==2.28.2 scikit-learn==1.2.0 -scipy==1.9.3 +scipy==1.10.0 setuptools-scm==7.1.0 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.45 +SQLAlchemy==1.4.46 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 typed-ast==1.5.4 -types-beautifulsoup4==4.11.6.1 +types-beautifulsoup4==4.11.6.2 types-requests==2.28.11.7 types-urllib3==1.26.25.4 typing_extensions==4.4.0 tzdata==2022.7 tzlocal==4.2 -urllib3==1.26.13 +urllib3==1.26.14 uWSGI==2.0.21 w3lib==2.1.1 webencodings==0.5.1 From fffc4713ba1b68cbd178812e2ea81cd80bcccb29 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 15 Jan 2023 22:59:42 +0100 Subject: [PATCH 254/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ * Translated using Weblate (German) Currently translated at 100.0% (420 of 420 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/de/ * Added translation using Weblate (French) * Added translation using Weblate (Norwegian Bokmål) * Added translation using Weblate (Portuguese) * Added translation using Weblate (Portuguese (Brazil)) * Added translation using Weblate (Spanish) * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (420 of 420 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt_BR/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (420 of 420 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt_BR/ * Translated using Weblate (Spanish) Currently translated at 100.0% (420 of 420 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ * Translated using Weblate (Spanish) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ Co-authored-by: J. Lavoie Co-authored-by: Allan Nordhøy Co-authored-by: Iagocds Co-authored-by: German Co-authored-by: gallegonovato --- backend/templates/l10n/de.json | 29 +- backend/templates/l10n/en.json | 14 +- backend/templates/l10n/es.json | 425 ++++++++++++++++++++++++++++++ backend/templates/l10n/fr.json | 425 ++++++++++++++++++++++++++++++ backend/templates/l10n/nb_NO.json | 1 + backend/templates/l10n/pt.json | 1 + backend/templates/l10n/pt_BR.json | 425 ++++++++++++++++++++++++++++++ 7 files changed, 1311 insertions(+), 9 deletions(-) create mode 100644 backend/templates/l10n/es.json create mode 100644 backend/templates/l10n/fr.json create mode 100644 backend/templates/l10n/nb_NO.json create mode 100644 backend/templates/l10n/pt.json create mode 100644 backend/templates/l10n/pt_BR.json diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index 4beb6df8..59c37cb2 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -35,7 +35,6 @@ "baking_yeast": "Backhefe", "balsamic_vinegar": "Balsamico Essig", "bananas": "Bananen", - "barbecue_butter_spice": "Grillbutter-Gewürz", "basil": "Basilikum", "basmati_rice": "Basmati Reis", "bathroom_cleaner": "Badreiniger", @@ -44,9 +43,11 @@ "beans": "Bohnen", "beer": "Bier", "beet": "Rote Beete", + "beetroot": "Rote Bete", "birthday_card": "Geburtstagskarte", "black_beans": "Schwarze Bohnen", "bockwurst": "Bockwurst", + "bodywash": "Bodywash", "bread": "Brot", "breadcrumbs": "Paniermehl", "broccoli": "Brokkoli", @@ -66,6 +67,7 @@ "cake_icing": "Kuchenglasur", "cane_sugar": "Rohrzucker", "cannelloni": "Cannelloni", + "canola_oil": "Rapsöl", "cardamom": "Kardamom", "carrots": "Möhren", "cashews": "Cashewkerne", @@ -73,6 +75,7 @@ "cauliflower": "Blumenkohl", "celeriac": "Knollensellerie", "celery": "Sellerie", + "cereal_bar": "Müsliriegel", "cheddar": "Cheddar", "cheese": "Käse", "cherry_tomatoes": "Kirschtomaten", @@ -84,6 +87,7 @@ "chopped_tomatoes": "Gehackte Tomaten", "ciabatta": "Ciabatta", "cider_vinegar": "Apfelessig", + "cilantro": "Koriander", "cinnamon": "Zimt", "cinnamon_stick": "Zimtstange", "cocktail_sauce": "Cocktailsauce", @@ -96,6 +100,7 @@ "cookies": "Kekse", "coriander": "Koriander", "corn": "Mais", + "cornflakes": "Cornflakes", "cornstarch": "Speisestärke", "cornys": "Cornys", "cough_drops": "Hustenbonbons", @@ -116,8 +121,10 @@ "dates": "Datteln", "dental_floss": "Zahnseide", "deodorant": "Deodorant", + "detergent": "Waschmittel", "dill": "Dill", "dishwasher_salt": "Spülmaschinensalz", + "dishwasher_tabs": "Tabs für die Spülmaschine", "disinfection_spray": "Desinfektionsspray", "dried_tomatoes": "Getrocknete Tomaten", "edamame": "Edamame", @@ -177,7 +184,9 @@ "ketchup": "Ketchup", "kidney_beans": "Kidneybohnen", "kitchen_roll": "Küchenrolle", + "kitchen_towels": "Küchenhandtücher", "kohlrabi": "Kohlrabi", + "lasagna": "Lasagne", "lasagna_noodles": "Lasagnenudeln", "lasagna_plates": "Lasagne Platten", "leaf_spinach": "Blattspinat", @@ -186,7 +195,9 @@ "lemon_juice": "Zitronensaft", "lemonade": "Limonade", "lemongrass": "Zitronengras", + "lentils": "Linsen", "lentils_red": "Linsen rot", + "lettuce": "Kopfsalat", "lillet": "Lillet", "lime": "Limette", "linguine": "Linguine", @@ -245,14 +256,17 @@ "penne": "Penne", "pepper": "Pfeffer", "pepper_mill": "Pfeffermühle", + "peppers": "Paprika", "persian_rice": "Persischer Reis", "pesto": "Pesto", "pilsner": "Pils", "pine_nuts": "Pinienkerne", "pineapple": "Ananas", "pita_bag": "Pitatasche", + "pizza": "Pizza", "pizza_dough": "Pizzateig", "plant_magarine": "Pflanzenmagarine", + "plant_oil": "Pflanzenöl", "plaster": "Pflaster", "porcini_mushrooms": "Steinpilze", "potato_dumpling_dough": "Kartoffelkloßteig", @@ -266,6 +280,7 @@ "puff_pastry": "Blätterteig", "pumpkin": "Kürbis", "pumpkin_seeds": "Kürbiskerne", + "quark": "Quark", "quinoa": "Quinoa", "radicchio": "Radicchio", "radish": "Radieschen", @@ -290,6 +305,7 @@ "rinse_tabs": "Spültabs", "rinsing_agent": "Spüli", "risotto_rice": "Risotto Reis", + "rocket": "Rakete", "roll": "Brötchen", "rosemary": "Rosmarin ", "saffron_threads": "Safranfäden", @@ -304,6 +320,7 @@ "sausage": "Wurst", "sausages": "Würstchen", "savoy_cabbage": "Wirsing", + "scallion": "Jakobsmuschel", "scattered_cheese": "Streukäse", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", @@ -317,12 +334,15 @@ "shiitake_mushroom": "Shiitakepilz", "shoe_insoles": "Schuheinlagen", "shower_gel": "Duschgel", + "shredded_cheese": "Geriebener Käse", + "sieved_tomatoes": "Gesiebte Tomaten", "sliced_cheese": "Scheibenkäse", "smoked_paprika": "Smoked Paprika", "smoked_tofu": "Räuchertofu", "snacks": "Snacks", "soap": "Seife", "soft_drinks": "Softdrinks", + "softdrinks": "Erfrischungsgetränke", "sour_cream": "Schmand", "sour_cucumbers": "Saure Gurken", "soy_hack": "Sojahack", @@ -339,6 +359,7 @@ "spreading_cream": "Streichcreme", "spring_onions": "Frühlingszwiebeln", "sprite": "Sprite", + "sprouts": "Sprossen", "sriracha": "Sriracha", "strained_tomatoes": "Passierte Tomaten", "sugar": "Zucker", @@ -347,9 +368,12 @@ "sushi_rice": "Sushi Reis", "swabian_ravioli": "Maultaschen", "sweet_potato": "Süßkartoffel", + "sweet_potatoes": "Süßkartoffeln", "table_salt": "Tafelsalz", + "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Mandarinen", + "tape": "Klebeband", "tea": "Tee", "teriyaki_sauce": "Teriyaki-Soße", "thyme": "Thymian", @@ -391,10 +415,11 @@ "wrapping_paper": "Geschenkpapier", "wraps": "Wraps", "yeast": "Hefe", + "yoghurt": "Joghurt", "yogurt": "Joghurt", "yum_yum": "Yum Yum", "zewa": "Zewa", "zinc_cream": "Zinkcreme", "zucchini": "Zucchini" } -} \ No newline at end of file +} diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index 4fbb5d90..a934ee71 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -5,7 +5,7 @@ "dairy": "🥛 Dairy", "drinks": "🍹 Drinks", "freezer": "❄️ Freezer", - "fruits_vegetables": "🥬 Fruits & Vegetables", + "fruits_vegetables": "🥬 Fruits and vegetables", "grain": "🥟 Grain Products", "hygiene": "🚽 Hygiene", "refrigerated": "💧 Refrigerated", @@ -160,7 +160,7 @@ "greek_yogurt": "Greek yogurt", "green_asparagus": "Green asparagus", "green_chili": "Green chili", - "green_pesto": "green pesto", + "green_pesto": "Green pesto", "hair_gel": "Hair gel", "hair_wax": "Hair Wax", "handkerchief_box": "Handkerchief box", @@ -195,8 +195,8 @@ "lemon_juice": "Lemon juice", "lemonade": "Lemonade", "lemongrass": "Lemongrass", - "lentils_red": "Red lentils", "lentils": "Lentils", + "lentils_red": "Red lentils", "lettuce": "Lettuce", "lillet": "Lillet", "lime": "Lime", @@ -227,7 +227,7 @@ "neutral_oil": "Neutral oil", "nori_sheets": "Nori sheets", "nutmeg": "Nutmeg", - "oat_milk": "Oat milk", + "oat_milk": "Oat drink", "oatmeal": "Oatmeal", "oatmeal_cookies": "Oatmeal cookies", "oatsome": "Oatsome", @@ -270,6 +270,7 @@ "plaster": "Plaster", "porcini_mushrooms": "Porcini mushrooms", "potato_dumpling_dough": "Potato dumpling dough", + "potato_wedges": "Potato wedges", "potatoes": "Potatoes", "potting_soil": "Potting soil", "powder": "Powder", @@ -323,7 +324,7 @@ "scattered_cheese": "Scattered cheese", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "sckocolate_chips": "Sckocolate chips", + "sckocolate_chips": "Chocolate chips", "semolina_porridge": "Semolina porridge", "sesame": "Sesame", "sesame_oil": "Sesame oil", @@ -376,7 +377,6 @@ "tea": "Tea", "teriyaki_sauce": "Teriyaki sauce", "thyme": "Thyme", - "potato_wedges": "Potato wedges", "toast": "Toast", "tofu": "Tofu", "toilet_paper": "Toilet paper", @@ -422,4 +422,4 @@ "zinc_cream": "Zinc cream", "zucchini": "Zucchini" } -} \ No newline at end of file +} diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json new file mode 100644 index 00000000..cf02657b --- /dev/null +++ b/backend/templates/l10n/es.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 Productos de panadería", + "canned": "🥫 Conservas", + "dairy": "🥛 Lácteos", + "drinks": "🍹 Bebidas", + "freezer": "❄️ Congelados", + "fruits_vegetables": "🥬 Frutas y verduras", + "grain": "🥟 Productos de cereales", + "hygiene": "🚽 Higiene", + "refrigerated": "💧 Refrigerado", + "snacks": "🥜 Aperitivos" + }, + "items": { + "aioli": "Alioli", + "amaretto": "Amaretto", + "apple": "Manzana", + "apple_pulp": "Pulpa de la manzana", + "applesauce": "Compota de manzana", + "apérol": "Apérol", + "arugula": "Rúcula", + "asian_egg_noodles": "Fideos asiáticos al huevo", + "aspargus": "Espárragos", + "aspirin": "Aspirina", + "avocado": "Aguacate", + "baby_spinach": "Espinacas tiernas", + "bacon": "Beicon", + "baguette": "Baguette", + "bakefish": "Pescado al horno", + "baking_cocoa": "Cacao de repostería", + "baking_mix": "Preparado para hornear", + "baking_paper": "Papel de horno", + "baking_powder": "Levadura en polvo", + "baking_soda": "Bicarbonato", + "baking_yeast": "Levadura", + "balsamic_vinegar": "Vinagre balsámico", + "bananas": "Plátanos", + "basil": "Albahaca", + "basmati_rice": "Arroz basmati", + "bathroom_cleaner": "Limpiador de baños", + "batteries": "Pilas", + "bay_leaf": "Hoja de laurel", + "beans": "Judías", + "beer": "Cerveza", + "beet": "Remolacha", + "beetroot": "Remolacha", + "birthday_card": "Tarjeta de cumpleaños", + "black_beans": "Alubias negras", + "bockwurst": "Bockwurst", + "bodywash": "Jabón líquido", + "bread": "Pan", + "breadcrumbs": "Migas de pan", + "broccoli": "Brócoli", + "brown_sugar": "Azúcar moreno", + "brussels_sprouts": "Coles de Bruselas", + "buffalo_mozzarella": "Mozzarella de búfala", + "buko": "Buko", + "buns": "Bollos", + "burger_buns": "Pan de hamburguesa", + "burger_patties": "Hamburguesas", + "burger_sauces": "Salsas para hamburguesas", + "butter": "Mantequilla", + "butter_cookies": "Galletas de mantequilla", + "button_cells": "Pilas de botón", + "börek_cheese": "Queso Börek", + "cake": "Pastel", + "cake_icing": "Glaseado para tartas", + "cane_sugar": "Azúcar de caña", + "cannelloni": "Canelones", + "canola_oil": "Aceite de canola", + "cardamom": "Cardamomo", + "carrots": "Zanahorias", + "cashews": "Anacardos", + "cat_treats": "Golosinas para gatos", + "cauliflower": "Coliflor", + "celeriac": "Apionabo", + "celery": "Apio", + "cereal_bar": "Barra de cereales", + "cheddar": "Cheddar", + "cheese": "Queso", + "cherry_tomatoes": "Tomates cherry", + "chickpeas": "Garbanzos", + "chili_oil": "Aceite de chile", + "chips": "Fichas", + "chives": "Cebollino", + "chocolate": "Chocolate", + "chopped_tomatoes": "Tomates picados", + "ciabatta": "Ciabatta", + "cider_vinegar": "Vinagre de sidra", + "cilantro": "Cilantro", + "cinnamon": "Canela", + "cinnamon_stick": "Canela en rama", + "cocktail_sauce": "Salsa cóctel", + "cocktail_tomatoes": "Tomates de cóctel", + "coconut_flakes": "Copos de coco", + "coconut_milk": "Leche de coco", + "coconut_oil": "Aceite de coco", + "colorful_sprinkles": "Espolvoreado de colores", + "concealer": "Corrector", + "cookies": "Cookies", + "coriander": "Cilantro", + "corn": "Maíz", + "cornflakes": "Copos de maíz", + "cornstarch": "Maicena", + "cornys": "Cornys", + "cough_drops": "Pastillas para la tos", + "couscous": "Cuscús", + "covid_rapid_test": "Prueba rápida COVID", + "cow's_milk": "Leche de vaca", + "cream": "Crema", + "cream_cheese": "Queso cremoso", + "creamed_spinach": "Espinacas a la crema", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Cinta de crepé", + "crispbread": "Pan crujiente", + "cucumber": "Pepino", + "cumin": "Comino", + "curry_paste": "Pasta de curry", + "curry_powder": "Curry en polvo", + "curry_sauce": "Salsa de curry", + "dates": "Fechas", + "dental_floss": "Hilo dental", + "deodorant": "Desodorante", + "detergent": "Detergente", + "dill": "Eneldo", + "dishwasher_salt": "Sal para lavavajillas", + "dishwasher_tabs": "Pestañas para lavavajillas", + "disinfection_spray": "Spray desinfectante", + "dried_tomatoes": "Tomates secos", + "edamame": "Edamame", + "eggplant": "Berenjena", + "eggs": "Huevos", + "falafel": "Falafel", + "falafel_powder": "Falafel en polvo", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Palitos de pescado", + "flour": "Harina", + "flushing": "Enjuague", + "fresh_cili_pepper": "Guindilla fresca", + "frozen_berries": "Bayas congeladas", + "frozen_fruit": "Fruta congelada", + "frozen_pizza": "Pizza congelada", + "frozen_spinach": "Espinacas congeladas", + "garam_masala": "Garam Masala", + "garbage_bag": "Bolsa de basura", + "garlic": "Ajo", + "garlic_dip": "Salsa de ajo", + "garlic_granules": "Ajo granulado", + "gherkins": "Pepinillos", + "ginger": "Jengibre", + "glass_noodles": "Fideos de vidrio", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Uvas", + "greek_yogurt": "Yogur griego", + "green_asparagus": "Espárragos verdes", + "green_chili": "Guindilla verde", + "green_pesto": "Pesto verde", + "hair_gel": "Gel para el pelo", + "hair_wax": "Cera para el pelo", + "handkerchief_box": "Caja de pañuelos", + "handkerchiefs": "Pañuelos", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Avellanas", + "head_of_lettuce": "Cogollo de lechuga", + "herb_baguettes": "Baguettes de hierbas", + "herb_cream_cheese": "Crema de queso a las hierbas", + "honey": "Miel", + "honey_wafers": "Barquillos de miel", + "hot_dog_bun": "Panecillo para perritos calientes", + "ice_cream": "Helados", + "ice_cube": "Cubito de hielo", + "iceberg_lettuce": "Lechuga iceberg", + "iced_tea": "Té helado", + "instant_soups": "Sopas instantáneas", + "jam": "Mermelada", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Alubias rojas", + "kitchen_roll": "Papel de cocina", + "kitchen_towels": "Paños de cocina", + "kohlrabi": "Colirrábano", + "lasagna": "Lasaña", + "lasagna_noodles": "Fideos para lasaña", + "lasagna_plates": "Platos de lasaña", + "leaf_spinach": "Espinacas de hoja", + "leek": "Puerro", + "lemon": "Limón", + "lemon_juice": "Zumo de limón", + "lemonade": "Limonada", + "lemongrass": "Hierba limón", + "lentils": "Lentejas", + "lentils_red": "Lentejas rojas", + "lettuce": "Lechuga", + "lillet": "Lillet", + "lime": "Cal", + "linguine": "Linguine", + "low-fat_curd_cheese": "Cuajada baja en grasas", + "magnesium": "Magnesio", + "mango": "Mango", + "margarine": "Margarina", + "marjoram": "Mejorana", + "marshmallows": "Malvaviscos", + "mask": "Máscara", + "mayonnaise": "Mayonesa", + "meat_substitute_product": "Producto sustitutivo de la carne", + "microfiber_cloth": "Paño de microfibra", + "milk": "Leche", + "mint": "Menta", + "mint_candy": "Caramelos de menta", + "mixed_vegetables": "Mezcla de verduras", + "mochis": "Mochis", + "mountain_cheese": "Queso de montaña", + "mouth_wash": "Enjuague bucal", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Barra de muesli", + "mulled_wine": "Vino caliente", + "mushrooms": "Setas", + "mustard": "Mostaza", + "neutral_oil": "Aceite neutro", + "nori_sheets": "Hojas de nori", + "nutmeg": "Nuez moscada", + "oat_milk": "Bebida de avena", + "oatmeal": "Harina de avena", + "oatmeal_cookies": "Galletas de avena", + "oatsome": "Avena", + "obatzda": "Obatzda", + "olive_oil": "Aceite de oliva", + "olives": "Aceitunas", + "onion": "Cebolla", + "orange_juice": "Zumo de naranja", + "oranges": "Naranjas", + "oregano": "Orégano", + "organic_lemon": "Limón ecológico", + "organic_waste_bags": "Bolsas para residuos orgánicos", + "pak_choi": "Pak Choi", + "paprika": "Pimentón", + "pardina_lentils_dried": "Lentejas pardinas secas", + "parmesan": "Parmesano", + "parsley": "Perejil", + "pasta": "Pasta", + "peach": "Melocotón", + "peanut_butter": "Mantequilla de cacahuete", + "peanut_flips": "Vueltas de cacahuete", + "peanut_oil": "Aceite de cacahuete", + "peanuts": "Cacahuetes", + "pears": "Peras", + "peas": "Guisantes", + "penne": "Penne", + "pepper": "Pimienta", + "pepper_mill": "Molinillo de pimienta", + "peppers": "Pimientos", + "persian_rice": "Arroz persa", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Piñones", + "pineapple": "Piña", + "pita_bag": "Bolsa de pita", + "pizza": "Pizza", + "pizza_dough": "Masa de pizza", + "plant_magarine": "Planta Magarine", + "plant_oil": "Aceite vegetal", + "plaster": "Escayola", + "porcini_mushrooms": "Setas porcini", + "potato_dumpling_dough": "Masa de albóndigas de patata", + "potato_wedges": "Cuñas de patata", + "potatoes": "Patatas", + "potting_soil": "Tierra para macetas", + "powder": "Polvo", + "powdered_sugar": "Azúcar en polvo", + "processed_cheese": "Queso fundido", + "prosecco": "Prosecco", + "puff_pastry": "Hojaldre", + "pumpkin": "Calabaza", + "pumpkin_seeds": "Semillas de calabaza", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Rábano", + "ramen": "Ramen", + "rapeseed_oil": "Aceite de colza", + "raspberries": "Frambuesas", + "raspberry_syrup": "Sirope de frambuesa", + "red_bull": "Red Bull", + "red_chili": "Guindilla roja", + "red_lentils": "Lentejas rojas", + "red_onions": "Cebollas rojas", + "red_pesto": "Pesto rojo", + "red_wine": "Vino tinto", + "red_wine_vinegar": "Vinagre de vino tinto", + "rhubarb": "Ruibarbo", + "ribbon_noodles": "Cinta de fideos", + "rice": "Arroz", + "rice_cakes": "Pasteles de arroz", + "rice_ribbon_noodles": "Fideos con cinta de arroz", + "rice_vinegar": "Vinagre de arroz", + "ricotta": "Ricotta", + "rinse_tabs": "Pestañas de enjuague", + "rinsing_agent": "Agente de enjuague", + "risotto_rice": "Arroz para risotto", + "rocket": "Cohete", + "roll": "Rollo", + "rosemary": "Rosemary", + "saffron_threads": "Hilos de azafrán", + "sage": "Salvia", + "saitan_powder": "Saitán en polvo", + "salad_mix": "Mezcla para ensalada", + "salad_seeds_mix": "Mezcla de semillas para ensalada", + "salt": "Sal", + "salt_mill": "Molino de sal", + "sambal_oelek": "Sambal oelek", + "sauce": "Salsa", + "sausage": "Salchichas", + "sausages": "Salchichas", + "savoy_cabbage": "Col de Milán", + "scallion": "Cebolleta", + "scattered_cheese": "Queso esparcido", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "sckocolate_chips": "Chispas de chocolate", + "semolina_porridge": "Gachas de sémola", + "sesame": "Sésamo", + "sesame_oil": "Aceite de sésamo", + "shallot": "Chalota", + "shampoo": "Champú", + "shawarma_spice": "Shawarma picante", + "shiitake_mushroom": "Champiñón shiitake", + "shoe_insoles": "Plantillas", + "shower_gel": "Gel de ducha", + "shredded_cheese": "Queso rallado", + "sieved_tomatoes": "Tomates tamizados", + "sliced_cheese": "Queso en lonchas", + "smoked_paprika": "Pimentón ahumado", + "smoked_tofu": "Tofu ahumado", + "snacks": "Aperitivos", + "soap": "Jabón", + "soft_drinks": "Refrescos", + "softdrinks": "Refrescos", + "sour_cream": "Nata agria", + "sour_cucumbers": "Pepinos agrios", + "soy_hack": "Hack de soja", + "soy_sauce": "Salsa de soja", + "soy_shred": "Triturado de soja", + "spaetzle": "Spaetzle", + "spaghetti": "Espaguetis", + "sparkling_water": "Agua con gas", + "spelt": "Escanda", + "spinach": "Espinacas", + "sponge_cloth": "Paño de esponja", + "sponge_wipes": "Toallitas de esponja", + "sponges": "Esponjas", + "spreading_cream": "Crema para untar", + "spring_onions": "Cebolletas", + "sprite": "Sprite", + "sprouts": "Brotes", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomates escurridos", + "sugar": "Azúcar", + "summer_roll_paper": "Rollo de papel de verano", + "sunflower_seeds": "Semillas de girasol", + "sushi_rice": "Arroz para sushi", + "swabian_ravioli": "Raviolis suabos", + "sweet_potato": "Boniato", + "sweet_potatoes": "Boniatos", + "table_salt": "Sal de mesa", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandarinas", + "tape": "Cinta", + "tea": "Té", + "teriyaki_sauce": "Salsa Teriyaki", + "thyme": "Tomillo", + "toast": "Tostadas", + "tofu": "Tofu", + "toilet_paper": "Papel higiénico", + "tomato_juice": "Zumo de tomate", + "tomato_paste": "Pasta de tomate", + "tomato_sauce": "Salsa de tomate", + "tomatoes": "Tomates", + "tonic_water": "Agua tónica", + "toothpaste": "Pasta de dientes", + "tortellini": "Tortellini", + "tortilla_chips": "Tortillas fritas", + "tuna": "Atún", + "turmeric": "Cúrcuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Fideos Udon", + "uht_milk": "Leche UHT", + "vanilla_sugar": "Azúcar vainillado", + "vegetable_bouillon_cube": "Cubitos de caldo vegetal", + "vegetable_broth": "Caldo de verduras", + "vegetable_oil": "Aceite vegetal", + "vegetable_onion": "Cebolla vegetal", + "vegetables": "Verduras", + "vegetarian_cold_cuts": "fiambres vegetarianos", + "vinegar": "Vinagre", + "vodka": "Vodka", + "washing_powder": "Detergente en polvo", + "water": "Agua", + "water_ice": "Hielo", + "watermelon": "Sandía", + "wc_cleaner": "Limpiador de WC", + "whipped_cream": "Nata montada", + "white_wine": "Vino blanco", + "white_wine_vinegar": "Vinagre de vino blanco", + "whole_canned_tomatoes": "Tomates enteros en conserva", + "wild_berries": "Bayas silvestres", + "wrapping_paper": "Papel de envolver", + "wraps": "Envolturas", + "yeast": "Levadura", + "yoghurt": "Yogur", + "yogurt": "Yogur", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Crema de zinc", + "zucchini": "Calabacín" + } +} diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json new file mode 100644 index 00000000..0a3111ce --- /dev/null +++ b/backend/templates/l10n/fr.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 Produits de boulangerie", + "canned": "🥫 Conserves", + "dairy": "🥛 Laitage", + "drinks": "🍹 Boissons", + "freezer": "❄️ Surgelé", + "fruits_vegetables": "🥬 Fruits et légumes", + "grain": "🥟 Produits céréaliers", + "hygiene": "🚽 Hygiène", + "refrigerated": "💧 Réfrigéré", + "snacks": "🥜 Collations" + }, + "items": { + "aioli": "Aïoli", + "amaretto": "Amaretto", + "apple": "Apple", + "apple_pulp": "Pulpe de pomme", + "applesauce": "Compote de pommes", + "apérol": "Apérol", + "arugula": "Roquette", + "asian_egg_noodles": "Nouilles asiatiques aux œufs", + "aspargus": "Aspargus", + "aspirin": "Aspirine", + "avocado": "Avocat", + "baby_spinach": "Jeunes épinards", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Bakefish", + "baking_cocoa": "Cacao à cuire", + "baking_mix": "Mélange à pâtisserie", + "baking_paper": "Papier sulfurisé", + "baking_powder": "Poudre à lever", + "baking_soda": "Bicarbonate de soude", + "baking_yeast": "Levure de boulangerie", + "balsamic_vinegar": "Vinaigre balsamique", + "bananas": "Bananes", + "basil": "Basilic", + "basmati_rice": "Riz basmati", + "bathroom_cleaner": "Nettoyant pour salle de bains", + "batteries": "Piles", + "bay_leaf": "Feuilles de laurier", + "beans": "Haricots", + "beer": "Bière", + "beet": "Betterave", + "beetroot": "Betterave rouge", + "birthday_card": "Carte d'anniversaire", + "black_beans": "Haricots noirs", + "bockwurst": "Bockwurst", + "bodywash": "Bain de corps", + "bread": "Pain", + "breadcrumbs": "Chapelure", + "broccoli": "Brocoli", + "brown_sugar": "Sucre brun", + "brussels_sprouts": "Choux de Bruxelles", + "buffalo_mozzarella": "Mozzarella de buffle", + "buko": "Buko", + "buns": "Brioches", + "burger_buns": "Pains à burger", + "burger_patties": "Galettes pour hamburgers", + "burger_sauces": "Sauces pour hamburgers", + "butter": "Beurre", + "butter_cookies": "Biscuits au beurre", + "button_cells": "Cellules boutons", + "börek_cheese": "Fromage Börek", + "cake": "Gâteau", + "cake_icing": "Glaçage de gâteau", + "cane_sugar": "Sucre de canne", + "cannelloni": "Cannelloni", + "canola_oil": "Huile de canola", + "cardamom": "Cardamome", + "carrots": "Carottes", + "cashews": "Noix de cajou", + "cat_treats": "Friandises pour chats", + "cauliflower": "Chou-fleur", + "celeriac": "Céleri-rave", + "celery": "Céleri", + "cereal_bar": "Barre de céréales", + "cheddar": "Cheddar", + "cheese": "Fromage", + "cherry_tomatoes": "Tomates cerises", + "chickpeas": "Pois chiches", + "chili_oil": "Huile de piment", + "chips": "Chips", + "chives": "Ciboulette", + "chocolate": "Chocolat", + "chopped_tomatoes": "Tomates coupées en morceaux", + "ciabatta": "Ciabatta", + "cider_vinegar": "Vinaigre de cidre", + "cilantro": "Coriandre", + "cinnamon": "Cannelle", + "cinnamon_stick": "Bâton de cannelle", + "cocktail_sauce": "Sauce cocktail", + "cocktail_tomatoes": "Tomates cocktail", + "coconut_flakes": "Flocons de noix de coco", + "coconut_milk": "Lait de coco", + "coconut_oil": "Huile de noix de coco", + "colorful_sprinkles": "Saupoudrage coloré", + "concealer": "Correcteur de teint", + "cookies": "Cookies", + "coriander": "Coriandre", + "corn": "Maïs", + "cornflakes": "Cornflakes", + "cornstarch": "Amidon de maïs", + "cornys": "Cornys", + "cough_drops": "Gouttes contre la toux", + "couscous": "Couscous", + "covid_rapid_test": "Test rapide COVID", + "cow's_milk": "Lait de vache", + "cream": "Crème", + "cream_cheese": "Fromage à la crème", + "creamed_spinach": "Crème d'épinards", + "creme_fraiche": "Crème fraiche", + "crepe_tape": "Bande crêpe", + "crispbread": "Pain croustillant", + "cucumber": "Concombre", + "cumin": "Cumin", + "curry_paste": "Pâte de curry", + "curry_powder": "Poudre de curry", + "curry_sauce": "Sauce au curry", + "dates": "Dates", + "dental_floss": "Fil dentaire", + "deodorant": "Déodorant", + "detergent": "Détergent", + "dill": "Aneth", + "dishwasher_salt": "Sel pour lave-vaisselle", + "dishwasher_tabs": "Languettes pour lave-vaisselle", + "disinfection_spray": "Spray désinfectant", + "dried_tomatoes": "Tomates séchées", + "edamame": "Edamame", + "eggplant": "Aubergine", + "eggs": "Œufs", + "falafel": "Falafel", + "falafel_powder": "Poudre de falafel", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Bâtonnets de poisson", + "flour": "Farine", + "flushing": "Chasse d'eau", + "fresh_cili_pepper": "Piment cili frais", + "frozen_berries": "Baies congelées", + "frozen_fruit": "Fruits congelés", + "frozen_pizza": "Pizza surgelée", + "frozen_spinach": "Epinards surgelés", + "garam_masala": "Garam Masala", + "garbage_bag": "Sac à ordures", + "garlic": "Ail", + "garlic_dip": "Trempette à l'ail", + "garlic_granules": "Ail en granulés", + "gherkins": "Cornichons", + "ginger": "Gingembre", + "glass_noodles": "Nouilles en verre", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Raisins", + "greek_yogurt": "Yogourt grec", + "green_asparagus": "Asperges vertes", + "green_chili": "Piment vert", + "green_pesto": "Pesto vert", + "hair_gel": "Gel pour cheveux", + "hair_wax": "Cire pour cheveux", + "handkerchief_box": "Boîte à mouchoirs", + "handkerchiefs": "Mouchoirs en papier", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Noisettes", + "head_of_lettuce": "Tête de laitue", + "herb_baguettes": "Baguettes aux herbes", + "herb_cream_cheese": "Fromage frais aux herbes", + "honey": "Miel", + "honey_wafers": "Gaufres au miel", + "hot_dog_bun": "Pain à hot-dog", + "ice_cream": "Crème glacée", + "ice_cube": "Glaçon", + "iceberg_lettuce": "Laitue iceberg", + "iced_tea": "Thé glacé", + "instant_soups": "Soupes instantanées", + "jam": "Confiture", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Haricots rouges", + "kitchen_roll": "Rouleau de cuisine", + "kitchen_towels": "Torchons de cuisine", + "kohlrabi": "Chou-rave", + "lasagna": "Lasagnes", + "lasagna_noodles": "Nouilles à lasagnes", + "lasagna_plates": "Assiettes à lasagnes", + "leaf_spinach": "Epinards à feuilles", + "leek": "Poireau", + "lemon": "Citron", + "lemon_juice": "Jus de citron", + "lemonade": "Limonade", + "lemongrass": "Lemongrass", + "lentils": "Lentilles", + "lentils_red": "Lentilles rouges", + "lettuce": "Laitue", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "low-fat_curd_cheese": "Fromage blanc à faible teneur en matières grasses", + "magnesium": "Magnésium", + "mango": "Mangue", + "margarine": "Margarine", + "marjoram": "Marjolaine", + "marshmallows": "Guimauves", + "mask": "Masque", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Produit de substitution de la viande", + "microfiber_cloth": "Chiffon en microfibre", + "milk": "Lait", + "mint": "Menthe", + "mint_candy": "Bonbons à la menthe", + "mixed_vegetables": "Légumes mélangés", + "mochis": "Mochis", + "mountain_cheese": "Fromage de montagne", + "mouth_wash": "Bain de bouche", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Bar à muesli", + "mulled_wine": "Vin chaud", + "mushrooms": "Champignons", + "mustard": "Moutarde", + "neutral_oil": "Huile neutre", + "nori_sheets": "Feuilles de nori", + "nutmeg": "Noix de muscade", + "oat_milk": "Boisson à l'avoine", + "oatmeal": "Flocons d'avoine", + "oatmeal_cookies": "Biscuits à la farine d'avoine", + "oatsome": "Avoine", + "obatzda": "Obatzda", + "olive_oil": "Huile d'olive", + "olives": "Olives", + "onion": "Oignon", + "orange_juice": "Jus d'orange", + "oranges": "Oranges", + "oregano": "Origan", + "organic_lemon": "Citron biologique", + "organic_waste_bags": "Sacs à déchets organiques", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Lentilles Pardina séchées", + "parmesan": "Parmesan", + "parsley": "Persil", + "pasta": "Pâtes", + "peach": "Pêche", + "peanut_butter": "Beurre de cacahuète", + "peanut_flips": "Flips aux cacahuètes", + "peanut_oil": "Huile d'arachide", + "peanuts": "Cacahuètes", + "pears": "Poires", + "peas": "Pois", + "penne": "Penne", + "pepper": "Poivre", + "pepper_mill": "Moulin à poivre", + "peppers": "Poivrons", + "persian_rice": "Riz persan", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pignons de pin", + "pineapple": "Ananas", + "pita_bag": "Sac à pita", + "pizza": "Pizza", + "pizza_dough": "Pâte à pizza", + "plant_magarine": "Magarine végétale", + "plant_oil": "Huile végétale", + "plaster": "Plâtre", + "porcini_mushrooms": "Champignons Porcini", + "potato_dumpling_dough": "Pâte à boulettes de pommes de terre", + "potato_wedges": "Quartiers de pommes de terre", + "potatoes": "Pommes de terre", + "potting_soil": "Terreau", + "powder": "Poudre", + "powdered_sugar": "Sucre en poudre", + "processed_cheese": "Fromage fondu", + "prosecco": "Prosecco", + "puff_pastry": "Pâte feuilletée", + "pumpkin": "Citrouille", + "pumpkin_seeds": "Graines de citrouille", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Radis", + "ramen": "Ramen", + "rapeseed_oil": "Huile de colza", + "raspberries": "Framboises", + "raspberry_syrup": "Sirop de framboise", + "red_bull": "Red Bull", + "red_chili": "Piment rouge", + "red_lentils": "Lentilles rouges", + "red_onions": "Oignons rouges", + "red_pesto": "Pesto rouge", + "red_wine": "Vin rouge", + "red_wine_vinegar": "Vinaigre de vin rouge", + "rhubarb": "Rhubarbe", + "ribbon_noodles": "Nouilles en ruban", + "rice": "Riz", + "rice_cakes": "Gâteaux de riz", + "rice_ribbon_noodles": "Nouilles en ruban de riz", + "rice_vinegar": "Vinaigre de riz", + "ricotta": "Ricotta", + "rinse_tabs": "Onglets de rinçage", + "rinsing_agent": "Agent de rinçage", + "risotto_rice": "Riz pour risotto", + "rocket": "Fusée", + "roll": "Rouleau", + "rosemary": "Rosemary", + "saffron_threads": "Fils de safran", + "sage": "Sage", + "saitan_powder": "Poudre de saitan", + "salad_mix": "Mélange de salades", + "salad_seeds_mix": "Mélange de graines pour salade", + "salt": "Sel", + "salt_mill": "Moulin à sel", + "sambal_oelek": "Sambal oelek", + "sauce": "Sauce", + "sausage": "Saucisse", + "sausages": "Saucisses", + "savoy_cabbage": "Chou de Savoie", + "scallion": "Echalote", + "scattered_cheese": "Fromage éparpillé", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "sckocolate_chips": "Pépites de chocolat", + "semolina_porridge": "Porridge de semoule", + "sesame": "Sésame", + "sesame_oil": "Huile de sésame", + "shallot": "Échalote", + "shampoo": "Shampooing", + "shawarma_spice": "Épices pour shawarma", + "shiitake_mushroom": "Champignon Shiitake", + "shoe_insoles": "Semelles de chaussures", + "shower_gel": "Gel douche", + "shredded_cheese": "Fromage râpé", + "sieved_tomatoes": "Tomates tamisées", + "sliced_cheese": "Fromage en tranches", + "smoked_paprika": "Paprika fumé", + "smoked_tofu": "Tofu fumé", + "snacks": "Snacks", + "soap": "Savon", + "soft_drinks": "Boissons gazeuses", + "softdrinks": "Boissons gazeuses", + "sour_cream": "Crème aigre", + "sour_cucumbers": "Concombres aigres", + "soy_hack": "Le soja", + "soy_sauce": "Sauce soja", + "soy_shred": "Effilochage de soja", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Eau pétillante", + "spelt": "Épeautre", + "spinach": "Epinards", + "sponge_cloth": "Tissu éponge", + "sponge_wipes": "Lingettes éponge", + "sponges": "Éponges", + "spreading_cream": "Crème à tartiner", + "spring_onions": "Oignons de printemps", + "sprite": "Sprite", + "sprouts": "Sprouts", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomates égouttées", + "sugar": "Sucre", + "summer_roll_paper": "Rouleau de papier d'été", + "sunflower_seeds": "Graines de tournesol", + "sushi_rice": "Riz à sushi", + "swabian_ravioli": "Raviolis souabes", + "sweet_potato": "Patate douce", + "sweet_potatoes": "Patates douces", + "table_salt": "Sel de table", + "tagliatelle": "Tagliatelles", + "tahini": "Tahini", + "tangerines": "Mandarines", + "tape": "Ruban adhésif", + "tea": "Thé", + "teriyaki_sauce": "Sauce teriyaki", + "thyme": "Thym", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Papier hygiénique", + "tomato_juice": "Jus de tomate", + "tomato_paste": "Pâte de tomates", + "tomato_sauce": "Sauce tomate", + "tomatoes": "Tomates", + "tonic_water": "Eau tonique", + "toothpaste": "Dentifrice", + "tortellini": "Tortellini", + "tortilla_chips": "Chips de tortilla", + "tuna": "Thon", + "turmeric": "Curcuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Nouilles Udon", + "uht_milk": "Lait UHT", + "vanilla_sugar": "Sucre vanillé", + "vegetable_bouillon_cube": "Cube de bouillon de légumes", + "vegetable_broth": "Bouillon de légumes", + "vegetable_oil": "Huile végétale", + "vegetable_onion": "Oignon végétal", + "vegetables": "Légumes", + "vegetarian_cold_cuts": "charcuterie végétarienne", + "vinegar": "Vinaigre", + "vodka": "Vodka", + "washing_powder": "Poudre à laver", + "water": "Eau", + "water_ice": "Glace d'eau", + "watermelon": "Pastèque", + "wc_cleaner": "Nettoyant pour WC", + "whipped_cream": "Crème fouettée", + "white_wine": "Vin blanc", + "white_wine_vinegar": "Vinaigre de vin blanc", + "whole_canned_tomatoes": "Tomates entières en conserve", + "wild_berries": "Baies sauvages", + "wrapping_paper": "Papier d'emballage", + "wraps": "Wraps", + "yeast": "Levure", + "yoghurt": "Yoghourt", + "yogurt": "Yogourt", + "yum_yum": "Miam miam", + "zewa": "Zewa", + "zinc_cream": "Crème de zinc", + "zucchini": "Courgettes" + } +} diff --git a/backend/templates/l10n/nb_NO.json b/backend/templates/l10n/nb_NO.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/backend/templates/l10n/nb_NO.json @@ -0,0 +1 @@ +{} diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/backend/templates/l10n/pt.json @@ -0,0 +1 @@ +{} diff --git a/backend/templates/l10n/pt_BR.json b/backend/templates/l10n/pt_BR.json new file mode 100644 index 00000000..9f31f9e6 --- /dev/null +++ b/backend/templates/l10n/pt_BR.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 Padaria", + "canned": "🥫 Comida Enlatada", + "dairy": "🥛 Derivados de Leite", + "drinks": "🍹 Bebidas", + "freezer": "❄️ Congelados", + "fruits_vegetables": "🥬 Frutas e Vegetais", + "grain": "🥟 Grãos", + "hygiene": "🚽 Higiene", + "refrigerated": "💧 Refrigerado", + "snacks": "🥜 Lanches" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Maçã", + "apple_pulp": "Polpa de Maçã", + "applesauce": "Molho de Maçã", + "apérol": "Aperol", + "arugula": "Rúcula", + "asian_egg_noodles": "Macarrão com ovos asiáticos", + "aspargus": "Aspargos", + "aspirin": "Aspirina", + "avocado": "Abacate", + "baby_spinach": "Espinafre baby", + "bacon": "Bacon", + "baguette": "Baguete", + "bakefish": "Peixe Assado", + "baking_cocoa": "Cozimento de cacau", + "baking_mix": "Mistura para panificação", + "baking_paper": "Papel para panificação", + "baking_powder": "Pó de fermento", + "baking_soda": "Bicarbonato de sódio", + "baking_yeast": "Levedura para panificação", + "balsamic_vinegar": "Vinagre balsâmico", + "bananas": "Bananas", + "basil": "Manjericão", + "basmati_rice": "Arroz basmati", + "bathroom_cleaner": "Limpador de banheiros", + "batteries": "Baterias", + "bay_leaf": "Folha de baía", + "beans": "Feijão", + "beer": "Cerveja", + "beet": "Beterraba", + "beetroot": "Beterraba", + "birthday_card": "Cartão de aniversário", + "black_beans": "Feijão preto", + "bockwurst": "Bockwurst", + "bodywash": "Lavagem do corpo", + "bread": "Pão", + "breadcrumbs": "Breadcrumbs", + "broccoli": "Brócolis", + "brown_sugar": "Açúcar mascavo", + "brussels_sprouts": "Couve-de-bruxelas", + "buffalo_mozzarella": "Mozzarella de búfalo", + "buko": "Buko", + "buns": "Pãezinhos", + "burger_buns": "Burger Buns", + "burger_patties": "Hambúrgueres Patties", + "burger_sauces": "Molhos para hambúrgueres", + "butter": "Manteiga", + "butter_cookies": "Biscoitos de manteiga", + "button_cells": "Células de botão", + "börek_cheese": "Queijo Börek", + "cake": "Bolo", + "cake_icing": "Cobertura de bolo", + "cane_sugar": "Açúcar de cana", + "cannelloni": "Cannelloni", + "canola_oil": "Óleo de canola", + "cardamom": "Cardamom", + "carrots": "Cenouras", + "cashews": "Cajus", + "cat_treats": "Gatos", + "cauliflower": "Couve-flor", + "celeriac": "Celeriac", + "celery": "Aipo", + "cereal_bar": "Barra de cereais", + "cheddar": "Cheddar", + "cheese": "Queijo", + "cherry_tomatoes": "Tomates cereja", + "chickpeas": "Chickpeas", + "chili_oil": "Óleo de pimenta", + "chips": "Fichas", + "chives": "Cebolinho", + "chocolate": "Chocolate", + "chopped_tomatoes": "Tomates picados", + "ciabatta": "Ciabatta", + "cider_vinegar": "Vinagre de cidra", + "cilantro": "Cilantro", + "cinnamon": "Canela", + "cinnamon_stick": "Canela em pau", + "cocktail_sauce": "Molho de coquetel", + "cocktail_tomatoes": "Cocktail de tomates", + "coconut_flakes": "Flocos de coco", + "coconut_milk": "Leite de coco", + "coconut_oil": "Óleo de coco", + "colorful_sprinkles": "Polvilhos coloridos", + "concealer": "Corretivo", + "cookies": "Biscoitos", + "coriander": "Coriander", + "corn": "Milho", + "cornflakes": "Cornflakes", + "cornstarch": "Amido de milho", + "cornys": "Cornys", + "cough_drops": "Rebuçados para a tosse", + "couscous": "Couscous", + "covid_rapid_test": "Teste rápido COVID", + "cow's_milk": "Leite de vaca", + "cream": "Cremes", + "cream_cheese": "Queijo cremoso", + "creamed_spinach": "Espinafres cremosos", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Fita crepe", + "crispbread": "Crispbread", + "cucumber": "Pepino", + "cumin": "Cumin", + "curry_paste": "Pasta de caril", + "curry_powder": "Caril em pó", + "curry_sauce": "Molho de caril", + "dates": "Datas", + "dental_floss": "Fio dental", + "deodorant": "Desodorante", + "detergent": "Detergente", + "dill": "Endro", + "dishwasher_salt": "Sal de lava-louças", + "dishwasher_tabs": "Abas para lava-louça", + "disinfection_spray": "Spray de desinfecção", + "dried_tomatoes": "Tomates secos", + "edamame": "Edamame", + "eggplant": "Berinjela", + "eggs": "Ovos", + "falafel": "Falafel", + "falafel_powder": "Pó de falafel", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Palitos de peixe", + "flour": "Farinha", + "flushing": "Flushing", + "fresh_cili_pepper": "Pimenta cili fresca", + "frozen_berries": "Frutas congeladas", + "frozen_fruit": "Frutas congeladas", + "frozen_pizza": "Pizza congelada", + "frozen_spinach": "Espinafres congelados", + "garam_masala": "Garam Masala", + "garbage_bag": "Saco do lixo", + "garlic": "Alho", + "garlic_dip": "Molho de alho", + "garlic_granules": "Grânulos de alho", + "gherkins": "Gherkins", + "ginger": "Gengibre", + "glass_noodles": "Macarrão de vidro", + "gluten": "Glúten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Uvas", + "greek_yogurt": "Iogurte grego", + "green_asparagus": "Espargos verdes", + "green_chili": "Pimenta verde", + "green_pesto": "Pesto verde", + "hair_gel": "Gel para cabelo", + "hair_wax": "Cera para cabelos", + "handkerchief_box": "Caixa de lenços de bolso", + "handkerchiefs": "Lenços de assoar e de bolso", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Avelãs", + "head_of_lettuce": "Cabeça de alface", + "herb_baguettes": "Baguetes de ervas", + "herb_cream_cheese": "Queijo creme de ervas", + "honey": "Mel", + "honey_wafers": "Bolachas de mel", + "hot_dog_bun": "Pão de cachorro-quente", + "ice_cream": "Sorvete", + "ice_cube": "Cubo de gelo", + "iceberg_lettuce": "Alface Iceberg", + "iced_tea": "Chá gelado", + "instant_soups": "Sopas instantâneas", + "jam": "Jam", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Feijão para os rins", + "kitchen_roll": "Rolo de cozinha", + "kitchen_towels": "Toalhas de cozinha", + "kohlrabi": "Kohlrabi", + "lasagna": "Lasanha", + "lasagna_noodles": "Macarrão Lasagna", + "lasagna_plates": "Placas de lasanha", + "leaf_spinach": "Espinafres de folha", + "leek": "Leek", + "lemon": "Limão", + "lemon_juice": "Suco de limão", + "lemonade": "Limonada", + "lemongrass": "Capim-limão", + "lentils": "Lentilhas", + "lentils_red": "Lentilhas vermelhas", + "lettuce": "Alface", + "lillet": "Lillet", + "lime": "Cal", + "linguine": "Linguine", + "low-fat_curd_cheese": "Queijo de coalho de baixo teor de gordura", + "magnesium": "Magnésio", + "mango": "Manga", + "margarine": "Margarine", + "marjoram": "Manjerona", + "marshmallows": "Marshmallows", + "mask": "Máscara", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Produto substituto da carne", + "microfiber_cloth": "Pano de microfibra", + "milk": "Leite", + "mint": "Casa da Moeda", + "mint_candy": "Doces de menta", + "mixed_vegetables": "Vegetais mistos", + "mochis": "Mochis", + "mountain_cheese": "Queijo de montanha", + "mouth_wash": "Lavagem bucal", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Barra de Muesli", + "mulled_wine": "Vinho de mesa", + "mushrooms": "Cogumelos", + "mustard": "Mostarda", + "neutral_oil": "Óleo neutro", + "nori_sheets": "Folhas Nori", + "nutmeg": "Nutmeg", + "oat_milk": "Bebida de aveia", + "oatmeal": "Farinha de aveia", + "oatmeal_cookies": "Biscoitos com aveia", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "olive_oil": "Azeite de oliva", + "olives": "Azeitonas", + "onion": "Cebola", + "orange_juice": "Suco de laranja", + "oranges": "Laranjas", + "oregano": "Orégano", + "organic_lemon": "Limão orgânico", + "organic_waste_bags": "Sacos para resíduos orgânicos", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Pardina lentilhas secas", + "parmesan": "Parmesão", + "parsley": "Salsa", + "pasta": "Massas alimentícias", + "peach": "Pêssego", + "peanut_butter": "Manteiga de amendoim", + "peanut_flips": "Flips de amendoim", + "peanut_oil": "Óleo de amendoim", + "peanuts": "Amendoins", + "pears": "Peras", + "peas": "Ervilhas", + "penne": "Penne", + "pepper": "Pimenta", + "pepper_mill": "Moinho de pimenta", + "peppers": "Pimentas", + "persian_rice": "Arroz persa", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinhões", + "pineapple": "Abacaxi", + "pita_bag": "Saco Pita", + "pizza": "Pizza", + "pizza_dough": "Massa para pizza", + "plant_magarine": "Planta Magarine", + "plant_oil": "Óleo vegetal", + "plaster": "Gesso", + "porcini_mushrooms": "Cogumelos Porcini", + "potato_dumpling_dough": "Massa de bolinho de batata", + "potato_wedges": "Cunhas de batata", + "potatoes": "Batatas", + "potting_soil": "Terra para vaso", + "powder": "Pó", + "powdered_sugar": "Açúcar em pó", + "processed_cheese": "Queijos fundidos", + "prosecco": "Prosecco", + "puff_pastry": "Massa folhada", + "pumpkin": "Abóbora", + "pumpkin_seeds": "Sementes de abóbora", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Rabanete", + "ramen": "Ramen", + "rapeseed_oil": "Óleo de colza", + "raspberries": "Framboesas", + "raspberry_syrup": "Xarope de framboesa", + "red_bull": "Red Bull", + "red_chili": "Pimenta vermelha", + "red_lentils": "Lentilhas vermelhas", + "red_onions": "Cebolas vermelhas", + "red_pesto": "Pesto vermelho", + "red_wine": "Vinho tinto", + "red_wine_vinegar": "Vinagre de vinho tinto", + "rhubarb": "Ruibarbo", + "ribbon_noodles": "Macarrão de fita", + "rice": "Arroz", + "rice_cakes": "Bolos de arroz", + "rice_ribbon_noodles": "Macarrão com fitas de arroz", + "rice_vinegar": "Vinagre de arroz", + "ricotta": "Ricotta", + "rinse_tabs": "Abas de enxágüe", + "rinsing_agent": "Agente de enxágüe", + "risotto_rice": "Arroz risoto", + "rocket": "Foguete", + "roll": "Rolo", + "rosemary": "Rosemary", + "saffron_threads": "Fios de açafrão", + "sage": "Sábio", + "saitan_powder": "Pó de saitan", + "salad_mix": "Mistura para salada", + "salad_seeds_mix": "Mistura de sementes para salada", + "salt": "Sal", + "salt_mill": "Moinho de sal", + "sambal_oelek": "Sambal oelek", + "sauce": "Molho", + "sausage": "Salsicha", + "sausages": "Salsichas", + "savoy_cabbage": "Couve-lombarda", + "scallion": "Escalhão", + "scattered_cheese": "Queijo disperso", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "sckocolate_chips": "Chocolate em pedaços", + "semolina_porridge": "Mingau de semolina", + "sesame": "Sésamo", + "sesame_oil": "Óleo de gergelim", + "shallot": "Chalota", + "shampoo": "Shampoo", + "shawarma_spice": "Especiaria Shawarma", + "shiitake_mushroom": "Cogumelo Shiitake", + "shoe_insoles": "Palmilhas de sapato", + "shower_gel": "Gel de ducha", + "shredded_cheese": "Queijo ralado", + "sieved_tomatoes": "Tomates peneirados", + "sliced_cheese": "Queijo fatiado", + "smoked_paprika": "Pimentão-doce defumado", + "smoked_tofu": "Tofu defumado", + "snacks": "Lanches", + "soap": "Sabonete", + "soft_drinks": "Refrigerantes", + "softdrinks": "Refrigerantes", + "sour_cream": "Creme azedo", + "sour_cucumbers": "Pepinos azedos", + "soy_hack": "Hack de soja", + "soy_sauce": "Molho de soja", + "soy_shred": "Trituração de soja", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Água com gás", + "spelt": "Espelta", + "spinach": "Espinafres", + "sponge_cloth": "Pano de esponja", + "sponge_wipes": "Toalhetes de esponja", + "sponges": "Esponjas", + "spreading_cream": "Creme de espalhamento", + "spring_onions": "Cebola de primavera", + "sprite": "Sprite", + "sprouts": "Brotos", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomates deformados", + "sugar": "Açúcar", + "summer_roll_paper": "Papel em rolo de verão", + "sunflower_seeds": "Sementes de girassol", + "sushi_rice": "Arroz sushi", + "swabian_ravioli": "Ravióli suábio", + "sweet_potato": "Batata doce", + "sweet_potatoes": "Batata doce", + "table_salt": "Sal de mesa", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Tangerinas", + "tape": "Fita", + "tea": "Chá", + "teriyaki_sauce": "Molho Teriyaki", + "thyme": "Tomilho", + "toast": "Brinde", + "tofu": "Tofu", + "toilet_paper": "Papel higiênico", + "tomato_juice": "Suco de tomate", + "tomato_paste": "Pasta de tomate", + "tomato_sauce": "Molho de tomate", + "tomatoes": "Tomate", + "tonic_water": "Água tônica", + "toothpaste": "Pasta de dente", + "tortellini": "Tortellini", + "tortilla_chips": "Batatas fritas Tortilla", + "tuna": "Atum", + "turmeric": "Cúrcuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Macarrão Udon", + "uht_milk": "Leite UHT", + "vanilla_sugar": "Açúcar baunilhado", + "vegetable_bouillon_cube": "Cubo de caldo de legumes", + "vegetable_broth": "Caldo de legumes", + "vegetable_oil": "Óleo vegetal", + "vegetable_onion": "Cebola de legumes", + "vegetables": "Legumes", + "vegetarian_cold_cuts": "frios vegetarianos", + "vinegar": "Vinagre", + "vodka": "Vodca", + "washing_powder": "Pó de lavagem", + "water": "Água", + "water_ice": "Gelo de água", + "watermelon": "Melancia", + "wc_cleaner": "Limpador de WC", + "whipped_cream": "Nata batida", + "white_wine": "Vinho branco", + "white_wine_vinegar": "Vinagre de vinho branco", + "whole_canned_tomatoes": "Tomates inteiros enlatados", + "wild_berries": "Frutos silvestres", + "wrapping_paper": "Papel de embrulho", + "wraps": "Wraps", + "yeast": "Levedura", + "yoghurt": "Iogurte", + "yogurt": "Iogurte", + "yum_yum": "Yum Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Creme de zinco", + "zucchini": "Abobrinha" + } +} From 9cdab11e6321f2014d49efa39bff86f179310c3c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 15 Jan 2023 23:04:41 +0100 Subject: [PATCH 255/496] feat: Add languages --- backend/app/config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 60ce3446..4d824a98 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -34,7 +34,12 @@ SUPPORTED_LANGUAGES = { 'en': 'English', - 'de': 'Deutsch' + 'de': 'Deutsch', + 'es': 'Español', + 'fr': 'Français', + # 'nb_NO': '', + # 'pt': 'Português', + 'pt_BR': 'Português Brasileiro', } Flask.json_provider_class = KitchenOwlJSONProvider From 0fb0c42d994be11f647fcf31a815d2834e15fa10 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 1 Feb 2023 23:06:43 +0100 Subject: [PATCH 256/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ * Translated using Weblate (German) Currently translated at 100.0% (420 of 420 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/de/ * Added translation using Weblate (French) * Added translation using Weblate (Norwegian Bokmål) * Added translation using Weblate (Portuguese) * Added translation using Weblate (Portuguese (Brazil)) * Added translation using Weblate (Spanish) * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (420 of 420 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt_BR/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (420 of 420 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt_BR/ * Translated using Weblate (Spanish) Currently translated at 100.0% (420 of 420 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ * Translated using Weblate (Spanish) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ * Added translation using Weblate (Indonesian) * Translated using Weblate (Indonesian) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/id/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/id/ * Added translation using Weblate (Russian) * Translated using Weblate (Russian) Currently translated at 3.5% (15 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ru/ * Translated using Weblate (Portuguese) Currently translated at 4.7% (20 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt/ --------- Co-authored-by: J. Lavoie Co-authored-by: Allan Nordhøy Co-authored-by: Iagocds Co-authored-by: German Co-authored-by: gallegonovato Co-authored-by: liimee Co-authored-by: Roman Co-authored-by: ssantos --- backend/templates/l10n/es.json | 32 +-- backend/templates/l10n/id.json | 425 +++++++++++++++++++++++++++++++++ backend/templates/l10n/pt.json | 27 ++- backend/templates/l10n/ru.json | 21 ++ 4 files changed, 488 insertions(+), 17 deletions(-) create mode 100644 backend/templates/l10n/id.json create mode 100644 backend/templates/l10n/ru.json diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index cf02657b..ce5d9f96 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -54,7 +54,7 @@ "brown_sugar": "Azúcar moreno", "brussels_sprouts": "Coles de Bruselas", "buffalo_mozzarella": "Mozzarella de búfala", - "buko": "Buko", + "buko": "Coco", "buns": "Bollos", "burger_buns": "Pan de hamburguesa", "burger_patties": "Hamburguesas", @@ -85,7 +85,7 @@ "chives": "Cebollino", "chocolate": "Chocolate", "chopped_tomatoes": "Tomates picados", - "ciabatta": "Ciabatta", + "ciabatta": "Chapata", "cider_vinegar": "Vinagre de sidra", "cilantro": "Cilantro", "cinnamon": "Canela", @@ -110,7 +110,7 @@ "cream": "Crema", "cream_cheese": "Queso cremoso", "creamed_spinach": "Espinacas a la crema", - "creme_fraiche": "Creme fraiche", + "creme_fraiche": "Nata agria (Crème fraîche)", "crepe_tape": "Cinta de crepé", "crispbread": "Pan crujiente", "cucumber": "Pepino", @@ -127,10 +127,10 @@ "dishwasher_tabs": "Pestañas para lavavajillas", "disinfection_spray": "Spray desinfectante", "dried_tomatoes": "Tomates secos", - "edamame": "Edamame", + "edamame": "Vainas de soja tiernas (Edamame)", "eggplant": "Berenjena", "eggs": "Huevos", - "falafel": "Falafel", + "falafel": "Faláfel", "falafel_powder": "Falafel en polvo", "fanta": "Fanta", "feta": "Feta", @@ -152,7 +152,7 @@ "ginger": "Jengibre", "glass_noodles": "Fideos de vidrio", "gluten": "Gluten", - "gnocchi": "Gnocchi", + "gnocchi": "Ñoqui", "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", @@ -181,7 +181,7 @@ "instant_soups": "Sopas instantáneas", "jam": "Mermelada", "katjes": "Katjes", - "ketchup": "Ketchup", + "ketchup": "Kétchup", "kidney_beans": "Alubias rojas", "kitchen_roll": "Papel de cocina", "kitchen_towels": "Paños de cocina", @@ -218,7 +218,7 @@ "mochis": "Mochis", "mountain_cheese": "Queso de montaña", "mouth_wash": "Enjuague bucal", - "mozzarella": "Mozzarella", + "mozzarella": "Queso Mozzarella", "muesli": "Muesli", "muesli_bar": "Barra de muesli", "mulled_wine": "Vino caliente", @@ -240,7 +240,7 @@ "oregano": "Orégano", "organic_lemon": "Limón ecológico", "organic_waste_bags": "Bolsas para residuos orgánicos", - "pak_choi": "Pak Choi", + "pak_choi": "Col china o repollo chino", "paprika": "Pimentón", "pardina_lentils_dried": "Lentejas pardinas secas", "parmesan": "Parmesano", @@ -301,13 +301,13 @@ "rice_cakes": "Pasteles de arroz", "rice_ribbon_noodles": "Fideos con cinta de arroz", "rice_vinegar": "Vinagre de arroz", - "ricotta": "Ricotta", + "ricotta": "Requesón", "rinse_tabs": "Pestañas de enjuague", "rinsing_agent": "Agente de enjuague", "risotto_rice": "Arroz para risotto", "rocket": "Cohete", "roll": "Rollo", - "rosemary": "Rosemary", + "rosemary": "Romero", "saffron_threads": "Hilos de azafrán", "sage": "Salvia", "saitan_powder": "Saitán en polvo", @@ -315,7 +315,7 @@ "salad_seeds_mix": "Mezcla de semillas para ensalada", "salt": "Sal", "salt_mill": "Molino de sal", - "sambal_oelek": "Sambal oelek", + "sambal_oelek": "Sambal", "sauce": "Salsa", "sausage": "Salchichas", "sausages": "Salchichas", @@ -323,7 +323,7 @@ "scallion": "Cebolleta", "scattered_cheese": "Queso esparcido", "schlemmerfilet": "Schlemmerfilet", - "schupfnudeln": "Schupfnudeln", + "schupfnudeln": "Schupfnudeln (ñoquis alemanes)", "sckocolate_chips": "Chispas de chocolate", "semolina_porridge": "Gachas de sémola", "sesame": "Sésamo", @@ -348,7 +348,7 @@ "soy_hack": "Hack de soja", "soy_sauce": "Salsa de soja", "soy_shred": "Triturado de soja", - "spaetzle": "Spaetzle", + "spaetzle": "Späeztle", "spaghetti": "Espaguetis", "sparkling_water": "Agua con gas", "spelt": "Escanda", @@ -370,7 +370,7 @@ "sweet_potato": "Boniato", "sweet_potatoes": "Boniatos", "table_salt": "Sal de mesa", - "tagliatelle": "Tagliatelle", + "tagliatelle": "Tallarín", "tahini": "Tahini", "tangerines": "Mandarinas", "tape": "Cinta", @@ -417,7 +417,7 @@ "yeast": "Levadura", "yoghurt": "Yogur", "yogurt": "Yogur", - "yum_yum": "Yum Yum", + "yum_yum": "Ñam Ñam", "zewa": "Zewa", "zinc_cream": "Crema de zinc", "zucchini": "Calabacín" diff --git a/backend/templates/l10n/id.json b/backend/templates/l10n/id.json new file mode 100644 index 00000000..f1689f4d --- /dev/null +++ b/backend/templates/l10n/id.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 Barang Roti", + "canned": "🥫 Makanan Kaleng", + "dairy": "🥛 Susu", + "drinks": "🍹 Minuman", + "freezer": "❄️ Freezer", + "fruits_vegetables": "🥬 Buah dan sayur", + "grain": "🥟 Produk Biji-bijian", + "hygiene": "🚽 Kebersihan", + "refrigerated": "💧 Didinginkan", + "snacks": "🥜 Camilan" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Apple", + "apple_pulp": "Bubur apel", + "applesauce": "Saus apel", + "apérol": "Apérol", + "arugula": "Arugula", + "asian_egg_noodles": "Mie telur Asia", + "aspargus": "Aspargus", + "aspirin": "Aspirin", + "avocado": "Alpukat", + "baby_spinach": "Bayam bayi", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Bakefish", + "baking_cocoa": "Memanggang kakao", + "baking_mix": "Campuran kue", + "baking_paper": "Kertas roti", + "baking_powder": "Bubuk pengembang", + "baking_soda": "Soda kue", + "baking_yeast": "Ragi kue", + "balsamic_vinegar": "Cuka balsamic", + "bananas": "Pisang", + "basil": "Basil", + "basmati_rice": "Beras Basmati", + "bathroom_cleaner": "Pembersih kamar mandi", + "batteries": "Baterai", + "bay_leaf": "Daun salam", + "beans": "Kacang", + "beer": "Bir", + "beet": "Bit", + "beetroot": "Bit", + "birthday_card": "Kartu ulang tahun", + "black_beans": "Kacang hitam", + "bockwurst": "Bockwurst", + "bodywash": "Cuci tubuh", + "bread": "Roti", + "breadcrumbs": "Remah roti", + "broccoli": "Brokoli", + "brown_sugar": "Gula merah", + "brussels_sprouts": "Kubis Brussel", + "buffalo_mozzarella": "Mozzarella kerbau", + "buko": "Buko", + "buns": "Roti", + "burger_buns": "Roti Burger", + "burger_patties": "Roti Burger", + "burger_sauces": "Saus burger", + "butter": "Mentega", + "butter_cookies": "Kue mentega", + "button_cells": "Sel tombol", + "börek_cheese": "Keju Börek", + "cake": "Kue", + "cake_icing": "Lapisan gula kue", + "cane_sugar": "Gula tebu", + "cannelloni": "Cannelloni", + "canola_oil": "Minyak kanola", + "cardamom": "Kapulaga", + "carrots": "Wortel", + "cashews": "Kacang mete", + "cat_treats": "Camilan kucing", + "cauliflower": "Kembang kol", + "celeriac": "Celeriac", + "celery": "Seledri", + "cereal_bar": "Bar sereal", + "cheddar": "Cheddar", + "cheese": "Keju", + "cherry_tomatoes": "Tomat ceri", + "chickpeas": "Buncis", + "chili_oil": "Minyak cabai", + "chips": "Keripik", + "chives": "Daun bawang", + "chocolate": "Cokelat", + "chopped_tomatoes": "Tomat cincang", + "ciabatta": "Ciabatta", + "cider_vinegar": "Cuka sari apel", + "cilantro": "Ketumbar", + "cinnamon": "Kayu manis", + "cinnamon_stick": "Batang kayu manis", + "cocktail_sauce": "Saus koktail", + "cocktail_tomatoes": "Tomat koktail", + "coconut_flakes": "Serpihan kelapa", + "coconut_milk": "Santan", + "coconut_oil": "Minyak kelapa", + "colorful_sprinkles": "Taburan warna-warni", + "concealer": "Concealer", + "cookies": "Cookie", + "coriander": "Ketumbar", + "corn": "Jagung", + "cornflakes": "Serpihan jagung", + "cornstarch": "Tepung maizena", + "cornys": "Cornys", + "cough_drops": "Obat tetes batuk", + "couscous": "Couscous", + "covid_rapid_test": "Tes cepat COVID", + "cow's_milk": "Susu sapi", + "cream": "Krim", + "cream_cheese": "Keju krim", + "creamed_spinach": "Bayam krim", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Pita krep", + "crispbread": "Roti Garing", + "cucumber": "Mentimun", + "cumin": "Jinten", + "curry_paste": "Pasta kari", + "curry_powder": "Bubuk kari", + "curry_sauce": "Saus kari", + "dates": "Kurma", + "dental_floss": "Benang gigi", + "deodorant": "Deodoran", + "detergent": "Deterjen", + "dill": "Dill", + "dishwasher_salt": "Garam pencuci piring", + "dishwasher_tabs": "Tab pencuci piring", + "disinfection_spray": "Semprotan desinfeksi", + "dried_tomatoes": "Tomat kering", + "edamame": "Edamame", + "eggplant": "Terong", + "eggs": "Telur", + "falafel": "Falafel", + "falafel_powder": "Bubuk falafel", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Tongkat ikan", + "flour": "Tepung", + "flushing": "Pembilasan", + "fresh_cili_pepper": "Cabai rawit segar", + "frozen_berries": "Buah beri beku", + "frozen_fruit": "Buah beku", + "frozen_pizza": "Pizza beku", + "frozen_spinach": "Bayam beku", + "garam_masala": "Garam Masala", + "garbage_bag": "Kantong sampah", + "garlic": "Bawang putih", + "garlic_dip": "Saus bawang putih", + "garlic_granules": "Butiran bawang putih", + "gherkins": "Gherkins", + "ginger": "Jahe", + "glass_noodles": "Mie gelas", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Anggur", + "greek_yogurt": "Yoghurt Yunani", + "green_asparagus": "Asparagus hijau", + "green_chili": "Cabai hijau", + "green_pesto": "Pesto hijau", + "hair_gel": "Gel rambut", + "hair_wax": "Lilin Rambut", + "handkerchief_box": "Kotak saputangan", + "handkerchiefs": "Saputangan", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hazelnut", + "head_of_lettuce": "Kepala selada", + "herb_baguettes": "Baguette herbal", + "herb_cream_cheese": "Keju krim herbal", + "honey": "Sayang.", + "honey_wafers": "Wafer madu", + "hot_dog_bun": "Roti hot dog", + "ice_cream": "Es krim", + "ice_cube": "Es batu", + "iceberg_lettuce": "Selada gunung es", + "iced_tea": "Es teh", + "instant_soups": "Sup instan", + "jam": "Selai", + "katjes": "Katjes", + "ketchup": "Kecap", + "kidney_beans": "Kacang merah", + "kitchen_roll": "Gulungan dapur", + "kitchen_towels": "Handuk dapur", + "kohlrabi": "Kohlrabi", + "lasagna": "Lasagna", + "lasagna_noodles": "Mie Lasagna", + "lasagna_plates": "Piring Lasagna", + "leaf_spinach": "Daun bayam", + "leek": "Leek", + "lemon": "Lemon", + "lemon_juice": "Jus lemon", + "lemonade": "Limun", + "lemongrass": "Serai", + "lentils": "Lentil", + "lentils_red": "Lentil merah", + "lettuce": "Selada", + "lillet": "Lillet", + "lime": "Kapur", + "linguine": "Linguine", + "low-fat_curd_cheese": "Keju dadih rendah lemak", + "magnesium": "Magnesium", + "mango": "Mangga", + "margarine": "Margarin", + "marjoram": "Marjoram", + "marshmallows": "Marshmallow", + "mask": "Topeng", + "mayonnaise": "Mayones", + "meat_substitute_product": "Produk pengganti daging", + "microfiber_cloth": "Kain mikrofiber", + "milk": "Susu", + "mint": "Mint", + "mint_candy": "Permen mint", + "mixed_vegetables": "Sayuran campuran", + "mochis": "Mochis", + "mountain_cheese": "Keju gunung", + "mouth_wash": "Cuci mulut", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Muesli bar", + "mulled_wine": "Mulled wine", + "mushrooms": "Jamur", + "mustard": "Mustard", + "neutral_oil": "Minyak netral", + "nori_sheets": "Lembaran nori", + "nutmeg": "Pala", + "oat_milk": "Minuman gandum", + "oatmeal": "Oatmeal", + "oatmeal_cookies": "Kue gandum", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "olive_oil": "Minyak zaitun", + "olives": "Zaitun", + "onion": "Bawang", + "orange_juice": "Jus jeruk", + "oranges": "Jeruk", + "oregano": "Oregano", + "organic_lemon": "Lemon organik", + "organic_waste_bags": "Kantong sampah organik", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Lentil pardina dikeringkan", + "parmesan": "Parmesan", + "parsley": "Peterseli", + "pasta": "Pasta", + "peach": "Peach", + "peanut_butter": "Selai kacang", + "peanut_flips": "Membalik Kacang", + "peanut_oil": "Minyak kacang", + "peanuts": "Kacang", + "pears": "Pir", + "peas": "Kacang polong", + "penne": "Penne", + "pepper": "Lada", + "pepper_mill": "Penggilingan lada", + "peppers": "Paprika", + "persian_rice": "Beras Persia", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Kacang pinus", + "pineapple": "Nanas", + "pita_bag": "Tas pita", + "pizza": "Pizza", + "pizza_dough": "Adonan pizza", + "plant_magarine": "Tanaman Magarine", + "plant_oil": "Minyak nabati", + "plaster": "Plester", + "porcini_mushrooms": "Jamur porcini", + "potato_dumpling_dough": "Adonan pangsit kentang", + "potato_wedges": "Irisan kentang", + "potatoes": "Kentang", + "potting_soil": "Tanah pot", + "powder": "Bedak", + "powdered_sugar": "Gula bubuk", + "processed_cheese": "Keju olahan", + "prosecco": "Prosecco", + "puff_pastry": "Kue puff", + "pumpkin": "Labu", + "pumpkin_seeds": "Biji labu", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Lobak", + "ramen": "Ramen", + "rapeseed_oil": "Minyak lobak", + "raspberries": "Raspberry", + "raspberry_syrup": "Sirup raspberry", + "red_bull": "Banteng Merah", + "red_chili": "Cabai merah", + "red_lentils": "Lentil merah", + "red_onions": "Bawang merah", + "red_pesto": "Pesto merah", + "red_wine": "Anggur merah", + "red_wine_vinegar": "Cuka anggur merah", + "rhubarb": "Rhubarb", + "ribbon_noodles": "Mie pita", + "rice": "Beras", + "rice_cakes": "Kue beras", + "rice_ribbon_noodles": "Mie pita nasi", + "rice_vinegar": "Cuka beras", + "ricotta": "Ricotta", + "rinse_tabs": "Bilas tab", + "rinsing_agent": "Agen pembilas", + "risotto_rice": "Nasi risotto", + "rocket": "Roket", + "roll": "Gulung", + "rosemary": "Rosemary", + "saffron_threads": "Benang kunyit", + "sage": "Sage", + "saitan_powder": "Bubuk saitan", + "salad_mix": "Campuran Salad", + "salad_seeds_mix": "Campuran biji salad", + "salt": "Garam", + "salt_mill": "Pabrik garam", + "sambal_oelek": "Sambal oelek", + "sauce": "Saus", + "sausage": "Sosis", + "sausages": "Sosis", + "savoy_cabbage": "Kubis savoy", + "scallion": "Daun bawang", + "scattered_cheese": "Keju yang tersebar", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "sckocolate_chips": "Keripik cokelat", + "semolina_porridge": "Bubur semolina", + "sesame": "Wijen", + "sesame_oil": "Minyak wijen", + "shallot": "Bawang merah", + "shampoo": "Sampo", + "shawarma_spice": "Bumbu Shawarma", + "shiitake_mushroom": "Jamur Shiitake", + "shoe_insoles": "Sol sepatu", + "shower_gel": "Gel mandi", + "shredded_cheese": "Keju parut", + "sieved_tomatoes": "Tomat yang diayak", + "sliced_cheese": "Keju iris", + "smoked_paprika": "Paprika asap", + "smoked_tofu": "Tahu asap", + "snacks": "Makanan ringan", + "soap": "Sabun", + "soft_drinks": "Minuman ringan", + "softdrinks": "Minuman ringan", + "sour_cream": "Krim asam", + "sour_cucumbers": "Mentimun asam", + "soy_hack": "Peretasan kedelai", + "soy_sauce": "Kecap", + "soy_shred": "Rusaknya kedelai", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Air soda", + "spelt": "Eja", + "spinach": "Bayam", + "sponge_cloth": "Kain spons", + "sponge_wipes": "Tisu spons", + "sponges": "Spons", + "spreading_cream": "Menyebarkan krim", + "spring_onions": "Daun bawang", + "sprite": "Sprite", + "sprouts": "Kecambah", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomat yang disaring", + "sugar": "Gula", + "summer_roll_paper": "Kertas gulung musim panas", + "sunflower_seeds": "Biji bunga matahari", + "sushi_rice": "Nasi sushi", + "swabian_ravioli": "Ravioli Swabia", + "sweet_potato": "Ubi jalar", + "sweet_potatoes": "Ubi jalar", + "table_salt": "Garam meja", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Jeruk keprok", + "tape": "Pita", + "tea": "Teh", + "teriyaki_sauce": "Saus teriyaki", + "thyme": "Thyme", + "toast": "Bersulang", + "tofu": "Tahu", + "toilet_paper": "Kertas toilet", + "tomato_juice": "Jus tomat", + "tomato_paste": "Pasta tomat", + "tomato_sauce": "Saus tomat", + "tomatoes": "Tomat", + "tonic_water": "Air tonik", + "toothpaste": "Pasta gigi", + "tortellini": "Tortellini", + "tortilla_chips": "Keripik Tortilla", + "tuna": "Tuna", + "turmeric": "Kunyit", + "tzatziki": "Tzatziki", + "udon_noodles": "Mie Udon", + "uht_milk": "Susu UHT", + "vanilla_sugar": "Gula vanila", + "vegetable_bouillon_cube": "Kubus kaldu sayuran", + "vegetable_broth": "Kaldu sayuran", + "vegetable_oil": "Minyak sayur", + "vegetable_onion": "Bawang sayur", + "vegetables": "Sayuran", + "vegetarian_cold_cuts": "potongan daging dingin vegetarian", + "vinegar": "Cuka", + "vodka": "Vodka", + "washing_powder": "Bubuk pencuci", + "water": "Air", + "water_ice": "Es air", + "watermelon": "Semangka", + "wc_cleaner": "Pembersih WC", + "whipped_cream": "Krim kocok", + "white_wine": "Anggur putih", + "white_wine_vinegar": "Cuka anggur putih", + "whole_canned_tomatoes": "Tomat kalengan utuh", + "wild_berries": "Buah beri liar", + "wrapping_paper": "Kertas pembungkus", + "wraps": "Membungkus", + "yeast": "Ragi", + "yoghurt": "Yoghurt", + "yogurt": "Yogurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Krim seng", + "zucchini": "Zucchini" + } +} diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json index 0967ef42..1c63ef5d 100644 --- a/backend/templates/l10n/pt.json +++ b/backend/templates/l10n/pt.json @@ -1 +1,26 @@ -{} +{ + "categories": { + "bread": "🍞 Produtos de Pão", + "canned": "🥫 Alimentos Enlatados", + "dairy": "🥛 Lácteos", + "drinks": "🍹 Bebidas", + "freezer": "❄️ Congelados", + "fruits_vegetables": "🥬 Frutas e legumes", + "grain": "🥟 Grãos", + "hygiene": "🚽 Higiene", + "refrigerated": "💧 Refrigerados", + "snacks": "🥜 Lanches" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Maçã", + "apple_pulp": "Polpa de maçã", + "apérol": "Apérol", + "aspargus": "Espargos", + "aspirin": "Aspirina", + "avocado": "Abacate", + "bacon": "Toucinho", + "baguette": "Baguete" + } +} diff --git a/backend/templates/l10n/ru.json b/backend/templates/l10n/ru.json new file mode 100644 index 00000000..03b6e3bc --- /dev/null +++ b/backend/templates/l10n/ru.json @@ -0,0 +1,21 @@ +{ + "categories": { + "canned": "Консервированная еда", + "dairy": "Дневник", + "drinks": "Напитки", + "freezer": "Холодильник", + "fruits_vegetables": "Фрукты и овощи", + "hygiene": "Гигиена", + "snacks": "Закуски" + }, + "items": { + "amaretto": "Амаретто", + "apple": "Яблоко", + "apple_pulp": "Яблочное пюре", + "applesauce": "Яблочный сок", + "aspirin": "Аспирин", + "avocado": "Авокадо", + "bacon": "Бекон", + "baguette": "Багет" + } +} From 77f8b4dc7e569d86ecd6cff5e4059b72ff456127 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 1 Feb 2023 23:20:43 +0100 Subject: [PATCH 257/496] feat: Add Indonesian and Russian --- backend/app/config.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 4d824a98..fd52e8cf 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -26,7 +26,8 @@ username=os.getenv('DB_USER', ""), password=os.getenv('DB_PASSWORD', ""), host=os.getenv('DB_HOST', ""), - database=os.getenv('DB_NAME', os.getenv('STORAGE_PATH', PROJECT_DIR) + "/database.db"), + database=os.getenv('DB_NAME', os.getenv( + 'STORAGE_PATH', PROJECT_DIR) + "/database.db"), ) JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) @@ -37,9 +38,11 @@ 'de': 'Deutsch', 'es': 'Español', 'fr': 'Français', + 'id': 'Bahasa Indonesia', # 'nb_NO': '', - # 'pt': 'Português', 'pt_BR': 'Português Brasileiro', + # 'pt': 'Português', + 'ru': 'русский язык', } Flask.json_provider_class = KitchenOwlJSONProvider From d6069e1d78161b67a4a642e8349a495b7af6fc8b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 1 Feb 2023 23:40:46 +0100 Subject: [PATCH 258/496] fix: bugs and upgrade dependencies --- backend/app/config.py | 6 ++--- .../controller/expense/expense_controller.py | 2 +- backend/app/models/history.py | 4 +-- backend/app/models/recipe_history.py | 4 +-- backend/requirements.txt | 26 +++++++++---------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index fd52e8cf..fe79d8a0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,9 +23,9 @@ DB_URL = URL.create( os.getenv('DB_DRIVER', "sqlite"), - username=os.getenv('DB_USER', ""), - password=os.getenv('DB_PASSWORD', ""), - host=os.getenv('DB_HOST', ""), + username=os.getenv('DB_USER'), + password=os.getenv('DB_PASSWORD'), + host=os.getenv('DB_HOST'), database=os.getenv('DB_NAME', os.getenv( 'STORAGE_PATH', PROJECT_DIR) + "/database.db"), ) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 9e3a6089..5715e80a 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -205,7 +205,7 @@ def getOverviewForMonthAgo(monthAgo: int): (e.id or -1): (float(e.balance) or 0) for e in query .with_entities(ExpenseCategory.id.label("id"), func.sum(Expense.amount * factor).label("balance")) - .filter(Expense.created_at >= monthStart, Expense.created_at <= monthEnd) + .filter(Expense.date >= monthStart, Expense.date <= monthEnd) .all() } diff --git a/backend/app/models/history.py b/backend/app/models/history.py index 62ada5b0..7dc4d3b5 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -74,8 +74,8 @@ def find_all(cls) -> list[Self]: @classmethod def get_recent(cls, shoppinglist_id: int) -> list[Self]: sq = db.session.query( - ShoppinglistItems.item_id).subquery().select(cls.item_id) + ShoppinglistItems.item_id).subquery().select() sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( - cls.item_id.notin_(sq)).group_by(cls.item_id).join(cls.item).subquery().select(cls.id) + cls.item_id.notin_(sq)).group_by(cls.item_id).join(cls.item).subquery().select() return cls.query.filter( cls.shoppinglist_id == shoppinglist_id).filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py index f6948dae..2bb3632e 100644 --- a/backend/app/models/recipe_history.py +++ b/backend/app/models/recipe_history.py @@ -58,7 +58,7 @@ def find_all(cls) -> list[Self]: @classmethod def get_recent(cls) -> list[Self]: sq = db.session.query(Recipe.id).filter( - Recipe.planned).subquery().select(Recipe.id) + Recipe.planned).subquery().select() sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( - cls.recipe_id.notin_(sq)).group_by(cls.recipe_id).join(cls.recipe).subquery().select(cls.id) + cls.recipe_id.notin_(sq)).group_by(cls.recipe_id).join(cls.recipe).subquery().select() return cls.query.filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9a8edd2b..6dc3ae57 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,10 +1,10 @@ alembic==1.9.2 appdirs==1.4.4 -APScheduler==3.9.1 +APScheduler==3.10.0 attrs==22.2.0 autopep8==2.0.1 bcrypt==4.0.1 -beautifulsoup4==4.11.1 +beautifulsoup4==4.11.2 black==23.1a1 certifi==2022.12.7 cffi==1.15.1 @@ -19,10 +19,10 @@ Flask==2.2.2 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 -Flask-Migrate==4.0.1 -Flask-SQLAlchemy==3.0.2 +Flask-Migrate==4.0.3 +Flask-SQLAlchemy==3.0.3 fonttools==4.38.0 -greenlet==2.0.1 +greenlet==2.0.2 html-text==0.5.2 html5lib==1.1 idna==3.4 @@ -36,7 +36,7 @@ kiwisolver==1.4.4 lark==1.1.5 lxml==4.9.2 Mako==1.2.4 -MarkupSafe==2.1.1 +MarkupSafe==2.1.2 marshmallow==3.19.0 matplotlib==3.6.3 mccabe==0.7.0 @@ -45,8 +45,8 @@ mlxtend==0.21.0 mypy-extensions==0.4.3 numpy==1.24.1 packaging==23.0 -pandas==1.5.2 -pathspec==0.10.3 +pandas==1.5.3 +pathspec==0.11.0 Pillow==9.4.0 platformdirs==2.6.2 pluggy==1.0.0 @@ -64,21 +64,21 @@ pytz==2022.7.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.28.0 +recipe-scrapers==14.30.0 regex==2022.10.31 requests==2.28.2 -scikit-learn==1.2.0 +scikit-learn==1.2.1 scipy==1.10.0 setuptools-scm==7.1.0 six==1.16.0 soupsieve==2.3.2 -SQLAlchemy==1.4.46 +SQLAlchemy==2.0.1 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 typed-ast==1.5.4 -types-beautifulsoup4==4.11.6.2 -types-requests==2.28.11.7 +types-beautifulsoup4==4.11.6.5 +types-requests==2.28.11.8 types-urllib3==1.26.25.4 typing_extensions==4.4.0 tzdata==2022.7 From c46f8e8c00f468f7262cac57449b6be6897fe02b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 1 Feb 2023 23:44:39 +0100 Subject: [PATCH 259/496] Prepare release 56 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index fe79d8a0..9d7ae86b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,7 +13,7 @@ MIN_FRONTEND_VERSION = 67 -BACKEND_VERSION = 55 +BACKEND_VERSION = 56 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 1b6540a89bd3afaf5553d92bc7484d81721c2bec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 09:54:43 +0100 Subject: [PATCH 260/496] chore(deps): bump werkzeug from 2.2.2 to 2.2.3 (TomBursch/kitchenowl-backend#21) Bumps [werkzeug](https://github.com/pallets/werkzeug) from 2.2.2 to 2.2.3. - [Release notes](https://github.com/pallets/werkzeug/releases) - [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/werkzeug/compare/2.2.2...2.2.3) --- updated-dependencies: - dependency-name: werkzeug dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 6dc3ae57..8ba72719 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -87,4 +87,4 @@ urllib3==1.26.14 uWSGI==2.0.21 w3lib==2.1.1 webencodings==0.5.1 -Werkzeug==2.2.2 +Werkzeug==2.2.3 From 4c17be1953656703375adf1509c73430ba8bf7df Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 2 Mar 2023 22:02:32 +0100 Subject: [PATCH 261/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Portuguese) Currently translated at 5.0% (21 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt/ * Translated using Weblate (Russian) Currently translated at 66.3% (278 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ru/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nb_NO/ * Translated using Weblate (Russian) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ru/ * Translated using Weblate (German) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/de/ --------- Co-authored-by: Tom Bursch Co-authored-by: gekenson --- backend/templates/l10n/de.json | 6 +- backend/templates/l10n/nb_NO.json | 426 +++++++++++++++++++++++++++++- backend/templates/l10n/pt.json | 1 + backend/templates/l10n/ru.json | 410 +++++++++++++++++++++++++++- 4 files changed, 836 insertions(+), 7 deletions(-) diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index 59c37cb2..ce87a2c1 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -175,7 +175,7 @@ "honey_wafers": "Honigwaffeln", "hot_dog_bun": "Hot Dog Brötchen", "ice_cream": "Eis", - "ice_cube": "Eiswürfel ", + "ice_cube": "Eiswürfel", "iceberg_lettuce": "Eisbergsalat", "iced_tea": "Eistee", "instant_soups": "Instant Suppen", @@ -307,7 +307,7 @@ "risotto_rice": "Risotto Reis", "rocket": "Rakete", "roll": "Brötchen", - "rosemary": "Rosmarin ", + "rosemary": "Rosmarin", "saffron_threads": "Safranfäden", "sage": "Salbei", "saitan_powder": "Saitan-Pulver", @@ -316,7 +316,7 @@ "salt": "Salz", "salt_mill": "Salzmühle", "sambal_oelek": "Sambal oelek", - "sauce": "Soße ", + "sauce": "Soße", "sausage": "Wurst", "sausages": "Würstchen", "savoy_cabbage": "Wirsing", diff --git a/backend/templates/l10n/nb_NO.json b/backend/templates/l10n/nb_NO.json index 0967ef42..dd8fab3e 100644 --- a/backend/templates/l10n/nb_NO.json +++ b/backend/templates/l10n/nb_NO.json @@ -1 +1,425 @@ -{} +{ + "categories": { + "bread": "🍞 Brødvarer", + "canned": "🥫 Hermetikk", + "dairy": "🥛 Meieri", + "drinks": "🍹 Drinker", + "freezer": "❄️ Fryser", + "fruits_vegetables": "🥬 Frukt og grønnsaker", + "grain": "🥟 Kornprodukter", + "hygiene": "🚽 Hygiene", + "refrigerated": "💧 Nedkjølt", + "snacks": "🥜 Snacks" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Eple", + "apple_pulp": "Eplemasse", + "applesauce": "Eplemos", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Asiatiske eggnudler", + "aspargus": "Asparges", + "aspirin": "Aspirin", + "avocado": "Avokado", + "baby_spinach": "Baby spinat", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Bakefisk", + "baking_cocoa": "Baking av kakao", + "baking_mix": "Bakemiks", + "baking_paper": "Bakepapir", + "baking_powder": "Bakepulver", + "baking_soda": "Bakepulver", + "baking_yeast": "Bakegjær", + "balsamic_vinegar": "Balsamicoeddik", + "bananas": "Bananer", + "basil": "Basilikum", + "basmati_rice": "Basmati ris", + "bathroom_cleaner": "Baderomsrengjøringsmiddel", + "batteries": "Batterier", + "bay_leaf": "Løvblad", + "beans": "Bønner", + "beer": "Øl", + "beet": "Rødbeter", + "beetroot": "Rødbeter", + "birthday_card": "Bursdagskort", + "black_beans": "Svarte bønner", + "bockwurst": "Bockwurst", + "bodywash": "Kroppsvask", + "bread": "Brød", + "breadcrumbs": "Brødsmuler", + "broccoli": "Brokkoli", + "brown_sugar": "Brunt sukker", + "brussels_sprouts": "Rosenkål", + "buffalo_mozzarella": "Bøffelmozzarella", + "buko": "Buko", + "buns": "Boller", + "burger_buns": "Burgerboller", + "burger_patties": "Burger Patties", + "burger_sauces": "Burgersauser", + "butter": "Smør", + "butter_cookies": "Smørkaker", + "button_cells": "Knappeceller", + "börek_cheese": "Börek-ost", + "cake": "Kake", + "cake_icing": "Kakeglasur", + "cane_sugar": "Rørsukker", + "cannelloni": "Cannelloni", + "canola_oil": "Rapsolje", + "cardamom": "Kardemomme", + "carrots": "Gulrøtter", + "cashews": "Cashewnøtter", + "cat_treats": "Kattegodbiter", + "cauliflower": "Blomkål", + "celeriac": "Knollselleri", + "celery": "Selleri", + "cereal_bar": "Frokostblanding", + "cheddar": "Cheddar", + "cheese": "Ost", + "cherry_tomatoes": "Cherrytomater", + "chickpeas": "Kikerter", + "chili_oil": "Chiliolje", + "chips": "Chips", + "chives": "Gressløk", + "chocolate": "Sjokolade", + "chopped_tomatoes": "Hakkede tomater", + "ciabatta": "Ciabatta", + "cider_vinegar": "Cider eddik", + "cilantro": "Koriander", + "cinnamon": "Kanel", + "cinnamon_stick": "Kanelstang", + "cocktail_sauce": "Cocktailsaus", + "cocktail_tomatoes": "Cocktailtomater", + "coconut_flakes": "Kokosflak", + "coconut_milk": "Kokosmelk", + "coconut_oil": "Kokosnøttolje", + "colorful_sprinkles": "Fargerikt strøssel", + "concealer": "Concealer", + "cookies": "Informasjonskapsler", + "coriander": "Koriander", + "corn": "Mais", + "cornflakes": "Cornflakes", + "cornstarch": "Maisstivelse", + "cornys": "Cornys", + "cough_drops": "Hostedråper", + "couscous": "Couscous", + "covid_rapid_test": "COVID-hurtigtest", + "cow's_milk": "Kumelk", + "cream": "Krem", + "cream_cheese": "Fløteost", + "creamed_spinach": "Kremet spinat", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Kreppbånd", + "crispbread": "Knekkebrød", + "cucumber": "Agurk", + "cumin": "Kummin", + "curry_paste": "Karrypasta", + "curry_powder": "Karrypulver", + "curry_sauce": "Karrisaus", + "dates": "Datoer", + "dental_floss": "Tanntråd", + "deodorant": "Deodorant", + "detergent": "Vaskemiddel", + "dill": "Dill", + "dishwasher_salt": "Oppvaskmaskinsalt", + "dishwasher_tabs": "Tabs til oppvaskmaskin", + "disinfection_spray": "Desinfeksjonsspray", + "dried_tomatoes": "Tørkede tomater", + "edamame": "Edamame", + "eggplant": "Aubergine", + "eggs": "Egg", + "falafel": "Falafel", + "falafel_powder": "Falafel pulver", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fiskepinner", + "flour": "Mel", + "flushing": "Spyling", + "fresh_cili_pepper": "Fersk cili pepper", + "frozen_berries": "Frosne bær", + "frozen_fruit": "Frossen frukt", + "frozen_pizza": "Frossen pizza", + "frozen_spinach": "Frossen spinat", + "garam_masala": "Garam Masala", + "garbage_bag": "Søppelpose", + "garlic": "Hvitløk", + "garlic_dip": "Hvitløksdipp", + "garlic_granules": "Hvitløksgranulat", + "gherkins": "Agurker", + "ginger": "Ingefær", + "glass_noodles": "Glassnudler", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Druer", + "greek_yogurt": "Gresk yoghurt", + "green_asparagus": "Grønn asparges", + "green_chili": "Grønn chili", + "green_pesto": "Grønn pesto", + "hair_gel": "Hårgelé", + "hair_wax": "Hårvoks", + "handkerchief_box": "Lommetørkleboks", + "handkerchiefs": "Lommetørklær", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hasselnøtter", + "head_of_lettuce": "Salathode", + "herb_baguettes": "Urtebaguetter", + "herb_cream_cheese": "Kremost med urter", + "honey": "Honning", + "honey_wafers": "Honning wafers", + "hot_dog_bun": "Pølsebrød", + "ice_cream": "Iskrem", + "ice_cube": "Isterning", + "iceberg_lettuce": "Isbergsalat", + "iced_tea": "Iste", + "instant_soups": "Instant supper", + "jam": "Syltetøy", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidneybønner", + "kitchen_roll": "Kjøkkenrull", + "kitchen_towels": "Kjøkkenhåndklær", + "kohlrabi": "Kålrabi", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagne nudler", + "lasagna_plates": "Lasagneplater", + "leaf_spinach": "Bladspinat", + "leek": "Purre", + "lemon": "Sitron", + "lemon_juice": "Sitronsaft", + "lemonade": "Limonade", + "lemongrass": "Sitrongress", + "lentils": "Linser", + "lentils_red": "Røde linser", + "lettuce": "Salat", + "lillet": "Lillet", + "lime": "Kalk", + "linguine": "Linguine", + "low-fat_curd_cheese": "Ost med lavt fettinnhold", + "magnesium": "Magnesium", + "mango": "Mango", + "margarine": "Margarin", + "marjoram": "Merian", + "marshmallows": "Marshmallows", + "mask": "Maske", + "mayonnaise": "Majones", + "meat_substitute_product": "Kjøtterstatningsprodukt", + "microfiber_cloth": "Mikrofiberklut", + "milk": "Melk", + "mint": "Mint", + "mint_candy": "Mint godteri", + "mixed_vegetables": "Blandede grønnsaker", + "mochis": "Mochis", + "mountain_cheese": "Fjellost", + "mouth_wash": "Munnskyllemiddel", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müslibar", + "mulled_wine": "Gløgg", + "mushrooms": "Sopp", + "mustard": "Sennep", + "neutral_oil": "Nøytral olje", + "nori_sheets": "Nori-ark", + "nutmeg": "Muskatnøtt", + "oat_milk": "Havredrikk", + "oatmeal": "Havregryn", + "oatmeal_cookies": "Havregrynkaker", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "olive_oil": "Olivenolje", + "olives": "Oliven", + "onion": "Løk", + "orange_juice": "Appelsinjuice", + "oranges": "Appelsiner", + "oregano": "Oregano", + "organic_lemon": "Økologisk sitron", + "organic_waste_bags": "Poser for organisk avfall", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Pardina linser tørket", + "parmesan": "Parmesan", + "parsley": "Persille", + "pasta": "Pasta", + "peach": "Fersken", + "peanut_butter": "Peanøttsmør", + "peanut_flips": "Peanøtt Flips", + "peanut_oil": "Jordnøttolje", + "peanuts": "Peanøtter", + "pears": "Pærer", + "peas": "Erter", + "penne": "Penne", + "pepper": "Pepper", + "pepper_mill": "Pepperkvern", + "peppers": "Paprika", + "persian_rice": "Persisk ris", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinjekjerner", + "pineapple": "Ananas", + "pita_bag": "Pitapose", + "pizza": "Pizza", + "pizza_dough": "Pizzadeig", + "plant_magarine": "Plant Magarine", + "plant_oil": "Planteolje", + "plaster": "Gips", + "porcini_mushrooms": "Porcini sopp", + "potato_dumpling_dough": "Deig til potetboller", + "potato_wedges": "Potetkiler", + "potatoes": "Poteter", + "potting_soil": "Pottejord", + "powder": "Pulver", + "powdered_sugar": "Pulverisert sukker", + "processed_cheese": "Bearbeidet ost", + "prosecco": "Prosecco", + "puff_pastry": "Butterdeig", + "pumpkin": "Gresskar", + "pumpkin_seeds": "Gresskarfrø", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Reddik", + "ramen": "Ramen", + "rapeseed_oil": "Rapsolje", + "raspberries": "Bringebær", + "raspberry_syrup": "Bringebærsirup", + "red_bull": "Red Bull", + "red_chili": "Rød chili", + "red_lentils": "Røde linser", + "red_onions": "Rødløk", + "red_pesto": "Rød pesto", + "red_wine": "Rødvin", + "red_wine_vinegar": "Rødvinseddik", + "rhubarb": "Rabarbra", + "ribbon_noodles": "Båndnudler", + "rice": "Ris", + "rice_cakes": "Riskaker", + "rice_ribbon_noodles": "Risbåndnudler", + "rice_vinegar": "Riseddik", + "ricotta": "Ricotta", + "rinse_tabs": "Skylletabletter", + "rinsing_agent": "Skyllemiddel", + "risotto_rice": "Risottoris", + "rocket": "Rakett", + "roll": "Rulle", + "rosemary": "Rosmarin", + "saffron_threads": "Safrantråder", + "sage": "Sage", + "saitan_powder": "Saitan pulver", + "salad_mix": "Salatblanding", + "salad_seeds_mix": "Salatfrøblanding", + "salt": "Salt", + "salt_mill": "Saltmølle", + "sambal_oelek": "Sambal oelek", + "sauce": "Saus", + "sausage": "Pølse", + "sausages": "Pølser", + "savoy_cabbage": "Savoykål", + "scallion": "Scallion", + "scattered_cheese": "Spredt ost", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "sckocolate_chips": "Sjokoladebiter", + "semolina_porridge": "Grøt av semulegryn", + "sesame": "Sesam", + "sesame_oil": "Sesamolje", + "shallot": "Sjalottløk", + "shampoo": "Sjampo", + "shawarma_spice": "Shawarma krydder", + "shiitake_mushroom": "Shiitake-sopp", + "shoe_insoles": "Skosåler", + "shower_gel": "Dusjgelé", + "shredded_cheese": "Strimlet ost", + "sieved_tomatoes": "Siktede tomater", + "sliced_cheese": "Skivet ost", + "smoked_paprika": "Røkt paprika", + "smoked_tofu": "Røkt tofu", + "snacks": "Snacks", + "soap": "Såpe", + "soft_drinks": "Brus", + "softdrinks": "Brus", + "sour_cream": "Rømme", + "sour_cucumbers": "Sure agurker", + "soy_hack": "Soya-hack", + "soy_sauce": "Soyasaus", + "soy_shred": "Soyastrimler", + "spaetzle": "Spätzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Kullsyreholdig vann", + "spelt": "Spelt", + "spinach": "Spinat", + "sponge_cloth": "Svampeklut", + "sponge_wipes": "Svampeservietter", + "sponges": "Svamper", + "spreading_cream": "Påsmøring av krem", + "spring_onions": "Vårløk", + "sprite": "Sprite", + "sprouts": "Spirer", + "sriracha": "Sriracha", + "strained_tomatoes": "Silte tomater", + "sugar": "Sukker", + "summer_roll_paper": "Papir for sommerruller", + "sunflower_seeds": "Solsikkefrø", + "sushi_rice": "Sushi ris", + "swabian_ravioli": "Schwabisk ravioli", + "sweet_potato": "Søtpotet", + "sweet_potatoes": "Søtpoteter", + "table_salt": "Bordsalt", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandariner", + "tape": "Tape", + "tea": "Te", + "teriyaki_sauce": "Teriyakisaus", + "thyme": "Timian", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Toalettpapir", + "tomato_juice": "Tomatjuice", + "tomato_paste": "Tomatpuré", + "tomato_sauce": "Tomatsaus", + "tomatoes": "Tomater", + "tonic_water": "Tonic vann", + "toothpaste": "Tannkrem", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla Chips", + "tuna": "Tunfisk", + "turmeric": "Gurkemeie", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon-nudler", + "uht_milk": "UHT-melk", + "vanilla_sugar": "Vaniljesukker", + "vegetable_bouillon_cube": "Grønnsaksbuljongterning", + "vegetable_broth": "Grønnsaksbuljong", + "vegetable_oil": "Vegetabilsk olje", + "vegetable_onion": "Grønnsaksløk", + "vegetables": "Grønnsaker", + "vegetarian_cold_cuts": "vegetarisk pålegg", + "vinegar": "Eddik", + "vodka": "Vodka", + "washing_powder": "Vaskepulver", + "water": "Vann", + "water_ice": "Vannis", + "watermelon": "Vannmelon", + "wc_cleaner": "WC-rengjøringsmiddel", + "whipped_cream": "Pisket krem", + "white_wine": "Hvitvin", + "white_wine_vinegar": "Hvitvinseddik", + "whole_canned_tomatoes": "Hele hermetiske tomater", + "wild_berries": "Ville bær", + "wrapping_paper": "Innpakningspapir", + "wraps": "Innpakning", + "yeast": "Gjær", + "yoghurt": "Yoghurt", + "yogurt": "Yoghurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Sink krem", + "zucchini": "Courgetter" + } +} diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json index 1c63ef5d..30caeed9 100644 --- a/backend/templates/l10n/pt.json +++ b/backend/templates/l10n/pt.json @@ -16,6 +16,7 @@ "amaretto": "Amaretto", "apple": "Maçã", "apple_pulp": "Polpa de maçã", + "applesauce": "Maçã", "apérol": "Apérol", "aspargus": "Espargos", "aspirin": "Aspirina", diff --git a/backend/templates/l10n/ru.json b/backend/templates/l10n/ru.json index 03b6e3bc..8ccf8f4e 100644 --- a/backend/templates/l10n/ru.json +++ b/backend/templates/l10n/ru.json @@ -1,21 +1,425 @@ { "categories": { + "bread": "🍞 Хлеб", "canned": "Консервированная еда", - "dairy": "Дневник", + "dairy": "Молочное", "drinks": "Напитки", - "freezer": "Холодильник", + "freezer": "❄️ Заморозка", "fruits_vegetables": "Фрукты и овощи", + "grain": "Крупы", "hygiene": "Гигиена", + "refrigerated": "Охлажденное", "snacks": "Закуски" }, "items": { + "aioli": "Айоли", "amaretto": "Амаретто", "apple": "Яблоко", "apple_pulp": "Яблочное пюре", "applesauce": "Яблочный сок", + "apérol": "Апероль", + "arugula": "Руккола", + "asian_egg_noodles": "Азиатская яичная лапша", + "aspargus": "Спаржа", "aspirin": "Аспирин", "avocado": "Авокадо", + "baby_spinach": "Молодой шпинат", "bacon": "Бекон", - "baguette": "Багет" + "baguette": "Багет", + "bakefish": "Пекарня", + "baking_cocoa": "Какао-порошок", + "baking_mix": "Смесь для выпечки", + "baking_paper": "Бумага для выпечки", + "baking_powder": "Разрыхлитель теста", + "baking_soda": "Сода", + "baking_yeast": "Дрожжи хлебопекарные", + "balsamic_vinegar": "Бальзамический уксус", + "bananas": "Бананы", + "basil": "Базилик", + "basmati_rice": "Рис басмати", + "bathroom_cleaner": "Очиститель для ванной комнаты", + "batteries": "Батарейки", + "bay_leaf": "Лавровый лист", + "beans": "Фасоль", + "beer": "Пиво", + "beet": "Свекла", + "beetroot": "Свекла", + "birthday_card": "Поздравительная открытка", + "black_beans": "Черная фасоль", + "bockwurst": "Боквурст", + "bodywash": "Мойка для тела", + "bread": "Хлеб", + "breadcrumbs": "Панировочные сухари", + "broccoli": "Брокколи", + "brown_sugar": "Коричневый сахар", + "brussels_sprouts": "Брюссельская капуста", + "buffalo_mozzarella": "Моцарелла Буффало", + "buko": "Буко", + "buns": "Булочки", + "burger_buns": "Булочки для бургеров", + "burger_patties": "Котлеты для бургеров", + "burger_sauces": "Соусы для бургеров", + "butter": "Сливочное масло", + "butter_cookies": "Печенье с маслом", + "button_cells": "Кнопочные ячейки", + "börek_cheese": "Сыр Бёрек", + "cake": "Торт", + "cake_icing": "Глазурь для торта", + "cane_sugar": "Тростниковый сахар", + "cannelloni": "Каннеллони", + "canola_oil": "Масло канолы", + "cardamom": "Кардамон", + "carrots": "Морковь", + "cashews": "Кешью", + "cat_treats": "Лакомства для кошек", + "cauliflower": "Цветная капуста", + "celeriac": "Корень сельдерея", + "celery": "Сельдерей", + "cereal_bar": "Зерновой батончик", + "cheddar": "Чеддер", + "cheese": "Сыр", + "cherry_tomatoes": "Помидоры Черри", + "chickpeas": "Нут", + "chili_oil": "Масло чили", + "chips": "Чипсы", + "chives": "Зеленый лук", + "chocolate": "Шоколад", + "chopped_tomatoes": "Томаты резаные", + "ciabatta": "Чиабатта", + "cider_vinegar": "Яблочный уксус", + "cilantro": "Кинза", + "cinnamon": "Корица", + "cinnamon_stick": "Палочка корицы", + "cocktail_sauce": "Коктейльный соус", + "cocktail_tomatoes": "Коктейльные помидоры", + "coconut_flakes": "Кокосовая стружка", + "coconut_milk": "Кокосовое молоко", + "coconut_oil": "Кокосовое масло", + "colorful_sprinkles": "Разноцветные посыпки", + "concealer": "Консилер", + "cookies": "Печенье", + "coriander": "Кориандр", + "corn": "Кукуруза", + "cornflakes": "Кукурузные хлопья", + "cornstarch": "Кукурузный крахмал", + "cornys": "Cornys", + "cough_drops": "Капли от кашля", + "couscous": "Кускус", + "covid_rapid_test": "Экспресс-тест COVID", + "cow's_milk": "Коровье молоко", + "cream": "Сливки", + "cream_cheese": "Сливочный сыр", + "creamed_spinach": "Шпинат со сливками", + "creme_fraiche": "Крем-фрайш", + "crepe_tape": "Креповая лента", + "crispbread": "Хлебцы", + "cucumber": "Огурцы", + "cumin": "Тмин", + "curry_paste": "Паста карри", + "curry_powder": "Карри", + "curry_sauce": "Соус карри", + "dates": "Даты", + "dental_floss": "Зубная нить", + "deodorant": "Дезодорант", + "detergent": "Стиральный порошок", + "dill": "Укроп", + "dishwasher_salt": "Соль для посудомойки", + "dishwasher_tabs": "Таблетки для посудомойки", + "disinfection_spray": "Дезинфицирующий спрей", + "dried_tomatoes": "Сушеные помидоры", + "edamame": "Эдамаме", + "eggplant": "Баклажаны", + "eggs": "Яйца", + "falafel": "Фалафель", + "falafel_powder": "Порошок для фалафеля", + "fanta": "Фанта", + "feta": "Сыр Фета", + "ffp2": "FFP2", + "fish_sticks": "Рыбные палочки", + "flour": "Мука", + "flushing": "Промывка", + "fresh_cili_pepper": "Свежий перец чили", + "frozen_berries": "Замороженные ягоды", + "frozen_fruit": "Замороженные фрукты", + "frozen_pizza": "Замороженная пицца", + "frozen_spinach": "Замороженный шпинат", + "garam_masala": "Гарам Масала", + "garbage_bag": "Мешки для мусора", + "garlic": "Чеснок", + "garlic_dip": "Чесночный соус", + "garlic_granules": "Гранулированный чеснок", + "gherkins": "Корнишоны", + "ginger": "Имбирь", + "glass_noodles": "Фунчоза", + "gluten": "Глютен", + "gnocchi": "Ньокки", + "gochujang": "Гочуджан", + "gorgonzola": "Горгонзола", + "gouda": "Гауда", + "grapes": "Виноград", + "greek_yogurt": "Греческий йогурт", + "green_asparagus": "Зеленая спаржа", + "green_chili": "Зеленый перец чили", + "green_pesto": "Зеленый песто", + "hair_gel": "Гель для волос", + "hair_wax": "Воск для волос", + "handkerchief_box": "Коробка для носовых платков", + "handkerchiefs": "Носовые платки", + "haribo": "Haribo", + "harissa": "Харисса", + "hazelnuts": "Фундук", + "head_of_lettuce": "Головка салата-латука", + "herb_baguettes": "Багеты с травами", + "herb_cream_cheese": "Сливочный сыр с травами", + "honey": "Мед", + "honey_wafers": "Медовые вафли", + "hot_dog_bun": "Булочки для хот-догов", + "ice_cream": "Мороженое", + "ice_cube": "Лед в кубиках", + "iceberg_lettuce": "Салат Айсберг", + "iced_tea": "Холодный чай", + "instant_soups": "Супы быстрого приготовления", + "jam": "Джем", + "katjes": "Katjes", + "ketchup": "Кетчуп", + "kidney_beans": "Почечная фасоль", + "kitchen_roll": "Бумажные полотенца", + "kitchen_towels": "Кухонные полотенца", + "kohlrabi": "Кольраби", + "lasagna": "Лазанья", + "lasagna_noodles": "Макароны для лазаньи", + "lasagna_plates": "Тарелки для лазаньи", + "leaf_spinach": "Листья шпината", + "leek": "Лук-порей", + "lemon": "Лимоны", + "lemon_juice": "Лимонный сок", + "lemonade": "Лимонад", + "lemongrass": "Лемонграсс", + "lentils": "Чечевица", + "lentils_red": "Красная чечевица", + "lettuce": "Зелень салата", + "lillet": "Lillet", + "lime": "Лайм", + "linguine": "Макароны лингуини", + "low-fat_curd_cheese": "Обезжиренный творог", + "magnesium": "Магний", + "mango": "Манго", + "margarine": "Маргарин", + "marjoram": "Майоран", + "marshmallows": "Маршмэллоу", + "mask": "Маска", + "mayonnaise": "Майонез", + "meat_substitute_product": "Продукт, заменяющий мясо", + "microfiber_cloth": "Салфетки из микрофибры", + "milk": "Молоко", + "mint": "Мята", + "mint_candy": "Мятные леденцы", + "mixed_vegetables": "Овощная смесь", + "mochis": "Мочис", + "mountain_cheese": "Горный сыр", + "mouth_wash": "Полоскание рта", + "mozzarella": "Моцарелла", + "muesli": "Мюсли", + "muesli_bar": "Бар мюсли", + "mulled_wine": "Глинтвейн", + "mushrooms": "Грибы", + "mustard": "Горчица", + "neutral_oil": "Рафинированное масло", + "nori_sheets": "Водоросли нори", + "nutmeg": "Мускатный орех", + "oat_milk": "Овсяный напиток", + "oatmeal": "Овсяная каша", + "oatmeal_cookies": "Овсяное печенье", + "oatsome": "Овес", + "obatzda": "Обацда", + "olive_oil": "Оливковое масло", + "olives": "Оливки", + "onion": "Лук", + "orange_juice": "Апельсиновый сок", + "oranges": "Апельсины", + "oregano": "Орегано", + "organic_lemon": "Органический лимон", + "organic_waste_bags": "Мешки для органических отходов", + "pak_choi": "Пак Чой", + "paprika": "Паприка", + "pardina_lentils_dried": "Чечевица пардина сушеная", + "parmesan": "Пармезан", + "parsley": "Петрушка", + "pasta": "Паста", + "peach": "Персики", + "peanut_butter": "Арахисовая паста", + "peanut_flips": "Ореховые флипы", + "peanut_oil": "Арахисовое масло", + "peanuts": "Арахис", + "pears": "Груши", + "peas": "Горох", + "penne": "Макароны перья", + "pepper": "Перец", + "pepper_mill": "Мельница для перца", + "peppers": "Перец", + "persian_rice": "Персидский рис", + "pesto": "Песто", + "pilsner": "Пиво пилснер", + "pine_nuts": "Кедровые орешки", + "pineapple": "Ананасы", + "pita_bag": "Мешок лаваша", + "pizza": "Пицца", + "pizza_dough": "Тесто для пиццы", + "plant_magarine": "Растение Магарин", + "plant_oil": "Растительное масло", + "plaster": "Пластырь", + "porcini_mushrooms": "Белые грибы", + "potato_dumpling_dough": "Тесто для картофельных клецок", + "potato_wedges": "Картофельные дольки", + "potatoes": "Картофель", + "potting_soil": "Посадочная земля", + "powder": "Порошок", + "powdered_sugar": "Сахарная пудра", + "processed_cheese": "Плавленный сыр", + "prosecco": "Просекко", + "puff_pastry": "Слоеное тесто", + "pumpkin": "Тыква", + "pumpkin_seeds": "Тыквенные семечки", + "quark": "Кварк", + "quinoa": "Киноа", + "radicchio": "Радиккио", + "radish": "Редис", + "ramen": "Рамен", + "rapeseed_oil": "Рапсовое масло", + "raspberries": "Малина", + "raspberry_syrup": "Малиновый сироп", + "red_bull": "Ред Булл", + "red_chili": "Красный перец чили", + "red_lentils": "Красная чечевица", + "red_onions": "Красный лук", + "red_pesto": "Красный песто", + "red_wine": "Красное вино", + "red_wine_vinegar": "Красный винный уксус", + "rhubarb": "Ревень", + "ribbon_noodles": "Ленточная лапша", + "rice": "Рис", + "rice_cakes": "Рисовые лепешки", + "rice_ribbon_noodles": "Рисовая ленточная лапша", + "rice_vinegar": "Рисовый уксус", + "ricotta": "Рикотта", + "rinse_tabs": "Таблетки для полоскания", + "rinsing_agent": "Ополаскиватель", + "risotto_rice": "Рис ризотто", + "rocket": "Рукола", + "roll": "Рулон", + "rosemary": "Розмарин", + "saffron_threads": "Шафран", + "sage": "Шалфей", + "saitan_powder": "Сайтанский порошок", + "salad_mix": "Салатная смесь", + "salad_seeds_mix": "Смесь семян для салата", + "salt": "Соль", + "salt_mill": "Мельница для соли", + "sambal_oelek": "Самбал олек", + "sauce": "Соус", + "sausage": "Колбаса", + "sausages": "Сосиски", + "savoy_cabbage": "Савойская капуста", + "scallion": "Зеленый лук", + "scattered_cheese": "Рассыпчатый сыр", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "sckocolate_chips": "Шоколадные чипсы", + "semolina_porridge": "Каша из манной крупы", + "sesame": "Кунжут", + "sesame_oil": "Кунжутное масло", + "shallot": "Лук-шалот", + "shampoo": "Шампунь", + "shawarma_spice": "Приправа для шаурмы", + "shiitake_mushroom": "Грибы шиитаке", + "shoe_insoles": "Стельки", + "shower_gel": "Гель для душа", + "shredded_cheese": "Тертый сыр", + "sieved_tomatoes": "Томатная паста", + "sliced_cheese": "Сыр в нарезке", + "smoked_paprika": "Копченая паприка", + "smoked_tofu": "Копченый тофу", + "snacks": "Закуски", + "soap": "Мыло", + "soft_drinks": "Лимонады", + "softdrinks": "Прохладительные напитки", + "sour_cream": "Сметана", + "sour_cucumbers": "Маринованные огурцы", + "soy_hack": "Взлом сои", + "soy_sauce": "Соевый соус", + "soy_shred": "Измельчение сои", + "spaetzle": "Шпецле", + "spaghetti": "Спагетти", + "sparkling_water": "Газированная вода", + "spelt": "Полба", + "spinach": "Шпинат", + "sponge_cloth": "Губчатая ткань", + "sponge_wipes": "Губчатые салфетки", + "sponges": "Губка", + "spreading_cream": "Распределяющий крем", + "spring_onions": "Весенний лук", + "sprite": "Спрайт", + "sprouts": "Проростки", + "sriracha": "Шрирача", + "strained_tomatoes": "Процеженные помидоры", + "sugar": "Сахар", + "summer_roll_paper": "Летняя рулонная бумага", + "sunflower_seeds": "Семечки", + "sushi_rice": "Рис для суши", + "swabian_ravioli": "Швабские равиоли", + "sweet_potato": "Батат", + "sweet_potatoes": "Батат", + "table_salt": "Поваренная соль", + "tagliatelle": "Тальятелле", + "tahini": "Тахина", + "tangerines": "Мандарины", + "tape": "Лента", + "tea": "Чай", + "teriyaki_sauce": "Соус терияки", + "thyme": "Тимьян", + "toast": "Тост", + "tofu": "Тофу", + "toilet_paper": "Туалетная бумага", + "tomato_juice": "Томатный сок", + "tomato_paste": "Томатная паста", + "tomato_sauce": "Томатный соус", + "tomatoes": "Помидоры", + "tonic_water": "Тоник", + "toothpaste": "Зубная паста", + "tortellini": "Тортеллини", + "tortilla_chips": "Чипсы Тортилья", + "tuna": "Тунец", + "turmeric": "Куркума", + "tzatziki": "Дзадзыки", + "udon_noodles": "Лапша удон", + "uht_milk": "Ультрапастеризованное молоко", + "vanilla_sugar": "Ванильный сахар", + "vegetable_bouillon_cube": "Овощной бульонный кубик", + "vegetable_broth": "Овощной бульон", + "vegetable_oil": "Растительное масло", + "vegetable_onion": "Овощной лук", + "vegetables": "Овощи", + "vegetarian_cold_cuts": "вегетарианские холодные закуски", + "vinegar": "Уксус", + "vodka": "Водка", + "washing_powder": "Стиральный порошок", + "water": "Вода", + "water_ice": "Водяной лед", + "watermelon": "Арбуз", + "wc_cleaner": "Очиститель унитаза", + "whipped_cream": "Взбитые сливки", + "white_wine": "Белое вино", + "white_wine_vinegar": "Белый винный уксус", + "whole_canned_tomatoes": "Консервированные помидоры", + "wild_berries": "Дикие ягоды", + "wrapping_paper": "Оберточная бумага", + "wraps": "Обертывания", + "yeast": "Дрожжи", + "yoghurt": "Йогурт", + "yogurt": "Йогурт", + "yum_yum": "Ням-ням", + "zewa": "Zewa", + "zinc_cream": "Цинковый крем", + "zucchini": "Цуккини" } } From aecb3449b3fab0bb3352ab0153370846151c9858 Mon Sep 17 00:00:00 2001 From: Ben Dundon Date: Fri, 3 Mar 2023 07:07:12 +1000 Subject: [PATCH 262/496] fix: Amended a spelling error in "Chili Pepper" (TomBursch/kitchenowl-backend#22) * fix: Amended a spelling error in "Chili Pepper" * fix: other languages --------- Co-authored-by: Tom Bursch --- backend/templates/attributes.json | 2 +- backend/templates/l10n/de.json | 2 +- backend/templates/l10n/en.json | 2 +- backend/templates/l10n/es.json | 2 +- backend/templates/l10n/fr.json | 2 +- backend/templates/l10n/id.json | 2 +- backend/templates/l10n/pt_BR.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index d69bac70..988ffa36 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -151,7 +151,7 @@ "fish_sticks": {}, "flour": {}, "flushing": {}, - "fresh_cili_pepper": {}, + "fresh_chili_pepper": {}, "frozen_berries": { "category": "freezer" }, diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index ce87a2c1..3e8a88ff 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -138,7 +138,7 @@ "fish_sticks": "Fischstäbchen", "flour": "Mehl", "flushing": "Spülung", - "fresh_cili_pepper": "Frische Cilischote", + "fresh_chili_pepper": "Frische Chilischote", "frozen_berries": "TK Beeren", "frozen_fruit": "TK Obst", "frozen_pizza": "Tiefkühlpizza", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index a934ee71..076f1c82 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -138,7 +138,7 @@ "fish_sticks": "Fish sticks", "flour": "Flour", "flushing": "Flushing", - "fresh_cili_pepper": "Fresh cili pepper", + "fresh_chili_pepper": "Fresh chili pepper", "frozen_berries": "Frozen berries", "frozen_fruit": "Frozen fruit", "frozen_pizza": "Frozen pizza", diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index ce5d9f96..6e1e67aa 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -138,7 +138,7 @@ "fish_sticks": "Palitos de pescado", "flour": "Harina", "flushing": "Enjuague", - "fresh_cili_pepper": "Guindilla fresca", + "fresh_chili_pepper": "Guindilla fresca", "frozen_berries": "Bayas congeladas", "frozen_fruit": "Fruta congelada", "frozen_pizza": "Pizza congelada", diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json index 0a3111ce..f2f59ec5 100644 --- a/backend/templates/l10n/fr.json +++ b/backend/templates/l10n/fr.json @@ -138,7 +138,7 @@ "fish_sticks": "Bâtonnets de poisson", "flour": "Farine", "flushing": "Chasse d'eau", - "fresh_cili_pepper": "Piment cili frais", + "fresh_chili_pepper": "Piment cili frais", "frozen_berries": "Baies congelées", "frozen_fruit": "Fruits congelés", "frozen_pizza": "Pizza surgelée", diff --git a/backend/templates/l10n/id.json b/backend/templates/l10n/id.json index f1689f4d..0edf0e96 100644 --- a/backend/templates/l10n/id.json +++ b/backend/templates/l10n/id.json @@ -138,7 +138,7 @@ "fish_sticks": "Tongkat ikan", "flour": "Tepung", "flushing": "Pembilasan", - "fresh_cili_pepper": "Cabai rawit segar", + "fresh_chili_pepper": "Cabai rawit segar", "frozen_berries": "Buah beri beku", "frozen_fruit": "Buah beku", "frozen_pizza": "Pizza beku", diff --git a/backend/templates/l10n/pt_BR.json b/backend/templates/l10n/pt_BR.json index 9f31f9e6..72fe4bd4 100644 --- a/backend/templates/l10n/pt_BR.json +++ b/backend/templates/l10n/pt_BR.json @@ -138,7 +138,7 @@ "fish_sticks": "Palitos de peixe", "flour": "Farinha", "flushing": "Flushing", - "fresh_cili_pepper": "Pimenta cili fresca", + "fresh_chili_pepper": "Pimenta cili fresca", "frozen_berries": "Frutas congeladas", "frozen_fruit": "Frutas congeladas", "frozen_pizza": "Pizza congelada", From c38efe5ce9da2be35b82ab36dc8d22d8106bb5ea Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 2 Mar 2023 22:48:53 +0100 Subject: [PATCH 263/496] l10n: update and fix --- backend/templates/attributes.json | 644 ++++++++++++++++++++++-------- backend/templates/l10n/de.json | 4 +- backend/templates/l10n/en.json | 4 +- backend/templates/l10n/es.json | 4 +- backend/templates/l10n/fr.json | 4 +- backend/templates/l10n/id.json | 4 +- backend/templates/l10n/nb_NO.json | 4 +- backend/templates/l10n/pt.json | 2 +- backend/templates/l10n/pt_BR.json | 4 +- backend/templates/l10n/ru.json | 4 +- 10 files changed, 496 insertions(+), 182 deletions(-) diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index 988ffa36..da353632 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -1,22 +1,38 @@ { "items": { "aioli": {}, - "amaretto": {}, + "amaretto": { + "category": "drinks" + }, "apple": { "category": "fruits_vegetables" }, "apple_pulp": {}, "applesauce": {}, - "apérol": {}, - "arugula": {}, + "apérol": { + "category": "drinks" + }, + "arugula": { + "category": "fruits_vegetables" + }, "asian_egg_noodles": {}, - "aspargus": {}, + "asparagus": { + "category": "fruits_vegetables" + }, "aspirin": {}, - "avocado": {}, - "baby_spinach": {}, + "avocado": { + "category": "fruits_vegetables" + }, + "baby_spinach": { + "category": "fruits_vegetables" + }, "bacon": {}, - "baguette": {}, - "baguettes": {}, + "baguette": { + "category": "bread" + }, + "baguettes": { + "category": "bread" + }, "bakefish": {}, "baking_cocoa": {}, "baking_mix": {}, @@ -25,49 +41,83 @@ "baking_soda": {}, "baking_yeast": {}, "balsamic_vinegar": {}, - "bananas": {}, + "bananas": { + "category": "fruits_vegetables" + }, "basil": {}, "basmati_rice": {}, "bathroom_cleaner": {}, "batteries": {}, "bay_leaf": {}, "beans": {}, - "beer": {}, - "beet": {}, - "beetroot": {}, + "beer": { + "category": "drinks" + }, + "beet": { + "category": "fruits_vegetables" + }, + "beetroot": { + "category": "fruits_vegetables" + }, "birthday_card": {}, "black_beans": {}, "bockwurst": {}, - "bodywash": {}, + "bodywash": { + "category": "hygiene" + }, "bread": { "category": "bread" }, "breadcrumbs": {}, - "broccoli": {}, + "broccoli": { + "category": "fruits_vegetables" + }, "brown_sugar": {}, - "brussels_sprouts": {}, - "buffalo_mozzarella": {}, - "buko": {}, - "buns": {}, - "burger_buns": {}, + "brussels_sprouts": { + "category": "fruits_vegetables" + }, + "buffalo_mozzarella": { + "category": "dairy" + }, + "buko": { + "category": "dairy" + }, + "buns": { + "category": "bread" + }, + "burger_buns": { + "category": "bread" + }, "burger_patties": {}, "burger_sauces": {}, - "butter": {}, + "butter": { + "category": "dairy" + }, "butter_cookies": {}, "button_cells": {}, - "börek_cheese": {}, + "börek_cheese": { + "category": "dairy" + }, "cake": {}, "cake_icing": {}, "cane_sugar": {}, "cannelloni": {}, "canola_oil": {}, "cardamom": {}, - "carrots": {}, + "carrots": { + "category": "fruits_vegetables" + }, "cashews": {}, "cat_treats": {}, - "cauliflower": {}, - "celeriac": {}, - "celery": {}, + "cauliflower": { + "category": "fruits_vegetables" + }, + "celeriac": { + "category": "fruits_vegetables" + }, + "celery": { + "category": "fruits_vegetables" + }, "cereal_bar": {}, "cheddar": { "category": "dairy" @@ -75,20 +125,32 @@ "cheese": { "category": "dairy" }, - "cherry_tomatoes": {}, + "cherry_tomatoes": { + "category": "fruits_vegetables" + }, "chickpeas": {}, "chili_oil": {}, - "chips": {}, - "chives": {}, - "chocolate": {}, + "chips": { + "category": "snacks" + }, + "chives": { + "category": "fruits_vegetables" + }, + "chocolate": { + "category": "snacks" + }, "chopped_tomatoes": {}, - "ciabatta": {}, + "ciabatta": { + "category": "bread" + }, "cider_vinegar": {}, "cilantro": {}, "cinnamon": {}, "cinnamon_stick": {}, "cocktail_sauce": {}, - "cocktail_tomatoes": {}, + "cocktail_tomatoes": { + "category": "fruits_vegetables" + }, "coconut_flakes": {}, "coconut_milk": { "category": "canned" @@ -96,9 +158,13 @@ "coconut_oil": {}, "colorful_sprinkles": {}, "concealer": {}, - "cookies": {}, + "cookies": { + "category": "snacks" + }, "coriander": {}, - "corn": {}, + "corn": { + "category": "fruits_vegetables" + }, "cornflakes": {}, "cornstarch": {}, "cornys": {}, @@ -115,16 +181,24 @@ "category": "dairy" }, "creamed_spinach": {}, - "creme_fraiche": {}, + "creme_fraiche": { + "category": "dairy" + }, "crepe_tape": {}, "crispbread": {}, - "cucumber": {}, + "cucumber": { + "category": "fruits_vegetables" + }, "cumin": {}, "curry_paste": {}, "curry_powder": {}, "curry_sauce": {}, - "dates": {}, - "dental_floss": {}, + "dates": { + "category": "fruits_vegetables" + }, + "dental_floss": { + "category": "hygiene" + }, "deodorant": { "category": "hygiene" }, @@ -132,26 +206,40 @@ "category": "hygiene" }, "dill": {}, - "dishwasher_salt": {}, + "dishwasher_salt": { + "category": "hygiene" + }, "dishwasher_tabs": { "category": "hygiene" }, - "disinfection_spray": {}, + "disinfection_spray": { + "category": "hygiene" + }, "dried_tomatoes": {}, "edamame": {}, - "eggplant": {}, - "eggs": {}, + "eggplant": { + "category": "fruits_vegetables" + }, + "eggs": { + "category": "dairy" + }, "falafel": {}, "falafel_powder": {}, "fanta": { "category": "drinks" }, - "feta": {}, - "ffp2": {}, + "feta": { + "category": "dairy" + }, + "ffp2": { + "category": "hygiene" + }, "fish_sticks": {}, "flour": {}, "flushing": {}, - "fresh_chili_pepper": {}, + "fresh_chili_pepper": { + "category": "fruits_vegetables" + }, "frozen_berries": { "category": "freezer" }, @@ -165,95 +253,171 @@ "category": "freezer" }, "garam_masala": {}, - "garbage_bag": {}, - "garbage_bags": {}, - "garlic": {}, + "garbage_bag": { + "category": "hygiene" + }, + "garbage_bags": { + "category": "hygiene" + }, + "garlic": { + "category": "fruits_vegetables" + }, "garlic_dip": {}, "garlic_granules": {}, "gherkins": {}, - "ginger": {}, + "ginger": { + "category": "fruits_vegetables" + }, "glass_noodles": {}, - "gluten": {}, + "gluten": { + "category": "bread" + }, "gnocchi": {}, "gochujang": {}, - "gorgonzola": {}, - "gouda": {}, - "grapes": {}, - "greek_yogurt": {}, - "green_asparagus": {}, + "gorgonzola": { + "category": "dairy" + }, + "gouda": { + "category": "dairy" + }, + "grapes": { + "category": "fruits_vegetables" + }, + "greek_yogurt": { + "category": "dairy" + }, + "green_asparagus": { + "category": "fruits_vegetables" + }, "green_chili": {}, "green_pesto": {}, "hair_gel": {}, "hair_wax": {}, "handkerchief_box": {}, "handkerchiefs": {}, - "haribo": {}, + "haribo": { + "category": "snacks" + }, "harissa": {}, "hazelnuts": {}, - "head_of_lettuce": {}, + "head_of_lettuce": { + "category": "fruits_vegetables" + }, "herb_baguettes": {}, - "herb_cream_cheese": {}, + "herb_cream_cheese": { + "category": "dairy" + }, "honey": {}, "honey_wafers": {}, - "hot_dog_bun": {}, - "ice_cream": {}, + "hot_dog_bun": { + "category": "bread" + }, + "ice_cream": { + "category": "freezer" + }, "ice_cube": {}, - "iceberg_lettuce": {}, - "iced_tea": {}, + "iceberg_lettuce": { + "category": "fruits_vegetables" + }, + "iced_tea": { + "category": "drinks" + }, "instant_soups": {}, "jam": {}, "katjes": {}, "ketchup": {}, "kidney_beans": {}, - "kitchen_roll": {}, - "kitchen_towels": {}, - "kohlrabi": {}, + "kitchen_roll": { + "category": "hygiene" + }, + "kitchen_towels": { + "category": "hygiene" + }, + "kohlrabi": { + "category": "fruits_vegetables" + }, "lasagna": {}, "lasagna_noodles": {}, "lasagna_plates": {}, - "leaf_spinach": {}, - "leek": {}, - "lemon": {}, + "leaf_spinach": { + "category": "fruits_vegetables" + }, + "leek": { + "category": "fruits_vegetables" + }, + "lemon": { + "category": "fruits_vegetables" + }, "lemon_juice": {}, - "lemonade": {}, - "lemongrass": {}, - "lemonjuice": {}, + "lemonade": { + "category": "drinks" + }, + "lemongrass": { + "category": "fruits_vegetables" + }, "lenses": {}, "lenses_red": {}, "lentils": {}, - "lettuce": {}, - "lillet": {}, - "lime": {}, + "lettuce": { + "category": "fruits_vegetables" + }, + "lillet": { + "category": "drinks" + }, + "lime": { + "category": "fruits_vegetables" + }, "linguine": {}, - "low-fat_curd_cheese": {}, + "low-fat_curd_cheese": { + "category": "dairy" + }, "magnesium": {}, - "mango": {}, - "margarine": {}, + "mango": { + "category": "fruits_vegetables" + }, + "margarine": { + "category": "dairy" + }, "marjoram": {}, - "marshmallows": {}, - "mask": {}, + "marshmallows": { + "category": "snacks" + }, + "mask": { + "category": "hygiene" + }, "mayonnaise": {}, "meat_substitute_product": {}, "microfiber_cloth": {}, - "milk": {}, + "milk": { + "category": "dairy" + }, "mint": {}, "mint_candy": {}, "mixed_vegetables": {}, "mochis": {}, - "mountain_cheese": {}, - "mouth_wash": {}, - "mouthwash": {}, + "mountain_cheese": { + "category": "dairy" + }, + "mouth_wash": { + "category": "hygiene" + }, "mozzarella": {}, "muesli": {}, "muesli_bar": {}, - "mulled_wine": {}, - "mushrooms": {}, + "mulled_wine": { + "category": "drinks" + }, + "mushrooms": { + "category": "fruits_vegetables" + }, "mustard": {}, "neutral_oil": {}, "nori_leaves": {}, "nori_sheets": {}, "nutmeg": {}, - "oat_milk": {}, + "oat_milk": { + "category": "dairy" + }, "oatmeal": {}, "oatmeal_cookies": {}, "oatsome": {}, @@ -261,72 +425,130 @@ "category": "refrigerated" }, "olive_oil": {}, - "olives": {}, - "onion": {}, - "onions": {}, - "orange_juice": {}, + "olives": { + "category": "fruits_vegetables" + }, + "onion": { + "category": "fruits_vegetables" + }, + "onions": { + "category": "fruits_vegetables" + }, + "orange_juice": { + "category": "drinks" + }, "oranges": {}, "oregano": {}, - "organic_lemon": {}, + "organic_lemon": { + "category": "fruits_vegetables" + }, "organic_waste_bags": {}, - "pak_choi": {}, - "paprika": {}, + "pak_choi": { + "category": "fruits_vegetables" + }, + "paprika": { + "category": "fruits_vegetables" + }, "pardina_lentils_dried": {}, - "parmesan": {}, + "parmesan": { + "category": "dairy" + }, "parsley": {}, "pasta": { "category": "grain" }, - "peach": {}, + "peach": { + "category": "fruits_vegetables" + }, "peanut_butter": {}, "peanut_flips": {}, "peanut_oil": {}, "peanutbutter": {}, - "peanuts": {}, - "pears": {}, + "peanuts": { + "category": "snacks" + }, + "pears": { + "category": "fruits_vegetables" + }, "peas": {}, "penne": {}, "pepper": {}, "pepper_mill": {}, - "peppers": {}, + "peppers": { + "category": "fruits_vegetables" + }, "persian_rice": {}, "pesto": {}, - "pilsner": {}, + "pilsner": { + "category": "drinks" + }, "pine_nuts": {}, - "pineapple": {}, + "pineapple": { + "category": "fruits_vegetables" + }, "pita_bag": {}, "pizza": {}, "pizza_dough": {}, - "plant_magarine": {}, + "plant_magarine": { + "category": "dairy" + }, "plant_oil": {}, "plaster": {}, - "porcini_mushrooms": {}, + "porcini_mushrooms": { + "category": "fruits_vegetables" + }, "potato_dumpling_dough": {}, - "potatoes": {}, + "potatoes": { + "category": "fruits_vegetables" + }, "potting_soil": {}, "powder": {}, "powdered_sugar": {}, - "processed_cheese": {}, - "prosecco": {}, + "processed_cheese": { + "category": "dairy" + }, + "prosecco": { + "category": "drinks" + }, "puff_pastry": {}, - "pumpkin": {}, + "pumpkin": { + "category": "fruits_vegetables" + }, "pumpkin_seeds": {}, - "quark": {}, + "quark": { + "category": "dairy" + }, "quinoa": {}, - "radicchio": {}, - "radish": {}, + "radicchio": { + "category": "fruits_vegetables" + }, + "radish": { + "category": "fruits_vegetables" + }, "ramen": {}, "rapeseed_oil": {}, - "raspberries": {}, - "raspberry_syrup": {}, - "red_bull": {}, + "raspberries": { + "category": "fruits_vegetables" + }, + "raspberry_syrup": { + "category": "drinks" + }, + "red_bull": { + "category": "drinks" + }, "red_chili": {}, "red_lentils": {}, - "red_onions": {}, + "red_onions": { + "category": "fruits_vegetables" + }, "red_pesto": {}, - "red_wine": {}, + "red_wine": { + "category": "drinks" + }, "red_wine_vinegar": {}, - "rhubarb": {}, + "rhubarb": { + "category": "fruits_vegetables" + }, "ribbon_noodles": {}, "rice": {}, "rice_cakes": {}, @@ -336,13 +558,17 @@ "rinse_tabs": {}, "rinsing_agent": {}, "risotto_rice": {}, - "rocket": {}, + "rocket": { + "category": "fruits_vegetables" + }, "roll": {}, "rosemary": {}, "saffron_threads": {}, "sage": {}, "saitan_powder": {}, - "salad_mix": {}, + "salad_mix": { + "category": "fruits_vegetables" + }, "salad_seeds_mix": {}, "salt": {}, "salt_mill": {}, @@ -352,48 +578,88 @@ "sausages": {}, "savoy_cabbage": {}, "scallion": {}, - "scattered_cheese": {}, + "scattered_cheese": { + "category": "dairy" + }, "schlemmerfilet": {}, "schupfnudeln": {}, - "sckocolate_chips": {}, + "chocolate_chips": {}, "semolina_porridge": {}, "sesame": {}, "sesame_oil": {}, - "shallot": {}, - "shampoo": {}, + "shallot": { + "category": "fruits_vegetables" + }, + "shampoo": { + "category": "hygiene" + }, "shawarma_spice": {}, - "shiitake_mushroom": {}, + "shiitake_mushroom": { + "category": "fruits_vegetables" + }, "shoe_insoles": {}, - "shower_gel": {}, - "shredded_cheese": {}, + "shower_gel": { + "category": "hygiene" + }, + "shredded_cheese": { + "category": "dairy" + }, "sieved_tomatoes": {}, - "slice_cheese": {}, - "sliced_cheese": {}, + "slice_cheese": { + "category": "dairy" + }, + "sliced_cheese": { + "category": "dairy" + }, "smoked_paprika": {}, "smoked_tofu": {}, "snacks": { "category": "snacks" }, "soap": {}, - "soft_drinks": {}, - "softdrinks": {}, - "sour_cream": {}, + "soft_drinks": { + "category": "drinks" + }, + "softdrinks": { + "category": "drinks" + }, + "sour_cream": { + "category": "dairy" + }, "sour_cucumbers": {}, "soy_hack": {}, "soy_sauce": {}, "soy_shred": {}, "spaetzle": {}, "spaghetti": {}, - "sparkling_water": {}, + "sparkling_water": { + "category": "drinks" + }, "spelt": {}, - "spinach": {}, - "sponge_cloth": {}, - "sponge_wipes": {}, - "sponges": {}, - "spreading_cream": {}, - "spring_onions": {}, - "sprite": {}, - "sprouts": {}, + "spinach": { + "category": "fruits_vegetables" + }, + "sponge_cloth": { + "category": "hygiene" + }, + "sponge_wipes": { + "category": "hygiene" + }, + "sponges": { + "category": "hygiene" + }, + "spreading_cream": { + "category": "dairy" + }, + "spring_onions": { + "category": "fruits_vegetables" + }, + "sprite": { + "category": "drinks" + }, + "sprouts": { + "category": "fruits_vegetables" + }, "sriracha": {}, "strained_tomatoes": {}, "sugar": {}, @@ -401,64 +667,112 @@ "sunflower_seeds": {}, "sushi_rice": {}, "swabian_ravioli": {}, - "sweet_potato": {}, - "sweet_potatoes": {}, + "sweet_potato": { + "category": "fruits_vegetables" + }, + "sweet_potatoes": { + "category": "fruits_vegetables" + }, "table_salt": {}, "tagliatelle": {}, "tahini": {}, - "tangerines": {}, + "tangerines": { + "category": "fruits_vegetables" + }, "tape": {}, - "tea": {}, + "tea": { + "category": "drinks" + }, "teriyaki_sauce": {}, "thyme": {}, - "tk_potato_wedges": {}, - "toast": {}, + "toast": { + "category": "bread" + }, "tofu": {}, "toilet_paper": {}, "tomato_juice": {}, "tomato_paste": {}, "tomato_sauce": {}, - "tomatoes": {}, - "tonic_water": {}, - "toothpaste": {}, + "tomatoes": { + "category": "fruits_vegetables" + }, + "tonic_water": { + "category": "drinks" + }, + "toothpaste": { + "category": "hygiene" + }, "tortellini": {}, "tortilla_chips": {}, "tuna": {}, "turmeric": {}, "tzatziki": {}, "udon_noodles": {}, - "uht_milk": {}, + "uht_milk": { + "category": "dairy" + }, "vanilla_sugar": {}, "vegetable broth": {}, "vegetable_bouillon_cube": {}, "vegetable_broth": {}, "vegetable_oil": {}, - "vegetable_onion": {}, - "vegetables": {}, + "vegetable_onion": { + "category": "fruits_vegetables" + }, + "vegetables": { + "category": "fruits_vegetables" + }, "vegetarian_cold_cuts": {}, "vinegar": {}, - "vodka": {}, - "washing powder": {}, - "washing_powder": {}, - "water": {}, + "vodka": { + "category": "drinks" + }, + "washing_powder": { + "category": "hygiene" + }, + "water": { + "category": "drinks" + }, "water_ice": {}, - "watermelon": {}, - "wc_cleaner": {}, - "whipped_cream": {}, - "white_wine": {}, + "watermelon": { + "category": "fruits_vegetables" + }, + "wc_cleaner": { + "category": "hygiene" + }, + "whipped_cream": { + "category": "dairy" + }, + "white_wine": { + "category": "drinks" + }, "white_wine_vinegar": {}, "whole_canned_tomatoes": { "category": "canned" }, - "wild_berries": {}, + "wild_berries": { + "category": "fruits_vegetables" + }, "wrapping_paper": {}, - "wraps": {}, + "wraps": { + "category": "bread" + }, "yeast": {}, - "yoghurt": {}, - "yogurt": {}, + "yoghurt": { + "category": "dairy" + }, + "yogurt": { + "category": "dairy" + }, "yum_yum": {}, - "zewa": {}, - "zinc_cream": {}, - "zucchini": {} + "zewa": { + "category": "hygiene" + }, + "zinc_cream": { + "category": "hygiene" + }, + "zucchini": { + "category": "fruits_vegetables" + } } -} \ No newline at end of file +} diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index 3e8a88ff..65c86927 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -20,7 +20,7 @@ "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Asiatische Eiernudeln", - "aspargus": "Spargel", + "asparagus": "Spargel", "aspirin": "Aspirin", "avocado": "Avocado", "baby_spinach": "Babyspinat", @@ -324,7 +324,7 @@ "scattered_cheese": "Streukäse", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "sckocolate_chips": "Sckokoladenstückchen", + "chocolate_chips": "Sckokoladenstückchen", "semolina_porridge": "Grießbrei", "sesame": "Sesam", "sesame_oil": "Sesamöl", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index 076f1c82..32137ff5 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -20,7 +20,7 @@ "apérol": "Apérol", "arugula": "Arugula", "asian_egg_noodles": "Asian egg noodles", - "aspargus": "Aspargus", + "asparagus": "Asparagus", "aspirin": "Aspirin", "avocado": "Avocado", "baby_spinach": "Baby spinach", @@ -324,7 +324,7 @@ "scattered_cheese": "Scattered cheese", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "sckocolate_chips": "Chocolate chips", + "chocolate_chips": "Chocolate chips", "semolina_porridge": "Semolina porridge", "sesame": "Sesame", "sesame_oil": "Sesame oil", diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index 6e1e67aa..7a78d2b2 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -20,7 +20,7 @@ "apérol": "Apérol", "arugula": "Rúcula", "asian_egg_noodles": "Fideos asiáticos al huevo", - "aspargus": "Espárragos", + "asparagus": "Espárragos", "aspirin": "Aspirina", "avocado": "Aguacate", "baby_spinach": "Espinacas tiernas", @@ -324,7 +324,7 @@ "scattered_cheese": "Queso esparcido", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln (ñoquis alemanes)", - "sckocolate_chips": "Chispas de chocolate", + "chocolate_chips": "Chispas de chocolate", "semolina_porridge": "Gachas de sémola", "sesame": "Sésamo", "sesame_oil": "Aceite de sésamo", diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json index f2f59ec5..cbb1da6e 100644 --- a/backend/templates/l10n/fr.json +++ b/backend/templates/l10n/fr.json @@ -20,7 +20,7 @@ "apérol": "Apérol", "arugula": "Roquette", "asian_egg_noodles": "Nouilles asiatiques aux œufs", - "aspargus": "Aspargus", + "asparagus": "Asperges", "aspirin": "Aspirine", "avocado": "Avocat", "baby_spinach": "Jeunes épinards", @@ -324,7 +324,7 @@ "scattered_cheese": "Fromage éparpillé", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "sckocolate_chips": "Pépites de chocolat", + "chocolate_chips": "Pépites de chocolat", "semolina_porridge": "Porridge de semoule", "sesame": "Sésame", "sesame_oil": "Huile de sésame", diff --git a/backend/templates/l10n/id.json b/backend/templates/l10n/id.json index 0edf0e96..5ce7a9ec 100644 --- a/backend/templates/l10n/id.json +++ b/backend/templates/l10n/id.json @@ -20,7 +20,7 @@ "apérol": "Apérol", "arugula": "Arugula", "asian_egg_noodles": "Mie telur Asia", - "aspargus": "Aspargus", + "asparagus": "Asparagus", "aspirin": "Aspirin", "avocado": "Alpukat", "baby_spinach": "Bayam bayi", @@ -324,7 +324,7 @@ "scattered_cheese": "Keju yang tersebar", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "sckocolate_chips": "Keripik cokelat", + "chocolate_chips": "Keripik cokelat", "semolina_porridge": "Bubur semolina", "sesame": "Wijen", "sesame_oil": "Minyak wijen", diff --git a/backend/templates/l10n/nb_NO.json b/backend/templates/l10n/nb_NO.json index dd8fab3e..3eee9af9 100644 --- a/backend/templates/l10n/nb_NO.json +++ b/backend/templates/l10n/nb_NO.json @@ -20,7 +20,7 @@ "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Asiatiske eggnudler", - "aspargus": "Asparges", + "asparagus": "Asparges", "aspirin": "Aspirin", "avocado": "Avokado", "baby_spinach": "Baby spinat", @@ -324,7 +324,7 @@ "scattered_cheese": "Spredt ost", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "sckocolate_chips": "Sjokoladebiter", + "chocolate_chips": "Sjokoladebiter", "semolina_porridge": "Grøt av semulegryn", "sesame": "Sesam", "sesame_oil": "Sesamolje", diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json index 30caeed9..e655a0b5 100644 --- a/backend/templates/l10n/pt.json +++ b/backend/templates/l10n/pt.json @@ -18,7 +18,7 @@ "apple_pulp": "Polpa de maçã", "applesauce": "Maçã", "apérol": "Apérol", - "aspargus": "Espargos", + "asparagus": "Espargos", "aspirin": "Aspirina", "avocado": "Abacate", "bacon": "Toucinho", diff --git a/backend/templates/l10n/pt_BR.json b/backend/templates/l10n/pt_BR.json index 72fe4bd4..9b3cc905 100644 --- a/backend/templates/l10n/pt_BR.json +++ b/backend/templates/l10n/pt_BR.json @@ -20,7 +20,7 @@ "apérol": "Aperol", "arugula": "Rúcula", "asian_egg_noodles": "Macarrão com ovos asiáticos", - "aspargus": "Aspargos", + "asparagus": "Aspargos", "aspirin": "Aspirina", "avocado": "Abacate", "baby_spinach": "Espinafre baby", @@ -324,7 +324,7 @@ "scattered_cheese": "Queijo disperso", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "sckocolate_chips": "Chocolate em pedaços", + "chocolate_chips": "Chocolate em pedaços", "semolina_porridge": "Mingau de semolina", "sesame": "Sésamo", "sesame_oil": "Óleo de gergelim", diff --git a/backend/templates/l10n/ru.json b/backend/templates/l10n/ru.json index 8ccf8f4e..b8039e0f 100644 --- a/backend/templates/l10n/ru.json +++ b/backend/templates/l10n/ru.json @@ -20,7 +20,7 @@ "apérol": "Апероль", "arugula": "Руккола", "asian_egg_noodles": "Азиатская яичная лапша", - "aspargus": "Спаржа", + "asparagus": "Спаржа", "aspirin": "Аспирин", "avocado": "Авокадо", "baby_spinach": "Молодой шпинат", @@ -324,7 +324,7 @@ "scattered_cheese": "Рассыпчатый сыр", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "sckocolate_chips": "Шоколадные чипсы", + "chocolate_chips": "Шоколадные чипсы", "semolina_porridge": "Каша из манной крупы", "sesame": "Кунжут", "sesame_oil": "Кунжутное масло", From bd5a72dd1b162fa809386219b46d61d7d775f027 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 2 Mar 2023 23:03:44 +0100 Subject: [PATCH 264/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Norwegian Bokmål) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nb_NO/ * Translated using Weblate (Russian) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ru/ --- backend/templates/l10n/nb_NO.json | 4 ++-- backend/templates/l10n/ru.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/templates/l10n/nb_NO.json b/backend/templates/l10n/nb_NO.json index 3eee9af9..d390694e 100644 --- a/backend/templates/l10n/nb_NO.json +++ b/backend/templates/l10n/nb_NO.json @@ -84,6 +84,7 @@ "chips": "Chips", "chives": "Gressløk", "chocolate": "Sjokolade", + "chocolate_chips": "Sjokoladebiter", "chopped_tomatoes": "Hakkede tomater", "ciabatta": "Ciabatta", "cider_vinegar": "Cider eddik", @@ -138,7 +139,7 @@ "fish_sticks": "Fiskepinner", "flour": "Mel", "flushing": "Spyling", - "fresh_cili_pepper": "Fersk cili pepper", + "fresh_chili_pepper": "Fersk chilipepper", "frozen_berries": "Frosne bær", "frozen_fruit": "Frossen frukt", "frozen_pizza": "Frossen pizza", @@ -324,7 +325,6 @@ "scattered_cheese": "Spredt ost", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "chocolate_chips": "Sjokoladebiter", "semolina_porridge": "Grøt av semulegryn", "sesame": "Sesam", "sesame_oil": "Sesamolje", diff --git a/backend/templates/l10n/ru.json b/backend/templates/l10n/ru.json index b8039e0f..b3f2bb5f 100644 --- a/backend/templates/l10n/ru.json +++ b/backend/templates/l10n/ru.json @@ -84,6 +84,7 @@ "chips": "Чипсы", "chives": "Зеленый лук", "chocolate": "Шоколад", + "chocolate_chips": "Шоколадные чипсы", "chopped_tomatoes": "Томаты резаные", "ciabatta": "Чиабатта", "cider_vinegar": "Яблочный уксус", @@ -138,7 +139,7 @@ "fish_sticks": "Рыбные палочки", "flour": "Мука", "flushing": "Промывка", - "fresh_cili_pepper": "Свежий перец чили", + "fresh_chili_pepper": "Свежий перец чили", "frozen_berries": "Замороженные ягоды", "frozen_fruit": "Замороженные фрукты", "frozen_pizza": "Замороженная пицца", @@ -324,7 +325,6 @@ "scattered_cheese": "Рассыпчатый сыр", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "chocolate_chips": "Шоколадные чипсы", "semolina_porridge": "Каша из манной крупы", "sesame": "Кунжут", "sesame_oil": "Кунжутное масло", From 4ede6a54f22080757cda67530b8918dc4543702d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 3 Mar 2023 14:28:02 +0100 Subject: [PATCH 265/496] chore: upgrade requirements --- backend/requirements.txt | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 8ba72719..dc137e85 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,8 +1,8 @@ -alembic==1.9.2 +alembic==1.9.4 appdirs==1.4.4 -APScheduler==3.10.0 +APScheduler==3.10.1 attrs==22.2.0 -autopep8==2.0.1 +autopep8==2.0.2 bcrypt==4.0.1 beautifulsoup4==4.11.2 black==23.1a1 @@ -12,14 +12,14 @@ charset-normalizer==3.0.1 click==8.1.3 contourpy==1.0.7 cycler==0.11.0 -dbscan1d==0.1.6 +dbscan1d==0.2.2 extruct==0.14.0 flake8==6.0.0 -Flask==2.2.2 +Flask==2.2.3 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 -Flask-Migrate==4.0.3 +Flask-Migrate==4.0.4 Flask-SQLAlchemy==3.0.3 fonttools==4.38.0 greenlet==2.0.2 @@ -38,17 +38,17 @@ lxml==4.9.2 Mako==1.2.4 MarkupSafe==2.1.2 marshmallow==3.19.0 -matplotlib==3.6.3 +matplotlib==3.7.0 mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.21.0 -mypy-extensions==0.4.3 -numpy==1.24.1 +mypy-extensions==1.0.0 +numpy==1.24.2 packaging==23.0 pandas==1.5.3 pathspec==0.11.0 Pillow==9.4.0 -platformdirs==2.6.2 +platformdirs==3.0.0 pluggy==1.0.0 py==1.11.0 pycodestyle==2.10.0 @@ -64,23 +64,23 @@ pytz==2022.7.1 pytz-deprecation-shim==0.1.0.post0 rdflib==6.2.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.30.0 +recipe-scrapers==14.32.1 regex==2022.10.31 requests==2.28.2 scikit-learn==1.2.1 -scipy==1.10.0 +scipy==1.10.1 setuptools-scm==7.1.0 six==1.16.0 -soupsieve==2.3.2 -SQLAlchemy==2.0.1 +soupsieve==2.4 +SQLAlchemy==2.0.4 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 typed-ast==1.5.4 -types-beautifulsoup4==4.11.6.5 -types-requests==2.28.11.8 -types-urllib3==1.26.25.4 -typing_extensions==4.4.0 +types-beautifulsoup4==4.11.6.7 +types-requests==2.28.11.15 +types-urllib3==1.26.25.8 +typing_extensions==4.5.0 tzdata==2022.7 tzlocal==4.2 urllib3==1.26.14 From ccd85cd88b43293adaf1e2a596e7bd660d57553a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 6 Mar 2023 16:45:36 +0100 Subject: [PATCH 266/496] feat: Refactor meal planner and add planned yields --- .../controller/planner/planner_controller.py | 53 ++++++---- backend/app/controller/planner/schemas.py | 1 + .../controller/recipe/recipe_controller.py | 2 +- backend/app/models/__init__.py | 1 + backend/app/models/planner.py | 24 +++++ backend/app/models/recipe.py | 11 ++- backend/app/models/recipe_history.py | 4 +- backend/migrations/versions/d611f88dafb2_.py | 96 +++++++++++++++++++ 8 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 backend/app/models/planner.py create mode 100644 backend/migrations/versions/d611f88dafb2_.py diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index e82dd357..63e9a359 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -1,8 +1,9 @@ from app.errors import NotFoundRequest from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required +from app import db from app.helpers import validate_args -from app.models import Recipe, RecipeHistory +from app.models import Recipe, RecipeHistory, Planner from .schemas import AddPlannedRecipe, RemovePlannedRecipe planner = Blueprint('planner', __name__) @@ -11,10 +12,20 @@ @planner.route('/recipes', methods=['GET']) @jwt_required() def getAllPlannedRecipes(): - recipes = Recipe.query.filter(Recipe.planned).order_by(Recipe.name).all() + plannedRecipes = db.session.query(Planner.recipe_id).group_by( + Planner.recipe_id).scalar_subquery() + recipes = Recipe.query.filter(Recipe.id.in_( + plannedRecipes)).order_by(Recipe.name).all() return jsonify([e.obj_to_full_dict() for e in recipes]) +@planner.route('/', methods=['GET']) +@jwt_required() +def getPlanner(): + plans = Planner.query.all() + return jsonify([e.obj_to_full_dict() for e in plans]) + + @planner.route('/recipe', methods=['POST']) @jwt_required() @validate_args(AddPlannedRecipe) @@ -22,14 +33,23 @@ def addPlannedRecipe(args): recipe = Recipe.find_by_id(args['recipe_id']) if not recipe: raise NotFoundRequest() - if 'day' in args: - recipe.planned_days = recipe.planned_days.copy() - recipe.planned_days.add(args['day']) - else: - recipe.planned_days = set() - recipe.planned = True - recipe.save() - RecipeHistory.create_added(recipe) + day = args['day'] if 'day' in args else -1 + planner = Planner.find_by_day(recipe_id=recipe.id, day=day) + if not planner: + if day >= 0: + old = Planner.find_by_day(recipe_id=recipe.id, day=-1) + if old: old.delete() + elif len(recipe.plans) > 0: + return jsonify(recipe.obj_to_dict()) + planner = Planner() + planner.recipe_id = recipe.id + planner.day = day + if 'yields' in args: + planner.yields = args['yields'] + planner.save() + + RecipeHistory.create_added(recipe) + return jsonify(recipe.obj_to_dict()) @@ -40,14 +60,11 @@ def removePlannedRecipeById(args, id): recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() - if recipe.planned: - if 'day' in args: - recipe.planned_days = recipe.planned_days.copy() - recipe.planned_days.discard(args['day']) - else: - recipe.planned_days = {} - recipe.planned = len(recipe.planned_days) > 0 - recipe.save() + + day = args['day'] if 'day' in args else -1 + planner = Planner.find_by_day(recipe_id=recipe.id, day=day) + if planner: + planner.delete() RecipeHistory.create_dropped(recipe) return jsonify(recipe.obj_to_dict()) diff --git a/backend/app/controller/planner/schemas.py b/backend/app/controller/planner/schemas.py index c5ed1efd..232b863c 100644 --- a/backend/app/controller/planner/schemas.py +++ b/backend/app/controller/planner/schemas.py @@ -8,6 +8,7 @@ class AddPlannedRecipe(Schema): ) day = fields.Integer(validate=Range( min=0, min_inclusive=True, max=6, max_inclusive=True)) + yields = fields.Integer() class RemovePlannedRecipe(Schema): diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index aada8e3d..3ea85faa 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -19,7 +19,7 @@ recipe = Blueprint('recipe', __name__) -@recipe.route('', methods=['GET']) +@recipe.route('/', methods=['GET']) @jwt_required() def getAllRecipes(): return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name()]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 052d2f55..4876b7c7 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from .settings import Settings from .history import History, Status from .recipe import RecipeTags, RecipeItems, Recipe +from .planner import Planner from .tag import Tag from .shoppinglist import ShoppinglistItems, Shoppinglist from .recipe_history import RecipeHistory diff --git a/backend/app/models/planner.py b/backend/app/models/planner.py new file mode 100644 index 00000000..6b088500 --- /dev/null +++ b/backend/app/models/planner.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class Planner(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'planner' + + recipe_id = db.Column(db.Integer, db.ForeignKey( + 'recipe.id'), primary_key=True) + day = db.Column(db.Integer, primary_key=True) + yields = db.Column(db.Integer) + + recipe = db.relationship("Recipe", back_populates="plans") + + def obj_to_full_dict(self) -> dict: + res = self.obj_to_dict() + res['recipe'] = self.recipe.obj_to_full_dict() + return res + + @classmethod + def find_by_day(cls, recipe_id: int, day: int) -> Self: + return cls.query.filter(cls.recipe_id == recipe_id, cls.day == day).first() diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 2f922385..b8b9091b 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -5,6 +5,7 @@ from app.helpers.db_set_type import DbSetType from .item import Item from .tag import Tag +from .planner import Planner from random import randint @@ -15,8 +16,6 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): name = db.Column(db.String(128)) description = db.Column(db.String()) photo = db.Column(db.String()) - planned = db.Column(db.Boolean) - planned_days = db.Column(DbSetType(), default=set()) time = db.Column(db.Integer) cook_time = db.Column(db.Integer) prep_time = db.Column(db.Integer) @@ -31,10 +30,13 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): 'RecipeItems', back_populates='recipe', cascade="all, delete-orphan") tags = db.relationship( 'RecipeTags', back_populates='recipe', cascade="all, delete-orphan") + plans = db.relationship( + 'Planner', back_populates='recipe', cascade="all, delete-orphan") def obj_to_dict(self) -> dict: res = super().obj_to_dict() - res['planned_days'] = list(self.planned_days or set()) + res['planned'] = len(self.plans) > 0 + res['planned_days'] = [plan.day for plan in self.plans if plan.day >=0 ] return res def obj_to_full_dict(self) -> dict: @@ -99,7 +101,8 @@ def compute_suggestion_ranking(cls): @classmethod def find_suggestions(cls) -> list[Self]: - return cls.query.filter(cls.planned == False).filter( # noqa + sq = db.session.query(Planner.recipe_id).group_by(Planner.recipe_id).scalar_subquery() + return cls.query.filter(cls.id.notin_(sq)).filter( # noqa cls.suggestion_rank > 0).order_by(cls.suggestion_rank).all() @classmethod diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py index 2bb3632e..fef34a23 100644 --- a/backend/app/models/recipe_history.py +++ b/backend/app/models/recipe_history.py @@ -2,6 +2,7 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin from .recipe import Recipe +from .planner import Planner from sqlalchemy import func import enum @@ -57,8 +58,7 @@ def find_all(cls) -> list[Self]: @classmethod def get_recent(cls) -> list[Self]: - sq = db.session.query(Recipe.id).filter( - Recipe.planned).subquery().select() + sq = db.session.query(Planner.recipe_id).group_by(Planner.recipe_id).subquery().select() sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( cls.recipe_id.notin_(sq)).group_by(cls.recipe_id).join(cls.recipe).subquery().select() return cls.query.filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) diff --git a/backend/migrations/versions/d611f88dafb2_.py b/backend/migrations/versions/d611f88dafb2_.py new file mode 100644 index 00000000..ba25c07c --- /dev/null +++ b/backend/migrations/versions/d611f88dafb2_.py @@ -0,0 +1,96 @@ +"""empty message + +Revision ID: d611f88dafb2 +Revises: 4b4823a384e7 +Create Date: 2023-03-03 15:05:29.932888 + +""" +from alembic import op +import sqlalchemy as sa +from datetime import datetime +from app.helpers.db_set_type import DbSetType + +DeclarativeBase = sa.orm.declarative_base() + +# revision identifiers, used by Alembic. +revision = 'd611f88dafb2' +down_revision = '4b4823a384e7' +branch_labels = None +depends_on = None + + +class Recipe(DeclarativeBase): + __tablename__ = 'recipe' + id = sa.Column(sa.Integer, primary_key=True) + planned = sa.Column(sa.Boolean) + planned_days = sa.Column(DbSetType(), default=set()) + + +class Planner(DeclarativeBase): + __tablename__ = 'planner' + recipe_id = sa.Column(sa.Integer, sa.ForeignKey( + 'recipe.id'), primary_key=True) + day = sa.Column(sa.Integer, primary_key=True) + yields = sa.Column(sa.Integer) + created_at = sa.Column(sa.DateTime, nullable=False) + updated_at = sa.Column(sa.DateTime, nullable=False) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('planner', + sa.Column('recipe_id', sa.Integer(), nullable=False), + sa.Column('day', sa.Integer(), nullable=False), + sa.Column('yields', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['recipe_id'], ['recipe.id'], name=op.f('fk_planner_recipe_id_recipe')), + sa.PrimaryKeyConstraint('recipe_id', 'day', name=op.f('pk_planner')) + ) + + # Data migration + bind = op.get_bind() + session = sa.orm.Session(bind=bind) + plans = [] + for recipe in session.query(Recipe).all(): + if recipe.planned: + if len(recipe.planned_days) > 0: + for day in recipe.planned_days: + p = Planner() + p.recipe_id = recipe.id + p.day = day + p.created_at = datetime.utcnow() + p.updated_at = datetime.utcnow() + plans.append(p) + else: + p = Planner() + p.recipe_id = recipe.id + p.day = -1 + p.created_at = datetime.utcnow() + p.updated_at = datetime.utcnow() + plans.append(p) + + try: + session.bulk_save_objects(plans) + session.commit() + except Exception as e: + session.rollback() + raise e + + # Data migration end + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_column('planned') + batch_op.drop_column('planned_days') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.add_column(sa.Column('planned_days', sa.VARCHAR(), nullable=True)) + batch_op.add_column(sa.Column('planned', sa.BOOLEAN(), nullable=True)) + + op.drop_table('planner') + # ### end Alembic commands ### From cde0e15bf0cc29358ae73009a144683f328f54db Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 6 Mar 2023 16:46:48 +0100 Subject: [PATCH 267/496] feat: add item icons --- backend/app/models/item.py | 1 + backend/app/service/export_import.py | 5 +- backend/migrations/versions/6d641b08aaa8_.py | 32 ++ backend/templates/attributes.json | 448 +++++++++++++------ 4 files changed, 352 insertions(+), 134 deletions(-) create mode 100644 backend/migrations/versions/6d641b08aaa8_.py diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 0937792d..4a14eb63 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -10,6 +10,7 @@ class Item(db.Model, DbModelMixin, TimestampMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128), unique=True) + icon = db.Column(db.String(128), nullable=True) category_id = db.Column(db.Integer, db.ForeignKey('category.id')) default = db.Column(db.Boolean, default=False) diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index 63442eb1..befd6451 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -24,9 +24,12 @@ def importFromLanguage(lang, bulkSave=False): if bulkSave and any(i.name == name for i in models): # slow but needed to filter out duplicate names continue item = Item() - item.name = name + item.name = name.strip() item.default = True + if key in attributes["items"] and "icon" in attributes["items"][key]: + item.icon = attributes["items"][key]["icon"] + # Category not already set for existing item and category set for template and category translation exist for language if not item.category_id and key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: category_name = data["categories"][attributes["items"] diff --git a/backend/migrations/versions/6d641b08aaa8_.py b/backend/migrations/versions/6d641b08aaa8_.py new file mode 100644 index 00000000..ec4caa81 --- /dev/null +++ b/backend/migrations/versions/6d641b08aaa8_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 6d641b08aaa8 +Revises: d611f88dafb2 +Create Date: 2023-03-06 16:45:59.256447 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6d641b08aaa8' +down_revision = 'd611f88dafb2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.add_column(sa.Column('icon', sa.String(length=128), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_column('icon') + + # ### end Alembic commands ### diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index da353632..39ba5775 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -5,33 +5,48 @@ "category": "drinks" }, "apple": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "apple" + }, + "apple_pulp": { + "icon": "apple" + }, + "applesauce": { + "icon": "apple" }, - "apple_pulp": {}, - "applesauce": {}, "apérol": { - "category": "drinks" + "category": "drinks", + "icon": "wine-bottle" }, "arugula": { "category": "fruits_vegetables" }, "asian_egg_noodles": {}, "asparagus": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "asparagus" + }, + "aspirin": { + "icon": "pills" }, - "aspirin": {}, "avocado": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "avocado" }, "baby_spinach": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "spinach" + }, + "bacon": { + "icon": "bacon" }, - "bacon": {}, "baguette": { - "category": "bread" + "category": "bread", + "icon": "bread" }, "baguettes": { - "category": "bread" + "category": "bread", + "icon": "bread" }, "bakefish": {}, "baking_cocoa": {}, @@ -42,35 +57,47 @@ "baking_yeast": {}, "balsamic_vinegar": {}, "bananas": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "banana" + }, + "basil": { + "icon": "basil" + }, + "basmati_rice": { + "icon": "grains-of-rice" }, - "basil": {}, - "basmati_rice": {}, "bathroom_cleaner": {}, "batteries": {}, "bay_leaf": {}, "beans": {}, "beer": { - "category": "drinks" + "category": "drinks", + "icon": "beer-bottle" }, "beet": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "beet" }, "beetroot": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "beet" }, "birthday_card": {}, "black_beans": {}, - "bockwurst": {}, + "bockwurst": { + "icon": "sausage" + }, "bodywash": { "category": "hygiene" }, "bread": { - "category": "bread" + "category": "bread", + "icon": "bread" }, "breadcrumbs": {}, "broccoli": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "broccoli" }, "brown_sugar": {}, "brussels_sprouts": { @@ -93,10 +120,13 @@ "butter": { "category": "dairy" }, - "butter_cookies": {}, + "butter_cookies": { + "icon": "cookies" + }, "button_cells": {}, "börek_cheese": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" }, "cake": {}, "cake_icing": {}, @@ -105,7 +135,8 @@ "canola_oil": {}, "cardamom": {}, "carrots": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "carrot" }, "cashews": {}, "cat_treats": {}, @@ -116,19 +147,25 @@ "category": "fruits_vegetables" }, "celery": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "celery" }, "cereal_bar": {}, "cheddar": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" }, "cheese": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" }, "cherry_tomatoes": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "tomato" + }, + "chickpeas": { + "icon": "peas" }, - "chickpeas": {}, "chili_oil": {}, "chips": { "category": "snacks" @@ -137,11 +174,15 @@ "category": "fruits_vegetables" }, "chocolate": { - "category": "snacks" + "category": "snacks", + "icon": "chocolate-bar" + }, + "chopped_tomatoes": { + "icon": "tomato" }, - "chopped_tomatoes": {}, "ciabatta": { - "category": "bread" + "category": "bread", + "icon": "bread" }, "cider_vinegar": {}, "cilantro": {}, @@ -149,21 +190,29 @@ "cinnamon_stick": {}, "cocktail_sauce": {}, "cocktail_tomatoes": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "tomato" + }, + "coconut_flakes": { + "icon": "coconut" }, - "coconut_flakes": {}, "coconut_milk": { - "category": "canned" + "category": "canned", + "icon": "coconut" + }, + "coconut_oil": { + "icon": "coconut" }, - "coconut_oil": {}, "colorful_sprinkles": {}, "concealer": {}, "cookies": { - "category": "snacks" + "category": "snacks", + "icon": "cookies" }, "coriander": {}, "corn": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "corn" }, "cornflakes": {}, "cornstarch": {}, @@ -172,7 +221,8 @@ "couscous": {}, "covid_rapid_test": {}, "cow's_milk": { - "category": "dairy" + "category": "dairy", + "icon": "milk-carton" }, "cream": { "category": "dairy" @@ -180,14 +230,17 @@ "cream_cheese": { "category": "dairy" }, - "creamed_spinach": {}, + "creamed_spinach": { + "icon": "spinach" + }, "creme_fraiche": { "category": "dairy" }, "crepe_tape": {}, "crispbread": {}, "cucumber": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "cucumber" }, "cumin": {}, "curry_paste": {}, @@ -216,12 +269,16 @@ "category": "hygiene" }, "dried_tomatoes": {}, - "edamame": {}, + "edamame": { + "icon": "peas" + }, "eggplant": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "eggplant" }, "eggs": { - "category": "dairy" + "category": "dairy", + "icon": "eggs" }, "falafel": {}, "falafel_powder": {}, @@ -235,7 +292,9 @@ "category": "hygiene" }, "fish_sticks": {}, - "flour": {}, + "flour": { + "icon": "wheat" + }, "flushing": {}, "fresh_chili_pepper": { "category": "fruits_vegetables" @@ -247,10 +306,12 @@ "category": "freezer" }, "frozen_pizza": { - "category": "freezer" + "category": "freezer", + "icon": "salami-pizza" }, "frozen_spinach": { - "category": "freezer" + "category": "freezer", + "icon": "spinach" }, "garam_masala": {}, "garbage_bag": { @@ -260,13 +321,19 @@ "category": "hygiene" }, "garlic": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "garlic" + }, + "garlic_dip": { + "icon": "garlic" + }, + "garlic_granules": { + "icon": "garlic" }, - "garlic_dip": {}, - "garlic_granules": {}, "gherkins": {}, "ginger": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "ginger" }, "glass_noodles": {}, "gluten": { @@ -278,10 +345,12 @@ "category": "dairy" }, "gouda": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" }, "grapes": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "grapes" }, "greek_yogurt": { "category": "dairy" @@ -301,7 +370,8 @@ "harissa": {}, "hazelnuts": {}, "head_of_lettuce": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "lettuce" }, "herb_baguettes": {}, "herb_cream_cheese": { @@ -317,7 +387,8 @@ }, "ice_cube": {}, "iceberg_lettuce": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "lettuce" }, "iced_tea": { "category": "drinks" @@ -325,7 +396,9 @@ "instant_soups": {}, "jam": {}, "katjes": {}, - "ketchup": {}, + "ketchup": { + "icon": "ketchup" + }, "kidney_beans": {}, "kitchen_roll": { "category": "hygiene" @@ -334,7 +407,8 @@ "category": "hygiene" }, "kohlrabi": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "kohlrabi" }, "lasagna": {}, "lasagna_noodles": {}, @@ -343,12 +417,16 @@ "category": "fruits_vegetables" }, "leek": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "leek" }, "lemon": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "citrus" + }, + "lemon_juice": { + "icon": "citrus" }, - "lemon_juice": {}, "lemonade": { "category": "drinks" }, @@ -359,13 +437,15 @@ "lenses_red": {}, "lentils": {}, "lettuce": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "lettuce" }, "lillet": { "category": "drinks" }, "lime": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "citrus" }, "linguine": {}, "low-fat_curd_cheese": { @@ -389,14 +469,18 @@ "meat_substitute_product": {}, "microfiber_cloth": {}, "milk": { - "category": "dairy" + "category": "dairy", + "icon": "milk-carton" + }, + "mint": { + "icon": "basil" }, - "mint": {}, "mint_candy": {}, "mixed_vegetables": {}, "mochis": {}, "mountain_cheese": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" }, "mouth_wash": { "category": "hygiene" @@ -408,7 +492,8 @@ "category": "drinks" }, "mushrooms": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "mushroom" }, "mustard": {}, "neutral_oil": {}, @@ -416,7 +501,8 @@ "nori_sheets": {}, "nutmeg": {}, "oat_milk": { - "category": "dairy" + "category": "dairy", + "icon": "milk-carton" }, "oatmeal": {}, "oatmeal_cookies": {}, @@ -424,30 +510,42 @@ "obatzda": { "category": "refrigerated" }, - "olive_oil": {}, + "olive_oil": { + "icon": "olive" + }, "olives": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "olive" }, "onion": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "onion" }, "onions": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "onion" }, "orange_juice": { - "category": "drinks" + "category": "drinks", + "icon": "orange" + }, + "oranges": { + "icon": "orange" + }, + "oregano": { + "icon": "basil" }, - "oranges": {}, - "oregano": {}, "organic_lemon": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "citrus" }, "organic_waste_bags": {}, "pak_choi": { "category": "fruits_vegetables" }, "paprika": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "paprika" }, "pardina_lentils_dried": {}, "parmesan": { @@ -455,64 +553,94 @@ }, "parsley": {}, "pasta": { - "category": "grain" + "category": "grain", + "icon": "penne" }, "peach": { "category": "fruits_vegetables" }, - "peanut_butter": {}, - "peanut_flips": {}, - "peanut_oil": {}, - "peanutbutter": {}, + "peanut_butter": { + "icon": "peanuts" + }, + "peanut_flips": { + "icon": "peanuts" + }, + "peanut_oil": { + "icon": "peanuts" + }, + "peanutbutter": { + "icon": "peanuts" + }, "peanuts": { - "category": "snacks" + "category": "snacks", + "icon": "peanuts" }, "pears": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "pear" + }, + "peas": { + "icon": "peas" + }, + "penne": { + "icon": "penne" }, - "peas": {}, - "penne": {}, "pepper": {}, "pepper_mill": {}, "peppers": { "category": "fruits_vegetables" }, - "persian_rice": {}, + "persian_rice": { + "icon": "grains-of-rice" + }, "pesto": {}, "pilsner": { "category": "drinks" }, - "pine_nuts": {}, + "pine_nuts": { + "icon": "nut" + }, "pineapple": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "pineapple" }, "pita_bag": {}, - "pizza": {}, - "pizza_dough": {}, + "pizza": { + "icon": "salami-pizza" + }, + "pizza_dough": { + "icon": "salami-pizza" + }, "plant_magarine": { "category": "dairy" }, "plant_oil": {}, "plaster": {}, "porcini_mushrooms": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "mushroom" + }, + "potato_dumpling_dough": { + "icon": "potato" }, - "potato_dumpling_dough": {}, "potatoes": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "potato" }, "potting_soil": {}, "powder": {}, "powdered_sugar": {}, "processed_cheese": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" }, "prosecco": { "category": "drinks" }, "puff_pastry": {}, "pumpkin": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "pumpkin" }, "pumpkin_seeds": {}, "quark": { @@ -520,18 +648,22 @@ }, "quinoa": {}, "radicchio": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "radish" }, "radish": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "radish" }, "ramen": {}, "rapeseed_oil": {}, "raspberries": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "raspberry" }, "raspberry_syrup": { - "category": "drinks" + "category": "drinks", + "icon": "raspberry" }, "red_bull": { "category": "drinks" @@ -539,18 +671,22 @@ "red_chili": {}, "red_lentils": {}, "red_onions": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "onion" }, "red_pesto": {}, "red_wine": { - "category": "drinks" + "category": "drinks", + "icon": "wine-bottle" }, "red_wine_vinegar": {}, "rhubarb": { "category": "fruits_vegetables" }, "ribbon_noodles": {}, - "rice": {}, + "rice": { + "icon": "grains-of-rice" + }, "rice_cakes": {}, "rice_ribbon_noodles": {}, "rice_vinegar": {}, @@ -567,19 +703,27 @@ "sage": {}, "saitan_powder": {}, "salad_mix": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "lettuce" }, "salad_seeds_mix": {}, "salt": {}, "salt_mill": {}, "sambal_oelek": {}, "sauce": {}, - "sausage": {}, - "sausages": {}, - "savoy_cabbage": {}, + "sausage": { + "icon": "sausage" + }, + "sausages": { + "icon": "sausage" + }, + "savoy_cabbage": { + "icon": "cabbage" + }, "scallion": {}, "scattered_cheese": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" }, "schlemmerfilet": {}, "schupfnudeln": {}, @@ -588,30 +732,39 @@ "sesame": {}, "sesame_oil": {}, "shallot": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "onion" }, "shampoo": { "category": "hygiene" }, "shawarma_spice": {}, "shiitake_mushroom": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "mushroom" }, "shoe_insoles": {}, "shower_gel": { "category": "hygiene" }, "shredded_cheese": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" + }, + "sieved_tomatoes": { + "icon": "tomato" }, - "sieved_tomatoes": {}, "slice_cheese": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" }, "sliced_cheese": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" + }, + "smoked_paprika": { + "icon": "paprika" }, - "smoked_paprika": {}, "smoked_tofu": {}, "snacks": { "category": "snacks" @@ -626,18 +779,26 @@ "sour_cream": { "category": "dairy" }, - "sour_cucumbers": {}, + "sour_cucumbers": { + "icon": "cucumber" + }, "soy_hack": {}, - "soy_sauce": {}, - "soy_shred": {}, + "soy_sauce": { + "icon": "soy-sauce" + }, + "soy_shred": { + "icon": "soy" + }, "spaetzle": {}, "spaghetti": {}, "sparkling_water": { - "category": "drinks" + "category": "drinks", + "icon": "plastic-bottle" }, "spelt": {}, "spinach": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "spinach" }, "sponge_cloth": { "category": "hygiene" @@ -652,7 +813,8 @@ "category": "dairy" }, "spring_onions": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "leek" }, "sprite": { "category": "drinks" @@ -661,17 +823,23 @@ "category": "fruits_vegetables" }, "sriracha": {}, - "strained_tomatoes": {}, + "strained_tomatoes": { + "icon": "tomato" + }, "sugar": {}, "summer_roll_paper": {}, "sunflower_seeds": {}, - "sushi_rice": {}, + "sushi_rice": { + "icon": "grains-of-rice" + }, "swabian_ravioli": {}, "sweet_potato": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "sweet-potato" }, "sweet_potatoes": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "sweet-potato" }, "table_salt": {}, "tagliatelle": {}, @@ -681,20 +849,29 @@ }, "tape": {}, "tea": { - "category": "drinks" + "category": "drinks", + "icon": "tea" }, "teriyaki_sauce": {}, "thyme": {}, "toast": { - "category": "bread" + "category": "bread", + "icon": "bread-loaf" }, "tofu": {}, "toilet_paper": {}, - "tomato_juice": {}, - "tomato_paste": {}, - "tomato_sauce": {}, + "tomato_juice": { + "icon": "tomato" + }, + "tomato_paste": { + "icon": "tomato" + }, + "tomato_sauce": { + "icon": "tomato" + }, "tomatoes": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "tomato" }, "tonic_water": { "category": "drinks" @@ -704,7 +881,9 @@ }, "tortellini": {}, "tortilla_chips": {}, - "tuna": {}, + "tuna": { + "icon": "fish-food" + }, "turmeric": {}, "tzatziki": {}, "udon_noodles": {}, @@ -744,14 +923,17 @@ "category": "dairy" }, "white_wine": { - "category": "drinks" + "category": "drinks", + "icon": "wine-bottle" }, "white_wine_vinegar": {}, "whole_canned_tomatoes": { - "category": "canned" + "category": "canned", + "icon": "tomato" }, "wild_berries": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "raspberry" }, "wrapping_paper": {}, "wraps": { From ecf5c7a78e8ee11a8c95767988bbe84e3e0cb3f8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 6 Mar 2023 19:02:21 +0100 Subject: [PATCH 268/496] fix: item icon API --- backend/app/controller/item/item_controller.py | 2 ++ backend/app/controller/item/schemas.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 89bf64ac..9a01f6f1 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -61,5 +61,7 @@ def updateItem(args, id): item.category = Category.find_by_id(args['category']['id']) else: raise InvalidUsage() + if 'icon' in args: + item.icon = args['icon'] item.save() return jsonify(item.obj_to_dict()) diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py index 07ea29e4..94d6af7f 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -24,3 +24,7 @@ class Meta: ) category = fields.Nested(Category(), allow_none=True) + icon = fields.String( + validate=lambda a: not a or not a.isspace(), + allow_none=True, + ) From c293dbeff68b64529e3a1bf85a98fb9f329659b1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 7 Mar 2023 01:04:39 +0100 Subject: [PATCH 269/496] feat: add management scripts --- backend/Dockerfile | 2 +- backend/app/models/item.py | 5 +++ backend/app/service/export_import.py | 31 +++++++++-------- backend/entrypoint.sh | 1 + backend/manage.py | 52 ++++++++++++++++++++++++++++ backend/upgrade_default_items.py | 10 ++++++ 6 files changed, 85 insertions(+), 16 deletions(-) create mode 100755 backend/manage.py create mode 100644 backend/upgrade_default_items.py diff --git a/backend/Dockerfile b/backend/Dockerfile index ef451070..a559bbf2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -31,7 +31,7 @@ COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" # Setup KitchenOwl -COPY wsgi.ini wsgi.py entrypoint.sh /usr/src/kitchenowl/ +COPY wsgi.ini wsgi.py entrypoint.sh manage.py upgrade_default_items.py /usr/src/kitchenowl/ COPY app /usr/src/kitchenowl/app COPY templates /usr/src/kitchenowl/templates COPY migrations /usr/src/kitchenowl/migrations diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 4a14eb63..57ad523d 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -47,6 +47,11 @@ def obj_to_export_dict(self) -> dict: if self.category: res["category"] = self.category.name return res + + def save(self, keepDefault=False) -> Self: + if not keepDefault: + self.default = False + super().save() @classmethod def create_by_name(cls, name: str, default=False) -> Self: diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index befd6451..57474f05 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -26,22 +26,23 @@ def importFromLanguage(lang, bulkSave=False): item = Item() item.name = name.strip() item.default = True + + if item.default: + if key in attributes["items"] and "icon" in attributes["items"][key]: + item.icon = attributes["items"][key]["icon"] - if key in attributes["items"] and "icon" in attributes["items"][key]: - item.icon = attributes["items"][key]["icon"] - - # Category not already set for existing item and category set for template and category translation exist for language - if not item.category_id and key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: - category_name = data["categories"][attributes["items"] - [key]["category"]] - category = Category.find_by_name(category_name) - if not category: - category = Category.create_by_name(category_name, True) - item.category = category - if not bulkSave: - item.save() - else: - models.append(item) + # Category not already set for existing item and category set for template and category translation exist for language + if not item.category_id and key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: + category_name = data["categories"][attributes["items"] + [key]["category"]] + category = Category.find_by_name(category_name) + if not category: + category = Category.create_by_name(category_name, True) + item.category = category + if not bulkSave: + item.save(keepDefault=True) + else: + models.append(item) if bulkSave: try: diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 4f9cb3a1..909c18fd 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,4 +1,5 @@ #!/bin/sh flask db upgrade mkdir -p $STORAGE_PATH/upload +python upgrade_default_items.py uwsgi "$@" \ No newline at end of file diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 00000000..e182fbb1 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,52 @@ +from app import app +from app.models import User + + +def manageUsers(): + while True: + print(""" +What next? + 1. List user + 2. Reset password + 3. Delete user + 4. Go back""") + selection = input("Your selection (4):") + if selection == "1": + for u in User.all(): + print(u.username) + elif selection == "2": + username = input("Enter the username:") + user = User.find_by_username(username) + if not user: + print("No user found with that username") + else: + newPW = input("Enter new password:") + if not newPW: + print("Password cannot be empty") + continue + newPWRepeat = input("Repeat new password:") + if newPW == newPWRepeat: + user.set_password(newPW) + elif selection == "3": + username = input("Enter the username:") + user = User.find_by_username(username) + if not user: + print("No user found with that username") + else: + user.delete() + else: + return + +# docker exec -it [backend container name] python manage.py +if __name__ == "__main__": + with app.app_context(): + while True: + print(""" +Manage KitchenOwl\n---\nWhat do you want to do? + 1. Manage users + (q) Exit""") + selection = input("Your selection (q):") + if selection == "1": + manageUsers() + else: + exit() diff --git a/backend/upgrade_default_items.py b/backend/upgrade_default_items.py new file mode 100644 index 00000000..1de18876 --- /dev/null +++ b/backend/upgrade_default_items.py @@ -0,0 +1,10 @@ +from app import app +from app.models import Settings +from app.service import export_import + + +if __name__ == "__main__": + with app.app_context(): + settings = Settings.get() + if False: + export_import.importFromLanguage(lang, bulkSave=True) \ No newline at end of file From 0a91840b5c40298f49789d6a3401416e8c1883e2 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 5 Apr 2023 10:40:20 +0200 Subject: [PATCH 270/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added translation using Weblate (Danish) * Translated using Weblate (Danish) Currently translated at 5.7% (24 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/da/ * Translated using Weblate (Portuguese) Currently translated at 7.1% (30 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt/ * Translated using Weblate (French) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fr/ * Translated using Weblate (Portuguese) Currently translated at 64.2% (269 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/id/ --------- Co-authored-by: Anders Obro Co-authored-by: グヌーツマン Co-authored-by: J. Lavoie Co-authored-by: Iuri Pereira Co-authored-by: liimee --- backend/templates/l10n/da.json | 425 +++++++++++++++++++++++++++++++++ backend/templates/l10n/fr.json | 10 +- backend/templates/l10n/id.json | 8 +- backend/templates/l10n/pt.json | 250 ++++++++++++++++++- 4 files changed, 683 insertions(+), 10 deletions(-) create mode 100644 backend/templates/l10n/da.json diff --git a/backend/templates/l10n/da.json b/backend/templates/l10n/da.json new file mode 100644 index 00000000..df3237f8 --- /dev/null +++ b/backend/templates/l10n/da.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 Brød", + "canned": "🥫 Konserves", + "dairy": "🥛 Mejeriprodukter", + "drinks": "🍹 Drikkevarer", + "freezer": "❄️ Frost", + "fruits_vegetables": "🥬 Frugt og grønt", + "grain": "🥟 Kornprodukter", + "hygiene": "🚽 Hygiejne", + "refrigerated": "💧 Køl", + "snacks": "🥜 Snacks" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Æble", + "apple_pulp": "Æblemos", + "applesauce": "Æblemos", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Asiatiske ægnudler", + "asparagus": "Asparges", + "aspirin": "Aspirin", + "avocado": "Avocado", + "baby_spinach": "Babyspinat", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Bagefisk", + "baking_cocoa": "Bagekakao", + "baking_mix": "Bage-blanding", + "baking_paper": "Bagepapir", + "baking_powder": "Bagepulver", + "baking_soda": "Bagepulver", + "baking_yeast": "Bage gær", + "balsamic_vinegar": "Balsamicoeddike", + "bananas": "Bananer", + "basil": "Basil", + "basmati_rice": "Basmati-ris", + "bathroom_cleaner": "Rengøringsmiddel til badeværelset", + "batteries": "Batterier", + "bay_leaf": "Laurbærblad", + "beans": "Bønner", + "beer": "Øl", + "beet": "Rødbeder", + "beetroot": "Rødbeder", + "birthday_card": "Fødselsdagskort", + "black_beans": "Sorte bønner", + "bockwurst": "Bockwurst", + "bodywash": "Bodywash", + "bread": "Brød", + "breadcrumbs": "Brødkrummer", + "broccoli": "Broccoli", + "brown_sugar": "Brunt sukker", + "brussels_sprouts": "rosenkål", + "buffalo_mozzarella": "Buffalo-mozzarella", + "buko": "Buko", + "buns": "Boller", + "burger_buns": "Burgerboller", + "burger_patties": "Burgerpatties", + "burger_sauces": "Burger saucer", + "butter": "Smør", + "butter_cookies": "Smørkager", + "button_cells": "Knapceller", + "börek_cheese": "Börek-ost", + "cake": "Kage", + "cake_icing": "Kage glasur", + "cane_sugar": "Rørsukker", + "cannelloni": "Cannelloni", + "canola_oil": "Rapsolie", + "cardamom": "Kardemomme", + "carrots": "Gulerødder", + "cashews": "Cashewnødder", + "cat_treats": "Kattegodbidder", + "cauliflower": "Blomkål", + "celeriac": "Knoldselleri", + "celery": "Selleri", + "cereal_bar": "Müslibar", + "cheddar": "Cheddar", + "cheese": "Ost", + "cherry_tomatoes": "Cherrytomater", + "chickpeas": "Kikærter", + "chili_oil": "Chiliolie", + "chips": "Chips", + "chives": "Purløg", + "chocolate": "Chokolade", + "chocolate_chips": "Chokoladestykker", + "chopped_tomatoes": "Hakkede tomater", + "ciabatta": "Ciabatta", + "cider_vinegar": "Æblecidereddike", + "cilantro": "Cilantro", + "cinnamon": "Kanel", + "cinnamon_stick": "Kanelstang", + "cocktail_sauce": "Cocktailsauce", + "cocktail_tomatoes": "Cocktail-tomater", + "coconut_flakes": "Kokosnøddeflager", + "coconut_milk": "Kokosmælk", + "coconut_oil": "Kokosolie", + "colorful_sprinkles": "Farverige drys", + "concealer": "Concealer", + "cookies": "Cookies", + "coriander": "Koriander", + "corn": "Majs", + "cornflakes": "Cornflakes", + "cornstarch": "Majsstivelse", + "cornys": "Cornys", + "cough_drops": "Hostedråber", + "couscous": "Couscous", + "covid_rapid_test": "COVID-sneltest", + "cow's_milk": "Komælk", + "cream": "Creme", + "cream_cheese": "Flødeost", + "creamed_spinach": "Cremet spinat", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Crepe tape", + "crispbread": "Knækbrød", + "cucumber": "Agurk", + "cumin": "Spidskommen", + "curry_paste": "Karrypasta", + "curry_powder": "Karrypulver", + "curry_sauce": "Karrysauce", + "dates": "Datoer", + "dental_floss": "Tandtråd", + "deodorant": "Deodorant", + "detergent": "Vaskemiddel", + "dill": "Dild", + "dishwasher_salt": "Salt til opvaskemaskine", + "dishwasher_tabs": "Tabs til opvaskemaskine", + "disinfection_spray": "Desinfektionsspray", + "dried_tomatoes": "Tørrede tomater", + "edamame": "Edamame", + "eggplant": "Aubergine", + "eggs": "Æg", + "falafel": "Falafel", + "falafel_powder": "Falafel-pulver", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fiskestænger", + "flour": "Mel", + "flushing": "Skylning", + "fresh_chili_pepper": "Frisk chilipeber", + "frozen_berries": "Frosne bær", + "frozen_fruit": "Frossen frugt", + "frozen_pizza": "Frossen pizza", + "frozen_spinach": "Frossen spinat", + "garam_masala": "Garam Masala", + "garbage_bag": "Affaldspose", + "garlic": "Hvidløg", + "garlic_dip": "Hvidløgsdip", + "garlic_granules": "Hvidløg i granulatform", + "gherkins": "Agurker", + "ginger": "Ingefær", + "glass_noodles": "Glasnudler", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Druer", + "greek_yogurt": "Græsk yoghurt", + "green_asparagus": "Grønne asparges", + "green_chili": "Grøn chili", + "green_pesto": "Grøn pesto", + "hair_gel": "Hårgel", + "hair_wax": "Hårvoks", + "handkerchief_box": "Lommetørklæde boks", + "handkerchiefs": "Lommetørklæder", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hasselnødder", + "head_of_lettuce": "Hoved af salat", + "herb_baguettes": "Baguettes med urter", + "herb_cream_cheese": "Krydderurter flødeost", + "honey": "Honning", + "honey_wafers": "Honningvafler", + "hot_dog_bun": "Hotdog-bolle", + "ice_cream": "Is", + "ice_cube": "Isterning", + "iceberg_lettuce": "Iceberg-salat", + "iced_tea": "Iste", + "instant_soups": "Instant-supper", + "jam": "Syltetøj", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidneybønner", + "kitchen_roll": "Køkkenrulle", + "kitchen_towels": "Køkkenhåndklæder", + "kohlrabi": "Kålrabi", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagne-nudler", + "lasagna_plates": "Lasagneplader", + "leaf_spinach": "Bladspinat", + "leek": "Porre", + "lemon": "Citron", + "lemon_juice": "Citronsaft", + "lemonade": "Lemonade", + "lemongrass": "Citrongræs", + "lentils": "Linser", + "lentils_red": "Røde linser", + "lettuce": "Salat", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "low-fat_curd_cheese": "Ostemasse med lavt fedtindhold", + "magnesium": "Magnesium", + "mango": "Mango", + "margarine": "Margarine", + "marjoram": "Merian", + "marshmallows": "Marshmallows", + "mask": "Maske", + "mayonnaise": "Mayonnaise", + "meat_substitute_product": "Køderstatningsprodukt", + "microfiber_cloth": "Mikrofiberklud", + "milk": "Mælk", + "mint": "Mynte", + "mint_candy": "Mint slik", + "mixed_vegetables": "Blandede grøntsager", + "mochis": "Mochis", + "mountain_cheese": "Ost fra bjergene", + "mouth_wash": "Mundskylning", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müsli bar", + "mulled_wine": "Gløgg", + "mushrooms": "Svampe", + "mustard": "Sennep", + "neutral_oil": "Neutral olie", + "nori_sheets": "Nori-ark", + "nutmeg": "Muskatnød", + "oat_milk": "Havredrik", + "oatmeal": "Havregryn", + "oatmeal_cookies": "Havregrynskager", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "olive_oil": "Olivenolie", + "olives": "Oliven", + "onion": "Løg", + "orange_juice": "Appelsinjuice", + "oranges": "Appelsiner", + "oregano": "Oregano", + "organic_lemon": "Økologisk citron", + "organic_waste_bags": "Poser til organisk affald", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Pardina-linser, tørrede", + "parmesan": "Parmesan", + "parsley": "Persille", + "pasta": "Pasta", + "peach": "Fersken", + "peanut_butter": "Jordnøddesmør", + "peanut_flips": "Peanut Flips", + "peanut_oil": "Jordnøddeolie", + "peanuts": "Jordnødder", + "pears": "Pærer", + "peas": "Ærter", + "penne": "Penne", + "pepper": "Peber", + "pepper_mill": "Peberkværn", + "peppers": "Peberfrugter", + "persian_rice": "Persiske ris", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinjekerner", + "pineapple": "Ananas", + "pita_bag": "Pita-pose", + "pizza": "Pizza", + "pizza_dough": "Pizzadej", + "plant_magarine": "Plante Magarine", + "plant_oil": "Planteolie", + "plaster": "Gips", + "porcini_mushrooms": "Porcini-svampe", + "potato_dumpling_dough": "Dej til kartoffelboller", + "potato_wedges": "Kartoffelkiler", + "potatoes": "Kartofler", + "potting_soil": "Pottemuld", + "powder": "Pulver", + "powdered_sugar": "Pulveriseret sukker", + "processed_cheese": "Smelteost", + "prosecco": "Prosecco", + "puff_pastry": "Butterdej", + "pumpkin": "Græskar", + "pumpkin_seeds": "Græskarkerner", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Radise", + "ramen": "Ramen", + "rapeseed_oil": "Rapsolie", + "raspberries": "Hindbær", + "raspberry_syrup": "Hindbærsirup", + "red_bull": "Red Bull", + "red_chili": "Rød chili", + "red_lentils": "Røde linser", + "red_onions": "Røde løg", + "red_pesto": "Rød pesto", + "red_wine": "Rødvin", + "red_wine_vinegar": "Rødvinseddike", + "rhubarb": "Rabarber", + "ribbon_noodles": "Nudler med bånd", + "rice": "Ris", + "rice_cakes": "Ristkager", + "rice_ribbon_noodles": "Risbåndsnudler", + "rice_vinegar": "Rice eddike", + "ricotta": "Ricotta", + "rinse_tabs": "Tabs til skylning", + "rinsing_agent": "Skyllemiddel", + "risotto_rice": "Risottoris", + "rocket": "Raket", + "roll": "Rulle", + "rosemary": "Rosemary", + "saffron_threads": "Safrantråde", + "sage": "Sage", + "saitan_powder": "Saitan-pulver", + "salad_mix": "Salatblanding", + "salad_seeds_mix": "Salatfrø mix", + "salt": "Salt", + "salt_mill": "Saltmølle", + "sambal_oelek": "Sambal oelek", + "sauce": "Sauce", + "sausage": "Pølse", + "sausages": "Pølser", + "savoy_cabbage": "Savoy-kål", + "scallion": "Skalotteløg", + "scattered_cheese": "Spredt ost", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Gryngrød af semulje", + "sesame": "Sesam", + "sesame_oil": "Sesamolie", + "shallot": "Skalotteløg", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarma krydderi", + "shiitake_mushroom": "Shiitake-svamp", + "shoe_insoles": "Indlægssåler til sko", + "shower_gel": "Brusegel", + "shredded_cheese": "Revet ost", + "sieved_tomatoes": "Tomater, sigtet", + "sliced_cheese": "Skiveskåret ost", + "smoked_paprika": "Røget paprika", + "smoked_tofu": "Røget tofu", + "snacks": "Snacks", + "soap": "Sæbe", + "soft_drinks": "Sodavand", + "softdrinks": "Sodavand", + "sour_cream": "Creme fraiche", + "sour_cucumbers": "Sure agurker", + "soy_hack": "Soja hack", + "soy_sauce": "Sojasovs", + "soy_shred": "Soja strimler", + "spaetzle": "Spätzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Mousserende vand", + "spelt": "Spelt", + "spinach": "Spinat", + "sponge_cloth": "Svampeklud", + "sponge_wipes": "Svampeservietter", + "sponges": "Svampe", + "spreading_cream": "Smørcreme", + "spring_onions": "Forårsløg", + "sprite": "Sprite", + "sprouts": "Spirer", + "sriracha": "Sriracha", + "strained_tomatoes": "Sigtede tomater", + "sugar": "Sukker", + "summer_roll_paper": "Sommerrullepapir", + "sunflower_seeds": "Solsikkefrø", + "sushi_rice": "Sushiris", + "swabian_ravioli": "Svabisk ravioli", + "sweet_potato": "Sød kartoffel", + "sweet_potatoes": "Søde kartofler", + "table_salt": "Bordsalt", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandariner", + "tape": "Bånd", + "tea": "Te", + "teriyaki_sauce": "Teriyaki-sauce", + "thyme": "Timian", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Toiletpapir", + "tomato_juice": "Tomatsaft", + "tomato_paste": "Tomatpasta", + "tomato_sauce": "Tomatsauce", + "tomatoes": "Tomater", + "tonic_water": "Tonicvand", + "toothpaste": "Tandpasta", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla-chips", + "tuna": "Tun", + "turmeric": "Gurkemeje", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon-nudler", + "uht_milk": "UHT-mælk", + "vanilla_sugar": "Vaniljesukker", + "vegetable_bouillon_cube": "Terning af grøntsagsbouillon", + "vegetable_broth": "Grøntsagsbouillon", + "vegetable_oil": "Vegetabilsk olie", + "vegetable_onion": "Vegetabilske løg", + "vegetables": "Grøntsager", + "vegetarian_cold_cuts": "vegetarisk pålæg", + "vinegar": "Eddike", + "vodka": "Vodka", + "washing_powder": "Vaskepulver", + "water": "Vand", + "water_ice": "Vandis", + "watermelon": "Vandmelon", + "wc_cleaner": "WC-rengøringsmiddel", + "whipped_cream": "Flødeskum", + "white_wine": "Hvidvin", + "white_wine_vinegar": "Hvidvinseddike", + "whole_canned_tomatoes": "Hele tomater på dåse", + "wild_berries": "Vilde bær", + "wrapping_paper": "Indpakningspapir", + "wraps": "Indpakninger", + "yeast": "Gær", + "yoghurt": "Yoghurt", + "yogurt": "Yoghurt", + "yum_yum": "Yum Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Zinkcreme", + "zucchini": "Zucchini" + } +} diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json index cbb1da6e..3601f9c0 100644 --- a/backend/templates/l10n/fr.json +++ b/backend/templates/l10n/fr.json @@ -14,7 +14,7 @@ "items": { "aioli": "Aïoli", "amaretto": "Amaretto", - "apple": "Apple", + "apple": "Pomme", "apple_pulp": "Pulpe de pomme", "applesauce": "Compote de pommes", "apérol": "Apérol", @@ -73,8 +73,8 @@ "cashews": "Noix de cajou", "cat_treats": "Friandises pour chats", "cauliflower": "Chou-fleur", - "celeriac": "Céleri-rave", - "celery": "Céleri", + "celeriac": "Cèleri-rave", + "celery": "Cèleri", "cereal_bar": "Barre de céréales", "cheddar": "Cheddar", "cheese": "Fromage", @@ -84,6 +84,7 @@ "chips": "Chips", "chives": "Ciboulette", "chocolate": "Chocolat", + "chocolate_chips": "Pépites de chocolat", "chopped_tomatoes": "Tomates coupées en morceaux", "ciabatta": "Ciabatta", "cider_vinegar": "Vinaigre de cidre", @@ -324,7 +325,6 @@ "scattered_cheese": "Fromage éparpillé", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "chocolate_chips": "Pépites de chocolat", "semolina_porridge": "Porridge de semoule", "sesame": "Sésame", "sesame_oil": "Huile de sésame", @@ -415,7 +415,7 @@ "wrapping_paper": "Papier d'emballage", "wraps": "Wraps", "yeast": "Levure", - "yoghurt": "Yoghourt", + "yoghurt": "Yaourt", "yogurt": "Yogourt", "yum_yum": "Miam miam", "zewa": "Zewa", diff --git a/backend/templates/l10n/id.json b/backend/templates/l10n/id.json index 5ce7a9ec..53dd1e2e 100644 --- a/backend/templates/l10n/id.json +++ b/backend/templates/l10n/id.json @@ -14,7 +14,7 @@ "items": { "aioli": "Aioli", "amaretto": "Amaretto", - "apple": "Apple", + "apple": "Apel", "apple_pulp": "Bubur apel", "applesauce": "Saus apel", "apérol": "Apérol", @@ -27,7 +27,7 @@ "bacon": "Bacon", "baguette": "Baguette", "bakefish": "Bakefish", - "baking_cocoa": "Memanggang kakao", + "baking_cocoa": "Kakao panggang", "baking_mix": "Campuran kue", "baking_paper": "Kertas roti", "baking_powder": "Bubuk pengembang", @@ -47,7 +47,7 @@ "birthday_card": "Kartu ulang tahun", "black_beans": "Kacang hitam", "bockwurst": "Bockwurst", - "bodywash": "Cuci tubuh", + "bodywash": "Sabun mandi", "bread": "Roti", "breadcrumbs": "Remah roti", "broccoli": "Brokoli", @@ -84,6 +84,7 @@ "chips": "Keripik", "chives": "Daun bawang", "chocolate": "Cokelat", + "chocolate_chips": "Keripik cokelat", "chopped_tomatoes": "Tomat cincang", "ciabatta": "Ciabatta", "cider_vinegar": "Cuka sari apel", @@ -324,7 +325,6 @@ "scattered_cheese": "Keju yang tersebar", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "chocolate_chips": "Keripik cokelat", "semolina_porridge": "Bubur semolina", "sesame": "Wijen", "sesame_oil": "Minyak wijen", diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json index e655a0b5..ddad5d3a 100644 --- a/backend/templates/l10n/pt.json +++ b/backend/templates/l10n/pt.json @@ -18,10 +18,258 @@ "apple_pulp": "Polpa de maçã", "applesauce": "Maçã", "apérol": "Apérol", + "arugula": "Rúcula", + "asian_egg_noodles": "Macarrão asiático", "asparagus": "Espargos", "aspirin": "Aspirina", "avocado": "Abacate", + "baby_spinach": "Baby espinafre", "bacon": "Toucinho", - "baguette": "Baguete" + "baguette": "Baguete", + "bakefish": "Peixe assado", + "baking_cocoa": "Cacau em pó", + "baking_mix": "Massa", + "baking_paper": "Papel manteiga", + "baking_powder": "Fermento em pó", + "baking_soda": "Bicarbonato de sódio", + "baking_yeast": "Fermento para bolos", + "balsamic_vinegar": "Vinagre Balsâmico", + "bananas": "Bananas", + "basil": "Manjericão", + "basmati_rice": "Arroz Basmati", + "bathroom_cleaner": "Limpa casa de banho", + "batteries": "Pilhas", + "bay_leaf": "Louro", + "beans": "Feijão", + "beer": "Cerveja", + "beet": "Beterraba", + "beetroot": "Beterraba Vermelha", + "birthday_card": "Cartão de Aniversário", + "black_beans": "Feijão Preto", + "bockwurst": "Salsichão", + "bodywash": "Gel de Banho", + "bread": "Pão", + "breadcrumbs": "Pão ralado", + "broccoli": "Brócolos", + "brown_sugar": "Açúcar Amarelo", + "brussels_sprouts": "Couve de Bruxelas", + "buffalo_mozzarella": "Mozzarella Bufalina", + "buko": "Coco", + "buns": "Pãezinhos", + "burger_buns": "Pão de Hambúrguer", + "burger_patties": "Carne de Hambúrguer", + "burger_sauces": "Molhos de Hambúrguer", + "butter": "Manteiga", + "butter_cookies": "Bolachas de Manteiga", + "button_cells": "Pilhas de relógio", + "börek_cheese": "Queijo Börek", + "cake": "Bolo", + "cane_sugar": "Açúcar de Cana", + "cannelloni": "Canelones", + "canola_oil": "Óleo de Canola", + "cardamom": "Cardamomo", + "carrots": "Cenouras", + "cashews": "Cajus", + "cat_treats": "Guloseimas para gato", + "cauliflower": "Couve-flor", + "celeriac": "Aipo-rábano", + "celery": "Aipo", + "cereal_bar": "Barra Cereais", + "cheddar": "Cheddar", + "cheese": "Queijo", + "cherry_tomatoes": "Tomates cherry", + "chickpeas": "Grão de bico", + "chili_oil": "Molho Chili", + "chips": "Batata frita de pacote", + "chives": "Cebolinho", + "chocolate": "Chocolate", + "chopped_tomatoes": "Tomates picados", + "ciabatta": "Pão chapata", + "cider_vinegar": "Vinagre de Cidra", + "cilantro": "Coentros", + "cinnamon": "Canela", + "cinnamon_stick": "Pau de canela", + "cocktail_sauce": "Molho de coquetel", + "coconut_flakes": "Flocos de coco", + "coconut_milk": "Leite de coco", + "coconut_oil": "Óleo de coco", + "colorful_sprinkles": "Pepitas Multicores", + "concealer": "Corretor de olheiras", + "cookies": "Bolachas", + "corn": "Milho", + "cornflakes": "Cornflakes", + "cornstarch": "Amido de milho", + "couscous": "Couscous", + "covid_rapid_test": "Teste rápido COVID", + "cow's_milk": "Leite de vaca", + "cream": "Natas", + "cream_cheese": "Queijo creme", + "creamed_spinach": "Esparregado", + "cucumber": "Pepino", + "cumin": "Cominhos", + "curry_paste": "Massa de caril", + "curry_powder": "Caril", + "curry_sauce": "Molho de caril", + "dates": "Tâmaras", + "dental_floss": "Fio dental", + "deodorant": "Desodorizante", + "detergent": "Detergente", + "dill": "Endro", + "dishwasher_salt": "Sal para máquina da loiça", + "dishwasher_tabs": "Pastilhas para máquina da loiça", + "disinfection_spray": "Spray desinfetante", + "eggplant": "Beringela", + "eggs": "Ovos", + "falafel": "Falafel", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Douradinhos", + "flour": "Farinha", + "frozen_fruit": "Frutas congeladas", + "frozen_pizza": "Pizza congelada", + "frozen_spinach": "Espinafres congelados", + "garbage_bag": "Saco do lixo", + "garlic": "Alho", + "garlic_dip": "Molho de alho", + "ginger": "Gengibre", + "gnocchi": "Gnocchi", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Uvas", + "greek_yogurt": "Iogurte grego", + "green_asparagus": "Espargos", + "green_chili": "Pimenta verde", + "green_pesto": "Pesto verde", + "hair_gel": "Gel para cabelo", + "hair_wax": "Cera para pelos", + "haribo": "Haribo", + "hazelnuts": "Avelãs", + "honey": "Mel", + "ice_cream": "Gelado", + "ice_cube": "Cubos de gelo", + "iceberg_lettuce": "Alface iceberg", + "iced_tea": "Ice tea", + "instant_soups": "Sopa instantânea", + "jam": "Geleia", + "ketchup": "Ketchup", + "kitchen_towels": "Pano de cozinha", + "lasagna": "Lasanha", + "lasagna_plates": "Placas para lasanha", + "leek": "Alho francês", + "lemon": "Limão", + "lemon_juice": "Sumo de limão", + "lemonade": "Limonada", + "lemongrass": "Erva Príncipe", + "lentils": "Lentilhas", + "lentils_red": "Lentilhas vermelhas", + "lettuce": "Alface", + "lime": "Lima", + "linguine": "Linguine", + "magnesium": "Magnésio", + "mango": "Manga", + "margarine": "Margarina", + "marshmallows": "Marshmallows", + "mayonnaise": "Maionese", + "microfiber_cloth": "Pano microfibras", + "milk": "Leite", + "mint": "Menta", + "mouth_wash": "Elixir bocal", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Barra de Muesli", + "mushrooms": "Cogumelos", + "mustard": "Mostarda", + "nori_sheets": "Folhas Nori", + "olive_oil": "Azeite", + "olives": "Azeitonas", + "onion": "Cebola", + "orange_juice": "Sumo de laranja", + "oranges": "Laranjas", + "oregano": "Orégãos", + "pak_choi": "Pak Choi", + "paprika": "Paprica", + "parmesan": "Parmesão", + "parsley": "Salsa", + "peach": "Pêssego", + "peanut_butter": "Manteiga de amendoim", + "peanuts": "Amendoins", + "pears": "Peras", + "peas": "Ervilhas", + "pepper": "Pimenta", + "pesto": "Pesto", + "pineapple": "Ananás", + "pizza": "Pizza", + "rice_vinegar": "Vinagre de arroz", + "ricotta": "Ricotta", + "risotto_rice": "Arroz risotto", + "salt": "Sal", + "salt_mill": "Moinho de sal", + "sauce": "Molho", + "sausage": "Salsicha", + "sausages": "Salsichas", + "savoy_cabbage": "Couve-lombarda", + "sesame": "Sésamo", + "shallot": "Chalota", + "shampoo": "Champô", + "shredded_cheese": "Queijo ralado", + "sliced_cheese": "Queijo fatiado", + "smoked_tofu": "Tofu fumado", + "snacks": "Snacks", + "soap": "Sabonete", + "soft_drinks": "Refrigerantes", + "softdrinks": "Refrigerantes", + "soy_sauce": "Molho de soja", + "spaetzle": "Spaetzle", + "spaghetti": "Esparguete", + "sparkling_water": "Água com gás", + "spelt": "Espelta", + "spinach": "Espinafres", + "sponges": "Esponjas", + "sprite": "Sprite", + "sugar": "Açúcar", + "sunflower_seeds": "Sementes de girassol", + "sushi_rice": "Arroz de sushi", + "sweet_potato": "Batata doce", + "sweet_potatoes": "Batatas doce", + "table_salt": "Sal de mesa", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Tangerinas", + "tape": "Fita", + "tea": "Chá", + "teriyaki_sauce": "Molho Teriyaki", + "thyme": "Tomilho", + "toast": "Tostas", + "tofu": "Tofu", + "toilet_paper": "Papel Higiénico", + "tomato_paste": "Polpa de tomate", + "tomatoes": "Tomate", + "tonic_water": "Água tónica", + "toothpaste": "Pasta de dentes", + "tortellini": "Tortellini", + "tuna": "Atum", + "tzatziki": "Tzatziki", + "uht_milk": "Leite UHT", + "vanilla_sugar": "Açúcar baunilhado", + "vegetable_oil": "Óleo vegetal", + "vegetables": "Legumes", + "vinegar": "Vinagre", + "vodka": "Vodka", + "water": "Água", + "watermelon": "Melancia", + "wc_cleaner": "Detergente de casa de banho", + "whipped_cream": "Nata batida", + "white_wine": "Vinho branco", + "white_wine_vinegar": "Vinagre de vinho branco", + "whole_canned_tomatoes": "Tomates inteiros enlatados", + "wild_berries": "Frutos silvestres", + "wrapping_paper": "Papel de embrulho", + "yeast": "Fermento", + "yoghurt": "Iogurte", + "yogurt": "Iogurte", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zucchini": "Courgette" } } From 87db9b871ea1757e7b63f3ddf04a08d00e324b9f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 6 Apr 2023 00:42:58 +0200 Subject: [PATCH 271/496] feat: Multiple households (TomBursch/kitchenowl-backend#25) --- backend/app/api/register_controller.py | 44 ++- backend/app/controller/__init__.py | 1 + .../app/controller/auth/auth_controller.py | 25 +- backend/app/controller/category/__init__.py | 2 +- .../category/category_controller.py | 38 ++- backend/app/controller/expense/__init__.py | 2 +- .../controller/expense/expense_controller.py | 140 ++++---- .../exportimport/export_controller.py | 18 +- .../exportimport/import_controller.py | 22 +- backend/app/controller/health_controller.py | 11 +- backend/app/controller/household/__init__.py | 1 + .../household/household_controller.py | 125 +++++++ backend/app/controller/household/schemas.py | 34 ++ backend/app/controller/item/__init__.py | 2 +- .../app/controller/item/item_controller.py | 42 ++- .../onboarding/onboarding_controller.py | 14 +- backend/app/controller/onboarding/schemas.py | 5 - backend/app/controller/planner/__init__.py | 2 +- .../controller/planner/planner_controller.py | 68 ++-- backend/app/controller/recipe/__init__.py | 2 +- .../controller/recipe/recipe_controller.py | 63 ++-- backend/app/controller/settings/schemas.py | 4 +- .../settings/settings_controller.py | 13 +- .../app/controller/shoppinglist/__init__.py | 2 +- .../shoppinglist/shoppinglist_controller.py | 76 +++-- backend/app/controller/tag/__init__.py | 2 +- backend/app/controller/tag/schemas.py | 7 - backend/app/controller/tag/tag_controller.py | 46 +-- backend/app/controller/user/schemas.py | 8 + .../app/controller/user/user_controller.py | 48 ++- backend/app/errors/__init__.py | 7 +- backend/app/helpers/__init__.py | 4 +- backend/app/helpers/admin_required.py | 31 -- backend/app/helpers/authorize_household.py | 41 +++ .../app/helpers/db_model_authorize_mixin.py | 20 ++ backend/app/helpers/db_model_mixin.py | 107 +----- backend/app/helpers/server_admin_required.py | 19 ++ backend/app/models/__init__.py | 1 + backend/app/models/category.py | 23 +- backend/app/models/expense.py | 6 +- backend/app/models/expense_category.py | 15 +- backend/app/models/history.py | 1 + backend/app/models/household.py | 90 +++++ backend/app/models/item.py | 35 +- backend/app/models/planner.py | 11 +- backend/app/models/recipe.py | 29 +- backend/app/models/recipe_history.py | 32 +- backend/app/models/settings.py | 6 +- backend/app/models/shoppinglist.py | 20 +- backend/app/models/tag.py | 18 +- backend/app/models/user.py | 25 +- backend/app/service/export_import.py | 43 ++- backend/manage.py | 6 +- backend/migrations/versions/4b4823a384e7_.py | 24 +- backend/migrations/versions/6c669d9ec3bd_.py | 317 ++++++++++++++++++ backend/upgrade_default_items.py | 11 +- 56 files changed, 1247 insertions(+), 562 deletions(-) create mode 100644 backend/app/controller/household/__init__.py create mode 100644 backend/app/controller/household/household_controller.py create mode 100644 backend/app/controller/household/schemas.py delete mode 100644 backend/app/helpers/admin_required.py create mode 100644 backend/app/helpers/authorize_household.py create mode 100644 backend/app/helpers/db_model_authorize_mixin.py create mode 100644 backend/app/helpers/server_admin_required.py create mode 100644 backend/app/models/household.py create mode 100644 backend/migrations/versions/6c669d9ec3bd_.py diff --git a/backend/app/api/register_controller.py b/backend/app/api/register_controller.py index 5d177dc9..6f073c0c 100644 --- a/backend/app/api/register_controller.py +++ b/backend/app/api/register_controller.py @@ -1,20 +1,32 @@ +from flask import Blueprint from app.config import app import app.controller as api # Register Endpoints -app.register_blueprint( - api.health, url_prefix='/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V') -app.register_blueprint(api.auth, url_prefix='/api/auth') -app.register_blueprint(api.category, url_prefix='/api/category') -app.register_blueprint(api.expense, url_prefix='/api/expense') -app.register_blueprint(api.export, url_prefix='/api/export') -app.register_blueprint(api.importBP, url_prefix='/api/import') -app.register_blueprint(api.item, url_prefix='/api/item') -app.register_blueprint(api.onboarding, url_prefix='/api/onboarding') -app.register_blueprint(api.planner, url_prefix='/api/planner') -app.register_blueprint(api.recipe, url_prefix='/api/recipe') -app.register_blueprint(api.settings, url_prefix='/api/settings') -app.register_blueprint(api.shoppinglist, url_prefix='/api/shoppinglist') -app.register_blueprint(api.tag, url_prefix='/api/tag') -app.register_blueprint(api.user, url_prefix='/api/user') -app.register_blueprint(api.upload, url_prefix='/api/upload') +apiv1 = Blueprint('api', __name__) + +api.household.register_blueprint(api.export, url_prefix='//export') +api.household.register_blueprint(api.importBP, url_prefix='//import') +api.household.register_blueprint(api.categoryHousehold, url_prefix='//category') +api.household.register_blueprint(api.plannerHousehold, url_prefix='//planner') +api.household.register_blueprint(api.expenseHousehold, url_prefix='//expense') +api.household.register_blueprint(api.itemHousehold, url_prefix='//item') +api.household.register_blueprint(api.recipeHousehold, url_prefix='//recipe') +api.household.register_blueprint(api.shoppinglistHousehold, url_prefix='//shoppinglist') +api.household.register_blueprint(api.tagHousehold, url_prefix='//tag') + +apiv1.register_blueprint(api.health, url_prefix='/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V') +apiv1.register_blueprint(api.auth, url_prefix='/auth') +apiv1.register_blueprint(api.household, url_prefix='/household') +apiv1.register_blueprint(api.category, url_prefix='/category') +apiv1.register_blueprint(api.expense, url_prefix='/expense') +apiv1.register_blueprint(api.item, url_prefix='/item') +apiv1.register_blueprint(api.onboarding, url_prefix='/onboarding') +apiv1.register_blueprint(api.recipe, url_prefix='/recipe') +apiv1.register_blueprint(api.settings, url_prefix='/settings') +apiv1.register_blueprint(api.shoppinglist, url_prefix='/shoppinglist') +apiv1.register_blueprint(api.tag, url_prefix='/tag') +apiv1.register_blueprint(api.user, url_prefix='/user') +apiv1.register_blueprint(api.upload, url_prefix='/upload') + +app.register_blueprint(apiv1, url_prefix='/api') diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 896ea023..71cbe9ca 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -10,5 +10,6 @@ from .expense import * from .tag import * from .upload import * +from .household import * from .category import * from .health_controller import health diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 51b0962e..2d53083f 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -1,7 +1,7 @@ from datetime import datetime from app.helpers import validate_args from flask import jsonify, Blueprint, request -from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt +from flask_jwt_extended import current_user, jwt_required, get_jwt from app.models import User, Token from app.errors import UnauthorizedRequest from .schemas import Login, CreateLongLivedToken @@ -26,7 +26,7 @@ def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool: # identity when creating JWTs and converts it to a JSON serializable format. @jwt.user_identity_loader def user_identity_lookup(user: User): - return user.username + return user.id # Register a callback function that loads a user from your database whenever @@ -36,7 +36,7 @@ def user_identity_lookup(user: User): @jwt.user_lookup_loader def user_lookup_callback(_jwt_header, jwt_data) -> User: identity = jwt_data["sub"] - return User.find_by_username(identity) + return User.find_by_id(identity) @auth.route('', methods=['POST']) @@ -62,22 +62,11 @@ def login(args): 'refresh_token': refreshToken }) -# Not in use as we are using the refresh token pattern -# @auth.route('/fresh-login', methods=['POST']) -# @validate_args(Login) -# def fresh_login(args): -# username = args['username'].lower() -# user = User.find_by_username(username.lower()) -# if not user or not user.check_password(args['password']): -# raise UnauthorizedRequest(message='Unauthorized') -# ret = {'access_token': create_access_token(identity=username, fresh=True)} -# return jsonify(ret), 200 - @auth.route('/refresh', methods=['GET']) @jwt_required(refresh=True) def refresh(): - user = User.find_by_username(get_jwt_identity()) + user = current_user if not user: raise UnauthorizedRequest( message='Unauthorized: IP {} refresh attemp with wrong username or password'.format(request.remote_addr)) @@ -117,7 +106,7 @@ def logout(): @jwt_required() @validate_args(CreateLongLivedToken) def createLongLivedToken(args): - user = User.find_by_username(get_jwt_identity()) + user = current_user if not user: raise UnauthorizedRequest( message='Unauthorized: IP {}'.format(request.remote_addr)) @@ -129,10 +118,10 @@ def createLongLivedToken(args): }) -@auth.route('llt/', methods=['DELETE']) +@auth.route('llt/', methods=['DELETE']) @jwt_required() def deleteLongLivedToken(id): - user = User.find_by_username(get_jwt_identity()) + user = current_user if not user: raise UnauthorizedRequest( message='Unauthorized: IP {}'.format(request.remote_addr)) diff --git a/backend/app/controller/category/__init__.py b/backend/app/controller/category/__init__.py index 76734239..0e723fcb 100644 --- a/backend/app/controller/category/__init__.py +++ b/backend/app/controller/category/__init__.py @@ -1 +1 @@ -from .category_controller import category +from .category_controller import category, categoryHousehold diff --git a/backend/app/controller/category/category_controller.py b/backend/app/controller/category/category_controller.py index 0541f7c3..4c641c19 100644 --- a/backend/app/controller/category/category_controller.py +++ b/backend/app/controller/category/category_controller.py @@ -1,4 +1,4 @@ -from app.helpers import validate_args +from app.helpers import validate_args, authorize_household from flask import jsonify, Blueprint from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required @@ -6,40 +6,46 @@ from .schemas import AddCategory, DeleteCategory, UpdateCategory category = Blueprint('category', __name__) +categoryHousehold = Blueprint('category', __name__) -@category.route('', methods=['GET']) +@categoryHousehold.route('', methods=['GET']) @jwt_required() -def getAllCategories(): - return jsonify([e.obj_to_dict() for e in Category.all_by_ordering()]) +@authorize_household() +def getAllCategories(household_id): + return jsonify([e.obj_to_dict() for e in Category.all_by_ordering(household_id)]) -@category.route('/', methods=['GET']) +@category.route('/', methods=['GET']) @jwt_required() def getCategory(id): category = Category.find_by_id(id) if not category: raise NotFoundRequest() + category.checkAuthorized() return jsonify(category.obj_to_dict()) -@category.route('', methods=['POST']) +@categoryHousehold.route('', methods=['POST']) @jwt_required() +@authorize_household() @validate_args(AddCategory) -def addCategory(args): +def addCategory(args, household_id): category = Category() category.name = args['name'] + category.household_id = household_id category.save() return jsonify(category.obj_to_dict()) -@category.route('/', methods=['POST']) +@category.route('/', methods=['POST', 'PATCH']) @jwt_required() @validate_args(UpdateCategory) def updateCategory(args, id): category = Category.find_by_id(id) if not category: raise NotFoundRequest() + category.checkAuthorized() if 'name' in args: category.name = args['name'] @@ -49,19 +55,25 @@ def updateCategory(args, id): return jsonify(category.obj_to_dict()) -@category.route('/', methods=['DELETE']) +@category.route('/', methods=['DELETE']) @jwt_required() def deleteCategoryById(id): - Category.delete_by_id(id) + category = Category.find_by_id(id) + if not category: + raise NotFoundRequest() + category.checkAuthorized() + + category.delete() return jsonify({'msg': 'DONE'}) -@category.route('', methods=['DELETE']) +@categoryHousehold.route('', methods=['DELETE']) @jwt_required() +@authorize_household() @validate_args(DeleteCategory) -def deleteExpenseCategoryById(args): +def deleteCategoryByName(args, household_id): if "name" in args: - category = Category.find_by_name(args['name']) + category = Category.find_by_name(args['name'], household_id) if category: category.delete() return jsonify({'msg': 'DONE'}) diff --git a/backend/app/controller/expense/__init__.py b/backend/app/controller/expense/__init__.py index 2727e921..c93aa969 100644 --- a/backend/app/controller/expense/__init__.py +++ b/backend/app/controller/expense/__init__.py @@ -1 +1 @@ -from .expense_controller import expense +from .expense_controller import expense, expenseHousehold diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 5715e80a..a33e2d97 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -7,18 +7,20 @@ from flask_jwt_extended import current_user, jwt_required from sqlalchemy import func from app import db -from app.helpers import validate_args, admin_required -from app.models import Expense, ExpensePaidFor, User, ExpenseCategory +from app.helpers import validate_args, authorize_household, RequiredRights +from app.models import Expense, ExpensePaidFor, ExpenseCategory, HouseholdMember from .schemas import GetExpenses, AddExpense, UpdateExpense, AddExpenseCategory, UpdateExpenseCategory, GetExpenseOverview expense = Blueprint('expense', __name__) +expenseHousehold = Blueprint('expense', __name__) -@expense.route('', methods=['GET']) +@expenseHousehold.route('', methods=['GET']) @jwt_required() +@authorize_household() @validate_args(GetExpenses) -def getAllExpenses(args): - filter = [] +def getAllExpenses(args, household_id): + filter = [Expense.household_id == household_id] if ('startAfterId' in args): filter.append(Expense.id < args['startAfterId']) @@ -33,25 +35,28 @@ def getAllExpenses(args): ]) -@expense.route('/', methods=['GET']) +@expense.route('/', methods=['GET']) @jwt_required() def getExpenseById(id): expense = Expense.find_by_id(id) if not expense: raise NotFoundRequest() + expense.checkAuthorized() return jsonify(expense.obj_to_full_dict()) -@expense.route('', methods=['POST']) +@expenseHousehold.route('', methods=['POST']) @jwt_required() +@authorize_household() @validate_args(AddExpense) -def addExpense(args): - user = User.find_by_id(args['paid_by']['id']) - if not user: +def addExpense(args, 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.household_id = household_id if 'date' in args: expense.date = datetime.fromtimestamp( args['date']/1000, timezone.utc) @@ -61,36 +66,38 @@ def addExpense(args): if args['category'] is not None: category = ExpenseCategory.find_by_id(args['category']) expense.category = category - expense.paid_by = user + expense.paid_by_id = member.user_id expense.save() - user.expense_balance = (user.expense_balance or 0) + expense.amount - user.save() + member.expense_balance = (member.expense_balance or 0) + expense.amount + member.save() factor_sum = 0 for user_data in args['paid_for']: - if User.find_by_id(user_data['id']): + if HouseholdMember.find_by_ids(household_id, user_data['id']): factor_sum += user_data['factor'] for user_data in args['paid_for']: - user_for = User.find_by_id(user_data['id']) - if user_for: + member_for = HouseholdMember.find_by_ids(household_id, user_data['id']) + if member_for: con = ExpensePaidFor( factor=user_data['factor'], ) - con.user = user_for + con.user_id = member_for.user_id con.expense = expense con.save() - user_for.expense_balance = ( - user_for.expense_balance or 0) - (con.factor / factor_sum) * expense.amount - user_for.save() + member_for.expense_balance = ( + member_for.expense_balance or 0) - (con.factor / factor_sum) * expense.amount + member_for.save() return jsonify(expense.obj_to_dict()) -@expense.route('/', methods=['POST']) +@expense.route('/', methods=['POST']) @jwt_required() @validate_args(UpdateExpense) def updateExpense(args, 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: @@ -107,9 +114,10 @@ def updateExpense(args, id): # noqa: C901 else: expense.category = None if 'paid_by' in args: - user = User.find_by_id(args['paid_by']['id']) - if user: - expense.paid_by = user + 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: for con in expense.paid_for: @@ -117,9 +125,10 @@ def updateExpense(args, id): # noqa: C901 if con.user.id not in user_ids: con.delete() for user_data in args['paid_for']: - user = User.find_by_id(user_data['id']) - if user: - con = ExpensePaidFor.find_by_ids(expense.id, user.id) + 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'] @@ -128,51 +137,60 @@ def updateExpense(args, id): # noqa: C901 factor=user_data['factor'], ) con.expense = expense - con.user = user + con.user_id = member.user_id con.save() - recalculateBalances() + recalculateBalances(expense.household_id) return jsonify(expense.obj_to_dict()) -@expense.route('/', methods=['DELETE']) +@expense.route('/', methods=['DELETE']) @jwt_required() def deleteExpenseById(id): - Expense.delete_by_id(id) - recalculateBalances() + expense = Expense.find_by_id(id) + if not expense: + raise NotFoundRequest() + expense.checkAuthorized() + + expense.delete() + recalculateBalances(expense.household_id) return jsonify({'msg': 'DONE'}) -@expense.route('/recalculate-balances') +@expenseHousehold.route('/recalculate-balances') @jwt_required() -@admin_required -def calculateBalances(): - recalculateBalances() +@authorize_household(required=RequiredRights.ADMIN) +def calculateBalances(household_id): + recalculateBalances(household_id) -def recalculateBalances(): - for user in User.all(): - user.expense_balance = float(Expense.query.with_entities(func.sum( - Expense.amount).label("balance")).filter(Expense.paid_by == user).first().balance or 0) - for expense in ExpensePaidFor.query.filter(ExpensePaidFor.user_id == user.id).all(): +def recalculateBalances(household_id): + for member in HouseholdMember.find_by_household(household_id): + member.expense_balance = float(Expense.query.with_entities(func.sum( + Expense.amount).label("balance")).filter(Expense.paid_by_id == member.user_id, Expense.household_id == household_id).first().balance or 0) + for paid_for in ExpensePaidFor.query.filter(ExpensePaidFor.user_id == member.user_id, ExpensePaidFor.expense_id.in_(db.session.query(Expense.id).filter( + Expense.household_id == household_id).scalar_subquery())).all(): factor_sum = Expense.query.with_entities(func.sum( ExpensePaidFor.factor).label("factor_sum"))\ - .filter(ExpensePaidFor.expense_id == expense.expense_id).first().factor_sum - user.expense_balance = user.expense_balance - \ - (expense.factor / factor_sum) * expense.expense.amount - user.save() + .filter(ExpensePaidFor.expense_id == paid_for.expense_id).first().factor_sum + member.expense_balance = member.expense_balance - \ + (paid_for.factor / factor_sum) * paid_for.expense.amount + member.save() -@expense.route('/categories', methods=['GET']) +@expenseHousehold.route('/categories', methods=['GET']) @jwt_required() -def getExpenseCategories(): - return jsonify([e.obj_to_dict() for e in ExpenseCategory.all_by_name()]) +@authorize_household() +def getExpenseCategories(household_id): + return jsonify([e.obj_to_dict() for e in ExpenseCategory.all_from_household_by_name(household_id)]) -@expense.route('/overview', methods=['GET']) +@expenseHousehold.route('/overview', methods=['GET']) @jwt_required() +@authorize_household() @validate_args(GetExpenseOverview) -def getExpenseOverview(args): - categories = list(map(lambda x: x.id, ExpenseCategory.all_by_name())) +def getExpenseOverview(args, household_id): + categories = list( + map(lambda x: x.id, ExpenseCategory.all_from_household_by_name(household_id))) categories.append(-1) thisMonthStart = datetime.utcnow().date().replace(day=1) @@ -180,6 +198,7 @@ def getExpenseOverview(args): factor = 1 query = Expense.query\ + .filter(Expense.household_id == household_id)\ .group_by(Expense.category_id)\ .join(Expense.category, isouter=True) @@ -217,33 +236,38 @@ def getOverviewForMonthAgo(monthAgo: int): return jsonify(byMonth) -@expense.route('/categories', methods=['POST']) +@expenseHousehold.route('/categories', methods=['POST']) @jwt_required() +@authorize_household() @validate_args(AddExpenseCategory) -def addExpenseCategory(args): +def addExpenseCategory(args, household_id): category = ExpenseCategory() category.name = args['name'] category.color = args['color'] + category.household_id = household_id category.save() return jsonify(category.obj_to_dict()) -@expense.route('/categories/', methods=['DELETE']) +@expense.route('/categories/', methods=['DELETE']) @jwt_required() -@admin_required def deleteExpenseCategoryById(id): - ExpenseCategory.delete_by_id(id) + category = ExpenseCategory.find_by_id(id) + if not category: + raise NotFoundRequest() + category.checkAuthorized() + category.delete() return jsonify({'msg': 'DONE'}) -@expense.route('/categories/', methods=['POST']) +@expense.route('/categories/', methods=['POST']) @jwt_required() @validate_args(UpdateExpenseCategory) def updateExpenseCategory(args, id): category = ExpenseCategory.find_by_id(id) - if not category: raise NotFoundRequest() + category.checkAuthorized() if 'name' in args: category.name = args['name'] diff --git a/backend/app/controller/exportimport/export_controller.py b/backend/app/controller/exportimport/export_controller.py index b20a5b7c..c6df7f6d 100644 --- a/backend/app/controller/exportimport/export_controller.py +++ b/backend/app/controller/exportimport/export_controller.py @@ -1,5 +1,6 @@ from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required +from app.helpers import authorize_household from app.models import Item, Recipe export = Blueprint('export', __name__) @@ -7,20 +8,23 @@ @export.route('', methods=['GET']) @jwt_required() -def getExportAll(): +@authorize_household() +def getExportAll(household_id): return jsonify({ - "items": [e.obj_to_export_dict() for e in Item.allByName()], - "recipes": [e.obj_to_export_dict() for e in Recipe.all()] + "items": [e.obj_to_export_dict() for e in Item.all_from_household_by_name(household_id)], + "recipes": [e.obj_to_export_dict() for e in Recipe.all_from_household_by_name(household_id)] }) @export.route('/items', methods=['GET']) @jwt_required() -def getExportItems(): - return jsonify({"items": [e.obj_to_export_dict() for e in Item.allByName()]}) +@authorize_household() +def getExportItems(household_id): + return jsonify({"items": [e.obj_to_export_dict() for e in Item.all_from_household_by_name(household_id)]}) @export.route('/recipes', methods=['GET']) @jwt_required() -def getExportRecipes(): - return jsonify({"recipes": [e.obj_to_export_dict() for e in Recipe.all()]}) +@authorize_household() +def getExportRecipes(household_id): + return jsonify({"recipes": [e.obj_to_export_dict() for e in Recipe.all_from_household_by_name(household_id)]}) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index 1cdc51a7..c37fe356 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -1,28 +1,16 @@ -from app.service.export_import import importFromDict, importFromLanguage +from app.service.export_import import importFromDict from .schemas import ImportSchema -from app.helpers import validate_args +from app.helpers import validate_args, authorize_household from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required -from app.config import SUPPORTED_LANGUAGES importBP = Blueprint('import', __name__) @importBP.route('', methods=['POST']) @jwt_required() +@authorize_household() @validate_args(ImportSchema) -def importData(args): - importFromDict(args) +def importData(args, household_id): + importFromDict(household_id, args) return jsonify({'msg': 'DONE'}) - - -@importBP.route('/', methods=['GET']) -@jwt_required() -def importLang(lang): - importFromLanguage(lang) - return jsonify({'msg': 'DONE'}) - - -@importBP.route('/supported-languages', methods=['GET']) -def getSupportedLanguages(): - return jsonify(SUPPORTED_LANGUAGES) diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 35fe7201..80a94c95 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -1,6 +1,7 @@ from flask import jsonify, Blueprint from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION from app.models import Settings +from app.config import SUPPORTED_LANGUAGES health = Blueprint('health', __name__) @@ -12,10 +13,8 @@ def get_health(): 'version': BACKEND_VERSION, 'min_frontend_version': MIN_FRONTEND_VERSION, } - settings = Settings.get() - info.update({ - 'planner_feature': settings.planner_feature, - 'expenses_feature': settings.expenses_feature, - 'view_ordering': settings.view_ordering, - }) return jsonify(info) + +@health.route('/supported-languages', methods=['GET']) +def getSupportedLanguages(): + return jsonify(SUPPORTED_LANGUAGES) \ No newline at end of file diff --git a/backend/app/controller/household/__init__.py b/backend/app/controller/household/__init__.py new file mode 100644 index 00000000..ec59be65 --- /dev/null +++ b/backend/app/controller/household/__init__.py @@ -0,0 +1 @@ +from .household_controller import household diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py new file mode 100644 index 00000000..9c4be943 --- /dev/null +++ b/backend/app/controller/household/household_controller.py @@ -0,0 +1,125 @@ +from app.config import SUPPORTED_LANGUAGES +from app.helpers import validate_args, authorize_household, RequiredRights +from flask import jsonify, Blueprint +from app.errors import NotFoundRequest +from flask_jwt_extended import current_user, jwt_required +from app.models import Household, HouseholdMember, Shoppinglist +from app.service.export_import import importLanguage +from .schemas import AddHousehold, UpdateHousehold, UpdateHouseholdMember + +household = Blueprint('household', __name__) + + +@household.route('', methods=['GET']) +@jwt_required() +def getUserHouseholds(): + return jsonify([e.household.obj_to_dict() for e in HouseholdMember.find_by_user(current_user.id)]) + + +@household.route('/', methods=['GET']) +@jwt_required() +@authorize_household() +def getHousehold(household_id): + household = Household.find_by_id(household_id) + if not household: + raise NotFoundRequest() + return jsonify(household.obj_to_dict()) + + +@household.route('', methods=['POST']) +@jwt_required() +@validate_args(AddHousehold) +def addHousehold(args): + household = Household() + household.name = args['name'] + if 'photo' in args: + household.photo = args['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'] + household.save() + + member = HouseholdMember() + member.household_id = household.id + member.user_id = current_user.id + member.owner = True + member.save() + + Shoppinglist(name="Default", household_id=household.id).save() + + if household.language: + importLanguage(household.id, household.language, True) + + return jsonify(household.obj_to_dict()) + + +@household.route('/', methods=['POST']) +@jwt_required() +@authorize_household(required=RequiredRights.ADMIN) +@validate_args(UpdateHousehold) +def updateHousehold(args, 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: + household.photo = args['photo'] + if 'language' in args and not household.language and args['language'] in SUPPORTED_LANGUAGES: + household.language = args['language'] + 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'] + + household.save() + return jsonify(household.obj_to_dict()) + + +@household.route('/', methods=['DELETE']) +@jwt_required() +@authorize_household(required=RequiredRights.ADMIN) +def deleteHouseholdById(household_id): + Household.delete_by_id(household_id) + return jsonify({'msg': 'DONE'}) + + +@household.route('//member/', methods=['PUT']) +@jwt_required() +@authorize_household(required=RequiredRights.ADMIN) +@validate_args(UpdateHouseholdMember) +def putHouseholdMember(args, household_id, user_id): + hm = HouseholdMember.find_by_ids(household_id, user_id) + if not hm: + household = Household.find_by_id(household_id) + if not household: + raise NotFoundRequest() + hm = HouseholdMember() + hm.household_id = household_id + hm.user_id = user_id + + if "admin" in args: + hm.admin = args["admin"] + + hm.save() + + return jsonify(hm.obj_to_user_dict()) + + +@household.route('//member/', methods=['DELETE']) +@jwt_required() +@authorize_household(required=RequiredRights.ADMIN_OR_SELF) +def deleteHouseholdMember(household_id, user_id): + hm = HouseholdMember.find_by_ids(household_id, user_id) + if hm: + hm.delete() + return jsonify({'msg': 'DONE'}) diff --git a/backend/app/controller/household/schemas.py b/backend/app/controller/household/schemas.py new file mode 100644 index 00000000..7329ced6 --- /dev/null +++ b/backend/app/controller/household/schemas.py @@ -0,0 +1,34 @@ +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() + language = fields.String() + planner_feature = fields.Boolean() + expenses_feature = fields.Boolean() + view_ordering = fields.List(fields.String) + + +class UpdateHousehold(Schema): + class Meta: + unknown = EXCLUDE + name = fields.String( + validate=lambda a: a and not a.isspace() + ) + photo = 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() diff --git a/backend/app/controller/item/__init__.py b/backend/app/controller/item/__init__.py index 37598a42..04c4b1c0 100644 --- a/backend/app/controller/item/__init__.py +++ b/backend/app/controller/item/__init__.py @@ -1 +1 @@ -from .item_controller import item +from .item_controller import item, itemHousehold diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 9a01f6f1..2beb0023 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -1,4 +1,4 @@ -from app.helpers import validate_args +from app.helpers import validate_args, authorize_household from flask import jsonify, Blueprint from app.errors import InvalidUsage, NotFoundRequest from flask_jwt_extended import jwt_required @@ -6,54 +6,68 @@ from .schemas import SearchByNameRequest, UpdateItem item = Blueprint('item', __name__) +itemHousehold = Blueprint('item', __name__) -@item.route('', methods=['GET']) +@itemHousehold.route('', methods=['GET']) @jwt_required() -def getAllItems(): - return jsonify([e.obj_to_dict() for e in Item.all()]) +@authorize_household() +def getAllItems(household_id): + return jsonify([e.obj_to_dict() for e in Item.all_by_name_with_filter(household_id)]) -@item.route('/', methods=['GET']) +@item.route('/', methods=['GET']) @jwt_required() def getItem(id): item = Item.find_by_id(id) if not item: raise NotFoundRequest() + item.checkAuthorized() return jsonify(item.obj_to_dict()) -@item.route('//recipes', methods=['GET']) +@item.route('//recipes', methods=['GET']) @jwt_required() def getItemRecipes(id): - items = RecipeItems.query.filter( + item = Item.find_by_id(id) + if not item: + raise NotFoundRequest() + item.checkAuthorized() + recipe = RecipeItems.query.filter( RecipeItems.item_id == id, RecipeItems.optional == False).join( # noqa RecipeItems.recipe).order_by( Recipe.name).all() - return jsonify([e.obj_to_recipe_dict() for e in items]) + return jsonify([e.obj_to_recipe_dict() for e in recipe]) -@item.route('/', methods=['DELETE']) +@item.route('/', methods=['DELETE']) @jwt_required() def deleteItemById(id): - Item.delete_by_id(id) + item = Item.find_by_id(id) + if not item: + raise NotFoundRequest() + item.checkAuthorized() + item.delete() return jsonify({'msg': 'DONE'}) -@item.route('/search', methods=['GET']) +@itemHousehold.route('/search', methods=['GET']) @jwt_required() +@authorize_household() @validate_args(SearchByNameRequest) -def searchItemByName(args): - return jsonify([e.obj_to_dict() for e in Item.search_name(args['query'])]) +def searchItemByName(args, household_id): + return jsonify([e.obj_to_dict() for e in Item.search_name(args['query'], household_id)]) -@item.route('/', methods=['POST']) +@item.route('/', methods=['POST']) @jwt_required() @validate_args(UpdateItem) def updateItem(args, id): item = Item.find_by_id(id) if not item: raise NotFoundRequest() + item.checkAuthorized() + if 'category' in args: if not args['category']: item.category = None diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py index 830c2162..e9f56cee 100644 --- a/backend/app/controller/onboarding/onboarding_controller.py +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -1,7 +1,6 @@ from app.helpers import validate_args from flask import jsonify, Blueprint -from app.models import User, Settings, Token -from app.service.export_import import importFromLanguage +from app.models import User, Token from .schemas import OnboardSchema onboarding = Blueprint('onboarding', __name__) @@ -19,17 +18,8 @@ def onboard(args): if User.count() > 0: return jsonify({'msg': "Onboarding not allowed"}), 403 - if 'planner_feature' in args or 'expenses_feature' in args: - settings = Settings.get() - if 'planner_feature' in args: - settings.planner_feature = args['planner_feature'] - if 'expenses_feature' in args: - settings.expenses_feature = args['expenses_feature'] - settings.save() - if 'language' in args: - importFromLanguage(args['language'], bulkSave=True) username = args['username'].lower() - user = User.create(username, args['password'], args['name'], owner=True) + user = User.create(username, args['password'], args['name'], admin=True) device = "Unkown" if "device" in args: diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py index e4cc3e53..45e3dc7c 100644 --- a/backend/app/controller/onboarding/schemas.py +++ b/backend/app/controller/onboarding/schemas.py @@ -15,11 +15,6 @@ class OnboardSchema(Schema): required=True, validate=lambda a: a and not a.isspace() ) - planner_feature = fields.Boolean() - expenses_feature = fields.Boolean() - language = fields.String( - validate=lambda a: a and not a.isspace() and a in SUPPORTED_LANGUAGES - ) device = fields.String( required=False, validate=lambda a: a and not a.isspace(), diff --git a/backend/app/controller/planner/__init__.py b/backend/app/controller/planner/__init__.py index 4e2980c8..efb760d7 100644 --- a/backend/app/controller/planner/__init__.py +++ b/backend/app/controller/planner/__init__.py @@ -1 +1 @@ -from .planner_controller import planner +from .planner_controller import plannerHousehold diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 63e9a359..84779054 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -2,87 +2,96 @@ from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required from app import db -from app.helpers import validate_args +from app.helpers import validate_args, authorize_household from app.models import Recipe, RecipeHistory, Planner from .schemas import AddPlannedRecipe, RemovePlannedRecipe -planner = Blueprint('planner', __name__) +plannerHousehold = Blueprint('planner', __name__) -@planner.route('/recipes', methods=['GET']) +@plannerHousehold.route('/recipes', methods=['GET']) @jwt_required() -def getAllPlannedRecipes(): - plannedRecipes = db.session.query(Planner.recipe_id).group_by( +@authorize_household() +def getAllPlannedRecipes(household_id): + plannedRecipes = db.session.query(Planner.recipe_id).filter(Planner.household_id == household_id).group_by( Planner.recipe_id).scalar_subquery() recipes = Recipe.query.filter(Recipe.id.in_( plannedRecipes)).order_by(Recipe.name).all() return jsonify([e.obj_to_full_dict() for e in recipes]) -@planner.route('/', methods=['GET']) +@plannerHousehold.route('', methods=['GET']) @jwt_required() -def getPlanner(): - plans = Planner.query.all() +@authorize_household() +def getPlanner(household_id): + plans = Planner.all_from_household(household_id) return jsonify([e.obj_to_full_dict() for e in plans]) -@planner.route('/recipe', methods=['POST']) +@plannerHousehold.route('/recipe', methods=['POST']) @jwt_required() +@authorize_household() @validate_args(AddPlannedRecipe) -def addPlannedRecipe(args): +def addPlannedRecipe(args, household_id): recipe = Recipe.find_by_id(args['recipe_id']) if not recipe: raise NotFoundRequest() day = args['day'] if 'day' in args else -1 - planner = Planner.find_by_day(recipe_id=recipe.id, day=day) + planner = Planner.find_by_day(household_id, recipe_id=recipe.id, day=day) if not planner: if day >= 0: - old = Planner.find_by_day(recipe_id=recipe.id, day=-1) - if old: old.delete() + old = Planner.find_by_day( + household_id, recipe_id=recipe.id, day=-1) + if old: + old.delete() elif len(recipe.plans) > 0: return jsonify(recipe.obj_to_dict()) planner = Planner() planner.recipe_id = recipe.id + planner.household_id = household_id planner.day = day if 'yields' in args: planner.yields = args['yields'] planner.save() - RecipeHistory.create_added(recipe) + RecipeHistory.create_added(recipe, household_id) return jsonify(recipe.obj_to_dict()) -@planner.route('/recipe/', methods=['DELETE']) +@plannerHousehold.route('/recipe/', methods=['DELETE']) @jwt_required() +@authorize_household() @validate_args(RemovePlannedRecipe) -def removePlannedRecipeById(args, id): +def removePlannedRecipeById(args, household_id, id): recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() - + day = args['day'] if 'day' in args else -1 - planner = Planner.find_by_day(recipe_id=recipe.id, day=day) + planner = Planner.find_by_day(household_id, recipe_id=recipe.id, day=day) if planner: planner.delete() - RecipeHistory.create_dropped(recipe) + RecipeHistory.create_dropped(recipe, household_id) return jsonify(recipe.obj_to_dict()) -@planner.route('/recent-recipes', methods=['GET']) +@plannerHousehold.route('/recent-recipes', methods=['GET']) @jwt_required() -def getRecentRecipes(): - recipes = RecipeHistory.get_recent() +@authorize_household() +def getRecentRecipes(household_id): + recipes = RecipeHistory.get_recent(household_id) return jsonify([e.recipe.obj_to_full_dict() for e in recipes]) -@planner.route('/suggested-recipes', methods=['GET']) +@plannerHousehold.route('/suggested-recipes', methods=['GET']) @jwt_required() -def getSuggestedRecipes(): +@authorize_household() +def getSuggestedRecipes(household_id): # all suggestions - suggested_recipes = Recipe.find_suggestions() + suggested_recipes = Recipe.find_suggestions(household_id) # remove recipes on recent list - recents = [e.recipe.id for e in RecipeHistory.get_recent()] + recents = [e.recipe.id for e in RecipeHistory.get_recent(household_id)] suggested_recipes = [s for s in suggested_recipes if s.id not in recents] # limit suggestions number to maximally 9 if len(suggested_recipes) > 9: @@ -90,10 +99,11 @@ def getSuggestedRecipes(): return jsonify([r.obj_to_full_dict() for r in suggested_recipes]) -@planner.route('/refresh-suggested-recipes', methods=['GET']) +@plannerHousehold.route('/refresh-suggested-recipes', methods=['GET', 'POST']) @jwt_required() -def getRefreshedSuggestedRecipes(): +@authorize_household() +def getRefreshedSuggestedRecipes(household_id): # re-compute suggestion ranking Recipe.compute_suggestion_ranking() # return suggested recipes - return getSuggestedRecipes() + return getSuggestedRecipes(household_id) diff --git a/backend/app/controller/recipe/__init__.py b/backend/app/controller/recipe/__init__.py index 6c91a762..d8cf238a 100644 --- a/backend/app/controller/recipe/__init__.py +++ b/backend/app/controller/recipe/__init__.py @@ -1 +1 @@ -from .recipe_controller import recipe +from .recipe_controller import recipe, recipeHousehold diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 3ea85faa..aca63c09 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -9,7 +9,7 @@ from app.models.recipe import RecipeItems, RecipeTags from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required -from app.helpers import validate_args +from app.helpers import validate_args, authorize_household from app.models import Recipe, Item, Tag from recipe_scrapers import scrape_me from recipe_scrapers._exceptions import SchemaOrgException @@ -17,30 +17,35 @@ from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest, ScrapeRecipe recipe = Blueprint('recipe', __name__) +recipeHousehold = Blueprint('recipe', __name__) -@recipe.route('/', methods=['GET']) +@recipeHousehold.route('', methods=['GET']) @jwt_required() -def getAllRecipes(): - return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name()]) +@authorize_household() +def getAllRecipes(household_id): + return jsonify([e.obj_to_full_dict() for e in Recipe.all_from_household_by_name(household_id)]) -@recipe.route('/', methods=['GET']) +@recipe.route('/', methods=['GET']) @jwt_required() def getRecipeById(id): recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() + recipe.checkAuthorized() return jsonify(recipe.obj_to_full_dict()) -@recipe.route('', methods=['POST']) +@recipeHousehold.route('', methods=['POST']) @jwt_required() +@authorize_household() @validate_args(AddRecipe) -def addRecipe(args): +def addRecipe(args, household_id): recipe = Recipe() recipe.name = args['name'] recipe.description = args['description'] + recipe.household_id = household_id if 'time' in args: recipe.time = args['time'] if 'cook_time' in args: @@ -56,9 +61,9 @@ def addRecipe(args): recipe.save() if 'items' in args: for recipeItem in args['items']: - item = Item.find_by_name(recipeItem['name']) + item = Item.find_by_name(household_id, recipeItem['name']) if not item: - item = Item.create_by_name(recipeItem['name']) + item = Item.create_by_name(household_id, recipeItem['name']) con = RecipeItems( description=recipeItem['description'], optional=recipeItem['optional'] @@ -68,9 +73,9 @@ def addRecipe(args): con.save() if 'tags' in args: for tagName in args['tags']: - tag = Tag.find_by_name(tagName) + tag = Tag.find_by_name(household_id, tagName) if not tag: - tag = Tag.create_by_name(tagName) + tag = Tag.create_by_name(household_id, tagName) con = RecipeTags() con.tag = tag con.recipe = recipe @@ -78,13 +83,15 @@ def addRecipe(args): return jsonify(recipe.obj_to_dict()) -@recipe.route('/', methods=['POST']) +@recipe.route('/', methods=['POST']) @jwt_required() @validate_args(UpdateRecipe) def updateRecipe(args, id): # noqa: C901 recipe = Recipe.find_by_id(id) if not recipe: raise NotFoundRequest() + recipe.checkAuthorized() + if 'name' in args: recipe.name = args['name'] if 'description' in args: @@ -108,9 +115,10 @@ def updateRecipe(args, id): # noqa: C901 if con.item.name not in item_names: con.delete() for recipeItem in args['items']: - item = Item.find_by_name(recipeItem['name']) + item = Item.find_by_name(recipe.household_id, recipeItem['name']) if not item: - item = Item.create_by_name(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: @@ -130,9 +138,9 @@ def updateRecipe(args, id): # noqa: C901 if con.tag.name not in args['tags']: con.delete() for recipeTag in args['tags']: - tag = Tag.find_by_name(recipeTag) + tag = Tag.find_by_name(recipe.household_id, recipeTag) if not tag: - tag = Tag.create_by_name(recipeTag) + tag = Tag.create_by_name(recipe.household_id, recipeTag) con = RecipeTags.find_by_ids(recipe.id, tag.id) if not con: con = RecipeTags() @@ -142,30 +150,37 @@ def updateRecipe(args, id): # noqa: C901 return jsonify(recipe.obj_to_dict()) -@recipe.route('/', methods=['DELETE']) +@recipe.route('/', methods=['DELETE']) @jwt_required() def deleteRecipeById(id): - Recipe.delete_by_id(id) + recipe = Recipe.find_by_id(id) + if not recipe: + raise NotFoundRequest() + recipe.checkAuthorized() + recipe.delete() return jsonify({'msg': 'DONE'}) -@recipe.route('/search', methods=['GET']) +@recipeHousehold.route('/search', methods=['GET']) @jwt_required() +@authorize_household() @validate_args(SearchByNameRequest) -def searchRecipeByName(args): +def searchRecipeByName(args, household_id): if 'only_ids' in args and args['only_ids']: return jsonify([e.id for e in Recipe.search_name(args['query'])]) - return jsonify([e.obj_to_dict() for e in Recipe.search_name(args['query'])]) + return jsonify([e.obj_to_dict() for e in Recipe.search_name(household_id, args['query'])]) -@recipe.route('/filter', methods=['POST']) +@recipeHousehold.route('/filter', methods=['POST']) @jwt_required() +@authorize_household() @validate_args(GetAllFilterRequest) -def getAllFiltered(args): - return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(args["filter"])]) +def getAllFiltered(args, household_id): + return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(household_id, args["filter"])]) @recipe.route('/scrape', methods=['GET', 'POST']) +@jwt_required() @validate_args(ScrapeRecipe) def scrapeRecipe(args): scraper = scrape_me(args['url'], wild_mode=True) diff --git a/backend/app/controller/settings/schemas.py b/backend/app/controller/settings/schemas.py index caf1d657..872163e9 100644 --- a/backend/app/controller/settings/schemas.py +++ b/backend/app/controller/settings/schemas.py @@ -2,6 +2,4 @@ class SetSettingsSchema(Schema): - planner_feature = fields.Boolean() - expenses_feature = fields.Boolean() - view_ordering = fields.List(fields.String) + pass diff --git a/backend/app/controller/settings/settings_controller.py b/backend/app/controller/settings/settings_controller.py index 5116b7f7..376246f8 100644 --- a/backend/app/controller/settings/settings_controller.py +++ b/backend/app/controller/settings/settings_controller.py @@ -1,5 +1,5 @@ from .schemas import SetSettingsSchema -from app.helpers import validate_args, admin_required +from app.helpers import validate_args, server_admin_required from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required from app.models import Settings @@ -9,16 +9,9 @@ @settings.route('', methods=['POST']) @jwt_required() -@admin_required -@validate_args(SetSettingsSchema) -def setSettings(args): +@server_admin_required() +def setSettings(): settings = Settings.get() - if 'planner_feature' in args: - settings.planner_feature = args['planner_feature'] - if 'expenses_feature' in args: - settings.expenses_feature = args['expenses_feature'] - if 'view_ordering' in args: - settings.view_ordering = args['view_ordering'] settings.save() return jsonify(settings.obj_to_dict()) diff --git a/backend/app/controller/shoppinglist/__init__.py b/backend/app/controller/shoppinglist/__init__.py index d8256911..7f126cdc 100644 --- a/backend/app/controller/shoppinglist/__init__.py +++ b/backend/app/controller/shoppinglist/__init__.py @@ -1 +1 @@ -from .shoppinglist_controller import shoppinglist +from .shoppinglist_controller import shoppinglist, shoppinglistHousehold diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 53ae0886..5d33d765 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -2,47 +2,42 @@ from flask_jwt_extended import jwt_required from app import db from app.models import Item, Shoppinglist, History, Status, Association, ShoppinglistItems -from app.helpers import validate_args +from app.helpers import validate_args, authorize_household from .schemas import (RemoveItem, UpdateDescription, AddItemByName, CreateList, AddRecipeItems, GetItems, UpdateList) -from app.errors import NotFoundRequest, ForbiddenRequest +from app.errors import NotFoundRequest, InvalidUsage from datetime import datetime, timedelta, timezone import app.util.description_merger as description_merger shoppinglist = Blueprint('shoppinglist', __name__) +shoppinglistHousehold = Blueprint('shoppinglist', __name__) -@shoppinglist.before_app_first_request -def before_first_request(): - # Add default shoppinglist - if (not Shoppinglist.find_by_id(1)): - Shoppinglist( - name='Default' - ).save() - - -@shoppinglist.route('', methods=['POST']) +@shoppinglistHousehold.route('', methods=['POST']) @jwt_required() +@authorize_household() @validate_args(CreateList) -def createShoppinglist(args): - return jsonify(Shoppinglist(name=args['name']).save().obj_to_dict()) +def createShoppinglist(args, household_id): + return jsonify(Shoppinglist(name=args['name'], household_id=household_id).save().obj_to_dict()) -@shoppinglist.route('', methods=['GET']) +@shoppinglistHousehold.route('', methods=['GET']) @jwt_required() -def getShoppinglists(): - shoppinglists = Shoppinglist.all() +@authorize_household() +def getShoppinglists(household_id): + shoppinglists = Shoppinglist.all_from_household(household_id) return jsonify([e.obj_to_dict() for e in shoppinglists]) -@shoppinglist.route('/', methods=['POST']) +@shoppinglist.route('/', methods=['POST']) @jwt_required() @validate_args(UpdateList) def updateShoppinglist(args, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() + shoppinglist.checkAuthorized() if 'name' in args: shoppinglist.name = args['name'] @@ -51,36 +46,43 @@ def updateShoppinglist(args, id): return jsonify(shoppinglist.obj_to_dict()) -@shoppinglist.route('/', methods=['DELETE']) +@shoppinglist.route('/', methods=['DELETE']) @jwt_required() def deleteShoppinglist(id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() + shoppinglist.checkAuthorized() if shoppinglist.isDefault(): - return ForbiddenRequest() + raise InvalidUsage() shoppinglist.delete() return jsonify({'msg': 'DONE'}) -@shoppinglist.route('//item/', methods=['POST']) +@shoppinglist.route('//item/', methods=['POST']) @jwt_required() @validate_args(UpdateDescription) def updateItemDescription(args, id, item_id): con = ShoppinglistItems.find_by_ids(id, item_id) if not con: raise NotFoundRequest() + con.shoppinglist.checkAuthorized() con.description = args['description'] or '' con.save() return jsonify(con.obj_to_item_dict()) -@shoppinglist.route('//items', methods=['GET']) +@shoppinglist.route('//items', methods=['GET']) @jwt_required() @validate_args(GetItems) def getAllShoppingListItems(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + orderby = [Item.name] if ('orderby' in args): if (args['orderby'] == 1): @@ -94,9 +96,14 @@ def getAllShoppingListItems(args, id): return jsonify([e.obj_to_item_dict() for e in items]) -@shoppinglist.route('//recent-items', methods=['GET']) +@shoppinglist.route('//recent-items', methods=['GET']) @jwt_required() def getRecentItems(id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + items = History.get_recent(id) return jsonify([e.item.obj_to_dict() | {'description': e.description} for e in items]) @@ -142,9 +149,14 @@ def getSuggestionsBasedOnFrequency(id, item_count): return suggestions -@shoppinglist.route('//suggested-items', methods=['GET']) +@shoppinglist.route('//suggested-items', methods=['GET']) @jwt_required() def getSuggestedItems(id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + item_suggestion_count = 9 suggestions = [] @@ -156,16 +168,19 @@ def getSuggestedItems(id): return jsonify([item.obj_to_dict() for item in suggestions]) -@shoppinglist.route('//add-item-by-name', methods=['POST']) +@shoppinglist.route('//add-item-by-name', methods=['POST']) @jwt_required() @validate_args(AddItemByName) def addShoppinglistItemByName(args, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() - item = Item.find_by_name(args['name']) + shoppinglist.checkAuthorized() + + item = Item.find_by_name(shoppinglist.household_id, args['name']) if not item: - item = Item.create_by_name(args['name']) + item = Item.create_by_name(shoppinglist.household_id, args['name']) + # item.checkAuthorized() con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if not con: @@ -180,13 +195,15 @@ def addShoppinglistItemByName(args, id): return jsonify(item.obj_to_dict()) -@shoppinglist.route('//item', methods=['DELETE']) +@shoppinglist.route('//item', methods=['DELETE']) @jwt_required() @validate_args(RemoveItem) def removeShoppinglistItem(args, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() + shoppinglist.checkAuthorized() + item = Item.find_by_id(args['item_id']) if not item: item = Item.find_by_name(args['name']) @@ -206,13 +223,14 @@ def removeShoppinglistItem(args, id): return jsonify({'msg': "DONE"}) -@shoppinglist.route('//recipeitems', methods=['POST']) +@shoppinglist.route('//recipeitems', methods=['POST']) @jwt_required() @validate_args(AddRecipeItems) def addRecipeItems(args, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() + shoppinglist.checkAuthorized() try: for recipeItem in args['items']: diff --git a/backend/app/controller/tag/__init__.py b/backend/app/controller/tag/__init__.py index b4d358ee..b7d7715a 100644 --- a/backend/app/controller/tag/__init__.py +++ b/backend/app/controller/tag/__init__.py @@ -1 +1 @@ -from .tag_controller import tag +from .tag_controller import tag, tagHousehold diff --git a/backend/app/controller/tag/schemas.py b/backend/app/controller/tag/schemas.py index a1aa826c..047336e6 100644 --- a/backend/app/controller/tag/schemas.py +++ b/backend/app/controller/tag/schemas.py @@ -1,13 +1,6 @@ from marshmallow import fields, Schema -class SearchByNameRequest(Schema): - query = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) - - class AddTag(Schema): name = fields.String( required=True, diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py index ef8bb0d1..50f15bf8 100644 --- a/backend/app/controller/tag/tag_controller.py +++ b/backend/app/controller/tag/tag_controller.py @@ -1,31 +1,39 @@ -from app.helpers import validate_args +from app.helpers import validate_args, authorize_household from flask import jsonify, Blueprint from app.errors import NotFoundRequest from flask_jwt_extended import jwt_required from app.models import Tag, RecipeTags, Recipe -from .schemas import SearchByNameRequest, AddTag, UpdateTag +from .schemas import AddTag, UpdateTag tag = Blueprint('tag', __name__) +tagHousehold = Blueprint('tag', __name__) -@tag.route('', methods=['GET']) +@tagHousehold.route('', methods=['GET']) @jwt_required() -def getAllTags(): - return jsonify([e.obj_to_dict() for e in Tag.all_by_name()]) +@authorize_household() +def getAllTags(household_id): + return jsonify([e.obj_to_dict() for e in Tag.all_from_household_by_name(household_id)]) -@tag.route('/', methods=['GET']) +@tag.route('/', methods=['GET']) @jwt_required() def getTag(id): tag = Tag.find_by_id(id) if not tag: raise NotFoundRequest() + tag.checkAuthorized() return jsonify(tag.obj_to_dict()) -@tag.route('//recipes', methods=['GET']) +@tag.route('//recipes', methods=['GET']) @jwt_required() def getTagRecipes(id): + tag = Tag.find_by_id(id) + if not tag: + raise NotFoundRequest() + tag.checkAuthorized() + tags = RecipeTags.query.filter( RecipeTags.tag_id == id).join( RecipeTags.recipe).order_by( @@ -33,23 +41,26 @@ def getTagRecipes(id): return jsonify([e.recipe.obj_to_dict() for e in tags]) -@tag.route('', methods=['POST']) +@tagHousehold.route('', methods=['POST']) @jwt_required() +@authorize_household() @validate_args(AddTag) -def addTag(args): +def addTag(args, household_id): tag = Tag() tag.name = args['name'] + tag.household_id = household_id tag.save() return jsonify(tag.obj_to_dict()) -@tag.route('/', methods=['POST']) +@tag.route('/', methods=['POST']) @jwt_required() @validate_args(UpdateTag) def updateTag(args, id): tag = Tag.find_by_id(id) if not tag: raise NotFoundRequest() + tag.checkAuthorized() if 'name' in args: tag.name = args['name'] @@ -58,15 +69,12 @@ def updateTag(args, id): return jsonify(tag.obj_to_dict()) -@tag.route('/', methods=['DELETE']) +@tag.route('/', methods=['DELETE']) @jwt_required() def deleteTagById(id): - Tag.delete_by_id(id) + tag = Tag.find_by_id(id) + if not tag: + raise NotFoundRequest() + tag.checkAuthorized() + tag.delete() return jsonify({'msg': 'DONE'}) - - -@tag.route('/search', methods=['GET']) -@jwt_required() -@validate_args(SearchByNameRequest) -def searchTagByName(args): - return jsonify([e.obj_to_dict() for e in Tag.search_name(args['query'])]) diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py index 568383eb..415bdced 100644 --- a/backend/app/controller/user/schemas.py +++ b/backend/app/controller/user/schemas.py @@ -22,6 +22,7 @@ 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(), load_only=True, @@ -33,3 +34,10 @@ class UpdateUser(Schema): admin = fields.Boolean( load_only=True, ) + + +class SearchByNameRequest(Schema): + query = fields.String( + required=True, + validate=lambda a: a and not a.isspace() + ) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 482b0084..ee9430db 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -1,10 +1,10 @@ from app.errors import NotFoundRequest, UnauthorizedRequest -from app.helpers.admin_required import admin_required +from app.helpers.server_admin_required import server_admin_required from app.helpers import validate_args from flask import jsonify, Blueprint -from flask_jwt_extended import current_user, jwt_required, get_jwt_identity +from flask_jwt_extended import current_user, jwt_required from app.models import User -from .schemas import CreateUser, UpdateUser +from .schemas import CreateUser, UpdateUser, SearchByNameRequest user = Blueprint('user', __name__) @@ -22,9 +22,9 @@ def getLoggedInUser(): return jsonify(current_user.obj_to_full_dict()) -@user.route('/', methods=['GET']) +@user.route('/', methods=['GET']) @jwt_required() -@admin_required +@server_admin_required() def getUserById(id): user = User.find_by_id(id) if not user: @@ -32,16 +32,25 @@ def getUserById(id): return jsonify(user.obj_to_dict()) -@user.route('/', methods=['DELETE']) +@user.route('', methods=['DELETE']) @jwt_required() -@admin_required -def deleteUserById(id): - user = User.find_by_id(id) - if not user or user.owner: +def deleteUser(): + if not current_user: raise UnauthorizedRequest( message='Cannot delete this user' ) - User.delete_by_id(id) + current_user.delete() + return jsonify({'msg': 'DONE'}) + + +@user.route('/', methods=['DELETE']) +@jwt_required() +@server_admin_required() +def deleteUserById(id): + user = User.find_by_id(id) + if not user: + raise NotFoundRequest() + user.delete() return jsonify({'msg': 'DONE'}) @@ -49,7 +58,7 @@ def deleteUserById(id): @jwt_required() @validate_args(UpdateUser) def updateUser(args): - user = User.find_by_username(get_jwt_identity()) + user = current_user if not user: raise NotFoundRequest() if 'name' in args: @@ -60,9 +69,9 @@ def updateUser(args): return jsonify({'msg': 'DONE'}) -@user.route('/', methods=['POST']) +@user.route('/', methods=['POST']) @jwt_required() -@admin_required +@server_admin_required() @validate_args(UpdateUser) def updateUserById(args, id): user = User.find_by_id(id) @@ -72,6 +81,8 @@ def updateUserById(args, id): user.name = args['name'] if 'password' in args: user.set_password(args['password']) + if 'photo' in args: + user.photo = args['photo'] if 'admin' in args: user.admin = args['admin'] or user.owner user.save() @@ -80,8 +91,15 @@ def updateUserById(args, id): @user.route('/new', methods=['POST']) @jwt_required() -@admin_required +@server_admin_required() @validate_args(CreateUser) def createUser(args): User.create(args['username'].lower(), args['password'], args['name']) return jsonify({'msg': 'DONE'}) + + +@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'])]) diff --git a/backend/app/errors/__init__.py b/backend/app/errors/__init__.py index 60baef28..d550de46 100644 --- a/backend/app/errors/__init__.py +++ b/backend/app/errors/__init__.py @@ -1,3 +1,6 @@ +from flask import request + + class InvalidUsage(Exception): def __init__(self, message="Invalid usage"): super(InvalidUsage, self).__init__(message) @@ -5,7 +8,9 @@ def __init__(self, message="Invalid usage"): class UnauthorizedRequest(Exception): - def __init__(self, message="Request unauthorized"): + def __init__(self, message=""): + message = message or 'Authorization required. IP {}'.format( + request.remote_addr) super(UnauthorizedRequest, self).__init__(message) self.message = message diff --git a/backend/app/helpers/__init__.py b/backend/app/helpers/__init__.py index f95e51f0..354060fd 100644 --- a/backend/app/helpers/__init__.py +++ b/backend/app/helpers/__init__.py @@ -1,4 +1,6 @@ from .db_model_mixin import DbModelMixin +from .db_model_authorize_mixin import DbModelAuthorizeMixin from .timestamp_mixin import TimestampMixin from .validate_args import validate_args -from .admin_required import admin_required +from .server_admin_required import server_admin_required +from .authorize_household import authorize_household, RequiredRights diff --git a/backend/app/helpers/admin_required.py b/backend/app/helpers/admin_required.py deleted file mode 100644 index 6f97f9f4..00000000 --- a/backend/app/helpers/admin_required.py +++ /dev/null @@ -1,31 +0,0 @@ -from flask import request -from app.models import User -from functools import wraps -from flask_jwt_extended import get_jwt_identity -from app.errors import UnauthorizedRequest - - -def admin_required(func): - @wraps(func) - def func_wrapper(*args, **kwargs): - user = User.find_by_username(get_jwt_identity()) - if not user or not (user.owner or user.admin): - raise UnauthorizedRequest( - message='Elevated rights required. IP {}'.format(request.remote_addr) - ) - return func(*args, **kwargs) - - return func_wrapper - - -def owner_required(func): - @wraps(func) - def func_wrapper(*args, **kwargs): - user = User.find_by_username(get_jwt_identity()) - if not user or not user.owner: - raise UnauthorizedRequest( - message='Elevated rights required. IP {}'.format(request.remote_addr) - ) - return func(*args, **kwargs) - - return func_wrapper diff --git a/backend/app/helpers/authorize_household.py b/backend/app/helpers/authorize_household.py new file mode 100644 index 00000000..5ab29151 --- /dev/null +++ b/backend/app/helpers/authorize_household.py @@ -0,0 +1,41 @@ +from functools import wraps +from enum import Enum +from flask_jwt_extended import current_user +from app.errors import UnauthorizedRequest, ForbiddenRequest +from app.models import HouseholdMember + + +class RequiredRights(Enum): + MEMBER = 1 + ADMIN = 2 + ADMIN_OR_SELF = 3 + + +def authorize_household(required: RequiredRights = RequiredRights.MEMBER) -> any: + def wrapper(func): + @wraps(func) + def decorator(*args, **kwargs): + if not 'household_id' in kwargs: + raise Exception("Wrong usage of authorize_household") + if required == RequiredRights.ADMIN_OR_SELF and not 'user_id' in kwargs: + raise Exception("Wrong usage of authorize_household") + if not current_user: + raise UnauthorizedRequest() + + if current_user.admin: + return func(*args, **kwargs) # case server admin + if required == RequiredRights.ADMIN_OR_SELF and current_user.id == kwargs['user_id']: + return func(*args, **kwargs) # case ressource deals with self + + member = HouseholdMember.find_by_ids( + kwargs['household_id'], current_user.id) + if required == RequiredRights.MEMBER and member: + return func(*args, **kwargs) # case member + + if (required == RequiredRights.ADMIN or required == RequiredRights.ADMIN_OR_SELF) and member and (member.admin or member.owner): + return func(*args, **kwargs) # case admin + + raise ForbiddenRequest() + + return decorator + return wrapper diff --git a/backend/app/helpers/db_model_authorize_mixin.py b/backend/app/helpers/db_model_authorize_mixin.py new file mode 100644 index 00000000..4a2c9db8 --- /dev/null +++ b/backend/app/helpers/db_model_authorize_mixin.py @@ -0,0 +1,20 @@ +from flask_jwt_extended import current_user +from app.errors import UnauthorizedRequest, ForbiddenRequest +import app + + +class DbModelAuthorizeMixin(object): + def checkAuthorized(self, requires_admin=False): + """ + Checks if current user ist authorized to access this model. Throws and unauthorized exception if not + IMPORTANT: requires household_id + """ + if not hasattr(self, 'household_id'): + raise Exception("Wrong usage of authorize_household") + if not current_user: + raise UnauthorizedRequest() + member = app.models.household.HouseholdMember.find_by_ids( + self.household_id, current_user.id) + if not current_user.admin: + if not member or requires_admin and not (member.admin or member.owner): + raise ForbiddenRequest() diff --git a/backend/app/helpers/db_model_mixin.py b/backend/app/helpers/db_model_mixin.py index 52f0255c..b7d48993 100644 --- a/backend/app/helpers/db_model_mixin.py +++ b/backend/app/helpers/db_model_mixin.py @@ -1,6 +1,5 @@ from __future__ import annotations from typing import Self -from sqlalchemy import asc, desc from app import db @@ -19,64 +18,6 @@ def save(self) -> Self: return self - def assign(self, **kwargs) -> Self: - """ - Update an entry - """ - for k, v in kwargs.items(): - setattr(self, k, v) - - return self - - def assign_columns(self, args: dict): - model_columns = list(self.__class__.__table__.columns.keys()) - for k, v in args.items(): - if k in model_columns: - setattr(self, k, v) - - return self - - def update(self, details: dict): - model_columns = list(self.__class__.__table__.columns.keys()) - for k, v in details.items(): - if k in model_columns and (v or v == ''): - setattr(self, k, v) - self.save() - - def update_attr(self, key: str, value): - model_columns = list(self.__class__.__table__.columns.keys()) - if key in model_columns: - setattr(self, key, value) - self.save() - - @classmethod - def get_column_names(cls) -> list[str]: - return list(cls.__table__.columns.keys()) - - @classmethod - def bulk_save(cls, records): - if not records: - return - - try: - db.session.bulk_save_objects(records) - db.session.commit() - except Exception as e: - db.session.rollback() - raise e - - @classmethod - def bulk_delete(cls, query): - if not query.all(): - return - - try: - query.delete() - db.session.commit() - except Exception as e: - db.session.rollback() - raise e - def delete(self): """ Delete this instance of model from db @@ -101,15 +42,9 @@ def obj_to_dict(self, skip_columns: list[str] = None, include_columns: list[str] return d - def clone(self, overrides) -> Self: - new_self = self.__class__() - new_self.assign_columns(self.obj_to_dict()) - - if overrides: - for k, v in overrides.items(): - setattr(new_self, k, v) - - return new_self + @classmethod + def get_column_names(cls) -> list[str]: + return list(cls.__table__.columns.keys()) @classmethod def find_by_id(cls, target_id: int) -> Self: @@ -118,20 +53,6 @@ def find_by_id(cls, target_id: int) -> Self: """ return cls.query.filter(cls.id == target_id).first() - @classmethod - def find_all_by_id(cls, target_id: int) -> list[Self]: - """ - Find all the rows with specified id - """ - return cls.query.filter(cls.id == target_id).all() - - @classmethod - def find_all_by_ids(cls, target_ids: list[int]) -> list[Self]: - """ - Find all the rows with specified id - """ - return cls.query.filter(cls.id.in_(target_ids)).all() - @classmethod def delete_by_id(cls, target_id: int): mc = cls.find_by_id(target_id) @@ -154,26 +75,20 @@ def all_by_name(cls) -> list[Self]: return cls.query.order_by(cls.name).all() @classmethod - def first(cls) -> Self: + def all_from_household(cls, household_id: int) -> list[Self]: """ - Returns the first entry of database + Return all instances of model + IMPORTANT: requires household_id column """ - entities = cls.query.order_by(asc(cls.id)).limit(1).all() - if len(entities) > 0: - return entities[0] - - return None + return cls.query.filter(cls.household_id == household_id).order_by(cls.id).all() @classmethod - def last(cls) -> Self: + def all_from_household_by_name(cls, household_id: int) -> list[Self]: """ - Return the last entry of table in database + Return all instances of model + IMPORTANT: requires household_id and name column """ - entities = cls.query.order_by(desc(cls.id)).limit(1).all() - if len(entities) > 0: - return entities[0] - - return None + return cls.query.filter(cls.household_id == household_id).order_by(cls.name).all() @classmethod def count(cls) -> int: diff --git a/backend/app/helpers/server_admin_required.py b/backend/app/helpers/server_admin_required.py new file mode 100644 index 00000000..f5f8a80e --- /dev/null +++ b/backend/app/helpers/server_admin_required.py @@ -0,0 +1,19 @@ +from flask import request +from functools import wraps +from flask_jwt_extended import current_user +from app.errors import ForbiddenRequest + + +def server_admin_required(): + def wrapper(func): + @wraps(func) + def decorator(*args, **kwargs): + if not current_user or not current_user.admin: + raise ForbiddenRequest( + message='Elevated rights required. IP {}'.format( + request.remote_addr) + ) + return func(*args, **kwargs) + + return decorator + return wrapper diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4876b7c7..541adb09 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -12,3 +12,4 @@ from .expense_category import ExpenseCategory from .category import Category from .token import Token +from .household import Household, HouseholdMember diff --git a/backend/app/models/category.py b/backend/app/models/category.py index f48d1ffa..7ca478b5 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -1,17 +1,20 @@ from __future__ import annotations from typing import Self from app import db -from app.helpers import DbModelMixin, TimestampMixin +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin -class Category(db.Model, DbModelMixin, TimestampMixin): +class Category(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'category' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) default = db.Column(db.Boolean, default=False) ordering = db.Column(db.Integer, default=0) + household_id = db.Column(db.Integer, db.ForeignKey( + 'household.id'), nullable=False) + household = db.relationship("Household", uselist=False) items = db.relationship( 'Item', back_populates='category') @@ -20,29 +23,31 @@ def obj_to_full_dict(self) -> dict: return res @classmethod - def all_by_ordering(cls): - return cls.query.order_by(cls.ordering, cls.name).all() + def all_by_ordering(cls, household_id: int): + return cls.query.filter(cls.household_id == household_id).order_by(cls.ordering, cls.name).all() @classmethod - def create_by_name(cls, name, default=False) -> Self: + def create_by_name(cls, household_id: int, name, default=False) -> Self: return cls( name=name, default=default, + household_id=household_id, ).save() @classmethod - def find_by_name(cls, name) -> Self: - return cls.query.filter(cls.name == name).first() + def find_by_name(cls, household_id: int, name: str) -> Self: + return cls.query.filter(cls.name == name, cls.household_id == household_id).first() @classmethod - def find_by_id(cls, id) -> Self: + def find_by_id(cls, id: int) -> Self: return cls.query.filter(cls.id == id).first() def reorder(self, newIndex: int): cls = self.__class__ self.ordering = newIndex - l: list[cls] = cls.query.order_by(cls.ordering, cls.name).all() + l: list[cls] = cls.query.filter(cls.household_id == self.household_id).order_by( + cls.ordering, cls.name).all() oldIndex = list(map(lambda x: x.id, l)).index(self.id) if oldIndex < 0: diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index 5baaef34..7b8b294f 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -1,10 +1,10 @@ from datetime import datetime from typing import Self from app import db -from app.helpers import DbModelMixin, TimestampMixin +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin -class Expense(db.Model, DbModelMixin, TimestampMixin): +class Expense(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'expense' id = db.Column(db.Integer, primary_key=True) @@ -14,7 +14,9 @@ class Expense(db.Model, DbModelMixin, TimestampMixin): category_id = db.Column(db.Integer, db.ForeignKey('expense_category.id')) photo = db.Column(db.String()) paid_by_id = db.Column(db.Integer, db.ForeignKey('user.id')) + household_id = db.Column(db.Integer, db.ForeignKey('household.id'), nullable=False) + household = db.relationship("Household", uselist=False) category = db.relationship("ExpenseCategory") paid_by = db.relationship("User") paid_for = db.relationship( diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py index 1bf2d4ae..73424f3f 100644 --- a/backend/app/models/expense_category.py +++ b/backend/app/models/expense_category.py @@ -1,16 +1,19 @@ from __future__ import annotations from typing import Self from app import db -from app.helpers import DbModelMixin, TimestampMixin +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin -class ExpenseCategory(db.Model, DbModelMixin, TimestampMixin): +class ExpenseCategory(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'expense_category' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) color = db.Column(db.Integer) + household_id = db.Column(db.Integer, db.ForeignKey( + 'household.id'), nullable=False) + household = db.relationship("Household", uselist=False) expenses = db.relationship( 'Expense', back_populates='category') @@ -19,15 +22,15 @@ def obj_to_full_dict(self) -> dict: return res @classmethod - def find_by_name(cls, name) -> Self: - return cls.query.filter(cls.name == name).first() + def find_by_name(cls, name: str, houshold_id: int) -> Self: + return cls.query.filter(cls.name == name, cls.household_id == houshold_id).first() @classmethod def find_by_id(cls, id) -> Self: return cls.query.filter(cls.id == id).first() @classmethod - def delete_by_name(cls, name): - mc = cls.find_by_name(name) + def delete_by_name(cls, name: str, household_id: int): + mc = cls.find_by_name(name, household_id) if mc: mc.delete() diff --git a/backend/app/models/history.py b/backend/app/models/history.py index 7dc4d3b5..0dc1abc7 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -23,6 +23,7 @@ class History(db.Model, DbModelMixin, TimestampMixin): item_id = db.Column(db.Integer, db.ForeignKey('item.id')) item = db.relationship("Item", uselist=False, back_populates="history") + shoppinglist = db.relationship("Shoppinglist", uselist=False, back_populates="history") status = db.Column(db.Enum(Status)) description = db.Column('description', db.String()) diff --git a/backend/app/models/household.py b/backend/app/models/household.py new file mode 100644 index 00000000..b868765c --- /dev/null +++ b/backend/app/models/household.py @@ -0,0 +1,90 @@ +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin +from app.helpers.db_list_type import DbListType + + +class Household(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'household' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128)) + photo = db.Column(db.String()) + language = db.Column(db.String()) + planner_feature = db.Column(db.Boolean(), default=True) + expenses_feature = db.Column(db.Boolean(), default=True) + + view_ordering = db.Column(DbListType(), default=list()) + + items = db.relationship( + 'Item', back_populates='household', cascade="all, delete-orphan") + shoppinglists = db.relationship( + 'Shoppinglist', back_populates='household', cascade="all, delete-orphan") + categories = db.relationship( + 'Category', back_populates='household', cascade="all, delete-orphan") + recipes = db.relationship( + 'Recipe', back_populates='household', cascade="all, delete-orphan") + tags = db.relationship( + 'Tag', back_populates='household', cascade="all, delete-orphan") + expenses = db.relationship( + 'Expense', back_populates='household', cascade="all, delete-orphan") + expenseCategories = db.relationship( + 'ExpenseCategory', back_populates='household', cascade="all, delete-orphan") + shoppinglists = db.relationship( + 'Shoppinglist', back_populates='household', cascade="all, delete-orphan") + member = db.relationship( + 'HouseholdMember', back_populates='household', cascade="all, delete-orphan") + + def obj_to_dict(self) -> dict: + res = super().obj_to_dict() + res['member'] = [m.obj_to_user_dict() for m in getattr(self, 'member')] + res['default_shopping_list'] = self.shoppinglists[0].obj_to_dict() + return res + + +class HouseholdMember(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = 'household_member' + + household_id = db.Column(db.Integer, db.ForeignKey( + 'household.id'), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + + owner = db.Column(db.Boolean(), default=False, nullable=False) + admin = db.Column(db.Boolean(), default=False, nullable=False) + + expense_balance = db.Column(db.Float(), default=0, nullable=False) + + household = db.relationship("Household", back_populates='member') + user = db.relationship("User", back_populates='households') + + def obj_to_user_dict(self) -> dict: + res = self.user.obj_to_dict() + res['owner'] = getattr(self, 'owner') + res['admin'] = getattr(self, 'admin') + res['expense_balance'] = getattr(self, 'expense_balance') + return res + + def delete(self): + if len(self.household.member) <= 1: + self.household.delete() + elif self.owner: + newOwner = next((m for m in self.household.member if m.admin and m != self), next( + (m for m in self.household.member if m != self))) + newOwner.admin = True + newOwner.owner = True + newOwner.save() + super().delete() + else: + super().delete() + + @classmethod + def find_by_ids(cls, household_id: int, user_id: int) -> Self: + return cls.query.filter(cls.household_id == household_id, cls.user_id == user_id).first() + + @classmethod + def find_by_household(cls, household_id: int) -> list[Self]: + return cls.query.filter(cls.household_id == household_id).all() + + @classmethod + def find_by_user(cls, user_id: int) -> list[Self]: + return cls.query.filter(cls.user_id == user_id).all() diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 57ad523d..50a0815e 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -1,19 +1,22 @@ from __future__ import annotations from typing import Self from app import db -from app.helpers import DbModelMixin, TimestampMixin +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin from app.models.category import Category -class Item(db.Model, DbModelMixin, TimestampMixin): +class Item(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'item' id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(128), unique=True) + name = db.Column(db.String(128)) icon = db.Column(db.String(128), nullable=True) category_id = db.Column(db.Integer, db.ForeignKey('category.id')) default = db.Column(db.Boolean, default=False) + household_id = db.Column(db.Integer, db.ForeignKey( + 'household.id'), nullable=False) + household = db.relationship("Household", uselist=False) category = db.relationship("Category") recipes = db.relationship( @@ -29,9 +32,9 @@ class Item(db.Model, DbModelMixin, TimestampMixin): history = db.relationship( "History", back_populates="item", cascade="all, delete-orphan") antecedents = db.relationship( - "Association", back_populates="antecedent", foreign_keys='Association.antecedent_id') + "Association", back_populates="antecedent", foreign_keys='Association.antecedent_id', cascade="all, delete-orphan") consequents = db.relationship( - "Association", back_populates="consequent", foreign_keys='Association.consequent_id') + "Association", back_populates="consequent", foreign_keys='Association.consequent_id', cascade="all, delete-orphan") def obj_to_dict(self) -> dict: res = super().obj_to_dict() @@ -47,44 +50,38 @@ def obj_to_export_dict(self) -> dict: if self.category: res["category"] = self.category.name return res - + def save(self, keepDefault=False) -> Self: if not keepDefault: self.default = False super().save() @classmethod - def create_by_name(cls, name: str, default=False) -> Self: + def create_by_name(cls, household_id: int, name: str, default=False) -> Self: return cls( name=name.strip(), default=default, + household_id=household_id, ).save() @classmethod - def allByName(cls) -> list[Self]: - """ - Return all instances of Item ordered by name - """ - return cls.query.order_by(cls.name).all() - - @classmethod - def find_by_name(cls, name: str) -> Self: + def find_by_name(cls, household_id: int, name: str) -> Self: name = name.strip() - return cls.query.filter(cls.name == name).first() + return cls.query.filter(cls.household_id == household_id, cls.name == name).first() @classmethod def find_by_id(cls, id) -> Self: return cls.query.filter(cls.id == id).first() @classmethod - def search_name(cls, name: str) -> list[Self]: + def search_name(cls, name: str, household_id: int) -> list[Self]: item_count = 11 found = [] # name is a regex if '*' in name or '?' in name or '%' in name or '_' in name: looking_for = name.replace('*', '%').replace('?', '_') - found = cls.query.filter(cls.name.ilike(looking_for)).order_by( + found = cls.query.filter(cls.name.ilike(looking_for), cls.household_id == household_id).order_by( cls.support.desc()).limit(item_count).all() return found @@ -97,7 +94,7 @@ def search_name(cls, name: str) -> list[Self]: one_error.append('%{0}%'.format(name_one_error)) for looking_for in [starts_with, contains] + one_error: - res = cls.query.filter(cls.name.ilike(looking_for)).order_by( + res = cls.query.filter(cls.name.ilike(looking_for), cls.household_id == household_id).order_by( cls.support.desc(), cls.name).all() for r in res: if r not in found: diff --git a/backend/app/models/planner.py b/backend/app/models/planner.py index 6b088500..ae9a0e6d 100644 --- a/backend/app/models/planner.py +++ b/backend/app/models/planner.py @@ -1,17 +1,20 @@ from __future__ import annotations from typing import Self from app import db -from app.helpers import DbModelMixin, TimestampMixin +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin -class Planner(db.Model, DbModelMixin, TimestampMixin): +class Planner(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'planner' recipe_id = db.Column(db.Integer, db.ForeignKey( 'recipe.id'), primary_key=True) day = db.Column(db.Integer, primary_key=True) yields = db.Column(db.Integer) + household_id = db.Column(db.Integer, db.ForeignKey( + 'household.id'), nullable=False) + household = db.relationship("Household", uselist=False) recipe = db.relationship("Recipe", back_populates="plans") def obj_to_full_dict(self) -> dict: @@ -20,5 +23,5 @@ def obj_to_full_dict(self) -> dict: return res @classmethod - def find_by_day(cls, recipe_id: int, day: int) -> Self: - return cls.query.filter(cls.recipe_id == recipe_id, cls.day == day).first() + def find_by_day(cls, household_id: int, recipe_id: int, day: int) -> Self: + return cls.query.filter(cls.household_id == household_id, cls.recipe_id == recipe_id, cls.day == day).first() diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index b8b9091b..53a27fa8 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -1,15 +1,14 @@ from __future__ import annotations from typing import Self from app import db -from app.helpers import DbModelMixin, TimestampMixin -from app.helpers.db_set_type import DbSetType +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin from .item import Item from .tag import Tag from .planner import Planner from random import randint -class Recipe(db.Model, DbModelMixin, TimestampMixin): +class Recipe(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'recipe' id = db.Column(db.Integer, primary_key=True) @@ -23,7 +22,10 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): source = db.Column(db.String()) suggestion_score = db.Column(db.Integer, server_default='0') suggestion_rank = db.Column(db.Integer, server_default='0') + household_id = db.Column(db.Integer, db.ForeignKey( + 'household.id'), nullable=False) + household = db.relationship("Household", uselist=False) recipe_history = db.relationship( "RecipeHistory", back_populates="recipe", cascade="all, delete-orphan") items = db.relationship( @@ -36,7 +38,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin): def obj_to_dict(self) -> dict: res = super().obj_to_dict() res['planned'] = len(self.plans) > 0 - res['planned_days'] = [plan.day for plan in self.plans if plan.day >=0 ] + res['planned_days'] = [plan.day for plan in self.plans if plan.day >= 0] return res def obj_to_full_dict(self) -> dict: @@ -100,34 +102,35 @@ def compute_suggestion_ranking(cls): db.session.commit() @classmethod - def find_suggestions(cls) -> list[Self]: - sq = db.session.query(Planner.recipe_id).group_by(Planner.recipe_id).scalar_subquery() - return cls.query.filter(cls.id.notin_(sq)).filter( # noqa + def find_suggestions(cls, household_id: int,) -> list[Self]: + sq = db.session.query(Planner.recipe_id).group_by( + Planner.recipe_id).scalar_subquery() + return cls.query.filter(cls.household_id == household_id, cls.id.notin_(sq)).filter( # noqa cls.suggestion_rank > 0).order_by(cls.suggestion_rank).all() @classmethod - def find_by_name(cls, name: str) -> Self: - return cls.query.filter(cls.name == name).first() + def find_by_name(cls, household_id: int, name: str) -> Self: + return cls.query.filter(cls.household_id == household_id, cls.name == name).first() @classmethod def find_by_id(cls, id: int) -> Self: return cls.query.filter(cls.id == id).first() @classmethod - def search_name(cls, name: str) -> list[Self]: + def search_name(cls, household_id: int, name: str) -> list[Self]: if '*' in name or '_' in name: looking_for = name.replace('_', '__')\ .replace('*', '%')\ .replace('?', '_') else: looking_for = '%{0}%'.format(name) - return cls.query.filter(cls.name.ilike(looking_for)).order_by(cls.name).all() + return cls.query.filter(cls.household_id == household_id, cls.name.ilike(looking_for)).order_by(cls.name).all() @classmethod - def all_by_name_with_filter(cls, filter: list[str]) -> list[Self]: + def all_by_name_with_filter(cls, household_id: int, filter: list[str]) -> list[Self]: sq = db.session.query(RecipeTags.recipe_id).join(RecipeTags.tag).filter( Tag.name.in_(filter)).subquery() - return db.session.query(cls).filter(cls.id.in_(sq)).order_by(cls.name).all() + return db.session.query(cls).filter(cls.household_id == household_id, cls.id.in_(sq)).order_by(cls.name).all() class RecipeItems(db.Model, DbModelMixin, TimestampMixin): diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py index fef34a23..55dbff5f 100644 --- a/backend/app/models/recipe_history.py +++ b/backend/app/models/recipe_history.py @@ -19,24 +19,29 @@ class RecipeHistory(db.Model, DbModelMixin, TimestampMixin): id = db.Column(db.Integer, primary_key=True) recipe_id = db.Column(db.Integer, db.ForeignKey('recipe.id')) + household_id = db.Column(db.Integer, db.ForeignKey( + 'household.id'), nullable=False) + household = db.relationship("Household", uselist=False) recipe = db.relationship("Recipe", uselist=False, back_populates="recipe_history") status = db.Column(db.Enum(Status)) @classmethod - def create_added(cls, recipe: Recipe) -> Self: + def create_added(cls, recipe: Recipe, household_id: int) -> Self: return cls( recipe_id=recipe.id, - status=Status.ADDED + status=Status.ADDED, + household_id=household_id, ).save() @classmethod - def create_dropped(cls, recipe: Recipe) -> Self: + def create_dropped(cls, recipe: Recipe, household_id: int) -> Self: return cls( recipe_id=recipe.id, - status=Status.DROPPED + status=Status.DROPPED, + household_id=household_id, ).save() def obj_to_item_dict(self) -> dict: @@ -45,20 +50,21 @@ def obj_to_item_dict(self) -> dict: return res @classmethod - def find_added(cls) -> list[Self]: - return cls.query.filter(cls.status == Status.ADDED).all() + def find_added(cls, household_id: int) -> list[Self]: + return cls.query.filter(cls.household_id == household_id, cls.status == Status.ADDED).all() @classmethod - def find_dropped(cls) -> list[Self]: - return cls.query.filter(cls.status == Status.DROPPED).all() + def find_dropped(cls, household_id: int) -> list[Self]: + return cls.query.filter(cls.household_id == household_id, cls.status == Status.DROPPED).all() @classmethod - def find_all(cls) -> list[Self]: - return cls.query.all() + def find_all(cls, household_id: int) -> list[Self]: + return cls.query.filter(cls.household_id == household_id).all() @classmethod - def get_recent(cls) -> list[Self]: - sq = db.session.query(Planner.recipe_id).group_by(Planner.recipe_id).subquery().select() - sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( + def get_recent(cls, household_id: int) -> list[Self]: + sq = db.session.query(Planner.recipe_id).group_by(Planner.recipe_id).filter( + Planner.household_id == household_id).subquery().select() + sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED, cls.household_id == household_id).filter( cls.recipe_id.notin_(sq)).group_by(cls.recipe_id).join(cls.recipe).subquery().select() return cls.query.filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index d7c5d432..5afba784 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,16 +1,12 @@ from typing import Self from app import db from app.helpers import DbModelMixin, TimestampMixin -from app.helpers.db_list_type import DbListType class Settings(db.Model, DbModelMixin, TimestampMixin): __tablename__ = 'settings' - planner_feature = db.Column(db.Boolean(), primary_key=True, default=True) - expenses_feature = db.Column(db.Boolean(), primary_key=True, default=True) - - view_ordering = db.Column(DbListType(), default=list()) + id = db.Column(db.Integer, primary_key=True) @classmethod def get(cls) -> Self: diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py index 1f835034..3e3cf63d 100644 --- a/backend/app/models/shoppinglist.py +++ b/backend/app/models/shoppinglist.py @@ -1,22 +1,28 @@ from typing import Self from app import db -from app.helpers import DbModelMixin, TimestampMixin +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin -class Shoppinglist(db.Model, DbModelMixin, TimestampMixin): +class Shoppinglist(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'shoppinglist' id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(128), unique=True) + name = db.Column(db.String(128)) + household_id = db.Column(db.Integer, db.ForeignKey('household.id'), nullable=False) + + household = db.relationship("Household", uselist=False) items = db.relationship('ShoppinglistItems', cascade="all, delete-orphan") + history = db.relationship( + "History", back_populates="shoppinglist", cascade="all, delete-orphan") + @classmethod - def getDefault(cls) -> Self: - return cls.find_by_id(1) + def getDefault(cls, household_id: int) -> Self: + return cls.query.filter(cls.household_id == household_id).order_by(cls.id).first() - def isDefault(self) -> bool: - return self.id == 1 + def isDefault(self, household_id: int) -> bool: + return self.id == self.getDefault(household_id).id class ShoppinglistItems(db.Model, DbModelMixin, TimestampMixin): diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py index 0b6da8d4..1dce7b9c 100644 --- a/backend/app/models/tag.py +++ b/backend/app/models/tag.py @@ -1,30 +1,34 @@ from typing import Self from app import db -from app.helpers import DbModelMixin, TimestampMixin +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin -class Tag(db.Model, DbModelMixin, TimestampMixin): +class Tag(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'tag' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) - recipes = db.relationship( - 'RecipeTags', back_populates='tag', cascade="all, delete-orphan") + household_id = db.Column(db.Integer, db.ForeignKey( + 'household.id'), nullable=False) + + household = db.relationship("Household", uselist=False) + recipes = db.relationship('RecipeTags', back_populates='tag') def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() return res @classmethod - def create_by_name(cls, name: str) -> Self: + def create_by_name(cls, household_id: int, name: str) -> Self: return cls( name=name, + household_id=household_id, ).save() @classmethod - def find_by_name(cls, name: str) -> Self: - return cls.query.filter(cls.name == name).first() + def find_by_name(cls, household_id: int, name: str) -> Self: + return cls.query.filter(cls.household_id == household_id, cls.name == name).first() @classmethod def find_by_id(cls, id: int) -> Self: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index c28b715e..ebd3f5da 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -12,14 +12,14 @@ class User(db.Model, DbModelMixin, TimestampMixin): username = db.Column(db.String(256), unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) photo = db.Column(db.String()) - owner = db.Column(db.Boolean(), default=False) admin = db.Column(db.Boolean(), default=False) - expense_balance = db.Column(db.Float(), default=0) - tokens = db.relationship( 'Token', back_populates='user', cascade="all, delete-orphan") + households = db.relationship( + 'HouseholdMember', back_populates='user', cascade="all, delete-orphan") + expenses_paid = db.relationship( 'Expense', back_populates='paid_by', cascade="all, delete-orphan") expenses_paid_for = db.relationship( @@ -33,14 +33,15 @@ def set_password(self, password: str): def obj_to_dict(self, skip_columns: list[str] = None, include_columns: list[str] = None) -> dict: if skip_columns: - skip_columns = skip_columns + ['password'] + skip_columns = skip_columns + ['password', 'admin'] else: - skip_columns = ['password'] + skip_columns = ['password', 'admin'] return super().obj_to_dict(skip_columns=skip_columns, include_columns=include_columns) def obj_to_full_dict(self) -> dict: from .token import Token res = self.obj_to_dict() + res['admin'] = self.admin tokens = Token.query.filter(Token.user_id == self.id, Token.type != 'access', ~Token.created_tokens.any(Token.type == 'refresh')).all() res['tokens'] = [e.obj_to_dict( @@ -52,10 +53,20 @@ def find_by_username(cls, username: str) -> Self: return cls.query.filter(cls.username == username).first() @classmethod - def create(cls, username: str, password: str, name: str, owner=False) -> Self: + def create(cls, username: str, password: str, name: str, admin=False) -> Self: return cls( username=username, password=bcrypt.generate_password_hash(password).decode('utf-8'), name=name, - owner=owner + admin=admin ).save() + + @classmethod + def search_name(cls, name: str) -> list[Self]: + if '*' in name or '_' in name: + looking_for = name.replace('_', '__')\ + .replace('*', '%')\ + .replace('?', '_') + else: + looking_for = '%{0}%'.format(name) + return cls.query.filter(cls.name.ilike(looking_for) | cls.username.ilike(looking_for)).order_by(cls.name).limit(15) diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py index 57474f05..3605d77a 100644 --- a/backend/app/service/export_import.py +++ b/backend/app/service/export_import.py @@ -7,7 +7,7 @@ from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags, Category -def importFromLanguage(lang, bulkSave=False): +def importLanguage(household_id, lang, bulkSave=False): file_path = f'{APP_DIR}/../templates/l10n/{lang}.json' if lang not in SUPPORTED_LANGUAGES or not exists(file_path): raise NotFoundRequest('Language code not supported') @@ -19,14 +19,16 @@ def importFromLanguage(lang, bulkSave=False): t0 = time.time() models: list[Item] = [] for key, name in data["items"].items(): - item = Item.find_by_name(name) + item = Item.find_by_name(household_id, name) if not item: - if bulkSave and any(i.name == name for i in models): # slow but needed to filter out duplicate names + # slow but needed to filter out duplicate names + if bulkSave and any(i.name == name for i in models): continue item = Item() item.name = name.strip() + item.household_id = household_id item.default = True - + if item.default: if key in attributes["items"] and "icon" in attributes["items"][key]: item.icon = attributes["items"][key]["icon"] @@ -34,10 +36,11 @@ def importFromLanguage(lang, bulkSave=False): # Category not already set for existing item and category set for template and category translation exist for language if not item.category_id and key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: category_name = data["categories"][attributes["items"] - [key]["category"]] - category = Category.find_by_name(category_name) + [key]["category"]] + category = Category.find_by_name(household_id, category_name) if not category: - category = Category.create_by_name(category_name, True) + category = Category.create_by_name( + household_id, category_name, True) item.category = category if not bulkSave: item.save(keepDefault=True) @@ -54,21 +57,25 @@ def importFromLanguage(lang, bulkSave=False): app.logger.info(f"Import took: {(time.time() - t0):.3f}s") -def importFromDict(args, bulkSave=False, override=False): # noqa +def importFromDict(args, household_id: int, bulkSave=False, override=False): # noqa t0 = time.time() models = [] if "items" in args: for importItem in args['items']: - item = Item.find_by_name(importItem['name']) + item = Item.find_by_name(household_id, importItem['name']) if not item: - if bulkSave and any(i.name == importItem['name'] for i in models): # slow but needed to filter out duplicate names + # slow but needed to filter out duplicate names + if bulkSave and any(i.name == importItem['name'] for i in models): continue item = Item() item.name = importItem['name'] + item.household_id = household_id if "category" in importItem and not item.category_id: - category = Category.find_by_name(importItem['category']) + category = Category.find_by_name( + household_id, importItem['category']) if not category: category = Category.create_by_name( + household_id, importItem['category']) item.category = category if not bulkSave: @@ -78,13 +85,14 @@ def importFromDict(args, bulkSave=False, override=False): # noqa if "recipes" in args: for importRecipe in args['recipes']: recipeNameCount = 0 - recipe = Recipe.find_by_name(importRecipe['name']) + recipe = Recipe.find_by_name(household_id, importRecipe['name']) if recipe and not override: recipeNameCount = 1 + \ - Recipe.query.filter(Recipe.name.ilike( + Recipe.query.filter(Recipe.household_id == household_id, Recipe.name.ilike( importRecipe['name'] + " (_%)")).count() if not recipe: recipe = Recipe() + recipe.household_id = household_id recipe.name = importRecipe['name'] + \ (f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "") recipe.description = importRecipe['description'] @@ -106,9 +114,10 @@ def importFromDict(args, bulkSave=False, override=False): # noqa if 'items' in importRecipe: for recipeItem in importRecipe['items']: - item = Item.find_by_name(recipeItem['name']) + item = Item.find_by_name(household_id, recipeItem['name']) if not item: - item = Item.create_by_name(recipeItem['name']) + item = Item.create_by_name( + household_id, recipeItem['name']) con = RecipeItems( description=recipeItem['description'], optional=recipeItem['optional'] @@ -121,9 +130,9 @@ def importFromDict(args, bulkSave=False, override=False): # noqa models.append(con) if 'tags' in args: for tagName in args['tags']: - tag = Tag.find_by_name(tagName) + tag = Tag.find_by_name(household_id, tagName) if not tag: - tag = Tag.create_by_name(tagName) + tag = Tag.create_by_name(household_id, tagName) con = RecipeTags() con.tag = tag con.recipe = recipe diff --git a/backend/manage.py b/backend/manage.py index e182fbb1..28ade30e 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -21,12 +21,12 @@ def manageUsers(): print("No user found with that username") else: newPW = input("Enter new password:") - if not newPW: + if not newPW.strip(): print("Password cannot be empty") continue newPWRepeat = input("Repeat new password:") - if newPW == newPWRepeat: - user.set_password(newPW) + if newPW.strip() == newPWRepeat.strip(): + user.set_password(newPW.strip()) elif selection == "3": username = input("Enter the username:") user = User.find_by_username(username) diff --git a/backend/migrations/versions/4b4823a384e7_.py b/backend/migrations/versions/4b4823a384e7_.py index 2aa105d1..51048e9f 100644 --- a/backend/migrations/versions/4b4823a384e7_.py +++ b/backend/migrations/versions/4b4823a384e7_.py @@ -7,9 +7,17 @@ """ from alembic import op import sqlalchemy as sa +from sqlalchemy import orm from app import db -from app.models.expense import Expense +DeclarativeBase = orm.declarative_base() + + +class Expense(DeclarativeBase): + __tablename__ = 'expense' + id = sa.Column(sa.Integer, primary_key=True) + date = sa.Column(sa.DateTime) + created_at = sa.Column(sa.DateTime, nullable=False) # revision identifiers, used by Alembic. @@ -22,18 +30,20 @@ def upgrade(): with op.batch_alter_table('expense', schema=None) as batch_op: batch_op.add_column(sa.Column('date', sa.DateTime())) - + # Data migration + bind = op.get_bind() + session = orm.Session(bind=bind) - expenses = Expense.all() + expenses = session.query(Expense).all() for expense in expenses: expense.date = expense.created_at - + try: - db.session.bulk_save_objects(expenses) - db.session.commit() + session.bulk_save_objects(expenses) + session.commit() except Exception as e: - db.session.rollback() + session.rollback() raise e with op.batch_alter_table('expense', schema=None) as batch_op: diff --git a/backend/migrations/versions/6c669d9ec3bd_.py b/backend/migrations/versions/6c669d9ec3bd_.py new file mode 100644 index 00000000..09278d9e --- /dev/null +++ b/backend/migrations/versions/6c669d9ec3bd_.py @@ -0,0 +1,317 @@ +"""empty message + +Revision ID: 6c669d9ec3bd +Revises: 4b4823a384e7 +Create Date: 2023-01-15 23:58:29.531456 + +""" +from datetime import datetime +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm +from app import db +import app.helpers.db_set_type + +DeclarativeBase = orm.declarative_base() + + +# revision identifiers, used by Alembic. +revision = '6c669d9ec3bd' +down_revision = '6d641b08aaa8' +branch_labels = None +depends_on = None + +class Category(DeclarativeBase): + __tablename__ = 'category' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Expense(DeclarativeBase): + __tablename__ = 'expense' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class ExpenseCategory(DeclarativeBase): + __tablename__ = 'expense_category' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Item(DeclarativeBase): + __tablename__ = 'item' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Planner(DeclarativeBase): + __tablename__ = 'planner' + recipe_id = sa.Column(sa.Integer, primary_key=True) + day = db.Column(db.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Recipe(DeclarativeBase): + __tablename__ = 'recipe' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class RecipeHistory(DeclarativeBase): + __tablename__ = 'recipe_history' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Shoppinglist(DeclarativeBase): + __tablename__ = 'shoppinglist' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Tag(DeclarativeBase): + __tablename__ = 'tag' + id = sa.Column(sa.Integer, primary_key=True) + household_id = sa.Column(sa.Integer, sa.ForeignKey('household.id'), nullable=True) + +class Settings(DeclarativeBase): + __tablename__ = 'settings' + id = sa.Column(sa.Integer) + planner_feature = sa.Column('planner_feature', sa.BOOLEAN(), nullable=False, primary_key=True) + expenses_feature = sa.Column('expenses_feature', sa.BOOLEAN(), nullable=False, primary_key=True) + view_ordering = sa.Column('view_ordering', app.helpers.db_list_type.DbListType(), nullable=True) + +class User(DeclarativeBase): + __tablename__ = 'user' + id = sa.Column(sa.Integer, primary_key=True) + owner = sa.Column('owner', sa.Boolean(), nullable=False) + admin = sa.Column('admin', sa.Boolean(), nullable=False) + expense_balance = sa.Column(sa.Float(), default=0, nullable=False) + +class Household(DeclarativeBase): + __tablename__ = 'household' + + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.String(128), unique=True) + planner_feature = sa.Column(sa.Boolean(), primary_key=True, default=True) + expenses_feature = sa.Column(sa.Boolean(), primary_key=True, default=True) + view_ordering = sa.Column(app.helpers.db_list_type.DbListType(), default=list()) + created_at = sa.Column(sa.DateTime, nullable=False) + updated_at = sa.Column(sa.DateTime, nullable=False) + +class HouseholdMember(DeclarativeBase): + __tablename__ = 'household_member' + + household_id = sa.Column(sa.Integer, sa.ForeignKey( + 'household.id'), primary_key=True) + user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), primary_key=True) + owner = sa.Column(sa.Boolean(), default=False, nullable=False) + admin = sa.Column(sa.Boolean(), default=False, nullable=False) + expense_balance = sa.Column(sa.Float(), default=0, nullable=False) + created_at = sa.Column(sa.DateTime, nullable=False) + updated_at = sa.Column(sa.DateTime, nullable=False) + + + +def upgrade(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('household', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('photo', sa.String(), nullable=True), + sa.Column('language', sa.String(), nullable=True), + sa.Column('planner_feature', sa.Boolean(), nullable=False), + sa.Column('expenses_feature', sa.Boolean(), nullable=False), + sa.Column('view_ordering', app.helpers.db_list_type.DbListType(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_household')), + ) + op.create_table('household_member', + sa.Column('household_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('owner', sa.Boolean(), nullable=False), + sa.Column('admin', sa.Boolean(), nullable=False), + sa.Column('expense_balance', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['household_id'], ['household.id'], name=op.f('fk_household_member_household_id_household')), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_household_member_user_id_user')), + sa.PrimaryKeyConstraint('household_id', 'user_id', name=op.f('pk_household_member')) + ) + # Initial + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_category_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_expense_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_expense_category_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_item_household_id_household'), 'household', ['household_id'], ['id']) + batch_op.drop_constraint('uq_item_name', type_='unique') + + with op.batch_alter_table('planner', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_planner_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_recipe_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('recipe_history', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_recipe_history_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_shoppinglist_household_id_household'), 'household', ['household_id'], ['id']) + batch_op.drop_constraint('uq_shoppinglist_name', type_='unique') + + with op.batch_alter_table('tag', schema=None) as batch_op: + batch_op.add_column(sa.Column('household_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_tag_household_id_household'), 'household', ['household_id'], ['id']) + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.add_column(sa.Column('id', sa.Integer(), nullable=True)) + + # Data Migration + settings = session.query(Settings).first() + if settings: + models: list[db.Model] = session.query(Category).all()\ + + session.query(Expense).all()\ + + session.query(ExpenseCategory).all()\ + + session.query(Item).all()\ + + session.query(Recipe).all()\ + + session.query(RecipeHistory).all()\ + + session.query(Shoppinglist).all()\ + + session.query(Tag).all()\ + + session.query(Planner).all() + for model in models: + model.household_id = 1 + + household = Household() + household.id = 1 + household.name = "Home" + household.planner_feature = settings.planner_feature + household.expenses_feature = settings.expenses_feature + household.view_ordering = settings.view_ordering + household.created_at = datetime.utcnow() + household.updated_at = datetime.utcnow() + + users = session.query(User) + for user in users: + hm = HouseholdMember() + hm.created_at = datetime.utcnow() + hm.updated_at = datetime.utcnow() + hm.user_id = user.id + hm.household_id = 1 + hm.admin = user.admin + hm.owner = user.owner + hm.expense_balance = user.expense_balance + models.append(hm) + + models.append(settings) + models.append(household) + models += users + + try: + session.bulk_save_objects(models) + session.commit() + except Exception as e: + session.rollback() + raise e + + + # Final + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('planner', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('recipe_history', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('tag', schema=None) as batch_op: + batch_op.alter_column('household_id', nullable=False) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('owner') + batch_op.drop_column('expense_balance') + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.alter_column('id', nullable=False) + batch_op.drop_column('view_ordering') + batch_op.drop_column('expenses_feature') + batch_op.drop_column('planner_feature') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('expense_balance', sa.FLOAT(), nullable=True)) + batch_op.add_column(sa.Column('owner', sa.BOOLEAN(), nullable=True)) + + with op.batch_alter_table('tag', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_tag_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_shoppinglist_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + batch_op.create_unique_constraint(batch_op.f('uq_shoppinglist_name'), ['name']) + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.add_column(sa.Column('planner_feature', sa.BOOLEAN(), nullable=False)) + batch_op.add_column(sa.Column('expenses_feature', sa.BOOLEAN(), nullable=False)) + batch_op.add_column(sa.Column('view_ordering', sa.VARCHAR(), nullable=True)) + batch_op.drop_column('id') + + with op.batch_alter_table('recipe_history', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_recipe_history_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_recipe_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_item_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + batch_op.create_unique_constraint(batch_op.f('uq_item_name'), ['name']) + + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_expense_category_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_expense_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_category_household_id_household'), type_='foreignkey') + batch_op.drop_column('household_id') + + op.drop_table('household_member') + op.drop_table('household') + # ### end Alembic commands ### diff --git a/backend/upgrade_default_items.py b/backend/upgrade_default_items.py index 1de18876..b0042b71 100644 --- a/backend/upgrade_default_items.py +++ b/backend/upgrade_default_items.py @@ -1,10 +1,11 @@ from app import app -from app.models import Settings -from app.service import export_import +from app.models import Household +from app.service.export_import import importLanguage if __name__ == "__main__": with app.app_context(): - settings = Settings.get() - if False: - export_import.importFromLanguage(lang, bulkSave=True) \ No newline at end of file + for household in Household.all(): + if household.language: + importLanguage(household.id, + household.language, bulkSave=True) From 57e7665f735e582d4f7b39dc66545cf714e149a1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 6 Apr 2023 00:50:21 +0200 Subject: [PATCH 272/496] chore: upgrade requirements --- backend/requirements.txt | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index dc137e85..ec285fe6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,14 +1,14 @@ -alembic==1.9.4 +alembic==1.10.3 appdirs==1.4.4 APScheduler==3.10.1 attrs==22.2.0 autopep8==2.0.2 bcrypt==4.0.1 -beautifulsoup4==4.11.2 +beautifulsoup4==4.12.1 black==23.1a1 certifi==2022.12.7 cffi==1.15.1 -charset-normalizer==3.0.1 +charset-normalizer==3.1.0 click==8.1.3 contourpy==1.0.7 cycler==0.11.0 @@ -21,7 +21,7 @@ Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 Flask-Migrate==4.0.4 Flask-SQLAlchemy==3.0.3 -fonttools==4.38.0 +fonttools==4.39.3 greenlet==2.0.2 html-text==0.5.2 html5lib==1.1 @@ -38,17 +38,17 @@ lxml==4.9.2 Mako==1.2.4 MarkupSafe==2.1.2 marshmallow==3.19.0 -matplotlib==3.7.0 +matplotlib==3.7.1 mccabe==0.7.0 mf2py==1.1.2 -mlxtend==0.21.0 +mlxtend==0.22.0 mypy-extensions==1.0.0 numpy==1.24.2 packaging==23.0 -pandas==1.5.3 -pathspec==0.11.0 -Pillow==9.4.0 -platformdirs==3.0.0 +pandas==2.0.0 +pathspec==0.11.1 +Pillow==9.5.0 +platformdirs==3.2.0 pluggy==1.0.0 py==1.11.0 pycodestyle==2.10.0 @@ -57,33 +57,33 @@ pyflakes==3.0.1 PyJWT==2.6.0 pyparsing==3.0.9 pyRdfa3==3.5.3 -pytest==7.2.1 +pytest==7.2.2 python-dateutil==2.8.2 python-editor==1.0.4 -pytz==2022.7.1 +pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 -rdflib==6.2.0 +rdflib==6.3.2 rdflib-jsonld==0.6.2 -recipe-scrapers==14.32.1 -regex==2022.10.31 +recipe-scrapers==14.36.0 +regex==2023.3.23 requests==2.28.2 -scikit-learn==1.2.1 +scikit-learn==1.2.2 scipy==1.10.1 setuptools-scm==7.1.0 six==1.16.0 soupsieve==2.4 -SQLAlchemy==2.0.4 +SQLAlchemy==2.0.8 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 typed-ast==1.5.4 -types-beautifulsoup4==4.11.6.7 -types-requests==2.28.11.15 -types-urllib3==1.26.25.8 +types-beautifulsoup4==4.12.0.1 +types-requests==2.28.11.17 +types-urllib3==1.26.25.10 typing_extensions==4.5.0 -tzdata==2022.7 -tzlocal==4.2 -urllib3==1.26.14 +tzdata==2023.3 +tzlocal==4.3 +urllib3==1.26.15 uWSGI==2.0.21 w3lib==2.1.1 webencodings==0.5.1 From 3b2fd1df75610c9858cd2dc193a6e599b19d944c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 6 Apr 2023 01:25:45 +0200 Subject: [PATCH 273/496] fix: migration --- backend/app/models/settings.py | 2 +- backend/migrations/versions/6c669d9ec3bd_.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 5afba784..e8718ab2 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -6,7 +6,7 @@ class Settings(db.Model, DbModelMixin, TimestampMixin): __tablename__ = 'settings' - id = db.Column(db.Integer, primary_key=True) + id = db.Column(db.Integer, primary_key=True, nullable=False) @classmethod def get(cls) -> Self: diff --git a/backend/migrations/versions/6c669d9ec3bd_.py b/backend/migrations/versions/6c669d9ec3bd_.py index 09278d9e..f65f09c5 100644 --- a/backend/migrations/versions/6c669d9ec3bd_.py +++ b/backend/migrations/versions/6c669d9ec3bd_.py @@ -213,11 +213,11 @@ def upgrade(): hm.expense_balance = user.expense_balance models.append(hm) - models.append(settings) models.append(household) models += users try: + session.delete(settings) session.bulk_save_objects(models) session.commit() except Exception as e: From 9b28ca2737725cdd9080ea6b60d7d7257b3c6f95 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 12 Apr 2023 21:22:32 +0200 Subject: [PATCH 274/496] fix: planner issues --- backend/app/controller/planner/schemas.py | 6 +++++- backend/app/models/planner.py | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/planner/schemas.py b/backend/app/controller/planner/schemas.py index 232b863c..cfb20fa8 100644 --- a/backend/app/controller/planner/schemas.py +++ b/backend/app/controller/planner/schemas.py @@ -1,8 +1,10 @@ -from marshmallow import fields, Schema +from marshmallow import fields, Schema, EXCLUDE from marshmallow.validate import Range class AddPlannedRecipe(Schema): + class Meta: + unknown = EXCLUDE recipe_id = fields.Integer( required=True, ) @@ -12,5 +14,7 @@ class AddPlannedRecipe(Schema): class RemovePlannedRecipe(Schema): + class Meta: + unknown = EXCLUDE day = fields.Integer(validate=Range( min=0, min_inclusive=True, max=6, max_inclusive=True)) diff --git a/backend/app/models/planner.py b/backend/app/models/planner.py index ae9a0e6d..5f8419c3 100644 --- a/backend/app/models/planner.py +++ b/backend/app/models/planner.py @@ -21,6 +21,14 @@ def obj_to_full_dict(self) -> dict: res = self.obj_to_dict() res['recipe'] = self.recipe.obj_to_full_dict() return res + + @classmethod + def all_from_household(cls, household_id: int) -> list[Self]: + """ + Return all instances of model + IMPORTANT: requires household_id column + """ + return cls.query.filter(cls.household_id == household_id).order_by(cls.day).all() @classmethod def find_by_day(cls, household_id: int, recipe_id: int, day: int) -> Self: From a8f1d0efed0705a43237fac5ba4b771d8f2ed9e9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 14 Apr 2023 02:09:26 +0200 Subject: [PATCH 275/496] fix: user issues --- backend/app/controller/user/user_controller.py | 2 +- backend/app/models/user.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index ee9430db..a71e5df6 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -84,7 +84,7 @@ def updateUserById(args, id): if 'photo' in args: user.photo = args['photo'] if 'admin' in args: - user.admin = args['admin'] or user.owner + user.admin = args['admin'] user.save() return jsonify({'msg': 'DONE'}) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ebd3f5da..8587432c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,6 @@ from typing import Self + +from flask_jwt_extended import current_user from app import db from app.helpers import DbModelMixin, TimestampMixin from app.config import bcrypt @@ -33,9 +35,13 @@ def set_password(self, password: str): def obj_to_dict(self, skip_columns: list[str] = None, include_columns: list[str] = None) -> dict: if skip_columns: - skip_columns = skip_columns + ['password', 'admin'] + skip_columns = skip_columns + ['password'] else: - skip_columns = ['password', 'admin'] + skip_columns = ['password'] + + if not current_user or not current_user.admin: + skip_columns = skip_columns + ['admin'] # Filter out admin status if current user is not an admin + return super().obj_to_dict(skip_columns=skip_columns, include_columns=include_columns) def obj_to_full_dict(self) -> dict: From ba59be34fe1c3caf2cfce8eb94fceefad45275d3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 14 Apr 2023 22:47:50 +0200 Subject: [PATCH 276/496] feat: parameterize recent items limit --- backend/app/controller/shoppinglist/schemas.py | 8 ++++++++ .../controller/shoppinglist/shoppinglist_controller.py | 7 ++++--- backend/app/models/history.py | 7 ++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index 39e55b1c..3e7a825f 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -33,6 +33,7 @@ class CreateList(Schema): validate=lambda a: a and not a.isspace() ) + class UpdateList(Schema): name = fields.String( validate=lambda a: a and not a.isspace() @@ -43,6 +44,13 @@ class GetItems(Schema): orderby = fields.Integer() +class GetRecentItems(Schema): + limit = fields.Integer( + load_default=9, + validate=lambda x: x > 0 and x < 50 + ) + + class UpdateDescription(Schema): description = fields.String( required=True diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 5d33d765..93698140 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -4,7 +4,7 @@ from app.models import Item, Shoppinglist, History, Status, Association, ShoppinglistItems from app.helpers import validate_args, authorize_household from .schemas import (RemoveItem, UpdateDescription, - AddItemByName, CreateList, AddRecipeItems, GetItems, UpdateList) + AddItemByName, CreateList, AddRecipeItems, GetItems, UpdateList, GetRecentItems) from app.errors import NotFoundRequest, InvalidUsage from datetime import datetime, timedelta, timezone import app.util.description_merger as description_merger @@ -98,13 +98,14 @@ def getAllShoppingListItems(args, id): @shoppinglist.route('//recent-items', methods=['GET']) @jwt_required() -def getRecentItems(id): +@validate_args(GetRecentItems) +def getRecentItems(args, id): shoppinglist = Shoppinglist.find_by_id(id) if not shoppinglist: raise NotFoundRequest() shoppinglist.checkAuthorized() - items = History.get_recent(id) + items = History.get_recent(id, args["limit"]) return jsonify([e.item.obj_to_dict() | {'description': e.description} for e in items]) diff --git a/backend/app/models/history.py b/backend/app/models/history.py index 0dc1abc7..52e006fc 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -23,7 +23,8 @@ class History(db.Model, DbModelMixin, TimestampMixin): item_id = db.Column(db.Integer, db.ForeignKey('item.id')) item = db.relationship("Item", uselist=False, back_populates="history") - shoppinglist = db.relationship("Shoppinglist", uselist=False, back_populates="history") + shoppinglist = db.relationship( + "Shoppinglist", uselist=False, back_populates="history") status = db.Column(db.Enum(Status)) description = db.Column('description', db.String()) @@ -73,10 +74,10 @@ def find_all(cls) -> list[Self]: return cls.query.all() @classmethod - def get_recent(cls, shoppinglist_id: int) -> list[Self]: + def get_recent(cls, shoppinglist_id: int, limit: int = 9) -> list[Self]: sq = db.session.query( ShoppinglistItems.item_id).subquery().select() sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( cls.item_id.notin_(sq)).group_by(cls.item_id).join(cls.item).subquery().select() return cls.query.filter( - cls.shoppinglist_id == shoppinglist_id).filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) + cls.shoppinglist_id == shoppinglist_id).filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(limit) From 9b81830e5df0964a32f84dd57a4d8d312c7ca087 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 15 Apr 2023 16:32:48 +0200 Subject: [PATCH 277/496] feat: parse scraped ingredients (TomBursch/kitchenowl-backend#26) --- backend/CONTRIBUTING.md | 3 ++- backend/Dockerfile | 2 ++ backend/app/controller/recipe/recipe_controller.py | 12 +++++++++++- backend/requirements.txt | 5 +++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md index facedd97..34f12157 100644 --- a/backend/CONTRIBUTING.md +++ b/backend/CONTRIBUTING.md @@ -34,7 +34,8 @@ The `description` is a descriptive summary of the change the PR will make. - Create a python environment `python3 -m venv venv` - Activate your python environment `source venv/bin/activate` (environment can be deactivated with `deactivate`) - Install dependencies `pip3 install -r requirements.txt` -- Initialize/Upgrade the sqlite database with `flask db upgrade` +- Initialize/Upgrade the SQLite database with `flask db upgrade` +- Initialize/Upgrade requirements for the recipe scraper `python -c "import nltk; nltk.download('averaged_perceptron_tagger')"` - Run debug server with `python3 wsgi.py` or without debugging `flask run` - The backend should be reachable at `localhost:5000` diff --git a/backend/Dockerfile b/backend/Dockerfile index a559bbf2..1191cd54 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -38,6 +38,8 @@ COPY migrations /usr/src/kitchenowl/migrations WORKDIR /usr/src/kitchenowl VOLUME ["/data"] +RUN python -c "import nltk; nltk.download('averaged_perceptron_tagger')" + ENV STORAGE_PATH='/data' ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' ENV DEBUG='False' diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index aca63c09..699cf942 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -13,6 +13,7 @@ from app.models import Recipe, Item, Tag from recipe_scrapers import scrape_me from recipe_scrapers._exceptions import SchemaOrgException +from ingredient_parser import parse_ingredient from werkzeug.utils import secure_filename from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest, ScrapeRecipe @@ -218,7 +219,16 @@ def scrapeRecipe(args): recipe.source = args['url'] items = {} for ingredient in scraper.ingredients(): - items[ingredient] = None + parsed = parse_ingredient(ingredient) + item = Item.find_by_name(1, parsed['name']) + if not item: + item = Item(name=parsed['name']) + + items[ingredient] = item.obj_to_dict() | { + "description": ' '.join( + filter(None, [parsed['quantity'] + parsed['unit'], parsed['comment']])), + "optional": False, + } return jsonify({ 'recipe': recipe.obj_to_dict(), 'items': items, diff --git a/backend/requirements.txt b/backend/requirements.txt index ec285fe6..2886ea7b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -26,6 +26,7 @@ greenlet==2.0.2 html-text==0.5.2 html5lib==1.1 idna==3.4 +ingredient-parser-nlp==0.1.0b1 iniconfig==2.0.0 isodate==0.6.1 itsdangerous==2.1.2 @@ -43,6 +44,7 @@ mccabe==0.7.0 mf2py==1.1.2 mlxtend==0.22.0 mypy-extensions==1.0.0 +nltk==3.8.1 numpy==1.24.2 packaging==23.0 pandas==2.0.0 @@ -58,6 +60,7 @@ PyJWT==2.6.0 pyparsing==3.0.9 pyRdfa3==3.5.3 pytest==7.2.2 +python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2023.3 @@ -76,8 +79,10 @@ SQLAlchemy==2.0.8 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 +tqdm==4.65.0 typed-ast==1.5.4 types-beautifulsoup4==4.12.0.1 +types-html5lib==1.1.11.10 types-requests==2.28.11.17 types-urllib3==1.26.25.10 typing_extensions==4.5.0 From b76a1e027c97487a08c49308ae1428910e050c71 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 15 Apr 2023 18:16:59 +0200 Subject: [PATCH 278/496] fix: for rootless docker --- backend/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 1191cd54..0dc783f5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,6 +16,8 @@ ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . RUN pip3 install --no-cache-dir -r requirements.txt && find /opt/venv \( -type d -a -name test -o -name tests \) -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ +RUN python -c "import nltk; nltk.download('averaged_perceptron_tagger', download_dir='/opt/venv/nltk_data')" + # ------------ # RUNNER # ------------ @@ -38,8 +40,6 @@ COPY migrations /usr/src/kitchenowl/migrations WORKDIR /usr/src/kitchenowl VOLUME ["/data"] -RUN python -c "import nltk; nltk.download('averaged_perceptron_tagger')" - ENV STORAGE_PATH='/data' ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' ENV DEBUG='False' From 41c4bb4955fd26c53ed2d877363397a33e4d1427 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 16 Apr 2023 17:31:32 +0200 Subject: [PATCH 279/496] fix: increase max recent items count --- backend/app/controller/shoppinglist/schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index 3e7a825f..a4117383 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -47,7 +47,7 @@ class GetItems(Schema): class GetRecentItems(Schema): limit = fields.Integer( load_default=9, - validate=lambda x: x > 0 and x < 50 + validate=lambda x: x > 0 and x <= 60 ) From 013ee3f673bc7f6a46f31d69630a88d2f9df80bd Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 16 Apr 2023 17:36:19 +0200 Subject: [PATCH 280/496] fix: recipe scrape endpoint --- backend/app/controller/recipe/recipe_controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 699cf942..730667aa 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -180,10 +180,10 @@ def getAllFiltered(args, household_id): return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(household_id, args["filter"])]) -@recipe.route('/scrape', methods=['GET', 'POST']) +@recipeHousehold.route('/scrape', methods=['GET', 'POST']) @jwt_required() @validate_args(ScrapeRecipe) -def scrapeRecipe(args): +def scrapeRecipe(args, household_id): scraper = scrape_me(args['url'], wild_mode=True) recipe = Recipe() recipe.name = scraper.title() @@ -220,7 +220,7 @@ def scrapeRecipe(args): items = {} for ingredient in scraper.ingredients(): parsed = parse_ingredient(ingredient) - item = Item.find_by_name(1, parsed['name']) + item = Item.find_by_name(household_id, parsed['name']) if not item: item = Item(name=parsed['name']) From e8188d2386110a5eff56527fe3c6967c1ee74fa9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 16 Apr 2023 17:39:57 +0200 Subject: [PATCH 281/496] fix: scrape access control --- backend/app/controller/recipe/recipe_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 730667aa..84384582 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -182,6 +182,7 @@ def getAllFiltered(args, household_id): @recipeHousehold.route('/scrape', methods=['GET', 'POST']) @jwt_required() +@authorize_household() @validate_args(ScrapeRecipe) def scrapeRecipe(args, household_id): scraper = scrape_me(args['url'], wild_mode=True) From 0365bf8a55984412fc4db910a9a4b869b7af15f8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Apr 2023 15:56:38 +0200 Subject: [PATCH 282/496] feat: filter expenses --- backend/app/controller/expense/expense_controller.py | 7 +++++-- backend/app/controller/expense/schemas.py | 5 +++++ backend/app/util/__init__.py | 1 + backend/app/util/multi_dict_list.py | 8 ++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 backend/app/util/multi_dict_list.py diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index a33e2d97..b8b7ef17 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -21,14 +21,17 @@ @validate_args(GetExpenses) def getAllExpenses(args, household_id): filter = [Expense.household_id == household_id] - if ('startAfterId' in args): + if 'startAfterId' in args: filter.append(Expense.id < args['startAfterId']) - if ('view' in args and args['view'] == 1): + if 'view' in args and args['view'] == 1: subquery = db.session.query(ExpensePaidFor.expense_id).filter( ExpensePaidFor.user_id == current_user.id).scalar_subquery() filter.append(Expense.id.in_(subquery)) + if 'filter' in args: + filter.append(Expense.category_id.in_(args['filter'])) + return jsonify([e.obj_to_full_dict() for e in Expense.query.order_by(desc(Expense.date)).filter(*filter) .join(Expense.category, isouter=True).limit(30).all() diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 0d745dc6..eef97585 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -1,11 +1,16 @@ from marshmallow import fields, Schema +from app.util import MultiDictList + class GetExpenses(Schema): view = fields.Integer() startAfterId = fields.Integer( validate=lambda a: a >= 0 ) + filter = MultiDictList(fields.Integer( + allow_none=True + )) class AddExpense(Schema): diff --git a/backend/app/util/__init__.py b/backend/app/util/__init__.py index 79ac2f91..4bba60df 100644 --- a/backend/app/util/__init__.py +++ b/backend/app/util/__init__.py @@ -1 +1,2 @@ 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 new file mode 100644 index 00000000..ff9331b4 --- /dev/null +++ b/backend/app/util/multi_dict_list.py @@ -0,0 +1,8 @@ +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) From 1c23acc4673f2f74a0bb1505749ead8f5a7a8c43 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 18 Apr 2023 23:42:59 +0200 Subject: [PATCH 283/496] fix: filter expenses --- backend/app/controller/expense/expense_controller.py | 7 ++++++- backend/app/controller/expense/schemas.py | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index b8b7ef17..0b95b9dd 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone from dateutil.relativedelta import relativedelta from sqlalchemy.sql.expression import desc +from sqlalchemy import or_ from app.errors import NotFoundRequest from flask import jsonify, Blueprint from flask_jwt_extended import current_user, jwt_required @@ -30,7 +31,11 @@ def getAllExpenses(args, household_id): filter.append(Expense.id.in_(subquery)) if 'filter' in args: - filter.append(Expense.category_id.in_(args['filter'])) + if None in args['filter']: + filter.append(or_(Expense.category_id == None, + Expense.category_id.in_(args['filter']))) + else: + filter.append(Expense.category_id.in_(args['filter'])) return jsonify([e.obj_to_full_dict() for e in Expense.query.order_by(desc(Expense.date)).filter(*filter) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index eef97585..41e5e9d3 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -3,12 +3,19 @@ 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 ) - filter = MultiDictList(fields.Integer( + filter = MultiDictList(CustomInteger( allow_none=True )) From 6390dd6341e25dd433565539e8fe8ecb05ae77f8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 20 Apr 2023 23:56:07 +0200 Subject: [PATCH 284/496] feat: image access restrictions --- .../controller/expense/expense_controller.py | 10 +- .../household/household_controller.py | 10 +- .../controller/recipe/recipe_controller.py | 9 +- .../controller/upload/upload_controller.py | 35 +++++- .../app/controller/user/user_controller.py | 8 +- .../app/helpers/db_model_authorize_mixin.py | 6 +- backend/app/models/__init__.py | 1 + backend/app/models/expense.py | 3 +- backend/app/models/file.py | 27 +++++ backend/app/models/household.py | 9 +- backend/app/models/recipe.py | 3 +- backend/app/models/user.py | 3 +- backend/migrations/versions/c058421705ec_.py | 101 ++++++++++++++++++ 13 files changed, 199 insertions(+), 26 deletions(-) create mode 100644 backend/app/models/file.py create mode 100644 backend/migrations/versions/c058421705ec_.py diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 0b95b9dd..f2cba7e5 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -9,7 +9,7 @@ from sqlalchemy import func from app import db from app.helpers import validate_args, authorize_household, RequiredRights -from app.models import Expense, ExpensePaidFor, ExpenseCategory, HouseholdMember +from app.models import Expense, ExpensePaidFor, ExpenseCategory, HouseholdMember, File from .schemas import GetExpenses, AddExpense, UpdateExpense, AddExpenseCategory, UpdateExpenseCategory, GetExpenseOverview expense = Blueprint('expense', __name__) @@ -69,7 +69,9 @@ def addExpense(args, household_id): expense.date = datetime.fromtimestamp( args['date']/1000, timezone.utc) if 'photo' in args: - expense.photo = args['photo'] + f = File.find(args['photo']) + if f and f.created_by == current_user.id: + expense.photo = f.filename if 'category' in args: if args['category'] is not None: category = ExpenseCategory.find_by_id(args['category']) @@ -114,7 +116,9 @@ def updateExpense(args, id): # noqa: C901 expense.date = datetime.fromtimestamp( args['date']/1000, timezone.utc) if 'photo' in args: - expense.photo = args['photo'] + f = File.find(args['photo']) + if f and f.created_by == current_user.id: + expense.photo = f.filename if 'category' in args: if args['category'] is not None: category = ExpenseCategory.find_by_id(args['category']) diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index 9c4be943..0a6d26ec 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -3,7 +3,7 @@ from flask import jsonify, Blueprint from app.errors import NotFoundRequest from flask_jwt_extended import current_user, jwt_required -from app.models import Household, HouseholdMember, Shoppinglist +from app.models import Household, HouseholdMember, Shoppinglist, File from app.service.export_import import importLanguage from .schemas import AddHousehold, UpdateHousehold, UpdateHouseholdMember @@ -33,7 +33,9 @@ def addHousehold(args): household = Household() household.name = args['name'] if 'photo' in args: - household.photo = args['photo'] + f = File.find(args['photo']) + if f and f.created_by == current_user.id: + household.photo = f.filename if 'language' in args and args['language'] in SUPPORTED_LANGUAGES: household.language = args['language'] if 'planner_feature' in args: @@ -70,7 +72,9 @@ def updateHousehold(args, household_id): if 'name' in args: household.name = args['name'] if 'photo' in args: - household.photo = args['photo'] + f = File.find(args['photo']) + if f and f.created_by == current_user.id: + household.photo = f.filename if 'language' in args and not household.language and args['language'] in SUPPORTED_LANGUAGES: household.language = args['language'] importLanguage(household.id, household.language) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 84384582..14fde927 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -8,9 +8,9 @@ from app.errors import NotFoundRequest from app.models.recipe import RecipeItems, RecipeTags from flask import jsonify, Blueprint -from flask_jwt_extended import jwt_required +from flask_jwt_extended import current_user, jwt_required from app.helpers import validate_args, authorize_household -from app.models import Recipe, Item, Tag +from app.models import Recipe, Item, Tag, File from recipe_scrapers import scrape_me from recipe_scrapers._exceptions import SchemaOrgException from ingredient_parser import parse_ingredient @@ -243,9 +243,12 @@ def upload_file_if_needed(url: str): ext = guess_extension(resp.headers['content-type']) if allowed_file('file' + ext): filename = secure_filename(str(uuid.uuid4()) + ext) + File(filename=filename, created_by=current_user.id).save() with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as o: o.write(resp.content) return filename elif url is not None: - return url + f = File.find(url) + if f and f.created_by == current_user.id: + return f.filename return None diff --git a/backend/app/controller/upload/upload_controller.py b/backend/app/controller/upload/upload_controller.py index 579af95a..aa0bad68 100644 --- a/backend/app/controller/upload/upload_controller.py +++ b/backend/app/controller/upload/upload_controller.py @@ -2,10 +2,12 @@ import uuid from flask import jsonify, Blueprint, send_from_directory, request -from flask_jwt_extended import jwt_required +from flask_jwt_extended import current_user, jwt_required from werkzeug.utils import secure_filename from app.config import UPLOAD_FOLDER +from app.errors import ForbiddenRequest, NotFoundRequest +from app.models import File from app.util.filename_validator import allowed_file upload = Blueprint('upload', __name__) @@ -26,13 +28,36 @@ def upload_file(): if file and allowed_file(file.filename): filename = secure_filename(str(uuid.uuid4()) + '.' + file.filename.rsplit('.', 1)[1].lower()) + f = File(filename=filename, created_by=current_user.id).save() file.save(os.path.join(UPLOAD_FOLDER, filename)) - return jsonify({'name': filename}) + return jsonify(f.obj_to_dict()) raise Exception("Invalid usage.") -@upload.route('', methods=['GET']) +@upload.route('', methods=['GET']) @jwt_required() -def download_file(name): - return send_from_directory(UPLOAD_FOLDER, name) +def download_file(filename): + filename = secure_filename(filename) + f: File = File.query.filter(File.filename == filename).first() + + if not f: + raise NotFoundRequest() + + if f.household or f.recipe: + household_id = None + if f.household: + household_id = f.household.id + if f.recipe: + household_id = f.recipe.household_id + if f.expense: + household_id = f.expense.household_id + f.checkAuthorized(household_id=household_id) + elif f.created_by and current_user and f.created_by == current_user.id: + pass # created by user can access his pictures + elif f.profile_picture: + pass # profile pictures are public + else: + raise ForbiddenRequest() + + return send_from_directory(UPLOAD_FOLDER, filename) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index a71e5df6..4711b537 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -3,7 +3,7 @@ from app.helpers import validate_args from flask import jsonify, Blueprint from flask_jwt_extended import current_user, jwt_required -from app.models import User +from app.models import User, File from .schemas import CreateUser, UpdateUser, SearchByNameRequest @@ -58,13 +58,17 @@ def deleteUserById(id): @jwt_required() @validate_args(UpdateUser) def updateUser(args): - user = current_user + user: User = current_user if not user: raise NotFoundRequest() if 'name' in args: user.name = args['name'] if 'password' in args: user.set_password(args['password']) + if 'photo' in args: + f = File.find(args['photo']) + if f and f.created_by == user.id: + user.photo = f.filename user.save() return jsonify({'msg': 'DONE'}) diff --git a/backend/app/helpers/db_model_authorize_mixin.py b/backend/app/helpers/db_model_authorize_mixin.py index 4a2c9db8..9eed1550 100644 --- a/backend/app/helpers/db_model_authorize_mixin.py +++ b/backend/app/helpers/db_model_authorize_mixin.py @@ -4,17 +4,17 @@ class DbModelAuthorizeMixin(object): - def checkAuthorized(self, requires_admin=False): + def checkAuthorized(self, requires_admin=False, household_id: int = None): """ Checks if current user ist authorized to access this model. Throws and unauthorized exception if not IMPORTANT: requires household_id """ - if not hasattr(self, 'household_id'): + if not household_id and not hasattr(self, 'household_id'): raise Exception("Wrong usage of authorize_household") if not current_user: raise UnauthorizedRequest() member = app.models.household.HouseholdMember.find_by_ids( - self.household_id, current_user.id) + household_id or self.household_id, current_user.id) if not current_user.admin: if not member or requires_admin and not (member.admin or member.owner): raise ForbiddenRequest() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 541adb09..33a2c001 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,3 +13,4 @@ from .category import Category from .token import Token from .household import Household, HouseholdMember +from .file import File diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index 7b8b294f..a37ed30a 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -12,7 +12,7 @@ class Expense(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): amount = db.Column(db.Float()) date = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) category_id = db.Column(db.Integer, db.ForeignKey('expense_category.id')) - photo = db.Column(db.String()) + photo = db.Column(db.String(), db.ForeignKey('file.filename')) paid_by_id = db.Column(db.Integer, db.ForeignKey('user.id')) household_id = db.Column(db.Integer, db.ForeignKey('household.id'), nullable=False) @@ -21,6 +21,7 @@ class Expense(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): paid_by = db.relationship("User") paid_for = db.relationship( 'ExpensePaidFor', back_populates='expense', cascade="all, delete-orphan") + photo_file = db.relationship("File", back_populates='expense', uselist=False) def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() diff --git a/backend/app/models/file.py b/backend/app/models/file.py new file mode 100644 index 00000000..26df8d93 --- /dev/null +++ b/backend/app/models/file.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from typing import Self +from app import db +from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin +from app.models.user import User + + +class File(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): + __tablename__ = 'file' + + filename = db.Column(db.String(), primary_key=True) + created_by = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + + created_by_user = db.relationship("User", foreign_keys=[created_by], uselist=False) + + household = db.relationship("Household", uselist=False) + recipe = db.relationship("Recipe", uselist=False) + expense = db.relationship("Expense", uselist=False) + profile_picture = db.relationship("User", foreign_keys=[User.photo], uselist=False) + + @classmethod + def find(cls, filename: str) -> Self: + """ + Find the row with specified id + """ + return cls.query.filter(cls.filename == filename).first() + diff --git a/backend/app/models/household.py b/backend/app/models/household.py index b868765c..9217c10a 100644 --- a/backend/app/models/household.py +++ b/backend/app/models/household.py @@ -8,11 +8,11 @@ class Household(db.Model, DbModelMixin, TimestampMixin): __tablename__ = 'household' id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(128)) - photo = db.Column(db.String()) + name = db.Column(db.String(128), nullable=False) + photo = db.Column(db.String(), db.ForeignKey('file.filename')) language = db.Column(db.String()) - planner_feature = db.Column(db.Boolean(), default=True) - expenses_feature = db.Column(db.Boolean(), default=True) + planner_feature = db.Column(db.Boolean(), nullable=False, default=True) + expenses_feature = db.Column(db.Boolean(), nullable=False, default=True) view_ordering = db.Column(DbListType(), default=list()) @@ -34,6 +34,7 @@ class Household(db.Model, DbModelMixin, TimestampMixin): 'Shoppinglist', back_populates='household', cascade="all, delete-orphan") member = db.relationship( 'HouseholdMember', back_populates='household', cascade="all, delete-orphan") + photo_file = db.relationship("File", back_populates='household', uselist=False) def obj_to_dict(self) -> dict: res = super().obj_to_dict() diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 53a27fa8..364f16df 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -14,7 +14,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) description = db.Column(db.String()) - photo = db.Column(db.String()) + photo = db.Column(db.String(), db.ForeignKey('file.filename')) time = db.Column(db.Integer) cook_time = db.Column(db.Integer) prep_time = db.Column(db.Integer) @@ -34,6 +34,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): 'RecipeTags', back_populates='recipe', cascade="all, delete-orphan") plans = db.relationship( 'Planner', back_populates='recipe', cascade="all, delete-orphan") + photo_file = db.relationship("File", back_populates='recipe', uselist=False) def obj_to_dict(self) -> dict: res = super().obj_to_dict() diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8587432c..2f829863 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,7 +13,7 @@ class User(db.Model, DbModelMixin, TimestampMixin): name = db.Column(db.String(128)) username = db.Column(db.String(256), unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) - photo = db.Column(db.String()) + photo = db.Column(db.String(), db.ForeignKey('file.filename')) admin = db.Column(db.Boolean(), default=False) tokens = db.relationship( @@ -26,6 +26,7 @@ class User(db.Model, DbModelMixin, TimestampMixin): 'Expense', back_populates='paid_by', cascade="all, delete-orphan") expenses_paid_for = db.relationship( 'ExpensePaidFor', back_populates='user', cascade="all, delete-orphan") + photo_file = db.relationship("File", back_populates='profile_picture', foreign_keys=[photo], uselist=False) def check_password(self, password: str) -> bool: return bcrypt.check_password_hash(self.password, password) diff --git a/backend/migrations/versions/c058421705ec_.py b/backend/migrations/versions/c058421705ec_.py new file mode 100644 index 00000000..d158bade --- /dev/null +++ b/backend/migrations/versions/c058421705ec_.py @@ -0,0 +1,101 @@ +"""empty message + +Revision ID: c058421705ec +Revises: 6c669d9ec3bd +Create Date: 2023-04-20 16:28:00.255353 + +""" +from datetime import datetime +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm +from os import listdir +from os.path import isfile, join + +from app.config import UPLOAD_FOLDER + +DeclarativeBase = orm.declarative_base() + +# revision identifiers, used by Alembic. +revision = 'c058421705ec' +down_revision = '6c669d9ec3bd' +branch_labels = None +depends_on = None + +class File(DeclarativeBase): + __tablename__ = 'file' + filename = sa.Column(sa.String, primary_key=True) + created_at = sa.Column(sa.DateTime, nullable=False, default=datetime.utcnow) + updated_at = sa.Column(sa.DateTime, nullable=False, default=datetime.utcnow) + user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) + +class Recipe(DeclarativeBase): + __tablename__ = 'recipe' + id = sa.Column(sa.Integer, primary_key=True) + photo = sa.Column(sa.String()) + +class Household(DeclarativeBase): + __tablename__ = 'household' + id = sa.Column(sa.Integer, primary_key=True) + photo = sa.Column(sa.String()) + +class User(DeclarativeBase): + __tablename__ = 'user' + id = sa.Column(sa.Integer, primary_key=True) + admin = sa.Column(sa.Boolean(), default=False) + photo = sa.Column(sa.String()) + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('file', + sa.Column('filename', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], name=op.f('fk_file_created_by_user')), + sa.PrimaryKeyConstraint('filename', name=op.f('pk_file')), + ) + # ### end Alembic commands ### + bind = op.get_bind() + session = orm.Session(bind=bind) + + filesInUploadFolder = [f for f in listdir(UPLOAD_FOLDER) if isfile(join(UPLOAD_FOLDER, f))] + + try: + files = [File(filename=f) for f in filesInUploadFolder] + + session.bulk_save_objects(files) + session.commit() + except Exception as e: + session.rollback() + raise e + + with op.batch_alter_table('household', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_household_photo_file'), 'file', ['photo'], ['filename']) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_recipe_photo_file'), 'file', ['photo'], ['filename']) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_expense_photo_file'), 'file', ['photo'], ['filename']) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_user_photo_file'), 'file', ['photo'], ['filename']) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_recipe_photo_file'), type_='foreignkey') + + with op.batch_alter_table('household', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_household_photo_file'), type_='foreignkey') + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_expense_photo_file'), type_='foreignkey') + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_user_photo_file'), type_='foreignkey') + + op.drop_table('file') + # ### end Alembic commands ### From b47891a1fa8f4b05c277379a043abc2c51d74bce Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 21 Apr 2023 01:58:19 +0200 Subject: [PATCH 285/496] feat: different expense overview time frames --- .../controller/expense/expense_controller.py | 38 +++++++++++++------ backend/app/controller/expense/schemas.py | 5 ++- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index f2cba7e5..5920c405 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -1,5 +1,5 @@ import calendar -from datetime import datetime, timezone +from datetime import time, datetime, timezone, timedelta from dateutil.relativedelta import relativedelta from sqlalchemy.sql.expression import desc from sqlalchemy import or_ @@ -206,7 +206,8 @@ def getExpenseOverview(args, household_id): categories.append(-1) thisMonthStart = datetime.utcnow().date().replace(day=1) - months = args['months'] if 'months' in args else 5 + steps = args['steps'] if 'steps' in args else 5 + frame = args['frame'] if args['frame'] != None else 2 factor = 1 query = Expense.query\ @@ -228,24 +229,39 @@ def getExpenseOverview(args, household_id): query = query.filter(Expense.id.in_(filterQuery)).join(s2) - def getOverviewForMonthAgo(monthAgo: int): - monthStart = thisMonthStart - relativedelta(months=monthAgo) - monthEnd = monthStart.replace(day=calendar.monthrange( - monthStart.year, monthStart.month)[1]) + def getFilterForStepAgo(stepAgo: int): + start = None + end = None + if frame == 0: + start = datetime.utcnow().date() - timedelta(days=stepAgo) + end = start + timedelta(hours=24) + elif frame == 1: + start = datetime.utcnow().date() - relativedelta(days=7, weekday=calendar.MONDAY, weeks=stepAgo) + end = start + timedelta(days=7) + elif frame == 2: + start = thisMonthStart - relativedelta(months=stepAgo) + end = start + relativedelta(months=1) + elif frame == 3: + start = datetime.utcnow().date().replace(day=1, month=1) - relativedelta(years=stepAgo) + end = start + relativedelta(years=1) + + return Expense.date >= start, Expense.date <= end + + def getOverviewForStepAgo(stepAgo: int): return { (e.id or -1): (float(e.balance) or 0) for e in query .with_entities(ExpenseCategory.id.label("id"), func.sum(Expense.amount * factor).label("balance")) - .filter(Expense.date >= monthStart, Expense.date <= monthEnd) + .filter(*getFilterForStepAgo(stepAgo)) .all() } - value = [getOverviewForMonthAgo(i) for i in range(0, months)] + value = [getOverviewForStepAgo(i) for i in range(0, steps)] - byMonth = {i: {category: (value[i][category] if category in value[i] else 0.0) - for category in categories} for i in range(0, months)} + byStep = {i: {category: (value[i][category] if category in value[i] else 0.0) + for category in categories} for i in range(0, steps)} - return jsonify(byMonth) + return jsonify(byStep) @expenseHousehold.route('/categories', methods=['POST']) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 41e5e9d3..159a8f18 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -96,6 +96,9 @@ class UpdateExpenseCategory(Schema): class GetExpenseOverview(Schema): view = fields.Integer() - months = fields.Integer( + frame = fields.Integer( + validate=lambda a: a >= 0 and a <= 3 + ) + steps = fields.Integer( validate=lambda a: a > 0 ) From a13b7a7dffca3fb1848bf03350a40fcb9d512633 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 21 Apr 2023 03:05:21 +0200 Subject: [PATCH 286/496] Translated using Weblate (Spanish) (TomBursch/kitchenowl-backend#27) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ Co-authored-by: gallegonovato --- backend/templates/l10n/es.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index 7a78d2b2..c6147936 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -17,7 +17,7 @@ "apple": "Manzana", "apple_pulp": "Pulpa de la manzana", "applesauce": "Compota de manzana", - "apérol": "Apérol", + "apérol": "Aperol", "arugula": "Rúcula", "asian_egg_noodles": "Fideos asiáticos al huevo", "asparagus": "Espárragos", @@ -45,15 +45,15 @@ "beet": "Remolacha", "beetroot": "Remolacha", "birthday_card": "Tarjeta de cumpleaños", - "black_beans": "Alubias negras", + "black_beans": "Frijol negro", "bockwurst": "Bockwurst", - "bodywash": "Jabón líquido", + "bodywash": "Gel de baño", "bread": "Pan", "breadcrumbs": "Migas de pan", - "broccoli": "Brócoli", + "broccoli": "Brécol", "brown_sugar": "Azúcar moreno", "brussels_sprouts": "Coles de Bruselas", - "buffalo_mozzarella": "Mozzarella de búfala", + "buffalo_mozzarella": "Queso Mozzarella de búfala campana", "buko": "Coco", "buns": "Bollos", "burger_buns": "Pan de hamburguesa", @@ -64,10 +64,10 @@ "button_cells": "Pilas de botón", "börek_cheese": "Queso Börek", "cake": "Pastel", - "cake_icing": "Glaseado para tartas", + "cake_icing": "Glaseado", "cane_sugar": "Azúcar de caña", "cannelloni": "Canelones", - "canola_oil": "Aceite de canola", + "canola_oil": "Aceite de colza", "cardamom": "Cardamomo", "carrots": "Zanahorias", "cashews": "Anacardos", @@ -76,7 +76,7 @@ "celeriac": "Apionabo", "celery": "Apio", "cereal_bar": "Barra de cereales", - "cheddar": "Cheddar", + "cheddar": "Queso cheddar", "cheese": "Queso", "cherry_tomatoes": "Tomates cherry", "chickpeas": "Garbanzos", @@ -84,6 +84,7 @@ "chips": "Fichas", "chives": "Cebollino", "chocolate": "Chocolate", + "chocolate_chips": "Chispas de chocolate", "chopped_tomatoes": "Tomates picados", "ciabatta": "Chapata", "cider_vinegar": "Vinagre de sidra", @@ -95,7 +96,7 @@ "coconut_flakes": "Copos de coco", "coconut_milk": "Leche de coco", "coconut_oil": "Aceite de coco", - "colorful_sprinkles": "Espolvoreado de colores", + "colorful_sprinkles": "Virutas de colores", "concealer": "Corrector", "cookies": "Cookies", "coriander": "Cilantro", @@ -105,7 +106,7 @@ "cornys": "Cornys", "cough_drops": "Pastillas para la tos", "couscous": "Cuscús", - "covid_rapid_test": "Prueba rápida COVID", + "covid_rapid_test": "Test rápido del COVID", "cow's_milk": "Leche de vaca", "cream": "Crema", "cream_cheese": "Queso cremoso", @@ -133,7 +134,7 @@ "falafel": "Faláfel", "falafel_powder": "Falafel en polvo", "fanta": "Fanta", - "feta": "Feta", + "feta": "Queso Feta", "ffp2": "FFP2", "fish_sticks": "Palitos de pescado", "flour": "Harina", @@ -154,8 +155,8 @@ "gluten": "Gluten", "gnocchi": "Ñoqui", "gochujang": "Gochujang", - "gorgonzola": "Gorgonzola", - "gouda": "Gouda", + "gorgonzola": "Queso Gorgonzola", + "gouda": "Queso Gouda", "grapes": "Uvas", "greek_yogurt": "Yogur griego", "green_asparagus": "Espárragos verdes", @@ -324,7 +325,6 @@ "scattered_cheese": "Queso esparcido", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln (ñoquis alemanes)", - "chocolate_chips": "Chispas de chocolate", "semolina_porridge": "Gachas de sémola", "sesame": "Sésamo", "sesame_oil": "Aceite de sésamo", From 219cc3c6fba71fcdeac51b8c1efb5c6f5e981795 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 21 Apr 2023 03:07:20 +0200 Subject: [PATCH 287/496] Prepare beta 57 --- backend/app/config.py | 4 ++-- backend/requirements.txt | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 9d7ae86b..6e94a164 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,8 +12,8 @@ import os -MIN_FRONTEND_VERSION = 67 -BACKEND_VERSION = 56 +MIN_FRONTEND_VERSION = 71 +BACKEND_VERSION = 57 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) diff --git a/backend/requirements.txt b/backend/requirements.txt index 2886ea7b..2541c2f5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,10 +1,10 @@ alembic==1.10.3 appdirs==1.4.4 APScheduler==3.10.1 -attrs==22.2.0 +attrs==23.1.0 autopep8==2.0.2 bcrypt==4.0.1 -beautifulsoup4==4.12.1 +beautifulsoup4==4.12.2 black==23.1a1 certifi==2022.12.7 cffi==1.15.1 @@ -46,7 +46,7 @@ mlxtend==0.22.0 mypy-extensions==1.0.0 nltk==3.8.1 numpy==1.24.2 -packaging==23.0 +packaging==23.1 pandas==2.0.0 pathspec==0.11.1 Pillow==9.5.0 @@ -59,7 +59,7 @@ pyflakes==3.0.1 PyJWT==2.6.0 pyparsing==3.0.9 pyRdfa3==3.5.3 -pytest==7.2.2 +pytest==7.3.1 python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 @@ -67,22 +67,22 @@ pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==6.3.2 rdflib-jsonld==0.6.2 -recipe-scrapers==14.36.0 +recipe-scrapers==14.36.1 regex==2023.3.23 requests==2.28.2 scikit-learn==1.2.2 scipy==1.10.1 setuptools-scm==7.1.0 six==1.16.0 -soupsieve==2.4 -SQLAlchemy==2.0.8 +soupsieve==2.4.1 +SQLAlchemy==2.0.9 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 tqdm==4.65.0 typed-ast==1.5.4 -types-beautifulsoup4==4.12.0.1 -types-html5lib==1.1.11.10 +types-beautifulsoup4==4.12.0.3 +types-html5lib==1.1.11.13 types-requests==2.28.11.17 types-urllib3==1.26.25.10 typing_extensions==4.5.0 From f4f8085ff13325c671af7af22f7890e8c95a3092 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 21 Apr 2023 13:00:37 +0200 Subject: [PATCH 288/496] fix: recipe search --- backend/app/controller/recipe/recipe_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 14fde927..7b2c890c 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -168,7 +168,7 @@ def deleteRecipeById(id): @validate_args(SearchByNameRequest) def searchRecipeByName(args, household_id): if 'only_ids' in args and args['only_ids']: - return jsonify([e.id for e in Recipe.search_name(args['query'])]) + return jsonify([e.id for e in Recipe.search_name(household_id, args['query'])]) return jsonify([e.obj_to_dict() for e in Recipe.search_name(household_id, args['query'])]) From 9aeb229c103fac6737f9c4852d210938ae98380c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 21 Apr 2023 19:47:40 +0200 Subject: [PATCH 289/496] fix: debug for web --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 6e94a164..c86bba0a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -88,7 +88,7 @@ def add_cors_headers(response): return response r = request.referrer[:-1] url = os.environ['FRONT_URL'] if 'FRONT_URL' in os.environ else None - if url and r == url: + if app.debug or url and r == url: response.headers.add('Access-Control-Allow-Origin', r) response.headers.add('Access-Control-Allow-Credentials', 'true') response.headers.add('Access-Control-Allow-Headers', 'Content-Type') From 686b653865d8591d06550a183b577983695e2464 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 25 Apr 2023 15:14:42 +0200 Subject: [PATCH 290/496] feat: add health-check --- backend/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 0dc783f5..d07c6b4f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,7 +25,7 @@ FROM python:3.11-slim as runner RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - libxml2 \ + libxml2 curl \ && rm -rf /var/lib/apt/lists/* # Use virtual enviroment @@ -40,6 +40,8 @@ COPY migrations /usr/src/kitchenowl/migrations WORKDIR /usr/src/kitchenowl VOLUME ["/data"] +HEALTHCHECK --interval=60s --timeout=3s CMD curl -f http://localhost/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V || exit 1 + ENV STORAGE_PATH='/data' ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' ENV DEBUG='False' From 753ec114ea22e30038ab7443e2bf36e2117e5f94 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Apr 2023 21:29:10 +0200 Subject: [PATCH 291/496] fix: image gets removed by editing --- backend/app/controller/expense/expense_controller.py | 4 ++-- backend/app/controller/household/household_controller.py | 4 ++-- backend/app/controller/recipe/recipe_controller.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 5920c405..c1a3ae51 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -68,7 +68,7 @@ def addExpense(args, household_id): if 'date' in args: expense.date = datetime.fromtimestamp( args['date']/1000, timezone.utc) - if 'photo' in args: + if 'photo' in args and args['photo'] != expense.photo: f = File.find(args['photo']) if f and f.created_by == current_user.id: expense.photo = f.filename @@ -115,7 +115,7 @@ def updateExpense(args, id): # noqa: C901 if 'date' in args: expense.date = datetime.fromtimestamp( args['date']/1000, timezone.utc) - if 'photo' in args: + if 'photo' in args and args['photo'] != expense.photo: f = File.find(args['photo']) if f and f.created_by == current_user.id: expense.photo = f.filename diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index 0a6d26ec..49892741 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -32,7 +32,7 @@ def getHousehold(household_id): def addHousehold(args): household = Household() household.name = args['name'] - if 'photo' in args: + if 'photo' in args and args['photo'] != household.photo: f = File.find(args['photo']) if f and f.created_by == current_user.id: household.photo = f.filename @@ -71,7 +71,7 @@ def updateHousehold(args, household_id): if 'name' in args: household.name = args['name'] - if 'photo' in args: + if 'photo' in args and args['photo'] != household.photo: f = File.find(args['photo']) if f and f.created_by == current_user.id: household.photo = f.filename diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 7b2c890c..0ff0f0e9 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -57,7 +57,7 @@ def addRecipe(args, household_id): recipe.yields = args['yields'] if 'source' in args: recipe.source = args['source'] - if 'photo' in args: + if 'photo' in args and args['photo'] != recipe.photo: recipe.photo = upload_file_if_needed(args['photo']) recipe.save() if 'items' in args: @@ -107,7 +107,7 @@ def updateRecipe(args, id): # noqa: C901 recipe.yields = args['yields'] if 'source' in args: recipe.source = args['source'] - if 'photo' in args: + if 'photo' in args and args['photo'] != recipe.photo: recipe.photo = upload_file_if_needed(args['photo']) recipe.save() if 'items' in args: From a0a71bbf8f8678ab65d936c55caed3950b5f0b69 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 27 Apr 2023 21:29:53 +0200 Subject: [PATCH 292/496] Prepare beta 58 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index c86bba0a..95be7404 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,7 +13,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 57 +BACKEND_VERSION = 58 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 9350b5710b70d68df28d9cffa85f7b970f1fec89 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 2 May 2023 17:20:30 +0200 Subject: [PATCH 293/496] fix: bug failed to add recipe with new items (TomBursch/kitchenowl-backend#30) --- .../app/controller/shoppinglist/shoppinglist_controller.py | 1 - backend/app/models/item.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 93698140..98389718 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -181,7 +181,6 @@ def addShoppinglistItemByName(args, id): item = Item.find_by_name(shoppinglist.household_id, args['name']) if not item: item = Item.create_by_name(shoppinglist.household_id, args['name']) - # item.checkAuthorized() con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if not con: diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 50a0815e..1626f34b 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -54,10 +54,10 @@ def obj_to_export_dict(self) -> dict: def save(self, keepDefault=False) -> Self: if not keepDefault: self.default = False - super().save() + return super().save() @classmethod - def create_by_name(cls, household_id: int, name: str, default=False) -> Self: + def create_by_name(cls, household_id: int, name: str, default: bool = False) -> Self: return cls( name=name.strip(), default=default, From 37174fde9f574cb5821ea716e560960a29228c6f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 2 May 2023 17:23:06 +0200 Subject: [PATCH 294/496] chore: upgrade requirements --- backend/requirements.txt | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 2541c2f5..0dadf64f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -alembic==1.10.3 +alembic==1.10.4 appdirs==1.4.4 APScheduler==3.10.1 attrs==23.1.0 @@ -15,7 +15,7 @@ cycler==0.11.0 dbscan1d==0.2.2 extruct==0.14.0 flake8==6.0.0 -Flask==2.2.3 +Flask==2.3.2 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 @@ -45,12 +45,12 @@ mf2py==1.1.2 mlxtend==0.22.0 mypy-extensions==1.0.0 nltk==3.8.1 -numpy==1.24.2 +numpy==1.24.3 packaging==23.1 -pandas==2.0.0 +pandas==2.0.1 pathspec==0.11.1 Pillow==9.5.0 -platformdirs==3.2.0 +platformdirs==3.5.0 pluggy==1.0.0 py==1.11.0 pycodestyle==2.10.0 @@ -69,22 +69,22 @@ rdflib==6.3.2 rdflib-jsonld==0.6.2 recipe-scrapers==14.36.1 regex==2023.3.23 -requests==2.28.2 +requests==2.29.0 scikit-learn==1.2.2 scipy==1.10.1 setuptools-scm==7.1.0 six==1.16.0 soupsieve==2.4.1 -SQLAlchemy==2.0.9 +SQLAlchemy==2.0.12 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 tqdm==4.65.0 typed-ast==1.5.4 -types-beautifulsoup4==4.12.0.3 +types-beautifulsoup4==4.12.0.4 types-html5lib==1.1.11.13 -types-requests==2.28.11.17 -types-urllib3==1.26.25.10 +types-requests==2.29.0.0 +types-urllib3==1.26.25.12 typing_extensions==4.5.0 tzdata==2023.3 tzlocal==4.3 @@ -92,4 +92,4 @@ urllib3==1.26.15 uWSGI==2.0.21 w3lib==2.1.1 webencodings==0.5.1 -Werkzeug==2.2.3 +Werkzeug==2.3.3 From 8af793d7d0d79b8171d7e51a08561532e6d12133 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 4 May 2023 14:54:15 +0200 Subject: [PATCH 295/496] feat: Update export & import --- .../controller/expense/expense_controller.py | 23 +-- .../exportimport/export_controller.py | 12 +- .../exportimport/import_controller.py | 32 +++- .../app/controller/exportimport/schemas.py | 58 ++++++- .../household/household_controller.py | 2 +- backend/app/models/expense.py | 13 ++ backend/app/models/expense_category.py | 14 +- backend/app/models/file.py | 2 +- backend/app/models/household.py | 19 ++- backend/app/models/item.py | 2 + backend/app/models/recipe.py | 1 + backend/app/models/user.py | 2 +- backend/app/service/export_import.py | 151 ------------------ .../app/service/importServices/__init__.py | 4 + .../service/importServices/import_expense.py | 45 ++++++ .../app/service/importServices/import_item.py | 20 +++ .../service/importServices/import_recipe.py | 59 +++++++ .../importServices/import_shoppinglist.py | 6 + backend/app/service/import_language.py | 57 +++++++ backend/app/service/recalculate_balances.py | 17 ++ backend/manage.py | 21 ++- backend/upgrade_default_items.py | 2 +- 22 files changed, 368 insertions(+), 194 deletions(-) delete mode 100644 backend/app/service/export_import.py create mode 100644 backend/app/service/importServices/__init__.py create mode 100644 backend/app/service/importServices/import_expense.py create mode 100644 backend/app/service/importServices/import_item.py create mode 100644 backend/app/service/importServices/import_recipe.py create mode 100644 backend/app/service/importServices/import_shoppinglist.py create mode 100644 backend/app/service/import_language.py create mode 100644 backend/app/service/recalculate_balances.py diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index c1a3ae51..0d748256 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -1,5 +1,5 @@ import calendar -from datetime import time, datetime, timezone, timedelta +from datetime import datetime, timezone, timedelta from dateutil.relativedelta import relativedelta from sqlalchemy.sql.expression import desc from sqlalchemy import or_ @@ -10,6 +10,7 @@ from app import db from app.helpers import validate_args, authorize_household, RequiredRights from app.models import Expense, ExpensePaidFor, ExpenseCategory, HouseholdMember, File +from app.service.recalculate_balances import recalculateBalances from .schemas import GetExpenses, AddExpense, UpdateExpense, AddExpenseCategory, UpdateExpenseCategory, GetExpenseOverview expense = Blueprint('expense', __name__) @@ -175,20 +176,6 @@ def calculateBalances(household_id): recalculateBalances(household_id) -def recalculateBalances(household_id): - for member in HouseholdMember.find_by_household(household_id): - member.expense_balance = float(Expense.query.with_entities(func.sum( - Expense.amount).label("balance")).filter(Expense.paid_by_id == member.user_id, Expense.household_id == household_id).first().balance or 0) - for paid_for in ExpensePaidFor.query.filter(ExpensePaidFor.user_id == member.user_id, ExpensePaidFor.expense_id.in_(db.session.query(Expense.id).filter( - Expense.household_id == household_id).scalar_subquery())).all(): - factor_sum = Expense.query.with_entities(func.sum( - ExpensePaidFor.factor).label("factor_sum"))\ - .filter(ExpensePaidFor.expense_id == paid_for.expense_id).first().factor_sum - member.expense_balance = member.expense_balance - \ - (paid_for.factor / factor_sum) * paid_for.expense.amount - member.save() - - @expenseHousehold.route('/categories', methods=['GET']) @jwt_required() @authorize_household() @@ -236,13 +223,15 @@ def getFilterForStepAgo(stepAgo: int): start = datetime.utcnow().date() - timedelta(days=stepAgo) end = start + timedelta(hours=24) elif frame == 1: - start = datetime.utcnow().date() - relativedelta(days=7, weekday=calendar.MONDAY, weeks=stepAgo) + start = datetime.utcnow().date() - relativedelta(days=7, + weekday=calendar.MONDAY, weeks=stepAgo) end = start + timedelta(days=7) elif frame == 2: start = thisMonthStart - relativedelta(months=stepAgo) end = start + relativedelta(months=1) elif frame == 3: - start = datetime.utcnow().date().replace(day=1, month=1) - relativedelta(years=stepAgo) + start = datetime.utcnow().date().replace( + day=1, month=1) - relativedelta(years=stepAgo) end = start + relativedelta(years=1) return Expense.date >= start, Expense.date <= end diff --git a/backend/app/controller/exportimport/export_controller.py b/backend/app/controller/exportimport/export_controller.py index c6df7f6d..cf07099a 100644 --- a/backend/app/controller/exportimport/export_controller.py +++ b/backend/app/controller/exportimport/export_controller.py @@ -1,7 +1,8 @@ from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required +from app.errors import NotFoundRequest from app.helpers import authorize_household -from app.models import Item, Recipe +from app.models import Item, Recipe, Household export = Blueprint('export', __name__) @@ -10,10 +11,11 @@ @jwt_required() @authorize_household() def getExportAll(household_id): - return jsonify({ - "items": [e.obj_to_export_dict() for e in Item.all_from_household_by_name(household_id)], - "recipes": [e.obj_to_export_dict() for e in Recipe.all_from_household_by_name(household_id)] - }) + household = Household.find_by_id(household_id) + if not household: + raise NotFoundRequest() + + return household.obj_to_export_dict() @export.route('/items', methods=['GET']) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index c37fe356..6e1669d2 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -1,4 +1,8 @@ -from app.service.export_import import importFromDict +import time +from app.config import app +from app.models import Household +from app.service.importServices import importItem, importRecipe, importExpense, importShoppinglist +from app.service.recalculate_balances import recalculateBalances from .schemas import ImportSchema from app.helpers import validate_args, authorize_household from flask import jsonify, Blueprint @@ -12,5 +16,29 @@ @authorize_household() @validate_args(ImportSchema) def importData(args, household_id): - importFromDict(household_id, args) + household = Household.find_by_id(household_id) + if not household: + return + + app.logger.info("Starting import...") + + t0 = time.time() + if "items" in args: + for item in args['items']: + importItem(household, item) + + if "recipes" in args: + for recipe in args['recipes']: + importRecipe(household_id, recipe) + + if "expenses" in args: + for expense in args['expenses']: + importExpense(household, expense) + recalculateBalances(household.id) + + if "shoppinglists" in args: + for shoppinglist in args['shoppinglists']: + importShoppinglist(household, shoppinglist) + + app.logger.info(f"Import took: {(time.time() - t0):.3f}s") return jsonify({'msg': 'DONE'}) diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index 01a4832a..517a966f 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -1,14 +1,23 @@ -from marshmallow import fields, Schema +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, @@ -28,12 +37,49 @@ class RecipeItem(Schema): description = fields.String( load_default='' ) - time = fields.Integer() - cook_time = fields.Integer() - prep_time = fields.Integer() - yields = fields.Integer() - source = fields.String() + 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)) + expenses = fields.List(fields.Nested(Expense)) + member = fields.List(fields.String()) + shoppinglists = fields.List(fields.String()) diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index 49892741..f45c710f 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -4,7 +4,7 @@ from app.errors import NotFoundRequest from flask_jwt_extended import current_user, jwt_required from app.models import Household, HouseholdMember, Shoppinglist, File -from app.service.export_import import importLanguage +from app.service.import_language import importLanguage from .schemas import AddHousehold, UpdateHousehold, UpdateHouseholdMember household = Blueprint('household', __name__) diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index a37ed30a..e55cb0e5 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -32,6 +32,19 @@ def obj_to_full_dict(self) -> dict: if (self.category): res['category'] = self.category.obj_to_full_dict() return res + + def obj_to_export_dict(self) -> dict: + res = { + 'name': self.name, + 'amount': self.amount, + 'date': self.date, + 'photo': self.photo, + 'paid_for': [{'factor': e.factor, 'username': e.user.username} for e in self.paid_for], + 'paid_by': self.paid_by.username, + } + if (self.category): + res['category'] = self.category.obj_to_export_dict() + return res @classmethod def find_by_name(cls, name) -> Self: diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py index 73424f3f..4a8b23a0 100644 --- a/backend/app/models/expense_category.py +++ b/backend/app/models/expense_category.py @@ -21,16 +21,22 @@ def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() return res + def obj_to_export_dict(self) -> dict: + return { + 'name': self.name, + 'color': self.color, + } + @classmethod - def find_by_name(cls, name: str, houshold_id: int) -> Self: + def find_by_name(cls, houshold_id: int, name: str) -> Self: return cls.query.filter(cls.name == name, cls.household_id == houshold_id).first() @classmethod - def find_by_id(cls, id) -> Self: + def find_by_id(cls, id: int) -> Self: return cls.query.filter(cls.id == id).first() @classmethod - def delete_by_name(cls, name: str, household_id: int): - mc = cls.find_by_name(name, household_id) + def delete_by_name(cls, household_id: int, name: str): + mc = cls.find_by_name(household_id, name) if mc: mc.delete() diff --git a/backend/app/models/file.py b/backend/app/models/file.py index 26df8d93..01204f76 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -9,7 +9,7 @@ class File(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'file' filename = db.Column(db.String(), primary_key=True) - created_by = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) created_by_user = db.relationship("User", foreign_keys=[created_by], uselist=False) diff --git a/backend/app/models/household.py b/backend/app/models/household.py index 9217c10a..22f72609 100644 --- a/backend/app/models/household.py +++ b/backend/app/models/household.py @@ -30,11 +30,10 @@ class Household(db.Model, DbModelMixin, TimestampMixin): 'Expense', back_populates='household', cascade="all, delete-orphan") expenseCategories = db.relationship( 'ExpenseCategory', back_populates='household', cascade="all, delete-orphan") - shoppinglists = db.relationship( - 'Shoppinglist', back_populates='household', cascade="all, delete-orphan") member = db.relationship( 'HouseholdMember', back_populates='household', cascade="all, delete-orphan") - photo_file = db.relationship("File", back_populates='household', uselist=False) + photo_file = db.relationship( + "File", back_populates='household', uselist=False) def obj_to_dict(self) -> dict: res = super().obj_to_dict() @@ -42,6 +41,20 @@ def obj_to_dict(self) -> dict: res['default_shopping_list'] = self.shoppinglists[0].obj_to_dict() return res + def obj_to_export_dict(self) -> dict: + return { + 'name': self.name, + 'language': self.language, + 'view_ordering': self.view_ordering, + 'planner_feature': self.planner_feature, + 'expenses_feature': self.expenses_feature, + 'member': [m.user.username for m in getattr(self, 'member')], + 'shoppinglists': [s.name for s in self.shoppinglists], + 'recipes': [s.obj_to_export_dict() for s in self.recipes], + 'items': [s.obj_to_export_dict() for s in self.items], + 'expenses': [s.obj_to_export_dict() for s in self.expenses], + } + class HouseholdMember(db.Model, DbModelMixin, TimestampMixin): __tablename__ = 'household_member' diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 1626f34b..2d924708 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -47,6 +47,8 @@ def obj_to_export_dict(self) -> dict: res = { "name": self.name, } + if self.icon: + res["icon"] = self.icon if self.category: res["category"] = self.category.name return res diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 364f16df..f6b2bb19 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -65,6 +65,7 @@ def obj_to_export_dict(self) -> dict: "name": self.name, "description": self.description, "time": self.time, + "photo": self.photo, "cook_time": self.cook_time, "prep_time": self.prep_time, "yields": self.yields, diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2f829863..ce299d39 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,7 +13,7 @@ class User(db.Model, DbModelMixin, TimestampMixin): name = db.Column(db.String(128)) username = db.Column(db.String(256), unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) - photo = db.Column(db.String(), db.ForeignKey('file.filename')) + photo = db.Column(db.String(), db.ForeignKey('file.filename', use_alter=True)) admin = db.Column(db.Boolean(), default=False) tokens = db.relationship( diff --git a/backend/app/service/export_import.py b/backend/app/service/export_import.py deleted file mode 100644 index 3605d77a..00000000 --- a/backend/app/service/export_import.py +++ /dev/null @@ -1,151 +0,0 @@ -import time -from app.config import app, APP_DIR, SUPPORTED_LANGUAGES, db -from os.path import exists -import json - -from app.errors import NotFoundRequest -from app.models import Item, Recipe, RecipeItems, Tag, RecipeTags, Category - - -def importLanguage(household_id, lang, bulkSave=False): - file_path = f'{APP_DIR}/../templates/l10n/{lang}.json' - if lang not in SUPPORTED_LANGUAGES or not exists(file_path): - raise NotFoundRequest('Language code not supported') - with open(file_path, 'r') as f: - data = json.load(f) - with open(f'{APP_DIR}/../templates/attributes.json', 'r') as f: - attributes = json.load(f) - - t0 = time.time() - models: list[Item] = [] - for key, name in data["items"].items(): - item = Item.find_by_name(household_id, name) - if not item: - # slow but needed to filter out duplicate names - if bulkSave and any(i.name == name for i in models): - continue - item = Item() - item.name = name.strip() - item.household_id = household_id - item.default = True - - if item.default: - if key in attributes["items"] and "icon" in attributes["items"][key]: - item.icon = attributes["items"][key]["icon"] - - # Category not already set for existing item and category set for template and category translation exist for language - if not item.category_id and key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: - category_name = data["categories"][attributes["items"] - [key]["category"]] - category = Category.find_by_name(household_id, category_name) - if not category: - category = Category.create_by_name( - household_id, category_name, True) - item.category = category - if not bulkSave: - item.save(keepDefault=True) - else: - models.append(item) - - if bulkSave: - try: - db.session.add_all(models) - db.session.commit() - except Exception as e: - db.session.rollback() - raise e - app.logger.info(f"Import took: {(time.time() - t0):.3f}s") - - -def importFromDict(args, household_id: int, bulkSave=False, override=False): # noqa - t0 = time.time() - models = [] - if "items" in args: - for importItem in args['items']: - item = Item.find_by_name(household_id, importItem['name']) - if not item: - # slow but needed to filter out duplicate names - if bulkSave and any(i.name == importItem['name'] for i in models): - continue - item = Item() - item.name = importItem['name'] - item.household_id = household_id - if "category" in importItem and not item.category_id: - category = Category.find_by_name( - household_id, importItem['category']) - if not category: - category = Category.create_by_name( - household_id, - importItem['category']) - item.category = category - if not bulkSave: - item.save() - else: - models.append(item) - if "recipes" in args: - for importRecipe in args['recipes']: - recipeNameCount = 0 - recipe = Recipe.find_by_name(household_id, importRecipe['name']) - if recipe and not override: - recipeNameCount = 1 + \ - Recipe.query.filter(Recipe.household_id == household_id, Recipe.name.ilike( - importRecipe['name'] + " (_%)")).count() - if not recipe: - recipe = Recipe() - recipe.household_id = household_id - recipe.name = importRecipe['name'] + \ - (f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "") - recipe.description = importRecipe['description'] - if 'time' in importRecipe: - recipe.time = importRecipe['time'] - if 'cook_time' in importRecipe: - recipe.cook_time = importRecipe['cook_time'] - if 'prep_time' in importRecipe: - recipe.prep_time = importRecipe['prep_time'] - if 'yields' in importRecipe: - recipe.yields = importRecipe['yields'] - if 'source' in importRecipe: - recipe.source = importRecipe['source'] - - if not bulkSave: - recipe.save() - else: - models.append(recipe) - - if 'items' in importRecipe: - for recipeItem in importRecipe['items']: - item = Item.find_by_name(household_id, recipeItem['name']) - if not item: - item = Item.create_by_name( - household_id, recipeItem['name']) - con = RecipeItems( - description=recipeItem['description'], - optional=recipeItem['optional'] - ) - con.item = item - con.recipe = recipe - if not bulkSave: - con.save() - else: - models.append(con) - if 'tags' in args: - for tagName in args['tags']: - tag = Tag.find_by_name(household_id, tagName) - if not tag: - tag = Tag.create_by_name(household_id, tagName) - con = RecipeTags() - con.tag = tag - con.recipe = recipe - if not bulkSave: - con.save() - else: - models.append(con) - - if bulkSave: - try: - db.session.add_all(models) - db.session.commit() - except Exception as e: - db.session.rollback() - raise e - app.logger.info(f"Import took: {(time.time() - t0):.3f}s") diff --git a/backend/app/service/importServices/__init__.py b/backend/app/service/importServices/__init__.py new file mode 100644 index 00000000..f2755cac --- /dev/null +++ b/backend/app/service/importServices/__init__.py @@ -0,0 +1,4 @@ +from .import_recipe import importRecipe +from .import_expense import importExpense +from .import_shoppinglist import importShoppinglist +from. import_item import importItem \ No newline at end of file diff --git a/backend/app/service/importServices/import_expense.py b/backend/app/service/importServices/import_expense.py new file mode 100644 index 00000000..18500b1f --- /dev/null +++ b/backend/app/service/importServices/import_expense.py @@ -0,0 +1,45 @@ + +from datetime import datetime, timezone +from flask_jwt_extended import current_user +from app.models import Household, Expense, ExpensePaidFor, File, ExpenseCategory + + +def importExpense(household: Household, args: dict): + expense = Expense() + expense.household = household + expense.name = args['name'] + expense.date = datetime.fromtimestamp( + args['date']/1000, timezone.utc) + expense.amount = args['amount'] + if 'photo' in args: + f = File.find(args['photo']) + if f and f.created_by == current_user.id: + expense.photo = f.filename + if 'category' in args: + category = ExpenseCategory.find_by_name( + household.id, args['category']['name']) + if not category: + category = ExpenseCategory() + category.name = args['category']['name'] + category.color = args['category']['color'] + category.household_id = household.id + category = category.save() + expense.category = category + + paid_by = next( + (x for x in household.member if x.user.username == args['paid_by']), None) + if paid_by: + expense.paid_by_id = paid_by.user_id + + expense.save() + + for paid_for in args['paid_for']: + paid_for_member = next( + (x for x in household.member if x.user.username == paid_for['username']), None) + if not paid_for_member: + continue + con = ExpensePaidFor() + con.expense = expense + con.user_id = paid_for_member.user_id + con.factor = paid_for['factor'] + con.save() diff --git a/backend/app/service/importServices/import_item.py b/backend/app/service/importServices/import_item.py new file mode 100644 index 00000000..965ffe0c --- /dev/null +++ b/backend/app/service/importServices/import_item.py @@ -0,0 +1,20 @@ +from app.models import Household, Item, Category + + +def importItem(household: Household, args: dict): + item = Item.find_by_name(household.id, args['name']) + if not item: + item = Item() + item.name = args['name'] + item.household = household + if "icon" in args: + item.icon = args['icon'] + if "category" in args and not item.category_id: + category = Category.find_by_name( + household.id, args['category']) + if not category: + category = Category.create_by_name( + household.id, + args['category']) + item.category = category + item.save() diff --git a/backend/app/service/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py new file mode 100644 index 00000000..beace4cb --- /dev/null +++ b/backend/app/service/importServices/import_recipe.py @@ -0,0 +1,59 @@ + +from flask_jwt_extended import current_user +from app.models import Recipe, RecipeTags, RecipeItems, Item, Tag, File + + +def importRecipe(household_id: int, args: dict, override: bool = False): + recipeNameCount = 0 + recipe = Recipe.find_by_name(household_id, args['name']) + if recipe and not override: + recipeNameCount = 1 + \ + Recipe.query.filter(Recipe.household_id == household_id, Recipe.name.ilike( + args['name'] + " (_%)")).count() + recipe = None + if not recipe: + recipe = Recipe() + recipe.household_id = household_id + recipe.name = args['name'] + \ + (f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "") + 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 'photo' in args: + f = File.find(args['photo']) + if f and f.created_by == current_user.id: + recipe.photo = f.filename + + recipe.save() + + + if 'items' in args: + 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']) + con = RecipeItems( + description=recipeItem['description'], + optional=recipeItem['optional'] + ) + con.item = item + con.recipe = recipe + con.save() + if 'tags' in args: + for tagName in args['tags']: + tag = Tag.find_by_name(household_id, tagName) + if not tag: + tag = Tag.create_by_name(household_id, tagName) + con = RecipeTags() + con.tag = tag + con.recipe = recipe + con.save() diff --git a/backend/app/service/importServices/import_shoppinglist.py b/backend/app/service/importServices/import_shoppinglist.py new file mode 100644 index 00000000..3b218b0b --- /dev/null +++ b/backend/app/service/importServices/import_shoppinglist.py @@ -0,0 +1,6 @@ + +from app.models import Household, Shoppinglist + + +def importShoppinglist(household: Household, args: dict): + pass diff --git a/backend/app/service/import_language.py b/backend/app/service/import_language.py new file mode 100644 index 00000000..5c7f2183 --- /dev/null +++ b/backend/app/service/import_language.py @@ -0,0 +1,57 @@ +import time +from app.config import app, APP_DIR, SUPPORTED_LANGUAGES, db +from os.path import exists +import json + +from app.errors import NotFoundRequest +from app.models import Item, Category + + +def importLanguage(household_id, lang, bulkSave=False): + file_path = f'{APP_DIR}/../templates/l10n/{lang}.json' + if lang not in SUPPORTED_LANGUAGES or not exists(file_path): + raise NotFoundRequest('Language code not supported') + with open(file_path, 'r') as f: + data = json.load(f) + with open(f'{APP_DIR}/../templates/attributes.json', 'r') as f: + attributes = json.load(f) + + t0 = time.time() + models: list[Item] = [] + for key, name in data["items"].items(): + item = Item.find_by_name(household_id, name) + if not item: + # slow but needed to filter out duplicate names + if bulkSave and any(i.name == name for i in models): + continue + item = Item() + item.name = name.strip() + item.household_id = household_id + item.default = True + + if item.default: + if key in attributes["items"] and "icon" in attributes["items"][key]: + item.icon = attributes["items"][key]["icon"] + + # Category not already set for existing item and category set for template and category translation exist for language + if not item.category_id and key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: + category_name = data["categories"][attributes["items"] + [key]["category"]] + category = Category.find_by_name(household_id, category_name) + if not category: + category = Category.create_by_name( + household_id, category_name, True) + item.category = category + if not bulkSave: + item.save(keepDefault=True) + else: + models.append(item) + + if bulkSave: + try: + db.session.add_all(models) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + app.logger.info(f"Import took: {(time.time() - t0):.3f}s") diff --git a/backend/app/service/recalculate_balances.py b/backend/app/service/recalculate_balances.py new file mode 100644 index 00000000..ded089e1 --- /dev/null +++ b/backend/app/service/recalculate_balances.py @@ -0,0 +1,17 @@ +from sqlalchemy import func +from app.models import Expense, ExpensePaidFor, HouseholdMember +from app import db + + +def recalculateBalances(household_id): + for member in HouseholdMember.find_by_household(household_id): + member.expense_balance = float(Expense.query.with_entities(func.sum( + Expense.amount).label("balance")).filter(Expense.paid_by_id == member.user_id, Expense.household_id == household_id).first().balance or 0) + for paid_for in ExpensePaidFor.query.filter(ExpensePaidFor.user_id == member.user_id, ExpensePaidFor.expense_id.in_(db.session.query(Expense.id).filter( + Expense.household_id == household_id).scalar_subquery())).all(): + factor_sum = Expense.query.with_entities(func.sum( + ExpensePaidFor.factor).label("factor_sum"))\ + .filter(ExpensePaidFor.expense_id == paid_for.expense_id).first().factor_sum + member.expense_balance = member.expense_balance - \ + (paid_for.factor / factor_sum) * paid_for.expense.amount + member.save() diff --git a/backend/manage.py b/backend/manage.py index 28ade30e..925c6be7 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -1,6 +1,20 @@ -from app import app -from app.models import User +from os import listdir +from os.path import isfile, join +from app import app, db +from app.config import UPLOAD_FOLDER +from app.models import User, File +def importFiles(): + try: + filesInUploadFolder = [f for f in listdir(UPLOAD_FOLDER) if isfile(join(UPLOAD_FOLDER, f))] + files = [File(filename=f) for f in filesInUploadFolder if not File.find(f)] + + db.session.bulk_save_objects(files) + db.session.commit() + print(f"-> Found {len(files)} new files in {UPLOAD_FOLDER}") + except Exception as e: + db.session.rollback() + raise e def manageUsers(): while True: @@ -44,9 +58,12 @@ def manageUsers(): print(""" Manage KitchenOwl\n---\nWhat do you want to do? 1. Manage users + 2. Import files (q) Exit""") selection = input("Your selection (q):") if selection == "1": manageUsers() + elif selection == "2": + importFiles() else: exit() diff --git a/backend/upgrade_default_items.py b/backend/upgrade_default_items.py index b0042b71..9cb3eaf0 100644 --- a/backend/upgrade_default_items.py +++ b/backend/upgrade_default_items.py @@ -1,6 +1,6 @@ from app import app from app.models import Household -from app.service.export_import import importLanguage +from app.service.import_language import importLanguage if __name__ == "__main__": From 96bf83c25b998908c711ec10a75affc8b4759814 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 4 May 2023 14:54:42 +0200 Subject: [PATCH 296/496] Fix: update error messages --- backend/app/config.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 95be7404..82cd23c4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,9 +1,10 @@ from datetime import timedelta from sqlalchemy import MetaData from sqlalchemy.engine import URL +from werkzeug.exceptions import MethodNotAllowed from app.errors import NotFoundRequest, UnauthorizedRequest, ForbiddenRequest, InvalidUsage from app.util import KitchenOwlJSONProvider -from flask import Flask, jsonify, request +from flask import Flask, request from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from flask_bcrypt import Bcrypt @@ -105,18 +106,21 @@ def add_cors_headers(response): def unhandled_exception(e): if type(e) is NotFoundRequest: app.logger.info(e) - return jsonify(message="Requested resource not found"), 404 + return "Requested resource not found", 404 if type(e) is ForbiddenRequest: app.logger.warning(e) - return jsonify(message="Request forbidden"), 403 + return "Request forbidden", 403 if type(e) is InvalidUsage: app.logger.warning(e) - return jsonify(message="Request invalid"), 400 + return "Request invalid", 400 if type(e) is UnauthorizedRequest: app.logger.warning(e) - return jsonify(message="Request unauthorized"), 401 + return "Request unauthorized", 401 + if type(e) is MethodNotAllowed: + app.logger.warning(e) + return "The method is not allowed for the requested URL", 405 app.logger.error(e) - return jsonify(message="Something went wrong"), 500 + return "Something went wrong", 500 @app.errorhandler(404) From 57dc4bf17880da979939168c6937614a7fd44e61 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 5 May 2023 14:32:42 +0200 Subject: [PATCH 297/496] refactor: file access and import --- .../controller/expense/expense_controller.py | 11 +++--- .../household/household_controller.py | 9 ++--- .../controller/recipe/recipe_controller.py | 34 ++++--------------- .../app/controller/user/user_controller.py | 13 ++++--- .../service/file_has_access_or_download.py | 30 ++++++++++++++++ .../service/importServices/import_expense.py | 8 ++--- .../service/importServices/import_recipe.py | 8 ++--- 7 files changed, 55 insertions(+), 58 deletions(-) create mode 100644 backend/app/service/file_has_access_or_download.py diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 0d748256..df52b73a 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -9,8 +9,9 @@ from sqlalchemy import func from app import db from app.helpers import validate_args, authorize_household, RequiredRights -from app.models import Expense, ExpensePaidFor, ExpenseCategory, HouseholdMember, File +from app.models import Expense, ExpensePaidFor, ExpenseCategory, HouseholdMember from app.service.recalculate_balances import recalculateBalances +from app.service.file_has_access_or_download import file_has_access_or_download from .schemas import GetExpenses, AddExpense, UpdateExpense, AddExpenseCategory, UpdateExpenseCategory, GetExpenseOverview expense = Blueprint('expense', __name__) @@ -70,9 +71,7 @@ def addExpense(args, household_id): expense.date = datetime.fromtimestamp( args['date']/1000, timezone.utc) if 'photo' in args and args['photo'] != expense.photo: - f = File.find(args['photo']) - if f and f.created_by == current_user.id: - expense.photo = f.filename + 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']) @@ -117,9 +116,7 @@ def updateExpense(args, id): # noqa: C901 expense.date = datetime.fromtimestamp( args['date']/1000, timezone.utc) if 'photo' in args and args['photo'] != expense.photo: - f = File.find(args['photo']) - if f and f.created_by == current_user.id: - expense.photo = f.filename + 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']) diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index f45c710f..f3f9a939 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -5,6 +5,7 @@ from flask_jwt_extended import current_user, jwt_required from app.models import Household, HouseholdMember, Shoppinglist, File from app.service.import_language import importLanguage +from app.service.file_has_access_or_download import file_has_access_or_download from .schemas import AddHousehold, UpdateHousehold, UpdateHouseholdMember household = Blueprint('household', __name__) @@ -33,9 +34,7 @@ def addHousehold(args): household = Household() household.name = args['name'] if 'photo' in args and args['photo'] != household.photo: - f = File.find(args['photo']) - if f and f.created_by == current_user.id: - household.photo = f.filename + 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: @@ -72,9 +71,7 @@ def updateHousehold(args, household_id): if 'name' in args: household.name = args['name'] if 'photo' in args and args['photo'] != household.photo: - f = File.find(args['photo']) - if f and f.created_by == current_user.id: - household.photo = f.filename + household.photo = file_has_access_or_download(args['photo'], household.photo) if 'language' in args and not household.language and args['language'] in SUPPORTED_LANGUAGES: household.language = args['language'] importLanguage(household.id, household.language) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 0ff0f0e9..e409a277 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -1,20 +1,16 @@ -import os import re -import uuid -import requests -from app.util.filename_validator import allowed_file -from app.config import UPLOAD_FOLDER from app.errors import NotFoundRequest from app.models.recipe import RecipeItems, RecipeTags from flask import jsonify, Blueprint -from flask_jwt_extended import current_user, jwt_required +from flask_jwt_extended import jwt_required from app.helpers import validate_args, authorize_household -from app.models import Recipe, Item, Tag, File +from app.models import Recipe, Item, Tag from recipe_scrapers import scrape_me from recipe_scrapers._exceptions import SchemaOrgException from ingredient_parser import parse_ingredient -from werkzeug.utils import secure_filename + +from app.service.file_has_access_or_download import file_has_access_or_download from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest, ScrapeRecipe recipe = Blueprint('recipe', __name__) @@ -58,7 +54,7 @@ def addRecipe(args, household_id): if 'source' in args: recipe.source = args['source'] if 'photo' in args and args['photo'] != recipe.photo: - recipe.photo = upload_file_if_needed(args['photo']) + recipe.photo = file_has_access_or_download(args['photo'], recipe.photo) recipe.save() if 'items' in args: for recipeItem in args['items']: @@ -108,7 +104,7 @@ def updateRecipe(args, id): # noqa: C901 if 'source' in args: recipe.source = args['source'] if 'photo' in args and args['photo'] != recipe.photo: - recipe.photo = upload_file_if_needed(args['photo']) + recipe.photo = file_has_access_or_download(args['photo'], recipe.photo) recipe.save() if 'items' in args: for con in recipe.items: @@ -234,21 +230,3 @@ def scrapeRecipe(args, household_id): 'recipe': recipe.obj_to_dict(), 'items': items, }) - - -def upload_file_if_needed(url: str): - if url is not None and '/' in url: - from mimetypes import guess_extension - resp = requests.get(url) - ext = guess_extension(resp.headers['content-type']) - if allowed_file('file' + ext): - filename = secure_filename(str(uuid.uuid4()) + ext) - File(filename=filename, created_by=current_user.id).save() - with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as o: - o.write(resp.content) - return filename - elif url is not None: - f = File.find(url) - if f and f.created_by == current_user.id: - return f.filename - return None diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 4711b537..7a5e22d6 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -3,7 +3,8 @@ from app.helpers import validate_args from flask import jsonify, Blueprint from flask_jwt_extended import current_user, jwt_required -from app.models import User, File +from app.models import User +from app.service.file_has_access_or_download import file_has_access_or_download from .schemas import CreateUser, UpdateUser, SearchByNameRequest @@ -65,10 +66,8 @@ def updateUser(args): user.name = args['name'] if 'password' in args: user.set_password(args['password']) - if 'photo' in args: - f = File.find(args['photo']) - if f and f.created_by == user.id: - user.photo = f.filename + if 'photo' in args and user.photo != args['photo']: + user.photo = file_has_access_or_download(args['photo'], user.photo) user.save() return jsonify({'msg': 'DONE'}) @@ -85,8 +84,8 @@ def updateUserById(args, id): user.name = args['name'] if 'password' in args: user.set_password(args['password']) - if 'photo' in args: - user.photo = args['photo'] + 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'] user.save() diff --git a/backend/app/service/file_has_access_or_download.py b/backend/app/service/file_has_access_or_download.py new file mode 100644 index 00000000..bdb16407 --- /dev/null +++ b/backend/app/service/file_has_access_or_download.py @@ -0,0 +1,30 @@ +import os +import uuid +import requests +from app.util.filename_validator import allowed_file +from app.config import UPLOAD_FOLDER +from app.models import File +from flask_jwt_extended import current_user +from werkzeug.utils import secure_filename + + +def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str: + """ + Downloads the file if the url is an external URL or checks if the user has access to the file on this server + If the user has no access oldPhoto is returned + """ + if newPhoto is not None and '/' in newPhoto: + from mimetypes import guess_extension + resp = requests.get(newPhoto) + ext = guess_extension(resp.headers['content-type']) + if allowed_file('file' + ext): + filename = secure_filename(str(uuid.uuid4()) + ext) + File(filename=filename, created_by=current_user.id).save() + with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as o: + o.write(resp.content) + return filename + elif newPhoto is not None: + f = File.find(newPhoto) + if f and (f.created_by == current_user.id or current_user.admin): + return f.filename + return oldPhoto diff --git a/backend/app/service/importServices/import_expense.py b/backend/app/service/importServices/import_expense.py index 18500b1f..2a148751 100644 --- a/backend/app/service/importServices/import_expense.py +++ b/backend/app/service/importServices/import_expense.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone -from flask_jwt_extended import current_user -from app.models import Household, Expense, ExpensePaidFor, File, ExpenseCategory +from app.models import Household, Expense, ExpensePaidFor, ExpenseCategory +from app.service.file_has_access_or_download import file_has_access_or_download def importExpense(household: Household, args: dict): @@ -12,9 +12,7 @@ def importExpense(household: Household, args: dict): args['date']/1000, timezone.utc) expense.amount = args['amount'] if 'photo' in args: - f = File.find(args['photo']) - if f and f.created_by == current_user.id: - expense.photo = f.filename + expense.photo = file_has_access_or_download(args['photo']) if 'category' in args: category = ExpenseCategory.find_by_name( household.id, args['category']['name']) diff --git a/backend/app/service/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py index beace4cb..e54559b8 100644 --- a/backend/app/service/importServices/import_recipe.py +++ b/backend/app/service/importServices/import_recipe.py @@ -1,6 +1,6 @@ -from flask_jwt_extended import current_user -from app.models import Recipe, RecipeTags, RecipeItems, Item, Tag, File +from app.models import Recipe, RecipeTags, RecipeItems, Item, Tag +from app.service.file_has_access_or_download import file_has_access_or_download def importRecipe(household_id: int, args: dict, override: bool = False): @@ -28,9 +28,7 @@ def importRecipe(household_id: int, args: dict, override: bool = False): if 'source' in args: recipe.source = args['source'] if 'photo' in args: - f = File.find(args['photo']) - if f and f.created_by == current_user.id: - recipe.photo = f.filename + recipe.photo = file_has_access_or_download(args['photo']) recipe.save() From 544374b06dcacdb7bca16e8b90dde8974e3a53e7 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 5 May 2023 14:40:41 +0200 Subject: [PATCH 298/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#28) * Translated using Weblate (Spanish) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ --------- Co-authored-by: gallegonovato Co-authored-by: Tom Bursch --- backend/templates/l10n/es.json | 68 +++++++++++++++++----------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index c6147936..001b83f6 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -81,7 +81,7 @@ "cherry_tomatoes": "Tomates cherry", "chickpeas": "Garbanzos", "chili_oil": "Aceite de chile", - "chips": "Fichas", + "chips": "Patatas fritas", "chives": "Cebollino", "chocolate": "Chocolate", "chocolate_chips": "Chispas de chocolate", @@ -110,9 +110,9 @@ "cow's_milk": "Leche de vaca", "cream": "Crema", "cream_cheese": "Queso cremoso", - "creamed_spinach": "Espinacas a la crema", + "creamed_spinach": "Crema de espinacas", "creme_fraiche": "Nata agria (Crème fraîche)", - "crepe_tape": "Cinta de crepé", + "crepe_tape": "Cinta adhesiva", "crispbread": "Pan crujiente", "cucumber": "Pepino", "cumin": "Comino", @@ -125,8 +125,8 @@ "detergent": "Detergente", "dill": "Eneldo", "dishwasher_salt": "Sal para lavavajillas", - "dishwasher_tabs": "Pestañas para lavavajillas", - "disinfection_spray": "Spray desinfectante", + "dishwasher_tabs": "Pastillas para el lavavajillas", + "disinfection_spray": "Desinfectante en spray", "dried_tomatoes": "Tomates secos", "edamame": "Vainas de soja tiernas (Edamame)", "eggplant": "Berenjena", @@ -162,7 +162,7 @@ "green_asparagus": "Espárragos verdes", "green_chili": "Guindilla verde", "green_pesto": "Pesto verde", - "hair_gel": "Gel para el pelo", + "hair_gel": "Gomina", "hair_wax": "Cera para el pelo", "handkerchief_box": "Caja de pañuelos", "handkerchiefs": "Pañuelos", @@ -183,10 +183,10 @@ "jam": "Mermelada", "katjes": "Katjes", "ketchup": "Kétchup", - "kidney_beans": "Alubias rojas", + "kidney_beans": "Judías rojas (Frijoles)", "kitchen_roll": "Papel de cocina", "kitchen_towels": "Paños de cocina", - "kohlrabi": "Colirrábano", + "kohlrabi": "Colinabo", "lasagna": "Lasaña", "lasagna_noodles": "Fideos para lasaña", "lasagna_plates": "Platos de lasaña", @@ -200,18 +200,18 @@ "lentils_red": "Lentejas rojas", "lettuce": "Lechuga", "lillet": "Lillet", - "lime": "Cal", + "lime": "Lima", "linguine": "Linguine", - "low-fat_curd_cheese": "Cuajada baja en grasas", + "low-fat_curd_cheese": "Requesón bajo en grasa", "magnesium": "Magnesio", "mango": "Mango", "margarine": "Margarina", - "marjoram": "Mejorana", + "marjoram": "Mejorana (Origanum majorana)", "marshmallows": "Malvaviscos", - "mask": "Máscara", + "mask": "Mascarilla", "mayonnaise": "Mayonesa", - "meat_substitute_product": "Producto sustitutivo de la carne", - "microfiber_cloth": "Paño de microfibra", + "meat_substitute_product": "Carne de origen vegetal (Tofu)", + "microfiber_cloth": "Paño de microfibras", "milk": "Leche", "mint": "Menta", "mint_candy": "Caramelos de menta", @@ -249,7 +249,7 @@ "pasta": "Pasta", "peach": "Melocotón", "peanut_butter": "Mantequilla de cacahuete", - "peanut_flips": "Vueltas de cacahuete", + "peanut_flips": "Maíz extruido crudo", "peanut_oil": "Aceite de cacahuete", "peanuts": "Cacahuetes", "pears": "Peras", @@ -266,10 +266,10 @@ "pita_bag": "Bolsa de pita", "pizza": "Pizza", "pizza_dough": "Masa de pizza", - "plant_magarine": "Planta Magarine", + "plant_magarine": "Margarina vegetal", "plant_oil": "Aceite vegetal", - "plaster": "Escayola", - "porcini_mushrooms": "Setas porcini", + "plaster": "Yeso", + "porcini_mushrooms": "Champiñón al ajillo", "potato_dumpling_dough": "Masa de albóndigas de patata", "potato_wedges": "Cuñas de patata", "potatoes": "Patatas", @@ -290,12 +290,12 @@ "raspberries": "Frambuesas", "raspberry_syrup": "Sirope de frambuesa", "red_bull": "Red Bull", - "red_chili": "Guindilla roja", + "red_chili": "Chili rojo", "red_lentils": "Lentejas rojas", "red_onions": "Cebollas rojas", "red_pesto": "Pesto rojo", "red_wine": "Vino tinto", - "red_wine_vinegar": "Vinagre de vino tinto", + "red_wine_vinegar": "Vinagre de Módena", "rhubarb": "Ruibarbo", "ribbon_noodles": "Cinta de fideos", "rice": "Arroz", @@ -303,7 +303,7 @@ "rice_ribbon_noodles": "Fideos con cinta de arroz", "rice_vinegar": "Vinagre de arroz", "ricotta": "Requesón", - "rinse_tabs": "Pestañas de enjuague", + "rinse_tabs": "Pastillas Abrillantadoras", "rinsing_agent": "Agente de enjuague", "risotto_rice": "Arroz para risotto", "rocket": "Cohete", @@ -318,21 +318,21 @@ "salt_mill": "Molino de sal", "sambal_oelek": "Sambal", "sauce": "Salsa", - "sausage": "Salchichas", + "sausage": "Embutido", "sausages": "Salchichas", - "savoy_cabbage": "Col de Milán", + "savoy_cabbage": "Col rizada", "scallion": "Cebolleta", - "scattered_cheese": "Queso esparcido", + "scattered_cheese": "Queso de untar", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln (ñoquis alemanes)", - "semolina_porridge": "Gachas de sémola", + "semolina_porridge": "Papilla de sémola", "sesame": "Sésamo", "sesame_oil": "Aceite de sésamo", "shallot": "Chalota", "shampoo": "Champú", "shawarma_spice": "Shawarma picante", "shiitake_mushroom": "Champiñón shiitake", - "shoe_insoles": "Plantillas", + "shoe_insoles": "Plantillas para zapatos", "shower_gel": "Gel de ducha", "shredded_cheese": "Queso rallado", "sieved_tomatoes": "Tomates tamizados", @@ -343,7 +343,7 @@ "soap": "Jabón", "soft_drinks": "Refrescos", "softdrinks": "Refrescos", - "sour_cream": "Nata agria", + "sour_cream": "Crema agria", "sour_cucumbers": "Pepinos agrios", "soy_hack": "Hack de soja", "soy_sauce": "Salsa de soja", @@ -351,10 +351,10 @@ "spaetzle": "Späeztle", "spaghetti": "Espaguetis", "sparkling_water": "Agua con gas", - "spelt": "Escanda", + "spelt": "Espelta", "spinach": "Espinacas", - "sponge_cloth": "Paño de esponja", - "sponge_wipes": "Toallitas de esponja", + "sponge_cloth": "Bayetas", + "sponge_wipes": "Esponjas limpiadoras (de poliuretano)", "sponges": "Esponjas", "spreading_cream": "Crema para untar", "spring_onions": "Cebolletas", @@ -384,17 +384,17 @@ "tomato_paste": "Pasta de tomate", "tomato_sauce": "Salsa de tomate", "tomatoes": "Tomates", - "tonic_water": "Agua tónica", + "tonic_water": "Tónica", "toothpaste": "Pasta de dientes", "tortellini": "Tortellini", - "tortilla_chips": "Tortillas fritas", + "tortilla_chips": "Tortilla chip", "tuna": "Atún", "turmeric": "Cúrcuma", "tzatziki": "Tzatziki", "udon_noodles": "Fideos Udon", "uht_milk": "Leche UHT", "vanilla_sugar": "Azúcar vainillado", - "vegetable_bouillon_cube": "Cubitos de caldo vegetal", + "vegetable_bouillon_cube": "Pastillas de caldo vegetal", "vegetable_broth": "Caldo de verduras", "vegetable_oil": "Aceite vegetal", "vegetable_onion": "Cebolla vegetal", @@ -413,7 +413,7 @@ "whole_canned_tomatoes": "Tomates enteros en conserva", "wild_berries": "Bayas silvestres", "wrapping_paper": "Papel de envolver", - "wraps": "Envolturas", + "wraps": "Wraps", "yeast": "Levadura", "yoghurt": "Yogur", "yogurt": "Yogur", From 027408112a2963b0ef20a300dad6e606abc34cc6 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 5 May 2023 14:52:02 +0200 Subject: [PATCH 299/496] feat: add languages --- backend/app/config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 82cd23c4..c4aef0ba 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -36,13 +36,14 @@ SUPPORTED_LANGUAGES = { 'en': 'English', + 'da': 'Dansk', 'de': 'Deutsch', 'es': 'Español', 'fr': 'Français', 'id': 'Bahasa Indonesia', - # 'nb_NO': '', + 'nb_NO': 'Bokmål', + 'pt': 'Português', 'pt_BR': 'Português Brasileiro', - # 'pt': 'Português', 'ru': 'русский язык', } From 077cfc8c9dff14c0c4c338bb00a31e2dfd57fe59 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 5 May 2023 15:01:57 +0200 Subject: [PATCH 300/496] Prepare beta 59 --- backend/app/config.py | 2 +- backend/requirements.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index c4aef0ba..a2222f13 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 58 +BACKEND_VERSION = 59 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0dadf64f..8ab7f50f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -69,7 +69,7 @@ rdflib==6.3.2 rdflib-jsonld==0.6.2 recipe-scrapers==14.36.1 regex==2023.3.23 -requests==2.29.0 +requests==2.30.0 scikit-learn==1.2.2 scipy==1.10.1 setuptools-scm==7.1.0 @@ -83,7 +83,7 @@ tqdm==4.65.0 typed-ast==1.5.4 types-beautifulsoup4==4.12.0.4 types-html5lib==1.1.11.13 -types-requests==2.29.0.0 +types-requests==2.30.0.0 types-urllib3==1.26.25.12 typing_extensions==4.5.0 tzdata==2023.3 From 6ae195471475500ab80439ca2e5aa16d03780250 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 8 May 2023 22:34:18 +0200 Subject: [PATCH 301/496] fix: fail to import recipe image (TomBursch/kitchenowl-backend#31) --- backend/app/service/file_has_access_or_download.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/service/file_has_access_or_download.py b/backend/app/service/file_has_access_or_download.py index bdb16407..e1ccef3d 100644 --- a/backend/app/service/file_has_access_or_download.py +++ b/backend/app/service/file_has_access_or_download.py @@ -17,7 +17,7 @@ def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str: from mimetypes import guess_extension resp = requests.get(newPhoto) ext = guess_extension(resp.headers['content-type']) - if allowed_file('file' + ext): + if ext and allowed_file('file' + ext): filename = secure_filename(str(uuid.uuid4()) + ext) File(filename=filename, created_by=current_user.id).save() with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as o: From 8af1f0d092682ef69b7ad102db36dc91446defda Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 9 May 2023 23:01:21 +0200 Subject: [PATCH 302/496] fix: add PostgreSQL plugin --- backend/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/requirements.txt b/backend/requirements.txt index 8ab7f50f..efd4df57 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,6 +6,7 @@ autopep8==2.0.2 bcrypt==4.0.1 beautifulsoup4==4.12.2 black==23.1a1 +blinker==1.6.2 certifi==2022.12.7 cffi==1.15.1 charset-normalizer==3.1.0 @@ -52,6 +53,7 @@ pathspec==0.11.1 Pillow==9.5.0 platformdirs==3.5.0 pluggy==1.0.0 +psycopg2-binary==2.9.6 py==1.11.0 pycodestyle==2.10.0 pycparser==2.21 From ae4be6a73944d0ec5800128d4a4ffbb8c85ec119 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 10 May 2023 00:40:09 +0200 Subject: [PATCH 303/496] fix: Flask-Migrate repository --- backend/migrations/README | 2 +- backend/migrations/alembic.ini | 7 +++++- backend/migrations/env.py | 44 ++++++++++++++++++++++------------ 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/backend/migrations/README b/backend/migrations/README index 98e4f9c4..0e048441 100644 --- a/backend/migrations/README +++ b/backend/migrations/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Single-database configuration for Flask. diff --git a/backend/migrations/alembic.ini b/backend/migrations/alembic.ini index f8ed4801..ec9d45c2 100644 --- a/backend/migrations/alembic.ini +++ b/backend/migrations/alembic.ini @@ -11,7 +11,7 @@ # Logging configuration [loggers] -keys = root,sqlalchemy,alembic +keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console @@ -34,6 +34,11 @@ level = INFO handlers = qualname = alembic +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + [handler_console] class = StreamHandler args = (sys.stderr,) diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 8b3fb335..89f80b21 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -1,10 +1,6 @@ -from __future__ import with_statement - import logging from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool from flask import current_app from alembic import context @@ -18,14 +14,30 @@ fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except TypeError: + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -config.set_main_option( - 'sqlalchemy.url', - str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) -target_metadata = current_app.extensions['migrate'].db.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db # other values from the config, defined by the needs of env.py, # can be acquired: @@ -33,6 +45,12 @@ # ... etc. +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + def run_migrations_offline(): """Run migrations in 'offline' mode. @@ -47,7 +65,7 @@ def run_migrations_offline(): """ url = config.get_main_option("sqlalchemy.url") context.configure( - url=url, target_metadata=target_metadata, literal_binds=True + url=url, target_metadata=get_metadata(), literal_binds=True ) with context.begin_transaction(): @@ -72,16 +90,12 @@ def process_revision_directives(context, revision, directives): directives[:] = [] logger.info('No changes in schema detected.') - connectable = engine_from_config( - config.get_section(config.config_ini_section), - prefix='sqlalchemy.', - poolclass=pool.NullPool, - ) + connectable = get_engine() with connectable.connect() as connection: context.configure( connection=connection, - target_metadata=target_metadata, + target_metadata=get_metadata(), process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) From 372175e0ee663bd5fa9abdec2b7d538bb70e683a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 10 May 2023 01:50:43 +0200 Subject: [PATCH 304/496] fix: PostgreSQL migration --- backend/migrations/versions/11c15698c8bf_.py | 28 +++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/backend/migrations/versions/11c15698c8bf_.py b/backend/migrations/versions/11c15698c8bf_.py index 056e6dae..821c4a01 100644 --- a/backend/migrations/versions/11c15698c8bf_.py +++ b/backend/migrations/versions/11c15698c8bf_.py @@ -8,6 +8,8 @@ from alembic import op import sqlalchemy as sa +from app.config import DB_URL + # revision identifiers, used by Alembic. revision = '11c15698c8bf' @@ -29,28 +31,30 @@ def upgrade(): batch_op.add_column(sa.Column('category_id', sa.Integer(), nullable=True)) batch_op.create_foreign_key(batch_op.f('fk_expense_category_id_expense_category'), 'expense_category', ['category_id'], ['id']) - with op.batch_alter_table('item', schema=None) as batch_op: - batch_op.create_unique_constraint(batch_op.f('uq_item_name'), ['name']) + if DB_URL.drivername == 'sqlite': + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_item_name'), ['name']) - with op.batch_alter_table('shoppinglist', schema=None) as batch_op: - batch_op.create_unique_constraint(batch_op.f('uq_shoppinglist_name'), ['name']) + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_shoppinglist_name'), ['name']) - with op.batch_alter_table('user', schema=None) as batch_op: - batch_op.create_unique_constraint(batch_op.f('uq_user_username'), ['username']) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_user_username'), ['username']) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('uq_user_username'), type_='unique') + if DB_URL.drivername == 'sqlite': + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_user_username'), type_='unique') - with op.batch_alter_table('shoppinglist', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('uq_shoppinglist_name'), type_='unique') + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_shoppinglist_name'), type_='unique') - with op.batch_alter_table('item', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('uq_item_name'), type_='unique') + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_item_name'), type_='unique') with op.batch_alter_table('expense', schema=None) as batch_op: batch_op.drop_constraint(batch_op.f('fk_expense_category_id_expense_category'), type_='foreignkey') From c58695a77847e2dfed2cb25a3b6bc45e9f1d3046 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 10 May 2023 14:07:45 +0200 Subject: [PATCH 305/496] Prepare release 60 --- backend/app/config.py | 2 +- backend/entrypoint.sh | 2 +- backend/requirements.txt | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index a2222f13..2c2961d3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 59 +BACKEND_VERSION = 60 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 909c18fd..6370c7db 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/sh flask db upgrade mkdir -p $STORAGE_PATH/upload -python upgrade_default_items.py +#python upgrade_default_items.py uwsgi "$@" \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index efd4df57..9e234952 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,7 +7,7 @@ bcrypt==4.0.1 beautifulsoup4==4.12.2 black==23.1a1 blinker==1.6.2 -certifi==2022.12.7 +certifi==2023.5.7 cffi==1.15.1 charset-normalizer==3.1.0 click==8.1.3 @@ -58,7 +58,7 @@ py==1.11.0 pycodestyle==2.10.0 pycparser==2.21 pyflakes==3.0.1 -PyJWT==2.6.0 +PyJWT==2.7.0 pyparsing==3.0.9 pyRdfa3==3.5.3 pytest==7.3.1 @@ -90,8 +90,8 @@ types-urllib3==1.26.25.12 typing_extensions==4.5.0 tzdata==2023.3 tzlocal==4.3 -urllib3==1.26.15 +urllib3==2.0.2 uWSGI==2.0.21 w3lib==2.1.1 webencodings==0.5.1 -Werkzeug==2.3.3 +Werkzeug==2.3.4 From bce30c4552fe0af743f50acb74eadf029c893df0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 10 May 2023 14:20:43 +0200 Subject: [PATCH 306/496] fix: manage user script --- backend/manage.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/manage.py b/backend/manage.py index 925c6be7..a4a1c07f 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -41,6 +41,10 @@ def manageUsers(): newPWRepeat = input("Repeat new password:") if newPW.strip() == newPWRepeat.strip(): user.set_password(newPW.strip()) + user.save() + else: + print("Passwords do not match") + continue elif selection == "3": username = input("Enter the username:") user = User.find_by_username(username) From 61bd59de90a67eb479d4bc4176acf8060cca5f38 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 11 May 2023 13:15:01 +0200 Subject: [PATCH 307/496] fix: suggestion calculation --- .../controller/planner/planner_controller.py | 6 ++-- backend/app/jobs/cluster_shoppings.py | 7 ++--- backend/app/jobs/item_ordering.py | 10 ++----- backend/app/jobs/item_suggestions.py | 13 ++++---- backend/app/jobs/jobs.py | 25 ++++++---------- backend/app/jobs/recipe_suggestions.py | 26 +++++++++------- backend/app/models/recipe.py | 13 ++++---- backend/manage.py | 30 ++++++++++++------- 8 files changed, 66 insertions(+), 64 deletions(-) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 84779054..9bd47641 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -99,11 +99,11 @@ def getSuggestedRecipes(household_id): return jsonify([r.obj_to_full_dict() for r in suggested_recipes]) -@plannerHousehold.route('/refresh-suggested-recipes', methods=['GET', 'POST']) +@plannerHousehold.route('/refresh-suggested-recipes', methods=['GET']) @jwt_required() @authorize_household() def getRefreshedSuggestedRecipes(household_id): # re-compute suggestion ranking - Recipe.compute_suggestion_ranking() + Recipe.compute_suggestion_ranking(household_id) # return suggested recipes - return getSuggestedRecipes(household_id) + return getSuggestedRecipes(household_id=household_id) diff --git a/backend/app/jobs/cluster_shoppings.py b/backend/app/jobs/cluster_shoppings.py index fcb422f6..5a29cf48 100644 --- a/backend/app/jobs/cluster_shoppings.py +++ b/backend/app/jobs/cluster_shoppings.py @@ -6,8 +6,8 @@ import numpy as np -def clusterShoppings(): - dropped = History.find_dropped_by_shoppinglist_id(1) +def clusterShoppings(shoppinglist_id: int) -> list: + dropped = History.find_dropped_by_shoppinglist_id(shoppinglist_id) if (len(dropped) == 0): app.logger.info("no history to investigate") @@ -44,7 +44,4 @@ def clusterShoppings(): shopping_instances = [list(set(instance)) for instance in shopping_instances] - app.logger.info('the found shopping instances are:') - app.logger.info(shopping_instances) - return shopping_instances diff --git a/backend/app/jobs/item_ordering.py b/backend/app/jobs/item_ordering.py index 9f4f566b..93312151 100644 --- a/backend/app/jobs/item_ordering.py +++ b/backend/app/jobs/item_ordering.py @@ -10,16 +10,13 @@ def findItemOrdering(shopping_instances): sorter.updateMatrix(items) order = sorter.topologicalSort() - # reset ordering for all items - for item in Item.query.all(): - item.ordering = 0 - # store the ordering directly in each item for ord in range(len(order)): item_id = order[ord] item = Item.find_by_id(item_id) if item: item.ordering = ord+1 + db.session.add(item) # commit changes to db db.session.commit() @@ -28,7 +25,6 @@ def findItemOrdering(shopping_instances): class ItemSort: - def __init__(self): # stores the costs for ordering self.matrix = [] @@ -40,7 +36,7 @@ def __init__(self): # determines decay rate (must be between 0 and 1) self.decay = 0.75 - def updateMatrix(self, lst): + def updateMatrix(self, lst: list): # extend matrix for unseed items for item in lst: if item not in self.indices: @@ -67,7 +63,7 @@ def updateMatrix(self, lst): predIndex = self.item_dict[pred] self.matrix[index][predIndex] += cost - def topologicalSort(self): + def topologicalSort(self) -> list: mtx = copy.deepcopy(self.matrix) order = [] diff --git a/backend/app/jobs/item_suggestions.py b/backend/app/jobs/item_suggestions.py index ec264c57..15ac4bf0 100644 --- a/backend/app/jobs/item_suggestions.py +++ b/backend/app/jobs/item_suggestions.py @@ -28,16 +28,13 @@ def findItemSuggestions(shopping_instances): single_items.insert(0, "single", [list(tup)[0] for tup in single_items["itemsets"]], False) - # reset ordering for all items - for item in Item.query.all(): - item.support = 0 - # store support values for index, row in single_items.iterrows(): item_id = row["single"] item = Item.find_by_id(item_id) if item: item.support = row["support"] + db.session.add(item) # commit changes to db db.session.commit() @@ -61,6 +58,10 @@ def findItemSuggestions(shopping_instances): # store all new associations for index, rule in single_rules.iterrows(): - Association.create(rule["antecedent"], rule["consequent"], - rule["support"], rule["confidence"], rule["lift"]) + a = Association(antecedent_id=rule["antecedent"], + consequent_id=rule["consequent"], + support=rule["support"], + confidence=rule["confidence"], + lift=rule["lift"]) + db.session.add(a) app.logger.info("associations rules of size 2 were updated") diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index bdd8fd91..8353a253 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -1,32 +1,25 @@ -from app.jobs.recipe_suggestions import findMealInstancesFromHistory, computeRecipeSuggestions +from app.jobs.recipe_suggestions import computeRecipeSuggestions from app import app, scheduler -from app.models import Token +from app.models import Token, Household, Shoppinglist from .item_ordering import findItemOrdering from .item_suggestions import findItemSuggestions from .cluster_shoppings import clusterShoppings -# # for debugging: -# @scheduler.task('interval', id='test', seconds=5) -# def test(): -# with app.app_context(): -# app.logger.info("--- test analysis is starting ---") -# # recipe planner tasks -# meal_instances = findMealInstancesFromHistory() -# computeRecipeSuggestions(meal_instances) -# app.logger.info("--- test analysis is completed ---") +# # for debugging run: FLASK_DEBUG=True python manage.py @scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') def daily(): with app.app_context(): app.logger.info("--- daily analysis is starting ---") # shopping tasks - shopping_instances = clusterShoppings() - findItemOrdering(shopping_instances) - findItemSuggestions(shopping_instances) + for household in Household.all(): + shopping_instances = clusterShoppings(Shoppinglist.query.filter(Shoppinglist.household_id == household.id).first().id) + if shopping_instances: + findItemOrdering(shopping_instances) + findItemSuggestions(shopping_instances) # recipe planner tasks - meal_instances = findMealInstancesFromHistory() - computeRecipeSuggestions(meal_instances) + computeRecipeSuggestions() app.logger.info("--- daily analysis is completed ---") diff --git a/backend/app/jobs/recipe_suggestions.py b/backend/app/jobs/recipe_suggestions.py index ad563848..9d17165a 100644 --- a/backend/app/jobs/recipe_suggestions.py +++ b/backend/app/jobs/recipe_suggestions.py @@ -2,6 +2,7 @@ from app import app, db import datetime +from app.models.recipe_history import Status # minimum hours on planner until a recipe is considered to have been cooked MEAL_THRESHOLD = 3 @@ -9,8 +10,8 @@ def findMealInstancesFromHistory(): return findMealInstances( - RecipeHistory.find_added(), - RecipeHistory.find_dropped()) + RecipeHistory.query.filter(RecipeHistory.status == Status.ADDED).all(), + RecipeHistory.query.filter(RecipeHistory.status == Status.DROPPED).all()) def findMealInstances(added, dropped): @@ -55,7 +56,8 @@ def findMealInstances(added, dropped): return meals -def computeRecipeSuggestions(meal_instances): +def computeRecipeSuggestions(): + meal_instances = findMealInstancesFromHistory() # group meals by their id meal_hist = dict() for m in meal_instances: @@ -67,29 +69,31 @@ def computeRecipeSuggestions(meal_instances): # 0) reset all suggestion scores for r in Recipe.all(): r.suggestion_score = 0 + db.session.add(r) # 1) count cooked instances in last six months - six_months_ago = datetime.datetime.now() - datetime.timedelta(days=182) + six_months_ago = datetime.datetime.utcnow() - datetime.timedelta(days=182) for id in meal_hist: cooking_count = 0 for cooked in meal_hist[id]: if cooked > six_months_ago: cooking_count += 1 # set suggestion_score to cooking_count - Recipe.find_by_id(id).suggestion_score = cooking_count + r = Recipe.find_by_id(id) + r.suggestion_score = cooking_count + print((r.id, cooking_count)) + db.session.add(r) # 2) do not suggest recent meals - week_ago = datetime.datetime.now() - datetime.timedelta(days=7) + week_ago = datetime.datetime.utcnow() - datetime.timedelta(days=7) # find recently cooked meals for id in meal_hist: for cooked in meal_hist[id]: if cooked > week_ago: - Recipe.find_by_id(id).suggestion_score = 0 + r = Recipe.find_by_id(id) + r.suggestion_score = 0 + db.session.add(r) # commit changes to db db.session.commit() app.logger.info("computed and stored new suggestion scores") - - # compute new suggestion ranking - Recipe.compute_suggestion_ranking() - app.logger.info("computed and stored new suggestion ranking") diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index f6b2bb19..d0b056aa 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -34,7 +34,8 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): 'RecipeTags', back_populates='recipe', cascade="all, delete-orphan") plans = db.relationship( 'Planner', back_populates='recipe', cascade="all, delete-orphan") - photo_file = db.relationship("File", back_populates='recipe', uselist=False) + photo_file = db.relationship( + "File", back_populates='recipe', uselist=False) def obj_to_dict(self) -> dict: res = super().obj_to_dict() @@ -76,13 +77,14 @@ def obj_to_export_dict(self) -> dict: return res @classmethod - def compute_suggestion_ranking(cls): + def compute_suggestion_ranking(cls, household_id: int): # reset all suggestion ranks - for r in cls.all(): + for r in cls.query.filter(cls.household_id == household_id).all(): r.suggestion_rank = 0 + db.session.add(r) # get all recipes with positive suggestion_score - recipes = cls.query.filter( # noqa - cls.suggestion_score != 0).all() + recipes = cls.query.filter( + cls.household_id == household_id, cls.suggestion_score != 0).all() # compute the initial sum of all suggestion_scores suggestion_sum = 0 for r in recipes: @@ -99,6 +101,7 @@ def compute_suggestion_ranking(cls): current_rank += 1 suggestion_sum -= r.suggestion_score to_be_removed = i + db.session.add(r) break recipes.pop(to_be_removed) db.session.commit() diff --git a/backend/manage.py b/backend/manage.py index a4a1c07f..f2094167 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -2,6 +2,7 @@ from os.path import isfile, join from app import app, db from app.config import UPLOAD_FOLDER +from app.jobs import jobs from app.models import User, File def importFiles(): @@ -57,17 +58,24 @@ def manageUsers(): # docker exec -it [backend container name] python manage.py if __name__ == "__main__": - with app.app_context(): - while True: - print(""" + while True: + print(""" Manage KitchenOwl\n---\nWhat do you want to do? - 1. Manage users - 2. Import files - (q) Exit""") - selection = input("Your selection (q):") - if selection == "1": +1. Manage users +2. Import files +3. Run all jobs +(q) Exit""") + selection = input("Your selection (q):") + if selection == "1": + with app.app_context(): manageUsers() - elif selection == "2": + elif selection == "2": + with app.app_context(): importFiles() - else: - exit() + elif selection == "3": + print("Starting jobs (might take a while)...") + jobs.daily() + jobs.halfHourly() + print("Done!") + else: + exit() From b05a1481e9693df45ea6d7f65204efa8b118d0a5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 11 May 2023 13:21:36 +0200 Subject: [PATCH 308/496] Prepare release 61 --- backend/app/config.py | 2 +- backend/app/controller/planner/planner_controller.py | 2 +- backend/requirements.txt | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 2c2961d3..a9aca13d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 60 +BACKEND_VERSION = 61 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index 9bd47641..cfdd5ba0 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -99,7 +99,7 @@ def getSuggestedRecipes(household_id): return jsonify([r.obj_to_full_dict() for r in suggested_recipes]) -@plannerHousehold.route('/refresh-suggested-recipes', methods=['GET']) +@plannerHousehold.route('/refresh-suggested-recipes', methods=['GET', 'POST']) @jwt_required() @authorize_household() def getRefreshedSuggestedRecipes(household_id): diff --git a/backend/requirements.txt b/backend/requirements.txt index 9e234952..b6244245 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,7 +22,7 @@ Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.4.4 Flask-Migrate==4.0.4 Flask-SQLAlchemy==3.0.3 -fonttools==4.39.3 +fonttools==4.39.4 greenlet==2.0.2 html-text==0.5.2 html5lib==1.1 @@ -70,23 +70,23 @@ pytz-deprecation-shim==0.1.0.post0 rdflib==6.3.2 rdflib-jsonld==0.6.2 recipe-scrapers==14.36.1 -regex==2023.3.23 +regex==2023.5.5 requests==2.30.0 scikit-learn==1.2.2 scipy==1.10.1 setuptools-scm==7.1.0 six==1.16.0 soupsieve==2.4.1 -SQLAlchemy==2.0.12 +SQLAlchemy==2.0.13 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 tqdm==4.65.0 typed-ast==1.5.4 -types-beautifulsoup4==4.12.0.4 -types-html5lib==1.1.11.13 +types-beautifulsoup4==4.12.0.5 +types-html5lib==1.1.11.14 types-requests==2.30.0.0 -types-urllib3==1.26.25.12 +types-urllib3==1.26.25.13 typing_extensions==4.5.0 tzdata==2023.3 tzlocal==4.3 From 91fd739a29b61d381f2681735ee2c349020e5aaf Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 11 May 2023 13:45:59 +0200 Subject: [PATCH 309/496] fix: remove print statement --- backend/app/jobs/recipe_suggestions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/jobs/recipe_suggestions.py b/backend/app/jobs/recipe_suggestions.py index 9d17165a..30d41bf2 100644 --- a/backend/app/jobs/recipe_suggestions.py +++ b/backend/app/jobs/recipe_suggestions.py @@ -81,7 +81,6 @@ def computeRecipeSuggestions(): # set suggestion_score to cooking_count r = Recipe.find_by_id(id) r.suggestion_score = cooking_count - print((r.id, cooking_count)) db.session.add(r) # 2) do not suggest recent meals From 188cfac2f3d7a47c95869bee162e60765f232b3b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 12 May 2023 19:43:05 +0200 Subject: [PATCH 310/496] feat: add features to manage script --- backend/manage.py | 84 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/backend/manage.py b/backend/manage.py index f2094167..779146ce 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -3,7 +3,7 @@ from app import app, db from app.config import UPLOAD_FOLDER from app.jobs import jobs -from app.models import User, File +from app.models import User, File, Household, HouseholdMember def importFiles(): try: @@ -17,35 +17,38 @@ def importFiles(): db.session.rollback() raise e +def manageHouseholds(): + while True: + print(""" +What next? + 1. List all households + (q) Go back""") + selection = input("Your selection (q):") + if selection == "1": + for h in Household.all(): + print(f"Id {h.id}: {h.name} ({len(h.member)} members)") + else: + return + def manageUsers(): while True: print(""" What next? - 1. List user - 2. Reset password + 1. List all users + 2. Update user 3. Delete user - 4. Go back""") - selection = input("Your selection (4):") + (q) Go back""") + selection = input("Your selection (q):") if selection == "1": for u in User.all(): - print(u.username) + print(f"@{u.username}: {u.name} (server admin: {u.admin})") elif selection == "2": username = input("Enter the username:") user = User.find_by_username(username) if not user: print("No user found with that username") else: - newPW = input("Enter new password:") - if not newPW.strip(): - print("Password cannot be empty") - continue - newPWRepeat = input("Repeat new password:") - if newPW.strip() == newPWRepeat.strip(): - user.set_password(newPW.strip()) - user.save() - else: - print("Passwords do not match") - continue + updateUser(user) elif selection == "3": username = input("Enter the username:") user = User.find_by_username(username) @@ -56,14 +59,52 @@ def manageUsers(): else: return +def updateUser(user: User): + print(f""" +Settings for {user.name} (@{user.username}) (server admin: {user.admin}) + 1. Update password + 2. Add to household + 3. Set server admin + (q) Go back""") + selection = input("Your selection (q):") + if selection == "1": + newPW = input("Enter new password:") + if not newPW.strip(): + print("Password cannot be empty") + newPWRepeat = input("Repeat new password:") + if newPW.strip() == newPWRepeat.strip(): + user.set_password(newPW.strip()) + user.save() + else: + print("Passwords do not match") + elif selection == "2": + id = input("Enter the household id:") + household = Household.find_by_id(id) + if not household: + print("No household found with that id") + elif not HouseholdMember.find_by_ids(household.id, user.id): + hm = HouseholdMember() + hm.user_id = user.id + hm.household_id = household.id + hm.save() + else: + print("User is already part of that household") + elif selection == "3": + selection = input("Set admin (y/N):") + user.admin = selection == "y" + user.save() + else: + return + # docker exec -it [backend container name] python manage.py if __name__ == "__main__": while True: print(""" Manage KitchenOwl\n---\nWhat do you want to do? 1. Manage users -2. Import files -3. Run all jobs +2. Manage households +3. Import files +4. Run all jobs (q) Exit""") selection = input("Your selection (q):") if selection == "1": @@ -71,8 +112,11 @@ def manageUsers(): manageUsers() elif selection == "2": with app.app_context(): - importFiles() + manageHouseholds() elif selection == "3": + with app.app_context(): + importFiles() + elif selection == "4": print("Starting jobs (might take a while)...") jobs.daily() jobs.halfHourly() From 01745d39195758a5d3b7314cd53a25a93ba75e59 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 12 May 2023 19:44:51 +0200 Subject: [PATCH 311/496] Prepare release 62 --- backend/app/config.py | 2 +- backend/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index a9aca13d..e2f792db 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 61 +BACKEND_VERSION = 62 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) diff --git a/backend/requirements.txt b/backend/requirements.txt index b6244245..67da1f00 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -51,7 +51,7 @@ packaging==23.1 pandas==2.0.1 pathspec==0.11.1 Pillow==9.5.0 -platformdirs==3.5.0 +platformdirs==3.5.1 pluggy==1.0.0 psycopg2-binary==2.9.6 py==1.11.0 From 76c7675706919a9458513fd4e128d107dedc396c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 15 May 2023 12:04:31 +0200 Subject: [PATCH 312/496] fix: migration of user admin --- backend/migrations/versions/6c669d9ec3bd_.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/migrations/versions/6c669d9ec3bd_.py b/backend/migrations/versions/6c669d9ec3bd_.py index f65f09c5..23ebd9ba 100644 --- a/backend/migrations/versions/6c669d9ec3bd_.py +++ b/backend/migrations/versions/6c669d9ec3bd_.py @@ -212,6 +212,7 @@ def upgrade(): hm.owner = user.owner hm.expense_balance = user.expense_balance models.append(hm) + user.admin = user.admin or user.owner models.append(household) models += users From d1d37b0eb702f846c1abd60f63ed350c1953160a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 15 May 2023 12:35:29 +0200 Subject: [PATCH 313/496] fix: no admin for existing instances --- backend/migrations/versions/8897db89e7af_.py | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 backend/migrations/versions/8897db89e7af_.py diff --git a/backend/migrations/versions/8897db89e7af_.py b/backend/migrations/versions/8897db89e7af_.py new file mode 100644 index 00000000..aa1d6c03 --- /dev/null +++ b/backend/migrations/versions/8897db89e7af_.py @@ -0,0 +1,43 @@ +"""empty message + +Revision ID: 8897db89e7af +Revises: c058421705ec +Create Date: 2023-05-15 12:26:45.223242 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm + +DeclarativeBase = orm.declarative_base() + + +# revision identifiers, used by Alembic. +revision = '8897db89e7af' +down_revision = 'c058421705ec' +branch_labels = None +depends_on = None + + +class User(DeclarativeBase): + __tablename__ = 'user' + id = sa.Column(sa.Integer, primary_key=True) + admin = sa.Column('admin', sa.Boolean(), nullable=False) + +def upgrade(): + bind = op.get_bind() + session = orm.Session(bind=bind) + if session.query(User).count() > 0 and session.query(User).filter(User.admin == True).count() == 0: + admin = session.query(User).order_by(User.id).first() + admin.admin = True + try: + session.add(admin) + session.commit() + except Exception as e: + session.rollback() + raise e + + + +def downgrade(): + pass From b67aa89e79cfb6ffd5fa3f7ec2afb483c348da28 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 15 May 2023 12:36:25 +0200 Subject: [PATCH 314/496] Prepare release 63 --- backend/app/config.py | 2 +- backend/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index e2f792db..63c07478 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 62 +BACKEND_VERSION = 63 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) diff --git a/backend/requirements.txt b/backend/requirements.txt index 67da1f00..76fe2cce 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -89,7 +89,7 @@ types-requests==2.30.0.0 types-urllib3==1.26.25.13 typing_extensions==4.5.0 tzdata==2023.3 -tzlocal==4.3 +tzlocal==5.0 urllib3==2.0.2 uWSGI==2.0.21 w3lib==2.1.1 From a8939e68e2ac8466a2cde5537e515444b0fc12f8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 23 May 2023 12:21:15 +0200 Subject: [PATCH 315/496] feat: make health endpoint return privacy policy --- backend/app/config.py | 2 ++ backend/app/controller/health_controller.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 63c07478..a51cc001 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,6 +22,8 @@ UPLOAD_FOLDER = os.getenv('STORAGE_PATH', PROJECT_DIR) + '/upload' ALLOWED_FILE_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} +PRIVACY_POLICY_URL = os.getenv('PRIVACY_POLICY_URL') + DB_URL = URL.create( os.getenv('DB_DRIVER', "sqlite"), username=os.getenv('DB_USER'), diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 80a94c95..16f68747 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -1,5 +1,5 @@ from flask import jsonify, Blueprint -from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION +from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION, PRIVACY_POLICY_URL from app.models import Settings from app.config import SUPPORTED_LANGUAGES @@ -13,6 +13,8 @@ def get_health(): 'version': BACKEND_VERSION, 'min_frontend_version': MIN_FRONTEND_VERSION, } + if PRIVACY_POLICY_URL: + info['privacy_policy'] = PRIVACY_POLICY_URL return jsonify(info) @health.route('/supported-languages', methods=['GET']) From a02b54d6d29918fa78ad60f8646169897b8f5870 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 May 2023 15:16:01 +0200 Subject: [PATCH 316/496] chore(deps): bump requests from 2.30.0 to 2.31.0 (TomBursch/kitchenowl-backend#33) Bumps [requests](https://github.com/psf/requests) from 2.30.0 to 2.31.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.30.0...v2.31.0) --- updated-dependencies: - dependency-name: requests dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 76fe2cce..ae862045 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -71,7 +71,7 @@ rdflib==6.3.2 rdflib-jsonld==0.6.2 recipe-scrapers==14.36.1 regex==2023.5.5 -requests==2.30.0 +requests==2.31.0 scikit-learn==1.2.2 scipy==1.10.1 setuptools-scm==7.1.0 From 4669c1d571f909b60c59f1cbcb7c3ea9b7e8adcc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 23 May 2023 15:56:57 +0200 Subject: [PATCH 317/496] chore: upgrade requirements --- backend/requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index ae862045..95835a13 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -alembic==1.10.4 +alembic==1.11.1 appdirs==1.4.4 APScheduler==3.10.1 attrs==23.1.0 @@ -77,7 +77,7 @@ scipy==1.10.1 setuptools-scm==7.1.0 six==1.16.0 soupsieve==2.4.1 -SQLAlchemy==2.0.13 +SQLAlchemy==2.0.15 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 @@ -85,11 +85,11 @@ tqdm==4.65.0 typed-ast==1.5.4 types-beautifulsoup4==4.12.0.5 types-html5lib==1.1.11.14 -types-requests==2.30.0.0 +types-requests==2.31.0.0 types-urllib3==1.26.25.13 -typing_extensions==4.5.0 +typing_extensions==4.6.0 tzdata==2023.3 -tzlocal==5.0 +tzlocal==5.0.1 urllib3==2.0.2 uWSGI==2.0.21 w3lib==2.1.1 From 027914b873f0a8ae642e9f239e9d01c0e41f4adc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 24 May 2023 00:27:02 +0200 Subject: [PATCH 318/496] feat: Allow optional public user signup (TomBursch/kitchenowl-backend#34) --- backend/app/config.py | 1 + .../app/controller/auth/auth_controller.py | 35 +++++++++++++++++-- backend/app/controller/auth/schemas.py | 20 +++++++++++ backend/app/controller/health_controller.py | 4 ++- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index a51cc001..cf6d4187 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,6 +23,7 @@ ALLOWED_FILE_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} PRIVACY_POLICY_URL = os.getenv('PRIVACY_POLICY_URL') +OPEN_REGISTRATION = os.getenv('OPEN_REGISTRATION', "False").lower() == "true" DB_URL = URL.create( os.getenv('DB_DRIVER', "sqlite"), diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 2d53083f..6b234b75 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -3,9 +3,9 @@ from flask import jsonify, Blueprint, request from flask_jwt_extended import current_user, jwt_required, get_jwt from app.models import User, Token -from app.errors import UnauthorizedRequest -from .schemas import Login, CreateLongLivedToken -from app.config import jwt +from app.errors import UnauthorizedRequest, InvalidUsage +from .schemas import Login, Signup, CreateLongLivedToken +from app.config import jwt, OPEN_REGISTRATION auth = Blueprint('auth', __name__) @@ -63,6 +63,35 @@ def login(args): }) +if OPEN_REGISTRATION: + @auth.route('signup', methods=['POST']) + @validate_args(Signup) + def signup(args): + username = args['username'].strip().lower() + user = User.find_by_username(username) + if user: + raise InvalidUsage() + + user = User(username=username, name=args['name'].strip()) + user.set_password(args['password']) + user.save() + + device = "Unkown" + if "device" in args: + device = args['device'] + + # Create refresh token + refreshToken, refreshModel = Token.create_refresh_token(user, device) + + # Create first access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({ + 'access_token': accesssToken, + 'refresh_token': refreshToken + }) + + @auth.route('/refresh', methods=['GET']) @jwt_required(refresh=True) def refresh(): diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py index 533b323f..8e9f1050 100644 --- a/backend/app/controller/auth/schemas.py +++ b/backend/app/controller/auth/schemas.py @@ -17,6 +17,26 @@ class Login(Schema): load_only=True, ) +class Signup(Schema): + username = fields.String( + required=True, + validate=lambda a: a and not a.isspace() and not "@" in a + ) + 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 CreateLongLivedToken(Schema): device = fields.String( diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 16f68747..7b49e198 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -1,5 +1,5 @@ from flask import jsonify, Blueprint -from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION, PRIVACY_POLICY_URL +from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION, PRIVACY_POLICY_URL, OPEN_REGISTRATION from app.models import Settings from app.config import SUPPORTED_LANGUAGES @@ -15,6 +15,8 @@ def get_health(): } if PRIVACY_POLICY_URL: info['privacy_policy'] = PRIVACY_POLICY_URL + if OPEN_REGISTRATION: + info['open_registration'] = True return jsonify(info) @health.route('/supported-languages', methods=['GET']) From 6815548c0ebf4c05069a9adc84d94abee962a4c8 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 24 May 2023 01:04:35 +0200 Subject: [PATCH 319/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#32) * Added translation using Weblate (Polish) * Translated using Weblate (Polish) Currently translated at 35.3% (148 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pl/ * Translated using Weblate (Polish) Currently translated at 53.9% (226 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pl/ --------- Co-authored-by: Kay --- backend/templates/l10n/pl.json | 425 +++++++++++++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 backend/templates/l10n/pl.json diff --git a/backend/templates/l10n/pl.json b/backend/templates/l10n/pl.json new file mode 100644 index 00000000..62cdf61c --- /dev/null +++ b/backend/templates/l10n/pl.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 Pieczywo", + "canned": "🥫 Konserwy", + "dairy": "🥛 Nabiał", + "drinks": "🍹 Napoje", + "freezer": "❄️ Mrożone", + "fruits_vegetables": "🥬 Warzywa i owoce", + "grain": "🥟 Wyroby mączne", + "hygiene": "🚽 Higiena", + "refrigerated": "💧 Schłodzone", + "snacks": "🥜 Przekąski" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Jabłko", + "apple_pulp": "Przecier jabłkowy", + "applesauce": "Mus jabłkowy", + "apérol": "Apérol", + "arugula": "Rukola", + "asian_egg_noodles": "Azjatycki makaron jajeczny", + "asparagus": "Szparagi", + "aspirin": "Aspiryna", + "avocado": "Awokado", + "baby_spinach": "Szpinak", + "bacon": "Boczek", + "baguette": "Bagietka", + "bakefish": "Smażona ryba", + "baking_cocoa": "Kakao do pieczenia", + "baking_mix": "Gotowa mieszanka", + "baking_paper": "Papier do pieczenia", + "baking_powder": "Proszek do pieczenia", + "baking_soda": "Soda oczyszczona", + "baking_yeast": "Drożdże", + "balsamic_vinegar": "Ocet balsamiczny", + "bananas": "Banany", + "basil": "Bazylia", + "basmati_rice": "Ryż basmati", + "bathroom_cleaner": "Płyn do czyszczenia toalet", + "batteries": "Baterie", + "bay_leaf": "Liść laurowy", + "beans": "Fasolka", + "beer": "Piwo", + "beet": "Buraki", + "beetroot": "Buraki", + "birthday_card": "Kartka urodzinowa", + "black_beans": "Czarna fasolka", + "bockwurst": "Parówka", + "bodywash": "Żel do mycia", + "bread": "Chleb", + "breadcrumbs": "Panierka", + "broccoli": "Brokuły", + "brown_sugar": "Cukier brązowy", + "brussels_sprouts": "Brukselka", + "buffalo_mozzarella": "Mozzarella wołowa", + "buko": "Buko", + "buns": "Bułeczki", + "burger_buns": "Bułki do hamburgerów", + "burger_patties": "Kotlety do hamburgerów", + "burger_sauces": "Sosy do burgerów", + "butter": "Masło", + "butter_cookies": "Ciastka maślane", + "button_cells": "Baterie guzikowe", + "börek_cheese": "Ser Börek", + "cake": "Ciasto", + "cake_icing": "Polewa do ciasta", + "cane_sugar": "Cukier trzcinowy", + "cannelloni": "Cannelloni", + "canola_oil": "Olej rzepakowy", + "cardamom": "Kardamon", + "carrots": "Marchewki", + "cashews": "Nanercz zachodni", + "cat_treats": "Smaczki dla kota", + "cauliflower": "Kalafior", + "celeriac": "Seler", + "celery": "Seler naciowy", + "cereal_bar": "Baton musli", + "cheddar": "Ser cheddar", + "cheese": "Ser", + "cherry_tomatoes": "Pomidorki koktajlowe", + "chickpeas": "Ciecierzyca", + "chili_oil": "Olej chili", + "chips": "Frytki", + "chives": "Szczypiorek", + "chocolate": "Czekolada", + "chocolate_chips": "Cząstki czekolady", + "chopped_tomatoes": "Pokrojone pomidory", + "ciabatta": "Ciabatta", + "cider_vinegar": "Ocet jabłkowy", + "cilantro": "Kolendra", + "cinnamon": "Cynamon", + "cinnamon_stick": "Laska cynamonowa", + "cocktail_sauce": "Sos koktajlowy", + "cocktail_tomatoes": "Pomidorki koktajlowe", + "coconut_flakes": "Wiórki kokosowe", + "coconut_milk": "Mleko kokosowe", + "coconut_oil": "Olej kokosowy", + "colorful_sprinkles": "Kolorowa posypka", + "concealer": "Korektor", + "cookies": "Ciastka", + "coriander": "Kolendra", + "corn": "Kukurydza", + "cornflakes": "Płatki kukurydziane", + "cornstarch": "Skrobia kukurydziana", + "cornys": "Cornys", + "cough_drops": "Tabletki na kaszel", + "couscous": "Kuskus", + "covid_rapid_test": "Szybki test COVID", + "cow's_milk": "Mleko krowie", + "cream": "Śmietana", + "cream_cheese": "Ser biały", + "creamed_spinach": "Breja szpinakowa", + "creme_fraiche": "Crème fraîche", + "crepe_tape": "Taśma bibułowa", + "crispbread": "Pieczywo chrupkie", + "cucumber": "Ogórek", + "cumin": "Kumin", + "curry_paste": "Pasta curry", + "curry_powder": "Curry", + "curry_sauce": "Sos curry", + "dates": "Daktyle", + "dental_floss": "Nić dentystyczna", + "deodorant": "Dezodorant", + "detergent": "Środek czyszczący", + "dill": "Koper", + "dishwasher_salt": "Sól do zmywarki", + "dishwasher_tabs": "Tabletki do zmywarki", + "disinfection_spray": "Spray do dezynfekcji", + "dried_tomatoes": "Suszone pomidory", + "edamame": "Edamame", + "eggplant": "Psianka podłużna", + "eggs": "Jajka", + "falafel": "Falafel", + "falafel_powder": "Falafel w proszku", + "fanta": "Fanta", + "feta": "Ser feta", + "ffp2": "Maska FFP2", + "fish_sticks": "Paluszki rybne", + "flour": "Mąka", + "flushing": "Zmywanie", + "fresh_chili_pepper": "Świeże papryczki chili", + "frozen_berries": "Mrożone owoce leśne", + "frozen_fruit": "Mrożone owoce", + "frozen_pizza": "Mrożona pizza", + "frozen_spinach": "Mrożony szpinak", + "garam_masala": "Garam Masala", + "garbage_bag": "Worki na śmieci", + "garlic": "Czosnek", + "garlic_dip": "Sos czosnkowy", + "garlic_granules": "Granulat czosnkowy", + "gherkins": "Korniszony", + "ginger": "Imbir", + "glass_noodles": "Szklany makaron", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Winogrona", + "greek_yogurt": "Jogurt grecki", + "green_asparagus": "Zielone szparagi", + "green_chili": "Zielone chili", + "green_pesto": "Zielone pesto", + "hair_gel": "Żel do włosów", + "hair_wax": "Wosk do włosów", + "handkerchief_box": "Pudełko na chusteczki do nosa", + "handkerchiefs": "Chusteczki do nosa", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Orzechy laskowe", + "head_of_lettuce": "Główka sałaty", + "herb_baguettes": "Bagietki ziołowe", + "herb_cream_cheese": "Ziołowy serek śmietankowy", + "honey": "Miód", + "honey_wafers": "Wafle miodowe", + "hot_dog_bun": "Bułka do hot doga", + "ice_cream": "Lody", + "ice_cube": "Kostka lodu", + "iceberg_lettuce": "Sałata lodowa", + "iced_tea": "Mrożona herbata", + "instant_soups": "Zupy błyskawiczne", + "jam": "Dżem", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Fasola kidney", + "kitchen_roll": "Rolka kuchenna", + "kitchen_towels": "Ręczniki kuchenne", + "kohlrabi": "Kalarepa", + "lasagna": "Lasagna", + "lasagna_noodles": "Makaron lasagne", + "lasagna_plates": "Talerze do lasagne", + "leaf_spinach": "Szpinak liściasty", + "leek": "Por", + "lemon": "Cytryna", + "lemon_juice": "Sok z cytryny", + "lemonade": "Lemoniada", + "lemongrass": "Trawa cytrynowa", + "lentils": "Soczewica", + "lentils_red": "Czerwona soczewica", + "lettuce": "Sałata", + "lillet": "Lillet", + "lime": "Limonka", + "linguine": "Linguine", + "low-fat_curd_cheese": "Twaróg o niskiej zawartości tłuszczu", + "magnesium": "Magnez", + "mango": "Mango", + "margarine": "Margaryna", + "marjoram": "Majeranek", + "marshmallows": "Marshmallows", + "mask": "Maska", + "mayonnaise": "Majonez", + "meat_substitute_product": "Produkt zastępujący mięso", + "microfiber_cloth": "Ściereczka z mikrofibry", + "milk": "Mleko", + "mint": "Mięta", + "mint_candy": "Cukierki miętowe", + "mixed_vegetables": "Mieszane warzywa", + "mochis": "Mochis", + "mountain_cheese": "Ser górski", + "mouth_wash": "Płyn do płukania ust", + "mozzarella": "Mozzarella", + "muesli": "Musli", + "muesli_bar": "Baton musli", + "mulled_wine": "Grzane wino", + "mushrooms": "Grzyby", + "mustard": "Musztarda", + "neutral_oil": "Neutralny olej", + "nori_sheets": "Arkusze nori", + "nutmeg": "Gałka muszkatołowa", + "oat_milk": "Napój owsiany", + "oatmeal": "Płatki owsiane", + "oatmeal_cookies": "Ciasteczka owsiane", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "olive_oil": "Oliwa z oliwek", + "olives": "Oliwki", + "onion": "Cebula", + "orange_juice": "Sok pomarańczowy", + "oranges": "Pomarańcze", + "oregano": "Oregano", + "organic_lemon": "Organiczna cytryna", + "organic_waste_bags": "Worki na odpady organiczne", + "pak_choi": "Pak Choi", + "paprika": "Papryka", + "pardina_lentils_dried": "Suszona soczewica Pardina", + "parmesan": "Parmezan", + "parsley": "Pietruszka", + "pasta": "Makaron", + "peach": "Brzoskwinia", + "peanut_butter": "Masło orzechowe", + "peanut_flips": "Peanut Flips", + "peanut_oil": "Olej arachidowy", + "peanuts": "Orzeszki ziemne", + "pears": "Gruszki", + "peas": "Groszek", + "penne": "Penne", + "pepper": "Pieprz", + "pepper_mill": "Młynek do pieprzu", + "peppers": "Papryka", + "persian_rice": "Ryż perski", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Orzeszki piniowe", + "pineapple": "Ananas", + "pita_bag": "Torba Pita", + "pizza": "Pizza", + "pizza_dough": "Ciasto na pizzę", + "plant_magarine": "Roślina Magarine", + "plant_oil": "Olej roślinny", + "plaster": "Tynk", + "porcini_mushrooms": "Grzyby Porcini", + "potato_dumpling_dough": "Ciasto na pyzy ziemniaczane", + "potato_wedges": "Kliny ziemniaczane", + "potatoes": "Ziemniaki", + "potting_soil": "Ziemia doniczkowa", + "powder": "Proszek", + "powdered_sugar": "Cukier puder", + "processed_cheese": "Ser przetworzony", + "prosecco": "Prosecco", + "puff_pastry": "Ciasto francuskie", + "pumpkin": "Dynia", + "pumpkin_seeds": "Pestki dyni", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Rzodkiewka", + "ramen": "Ramen", + "rapeseed_oil": "Olej rzepakowy", + "raspberries": "Maliny", + "raspberry_syrup": "Syrop malinowy", + "red_bull": "Red Bull", + "red_chili": "Czerwone chili", + "red_lentils": "Czerwona soczewica", + "red_onions": "Czerwona cebula", + "red_pesto": "Czerwone pesto", + "red_wine": "Czerwone wino", + "red_wine_vinegar": "Ocet z czerwonego wina", + "rhubarb": "Rabarbar", + "ribbon_noodles": "Makaron wstążkowy", + "rice": "Ryż", + "rice_cakes": "Ciastka ryżowe", + "rice_ribbon_noodles": "Makaron ryżowy wstążkowy", + "rice_vinegar": "Ocet ryżowy", + "ricotta": "Ser ricotta", + "rinse_tabs": "Tabletki do płukania", + "rinsing_agent": "Preparat do płukania", + "risotto_rice": "Ryż do risotto", + "rocket": "Rakieta", + "roll": "Bułka", + "rosemary": "Rozmaryn", + "saffron_threads": "Szafron", + "sage": "Szałwia", + "saitan_powder": "Proszek Saitan", + "salad_mix": "Mix sałatkowy", + "salad_seeds_mix": "Mix nasion sałatkowych", + "salt": "Sól", + "salt_mill": "Młynek do soli", + "sambal_oelek": "Sambal", + "sauce": "Sos", + "sausage": "Kiełbasa", + "sausages": "Kiełbasy", + "savoy_cabbage": "Kapusta włoska", + "scallion": "Szalotka", + "scattered_cheese": "Starty ser", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Budyń semolinowy", + "sesame": "Sezam", + "sesame_oil": "Olej sezamowy", + "shallot": "Szalotka", + "shampoo": "Szampon", + "shawarma_spice": "Przyprawa Shawarma", + "shiitake_mushroom": "Shiitake", + "shoe_insoles": "Wkładki do butów", + "shower_gel": "Żel pod prysznic", + "shredded_cheese": "Tarty ser", + "sieved_tomatoes": "Sitkowane pomidory", + "sliced_cheese": "Ser w plastrach", + "smoked_paprika": "Papryka wędzona", + "smoked_tofu": "Tofu wędzone", + "snacks": "Przekąski", + "soap": "Mydło", + "soft_drinks": "Napoje gazowane", + "softdrinks": "Napoje gazowane", + "sour_cream": "Kwaśna śmietana", + "sour_cucumbers": "Kwaśne ogórki", + "soy_hack": "Hack na soję", + "soy_sauce": "Sos sojowy", + "soy_shred": "Rozdrobniona soja", + "spaetzle": "Szpecle", + "spaghetti": "Spaghetti", + "sparkling_water": "Woda gazowana", + "spelt": "Orkisz", + "spinach": "Szpinak", + "sponge_cloth": "Ściereczka gąbczasta", + "sponge_wipes": "Gąbki do mycia", + "sponges": "Gąbki", + "spreading_cream": "Śmietana do smarowania", + "spring_onions": "Cebulki wiosenne", + "sprite": "Sprite", + "sprouts": "Kiełki", + "sriracha": "Sriracha", + "strained_tomatoes": "Odcedzone pomidory", + "sugar": "Cukier", + "summer_roll_paper": "Papier w rolce na lato", + "sunflower_seeds": "Nasiona słonecznika", + "sushi_rice": "Ryż do sushi", + "swabian_ravioli": "Maultaschen", + "sweet_potato": "Batat", + "sweet_potatoes": "Bataty", + "table_salt": "Sól stołowa", + "tagliatelle": "Makaron tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandarynki", + "tape": "Taśma klejąca", + "tea": "Herbata", + "teriyaki_sauce": "Sos teriyaki", + "thyme": "Tymianek", + "toast": "Tosty", + "tofu": "Tofu", + "toilet_paper": "Papier toaletowy", + "tomato_juice": "Sok pomidorowy", + "tomato_paste": "Pasta z pomidorów", + "tomato_sauce": "Sos pomidorowy", + "tomatoes": "Pomidory", + "tonic_water": "Woda tonizująca", + "toothpaste": "Pasta do zębów", + "tortellini": "Tortellini", + "tortilla_chips": "Chipsy Tortilla", + "tuna": "Tuńczyk", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Makaron Udon", + "uht_milk": "Mleko UHT", + "vanilla_sugar": "Cukier waniliowy", + "vegetable_bouillon_cube": "Kostka bulionu warzywnego", + "vegetable_broth": "Bulion warzywny", + "vegetable_oil": "Olej roślinny", + "vegetable_onion": "Cebula warzywna", + "vegetables": "Warzywa", + "vegetarian_cold_cuts": "wędliny wegetariańskie", + "vinegar": "Ocet", + "vodka": "Wódka", + "washing_powder": "Proszek do prania", + "water": "Woda", + "water_ice": "Lód wodny", + "watermelon": "Arbuz", + "wc_cleaner": "Środek do czyszczenia WC", + "whipped_cream": "Bita śmietana", + "white_wine": "Białe wino", + "white_wine_vinegar": "Ocet z białego wina", + "whole_canned_tomatoes": "Całe pomidory w puszce", + "wild_berries": "Dzikie jagody", + "wrapping_paper": "Papier pakowy", + "wraps": "Owijki", + "yeast": "Drożdże", + "yoghurt": "Jogurt", + "yogurt": "Jogurt", + "yum_yum": "Mniam mniam", + "zewa": "Zewa", + "zinc_cream": "Krem cynkowy", + "zucchini": "Cukinia" + } +} From 2b314bfc1a606760a2f8ba9bb9bc3612c0b6b4c9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 24 May 2023 01:05:54 +0200 Subject: [PATCH 320/496] feat: Add polish --- backend/app/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/config.py b/backend/app/config.py index cf6d4187..c4259596 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -45,6 +45,7 @@ 'fr': 'Français', 'id': 'Bahasa Indonesia', 'nb_NO': 'Bokmål', + 'pl': 'Polski', 'pt': 'Português', 'pt_BR': 'Português Brasileiro', 'ru': 'русский язык', From b52f01cb84578468cdb10c552f79c56cb6243fcf Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 24 May 2023 01:07:29 +0200 Subject: [PATCH 321/496] Prepare release 64 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index c4259596..eb4c2813 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 63 +BACKEND_VERSION = 64 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 8d71bbdded0c3b57419bf38e8018452d5f6e096e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 27 May 2023 17:54:13 +0200 Subject: [PATCH 322/496] feat: user email --- backend/app/config.py | 1 + .../app/controller/auth/auth_controller.py | 13 ++++--- backend/app/controller/auth/schemas.py | 7 ++++ backend/app/controller/health_controller.py | 4 ++- .../onboarding/onboarding_controller.py | 3 +- backend/app/controller/onboarding/schemas.py | 5 +-- backend/app/controller/user/schemas.py | 15 ++++++-- .../app/controller/user/user_controller.py | 20 ++++++++--- backend/app/models/user.py | 29 ++++++++++++---- backend/migrations/versions/ed32086bf606_.py | 34 +++++++++++++++++++ 10 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 backend/migrations/versions/ed32086bf606_.py diff --git a/backend/app/config.py b/backend/app/config.py index eb4c2813..4cc7e878 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,6 +24,7 @@ PRIVACY_POLICY_URL = os.getenv('PRIVACY_POLICY_URL') OPEN_REGISTRATION = os.getenv('OPEN_REGISTRATION', "False").lower() == "true" +EMAIL_MANDATORY = os.getenv('EMAIL_MANDATORY', "False").lower() == "true" DB_URL = URL.create( os.getenv('DB_DRIVER', "sqlite"), diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 6b234b75..14462755 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -67,14 +67,17 @@ def login(args): @auth.route('signup', methods=['POST']) @validate_args(Signup) def signup(args): - username = args['username'].strip().lower() + username = args['username'].strip().lower().replace(" ", "") user = User.find_by_username(username) if user: raise InvalidUsage() - - user = User(username=username, name=args['name'].strip()) - user.set_password(args['password']) - user.save() + + user = User.create( + username=username, + name=args['name'], + password=args['password'], + email=args['email'] if "email" in args else None, + ) device = "Unkown" if "device" in args: diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py index 8e9f1050..d2c2a7fa 100644 --- a/backend/app/controller/auth/schemas.py +++ b/backend/app/controller/auth/schemas.py @@ -1,5 +1,7 @@ from marshmallow import fields, Schema +from app.config import EMAIL_MANDATORY + class Login(Schema): username = fields.String( @@ -22,6 +24,11 @@ class Signup(Schema): 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() diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 7b49e198..a28d5f1b 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -1,5 +1,5 @@ from flask import jsonify, Blueprint -from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION, PRIVACY_POLICY_URL, OPEN_REGISTRATION +from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION, PRIVACY_POLICY_URL, OPEN_REGISTRATION, EMAIL_MANDATORY from app.models import Settings from app.config import SUPPORTED_LANGUAGES @@ -17,6 +17,8 @@ def get_health(): info['privacy_policy'] = PRIVACY_POLICY_URL if OPEN_REGISTRATION: info['open_registration'] = True + if EMAIL_MANDATORY: + info['email_mandatory'] = True return jsonify(info) @health.route('/supported-languages', methods=['GET']) diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py index e9f56cee..42089592 100644 --- a/backend/app/controller/onboarding/onboarding_controller.py +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -18,8 +18,7 @@ def onboard(args): if User.count() > 0: return jsonify({'msg': "Onboarding not allowed"}), 403 - username = args['username'].lower() - user = User.create(username, args['password'], args['name'], admin=True) + user = User.create(args['username'], args['password'], args['name'], admin=True) device = "Unkown" if "device" in args: diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py index 45e3dc7c..e62f27a6 100644 --- a/backend/app/controller/onboarding/schemas.py +++ b/backend/app/controller/onboarding/schemas.py @@ -9,11 +9,12 @@ class OnboardSchema(Schema): ) username = fields.String( required=True, - validate=lambda a: a and not a.isspace() + 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() + validate=lambda a: a and not a.isspace(), + load_only=True, ) device = fields.String( required=False, diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py index 415bdced..b90cb029 100644 --- a/backend/app/controller/user/schemas.py +++ b/backend/app/controller/user/schemas.py @@ -1,5 +1,7 @@ from marshmallow import fields, Schema +from app.config import EMAIL_MANDATORY + class CreateUser(Schema): name = fields.String( @@ -8,7 +10,12 @@ class CreateUser(Schema): ) username = fields.String( required=True, - validate=lambda a: a and not a.isspace(), + 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( @@ -24,7 +31,11 @@ class UpdateUser(Schema): ) photo = fields.String() username = fields.String( - validate=lambda a: a and not a.isspace(), + 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( diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 7a5e22d6..55d62e3c 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -13,8 +13,9 @@ @user.route('/all', methods=['GET']) @jwt_required() +@server_admin_required() def getAllUsers(): - return jsonify([e.obj_to_dict() for e in User.all_by_name()]) + return jsonify([e.obj_to_dict(include_email=True) for e in User.all_by_name()]) @user.route('', methods=['GET']) @@ -30,7 +31,7 @@ def getUserById(id): user = User.find_by_id(id) if not user: raise NotFoundRequest() - return jsonify(user.obj_to_dict()) + return jsonify(user.obj_to_dict(include_email=True)) @user.route('', methods=['DELETE']) @@ -63,9 +64,11 @@ def updateUser(args): if not user: raise NotFoundRequest() if 'name' in args: - user.name = args['name'] + user.name = args['name'].strip() if 'password' in args: user.set_password(args['password']) + if 'email' in args: + user.email = args['email'].strip() if 'photo' in args and user.photo != args['photo']: user.photo = file_has_access_or_download(args['photo'], user.photo) user.save() @@ -81,9 +84,11 @@ def updateUserById(args, id): if not user: raise NotFoundRequest() if 'name' in args: - user.name = args['name'] + user.name = args['name'].strip() if 'password' in args: user.set_password(args['password']) + if 'email' in args: + user.email = args['email'].strip() if 'photo' in args and user.photo != args['photo']: user.photo = file_has_access_or_download(args['photo'], user.photo) if 'admin' in args: @@ -97,7 +102,12 @@ def updateUserById(args, id): @server_admin_required() @validate_args(CreateUser) def createUser(args): - User.create(args['username'].lower(), args['password'], args['name']) + User.create( + args['username'], + args['password'], + args['name'], + email=args['email'] if 'email' in args else None, + ) return jsonify({'msg': 'DONE'}) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ce299d39..dda1167b 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -12,8 +12,10 @@ class User(db.Model, DbModelMixin, TimestampMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) username = db.Column(db.String(256), unique=True, nullable=False) + email = db.Column(db.String(256), unique=True, nullable=True) password = db.Column(db.String(256), nullable=False) - photo = db.Column(db.String(), db.ForeignKey('file.filename', use_alter=True)) + photo = db.Column(db.String(), db.ForeignKey( + 'file.filename', use_alter=True)) admin = db.Column(db.Boolean(), default=False) tokens = db.relationship( @@ -26,7 +28,8 @@ class User(db.Model, DbModelMixin, TimestampMixin): 'Expense', back_populates='paid_by', cascade="all, delete-orphan") expenses_paid_for = db.relationship( 'ExpensePaidFor', back_populates='user', cascade="all, delete-orphan") - photo_file = db.relationship("File", back_populates='profile_picture', foreign_keys=[photo], uselist=False) + photo_file = db.relationship( + "File", back_populates='profile_picture', foreign_keys=[photo], uselist=False) def check_password(self, password: str) -> bool: return bcrypt.check_password_hash(self.password, password) @@ -34,14 +37,20 @@ def check_password(self, password: str) -> bool: def set_password(self, password: str): self.password = bcrypt.generate_password_hash(password).decode('utf-8') - def obj_to_dict(self, skip_columns: list[str] = None, include_columns: list[str] = None) -> dict: + def set_password(self, password: str): + self.password = bcrypt.generate_password_hash(password).decode('utf-8') + + def obj_to_dict(self, include_email: bool = False, skip_columns: list[str] = None, include_columns: list[str] = None) -> dict: if skip_columns: skip_columns = skip_columns + ['password'] else: skip_columns = ['password'] + if not include_email: + skip_columns += ['email'] if not current_user or not current_user.admin: - skip_columns = skip_columns + ['admin'] # Filter out admin status if current user is not an admin + # Filter out admin status if current user is not an admin + skip_columns = skip_columns + ['admin'] return super().obj_to_dict(skip_columns=skip_columns, include_columns=include_columns) @@ -49,6 +58,7 @@ def obj_to_full_dict(self) -> dict: from .token import Token res = self.obj_to_dict() res['admin'] = self.admin + res['email'] = self.email tokens = Token.query.filter(Token.user_id == self.id, Token.type != 'access', ~Token.created_tokens.any(Token.type == 'refresh')).all() res['tokens'] = [e.obj_to_dict( @@ -60,11 +70,16 @@ def find_by_username(cls, username: str) -> Self: return cls.query.filter(cls.username == username).first() @classmethod - def create(cls, username: str, password: str, name: str, admin=False) -> Self: + def find_by_email(cls, email: str) -> Self: + return cls.query.filter(cls.email == email).first() + + @classmethod + def create(cls, username: str, password: str, name: str, email: str | None = None, admin: bool = False) -> Self: return cls( - username=username, + username=username.lower().strip().replace(" ", ""), password=bcrypt.generate_password_hash(password).decode('utf-8'), - name=name, + name=name.strip(), + email=email.strip() if email else None, admin=admin ).save() diff --git a/backend/migrations/versions/ed32086bf606_.py b/backend/migrations/versions/ed32086bf606_.py new file mode 100644 index 00000000..89915825 --- /dev/null +++ b/backend/migrations/versions/ed32086bf606_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: ed32086bf606 +Revises: 8897db89e7af +Create Date: 2023-05-26 10:34:59.800754 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ed32086bf606' +down_revision = '8897db89e7af' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('email', sa.String(length=256), nullable=True)) + batch_op.create_unique_constraint(batch_op.f('uq_user_email'), ['email']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_user_email'), type_='unique') + batch_op.drop_column('email') + + # ### end Alembic commands ### From 3c6912166b8451bff80cd0b5fbd55aa554ec0c56 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 28 May 2023 15:02:30 +0200 Subject: [PATCH 323/496] fix: delete shopping list (TomBursch/kitchenowl-backend#35) --- backend/app/models/shoppinglist.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py index 3e3cf63d..b69072a2 100644 --- a/backend/app/models/shoppinglist.py +++ b/backend/app/models/shoppinglist.py @@ -21,8 +21,8 @@ class Shoppinglist(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin def getDefault(cls, household_id: int) -> Self: return cls.query.filter(cls.household_id == household_id).order_by(cls.id).first() - def isDefault(self, household_id: int) -> bool: - return self.id == self.getDefault(household_id).id + def isDefault(self) -> bool: + return self.id == self.getDefault(self.household_id).id class ShoppinglistItems(db.Model, DbModelMixin, TimestampMixin): From a580ce28ca74b754e2158eabb3bad63f6b2d0ec5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 28 May 2023 15:30:12 +0200 Subject: [PATCH 324/496] fix: update signup return messages --- backend/app/controller/auth/auth_controller.py | 6 +++++- backend/app/models/user.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 14462755..722f90e4 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -70,7 +70,11 @@ def signup(args): username = args['username'].strip().lower().replace(" ", "") user = User.find_by_username(username) if user: - raise InvalidUsage() + return "Request invalid: username", 400 + if "email" in args: + user = User.find_by_email(args['email']) + if user: + return "Request invalid: email", 400 user = User.create( username=username, diff --git a/backend/app/models/user.py b/backend/app/models/user.py index dda1167b..e43ca10f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -71,7 +71,7 @@ def find_by_username(cls, username: str) -> Self: @classmethod def find_by_email(cls, email: str) -> Self: - return cls.query.filter(cls.email == email).first() + return cls.query.filter(cls.email == email.strip()).first() @classmethod def create(cls, username: str, password: str, name: str, email: str | None = None, admin: bool = False) -> Self: From 9d501ac8468ec75e722b0cc80649ad30ce0c80c3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 30 May 2023 19:20:13 +0200 Subject: [PATCH 325/496] Prepare release 65 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 4cc7e878..18bdaa3c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 64 +BACKEND_VERSION = 65 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From d945778137b0af0034a0b2a716eed124ba54a1ff Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 31 May 2023 14:29:04 +0200 Subject: [PATCH 326/496] fix: migration of c058421705ec (TomBursch/kitchenowl-backend#37) --- backend/migrations/versions/c058421705ec_.py | 28 +++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/backend/migrations/versions/c058421705ec_.py b/backend/migrations/versions/c058421705ec_.py index d158bade..d015ed71 100644 --- a/backend/migrations/versions/c058421705ec_.py +++ b/backend/migrations/versions/c058421705ec_.py @@ -8,11 +8,11 @@ from datetime import datetime from alembic import op import sqlalchemy as sa -from sqlalchemy import orm +from sqlalchemy import orm, inspect from os import listdir from os.path import isfile, join -from app.config import UPLOAD_FOLDER +from app.config import UPLOAD_FOLDER, db DeclarativeBase = orm.declarative_base() @@ -47,26 +47,30 @@ class User(DeclarativeBase): def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('file', - sa.Column('filename', sa.String(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('created_by', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['created_by'], ['user.id'], name=op.f('fk_file_created_by_user')), - sa.PrimaryKeyConstraint('filename', name=op.f('pk_file')), - ) + inspector = inspect(db.engine) + if not inspector.has_table("file"): + op.create_table('file', + sa.Column('filename', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], name=op.f('fk_file_created_by_user')), + sa.PrimaryKeyConstraint('filename', name=op.f('pk_file')), + ) # ### end Alembic commands ### bind = op.get_bind() session = orm.Session(bind=bind) - filesInUploadFolder = [f for f in listdir(UPLOAD_FOLDER) if isfile(join(UPLOAD_FOLDER, f))] try: + filesInUploadFolder = [f for f in listdir(UPLOAD_FOLDER) if isfile(join(UPLOAD_FOLDER, f))] files = [File(filename=f) for f in filesInUploadFolder] session.bulk_save_objects(files) session.commit() - except Exception as e: + except FileNotFoundError as e: + session.rollback() + except BaseException as e: session.rollback() raise e From 02d8360d381278c8932dd6291f3477a88a2dc43a Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 31 May 2023 14:32:09 +0200 Subject: [PATCH 327/496] Prepare release 66 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 18bdaa3c..b52e3da0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 65 +BACKEND_VERSION = 66 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 58228884fabe8f109ba81320d03e06cfdc058dec Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 31 May 2023 14:35:50 +0200 Subject: [PATCH 328/496] fix: reorder entrypoint commands --- backend/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 6370c7db..6bfc197f 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/sh -flask db upgrade mkdir -p $STORAGE_PATH/upload +flask db upgrade #python upgrade_default_items.py uwsgi "$@" \ No newline at end of file From a16ba7dbdec0f9359a9d12155c410573b8dcb97f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 5 Jun 2023 02:31:17 +0200 Subject: [PATCH 329/496] fix: delete user and file access --- backend/app/models/file.py | 23 ++++++++++++++--- backend/app/models/user.py | 10 ++++++++ backend/app/service/delete_unused.py | 18 +++++++++++++ .../service/file_has_access_or_download.py | 2 ++ backend/manage.py | 25 ++++++++++++++++--- 5 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 backend/app/service/delete_unused.py diff --git a/backend/app/models/file.py b/backend/app/models/file.py index 01204f76..584cf028 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -1,22 +1,38 @@ from __future__ import annotations from typing import Self from app import db +from app.config import UPLOAD_FOLDER from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin from app.models.user import User +import os class File(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'file' filename = db.Column(db.String(), primary_key=True) - created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + created_by = db.Column(db.Integer, db.ForeignKey( + 'user.id'), nullable=True) - created_by_user = db.relationship("User", foreign_keys=[created_by], uselist=False) + created_by_user = db.relationship( + "User", foreign_keys=[created_by], uselist=False) household = db.relationship("Household", uselist=False) recipe = db.relationship("Recipe", uselist=False) expense = db.relationship("Expense", uselist=False) - profile_picture = db.relationship("User", foreign_keys=[User.photo], uselist=False) + profile_picture = db.relationship( + "User", foreign_keys=[User.photo], uselist=False) + + def delete(self): + """ + Delete this instance of model from db + """ + os.remove(os.path.join(UPLOAD_FOLDER, self.filename)) + db.session.delete(self) + db.session.commit() + + def isUnused(self) -> bool: + return not self.household and not self.recipe and not self.expense and not self.profile_picture @classmethod def find(cls, filename: str) -> Self: @@ -24,4 +40,3 @@ def find(cls, filename: str) -> Self: Find the row with specified id """ return cls.query.filter(cls.filename == filename).first() - diff --git a/backend/app/models/user.py b/backend/app/models/user.py index e43ca10f..60658a26 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -65,6 +65,16 @@ def obj_to_full_dict(self) -> dict: skip_columns=['user_id']) for e in tokens] return res + def delete(self): + """ + Delete this instance of model from db + """ + from app.models import File + for f in File.query.filter(File.created_by == self.id).all(): + f.created_by = None + f.save() + super().delete() + @classmethod def find_by_username(cls, username: str) -> Self: return cls.query.filter(cls.username == username).first() diff --git a/backend/app/service/delete_unused.py b/backend/app/service/delete_unused.py new file mode 100644 index 00000000..44613e76 --- /dev/null +++ b/backend/app/service/delete_unused.py @@ -0,0 +1,18 @@ +from app.models import Household, File +from app import app + + +def deleteUnusedFiles() -> int: + filesToDelete = [f for f in File.query.all() if f.isUnused()] + for f in filesToDelete: + f.delete() + app.logger.info(f"Deleted {len(filesToDelete)} unused files") + return len(filesToDelete) + + +def deleteEmptyHouseholds() -> int: + householdsToDelete = [h for h in Household.all() if len(h.member) == 0] + for h in householdsToDelete: + h.delete() + app.logger.info(f"Deleted {len(householdsToDelete)} empty households") + return len(householdsToDelete) diff --git a/backend/app/service/file_has_access_or_download.py b/backend/app/service/file_has_access_or_download.py index e1ccef3d..63904081 100644 --- a/backend/app/service/file_has_access_or_download.py +++ b/backend/app/service/file_has_access_or_download.py @@ -24,6 +24,8 @@ def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str: o.write(resp.content) return filename elif newPhoto is not None: + if not newPhoto: + return None f = File.find(newPhoto) if f and (f.created_by == current_user.id or current_user.admin): return f.filename diff --git a/backend/manage.py b/backend/manage.py index 779146ce..c69784c8 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -4,6 +4,7 @@ from app.config import UPLOAD_FOLDER from app.jobs import jobs from app.models import User, File, Household, HouseholdMember +from app.service.delete_unused import deleteEmptyHouseholds, deleteUnusedFiles def importFiles(): try: @@ -22,11 +23,14 @@ def manageHouseholds(): print(""" What next? 1. List all households + 2. Delete empty (q) Go back""") selection = input("Your selection (q):") if selection == "1": for h in Household.all(): print(f"Id {h.id}: {h.name} ({len(h.member)} members)") + if selection == "2": + print(f"Deleted {deleteEmptyHouseholds()} unused households") else: return @@ -41,7 +45,7 @@ def manageUsers(): selection = input("Your selection (q):") if selection == "1": for u in User.all(): - print(f"@{u.username}: {u.name} (server admin: {u.admin})") + print(f"@{u.username} ({u.email}): {u.name} (server admin: {u.admin})") elif selection == "2": username = input("Enter the username:") user = User.find_by_username(username) @@ -96,6 +100,21 @@ def updateUser(user: User): else: return +def manageFiles(): + while True: + print(""" +What next? + 1. Import files + 2. Delete unused files + (q) Go back""") + selection = input("Your selection (q):") + if selection == "1": + importFiles() + elif selection == "2": + print(f"Deleted {deleteUnusedFiles()} unused files") + else: + return + # docker exec -it [backend container name] python manage.py if __name__ == "__main__": while True: @@ -103,7 +122,7 @@ def updateUser(user: User): Manage KitchenOwl\n---\nWhat do you want to do? 1. Manage users 2. Manage households -3. Import files +3. Manage images/files 4. Run all jobs (q) Exit""") selection = input("Your selection (q):") @@ -115,7 +134,7 @@ def updateUser(user: User): manageHouseholds() elif selection == "3": with app.app_context(): - importFiles() + manageFiles() elif selection == "4": print("Starting jobs (might take a while)...") jobs.daily() From 8e6f7c0ac179bea42a537dcfd0d0d917f6be9e84 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 5 Jun 2023 03:47:54 +0200 Subject: [PATCH 330/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#36) * Added translation using Weblate (Dutch) * Translated using Weblate (Dutch) Currently translated at 14.0% (59 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nl/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ * Translated using Weblate (Dutch) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nl/ --------- Co-authored-by: Rick van Oosterhout --- backend/templates/l10n/nl.json | 425 +++++++++++++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 backend/templates/l10n/nl.json diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json new file mode 100644 index 00000000..51b1d4cc --- /dev/null +++ b/backend/templates/l10n/nl.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "Brood artikelen", + "canned": "Ingeblikt eten", + "dairy": "Zuivel", + "drinks": "Drinken", + "freezer": "Vriezer", + "fruits_vegetables": "Fruit en Groenten", + "grain": "Graanproducten", + "hygiene": "Hygiëne", + "refrigerated": "Gekoeld", + "snacks": "Snacks" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Appel", + "apple_pulp": "Appelpulp", + "applesauce": "Appelmoes", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Aziatische eiernoedels", + "asparagus": "Asperges", + "aspirin": "Aspirine", + "avocado": "Avocado", + "baby_spinach": "Babyspinazie", + "bacon": "Spek", + "baguette": "Stokbrood", + "bakefish": "Gebakken vis", + "baking_cocoa": "Bak cacao", + "baking_mix": "Bak mix", + "baking_paper": "Bakpapier", + "baking_powder": "Bakpoeder", + "baking_soda": "Bak soda", + "baking_yeast": "Gist", + "balsamic_vinegar": "Balsamico azijn", + "bananas": "Bananen", + "basil": "Basilicum", + "basmati_rice": "Basmati rijst", + "bathroom_cleaner": "Badkamer reiniger", + "batteries": "Batterijen", + "bay_leaf": "Laurierblad", + "beans": "Bonen", + "beer": "Bier", + "beet": "Biet", + "beetroot": "Biet", + "birthday_card": "Verjaardagskaart", + "black_beans": "Zwarte bonen", + "bockwurst": "Bockworst", + "bodywash": "Zeep", + "bread": "Brood", + "breadcrumbs": "Paneermeel", + "broccoli": "Broccoli", + "brown_sugar": "Bruine suiker", + "brussels_sprouts": "Spruiten", + "buffalo_mozzarella": "Buffel mozzarella", + "buko": "Buko", + "buns": "Broodjes", + "burger_buns": "Hamburger broodjes", + "burger_patties": "Hamburgers", + "burger_sauces": "Burger sauzen", + "butter": "Boter", + "butter_cookies": "Boterkoekjes", + "button_cells": "Knoopbatterij", + "börek_cheese": "Börek kaas", + "cake": "Cake", + "cake_icing": "Taart glazuur", + "cane_sugar": "Rietsuiker", + "cannelloni": "Cannelloni", + "canola_oil": "Canola olie", + "cardamom": "Kardemom", + "carrots": "Wortelen", + "cashews": "Cashewnoten", + "cat_treats": "Kattensnoepjes", + "cauliflower": "Bloemkool", + "celeriac": "Knolselderij", + "celery": "Selderij", + "cereal_bar": "Graanreep", + "cheddar": "Cheddar", + "cheese": "Kaas", + "cherry_tomatoes": "Cherry tomaten", + "chickpeas": "Kikkererwten", + "chili_oil": "Chili olie", + "chips": "Chips", + "chives": "Bieslook", + "chocolate": "Chocolade", + "chocolate_chips": "Chocolade chips", + "chopped_tomatoes": "Gehakte tomaten", + "ciabatta": "Ciabatta", + "cider_vinegar": "Cider azijn", + "cilantro": "Cilantro", + "cinnamon": "Kaneel", + "cinnamon_stick": "Kaneelstokje", + "cocktail_sauce": "Cocktailsaus", + "cocktail_tomatoes": "Cocktail tomaten", + "coconut_flakes": "Kokosnootschilfers", + "coconut_milk": "Kokosmelk", + "coconut_oil": "Kokosolie", + "colorful_sprinkles": "Kleurrijke hagelslag", + "concealer": "Concealer", + "cookies": "Cookies", + "coriander": "Koriander", + "corn": "Maïs", + "cornflakes": "Cornflakes", + "cornstarch": "Maïszetmeel", + "cornys": "Cornys", + "cough_drops": "Hoestdruppels", + "couscous": "Couscous", + "covid_rapid_test": "COVID-sneltest", + "cow's_milk": "Koemelk", + "cream": "Crème", + "cream_cheese": "Roomkaas", + "creamed_spinach": "Spinazie", + "creme_fraiche": "Crème fraiche", + "crepe_tape": "Crepe tape", + "crispbread": "Knäckebröd", + "cucumber": "Komkommer", + "cumin": "Komijn", + "curry_paste": "Kerriepasta", + "curry_powder": "Kerriepoeder", + "curry_sauce": "Kerrie saus", + "dates": "Data", + "dental_floss": "Flosdraad", + "deodorant": "Deodorant", + "detergent": "Wasmiddel", + "dill": "Dille", + "dishwasher_salt": "Vaatwasser zout", + "dishwasher_tabs": "Vaatwasser tabs", + "disinfection_spray": "Ontsmettingsspray", + "dried_tomatoes": "Gedroogde tomaten", + "edamame": "Edamame", + "eggplant": "Aubergine", + "eggs": "Eieren", + "falafel": "Falafel", + "falafel_powder": "Falafel poeder", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Vissticks", + "flour": "Meel", + "flushing": "Spoelen", + "fresh_chili_pepper": "Verse chili peper", + "frozen_berries": "Bevroren bessen", + "frozen_fruit": "Bevroren fruit", + "frozen_pizza": "Bevroren pizza", + "frozen_spinach": "Bevroren spinazie", + "garam_masala": "Garam Masala", + "garbage_bag": "Vuilniszak", + "garlic": "Knoflook", + "garlic_dip": "Knoflook dip", + "garlic_granules": "Knoflookgranulaat", + "gherkins": "Augurken", + "ginger": "Ginger", + "glass_noodles": "Glazen noedels", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Druiven", + "greek_yogurt": "Griekse yoghurt", + "green_asparagus": "Groene asperges", + "green_chili": "Groene chili", + "green_pesto": "Groene pesto", + "hair_gel": "Haargel", + "hair_wax": "Haarwas", + "handkerchief_box": "Zakdoek doos", + "handkerchiefs": "Zakdoeken", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hazelnoten", + "head_of_lettuce": "Krop sla", + "herb_baguettes": "Baguettes met kruiden", + "herb_cream_cheese": "Kruidenroomkaas", + "honey": "Honing", + "honey_wafers": "Honingwafels", + "hot_dog_bun": "Hot dog broodje", + "ice_cream": "IJs", + "ice_cube": "IJsblokje", + "iceberg_lettuce": "IJsbergsla", + "iced_tea": "Ijsthee", + "instant_soups": "Instant soepen", + "jam": "Jam", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Nierbonen", + "kitchen_roll": "Keukenrol", + "kitchen_towels": "Keukenhanddoeken", + "kohlrabi": "Koolrabi", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagna noedels", + "lasagna_plates": "Lasagne borden", + "leaf_spinach": "Bladspinazie", + "leek": "Prei", + "lemon": "Citroen", + "lemon_juice": "Citroensap", + "lemonade": "Limonade", + "lemongrass": "Citroengras", + "lentils": "Linzen", + "lentils_red": "Rode linzen", + "lettuce": "Sla", + "lillet": "Lillet", + "lime": "Kalk", + "linguine": "Linguine", + "low-fat_curd_cheese": "Magere kwark", + "magnesium": "Magnesium", + "mango": "Mango", + "margarine": "Margarine", + "marjoram": "Marjolein", + "marshmallows": "Marshmallows", + "mask": "Masker", + "mayonnaise": "Mayonaise", + "meat_substitute_product": "Vleesvervangend product", + "microfiber_cloth": "Microfiber doek", + "milk": "Melk", + "mint": "Munt", + "mint_candy": "Mint snoep", + "mixed_vegetables": "Gemengde groenten", + "mochis": "Mochis", + "mountain_cheese": "Bergkaas", + "mouth_wash": "Mondspoeling", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Mueslireep", + "mulled_wine": "Glühwein", + "mushrooms": "Champignons", + "mustard": "Mosterd", + "neutral_oil": "Neutrale olie", + "nori_sheets": "Nori vellen", + "nutmeg": "Nootmuskaat", + "oat_milk": "Haverdrank", + "oatmeal": "Havermout", + "oatmeal_cookies": "Havermoutkoekjes", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "olive_oil": "Olijfolie", + "olives": "Olijven", + "onion": "Ui", + "orange_juice": "Sinaasappelsap", + "oranges": "Sinaasappels", + "oregano": "Oregano", + "organic_lemon": "Biologische citroen", + "organic_waste_bags": "Zakken voor organisch afval", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Pardina linzen gedroogd", + "parmesan": "Parmezaan", + "parsley": "Peterselie", + "pasta": "Pasta", + "peach": "Perzik", + "peanut_butter": "Pindakaas", + "peanut_flips": "Peanut Flips", + "peanut_oil": "Pindaolie", + "peanuts": "Pinda's", + "pears": "Peren", + "peas": "Erwten", + "penne": "Penne", + "pepper": "Peper", + "pepper_mill": "Pepermolen", + "peppers": "Paprika's", + "persian_rice": "Perzische rijst", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pijnboompitten", + "pineapple": "Ananas", + "pita_bag": "Pita zak", + "pizza": "Pizza", + "pizza_dough": "Pizzadeeg", + "plant_magarine": "Plant Magarine", + "plant_oil": "Plantaardige olie", + "plaster": "Gips", + "porcini_mushrooms": "Porcini paddestoelen", + "potato_dumpling_dough": "Aardappel knoedel deeg", + "potato_wedges": "Aardappelpartjes", + "potatoes": "Aardappelen", + "potting_soil": "Potgrond", + "powder": "Poeder", + "powdered_sugar": "Poedersuiker", + "processed_cheese": "Verwerkte kaas", + "prosecco": "Prosecco", + "puff_pastry": "Bladerdeeg", + "pumpkin": "Pompoen", + "pumpkin_seeds": "Pompoenpitten", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Radijs", + "ramen": "Ramen", + "rapeseed_oil": "Koolzaadolie", + "raspberries": "Frambozen", + "raspberry_syrup": "Frambozensiroop", + "red_bull": "Red Bull", + "red_chili": "Rode chili", + "red_lentils": "Rode linzen", + "red_onions": "Rode uien", + "red_pesto": "Rode pesto", + "red_wine": "Rode wijn", + "red_wine_vinegar": "Rode wijnazijn", + "rhubarb": "Rabarber", + "ribbon_noodles": "Lintnoedels", + "rice": "Rijst", + "rice_cakes": "Rijstwafels", + "rice_ribbon_noodles": "Rijstlint noedels", + "rice_vinegar": "Rijstazijn", + "ricotta": "Ricotta", + "rinse_tabs": "Spoel tabs", + "rinsing_agent": "Spoelmiddel", + "risotto_rice": "Risotto rijst", + "rocket": "Raket", + "roll": "Rol", + "rosemary": "Rozemarijn", + "saffron_threads": "Saffraandraden", + "sage": "Salie", + "saitan_powder": "Saitan poeder", + "salad_mix": "Salade Mix", + "salad_seeds_mix": "Salade zaden mix", + "salt": "Zout", + "salt_mill": "Zoutmolen", + "sambal_oelek": "Sambal oelek", + "sauce": "Saus", + "sausage": "Worst", + "sausages": "Worstjes", + "savoy_cabbage": "Savooiekool", + "scallion": "Scallion", + "scattered_cheese": "Verspreide kaas", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Griesmeelpap", + "sesame": "Sesam", + "sesame_oil": "Sesamolie", + "shallot": "Sjalot", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarma kruiden", + "shiitake_mushroom": "Shiitake paddenstoel", + "shoe_insoles": "Schoenzolen", + "shower_gel": "Douchegel", + "shredded_cheese": "Versnipperde kaas", + "sieved_tomatoes": "Gezeefde tomaten", + "sliced_cheese": "Gesneden kaas", + "smoked_paprika": "Gerookte paprika", + "smoked_tofu": "Gerookte tofu", + "snacks": "Snacks", + "soap": "Zeep", + "soft_drinks": "Frisdranken", + "softdrinks": "Frisdranken", + "sour_cream": "Zure room", + "sour_cucumbers": "Zure komkommers", + "soy_hack": "Soja hack", + "soy_sauce": "Sojasaus", + "soy_shred": "Sojasnippers", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Sprankelend water", + "spelt": "Spelt", + "spinach": "Spinazie", + "sponge_cloth": "Sponsdoek", + "sponge_wipes": "Sponsdoekjes", + "sponges": "Sponzen", + "spreading_cream": "Smeercrème", + "spring_onions": "Lente-uitjes", + "sprite": "Sprite", + "sprouts": "Sprouts", + "sriracha": "Sriracha", + "strained_tomatoes": "Gezeefde tomaten", + "sugar": "Suiker", + "summer_roll_paper": "Zomer rol papier", + "sunflower_seeds": "Zonnebloempitten", + "sushi_rice": "Sushi rijst", + "swabian_ravioli": "Zwabische ravioli", + "sweet_potato": "Zoete aardappel", + "sweet_potatoes": "Zoete aardappelen", + "table_salt": "Tafelzout", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandarijnen", + "tape": "Tape", + "tea": "Thee", + "teriyaki_sauce": "Teriyaki saus", + "thyme": "Tijm", + "toast": "Toast", + "tofu": "Tofu", + "toilet_paper": "Toiletpapier", + "tomato_juice": "Tomatensap", + "tomato_paste": "Tomatenpasta", + "tomato_sauce": "Tomatensaus", + "tomatoes": "Tomaten", + "tonic_water": "Tonic water", + "toothpaste": "Tandpasta", + "tortellini": "Tortellini", + "tortilla_chips": "Tortilla Chips", + "tuna": "Tonijn", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon noedels", + "uht_milk": "UHT-melk", + "vanilla_sugar": "Vanille suiker", + "vegetable_bouillon_cube": "Groentebouillonblokje", + "vegetable_broth": "Groentenbouillon", + "vegetable_oil": "Plantaardige olie", + "vegetable_onion": "Plantaardige ui", + "vegetables": "Groenten", + "vegetarian_cold_cuts": "vegetarische vleeswaren", + "vinegar": "Azijn", + "vodka": "Wodka", + "washing_powder": "Waspoeder", + "water": "Water", + "water_ice": "Waterijs", + "watermelon": "Watermeloen", + "wc_cleaner": "WC-reiniger", + "whipped_cream": "Slagroom", + "white_wine": "Witte wijn", + "white_wine_vinegar": "Witte wijnazijn", + "whole_canned_tomatoes": "Hele tomaten in blik", + "wild_berries": "Wilde bessen", + "wrapping_paper": "Inpakpapier", + "wraps": "Wraps", + "yeast": "Gist", + "yoghurt": "Yoghurt", + "yogurt": "Yoghurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Zink crème", + "zucchini": "Courgette" + } +} From 701adb1ac701a0a3e23edc1fc98be8100662c3af Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 5 Jun 2023 03:49:33 +0200 Subject: [PATCH 331/496] feat: Add dutch --- backend/app/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/config.py b/backend/app/config.py index b52e3da0..46407de4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -46,6 +46,7 @@ 'fr': 'Français', 'id': 'Bahasa Indonesia', 'nb_NO': 'Bokmål', + 'nl': 'Nederlands', 'pl': 'Polski', 'pt': 'Português', 'pt_BR': 'Português Brasileiro', From a8f8f35a1f82f5e1fc2d87fa15b966e07672d270 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 5 Jun 2023 03:50:27 +0200 Subject: [PATCH 332/496] Prepare release 67 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 46407de4..41a75db2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 66 +BACKEND_VERSION = 67 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 808bd87fbe23e4cc2122cecd4d06f968fe07c886 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 7 Jun 2023 18:11:53 +0200 Subject: [PATCH 333/496] fix: recipe suggestion and expense category color --- backend/app/jobs/jobs.py | 13 ++++--- backend/app/jobs/recipe_suggestions.py | 12 +++---- backend/app/models/expense_category.py | 2 +- backend/migrations/versions/3d667fcc5581_.py | 38 ++++++++++++++++++++ 4 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/versions/3d667fcc5581_.py diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index 8353a253..d9e5ab0e 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -1,25 +1,28 @@ from app.jobs.recipe_suggestions import computeRecipeSuggestions from app import app, scheduler -from app.models import Token, Household, Shoppinglist +from app.models import Token, Household, Shoppinglist, Recipe from .item_ordering import findItemOrdering from .item_suggestions import findItemSuggestions from .cluster_shoppings import clusterShoppings -# # for debugging run: FLASK_DEBUG=True python manage.py +# # for debugging run: FLASK_DEBUG=True python manage.py @scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') def daily(): with app.app_context(): app.logger.info("--- daily analysis is starting ---") - # shopping tasks + # task for all households for household in Household.all(): + # shopping tasks shopping_instances = clusterShoppings(Shoppinglist.query.filter(Shoppinglist.household_id == household.id).first().id) if shopping_instances: findItemOrdering(shopping_instances) findItemSuggestions(shopping_instances) - # recipe planner tasks - computeRecipeSuggestions() + # recipe planner tasks + computeRecipeSuggestions(household.id) + Recipe.compute_suggestion_ranking(household.id) + app.logger.info("--- daily analysis is completed ---") diff --git a/backend/app/jobs/recipe_suggestions.py b/backend/app/jobs/recipe_suggestions.py index 30d41bf2..e34343b8 100644 --- a/backend/app/jobs/recipe_suggestions.py +++ b/backend/app/jobs/recipe_suggestions.py @@ -8,10 +8,10 @@ MEAL_THRESHOLD = 3 -def findMealInstancesFromHistory(): +def findMealInstancesFromHistory(household_id: int): return findMealInstances( - RecipeHistory.query.filter(RecipeHistory.status == Status.ADDED).all(), - RecipeHistory.query.filter(RecipeHistory.status == Status.DROPPED).all()) + RecipeHistory.query.filter(RecipeHistory.status == Status.ADDED, RecipeHistory.household_id == household_id).all(), + RecipeHistory.query.filter(RecipeHistory.status == Status.DROPPED, RecipeHistory.household_id == household_id).all()) def findMealInstances(added, dropped): @@ -56,8 +56,8 @@ def findMealInstances(added, dropped): return meals -def computeRecipeSuggestions(): - meal_instances = findMealInstancesFromHistory() +def computeRecipeSuggestions(household_id: int): + meal_instances = findMealInstancesFromHistory(household_id) # group meals by their id meal_hist = dict() for m in meal_instances: @@ -67,7 +67,7 @@ def computeRecipeSuggestions(): meal_hist[id].append(m["cooked_at"]) # 0) reset all suggestion scores - for r in Recipe.all(): + for r in Recipe.all_from_household(household_id): r.suggestion_score = 0 db.session.add(r) diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py index 4a8b23a0..55b39b7a 100644 --- a/backend/app/models/expense_category.py +++ b/backend/app/models/expense_category.py @@ -9,7 +9,7 @@ class ExpenseCategory(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMi id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) - color = db.Column(db.Integer) + color = db.Column(db.BigInteger) household_id = db.Column(db.Integer, db.ForeignKey( 'household.id'), nullable=False) diff --git a/backend/migrations/versions/3d667fcc5581_.py b/backend/migrations/versions/3d667fcc5581_.py new file mode 100644 index 00000000..9ae19a40 --- /dev/null +++ b/backend/migrations/versions/3d667fcc5581_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 3d667fcc5581 +Revises: ed32086bf606 +Create Date: 2023-06-06 14:49:25.133125 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3d667fcc5581' +down_revision = 'ed32086bf606' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.alter_column('color', + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense_category', schema=None) as batch_op: + batch_op.alter_column('color', + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=True) + + # ### end Alembic commands ### From 4ed1f63942e00565544b8e74679b1f78af9a0d74 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 7 Jun 2023 18:13:57 +0200 Subject: [PATCH 334/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#38) * Added translation using Weblate (Italian) * Translated using Weblate (Italian) Currently translated at 5.2% (22 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/it/ * Translated using Weblate (Italian) Currently translated at 26.4% (111 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/it/ --------- Co-authored-by: Paolo Basso --- backend/templates/l10n/it.json | 376 +++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 backend/templates/l10n/it.json diff --git a/backend/templates/l10n/it.json b/backend/templates/l10n/it.json new file mode 100644 index 00000000..b073456f --- /dev/null +++ b/backend/templates/l10n/it.json @@ -0,0 +1,376 @@ +{ + "categories": { + "bread": "🍞 Panetteria", + "canned": "🥫 Cibi in scatola", + "dairy": "🥛 Latticini", + "drinks": "🍹 Bevande", + "freezer": "❄️ Surgelati", + "fruits_vegetables": "🥬 Frutta e verdura", + "grain": "🥟 Prodotti a base di cereali", + "hygiene": "🚽 Igene", + "refrigerated": "💧 Refrigerati", + "snacks": "🥜 Spuntini" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Mela", + "apple_pulp": "Popla di mela", + "applesauce": "Salsa di mela", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Noodles asiatici all'uovo", + "asparagus": "Asparagi", + "aspirin": "Aspirina", + "avocado": "Avocado", + "baby_spinach": "Spinaci baby", + "bacon": "Pancetta", + "baguette": "Baguette", + "bakefish": "Pesce al forno", + "baking_cocoa": "Cacao da forno", + "baking_mix": "Preparato per dolci", + "baking_paper": "Carta da forno", + "baking_powder": "Lievito in polvere", + "baking_soda": "Bicarbonato di sodio", + "baking_yeast": "Lievito", + "balsamic_vinegar": "Aceto balsamico", + "bananas": "Banane", + "basil": "Basilico", + "basmati_rice": "Riso basmati", + "bathroom_cleaner": "Detergente bagno", + "batteries": "Batterie", + "bay_leaf": "Alloro", + "beans": "Fagioli", + "beer": "Birra", + "beet": "Barbabietola", + "beetroot": "Rape", + "birthday_card": "Biglietto da compleanno", + "black_beans": "Fagioli neri", + "bockwurst": "Wurstel", + "bodywash": "Detergente corpo", + "bread": "Pane", + "breadcrumbs": "Pangrattato", + "broccoli": "Broccoli", + "brown_sugar": "Zucchero di canna", + "brussels_sprouts": "Cavoletti di Bruxelles", + "buffalo_mozzarella": "Mozzarella di bufala", + "buko": "Buko", + "buns": "Pagnotte", + "burger_buns": "Pagnotte per hamburger", + "burger_patties": "Hamburger", + "burger_sauces": "Salse per hamburger", + "butter": "Burro", + "butter_cookies": "Biscotti al burro", + "button_cells": "Pile a bottone", + "börek_cheese": "Formaggio Börek", + "cake": "Torta", + "cake_icing": "Glassa per torta", + "cane_sugar": "Zucchero di canna", + "cannelloni": "Cannelloni", + "canola_oil": "Olio di semi di colza", + "cardamom": "Cardamomo", + "carrots": "Carote", + "cashews": "Anacardi", + "cat_treats": "Snacks per gatti", + "cauliflower": "Cavolfiore", + "celeriac": "Sedano rapa", + "celery": "Sedano", + "cereal_bar": "Barretta ai cereali", + "cheddar": "Formaggio cheddar", + "cheese": "Formaggio", + "cherry_tomatoes": "Pomodori ciglieggina", + "chickpeas": "Ceci", + "chili_oil": "Olio al peperoncino", + "chips": "Patatine", + "chives": "Erba cipollina", + "chocolate": "Cioccolata", + "chocolate_chips": "Gocce di cioccolato", + "chopped_tomatoes": "Polpa di pomodoro", + "ciabatta": "Ciabatta", + "cider_vinegar": "Aceto di mele", + "cilantro": "Coriandolo", + "cinnamon": "Cannella", + "cinnamon_stick": "Bastoncini di cannella", + "cocktail_sauce": "Salsa cocktail", + "cocktail_tomatoes": "Pomodorini cocktail", + "coconut_flakes": "Fiocchi di cocco", + "coconut_milk": "Latte di cocco", + "coconut_oil": "Olio di cocco", + "colorful_sprinkles": "Confettini colorati", + "concealer": "Cancellina", + "cookies": "Biscotti", + "coriander": "Coriandolo", + "corn": "Mais", + "cornflakes": "Cornflakes", + "cornstarch": "Amido di mais", + "cornys": "Cereali Cornys", + "cough_drops": "Sciroppo per la tosse", + "couscous": "Couscous", + "covid_rapid_test": "Test rapido COVID", + "cow's_milk": "Latte di mucca", + "cream": "Crema", + "cream_cheese": "Crema di formaggio", + "creamed_spinach": "Spinaci alla crema", + "creme_fraiche": "Crème fraîche", + "crepe_tape": "Scotch crespo", + "crispbread": "Pane croccante", + "hair_wax": "Cera per capelli", + "handkerchief_box": "Scatola per fazzoletti", + "handkerchiefs": "Fazzoletti", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Nocciole", + "head_of_lettuce": "Testa di lattuga", + "herb_baguettes": "Baguette alle erbe", + "herb_cream_cheese": "Crema di formaggio alle erbe", + "honey": "Il miele", + "honey_wafers": "Cialde di miele", + "hot_dog_bun": "Panino per hot dog", + "ice_cream": "Gelato", + "ice_cube": "Cubetto di ghiaccio", + "iceberg_lettuce": "Lattuga Iceberg", + "iced_tea": "Tè freddo", + "instant_soups": "Zuppe istantanee", + "jam": "Marmellata", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Fagioli renali", + "kitchen_roll": "Rotolo da cucina", + "kitchen_towels": "Asciugamani da cucina", + "kohlrabi": "Cavolo rapa", + "lasagna": "Lasagne", + "lasagna_noodles": "Tagliatelle per lasagne", + "lasagna_plates": "Piatti di lasagna", + "leaf_spinach": "Spinaci in foglia", + "leek": "Porro", + "lemon": "Limone", + "lemon_juice": "Succo di limone", + "lemonade": "Limonata", + "lemongrass": "Citronella", + "lentils": "Lenticchie", + "lentils_red": "Lenticchie rosse", + "lettuce": "Lattuga", + "lillet": "Lillet", + "lime": "Calce", + "linguine": "Linguine", + "low-fat_curd_cheese": "Formaggio cagliato a basso contenuto di grassi", + "magnesium": "Magnesio", + "mango": "Mango", + "margarine": "Margarina", + "marjoram": "Maggiorana", + "marshmallows": "Marshmallow", + "mask": "Maschera", + "mayonnaise": "Maionese", + "meat_substitute_product": "Prodotto sostitutivo della carne", + "microfiber_cloth": "Panno in microfibra", + "milk": "Latte", + "mint": "Menta", + "mint_candy": "Caramelle alla menta", + "mixed_vegetables": "Verdure miste", + "mochis": "Mochis", + "mountain_cheese": "Formaggio di montagna", + "mouth_wash": "Lavaggio della bocca", + "mozzarella": "Mozzarella", + "muesli": "Muesli", + "muesli_bar": "Barretta di muesli", + "mulled_wine": "Vin brulè", + "mushrooms": "Funghi", + "mustard": "Senape", + "neutral_oil": "Olio neutro", + "nori_sheets": "Fogli di nori", + "nutmeg": "Noce moscata", + "oat_milk": "Bevanda all'avena", + "oatmeal": "Farina d'avena", + "oatmeal_cookies": "Biscotti d'avena", + "oatsome": "Avena", + "obatzda": "Obatzda", + "olive_oil": "Olio d'oliva", + "olives": "Olive", + "onion": "Cipolla", + "orange_juice": "Succo d'arancia", + "oranges": "Arance", + "oregano": "Origano", + "organic_lemon": "Limone biologico", + "organic_waste_bags": "Sacchetti per rifiuti organici", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Lenticchie Pardina secche", + "parmesan": "Parmigiano", + "parsley": "Prezzemolo", + "pasta": "Pasta", + "peach": "Pesca", + "peanut_butter": "Burro di arachidi", + "peanut_flips": "Pinzette di arachidi", + "peanut_oil": "Olio di arachidi", + "peanuts": "Arachidi", + "pears": "Pere", + "peas": "Piselli", + "penne": "Penne", + "pepper": "Pepe", + "pepper_mill": "Macinapepe", + "peppers": "Peperoni", + "persian_rice": "Riso persiano", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinoli", + "pineapple": "Ananas", + "pita_bag": "Sacchetto di pita", + "pizza": "Pizza", + "pizza_dough": "Impasto per pizza", + "plant_magarine": "Impianto Magarine", + "plant_oil": "Olio vegetale", + "plaster": "Gesso", + "porcini_mushrooms": "Funghi porcini", + "potato_dumpling_dough": "Impasto per gnocchi di patate", + "potato_wedges": "Cunei di patate", + "potatoes": "Patate", + "potting_soil": "Terriccio", + "powder": "Polvere", + "powdered_sugar": "Zucchero a velo", + "processed_cheese": "Formaggio trasformato", + "prosecco": "Prosecco", + "puff_pastry": "Pasta sfoglia", + "pumpkin": "Zucca", + "pumpkin_seeds": "Semi di zucca", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Ravanello", + "ramen": "Ramen", + "rapeseed_oil": "Olio di colza", + "raspberries": "Lamponi", + "raspberry_syrup": "Sciroppo di lamponi", + "red_bull": "Red Bull", + "red_chili": "Peperoncino rosso", + "red_lentils": "Lenticchie rosse", + "red_onions": "Cipolle rosse", + "red_pesto": "Pesto rosso", + "red_wine": "Vino rosso", + "red_wine_vinegar": "Aceto di vino rosso", + "rhubarb": "Rabarbaro", + "ribbon_noodles": "Tagliatelle a nastro", + "rice": "Il riso", + "rice_cakes": "Torte di riso", + "rice_ribbon_noodles": "Tagliatelle a nastro di riso", + "rice_vinegar": "Aceto di riso", + "ricotta": "Ricotta", + "rinse_tabs": "Schede di risciacquo", + "rinsing_agent": "Agente di risciacquo", + "risotto_rice": "Riso per risotti", + "rocket": "Razzo", + "roll": "Rotolo", + "rosemary": "Rosmarino", + "saffron_threads": "Fili di zafferano", + "sage": "Salvia", + "saitan_powder": "Polvere di saitan", + "salad_mix": "Miscela di insalate", + "salad_seeds_mix": "Miscela di semi per insalata", + "salt": "Il sale", + "salt_mill": "Mulino per il sale", + "sambal_oelek": "Sambal oelek", + "sauce": "Salsa", + "sausage": "Salsiccia", + "sausages": "Salsicce", + "savoy_cabbage": "Cavolo verza", + "scallion": "Scalogna", + "scattered_cheese": "Formaggio sparso", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Porridge di semola", + "sesame": "Sesamo", + "sesame_oil": "Olio di sesamo", + "shallot": "Scalogno", + "shampoo": "Shampoo", + "shawarma_spice": "Spezie Shawarma", + "shiitake_mushroom": "Fungo shiitake", + "shoe_insoles": "Solette per scarpe", + "shower_gel": "Gel doccia", + "shredded_cheese": "Formaggio a pezzetti", + "sieved_tomatoes": "Pomodori setacciati", + "sliced_cheese": "Formaggio a fette", + "smoked_paprika": "Paprika affumicata", + "smoked_tofu": "Tofu affumicato", + "snacks": "Spuntini", + "soap": "Sapone", + "soft_drinks": "Bevande analcoliche", + "softdrinks": "Bevande analcoliche", + "sour_cream": "Panna acida", + "sour_cucumbers": "Cetrioli acidi", + "soy_hack": "Hackeraggio della soia", + "soy_sauce": "Salsa di soia", + "soy_shred": "Tritatutto di soia", + "spaetzle": "Spaetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Acqua frizzante", + "spelt": "Farro", + "spinach": "Spinaci", + "sponge_cloth": "Panno di spugna", + "sponge_wipes": "Salviette di spugna", + "sponges": "Spugne", + "spreading_cream": "Crema da spalmare", + "spring_onions": "Cipolline", + "sprite": "Sprite", + "sprouts": "Germogli", + "sriracha": "Sriracha", + "strained_tomatoes": "Pomodori filtrati", + "sugar": "Zucchero", + "summer_roll_paper": "Carta in rotoli per l'estate", + "sunflower_seeds": "Semi di girasole", + "sushi_rice": "Riso per sushi", + "swabian_ravioli": "Ravioli svevi", + "sweet_potato": "Patata dolce", + "sweet_potatoes": "Patate dolci", + "table_salt": "Sale da cucina", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandarini", + "tape": "Nastro", + "tea": "Tè", + "teriyaki_sauce": "Salsa teriyaki", + "thyme": "Timo", + "toast": "Brindisi", + "tofu": "Tofu", + "toilet_paper": "Carta igienica", + "tomato_juice": "Succo di pomodoro", + "tomato_paste": "Pasta di pomodoro", + "tomato_sauce": "Salsa di pomodoro", + "tomatoes": "Pomodori", + "tonic_water": "Acqua tonica", + "toothpaste": "Dentifricio", + "tortellini": "Tortellini", + "tortilla_chips": "Patatine fritte", + "tuna": "Tonno", + "turmeric": "Curcuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Tagliatelle Udon", + "uht_milk": "Latte UHT", + "vanilla_sugar": "Zucchero vanigliato", + "vegetable_bouillon_cube": "Dado per brodo vegetale", + "vegetable_broth": "Brodo vegetale", + "vegetable_oil": "Olio vegetale", + "vegetable_onion": "Cipolla vegetale", + "vegetables": "Verdure", + "vegetarian_cold_cuts": "salumi vegetariani", + "vinegar": "Aceto", + "vodka": "Vodka", + "washing_powder": "Detersivo in polvere", + "water": "Acqua", + "water_ice": "Ghiaccio d'acqua", + "watermelon": "Anguria", + "wc_cleaner": "Detergente per WC", + "whipped_cream": "Panna montata", + "white_wine": "Vino bianco", + "white_wine_vinegar": "Aceto di vino bianco", + "whole_canned_tomatoes": "Pomodori interi in scatola", + "wild_berries": "Frutti di bosco", + "wrapping_paper": "Carta da regalo", + "wraps": "Avvolgimenti", + "yeast": "Lievito", + "yoghurt": "Yogurt", + "yogurt": "Yogurt", + "yum_yum": "Gnam gnam", + "zewa": "Zewa", + "zinc_cream": "Crema allo zinco", + "zucchini": "Zucchine" + } +} From 3b7c57997e5c5f9256bfcdc7c388623b046bd5dc Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 7 Jun 2023 18:17:05 +0200 Subject: [PATCH 335/496] Translated using Weblate (Italian) (TomBursch/kitchenowl-backend#39) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/it/ --- backend/templates/l10n/it.json | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/backend/templates/l10n/it.json b/backend/templates/l10n/it.json index b073456f..1af14e0a 100644 --- a/backend/templates/l10n/it.json +++ b/backend/templates/l10n/it.json @@ -114,6 +114,55 @@ "creme_fraiche": "Crème fraîche", "crepe_tape": "Scotch crespo", "crispbread": "Pane croccante", + "cucumber": "Cetriolo", + "cumin": "Cumino", + "curry_paste": "Pasta di curry", + "curry_powder": "Curry in polvere", + "curry_sauce": "Salsa al curry", + "dates": "Date", + "dental_floss": "Filo interdentale", + "deodorant": "Deodorante", + "detergent": "Detergente", + "dill": "Aneto", + "dishwasher_salt": "Sale per lavastoviglie", + "dishwasher_tabs": "Schede per lavastoviglie", + "disinfection_spray": "Spray di disinfezione", + "dried_tomatoes": "Pomodori secchi", + "edamame": "Edamame", + "eggplant": "Melanzana", + "eggs": "Uova", + "falafel": "Falafel", + "falafel_powder": "Falafel in polvere", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Bastoncini di pesce", + "flour": "Farina", + "flushing": "Risciacquo", + "fresh_chili_pepper": "Peperoncino fresco", + "frozen_berries": "Bacche congelate", + "frozen_fruit": "Frutta congelata", + "frozen_pizza": "Pizza surgelata", + "frozen_spinach": "Spinaci congelati", + "garam_masala": "Garam Masala", + "garbage_bag": "Sacchetto della spazzatura", + "garlic": "Aglio", + "garlic_dip": "Salsa all'aglio", + "garlic_granules": "Aglio in granuli", + "gherkins": "Cetriolini", + "ginger": "Zenzero", + "glass_noodles": "Tagliatelle di vetro", + "gluten": "Il glutine", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "grapes": "Uva", + "greek_yogurt": "Yogurt greco", + "green_asparagus": "Asparagi verdi", + "green_chili": "Peperoncino verde", + "green_pesto": "Pesto verde", + "hair_gel": "Gel per capelli", "hair_wax": "Cera per capelli", "handkerchief_box": "Scatola per fazzoletti", "handkerchiefs": "Fazzoletti", From 5ac97119eb40001f49cdaed004cc7d8b672dcb48 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 7 Jun 2023 18:18:54 +0200 Subject: [PATCH 336/496] feat: Add Italian --- backend/app/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/config.py b/backend/app/config.py index 41a75db2..94f3475b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -45,6 +45,7 @@ 'es': 'Español', 'fr': 'Français', 'id': 'Bahasa Indonesia', + 'it': 'Italiano', 'nb_NO': 'Bokmål', 'nl': 'Nederlands', 'pl': 'Polski', From 28784ca7f713eb47c16bdda23fbbd653114f8ea2 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 7 Jun 2023 18:19:20 +0200 Subject: [PATCH 337/496] Prepare release 68 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 94f3475b..4b621345 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 67 +BACKEND_VERSION = 68 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 165291cabd4a981e40774420c34c9aeded79f64b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 9 Jun 2023 23:17:18 +0200 Subject: [PATCH 338/496] feat: add local analytics --- backend/app/api/register_controller.py | 1 + backend/app/controller/__init__.py | 1 + backend/app/controller/analytics/__init__.py | 1 + .../analytics/analytics_controller.py | 23 +++++++++++++++++++ 4 files changed, 26 insertions(+) create mode 100644 backend/app/controller/analytics/__init__.py create mode 100644 backend/app/controller/analytics/analytics_controller.py diff --git a/backend/app/api/register_controller.py b/backend/app/api/register_controller.py index 6f073c0c..f70fec95 100644 --- a/backend/app/api/register_controller.py +++ b/backend/app/api/register_controller.py @@ -28,5 +28,6 @@ apiv1.register_blueprint(api.tag, url_prefix='/tag') apiv1.register_blueprint(api.user, url_prefix='/user') apiv1.register_blueprint(api.upload, url_prefix='/upload') +apiv1.register_blueprint(api.analytics, url_prefix='/analytics') app.register_blueprint(apiv1, url_prefix='/api') diff --git a/backend/app/controller/__init__.py b/backend/app/controller/__init__.py index 71cbe9ca..5ace091a 100644 --- a/backend/app/controller/__init__.py +++ b/backend/app/controller/__init__.py @@ -13,3 +13,4 @@ from .household import * from .category import * from .health_controller import health +from .analytics import * diff --git a/backend/app/controller/analytics/__init__.py b/backend/app/controller/analytics/__init__.py new file mode 100644 index 00000000..6bc6ec12 --- /dev/null +++ b/backend/app/controller/analytics/__init__.py @@ -0,0 +1 @@ +from .analytics_controller import analytics diff --git a/backend/app/controller/analytics/analytics_controller.py b/backend/app/controller/analytics/analytics_controller.py new file mode 100644 index 00000000..2f480f31 --- /dev/null +++ b/backend/app/controller/analytics/analytics_controller.py @@ -0,0 +1,23 @@ +import os +from app.helpers import server_admin_required +from app.models import User, Token, Household +from app.config import UPLOAD_FOLDER +from flask import jsonify, Blueprint +from flask_jwt_extended import jwt_required + + +analytics = Blueprint('analytics', __name__) + + +@analytics.route('', methods=['GET']) +@jwt_required() +@server_admin_required() +def getBaseAnalytics(): + statvfs = os.statvfs(UPLOAD_FOLDER) + return jsonify({ + "total_users": User.count(), + "active_users": Token.query.filter(Token.type == 'refresh').group_by(Token.user_id).count(), + "total_households": Household.count(), + "free_storage": statvfs.f_frsize * statvfs.f_bavail, + "available_storage": statvfs.f_frsize * statvfs.f_blocks, + }) From cb972ea6758aa0c314c9f98a458d8fdaebad09c0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 9 Jun 2023 23:17:36 +0200 Subject: [PATCH 339/496] fix: improve token deletion --- backend/app/models/token.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/app/models/token.py b/backend/app/models/token.py index 0d1e870b..20f7bac5 100644 --- a/backend/app/models/token.py +++ b/backend/app/models/token.py @@ -42,8 +42,9 @@ def find_by_jti(cls, jti: str) -> Self: @classmethod def delete_expired_refresh(cls): filter_before = datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES - db.session.query(cls).filter(cls.created_at <= filter_before, cls.type == - 'refresh', ~cls.created_tokens.any()).delete(synchronize_session=False) + for token in db.session.query(cls).filter(cls.created_at <= filter_before, cls.type == + 'refresh', ~cls.created_tokens.any()).all(): + token.delete_token_familiy(commit=False) db.session.commit() @classmethod @@ -55,7 +56,7 @@ def delete_expired_access(cls): # Delete oldest refresh token -> log out device # Used e.g. when a refresh token is used twice - def delete_token_familiy(self): + def delete_token_familiy(self, commit=True): if (self.type != 'refresh'): return token = self @@ -63,8 +64,10 @@ def delete_token_familiy(self): if token.refresh_token: token = token.refresh_token else: - token.delete() + db.session.delete(token) token = None + if commit: + db.session.commit() def has_created_refresh_token(self) -> bool: return db.session.query(Token).filter(Token.refresh_token_id == self.id, Token.type == 'refresh').count() > 0 From e8ca033d002b19f3174a90c7477619eb4a88c837 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 10 Jun 2023 19:11:32 +0200 Subject: [PATCH 340/496] feat: split item search query --- .../app/controller/item/item_controller.py | 4 +- backend/app/util/description_splitter.py | 113 ++++++++++++++++++ .../tests/util/test_description_splitter.py | 27 +++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 backend/app/util/description_splitter.py create mode 100644 backend/tests/util/test_description_splitter.py diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 2beb0023..88c799ac 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -1,6 +1,7 @@ from app.helpers import validate_args, authorize_household from flask import jsonify, Blueprint from app.errors import InvalidUsage, NotFoundRequest +import app.util.description_splitter as description_splitter from flask_jwt_extended import jwt_required from app.models import Item, RecipeItems, Recipe, Category from .schemas import SearchByNameRequest, UpdateItem @@ -56,7 +57,8 @@ def deleteItemById(id): @authorize_household() @validate_args(SearchByNameRequest) def searchItemByName(args, household_id): - return jsonify([e.obj_to_dict() for e in Item.search_name(args['query'], household_id)]) + query, description = description_splitter.split(args['query']) + return jsonify([e.obj_to_dict() | {"description": description} for e in Item.search_name(query, household_id)]) @item.route('/', methods=['POST']) diff --git a/backend/app/util/description_splitter.py b/backend/app/util/description_splitter.py new file mode 100644 index 00000000..f72342ac --- /dev/null +++ b/backend/app/util/description_splitter.py @@ -0,0 +1,113 @@ +from typing import Self, Tuple +from lark import Lark, Transformer, Tree, Token +from lark.visitors import Interpreter +import re + +grammar = r''' +start: (NUMBER unit?)? NAME? (NUMBER unit?)? + +unit: COUNT | SI_WEIGHT | SI_VOLUME +COUNT.5: "x"i +SI_WEIGHT.5: "mg"i | "g"i | "kg"i +SI_VOLUME.5: "ml"i | "l"i +NAME: /[^ ][^0-9]*/ + +DECIMAL: INT "." INT? | "." INT | INT "," INT +FLOAT: INT _EXP | DECIMAL _EXP? +NUMBER.10: FLOAT | INT + +%ignore WS +%import common (_EXP, INT, WS) +''' + + +class TreeItem(Tree): + # Quick and dirty class to not build an AST + def __init__(self, data: str, children) -> None: + self.data = data + self.children = children + self.number: Token = None + self.unit: Tree = None + self.name: Token = None + for c in children: + if isinstance(c, Token) and c.type == "NUMBER": + self.number = c + elif isinstance(c, Token) and (c.type == "NAME" or c.type == "NAME_WO_NUM"): + self.name = c + else: + self.unit = c + + +class T(Transformer): + def NUMBER(self, number: Token): + return number.update(value=float(number.replace(",", "."))) + + def start(self, children): + return TreeItem("start", children) + + +class Printer(Interpreter): + def start(self, start: Tree): + res = "" + for child in start.children: + if isinstance(child, Tree): + res += self.visit(child) + elif child.type == 'NUMBER': + value = round(child.value, 5) + res += str(int(value)) if value.is_integer() else f"{value}" + return res + + def unit(self, unit: Tree): + return unit.children[0] + + +# Objects +parser = Lark(grammar) +transformer = T() + + +def split(query: str) -> Tuple[str, str]: + try: + query = clean(query) + itemTree = transformer.transform(parser.parse(query)) + except: + return query, "" + + return (itemTree.name or "").strip(), Printer().visit(itemTree) + + +def clean(input: str) -> str: + input = re.sub( + '¼|½|¾|⅐|⅑|⅒|⅓|⅔|⅕|⅖|⅗|⅘|⅙|⅚|⅛|⅜|⅝|⅞', + lambda match: { + '¼': '0.25', + '½': '0.5', + '¾': '0.75', + '⅐': '0.142857142857', + '⅑': '0.111111111111', + '⅒': '0.1', + '⅓': '0.333333333333', + '⅔': '0.666666666667', + '⅕': '0.2', + '⅖': '0.4', + '⅗': '0.6', + '⅘': '0.8', + '⅙': '0.166666666667', + '⅚': '0.833333333333', + '⅛': '0.125', + '⅜': '0.375', + '⅝': '0.625', + '⅞': '0.875', + }.get(match.group(), match.group), + input + ) + + # replace 1/2 with .5 + input = re.sub( + r'(\d+((\.)\d+)?)\/(\d+((\.)\d+)?)', + lambda match: str(float(match.group(1)) / + float(match.group(4))), + input + ) + + return input diff --git a/backend/tests/util/test_description_splitter.py b/backend/tests/util/test_description_splitter.py new file mode 100644 index 00000000..1be70ed7 --- /dev/null +++ b/backend/tests/util/test_description_splitter.py @@ -0,0 +1,27 @@ +import pytest +import app.util.description_splitter as description_splitter + + +@pytest.mark.parametrize("query,item,description", [ + ("", "", ""), + ("300ml", "ml", "300"), + ("300ml Milk", "Milk", "300ml"), + ("Gouda", "Gouda", ""), + ("Gouda, Emmentaler", "Gouda, Emmentaler", ""), + ("1 bag of Kartoffeln", "bag of Kartoffeln", "1"), + ("5kg Gouda", "Gouda", "5kg"), + ("Gouda 5g", "Gouda", "5g"), + ("Gouda + 5 Kartoffeln", "Gouda + 5 Kartoffeln", ""), + ("Gouda + 5 Pumpkin", "Gouda + 5 Pumpkin", ""), +]) +def testDescriptionMerge(query, item, description): + assert description_splitter.split(query) == (item, description) + + +@pytest.mark.parametrize("input,result", [ + ("½", "0.5"), + ("1/2", "0.5"), + ("500/1000", "0.5") +]) +def testClean(input, result): + assert description_splitter.clean(input) == result From fe0a163c5a8ab586923a371934a95355db15aed3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 11 Jun 2023 21:44:17 +0200 Subject: [PATCH 341/496] feat: Add members on account creation --- .../app/controller/household/household_controller.py | 12 +++++++++++- backend/app/controller/household/schemas.py | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index f3f9a939..be05e412 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -3,7 +3,7 @@ from flask import jsonify, Blueprint from app.errors import NotFoundRequest from flask_jwt_extended import current_user, jwt_required -from app.models import Household, HouseholdMember, Shoppinglist, File +from app.models import Household, HouseholdMember, Shoppinglist, User from app.service.import_language import importLanguage from app.service.file_has_access_or_download import file_has_access_or_download from .schemas import AddHousehold, UpdateHousehold, UpdateHouseholdMember @@ -51,6 +51,16 @@ def addHousehold(args): member.owner = True member.save() + if 'member' in args: + print(args['member']) + for uid in args['member']: + if uid == current_user.id: continue + if not User.find_by_id(uid): continue + member = HouseholdMember() + member.household_id = household.id + member.user_id = uid + member.save() + Shoppinglist(name="Default", household_id=household.id).save() if household.language: diff --git a/backend/app/controller/household/schemas.py b/backend/app/controller/household/schemas.py index 7329ced6..5670709a 100644 --- a/backend/app/controller/household/schemas.py +++ b/backend/app/controller/household/schemas.py @@ -13,6 +13,7 @@ class Meta: planner_feature = fields.Boolean() expenses_feature = fields.Boolean() view_ordering = fields.List(fields.String) + member = fields.List(fields.Integer) class UpdateHousehold(Schema): From 2b5843945a69263b6f30962e6d8fd4dc8e72bbcc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 2 Jul 2023 12:17:41 +0200 Subject: [PATCH 342/496] chore: upgrade requirements --- backend/requirements.txt | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 95835a13..fcded459 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,7 +11,7 @@ certifi==2023.5.7 cffi==1.15.1 charset-normalizer==3.1.0 click==8.1.3 -contourpy==1.0.7 +contourpy==1.1.0 cycler==0.11.0 dbscan1d==0.2.2 extruct==0.14.0 @@ -19,10 +19,10 @@ flake8==6.0.0 Flask==2.3.2 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 -Flask-JWT-Extended==4.4.4 +Flask-JWT-Extended==4.5.2 Flask-Migrate==4.0.4 -Flask-SQLAlchemy==3.0.3 -fonttools==4.39.4 +Flask-SQLAlchemy==3.0.5 +fonttools==4.40.0 greenlet==2.0.2 html-text==0.5.2 html5lib==1.1 @@ -38,30 +38,30 @@ kiwisolver==1.4.4 lark==1.1.5 lxml==4.9.2 Mako==1.2.4 -MarkupSafe==2.1.2 +MarkupSafe==2.1.3 marshmallow==3.19.0 matplotlib==3.7.1 mccabe==0.7.0 -mf2py==1.1.2 +mf2py==1.1.3 mlxtend==0.22.0 mypy-extensions==1.0.0 nltk==3.8.1 -numpy==1.24.3 +numpy==1.25.0 packaging==23.1 -pandas==2.0.1 +pandas==2.0.3 pathspec==0.11.1 -Pillow==9.5.0 -platformdirs==3.5.1 -pluggy==1.0.0 +Pillow==10.0.0 +platformdirs==3.8.0 +pluggy==1.2.0 psycopg2-binary==2.9.6 py==1.11.0 pycodestyle==2.10.0 pycparser==2.21 pyflakes==3.0.1 PyJWT==2.7.0 -pyparsing==3.0.9 +pyparsing==3.1.0 pyRdfa3==3.5.3 -pytest==7.3.1 +pytest==7.4.0 python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 @@ -72,12 +72,12 @@ rdflib-jsonld==0.6.2 recipe-scrapers==14.36.1 regex==2023.5.5 requests==2.31.0 -scikit-learn==1.2.2 -scipy==1.10.1 +scikit-learn==1.3.0 +scipy==1.11.1 setuptools-scm==7.1.0 six==1.16.0 soupsieve==2.4.1 -SQLAlchemy==2.0.15 +SQLAlchemy==2.0.17 threadpoolctl==3.1.0 toml==0.10.2 tomli==2.0.1 @@ -85,13 +85,13 @@ tqdm==4.65.0 typed-ast==1.5.4 types-beautifulsoup4==4.12.0.5 types-html5lib==1.1.11.14 -types-requests==2.31.0.0 +types-requests==2.31.0.1 types-urllib3==1.26.25.13 -typing_extensions==4.6.0 +typing_extensions==4.7.0 tzdata==2023.3 tzlocal==5.0.1 -urllib3==2.0.2 +urllib3==2.0.3 uWSGI==2.0.21 w3lib==2.1.1 webencodings==0.5.1 -Werkzeug==2.3.4 +Werkzeug==2.3.6 From c4c06e0787dcbc0f58e41d73fa1646eff61e7ff5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 2 Jul 2023 12:38:10 +0200 Subject: [PATCH 343/496] feat: store user who added shopping list item --- .../shoppinglist/shoppinglist_controller.py | 4 ++- backend/app/models/shoppinglist.py | 3 ++ backend/app/models/user.py | 4 +++ backend/migrations/versions/5140d8f9339b_.py | 34 +++++++++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/5140d8f9339b_.py diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 98389718..4bf43245 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -1,5 +1,5 @@ from flask import jsonify, Blueprint -from flask_jwt_extended import jwt_required +from flask_jwt_extended import current_user, jwt_required from app import db from app.models import Item, Shoppinglist, History, Status, Association, ShoppinglistItems from app.helpers import validate_args, authorize_household @@ -186,6 +186,7 @@ def addShoppinglistItemByName(args, id): if not con: description = args['description'] if 'description' in args else '' con = ShoppinglistItems(description=description) + con.created_by = current_user.id con.item = item con.shoppinglist = shoppinglist con.save() @@ -245,6 +246,7 @@ def addRecipeItems(args, id): db.session.add(con) else: con = ShoppinglistItems(description=description) + con.created_by = current_user.id con.item = item con.shoppinglist = shoppinglist db.session.add(con) diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py index b69072a2..73c2397c 100644 --- a/backend/app/models/shoppinglist.py +++ b/backend/app/models/shoppinglist.py @@ -32,15 +32,18 @@ class ShoppinglistItems(db.Model, DbModelMixin, TimestampMixin): 'shoppinglist.id'), primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey('item.id'), primary_key=True) description = db.Column('description', db.String()) + created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) item = db.relationship("Item", back_populates='shoppinglists') shoppinglist = db.relationship("Shoppinglist", back_populates='items') + created_by_user = db.relationship("User", foreign_keys=[created_by], uselist=False) def obj_to_item_dict(self) -> dict: res = self.item.obj_to_dict() res['description'] = getattr(self, 'description') res['created_at'] = getattr(self, 'created_at') res['updated_at'] = getattr(self, 'updated_at') + res['created_by'] = getattr(self, 'created_by') return res @classmethod diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 60658a26..af7d51aa 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -73,6 +73,10 @@ def delete(self): for f in File.query.filter(File.created_by == self.id).all(): f.created_by = None f.save() + from app.models import ShoppinglistItems + for s in ShoppinglistItems.query.filter(ShoppinglistItems.created_by == self.id).all(): + s.created_by = None + s.save() super().delete() @classmethod diff --git a/backend/migrations/versions/5140d8f9339b_.py b/backend/migrations/versions/5140d8f9339b_.py new file mode 100644 index 00000000..0930c0cb --- /dev/null +++ b/backend/migrations/versions/5140d8f9339b_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 5140d8f9339b +Revises: 3d667fcc5581 +Create Date: 2023-07-02 12:19:57.117736 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5140d8f9339b' +down_revision = '3d667fcc5581' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shoppinglist_items', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_by', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_shoppinglist_items_created_by_user'), 'user', ['created_by'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shoppinglist_items', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_shoppinglist_items_created_by_user'), type_='foreignkey') + batch_op.drop_column('created_by') + + # ### end Alembic commands ### From 71441125ce9066b0a9f68f9c4d60ffbc492b13f6 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 2 Jul 2023 18:00:46 +0200 Subject: [PATCH 344/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#40) * Added translation using Weblate (Finnish) * Translated using Weblate (Finnish) Currently translated at 85.6% (359 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fi/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ * Translated using Weblate (Portuguese) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt/ * Translated using Weblate (Finnish) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fi/ * Translated using Weblate (Italian) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/it/ * Added translation using Weblate (Greek) * Translated using Weblate (Greek) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/el/ * Translated using Weblate (Greek) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/el/ --------- Co-authored-by: teemue Co-authored-by: Paolo Basso Co-authored-by: BeardedWatermelon Co-authored-by: Tom Bursch --- backend/templates/l10n/el.json | 425 +++++++++++++++++++++++++++++++++ backend/templates/l10n/fi.json | 425 +++++++++++++++++++++++++++++++++ backend/templates/l10n/it.json | 20 +- backend/templates/l10n/pt.json | 150 ++++++++++++ 4 files changed, 1010 insertions(+), 10 deletions(-) create mode 100644 backend/templates/l10n/el.json create mode 100644 backend/templates/l10n/fi.json diff --git a/backend/templates/l10n/el.json b/backend/templates/l10n/el.json new file mode 100644 index 00000000..cb4618c3 --- /dev/null +++ b/backend/templates/l10n/el.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 Είδη Άρτου", + "canned": "🥫 Είδη Κονσέρβας", + "dairy": "🥛 Γαλακτοκομικά", + "drinks": "🍹 Ποτά", + "freezer": "❄️ Κατεψυγμένα", + "fruits_vegetables": "🥬 Φρούτα και Λαχανικά", + "grain": "🥟 Είδη Σίτου", + "hygiene": "🚽 Είδη Υγιεινής", + "refrigerated": "💧 Είδη Ψυγείου", + "snacks": "🥜 Σνάκ" + }, + "items": { + "aioli": "Αιόλι", + "amaretto": "Αμαρέττο", + "apple": "Μήλο", + "apple_pulp": "Πολτός μήλου", + "applesauce": "Σάλτσα μήλου", + "apérol": "Άπερολ", + "arugula": "Ρόκα", + "asian_egg_noodles": "Ασιάτικα νούντλς αυγού", + "asparagus": "Σπαράγγια", + "aspirin": "Ασπιρίνη", + "avocado": "Αβοκάντο", + "baby_spinach": "Baby σπανάκι", + "bacon": "Μπέικον", + "baguette": "Μπαγκέτα", + "bakefish": "Ψητό ψάρι", + "baking_cocoa": "Κακάο ζαχαροπλαστικής", + "baking_mix": "Μείγμα ψησίματος", + "baking_paper": "Χαρτί ψησίματος", + "baking_powder": "Μπέικιν πάουντερ", + "baking_soda": "Μαγειρική σόδα", + "baking_yeast": "Μαγιά ψησίματος", + "balsamic_vinegar": "Βαλσάμικο ξύδι", + "bananas": "Μπανάνες", + "basil": "Βασιλικός", + "basmati_rice": "Ρύζι μπασμάτι", + "bathroom_cleaner": "Καθαριστικό μπάνιου", + "batteries": "Μπαταρίες", + "bay_leaf": "Δάφνη", + "beans": "Φασόλια", + "beer": "Μπίρα", + "beet": "Παντζάρι", + "beetroot": "Ρίζα παντζαριού", + "birthday_card": "Κάρτα γενεθλίων", + "black_beans": "Μαύρα φασόλια", + "bockwurst": "Λουκάνικο Bockwurst", + "bodywash": "Αφρόλουτρο", + "bread": "Ψωμί", + "breadcrumbs": "Τριμμένη φρυγανιά", + "broccoli": "Μπρόκολο", + "brown_sugar": "Καστανή ζάχαρη", + "brussels_sprouts": "Λαχανάκια Βρυξελλών", + "buffalo_mozzarella": "Μοτσαρέλα Buffalo", + "buko": "Μπούκο", + "buns": "Ψωμάκια", + "burger_buns": "Ψωμάκια Μπέργκερ", + "burger_patties": "Μπιφτέκια Μπέργκερ", + "burger_sauces": "Σάλτσες Μπέργκερ", + "butter": "Βούτυρο", + "butter_cookies": "Μπισκότα βουτύρου", + "button_cells": "Μπαταρίες ρολογιού", + "börek_cheese": "Τυρί Börek", + "cake": "Κέικ", + "cake_icing": "Γλάσο τούρτας", + "cane_sugar": "Ζάχαρη από ζαχαροκάλαμο", + "cannelloni": "Κανελόνι", + "canola_oil": "Λάδι Κανόλα", + "cardamom": "Κάρδαμο", + "carrots": "Καρότα", + "cashews": "Κάσιους", + "cat_treats": "Λιχουδιές για γάτες", + "cauliflower": "Κουνουπίδι", + "celeriac": "Σελινόριζα", + "celery": "Σέλινο", + "cereal_bar": "Μπάρα δημητριακών", + "cheddar": "Τσένταρ", + "cheese": "Τυρί", + "cherry_tomatoes": "Ντοματίνια", + "chickpeas": "Ρεβύθια", + "chili_oil": "Λάδι Τσίλι", + "chips": "Πατατάκια", + "chives": "Σχοινόπρασο", + "chocolate": "Σοκολάτα", + "chocolate_chips": "Κομματάκια σοκολάτας", + "chopped_tomatoes": "Κομμένες ντομάτες", + "ciabatta": "Τσιαμπάτα", + "cider_vinegar": "Ξίδι μηλίτη", + "cilantro": "Κόλιαντρο", + "cinnamon": "Κανέλλα", + "cinnamon_stick": "Στικ Κανέλλας", + "cocktail_sauce": "Σως κοκτέιλ", + "cocktail_tomatoes": "Ντομάτες κοκτέιλ", + "coconut_flakes": "Νιφάδες καρύδας", + "coconut_milk": "Γάλα καρύδας", + "coconut_oil": "Λάδι καρύδας", + "colorful_sprinkles": "Πολύχρωμες τρούφες", + "concealer": "Κονσίλερ", + "cookies": "Μπισκότα", + "coriander": "Κολίανδρο", + "corn": "Καλαμπόκι", + "cornflakes": "Δημητριακά", + "cornstarch": "Άμυλο καλαμποκιού", + "cornys": "Κόρνις", + "cough_drops": "Παστίλιες για τον βήχα", + "couscous": "Κουσκους", + "covid_rapid_test": "COVID ράπιντ τεστ", + "cow's_milk": "Γάλα αγελαδίσιο", + "cream": "Κρέμα", + "cream_cheese": "Τυρί κρέμα", + "creamed_spinach": "Σπανάκι κρέμα", + "creme_fraiche": "Κρέμα γάλακτος", + "crepe_tape": "Χαρτοταινία", + "crispbread": "Τραγανόψωμο", + "cucumber": "Αγγούρι", + "cumin": "Κύμινο", + "curry_paste": "Πάστα κάρι", + "curry_powder": "Σκόνη κάρι", + "curry_sauce": "Σάλτσα κάρι", + "dates": "Χουρμάδες", + "dental_floss": "Οδοντικό νήμα", + "deodorant": "Αποσμητικό", + "detergent": "Απορρυπαντικό", + "dill": "Άνηθος", + "dishwasher_salt": "Σκόνη πλυντηρίου πιάτων", + "dishwasher_tabs": "Ταμπλέτες πλυντηρίου πιάτων", + "disinfection_spray": "Απολυμαντικό σπρέι", + "dried_tomatoes": "Αποξηραμένες ντομάτες", + "edamame": "Εντάμαμε", + "eggplant": "Μελιτζάνα", + "eggs": "Αυγά", + "falafel": "Φαλάφελ", + "falafel_powder": "Σκόνη φαλάφελ", + "fanta": "Φάντα", + "feta": "Φέτα", + "ffp2": "Μάσκα FFP2", + "fish_sticks": "Ψαροκροκέτες", + "flour": "Αλεύρι", + "flushing": "Καθαριστικά τουαλέτας", + "fresh_chili_pepper": "Φρέσκια πιπεριά τσίλι", + "frozen_berries": "Κατεψυγμένα μούρα", + "frozen_fruit": "Κατεψυγμένα φρούτα", + "frozen_pizza": "Κατεψυγμένη πίτσα", + "frozen_spinach": "Κατεψυγμένο σπανάκι", + "garam_masala": "Γκαράμ Μασάλα", + "garbage_bag": "Σακούλες σκουπιδιών", + "garlic": "Σκόρδο", + "garlic_dip": "Σάλτσα σκόρδου", + "garlic_granules": "Κόκκοι σκόρδου", + "gherkins": "Αγγουράκια", + "ginger": "Τζίντζερ", + "glass_noodles": "Νουντλς φασολιού", + "gluten": "Γλουτένη", + "gnocchi": "Νιόκι", + "gochujang": "Κοτσουτζάν", + "gorgonzola": "Γκοργκονζόλα", + "gouda": "Γκούντα", + "grapes": "Σταφύλια", + "greek_yogurt": "Γιαούρτι", + "green_asparagus": "Πράσινα σπαράγγια", + "green_chili": "Πράσινο τσίλι", + "green_pesto": "Πράσινο Πέστο", + "hair_gel": "Τζέλ μαλλιών", + "hair_wax": "Κερί μαλλιών", + "handkerchief_box": "Κουτί χαρτομάντηλα", + "handkerchiefs": "Χαρτομάντηλα", + "haribo": "Haribo", + "harissa": "Harissa (καυτερή πάστα τσίλι)", + "hazelnuts": "Φουντούκια", + "head_of_lettuce": "Κεφάλι μαρουλιού", + "herb_baguettes": "Μπαγκέτες με μπαχαρικά", + "herb_cream_cheese": "Κρέμα τυριού με μπαχαρικά", + "honey": "Μέλι", + "honey_wafers": "Γκοφρέτες μελιού", + "hot_dog_bun": "Ψωμί χοτ ντόγκ", + "ice_cream": "Παγωτό", + "ice_cube": "Παγάκια", + "iceberg_lettuce": "Μαρούλι Iceberg", + "iced_tea": "Κρύο Τσάι", + "instant_soups": "Σούπες στιγμιαίες", + "jam": "Μαρμελάδα", + "katjes": "Τσίχλες βίγκαν", + "ketchup": "Κέτσαπ", + "kidney_beans": "Κόκκινα φασόλια", + "kitchen_roll": "Χαρτί κουζίνας", + "kitchen_towels": "Πετσέτες κουζίνας", + "kohlrabi": "Γογγυλοκράμβη", + "lasagna": "Λαζάνια", + "lasagna_noodles": "Νούντλς λαζάνια", + "lasagna_plates": "Ζυμαρικά λαζάνια", + "leaf_spinach": "Φύλλο Σπανάκι", + "leek": "Πράσο", + "lemon": "Λεμόνι", + "lemon_juice": "Χυμός λεμονιού", + "lemonade": "Λεμονάδα", + "lemongrass": "Λεμονόχορτο", + "lentils": "Φακές", + "lentils_red": "Κόκκινες φακές", + "lettuce": "Μαρούλι", + "lillet": "Απεριτίφ", + "lime": "Λάιμ", + "linguine": "Λιγκουίνι", + "low-fat_curd_cheese": "Τυρί με χαμηλά λιπαρά", + "magnesium": "Μαγνήσιο", + "mango": "Μάνγκο", + "margarine": "Μαργαρίνη", + "marjoram": "Μαντζουράνα", + "marshmallows": "Marshmallows", + "mask": "Μάσκα", + "mayonnaise": "Μαγιονέζα", + "meat_substitute_product": "Υποκατάστατο κρέατος", + "microfiber_cloth": "Πανί μικροϊνων", + "milk": "Γάλα", + "mint": "Μέντα", + "mint_candy": "Καραμέλα μέντας", + "mixed_vegetables": "Ανάμεικτα λαχανικά", + "mochis": "Mότσις", + "mountain_cheese": "Τυρί βουνού", + "mouth_wash": "Στοματικό διάλυμα", + "mozzarella": "Μοτσαρέλα", + "muesli": "Μουέσλι", + "muesli_bar": "Μπάρα μουέσλι", + "mulled_wine": "Ζεστό κρασί", + "mushrooms": "Μανιτάρια", + "mustard": "Μουστάρδα", + "neutral_oil": "Ουδέτερο λάδι", + "nori_sheets": "Φύλλα από φύκια", + "nutmeg": "Μοσχοκάρυδο", + "oat_milk": "Ποτό βρώμης", + "oatmeal": "Βρώμη", + "oatmeal_cookies": "Μπισκότα βρώμης", + "oatsome": "Γάλα βρώμης", + "obatzda": "Obatzda", + "olive_oil": "Ελαιόλαδο", + "olives": "Ελιές", + "onion": "Κρεμμύδι", + "orange_juice": "Πορτοκαλάδα", + "oranges": "Πορτοκάλια", + "oregano": "Ρίγανη", + "organic_lemon": "Βιολογικό λεμόνι", + "organic_waste_bags": "Οργανικές σακούλες σκουπιδιών", + "pak_choi": "Μποκ τσόι", + "paprika": "Πάπρικα", + "pardina_lentils_dried": "Αποξηραμένες φακές", + "parmesan": "Παρμεζάνα", + "parsley": "Μαϊντανός", + "pasta": "Ζυμαρικά", + "peach": "Ροδάκινο", + "peanut_butter": "Φυστικοβούτυρο", + "peanut_flips": "Γαριδάκια φιστικιού", + "peanut_oil": "Λάδι φιστικιού", + "peanuts": "Φιστίκια", + "pears": "Αχλάδια", + "peas": "Αρακάς", + "penne": "Πένες", + "pepper": "Πιπέρι", + "pepper_mill": "Μύλος πιπεριού", + "peppers": "Πιπεριές", + "persian_rice": "Περσικό ρύζι", + "pesto": "Πέστο", + "pilsner": "Πίλσνερ", + "pine_nuts": "Κουκουνάρι", + "pineapple": "Ανανάς", + "pita_bag": "Σακούλα Πίτα", + "pizza": "Πίτσα", + "pizza_dough": "Ζύμη πίτσας", + "plant_magarine": "Φυτική μαργαρίνη", + "plant_oil": "Φυτικό λάδι", + "plaster": "Γύψος", + "porcini_mushrooms": "Μανιτάρια πορτσίνι", + "potato_dumpling_dough": "Ζύμη για ντάμπλινγκ πατάτας", + "potato_wedges": "Κυδωνάτες πατάτες", + "potatoes": "Πατάτες", + "potting_soil": "Χώμα γλάστρας", + "powder": "Πούδρα", + "powdered_sugar": "Ζάχαρη άχνη", + "processed_cheese": "Επεξεργασμένο τυρί", + "prosecco": "Prosecco", + "puff_pastry": "Σφολιάτα", + "pumpkin": "Κολοκύθα", + "pumpkin_seeds": "Κολοκυθόσποροι", + "quark": "Τυρί Quark", + "quinoa": "Κινόα", + "radicchio": "Ραδίκιο", + "radish": "Ραπανάκι", + "ramen": "Ράμεν", + "rapeseed_oil": "Κραμβέλαιο", + "raspberries": "Βατόμουρα", + "raspberry_syrup": "Σιρόπι βατόμουρου", + "red_bull": "Red Bull", + "red_chili": "Κόκκινο τσίλι", + "red_lentils": "Κόκκινες φακές", + "red_onions": "Κόκκινα κρεμμύδια", + "red_pesto": "Κόκκινη πέστο", + "red_wine": "Κόκκινο κρασί", + "red_wine_vinegar": "Ξύδι από κόκκινο κρασί", + "rhubarb": "Ραβέντι", + "ribbon_noodles": "Νούντλς κορδέλα", + "rice": "Ρύζι", + "rice_cakes": "Ρυζογκοφρέτες", + "rice_ribbon_noodles": "Νούντλς κορδέλα από ρύζι", + "rice_vinegar": "Ξύδι ρυζιού", + "ricotta": "Ρικότα", + "rinse_tabs": "Ταμπλέτες λαμπρυντικού", + "rinsing_agent": "Απορρυπαντικό πιάτων", + "risotto_rice": "Ριζότο", + "rocket": "Ρόκα", + "roll": "Χαρτί", + "rosemary": "Δενδρολίβανο", + "saffron_threads": "Κλωστές σαφράν", + "sage": "Φασκόμηλο", + "saitan_powder": "Σκόνη Saitan", + "salad_mix": "Ανάμεικτη σαλάτα", + "salad_seeds_mix": "Ανάμεικτοι καρποί σαλατικών", + "salt": "Αλάτι", + "salt_mill": "Μύλος αλατιού", + "sambal_oelek": "Ινδονησιακή σάλτσα Sambal", + "sauce": "Σάλτσα", + "sausage": "Λουκάνικο", + "sausages": "Λουκάνικα", + "savoy_cabbage": "Λάχανο Σαβοΐας", + "scallion": "Πρασουλίδα", + "scattered_cheese": "Άλειμμα τυριού", + "schlemmerfilet": "Φιλέτο ψάρι", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Σιμιγδάλι", + "sesame": "Σουσάμι", + "sesame_oil": "Σησαμέλαιο", + "shallot": "Εσαλότ", + "shampoo": "Σαμπουάν", + "shawarma_spice": "Μπαχαρικά σουάρμα", + "shiitake_mushroom": "Μανιτάρι Σιτάκε", + "shoe_insoles": "Σόλες παπουτσιών", + "shower_gel": "Αφρόλουτρο τζελ", + "shredded_cheese": "Τριμμένο τυρί", + "sieved_tomatoes": "Κοσκινισμένες ντομάτες", + "sliced_cheese": "Τυρί σε φέτες", + "smoked_paprika": "Καπνιστή πάπρικα", + "smoked_tofu": "Καπνιστό τόφου", + "snacks": "Σνάκς", + "soap": "Σαπούνι", + "soft_drinks": "Αναψυκτικά", + "softdrinks": "Αναψυκτικά", + "sour_cream": "Ξινή κρέμα", + "sour_cucumbers": "Ξινά αγγούρια", + "soy_hack": "Σόγια", + "soy_sauce": "Σάλτσα σόγιας", + "soy_shred": "Τριμμένη σόγια", + "spaetzle": "Spaetzle (Νούντλς)", + "spaghetti": "Σπαγγέτι", + "sparkling_water": "Ανθρακούχο νερό", + "spelt": "Όλυρα", + "spinach": "Σπανάκι", + "sponge_cloth": "Σφουγγαρόπανο", + "sponge_wipes": "Μαντιλάκια σφουγγαριού", + "sponges": "Σφουγγάρια", + "spreading_cream": "Κρέμα αλειφόμμενη", + "spring_onions": "Φρέσκα κρεμμυδάκια", + "sprite": "Sprite", + "sprouts": "Βλαστάρια", + "sriracha": "Σιράτσα", + "strained_tomatoes": "Ντομάτες στραγγισμένες", + "sugar": "Ζάχαρη", + "summer_roll_paper": "Φύλλο Spring Rolls", + "sunflower_seeds": "Ηλιόσποροι", + "sushi_rice": "Ρύζι για σούσι", + "swabian_ravioli": "Σουηβικά ραβιόλια", + "sweet_potato": "Γλυκοπατάτα", + "sweet_potatoes": "Γλυκοπατάτες", + "table_salt": "Χοντρό αλάτι", + "tagliatelle": "Ταλιατέλλες", + "tahini": "Ταχίνι", + "tangerines": "Μανταρίνια", + "tape": "Ταινία", + "tea": "Τσάι", + "teriyaki_sauce": "Σάλτσα Τεριγιάκι", + "thyme": "Θυμάρι", + "toast": "Τόστ", + "tofu": "Τόφου", + "toilet_paper": "Χαρτί υγείας", + "tomato_juice": "Ντοματοχυμός", + "tomato_paste": "Ντοματοπελτές", + "tomato_sauce": "Σάλτσα ντομάτας", + "tomatoes": "Ντομάτες", + "tonic_water": "Τόνικ", + "toothpaste": "Οδοντόκρεμα", + "tortellini": "Τορτελίνι", + "tortilla_chips": "Τσίπς τορτίγιας", + "tuna": "Τόνος", + "turmeric": "Κουρκουμάς", + "tzatziki": "Τζατζίκι", + "udon_noodles": "Νούντλς Udon", + "uht_milk": "Γάλα υψηλής παστερίωσης", + "vanilla_sugar": "Βανίλιες (Ζάχαρη)", + "vegetable_bouillon_cube": "Κύβος λαχανικών", + "vegetable_broth": "Ζωμός λαχανικών", + "vegetable_oil": "Φυτικό έλαιο", + "vegetable_onion": "Κρεμμυδάκι", + "vegetables": "Λαχανικά", + "vegetarian_cold_cuts": "Χορτοφαγικά αλλαντικά", + "vinegar": "Ξίδι", + "vodka": "Βότκα", + "washing_powder": "Σκόνη πλυσίματος", + "water": "Νερό", + "water_ice": "Παγωμένο νερό", + "watermelon": "Καρπούζι", + "wc_cleaner": "Καθαριστικό τουαλέτας", + "whipped_cream": "Σαντιγύ", + "white_wine": "Λευκό κρασί", + "white_wine_vinegar": "Ξίδι λευκού κρασιού", + "whole_canned_tomatoes": "Ντομάτες κονσέρβα", + "wild_berries": "Άγρια μούρα", + "wrapping_paper": "Χαρτί περιτυλίγματος", + "wraps": "Αραβικές πίτες", + "yeast": "Μαγιά", + "yoghurt": "Γιαουρτάκι", + "yogurt": "Κατσικίσιο γιαούρτι", + "yum_yum": "Yum Yum Νούντλς", + "zewa": "Χαρτί Zewa", + "zinc_cream": "Κρέμα ψευδάργυρου", + "zucchini": "Κολοκύθι" + } +} diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json new file mode 100644 index 00000000..aaf4b067 --- /dev/null +++ b/backend/templates/l10n/fi.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 Leipätuotteet", + "canned": "🥫 Säilykkeet", + "dairy": "🥛 Maitotuotteet", + "drinks": "🍹 Juomat", + "freezer": "❄️ Pakasteet", + "fruits_vegetables": "🥬 Hedelmät ja vihannekset", + "grain": "🥟 Viljatuotteet", + "hygiene": "🚽 Hygienia", + "refrigerated": "💧 Jääkaappi", + "snacks": "🥜 Herkut" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Omena", + "apple_pulp": "Omenasose", + "applesauce": "Omenasose", + "apérol": "Apérol", + "arugula": "Rucola", + "asian_egg_noodles": "Munanuudelit", + "asparagus": "Parsa", + "aspirin": "Aspiriini", + "avocado": "Avocado", + "baby_spinach": "Babypinaatti", + "bacon": "Pekoni", + "baguette": "Patonki", + "bakefish": "Paneroitu kala", + "baking_cocoa": "Leivontakaakao", + "baking_mix": "Jauhoseos", + "baking_paper": "Leivinpaperi", + "baking_powder": "Leivinjauhe", + "baking_soda": "Ruokasooda", + "baking_yeast": "Hiiva", + "balsamic_vinegar": "Balsamiviinietikka", + "bananas": "Banaanit", + "basil": "Basilika", + "basmati_rice": "Basmatiriisi", + "bathroom_cleaner": "Kylpyhuoneen pesuaine", + "batteries": "Paristot", + "bay_leaf": "Laakerinlehti", + "beans": "Pavut", + "beer": "Olut", + "beet": "Juurikas", + "beetroot": "Punajuuri", + "birthday_card": "Syntymäpäiväkortti", + "black_beans": "Mustapavut", + "bockwurst": "Bockwurst", + "bodywash": "Vartalosaippua", + "bread": "Leipä", + "breadcrumbs": "Korppujauhot", + "broccoli": "Parsakaali", + "brown_sugar": "Fariinisokeri", + "brussels_sprouts": "Ruusukaali", + "buffalo_mozzarella": "Buffalomozzarella", + "buko": "Buko", + "buns": "Sämpylät", + "burger_buns": "Hampurilaissämpylät", + "burger_patties": "Burgerpihvit", + "burger_sauces": "Hampurilaiskastikkeet", + "butter": "Voi", + "butter_cookies": "Voikeksit", + "button_cells": "Nappiparistot", + "börek_cheese": "Börek-juusto", + "cake": "Kakku", + "cake_icing": "Kakun kuorrute", + "cane_sugar": "Ruokosokeri", + "cannelloni": "Cannelloni", + "canola_oil": "Rypsiöljy", + "cardamom": "Kardemumma", + "carrots": "Porkkanat", + "cashews": "Cashewpähkinät", + "cat_treats": "Kissan herkut", + "cauliflower": "Kukkakaali", + "celeriac": "Mukulaselleri", + "celery": "Selleri", + "cereal_bar": "Viljapatukka", + "cheddar": "Cheddar-juusto", + "cheese": "Juusto", + "cherry_tomatoes": "Kirsikkatomaatit", + "chickpeas": "Kikherneet", + "chili_oil": "Chiliöljy", + "chips": "Sipsit", + "chives": "Ruohosipuli", + "chocolate": "Suklaa", + "chocolate_chips": "Suklaalastut", + "chopped_tomatoes": "Pilkotut tomaatit", + "ciabatta": "Ciabatta", + "cider_vinegar": "Siideriviinietikka", + "cilantro": "Korianteri", + "cinnamon": "Kaneli", + "cinnamon_stick": "Kanelitanko", + "cocktail_sauce": "Cocktail-kastike", + "cocktail_tomatoes": "Cocktail-tomaatit", + "coconut_flakes": "Kookoshiutaleet", + "coconut_milk": "Kookosmaito", + "coconut_oil": "Kookosöljy", + "colorful_sprinkles": "Koristerakeet", + "concealer": "Peitevoide", + "cookies": "Keksit", + "coriander": "Korianteri", + "corn": "Maissi", + "cornflakes": "Maissihiutaleet", + "cornstarch": "Maissitärkkelys", + "cornys": "Cornys", + "cough_drops": "Yskänpastillit", + "couscous": "Couscous", + "covid_rapid_test": "COVID-pikatesti", + "cow's_milk": "Lehmänmaito", + "cream": "Kerma", + "cream_cheese": "Kermajuusto", + "creamed_spinach": "Kermapinaatti", + "creme_fraiche": "Ranskankerma", + "crepe_tape": "Maalarinteippi", + "crispbread": "Näkkileipä", + "cucumber": "Kurkku", + "cumin": "Kumina", + "curry_paste": "Currytahna", + "curry_powder": "Curryjauhe", + "curry_sauce": "Currykastike", + "dates": "Taatelit", + "dental_floss": "Hammaslanka", + "deodorant": "Deodorantti", + "detergent": "Pesuaine", + "dill": "Tilli", + "dishwasher_salt": "Astianpesukoneen suola", + "dishwasher_tabs": "Astianpesutabletit", + "disinfection_spray": "Desinfektiosuihke", + "dried_tomatoes": "Kuivatut tomaatit", + "edamame": "Edamame-pavut", + "eggplant": "Munakoiso", + "eggs": "Kananmunat", + "falafel": "Falafel", + "falafel_powder": "Falafel-jauhe", + "fanta": "Fanta", + "feta": "Feta-juusto", + "ffp2": "FFP2-maskit", + "fish_sticks": "Kalapuikot", + "flour": "Jauhot", + "flushing": "Huuhtelu", + "fresh_chili_pepper": "Tuore chilipippuri", + "frozen_berries": "Pakastemarjat", + "frozen_fruit": "Pakastehedelmät", + "frozen_pizza": "Pakastepizza", + "frozen_spinach": "Pakastepinaatti", + "garam_masala": "Garam Masala", + "garbage_bag": "Roskapussit", + "garlic": "Valkosipuli", + "garlic_dip": "Valkosipulidippi", + "garlic_granules": "Valkosipulirakeet", + "gherkins": "Maustekurkut", + "ginger": "Inkivääri", + "glass_noodles": "Lasinuudelit", + "gluten": "Gluteenijauho", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang-chilitahna", + "gorgonzola": "Gorgonzola-juusto", + "gouda": "Goudajuusto", + "grapes": "Viinirypäleet", + "greek_yogurt": "Kreikkalainen jogurtti", + "green_asparagus": "Vihreä parsa", + "green_chili": "Vihreä chili", + "green_pesto": "Vihreä pesto", + "hair_gel": "Hiusgeeli", + "hair_wax": "Hiusvaha", + "handkerchief_box": "Nenäliinalaatikko", + "handkerchiefs": "Nenäliinat", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hasselpähkinät", + "head_of_lettuce": "Salaatinlehdet", + "herb_baguettes": "Yrttipatongit", + "herb_cream_cheese": "Yrttituorejuusto", + "honey": "Hunaja", + "honey_wafers": "Hunajavohvelit", + "hot_dog_bun": "Hot Dog-sämpylä", + "ice_cream": "Jäätelö", + "ice_cube": "Jääpala", + "iceberg_lettuce": "Jäävuorisalaatti", + "iced_tea": "Jäätee", + "instant_soups": "Pikakeitot", + "jam": "Hillo", + "katjes": "Katjes", + "ketchup": "Ketsuppi", + "kidney_beans": "Kidneypavut", + "kitchen_roll": "Talouspaperi", + "kitchen_towels": "Keittiöpyyhkeet", + "kohlrabi": "Kyssäkaali", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagnan nuudelit", + "lasagna_plates": "Lasagnelevyt", + "leaf_spinach": "Lehtipinaatti", + "leek": "Purjo", + "lemon": "Sitruuna", + "lemon_juice": "Sitruunamehu", + "lemonade": "Limonadi", + "lemongrass": "Sitruunaruoho", + "lentils": "Linssit", + "lentils_red": "Punaiset linssit", + "lettuce": "Salaatti", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "low-fat_curd_cheese": "Vähärasvainen juusto", + "magnesium": "Magnesium", + "mango": "Mango", + "margarine": "Margariini", + "marjoram": "Meiram", + "marshmallows": "Vaahtokarkit", + "mask": "Maski", + "mayonnaise": "Majoneesi", + "meat_substitute_product": "Lihankorviketuote", + "microfiber_cloth": "Mikrokuitupyyhe", + "milk": "Maito", + "mint": "Minttu", + "mint_candy": "Minttukarkki", + "mixed_vegetables": "Vihannessekoitus", + "mochis": "Mochis", + "mountain_cheese": "Vuoristojuusto", + "mouth_wash": "Suuvesi", + "mozzarella": "Mozzarella", + "muesli": "Mysli", + "muesli_bar": "Myslipatukka", + "mulled_wine": "Glögi", + "mushrooms": "Sienet", + "mustard": "Sinappi", + "neutral_oil": "Neutraali öljy", + "nori_sheets": "Noriarkit", + "nutmeg": "Muskottipähkinä", + "oat_milk": "Kaurajuoma", + "oatmeal": "Kaurahiutaleet", + "oatmeal_cookies": "Kaurakeksit", + "oatsome": "Oatsome", + "obatzda": "Obatzda", + "olive_oil": "Oliiviöljy", + "olives": "Oliivit", + "onion": "Sipuli", + "orange_juice": "Appelsiinimehu", + "oranges": "Appelsiinit", + "oregano": "Oregano", + "organic_lemon": "Luomusitruuna", + "organic_waste_bags": "Biojätepussit", + "pak_choi": "Pak Choi", + "paprika": "Paprika", + "pardina_lentils_dried": "Kuivatut Pardina-linssit", + "parmesan": "Parmesan", + "parsley": "Persilja", + "pasta": "Pasta", + "peach": "Persikka", + "peanut_butter": "Maapähkinävoi", + "peanut_flips": "Maapähkinä Flips", + "peanut_oil": "Maapähkinäöljy", + "peanuts": "Maapähkinät", + "pears": "Päärynät", + "peas": "Herneet", + "penne": "Penne-pasta", + "pepper": "Pippuri", + "pepper_mill": "Pippurimylly", + "peppers": "Pippurit", + "persian_rice": "Persialainen riisi", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinjansiemenet", + "pineapple": "Ananas", + "pita_bag": "Pita-pussi", + "pizza": "Pizza", + "pizza_dough": "Pizzataikina", + "plant_magarine": "Kasvi Magarine", + "plant_oil": "Kasviöljy", + "plaster": "Laastari", + "porcini_mushrooms": "Porcini-sienet", + "potato_dumpling_dough": "Perunakimpaleiden taikina", + "potato_wedges": "Lohkoperunat", + "potatoes": "Perunat", + "potting_soil": "Ruukkumulta", + "powder": "Jauhe", + "powdered_sugar": "Tomusokeri", + "processed_cheese": "Sulatejuusto", + "prosecco": "Prosecco", + "puff_pastry": "Leivonnaiset", + "pumpkin": "Kurpitsa", + "pumpkin_seeds": "Kurpitsan siemenet", + "quark": "Rahka", + "quinoa": "Kvinoa", + "radicchio": "Radicchio", + "radish": "Retiisi", + "ramen": "Ramen", + "rapeseed_oil": "Rypsiöljy", + "raspberries": "Vadelmat", + "raspberry_syrup": "Vadelmasiirappi", + "red_bull": "Red Bull", + "red_chili": "Punainen chili", + "red_lentils": "Punaiset linssit", + "red_onions": "Punasipulit", + "red_pesto": "Punainen pesto", + "red_wine": "Punaviini", + "red_wine_vinegar": "Punaviinietikka", + "rhubarb": "Raparperi", + "ribbon_noodles": "Nauhanuudelit", + "rice": "Riisi", + "rice_cakes": "Riisikakut", + "rice_ribbon_noodles": "Riisinauhanuudelit", + "rice_vinegar": "Riisiviinietikka", + "ricotta": "Ricotta", + "rinse_tabs": "Huuhtele välilehdet", + "rinsing_agent": "Huuhteluaine", + "risotto_rice": "Risottoriisi", + "rocket": "Raketti", + "roll": "Rulla", + "rosemary": "Rosmariini", + "saffron_threads": "Sahramilangat", + "sage": "Salvia", + "saitan_powder": "Saitan-jauhe", + "salad_mix": "Salaattisekoitus", + "salad_seeds_mix": "Salaattisiemensekoitus", + "salt": "Suola", + "salt_mill": "Suolamylly", + "sambal_oelek": "Sambal oelek", + "sauce": "Kastike", + "sausage": "Makkara", + "sausages": "Makkarat", + "savoy_cabbage": "Savoijinkaali", + "scallion": "Sipuli", + "scattered_cheese": "Hajallaan oleva juusto", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Mannapuuro", + "sesame": "Seesami", + "sesame_oil": "Seesamöljy", + "shallot": "Salottisipuli", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarma-mauste", + "shiitake_mushroom": "Shiitake-sienet", + "shoe_insoles": "Kengänpohjalliset", + "shower_gel": "Suihkugeeli", + "shredded_cheese": "Juustoraaste", + "sieved_tomatoes": "Seulotut tomaatit", + "sliced_cheese": "Juustoviipaleet", + "smoked_paprika": "Savustettu paprika", + "smoked_tofu": "Savutofu", + "snacks": "Herkut", + "soap": "Saippua", + "soft_drinks": "Alkoholittomat juomat", + "softdrinks": "Alkoholittomat juomat", + "sour_cream": "Hapankerma", + "sour_cucumbers": "Hapanimelät kurkut", + "soy_hack": "Soija hack", + "soy_sauce": "Soijakastike", + "soy_shred": "Soija silppua", + "spaetzle": "Spetzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Hiilihapotettu vesi", + "spelt": "Speltti", + "spinach": "Pinaatti", + "sponge_cloth": "Sieniliina", + "sponge_wipes": "Sienipyyhkeet", + "sponges": "Pesusienet", + "spreading_cream": "Levitysvoide", + "spring_onions": "Kevätsipulit", + "sprite": "Sprite", + "sprouts": "Idut", + "sriracha": "Sriracha", + "strained_tomatoes": "Siivilöidyt tomaatit", + "sugar": "Sokeri", + "summer_roll_paper": "Kesän rullapaperi", + "sunflower_seeds": "Auringonkukan siemenet", + "sushi_rice": "Sushiriisi", + "swabian_ravioli": "Swabian ravioli", + "sweet_potato": "Bataatti", + "sweet_potatoes": "Bataatit", + "table_salt": "Pöytäsuola", + "tagliatelle": "Tagliatelle", + "tahini": "Tahini", + "tangerines": "Mandariinit", + "tape": "Teippi", + "tea": "Tee", + "teriyaki_sauce": "Teriyaki-kastike", + "thyme": "Timjami", + "toast": "Paahtoleipä", + "tofu": "Tofu", + "toilet_paper": "Vessapaperi", + "tomato_juice": "Tomaattimehu", + "tomato_paste": "Tomaattimurska", + "tomato_sauce": "Tomaattikastike", + "tomatoes": "Tomaatit", + "tonic_water": "Tonic-vesi", + "toothpaste": "Hammastahna", + "tortellini": "Tortellini", + "tortilla_chips": "Tortillasipsit", + "tuna": "Tonnikala", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki", + "udon_noodles": "Udon-nuudelit", + "uht_milk": "UHT-maito", + "vanilla_sugar": "Vaniljasokeri", + "vegetable_bouillon_cube": "Kasvisliemikuutio", + "vegetable_broth": "Kasvisliemi", + "vegetable_oil": "Kasvisöljy", + "vegetable_onion": "Kasvissipuli", + "vegetables": "Kasvikset", + "vegetarian_cold_cuts": "kasvissyöjä leikkeleitä", + "vinegar": "Etikka", + "vodka": "Vodka", + "washing_powder": "Pesujauhe", + "water": "Vesi", + "water_ice": "Vesijää", + "watermelon": "Vesimeloni", + "wc_cleaner": "WC:n puhdistusaine", + "whipped_cream": "Kermavaahto", + "white_wine": "Valkoviini", + "white_wine_vinegar": "Valkoviinietikka", + "whole_canned_tomatoes": "kokonaiset tomaattisäilykkeet", + "wild_berries": "Metsämarjoja", + "wrapping_paper": "Käärepaperi", + "wraps": "Wrapit", + "yeast": "Hiiva", + "yoghurt": "Jogurtti", + "yogurt": "Jogurtti", + "yum_yum": "Nami nami", + "zewa": "Zewa", + "zinc_cream": "Sinkkivoide", + "zucchini": "Kesäkurpitsa" + } +} diff --git a/backend/templates/l10n/it.json b/backend/templates/l10n/it.json index 1af14e0a..fe85ca9e 100644 --- a/backend/templates/l10n/it.json +++ b/backend/templates/l10n/it.json @@ -32,7 +32,7 @@ "baking_paper": "Carta da forno", "baking_powder": "Lievito in polvere", "baking_soda": "Bicarbonato di sodio", - "baking_yeast": "Lievito", + "baking_yeast": "Lievito da forno", "balsamic_vinegar": "Aceto balsamico", "bananas": "Banane", "basil": "Basilico", @@ -125,14 +125,14 @@ "detergent": "Detergente", "dill": "Aneto", "dishwasher_salt": "Sale per lavastoviglie", - "dishwasher_tabs": "Schede per lavastoviglie", - "disinfection_spray": "Spray di disinfezione", + "dishwasher_tabs": "Pastiglie per lavastoviglie", + "disinfection_spray": "Disinfestante spray", "dried_tomatoes": "Pomodori secchi", "edamame": "Edamame", "eggplant": "Melanzana", "eggs": "Uova", "falafel": "Falafel", - "falafel_powder": "Falafel in polvere", + "falafel_powder": "Preparato per Falafel", "fanta": "Fanta", "feta": "Feta", "ffp2": "FFP2", @@ -140,10 +140,10 @@ "flour": "Farina", "flushing": "Risciacquo", "fresh_chili_pepper": "Peperoncino fresco", - "frozen_berries": "Bacche congelate", - "frozen_fruit": "Frutta congelata", + "frozen_berries": "Bacche surgelate", + "frozen_fruit": "Frutta surgelata", "frozen_pizza": "Pizza surgelata", - "frozen_spinach": "Spinaci congelati", + "frozen_spinach": "Spinaci surgelati", "garam_masala": "Garam Masala", "garbage_bag": "Sacchetto della spazzatura", "garlic": "Aglio", @@ -151,8 +151,8 @@ "garlic_granules": "Aglio in granuli", "gherkins": "Cetriolini", "ginger": "Zenzero", - "glass_noodles": "Tagliatelle di vetro", - "gluten": "Il glutine", + "glass_noodles": "Glass noodles", + "gluten": "Glutine", "gnocchi": "Gnocchi", "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", @@ -161,7 +161,7 @@ "greek_yogurt": "Yogurt greco", "green_asparagus": "Asparagi verdi", "green_chili": "Peperoncino verde", - "green_pesto": "Pesto verde", + "green_pesto": "Pesto alla genovese", "hair_gel": "Gel per capelli", "hair_wax": "Cera per capelli", "handkerchief_box": "Scatola per fazzoletti", diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json index ddad5d3a..d8b7e951 100644 --- a/backend/templates/l10n/pt.json +++ b/backend/templates/l10n/pt.json @@ -64,6 +64,7 @@ "button_cells": "Pilhas de relógio", "börek_cheese": "Queijo Börek", "cake": "Bolo", + "cake_icing": "Cobertura de bolo", "cane_sugar": "Açúcar de Cana", "cannelloni": "Canelones", "canola_oil": "Óleo de Canola", @@ -83,6 +84,7 @@ "chips": "Batata frita de pacote", "chives": "Cebolinho", "chocolate": "Chocolate", + "chocolate_chips": "Pedaços de chocolate", "chopped_tomatoes": "Tomates picados", "ciabatta": "Pão chapata", "cider_vinegar": "Vinagre de Cidra", @@ -90,21 +92,28 @@ "cinnamon": "Canela", "cinnamon_stick": "Pau de canela", "cocktail_sauce": "Molho de coquetel", + "cocktail_tomatoes": "Tomates para cocktails", "coconut_flakes": "Flocos de coco", "coconut_milk": "Leite de coco", "coconut_oil": "Óleo de coco", "colorful_sprinkles": "Pepitas Multicores", "concealer": "Corretor de olheiras", "cookies": "Bolachas", + "coriander": "Coentros", "corn": "Milho", "cornflakes": "Cornflakes", "cornstarch": "Amido de milho", + "cornys": "Cornys", + "cough_drops": "Gotas para a tosse", "couscous": "Couscous", "covid_rapid_test": "Teste rápido COVID", "cow's_milk": "Leite de vaca", "cream": "Natas", "cream_cheese": "Queijo creme", "creamed_spinach": "Esparregado", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Fita crepe", + "crispbread": "Pão estaladiço", "cucumber": "Pepino", "cumin": "Cominhos", "curry_paste": "Massa de caril", @@ -118,22 +127,34 @@ "dishwasher_salt": "Sal para máquina da loiça", "dishwasher_tabs": "Pastilhas para máquina da loiça", "disinfection_spray": "Spray desinfetante", + "dried_tomatoes": "Tomates secos", + "edamame": "Edamame", "eggplant": "Beringela", "eggs": "Ovos", "falafel": "Falafel", + "falafel_powder": "Falafel em pó", "fanta": "Fanta", "feta": "Feta", "ffp2": "FFP2", "fish_sticks": "Douradinhos", "flour": "Farinha", + "flushing": "Lavagem", + "fresh_chili_pepper": "Pimenta fresca", + "frozen_berries": "Bagas congeladas", "frozen_fruit": "Frutas congeladas", "frozen_pizza": "Pizza congelada", "frozen_spinach": "Espinafres congelados", + "garam_masala": "Garam Masala", "garbage_bag": "Saco do lixo", "garlic": "Alho", "garlic_dip": "Molho de alho", + "garlic_granules": "Grânulos de alho", + "gherkins": "Pepinos", "ginger": "Gengibre", + "glass_noodles": "Massa de vidro", + "gluten": "Glúten", "gnocchi": "Gnocchi", + "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", "grapes": "Uvas", @@ -143,19 +164,33 @@ "green_pesto": "Pesto verde", "hair_gel": "Gel para cabelo", "hair_wax": "Cera para pelos", + "handkerchief_box": "Caixa de lenços", + "handkerchiefs": "Lenços de bolso", "haribo": "Haribo", + "harissa": "Harissa", "hazelnuts": "Avelãs", + "head_of_lettuce": "Cabeça de alface", + "herb_baguettes": "Baguetes de ervas", + "herb_cream_cheese": "Queijo creme de ervas", "honey": "Mel", + "honey_wafers": "Bolachas de mel", + "hot_dog_bun": "Pão de cachorro-quente", "ice_cream": "Gelado", "ice_cube": "Cubos de gelo", "iceberg_lettuce": "Alface iceberg", "iced_tea": "Ice tea", "instant_soups": "Sopa instantânea", "jam": "Geleia", + "katjes": "Katjes", "ketchup": "Ketchup", + "kidney_beans": "Feijão vermelho", + "kitchen_roll": "Rolo de cozinha", "kitchen_towels": "Pano de cozinha", + "kohlrabi": "Couve-rábano", "lasagna": "Lasanha", + "lasagna_noodles": "Massa de lasanha", "lasagna_plates": "Placas para lasanha", + "leaf_spinach": "Espinafres de folha", "leek": "Alho francês", "lemon": "Limão", "lemon_juice": "Sumo de limão", @@ -164,72 +199,174 @@ "lentils": "Lentilhas", "lentils_red": "Lentilhas vermelhas", "lettuce": "Alface", + "lillet": "Lillet", "lime": "Lima", "linguine": "Linguine", + "low-fat_curd_cheese": "Requeijão magro", "magnesium": "Magnésio", "mango": "Manga", "margarine": "Margarina", + "marjoram": "Manjerona", "marshmallows": "Marshmallows", + "mask": "Máscara", "mayonnaise": "Maionese", + "meat_substitute_product": "Produto de substituição de carne", "microfiber_cloth": "Pano microfibras", "milk": "Leite", "mint": "Menta", + "mint_candy": "Doces de menta", + "mixed_vegetables": "Legumes mistos", + "mochis": "Mochis", + "mountain_cheese": "Queijo de montanha", "mouth_wash": "Elixir bocal", "mozzarella": "Mozzarella", "muesli": "Muesli", "muesli_bar": "Barra de Muesli", + "mulled_wine": "Vinho quente", "mushrooms": "Cogumelos", "mustard": "Mostarda", + "neutral_oil": "Óleo neutro", "nori_sheets": "Folhas Nori", + "nutmeg": "Noz-moscada", + "oat_milk": "Bebida de aveia", + "oatmeal": "Farinha de aveia", + "oatmeal_cookies": "Bolachas de aveia", + "oatsome": "Aveia", + "obatzda": "Obatzda", "olive_oil": "Azeite", "olives": "Azeitonas", "onion": "Cebola", "orange_juice": "Sumo de laranja", "oranges": "Laranjas", "oregano": "Orégãos", + "organic_lemon": "Limão biológico", + "organic_waste_bags": "Sacos para resíduos orgânicos", "pak_choi": "Pak Choi", "paprika": "Paprica", + "pardina_lentils_dried": "Lentilhas Pardina secas", "parmesan": "Parmesão", "parsley": "Salsa", + "pasta": "Massa", "peach": "Pêssego", "peanut_butter": "Manteiga de amendoim", + "peanut_flips": "Amendoins", + "peanut_oil": "Óleo de amendoim", "peanuts": "Amendoins", "pears": "Peras", "peas": "Ervilhas", + "penne": "Penne", "pepper": "Pimenta", + "pepper_mill": "Moinho de pimenta", + "peppers": "Pimentos", + "persian_rice": "Arroz persa", "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinhões", "pineapple": "Ananás", + "pita_bag": "Saco de pita", "pizza": "Pizza", + "pizza_dough": "Massa de pizza", + "plant_magarine": "Planta Magarine", + "plant_oil": "Óleo vegetal", + "plaster": "Gesso", + "porcini_mushrooms": "Cogumelos Porcini", + "potato_dumpling_dough": "Massa de bolinhos de batata", + "potato_wedges": "Cunhas de batata", + "potatoes": "Batatas", + "potting_soil": "Terra para vasos", + "powder": "Pó", + "powdered_sugar": "Açúcar em pó", + "processed_cheese": "Queijo fundido", + "prosecco": "Prosecco", + "puff_pastry": "Massa folhada", + "pumpkin": "Abóbora", + "pumpkin_seeds": "Sementes de abóbora", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Radicchio", + "radish": "Rabanete", + "ramen": "Ramen", + "rapeseed_oil": "Óleo de colza", + "raspberries": "Framboesas", + "raspberry_syrup": "Xarope de framboesa", + "red_bull": "Red Bull", + "red_chili": "Pimento vermelho", + "red_lentils": "Lentilhas vermelhas", + "red_onions": "Cebolas vermelhas", + "red_pesto": "Pesto vermelho", + "red_wine": "Vinho tinto", + "red_wine_vinegar": "Vinagre de vinho tinto", + "rhubarb": "Ruibarbo", + "ribbon_noodles": "Massa com fita", + "rice": "Arroz", + "rice_cakes": "Bolos de arroz", + "rice_ribbon_noodles": "Massa de arroz com fita", "rice_vinegar": "Vinagre de arroz", "ricotta": "Ricotta", + "rinse_tabs": "Pastilhas de enxaguamento", + "rinsing_agent": "Agente de enxaguamento", "risotto_rice": "Arroz risotto", + "rocket": "Foguetão", + "roll": "Rolo", + "rosemary": "Alecrim", + "saffron_threads": "Fios de açafrão", + "sage": "Sálvia", + "saitan_powder": "Saitan em pó", + "salad_mix": "Mistura para salada", + "salad_seeds_mix": "Mistura de sementes para salada", "salt": "Sal", "salt_mill": "Moinho de sal", + "sambal_oelek": "Sambal oelek", "sauce": "Molho", "sausage": "Salsicha", "sausages": "Salsichas", "savoy_cabbage": "Couve-lombarda", + "scallion": "Cebolinha", + "scattered_cheese": "Queijo espalhado", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "Papas de sêmola", "sesame": "Sésamo", + "sesame_oil": "Óleo de sésamo", "shallot": "Chalota", "shampoo": "Champô", + "shawarma_spice": "Tempero Shawarma", + "shiitake_mushroom": "Cogumelo Shiitake", + "shoe_insoles": "Palmilhas para sapatos", + "shower_gel": "Gel de duche", "shredded_cheese": "Queijo ralado", + "sieved_tomatoes": "Tomate peneirado", "sliced_cheese": "Queijo fatiado", + "smoked_paprika": "Paprika fumada", "smoked_tofu": "Tofu fumado", "snacks": "Snacks", "soap": "Sabonete", "soft_drinks": "Refrigerantes", "softdrinks": "Refrigerantes", + "sour_cream": "Creme de leite", + "sour_cucumbers": "Pepinos azedos", + "soy_hack": "Hack de soja", "soy_sauce": "Molho de soja", + "soy_shred": "Triturador de soja", "spaetzle": "Spaetzle", "spaghetti": "Esparguete", "sparkling_water": "Água com gás", "spelt": "Espelta", "spinach": "Espinafres", + "sponge_cloth": "Pano de esponja", + "sponge_wipes": "Toalhetes de esponja", "sponges": "Esponjas", + "spreading_cream": "Creme para barrar", + "spring_onions": "Cebolinhas", "sprite": "Sprite", + "sprouts": "Rebentos", + "sriracha": "Sriracha", + "strained_tomatoes": "Tomates coados", "sugar": "Açúcar", + "summer_roll_paper": "Papel em rolo para o verão", "sunflower_seeds": "Sementes de girassol", "sushi_rice": "Arroz de sushi", + "swabian_ravioli": "Ravioli da Suábia", "sweet_potato": "Batata doce", "sweet_potatoes": "Batatas doce", "table_salt": "Sal de mesa", @@ -243,20 +380,31 @@ "toast": "Tostas", "tofu": "Tofu", "toilet_paper": "Papel Higiénico", + "tomato_juice": "Sumo de tomate", "tomato_paste": "Polpa de tomate", + "tomato_sauce": "Molho de tomate", "tomatoes": "Tomate", "tonic_water": "Água tónica", "toothpaste": "Pasta de dentes", "tortellini": "Tortellini", + "tortilla_chips": "Batatas fritas de tortilha", "tuna": "Atum", + "turmeric": "Açafrão-da-terra", "tzatziki": "Tzatziki", + "udon_noodles": "Macarrão Udon", "uht_milk": "Leite UHT", "vanilla_sugar": "Açúcar baunilhado", + "vegetable_bouillon_cube": "Cubo de caldo de legumes", + "vegetable_broth": "Caldo de legumes", "vegetable_oil": "Óleo vegetal", + "vegetable_onion": "Cebola vegetal", "vegetables": "Legumes", + "vegetarian_cold_cuts": "charcutaria vegetariana", "vinegar": "Vinagre", "vodka": "Vodka", + "washing_powder": "Pó de lavagem", "water": "Água", + "water_ice": "Água gelada", "watermelon": "Melancia", "wc_cleaner": "Detergente de casa de banho", "whipped_cream": "Nata batida", @@ -265,11 +413,13 @@ "whole_canned_tomatoes": "Tomates inteiros enlatados", "wild_berries": "Frutos silvestres", "wrapping_paper": "Papel de embrulho", + "wraps": "Embrulhos", "yeast": "Fermento", "yoghurt": "Iogurte", "yogurt": "Iogurte", "yum_yum": "Yum Yum", "zewa": "Zewa", + "zinc_cream": "Creme de zinco", "zucchini": "Courgette" } } From f3554d2a60daa0194bef10fc9581c2eb5dbe3ac4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 2 Jul 2023 18:07:53 +0200 Subject: [PATCH 345/496] feat: add Greek & Finnish --- backend/app/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/config.py b/backend/app/config.py index 4b621345..57b34f04 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -42,7 +42,9 @@ 'en': 'English', 'da': 'Dansk', 'de': 'Deutsch', + 'el': 'Ελληνικά', 'es': 'Español', + 'fi': 'Suomi', 'fr': 'Français', 'id': 'Bahasa Indonesia', 'it': 'Italiano', From 31e1f49e095a110e3f63cc336e059ab796ad9971 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 2 Jul 2023 18:45:11 +0200 Subject: [PATCH 346/496] Prepare release 69 --- backend/app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 57b34f04..8e1efc27 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 68 +BACKEND_VERSION = 69 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) @@ -113,7 +113,7 @@ def add_cors_headers(response): @app.errorhandler(Exception) -def unhandled_exception(e): +def unhandled_exception(e: Exception): if type(e) is NotFoundRequest: app.logger.info(e) return "Requested resource not found", 404 From 9602b216ae879dacc9e4e29839bc3a627edc4740 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 3 Jul 2023 00:23:38 +0200 Subject: [PATCH 347/496] fix: container --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index d07c6b4f..e1f4f5b4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,7 +25,7 @@ FROM python:3.11-slim as runner RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - libxml2 curl \ + libxml2 libpcre3 curl \ && rm -rf /var/lib/apt/lists/* # Use virtual enviroment From 58a13d5db4d893a4827686f076574d193762d7c2 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 3 Jul 2023 00:25:20 +0200 Subject: [PATCH 348/496] Prepare release 70 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 8e1efc27..1c903862 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 69 +BACKEND_VERSION = 70 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 08385d2592e668a8af787213bdc8d1159a9c08a0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 3 Jul 2023 01:15:34 +0200 Subject: [PATCH 349/496] fix: PostgreSQL query errors --- backend/app/controller/analytics/analytics_controller.py | 3 ++- backend/app/controller/expense/expense_controller.py | 2 +- backend/app/controller/household/household_controller.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/analytics/analytics_controller.py b/backend/app/controller/analytics/analytics_controller.py index 2f480f31..2f1c672c 100644 --- a/backend/app/controller/analytics/analytics_controller.py +++ b/backend/app/controller/analytics/analytics_controller.py @@ -2,6 +2,7 @@ from app.helpers import server_admin_required from app.models import User, Token, Household from app.config import UPLOAD_FOLDER +from app import db from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required @@ -16,7 +17,7 @@ def getBaseAnalytics(): statvfs = os.statvfs(UPLOAD_FOLDER) return jsonify({ "total_users": User.count(), - "active_users": Token.query.filter(Token.type == 'refresh').group_by(Token.user_id).count(), + "active_users": db.session.query(Token.user_id).filter(Token.type == 'refresh').group_by(Token.user_id).count(), "total_households": Household.count(), "free_storage": statvfs.f_frsize * statvfs.f_bavail, "available_storage": statvfs.f_frsize * statvfs.f_blocks, diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index df52b73a..32ea91f1 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -196,7 +196,7 @@ def getExpenseOverview(args, household_id): factor = 1 query = Expense.query\ .filter(Expense.household_id == household_id)\ - .group_by(Expense.category_id)\ + .group_by(Expense.category_id, ExpenseCategory.id)\ .join(Expense.category, isouter=True) if ('view' in args and args['view'] == 1): diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index be05e412..0cb7e912 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -52,7 +52,6 @@ def addHousehold(args): member.save() if 'member' in args: - print(args['member']) for uid in args['member']: if uid == current_user.id: continue if not User.find_by_id(uid): continue From 01fe0fae9080c50ced724fd2607729b2fd9d1c9b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 3 Jul 2023 01:16:06 +0200 Subject: [PATCH 350/496] Prepare release 71 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 1c903862..761ceb8e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 70 +BACKEND_VERSION = 71 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From f100ab13c2c2a94fc4e8bec217859eb537e254a9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 4 Jul 2023 08:24:31 +0200 Subject: [PATCH 351/496] fix: 193 database corruption (TomBursch/kitchenowl-backend#42) --- backend/migrations/versions/c058421705ec_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/migrations/versions/c058421705ec_.py b/backend/migrations/versions/c058421705ec_.py index d015ed71..cc77a14a 100644 --- a/backend/migrations/versions/c058421705ec_.py +++ b/backend/migrations/versions/c058421705ec_.py @@ -64,7 +64,7 @@ def upgrade(): try: filesInUploadFolder = [f for f in listdir(UPLOAD_FOLDER) if isfile(join(UPLOAD_FOLDER, f))] - files = [File(filename=f) for f in filesInUploadFolder] + files = [File(filename=f) for f in filesInUploadFolder if not File.query.filter(File.filename == f).first()] session.bulk_save_objects(files) session.commit() From 1169727314466231c010069a7bd7d0fb579ecadc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 4 Jul 2023 08:25:24 +0200 Subject: [PATCH 352/496] Prepare release 72 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 761ceb8e..f95b98a2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 71 +BACKEND_VERSION = 72 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From bf32fdc40e108602ecc1c0d737cb6a5faddad9f7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 10 Jul 2023 18:05:40 +0200 Subject: [PATCH 353/496] fix: 193 (TomBursch/kitchenowl-backend#43) --- backend/migrations/versions/c058421705ec_.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/migrations/versions/c058421705ec_.py b/backend/migrations/versions/c058421705ec_.py index cc77a14a..406efe30 100644 --- a/backend/migrations/versions/c058421705ec_.py +++ b/backend/migrations/versions/c058421705ec_.py @@ -27,7 +27,7 @@ class File(DeclarativeBase): filename = sa.Column(sa.String, primary_key=True) created_at = sa.Column(sa.DateTime, nullable=False, default=datetime.utcnow) updated_at = sa.Column(sa.DateTime, nullable=False, default=datetime.utcnow) - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) + created_by = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) class Recipe(DeclarativeBase): __tablename__ = 'recipe' @@ -64,7 +64,7 @@ def upgrade(): try: filesInUploadFolder = [f for f in listdir(UPLOAD_FOLDER) if isfile(join(UPLOAD_FOLDER, f))] - files = [File(filename=f) for f in filesInUploadFolder if not File.query.filter(File.filename == f).first()] + files = [File(filename=f) for f in filesInUploadFolder if not session.query(File.filename).filter(File.filename == f).first()] session.bulk_save_objects(files) session.commit() From e62b635096ca5339cb5a485dc3dda85cbb1ff584 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 10 Jul 2023 18:10:22 +0200 Subject: [PATCH 354/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#41) * Added translation using Weblate (Chinese (Simplified)) * Translated using Weblate (Chinese (Simplified)) Currently translated at 12.1% (51 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/zh_Hans/ * Added translation using Weblate (Turkish) * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/zh_Hans/ * Translated using Weblate (Turkish) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/tr/ * Translated using Weblate (Turkish) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/tr/ --------- Co-authored-by: icyleaf Co-authored-by: Kabraxist Co-authored-by: Tom Bursch --- backend/templates/l10n/tr.json | 425 ++++++++++++++++++++++++++++ backend/templates/l10n/zh_Hans.json | 425 ++++++++++++++++++++++++++++ 2 files changed, 850 insertions(+) create mode 100644 backend/templates/l10n/tr.json create mode 100644 backend/templates/l10n/zh_Hans.json diff --git a/backend/templates/l10n/tr.json b/backend/templates/l10n/tr.json new file mode 100644 index 00000000..7372c39c --- /dev/null +++ b/backend/templates/l10n/tr.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 Unlu Mamüller", + "canned": "🥫 Konserve", + "dairy": "🥛 Süt Ürünleri", + "drinks": "🍹 İçecek", + "freezer": "❄️ Dondurulmuş", + "fruits_vegetables": "🥬 Meyve ve Sebzeler", + "grain": "🥟 Tahıl Ürünleri", + "hygiene": "🚽 Temizlik", + "refrigerated": "💧 Soğuk Muhafaza", + "snacks": "🥜 Atıştırmalıklar" + }, + "items": { + "aioli": "Sarımsaklı Mayonez", + "amaretto": "Amaretto", + "apple": "Elma", + "apple_pulp": "Posalı Elma", + "applesauce": "Elma püresi", + "apérol": "Aperol", + "arugula": "Roka", + "asian_egg_noodles": "Yumurtalı Noodle", + "asparagus": "Kuşkonmaz", + "aspirin": "Aspirin", + "avocado": "Avokado", + "baby_spinach": "Bebek Ispanak", + "bacon": "Pastırma", + "baguette": "Baget Ekmek", + "bakefish": "Fırın Balığı", + "baking_cocoa": "Pişirmelik Kakao", + "baking_mix": "Pişirme Karışımı", + "baking_paper": "Pişirme Kağıdı", + "baking_powder": "Kabartma Tozu", + "baking_soda": "Karbonat", + "baking_yeast": "Hamur Mayası", + "balsamic_vinegar": "Balzamik Sirke", + "bananas": "Muz", + "basil": "Reyhan", + "basmati_rice": "Basmati Pirinci", + "bathroom_cleaner": "Banyo Temizleyici", + "batteries": "Pil", + "bay_leaf": "Defne", + "beans": "Fasülye", + "beer": "Bira", + "beet": "Pancar", + "beetroot": "Kırmızı Pancar", + "birthday_card": "Doğumgünü Kartı", + "black_beans": "Siyah Fasülye", + "bockwurst": "Bockwurst Sosis", + "bodywash": "Vücut Şampuanı", + "bread": "Ekmek", + "breadcrumbs": "Ekmek Kırıntısı", + "broccoli": "Brokoli", + "brown_sugar": "Esmer Şeker", + "brussels_sprouts": "Brüksel Lahanası", + "buffalo_mozzarella": "Buffalo Mozarella", + "buko": "Buko", + "buns": "Poğaça", + "burger_buns": "Burger Ekmeği", + "burger_patties": "Burger Köftesi", + "burger_sauces": "Burger Sosu", + "butter": "Tereyağı", + "butter_cookies": "Tereyağlı Kurabiye", + "button_cells": "Düğme Pil", + "börek_cheese": "Böreklik Peynir", + "cake": "Pasta", + "cake_icing": "Pasta Kreması", + "cane_sugar": "Şekerkamışı Şekeri", + "cannelloni": "Cannelloni Makarna", + "canola_oil": "Kanola Yağı", + "cardamom": "Kakule", + "carrots": "Havuç", + "cashews": "Kaju", + "cat_treats": "Kedi Ödül Maması", + "cauliflower": "Karnabahar", + "celeriac": "Kereviz Kökü", + "celery": "Kereviz", + "cereal_bar": "Tahıl Bar", + "cheddar": "Çedar Peyniri", + "cheese": "Peynir", + "cherry_tomatoes": "Kiraz Domates", + "chickpeas": "Nohut", + "chili_oil": "Biberli Yağ", + "chips": "Cips", + "chives": "Frenksoğanı", + "chocolate": "Çikolata", + "chocolate_chips": "Damla Çikolata", + "chopped_tomatoes": "Doğranmış Domates", + "ciabatta": "Ciabatta Ekmeği", + "cider_vinegar": "Elma Sirkesi", + "cilantro": "Kişniş", + "cinnamon": "Tarçın", + "cinnamon_stick": "Tarçın Çubuğu", + "cocktail_sauce": "Kokteyl sosu", + "cocktail_tomatoes": "Kokteyl Domates", + "coconut_flakes": "Hindistancevizi Parçası", + "coconut_milk": "Hindistancevizi Sütü", + "coconut_oil": "Hindistancevizi Yağı", + "colorful_sprinkles": "Pasta Süsü", + "concealer": "Kapatıcı", + "cookies": "Kurabiye", + "coriander": "Aşotu", + "corn": "Mısır", + "cornflakes": "Mısır gevreği", + "cornstarch": "Mısır Nişastası", + "cornys": "Ballı Tahıl", + "cough_drops": "Boğaz Pastili", + "couscous": "Kuskus", + "covid_rapid_test": "COVID hızlı testi", + "cow's_milk": "İnek Sütü", + "cream": "Krema", + "cream_cheese": "Krem Peynir", + "creamed_spinach": "Kremalı Ispanak", + "creme_fraiche": "Taze Krema", + "crepe_tape": "Maskeleme Bandı", + "crispbread": "Kraker", + "cucumber": "Hıyar", + "cumin": "Kimyon", + "curry_paste": "Köri Ezmesi", + "curry_powder": "Köri Tozu", + "curry_sauce": "Köri Sosu", + "dates": "Hurma", + "dental_floss": "Diş İpi", + "deodorant": "Deodorant", + "detergent": "Deterjan", + "dill": "Dereotu", + "dishwasher_salt": "Bulaşık Makinesi Tuzu", + "dishwasher_tabs": "Bulaşık Tableti", + "disinfection_spray": "Dezenfektan Sprey", + "dried_tomatoes": "Kurutulmuş Domates", + "edamame": "Edamame", + "eggplant": "Patlıcan", + "eggs": "Yumurta", + "falafel": "Falafel", + "falafel_powder": "Falafel Unu", + "fanta": "Sarı Gazoz", + "feta": "Feta Peyniri", + "ffp2": "FFP2", + "fish_sticks": "Balık Kroket", + "flour": "Un", + "flushing": "Lavaç", + "fresh_chili_pepper": "Taze Kırmızıbiber", + "frozen_berries": "Dondurulmuş Orman Meyvesi", + "frozen_fruit": "Dondurulmuş Meyve", + "frozen_pizza": "Dondurulmuş Pizza", + "frozen_spinach": "Dondurulmuş Ispanak", + "garam_masala": "Garam Masala", + "garbage_bag": "Çöp Torbası", + "garlic": "Sarımsak", + "garlic_dip": "Sarımsaklı Dip Sos", + "garlic_granules": "Sarımsak Granül", + "gherkins": "Kornişon", + "ginger": "Zencefil", + "glass_noodles": "Fasülye Şehriyesi", + "gluten": "Gluten", + "gnocchi": "Niyokki", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola Peyniri", + "gouda": "Gouda Peyniri", + "grapes": "Üzüm", + "greek_yogurt": "Yunan Yoğurdu", + "green_asparagus": "Yeşil Kuşkonmaz", + "green_chili": "Kıl Biber", + "green_pesto": "Yeşil Pesto", + "hair_gel": "Saç Jölesi", + "hair_wax": "Saç Sabitleyici", + "handkerchief_box": "Mendil Kutusu", + "handkerchiefs": "Mendil", + "haribo": "Jelibon", + "harissa": "Harissa Sosu", + "hazelnuts": "Fındık", + "head_of_lettuce": "Marul Demeti", + "herb_baguettes": "Otlu Ekmek", + "herb_cream_cheese": "Otlu krem peynir", + "honey": "Bal", + "honey_wafers": "Ballı Gofret", + "hot_dog_bun": "Sandviç Ekmeği", + "ice_cream": "Dondurma", + "ice_cube": "Buz Küpü", + "iceberg_lettuce": "Atom Marul", + "iced_tea": "Buzlu Çay", + "instant_soups": "Çabuk Çorba", + "jam": "Reçel", + "katjes": "Katjes Jelibon", + "ketchup": "Ketçap", + "kidney_beans": "Barbunya", + "kitchen_roll": "Kağıt Havlu", + "kitchen_towels": "Mutfak Havlusu", + "kohlrabi": "Alabaş", + "lasagna": "Lazanya", + "lasagna_noodles": "Lazanya Makarnası", + "lasagna_plates": "Lazanya Yaprağı", + "leaf_spinach": "Yaprak Ispanak", + "leek": "Pırasa", + "lemon": "Limon", + "lemon_juice": "Limon Suyu", + "lemonade": "Limonata", + "lemongrass": "Limon Otu", + "lentils": "Mercimek", + "lentils_red": "Kırmızı mercimek", + "lettuce": "Marul", + "lillet": "Lillet", + "lime": "Misket Limonu", + "linguine": "Uzun Erişte", + "low-fat_curd_cheese": "Böreklik Lor", + "magnesium": "Magnezyum", + "mango": "Mango", + "margarine": "Margarin", + "marjoram": "Mercanköşk", + "marshmallows": "Marşmelov", + "mask": "Maske", + "mayonnaise": "Mayonez", + "meat_substitute_product": "Et İkamesi", + "microfiber_cloth": "Mikrofiber Bez", + "milk": "Süt", + "mint": "Nane", + "mint_candy": "Mint Şeker", + "mixed_vegetables": "Karışık Sebze", + "mochis": "Mochis", + "mountain_cheese": "Dağ Peyniri", + "mouth_wash": "Ağız Çalkalama Suyu", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müsli Bar", + "mulled_wine": "Sıcak şarap", + "mushrooms": "Mantar", + "mustard": "Hardal", + "neutral_oil": "Kızartma Yağı", + "nori_sheets": "Nori Yaprağı", + "nutmeg": "Muskat", + "oat_milk": "Yulaf Sütü", + "oatmeal": "Yulaf", + "oatmeal_cookies": "Yulaf Kurabiyesi", + "oatsome": "Yulafsütü", + "obatzda": "Obatzda", + "olive_oil": "Zeytinyağı", + "olives": "Zeytin", + "onion": "Soğan", + "orange_juice": "Portakal suyu", + "oranges": "Portakal", + "oregano": "Güveyotu", + "organic_lemon": "Organik limon", + "organic_waste_bags": "Organik Çöp Torbası", + "pak_choi": "Pak Çoi", + "paprika": "Kırmızı biber", + "pardina_lentils_dried": "İspanyol Mercimeği", + "parmesan": "Parmesan", + "parsley": "Maydanoz", + "pasta": "Makarna", + "peach": "Şeftali", + "peanut_butter": "Fıstık ezmesi", + "peanut_flips": "Fıstıklı Cips", + "peanut_oil": "Yerfıstığı Yağı", + "peanuts": "Yerfıstığı", + "pears": "Ayva", + "peas": "Bezelye", + "penne": "Düdük Makarna", + "pepper": "Biber", + "pepper_mill": "Biber Öğütücü", + "peppers": "Biber", + "persian_rice": "Acem Pirinci", + "pesto": "Pesto Sos", + "pilsner": "Pilsen Birası", + "pine_nuts": "Çam Fıstığı", + "pineapple": "Ananas", + "pita_bag": "Pita Poşedi", + "pizza": "Pizza", + "pizza_dough": "Pizza hamuru", + "plant_magarine": "Bitkisel Margarin", + "plant_oil": "Bitkisel Yağ", + "plaster": "Alçı", + "porcini_mushrooms": "Porcini Mantarı", + "potato_dumpling_dough": "Pişi Hamuru", + "potato_wedges": "Patates Dilimi", + "potatoes": "Patates", + "potting_soil": "Saksı toprağı", + "powder": "Pudra", + "powdered_sugar": "Pudra şekeri", + "processed_cheese": "İşlenmiş peynir", + "prosecco": "Köpüklü Şarap", + "puff_pastry": "Puf Börek", + "pumpkin": "Balkabağı", + "pumpkin_seeds": "Kabak çekirdeği", + "quark": "Quark", + "quinoa": "Kinoa", + "radicchio": "Kırmızı Hindiba", + "radish": "Turp", + "ramen": "Ramen", + "rapeseed_oil": "Kolza Yağı", + "raspberries": "Ahududu", + "raspberry_syrup": "Ahududu Şurubu", + "red_bull": "Red Bull", + "red_chili": "Kırmızı acı biber", + "red_lentils": "Kırmızı mercimek", + "red_onions": "Mor Soğan", + "red_pesto": "Kırmızı Pesto", + "red_wine": "Kırmızı Şarap", + "red_wine_vinegar": "Kırmızı şarap sirkesi", + "rhubarb": "Işgın", + "ribbon_noodles": "Fiyonk Noodle", + "rice": "Pirinç", + "rice_cakes": "Pirinç Keki", + "rice_ribbon_noodles": "Pirinç Fiyonk Noodle", + "rice_vinegar": "Pirinç Sirkesi", + "ricotta": "Ricotta Peyniri", + "rinse_tabs": "Durulama Tableti", + "rinsing_agent": "Durulama Suyu", + "risotto_rice": "Risotto Pirinci", + "rocket": "Roka", + "roll": "Rulo", + "rosemary": "Biberiye", + "saffron_threads": "Safran Çubuğu", + "sage": "Adaçayı", + "saitan_powder": "Seitan Tozu", + "salad_mix": "Salata Karışımı", + "salad_seeds_mix": "Tohumlu Salata Karışımı", + "salt": "Tuz", + "salt_mill": "Tuz Öğütücü", + "sambal_oelek": "Sambal Oelek", + "sauce": "Sos", + "sausage": "Sosis", + "sausages": "Sosis", + "savoy_cabbage": "Karalahana", + "scallion": "Taze Soğan", + "scattered_cheese": "Peynir Tozu", + "schlemmerfilet": "Schlemmerfilet", + "schupfnudeln": "Schupfnudeln", + "semolina_porridge": "İrmik Lapası", + "sesame": "Susam", + "sesame_oil": "Susam yağı", + "shallot": "Arpacık soğanı", + "shampoo": "Şampuan", + "shawarma_spice": "Şavarma Baharatı", + "shiitake_mushroom": "Shitakee Mantarı", + "shoe_insoles": "Ayakkabı Tabanlığı", + "shower_gel": "Duş jeli", + "shredded_cheese": "Rendelenmiş peynir", + "sieved_tomatoes": "Domates Tozu", + "sliced_cheese": "Dilimlenmiş peynir", + "smoked_paprika": "Füme Paprika", + "smoked_tofu": "Füme tofu", + "snacks": "Atıştırmalık", + "soap": "Sabun", + "soft_drinks": "Meşrubat", + "softdrinks": "Meşrubat", + "sour_cream": "Ekşi Krema", + "sour_cucumbers": "Kornişon Turşu", + "soy_hack": "Soya", + "soy_sauce": "Soya Sosu", + "soy_shred": "Soya Dilimi", + "spaetzle": "Spaetzle", + "spaghetti": "Spagetti", + "sparkling_water": "Soda", + "spelt": "Kavuzlu Buğday", + "spinach": "Ispanak", + "sponge_cloth": "Sünger bez", + "sponge_wipes": "Sarı Bez", + "sponges": "Sünger", + "spreading_cream": "Sürülebilir Peynir", + "spring_onions": "Taze soğan", + "sprite": "Gazoz", + "sprouts": "Filiz", + "sriracha": "Şiraka Acı Sos", + "strained_tomatoes": "Süzme Domates", + "sugar": "Şeker", + "summer_roll_paper": "Summer Lavaş", + "sunflower_seeds": "Ay Çekirdeği", + "sushi_rice": "Suşi pirinci", + "swabian_ravioli": "Svabya mantısı", + "sweet_potato": "Tatlı Patates", + "sweet_potatoes": "Tatlı patates", + "table_salt": "Softa Tuzu", + "tagliatelle": "Tagliatelle", + "tahini": "Tahin", + "tangerines": "Mandalina", + "tape": "Bant", + "tea": "Çay", + "teriyaki_sauce": "Teriyaki sosu", + "thyme": "Kekik", + "toast": "Tost", + "tofu": "Tofu", + "toilet_paper": "Tuvalet Kağıdı", + "tomato_juice": "Domates Suyu", + "tomato_paste": "Salça", + "tomato_sauce": "Domates Sosu", + "tomatoes": "Domates", + "tonic_water": "Tonik", + "toothpaste": "Diş Macunu", + "tortellini": "Tortellini", + "tortilla_chips": "Toriccla Cipsi", + "tuna": "Ton Balığı", + "turmeric": "Zerdeçal", + "tzatziki": "Cacık", + "udon_noodles": "Udon Eriştesi", + "uht_milk": "UHT süt", + "vanilla_sugar": "Vanilya şekeri", + "vegetable_bouillon_cube": "Sebze bulyon", + "vegetable_broth": "Sebze Bulyon", + "vegetable_oil": "Bitkisel yağ", + "vegetable_onion": "Sebze Soğanı", + "vegetables": "Sebze", + "vegetarian_cold_cuts": "Vejetaryen Yemek", + "vinegar": "Sirke", + "vodka": "Votka", + "washing_powder": "Toz Deterjan", + "water": "Su", + "water_ice": "Dondurulmuş Tatlı", + "watermelon": "Karpuz", + "wc_cleaner": "Tuvalet Temizleyici", + "whipped_cream": "Krem Şanti", + "white_wine": "Beyaz şarap", + "white_wine_vinegar": "Beyaz Şarap Sirkesi", + "whole_canned_tomatoes": "Konserve Bütün Domates", + "wild_berries": "Yabani Yemiş", + "wrapping_paper": "Ambalaj kağıdı", + "wraps": "Dürüm", + "yeast": "Maya", + "yoghurt": "Yoğurt", + "yogurt": "Yoğurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Çinko Kremi", + "zucchini": "Kabak" + } +} diff --git a/backend/templates/l10n/zh_Hans.json b/backend/templates/l10n/zh_Hans.json new file mode 100644 index 00000000..57a8236e --- /dev/null +++ b/backend/templates/l10n/zh_Hans.json @@ -0,0 +1,425 @@ +{ + "categories": { + "bread": "🍞 面包商品", + "canned": "🥫 罐头食品", + "dairy": "🥛 牛乳", + "drinks": "🍹 饮品", + "freezer": "❄️ 冷冻商品", + "fruits_vegetables": "🥬 水果和蔬菜", + "grain": "🥟 米面商品", + "hygiene": "🚽 卫生用品", + "refrigerated": "💧 冷藏商品", + "snacks": "🥜 零食" + }, + "items": { + "aioli": "大蒜蛋黄酱", + "amaretto": "杏仁酒", + "apple": "苹果", + "apple_pulp": "苹果酱", + "applesauce": "苹果酱", + "apérol": "Apérol 利口酒", + "arugula": "芝麻菜", + "asian_egg_noodles": "亚洲鸡蛋面", + "asparagus": "芦笋", + "aspirin": "阿司匹林", + "avocado": "牛油果", + "baby_spinach": "嫩叶菠菜", + "bacon": "培根", + "baguette": "法棍面包", + "bakefish": "烤鱼", + "baking_cocoa": "烘焙可可粉", + "baking_mix": "蛋糕粉", + "baking_paper": "蛋糕纸", + "baking_powder": "泡打粉", + "baking_soda": "小苏打", + "baking_yeast": "烘培酵母", + "balsamic_vinegar": "意大利香醋", + "bananas": "香蕉", + "basil": "罗勒", + "basmati_rice": "印度香米", + "bathroom_cleaner": "卫生间清洁用品", + "batteries": "电池", + "bay_leaf": "香叶", + "beans": "豆", + "beer": "啤酒", + "beet": "甜菜", + "beetroot": "甜根菜", + "birthday_card": "生日贺卡", + "black_beans": "黑豆", + "bockwurst": "烟熏香肠", + "bodywash": "沐浴液", + "bread": "面包", + "breadcrumbs": "面包屑", + "broccoli": "西兰花", + "brown_sugar": "红糖", + "brussels_sprouts": "抱子甘蓝", + "buffalo_mozzarella": "马苏里拉奶酪", + "buko": "Buko", + "buns": "馒头", + "burger_buns": "汉堡包", + "burger_patties": "汉堡馅饼", + "burger_sauces": "汉堡酱汁", + "butter": "黄油", + "butter_cookies": "黄油饼干", + "button_cells": "纽扣电池", + "börek_cheese": "Börek奶酪", + "cake": "蛋糕", + "cake_icing": "蛋糕糖衣", + "cane_sugar": "蔗糖", + "cannelloni": "长面条", + "canola_oil": "菜籽油", + "cardamom": "小豆蔻", + "carrots": "胡萝卜", + "cashews": "腰果", + "cat_treats": "猫咪的食物", + "cauliflower": "花椰菜", + "celeriac": "芹菜", + "celery": "芹菜", + "cereal_bar": "谷物棒", + "cheddar": "切达奶酪", + "cheese": "奶酪", + "cherry_tomatoes": "樱桃西红柿", + "chickpeas": "鹰嘴豆", + "chili_oil": "辣椒油", + "chips": "薯片", + "chives": "韭菜", + "chocolate": "巧克力", + "chocolate_chips": "巧克力片", + "chopped_tomatoes": "切碎的西红柿", + "ciabatta": "玉米饼(Ciabatta", + "cider_vinegar": "苹果醋", + "cilantro": "芫荽", + "cinnamon": "肉桂", + "cinnamon_stick": "肉桂棒", + "cocktail_sauce": "鸡尾酒酱", + "cocktail_tomatoes": "鸡尾酒西红柿", + "coconut_flakes": "椰子片", + "coconut_milk": "椰子汁", + "coconut_oil": "椰子油", + "colorful_sprinkles": "五颜六色的喷洒物", + "concealer": "遮瑕膏", + "cookies": "饼干", + "coriander": "芫荽", + "corn": "玉米", + "cornflakes": "玉米片", + "cornstarch": "玉米淀粉", + "cornys": "科尼人", + "cough_drops": "咳嗽滴剂", + "couscous": "库斯库斯", + "covid_rapid_test": "COVID快速检测", + "cow's_milk": "牛奶", + "cream": "奶油", + "cream_cheese": "奶油奶酪", + "creamed_spinach": "奶油菠菜", + "creme_fraiche": "奶油蛋糕", + "crepe_tape": "绉绸带", + "crispbread": "脆皮面包", + "cucumber": "黄瓜", + "cumin": "孜然", + "curry_paste": "咖喱酱", + "curry_powder": "咖喱粉", + "curry_sauce": "咖喱酱", + "dates": "日期", + "dental_floss": "牙线", + "deodorant": "除臭剂", + "detergent": "洗涤剂", + "dill": "莳萝", + "dishwasher_salt": "洗碗机用盐", + "dishwasher_tabs": "洗碗机标签", + "disinfection_spray": "消毒喷雾", + "dried_tomatoes": "西红柿干", + "edamame": "毛豆", + "eggplant": "茄子", + "eggs": "鸡蛋", + "falafel": "法拉斐尔", + "falafel_powder": "法拉斐尔粉", + "fanta": "芬达", + "feta": "飞达", + "ffp2": "FFP2", + "fish_sticks": "鱼条", + "flour": "面粉", + "flushing": "法拉盛", + "fresh_chili_pepper": "新鲜辣椒", + "frozen_berries": "冰冻浆果", + "frozen_fruit": "冷冻水果", + "frozen_pizza": "冷冻比萨饼", + "frozen_spinach": "冷冻菠菜", + "garam_masala": "嘎拉玛沙拉", + "garbage_bag": "垃圾袋", + "garlic": "大蒜", + "garlic_dip": "大蒜蘸酱", + "garlic_granules": "大蒜颗粒", + "gherkins": "小黄瓜", + "ginger": "姜子牙", + "glass_noodles": "玻璃面条", + "gluten": "麸皮", + "gnocchi": "饺子", + "gochujang": "五味子", + "gorgonzola": "戈尔贡佐拉奶酪", + "gouda": "高达", + "grapes": "葡萄", + "greek_yogurt": "希腊酸奶", + "green_asparagus": "绿芦笋", + "green_chili": "绿辣椒", + "green_pesto": "绿酱", + "hair_gel": "发胶", + "hair_wax": "发蜡", + "handkerchief_box": "手帕盒", + "handkerchiefs": "手帕", + "haribo": "哈里博", + "harissa": "哈里萨", + "hazelnuts": "榛子", + "head_of_lettuce": "生菜头", + "herb_baguettes": "草本法棍", + "herb_cream_cheese": "草本奶油干酪", + "honey": "蜂蜜", + "honey_wafers": "蜂蜜威化饼", + "hot_dog_bun": "热狗包", + "ice_cream": "冰淇淋", + "ice_cube": "冰块", + "iceberg_lettuce": "冰山生菜", + "iced_tea": "冰茶", + "instant_soups": "速溶汤", + "jam": "果酱", + "katjes": "卡捷斯", + "ketchup": "番茄酱", + "kidney_beans": "肾豆", + "kitchen_roll": "厨房卷", + "kitchen_towels": "厨房毛巾", + "kohlrabi": "高丽菜", + "lasagna": "千层饼", + "lasagna_noodles": "宽面条", + "lasagna_plates": "宽面条盘", + "leaf_spinach": "菠菜叶", + "leek": "韭菜", + "lemon": "柠檬", + "lemon_juice": "柠檬汁", + "lemonade": "柠檬水", + "lemongrass": "柠檬草", + "lentils": "扁豆", + "lentils_red": "红扁豆", + "lettuce": "莴苣", + "lillet": "利莱", + "lime": "石灰", + "linguine": "意大利面条", + "low-fat_curd_cheese": "低脂凝乳干酪", + "magnesium": "镁", + "mango": "芒果", + "margarine": "人造黄油", + "marjoram": "马兰花", + "marshmallows": "棉花糖", + "mask": "面罩", + "mayonnaise": "蛋黄酱", + "meat_substitute_product": "肉类替代产品", + "microfiber_cloth": "超细纤维布", + "milk": "牛奶", + "mint": "薄荷糖", + "mint_candy": "薄荷糖", + "mixed_vegetables": "混合蔬菜", + "mochis": "莫奇斯", + "mountain_cheese": "山地奶酪", + "mouth_wash": "漱口水", + "mozzarella": "莫扎里拉奶酪", + "muesli": "麦片", + "muesli_bar": "麦片吧", + "mulled_wine": "闷酒", + "mushrooms": "蘑菇", + "mustard": "芥末酱", + "neutral_oil": "中性油", + "nori_sheets": "紫菜片", + "nutmeg": "肉豆蔻", + "oat_milk": "燕麦饮料", + "oatmeal": "燕麦片", + "oatmeal_cookies": "燕麦饼干", + "oatsome": "燕麦", + "obatzda": "Obatzda", + "olive_oil": "橄榄油", + "olives": "橄榄", + "onion": "洋葱", + "orange_juice": "橙汁", + "oranges": "橙子", + "oregano": "牛至", + "organic_lemon": "有机柠檬", + "organic_waste_bags": "有机废物袋", + "pak_choi": "白菜", + "paprika": "红辣椒", + "pardina_lentils_dried": "帕迪纳小扁豆干", + "parmesan": "帕玛森", + "parsley": "欧芹", + "pasta": "面食", + "peach": "桃子", + "peanut_butter": "花生酱", + "peanut_flips": "花生翻转", + "peanut_oil": "花生油", + "peanuts": "花生", + "pears": "梨子", + "peas": "豌豆", + "penne": "笔筒", + "pepper": "胡椒粉", + "pepper_mill": "胡椒粉碎机", + "peppers": "辣椒", + "persian_rice": "波斯大米", + "pesto": "香蒜酱", + "pilsner": "比尔森啤酒", + "pine_nuts": "松子", + "pineapple": "菠萝", + "pita_bag": "皮塔袋", + "pizza": "匹萨", + "pizza_dough": "披萨面团", + "plant_magarine": "植物人马加里", + "plant_oil": "植物油", + "plaster": "石膏", + "porcini_mushrooms": "牛肝菌", + "potato_dumpling_dough": "马铃薯饺子面团", + "potato_wedges": "马铃薯楔子", + "potatoes": "马铃薯", + "potting_soil": "盆栽土壤", + "powder": "粉末", + "powdered_sugar": "糖粉", + "processed_cheese": "加工奶酪", + "prosecco": "普罗塞克", + "puff_pastry": "酥皮", + "pumpkin": "南瓜", + "pumpkin_seeds": "南瓜籽", + "quark": "夸克", + "quinoa": "藜麦", + "radicchio": "拉迪奇奥", + "radish": "萝卜", + "ramen": "拉面", + "rapeseed_oil": "油菜籽油", + "raspberries": "覆盆子", + "raspberry_syrup": "覆盆子糖浆", + "red_bull": "红牛", + "red_chili": "红辣椒", + "red_lentils": "红扁豆", + "red_onions": "红洋葱", + "red_pesto": "红色香蒜酱", + "red_wine": "红葡萄酒", + "red_wine_vinegar": "红酒醋", + "rhubarb": "大黄", + "ribbon_noodles": "带状面条", + "rice": "大米", + "rice_cakes": "米饼", + "rice_ribbon_noodles": "米带面", + "rice_vinegar": "米醋", + "ricotta": "蓖麻籽油", + "rinse_tabs": "冲洗片", + "rinsing_agent": "漂洗剂", + "risotto_rice": "烩菜米", + "rocket": "火箭", + "roll": "滚动", + "rosemary": "迷迭香", + "saffron_threads": "藏红花线", + "sage": "圣人", + "saitan_powder": "斋堂粉", + "salad_mix": "沙拉混合", + "salad_seeds_mix": "沙拉种子组合", + "salt": "盐", + "salt_mill": "盐磨", + "sambal_oelek": "凉拌菜", + "sauce": "酱汁", + "sausage": "香肠", + "sausages": "香肠", + "savoy_cabbage": "萨瓦白菜", + "scallion": "斯卡利昂", + "scattered_cheese": "散落的奶酪", + "schlemmerfilet": "薛明辉", + "schupfnudeln": "蛋糕", + "semolina_porridge": "麦片粥", + "sesame": "芝麻", + "sesame_oil": "芝麻油", + "shallot": "大葱", + "shampoo": "洗发水", + "shawarma_spice": "沙瓦玛调料", + "shiitake_mushroom": "香菇", + "shoe_insoles": "鞋垫", + "shower_gel": "沐浴露", + "shredded_cheese": "奶酪丝", + "sieved_tomatoes": "过筛的西红柿", + "sliced_cheese": "芝士片", + "smoked_paprika": "烟熏辣椒粉", + "smoked_tofu": "熏制豆腐", + "snacks": "小吃", + "soap": "肥皂", + "soft_drinks": "软饮料", + "softdrinks": "软饮料", + "sour_cream": "酸奶油", + "sour_cucumbers": "酸黄瓜", + "soy_hack": "大豆黑客", + "soy_sauce": "酱油", + "soy_shred": "大豆丝", + "spaetzle": "玉米饼", + "spaghetti": "意大利面条", + "sparkling_water": "起泡水", + "spelt": "斯佩尔特", + "spinach": "菠菜", + "sponge_cloth": "海棉布", + "sponge_wipes": "海绵擦拭", + "sponges": "海棉", + "spreading_cream": "涂抹式奶油", + "spring_onions": "春天的洋葱", + "sprite": "雪碧", + "sprouts": "萌芽", + "sriracha": "斯里拉查 (Sriracha)", + "strained_tomatoes": "稀释的西红柿", + "sugar": "糖", + "summer_roll_paper": "夏季卷纸", + "sunflower_seeds": "葵花籽", + "sushi_rice": "寿司米", + "swabian_ravioli": "斯瓦比亚的馄饨", + "sweet_potato": "红薯", + "sweet_potatoes": "红薯", + "table_salt": "食用盐", + "tagliatelle": "塔利亚特面团", + "tahini": "塔希尼", + "tangerines": "橘子", + "tape": "录像带", + "tea": "茶叶", + "teriyaki_sauce": "照烧酱", + "thyme": "百里香", + "toast": "吐司", + "tofu": "豆腐", + "toilet_paper": "厕纸", + "tomato_juice": "番茄汁", + "tomato_paste": "番茄酱", + "tomato_sauce": "番茄酱", + "tomatoes": "西红柿", + "tonic_water": "汤力水", + "toothpaste": "牙膏", + "tortellini": "饺子", + "tortilla_chips": "玉米片", + "tuna": "金枪鱼", + "turmeric": "姜黄", + "tzatziki": "塔兹米奇", + "udon_noodles": "乌龙面", + "uht_milk": "UHT牛奶", + "vanilla_sugar": "香草糖", + "vegetable_bouillon_cube": "蔬菜肉汤块", + "vegetable_broth": "蔬菜汤", + "vegetable_oil": "植物油", + "vegetable_onion": "蔬菜洋葱", + "vegetables": "蔬菜", + "vegetarian_cold_cuts": "素食冷盘", + "vinegar": "醋", + "vodka": "伏特加", + "washing_powder": "洗衣粉", + "water": "水", + "water_ice": "水冰", + "watermelon": "西瓜", + "wc_cleaner": "厕所清洁剂", + "whipped_cream": "鲜奶油", + "white_wine": "白葡萄酒", + "white_wine_vinegar": "白葡萄酒醋", + "whole_canned_tomatoes": "完整的罐装西红柿", + "wild_berries": "野生浆果", + "wrapping_paper": "包装纸", + "wraps": "包裹", + "yeast": "酵母菌", + "yoghurt": "酸奶", + "yogurt": "酸奶", + "yum_yum": "百胜", + "zewa": "Zewa", + "zinc_cream": "锌霜", + "zucchini": "西葫芦" + } +} From 887b416bb7c60b8ece43f296384d61c021153da0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 10 Jul 2023 18:14:05 +0200 Subject: [PATCH 355/496] feat: Add Turkish and Simplified Chinese --- backend/app/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/config.py b/backend/app/config.py index f95b98a2..333437be 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -54,6 +54,8 @@ 'pt': 'Português', 'pt_BR': 'Português Brasileiro', 'ru': 'русский язык', + 'tr': 'Türkçe', + 'zh_Hans': '简化字', } Flask.json_provider_class = KitchenOwlJSONProvider From 5a59e6b2db0eb5e3a6183f9d0ac251319bd947a0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 10 Jul 2023 18:14:28 +0200 Subject: [PATCH 356/496] Prepare release 73 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 333437be..23b58dd9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 72 +BACKEND_VERSION = 73 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 945f583ca866d89b4a72ed7c5acd0041015cebb8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 19 Jul 2023 18:08:23 +0200 Subject: [PATCH 357/496] chore: upgrade requirements --- backend/requirements.txt | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index fcded459..fa4ff228 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,12 +9,12 @@ black==23.1a1 blinker==1.6.2 certifi==2023.5.7 cffi==1.15.1 -charset-normalizer==3.1.0 -click==8.1.3 +charset-normalizer==3.2.0 +click==8.1.6 contourpy==1.1.0 cycler==0.11.0 dbscan1d==0.2.2 -extruct==0.14.0 +extruct==0.16.0 flake8==6.0.0 Flask==2.3.2 Flask-APScheduler==1.12.4 @@ -22,21 +22,21 @@ Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.2 Flask-Migrate==4.0.4 Flask-SQLAlchemy==3.0.5 -fonttools==4.40.0 +fonttools==4.41.0 greenlet==2.0.2 html-text==0.5.2 html5lib==1.1 idna==3.4 -ingredient-parser-nlp==0.1.0b1 +ingredient-parser-nlp==0.1.0b3 iniconfig==2.0.0 isodate==0.6.1 itsdangerous==2.1.2 Jinja2==3.1.2 -joblib==1.2.0 +joblib==1.3.1 jstyleson==0.0.2 kiwisolver==1.4.4 -lark==1.1.5 -lxml==4.9.2 +lark==1.1.6 +lxml==4.9.3 Mako==1.2.4 MarkupSafe==2.1.3 marshmallow==3.19.0 @@ -46,19 +46,21 @@ mf2py==1.1.3 mlxtend==0.22.0 mypy-extensions==1.0.0 nltk==3.8.1 -numpy==1.25.0 +numpy==1.25.1 packaging==23.1 pandas==2.0.3 pathspec==0.11.1 Pillow==10.0.0 -platformdirs==3.8.0 +platformdirs==3.9.1 pluggy==1.2.0 +prometheus-client==0.17.1 +prometheus-flask-exporter==0.22.4 psycopg2-binary==2.9.6 py==1.11.0 pycodestyle==2.10.0 pycparser==2.21 pyflakes==3.0.1 -PyJWT==2.7.0 +PyJWT==2.8.0 pyparsing==3.1.0 pyRdfa3==3.5.3 pytest==7.4.0 @@ -69,25 +71,25 @@ pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==6.3.2 rdflib-jsonld==0.6.2 -recipe-scrapers==14.36.1 -regex==2023.5.5 +recipe-scrapers==14.39.0 +regex==2023.6.3 requests==2.31.0 scikit-learn==1.3.0 scipy==1.11.1 setuptools-scm==7.1.0 six==1.16.0 soupsieve==2.4.1 -SQLAlchemy==2.0.17 -threadpoolctl==3.1.0 +SQLAlchemy==2.0.19 +threadpoolctl==3.2.0 toml==0.10.2 tomli==2.0.1 tqdm==4.65.0 -typed-ast==1.5.4 +typed-ast==1.5.5 types-beautifulsoup4==4.12.0.5 types-html5lib==1.1.11.14 types-requests==2.31.0.1 types-urllib3==1.26.25.13 -typing_extensions==4.7.0 +typing_extensions==4.7.1 tzdata==2023.3 tzlocal==5.0.1 urllib3==2.0.3 From 2e686859d4d587bcc4ca2d110cf10b6070ea405c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 19 Jul 2023 18:10:49 +0200 Subject: [PATCH 358/496] Prepare release 74 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 23b58dd9..84aa4eef 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 73 +BACKEND_VERSION = 74 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From eb9edef47c56f847923d9ef54559eb55e8141796 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 31 Jul 2023 00:09:30 +0200 Subject: [PATCH 359/496] feat: add shopping list remove multiple items endpoint --- .../app/controller/shoppinglist/schemas.py | 18 ++++--- .../shoppinglist/shoppinglist_controller.py | 49 ++++++++++++++----- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index a4117383..e1ce70c3 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -56,10 +56,6 @@ class UpdateDescription(Schema): required=True ) - # def validate_id(self, args): - # if not ShoppinglistItem.find_by_ids(args['id'], args['item_id']): - # raise ValidationError('Item does not exist') - class RemoveItem(Schema): item_id = fields.Integer( @@ -67,6 +63,14 @@ class RemoveItem(Schema): ) removed_at = fields.Integer() - # def validate_id(self, args): - # if not ShoppinglistItem.find_by_id(args['id'], args['item_id']): - # raise ValidationError('Item does not exist') + +class RemoveItems(Schema): + class RecipeItem(Schema): + class Meta: + unknown = EXCLUDE + item_id = fields.Integer( + required=True, + ) + removed_at = fields.Integer() + + items = fields.List(fields.Nested(RecipeItem)) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 4bf43245..bbe96c58 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -4,7 +4,7 @@ from app.models import Item, Shoppinglist, History, Status, Association, ShoppinglistItems from app.helpers import validate_args, authorize_household from .schemas import (RemoveItem, UpdateDescription, - AddItemByName, CreateList, AddRecipeItems, GetItems, UpdateList, GetRecentItems) + AddItemByName, CreateList, AddRecipeItems, GetItems, UpdateList, GetRecentItems, RemoveItems) from app.errors import NotFoundRequest, InvalidUsage from datetime import datetime, timedelta, timezone import app.util.description_merger as description_merger @@ -205,23 +205,46 @@ def removeShoppinglistItem(args, id): raise NotFoundRequest() shoppinglist.checkAuthorized() - item = Item.find_by_id(args['item_id']) - if not item: - item = Item.find_by_name(args['name']) - if not item: + removeShoppinglistItem( + shoppinglist, args['item_id'], args['removed_at'] if 'removed_at' in args else None) + + return jsonify({'msg': "DONE"}) + + +@shoppinglist.route('//items', methods=['DELETE']) +@jwt_required() +@validate_args(RemoveItems) +def removeShoppinglistItems(args, id): + shoppinglist = Shoppinglist.find_by_id(id) + if not shoppinglist: raise NotFoundRequest() - con = ShoppinglistItems.find_by_ids(id, args['item_id']) + shoppinglist.checkAuthorized() + + for arg in args['items']: + removeShoppinglistItem( + shoppinglist, arg['item_id'], arg['removed_at'] if 'removed_at' in arg else None) + + return jsonify({'msg': "DONE"}) + + +def removeShoppinglistItem(shoppinglist: Shoppinglist, item_id: int, removed_at: int = None) -> bool: + item = Item.find_by_id(item_id) + if not item: + return False + con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) + if not con: + return False description = con.description con.delete() - removed_at = None - if 'removed_at' in args: - removed_at = datetime.fromtimestamp( - args['removed_at']/1000, timezone.utc) + removed_at_datetime = None + if removed_at: + removed_at_datetime = datetime.fromtimestamp( + removed_at/1000, timezone.utc) - History.create_dropped(shoppinglist, item, description, removed_at) - - return jsonify({'msg': "DONE"}) + History.create_dropped( + shoppinglist, item, description, removed_at_datetime) + return True @shoppinglist.route('//recipeitems', methods=['POST']) From 008e063f9380c8a746761544935c9bded70c1623 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 31 Jul 2023 00:12:45 +0200 Subject: [PATCH 360/496] chore: upgrade requirements --- backend/requirements.txt | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index fa4ff228..573a8ef1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,7 +7,7 @@ bcrypt==4.0.1 beautifulsoup4==4.12.2 black==23.1a1 blinker==1.6.2 -certifi==2023.5.7 +certifi==2023.7.22 cffi==1.15.1 charset-normalizer==3.2.0 click==8.1.6 @@ -15,14 +15,14 @@ contourpy==1.1.0 cycler==0.11.0 dbscan1d==0.2.2 extruct==0.16.0 -flake8==6.0.0 +flake8==6.1.0 Flask==2.3.2 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.2 Flask-Migrate==4.0.4 Flask-SQLAlchemy==3.0.5 -fonttools==4.41.0 +fonttools==4.41.1 greenlet==2.0.2 html-text==0.5.2 html5lib==1.1 @@ -35,12 +35,12 @@ Jinja2==3.1.2 joblib==1.3.1 jstyleson==0.0.2 kiwisolver==1.4.4 -lark==1.1.6 +lark==1.1.7 lxml==4.9.3 Mako==1.2.4 MarkupSafe==2.1.3 -marshmallow==3.19.0 -matplotlib==3.7.1 +marshmallow==3.20.1 +matplotlib==3.7.2 mccabe==0.7.0 mf2py==1.1.3 mlxtend==0.22.0 @@ -49,19 +49,19 @@ nltk==3.8.1 numpy==1.25.1 packaging==23.1 pandas==2.0.3 -pathspec==0.11.1 +pathspec==0.11.2 Pillow==10.0.0 -platformdirs==3.9.1 +platformdirs==3.10.0 pluggy==1.2.0 prometheus-client==0.17.1 prometheus-flask-exporter==0.22.4 psycopg2-binary==2.9.6 py==1.11.0 -pycodestyle==2.10.0 +pycodestyle==2.11.0 pycparser==2.21 -pyflakes==3.0.1 +pyflakes==3.1.0 PyJWT==2.8.0 -pyparsing==3.1.0 +pyparsing==3.0.9 pyRdfa3==3.5.3 pytest==7.4.0 python-crfsuite==0.9.9 @@ -71,7 +71,7 @@ pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==6.3.2 rdflib-jsonld==0.6.2 -recipe-scrapers==14.39.0 +recipe-scrapers==14.40.0 regex==2023.6.3 requests==2.31.0 scikit-learn==1.3.0 @@ -86,14 +86,14 @@ tomli==2.0.1 tqdm==4.65.0 typed-ast==1.5.5 types-beautifulsoup4==4.12.0.5 -types-html5lib==1.1.11.14 -types-requests==2.31.0.1 -types-urllib3==1.26.25.13 +types-html5lib==1.1.11.15 +types-requests==2.31.0.2 +types-urllib3==1.26.25.14 typing_extensions==4.7.1 tzdata==2023.3 tzlocal==5.0.1 -urllib3==2.0.3 -uWSGI==2.0.21 +urllib3==2.0.4 +uWSGI==2.0.22 w3lib==2.1.1 webencodings==0.5.1 Werkzeug==2.3.6 From fe868f0b53e0fa1edb13577a2e89998e4e7f1645 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 1 Aug 2023 23:35:57 +0200 Subject: [PATCH 361/496] lang: Add new items --- backend/templates/add_items_from_server.py | 59 +++++++++++++++----- backend/templates/attributes.json | 65 +++++++++++++++++++++- backend/templates/l10n/de.json | 60 +++++++++++++++++++- backend/templates/l10n/en.json | 60 +++++++++++++++++++- 4 files changed, 225 insertions(+), 19 deletions(-) diff --git a/backend/templates/add_items_from_server.py b/backend/templates/add_items_from_server.py index b96d585e..03b8d686 100644 --- a/backend/templates/add_items_from_server.py +++ b/backend/templates/add_items_from_server.py @@ -5,9 +5,10 @@ SERVER_URL = "http://localhost:5000" TOKEN = "" +HOUSEHOLD_ID = 1 DEEPL_AUTH_KEY = "" -SOURCE_LANG_CODE = "" +SOURCE_LANG_CODE = None # only used if the household has no language assigned BASE_PATH = os.path.dirname(os.path.abspath(__file__)) @@ -17,31 +18,59 @@ def nameToKey(name: str) -> str: def main(): - if not SOURCE_LANG_CODE: - print("Missing source language") + if not SERVER_URL or not TOKEN: + print("Server is not configured") return - if SOURCE_LANG_CODE != "en" and not DEEPL_AUTH_KEY: - print("For languages other than english an deepl token is required! Make sure the source languages is supported: https://www.deepl.com/docs-api/translate-text/translate-text/") + if not HOUSEHOLD_ID: + print("Household not configured") return - if SOURCE_LANG_CODE != "en" and not DEEPL_AUTH_KEY: - print("For languages other than english an deepl token is required! Make sure the source languages is supported: https://www.deepl.com/docs-api/translate-text/translate-text/") + + # Get household from server + household: dict = json.loads(requests.get( + SERVER_URL + "/api/household/" + str(HOUSEHOLD_ID), headers={'Authorization': 'Bearer ' + TOKEN}).content) + + if not household: + print("Could not find household") return - if not SERVER_URL or not TOKEN: - print("Server is not configured") + + lang_code = household['language'] or SOURCE_LANG_CODE + if not lang_code: + print("Household has no language") + return + print("Selected household '" + + household['name'] + "' with language code '" + lang_code + "'") + confirm = input("Confirm (y):").lower() or "y" + if not confirm == "y": + print("Abort") + return + + if lang_code != "en" and not DEEPL_AUTH_KEY: + print("For languages other than english an deepl token is required! Make sure the source languages is supported: https://www.deepl.com/docs-api/translate-text/translate-text/") return # Get item export from server add_items: list = json.loads(requests.get( - SERVER_URL + "/api/export/items", headers={'Authorization': 'Bearer ' + TOKEN}).content)["items"] + SERVER_URL + "/api/household/" + str(HOUSEHOLD_ID) + "/export/items", headers={'Authorization': 'Bearer ' + TOKEN}).content)["items"] + + if not add_items: + print("An error occured") + return # read en file with open(BASE_PATH + "/l10n/en.json", encoding="utf8") as f: en = json.load(f) # translate original file (used as the key) and write to file - if SOURCE_LANG_CODE != "en": - if os.path.exists(BASE_PATH + "/l10n/" + SOURCE_LANG_CODE + ".json"): - with open(BASE_PATH + "/l10n/" + SOURCE_LANG_CODE + ".json", "r", encoding="utf8") as f: + if lang_code != "en": + deepl_supported_lang: list = [v['language'].lower() for v in json.loads(requests.get("https://api-free.deepl.com/v2/languages?type=source", + headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)] + if lang_code not in deepl_supported_lang: + print("Source language not supported by deepl") + return + + + if os.path.exists(BASE_PATH + "/l10n/" + lang_code + ".json"): + with open(BASE_PATH + "/l10n/" + lang_code + ".json", "r", encoding="utf8") as f: content = f.read() if content: source = json.loads(content) @@ -55,13 +84,13 @@ def main(): for item in add_items: item["original"] = item["name"] - item["name"] = json.loads(requests.post("https://api-free.deepl.com/v2/translate", {"target_lang": "EN-US", "source_lang": SOURCE_LANG_CODE.upper(), "text": item["name"]}, + item["name"] = json.loads(requests.post("https://api-free.deepl.com/v2/translate", {"target_lang": "EN-US", "source_lang": lang_code.upper(), "text": item["name"]}, headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)['translations'][0]["text"] if (nameToKey(item["name"]) not in source["items"]): source["items"][nameToKey(item["name"])] = item["original"] - with open(BASE_PATH + "/l10n/" + SOURCE_LANG_CODE + ".json", "w", encoding="utf8") as f: + with open(BASE_PATH + "/l10n/" + lang_code + ".json", "w", encoding="utf8") as f: f.write(json.dumps(source, ensure_ascii=False, indent=2, sort_keys=True)) diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index 39ba5775..8ff9f053 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -8,12 +8,14 @@ "category": "fruits_vegetables", "icon": "apple" }, + "apple_cider_vinegar": {}, "apple_pulp": { "icon": "apple" }, "applesauce": { "icon": "apple" }, + "apricots": {}, "apérol": { "category": "drinks", "icon": "wine-bottle" @@ -22,6 +24,7 @@ "category": "fruits_vegetables" }, "asian_egg_noodles": {}, + "asian_noodles": {}, "asparagus": { "category": "fruits_vegetables", "icon": "asparagus" @@ -33,6 +36,7 @@ "category": "fruits_vegetables", "icon": "avocado" }, + "baby_potatoes": {}, "baby_spinach": { "category": "fruits_vegetables", "icon": "spinach" @@ -166,7 +170,9 @@ "chickpeas": { "icon": "peas" }, + "chicory": {}, "chili_oil": {}, + "chili_pepper": {}, "chips": { "category": "snacks" }, @@ -177,9 +183,11 @@ "category": "snacks", "icon": "chocolate-bar" }, + "chocolate_chips": {}, "chopped_tomatoes": { "icon": "tomato" }, + "chunky_tomatoes": {}, "ciabatta": { "category": "bread", "icon": "bread" @@ -217,6 +225,7 @@ "cornflakes": {}, "cornstarch": {}, "cornys": {}, + "corriander": {}, "cough_drops": {}, "couscous": {}, "covid_rapid_test": {}, @@ -243,6 +252,7 @@ "icon": "cucumber" }, "cumin": {}, + "curd": {}, "curry_paste": {}, "curry_powder": {}, "curry_sauce": {}, @@ -252,12 +262,15 @@ "dental_floss": { "category": "hygiene" }, + "deo": {}, "deodorant": { "category": "hygiene" }, "detergent": { "category": "hygiene" }, + "detergent_sheets": {}, + "diarrhea_remedy": {}, "dill": {}, "dishwasher_salt": { "category": "hygiene" @@ -272,6 +285,8 @@ "edamame": { "icon": "peas" }, + "egg_salad": {}, + "egg_yolk": {}, "eggplant": { "category": "fruits_vegetables", "icon": "eggplant" @@ -280,6 +295,8 @@ "category": "dairy", "icon": "eggs" }, + "enoki_mushrooms": {}, + "eyebrow_gel": {}, "falafel": {}, "falafel_powder": {}, "fanta": { @@ -313,6 +330,7 @@ "category": "freezer", "icon": "spinach" }, + "funeral_card": {}, "garam_masala": {}, "garbage_bag": { "category": "hygiene" @@ -348,6 +366,8 @@ "category": "dairy", "icon": "cheese" }, + "granola": {}, + "granola_bar": {}, "grapes": { "category": "fruits_vegetables", "icon": "grapes" @@ -361,9 +381,12 @@ "green_chili": {}, "green_pesto": {}, "hair_gel": {}, + "hair_ties": {}, "hair_wax": {}, + "hand_soap": {}, "handkerchief_box": {}, "handkerchiefs": {}, + "hard_cheese": {}, "haribo": { "category": "snacks" }, @@ -395,6 +418,7 @@ }, "instant_soups": {}, "jam": {}, + "jasmine_rice": {}, "katjes": {}, "ketchup": { "icon": "ketchup" @@ -424,6 +448,7 @@ "category": "fruits_vegetables", "icon": "citrus" }, + "lemon_curd": {}, "lemon_juice": { "icon": "citrus" }, @@ -435,7 +460,9 @@ }, "lenses": {}, "lenses_red": {}, + "lentil_stew": {}, "lentils": {}, + "lentils_red": {}, "lettuce": { "category": "fruits_vegetables", "icon": "lettuce" @@ -448,13 +475,16 @@ "icon": "citrus" }, "linguine": {}, + "lip_care": {}, "low-fat_curd_cheese": { "category": "dairy" }, + "maggi": {}, "magnesium": {}, "mango": { "category": "fruits_vegetables" }, + "maple_syrup": {}, "margarine": { "category": "dairy" }, @@ -462,6 +492,8 @@ "marshmallows": { "category": "snacks" }, + "mascara": {}, + "mascarpone": {}, "mask": { "category": "hygiene" }, @@ -476,8 +508,10 @@ "icon": "basil" }, "mint_candy": {}, + "miso_paste": {}, "mixed_vegetables": {}, "mochis": {}, + "mold_remover": {}, "mountain_cheese": { "category": "dairy", "icon": "cheese" @@ -496,6 +530,7 @@ "icon": "mushroom" }, "mustard": {}, + "nail_file": {}, "neutral_oil": {}, "nori_leaves": {}, "nori_sheets": {}, @@ -510,6 +545,7 @@ "obatzda": { "category": "refrigerated" }, + "oil": {}, "olive_oil": { "icon": "olive" }, @@ -521,6 +557,7 @@ "category": "fruits_vegetables", "icon": "onion" }, + "onion_powder": {}, "onions": { "category": "fruits_vegetables", "icon": "onion" @@ -543,10 +580,12 @@ "pak_choi": { "category": "fruits_vegetables" }, + "pantyhose": {}, "paprika": { "category": "fruits_vegetables", "icon": "paprika" }, + "paprika_seasoning": {}, "pardina_lentils_dried": {}, "parmesan": { "category": "dairy" @@ -605,6 +644,7 @@ "icon": "pineapple" }, "pita_bag": {}, + "pita_bread": {}, "pizza": { "icon": "salami-pizza" }, @@ -616,6 +656,7 @@ }, "plant_oil": {}, "plaster": {}, + "pointed_peppers": {}, "porcini_mushrooms": { "category": "fruits_vegetables", "icon": "mushroom" @@ -623,6 +664,7 @@ "potato_dumpling_dough": { "icon": "potato" }, + "potato_wedges": {}, "potatoes": { "category": "fruits_vegetables", "icon": "potato" @@ -665,10 +707,12 @@ "category": "drinks", "icon": "raspberry" }, + "razor_blades": {}, "red_bull": { "category": "drinks" }, "red_chili": {}, + "red_curry_paste": {}, "red_lentils": {}, "red_onions": { "category": "fruits_vegetables", @@ -688,6 +732,7 @@ "icon": "grains-of-rice" }, "rice_cakes": {}, + "rice_paper": {}, "rice_ribbon_noodles": {}, "rice_vinegar": {}, "ricotta": {}, @@ -727,7 +772,6 @@ }, "schlemmerfilet": {}, "schupfnudeln": {}, - "chocolate_chips": {}, "semolina_porridge": {}, "sesame": {}, "sesame_oil": {}, @@ -770,18 +814,21 @@ "category": "snacks" }, "soap": {}, + "soba_noodles": {}, "soft_drinks": { "category": "drinks" }, "softdrinks": { "category": "drinks" }, + "soup_vegetables": {}, "sour_cream": { "category": "dairy" }, "sour_cucumbers": { "icon": "cucumber" }, + "soy_cream": {}, "soy_hack": {}, "soy_sauce": { "icon": "soy-sauce" @@ -803,6 +850,7 @@ "sponge_cloth": { "category": "hygiene" }, + "sponge_fingers": {}, "sponge_wipes": { "category": "hygiene" }, @@ -826,13 +874,17 @@ "strained_tomatoes": { "icon": "tomato" }, + "strawberries": {}, "sugar": {}, "summer_roll_paper": {}, + "sunflower_oil": {}, "sunflower_seeds": {}, + "sunscreen": {}, "sushi_rice": { "icon": "grains-of-rice" }, "swabian_ravioli": {}, + "sweet_chili_sauce": {}, "sweet_potato": { "category": "fruits_vegetables", "icon": "sweet-potato" @@ -841,6 +893,7 @@ "category": "fruits_vegetables", "icon": "sweet-potato" }, + "sweets": {}, "table_salt": {}, "tagliatelle": {}, "tahini": {}, @@ -848,6 +901,7 @@ "category": "fruits_vegetables" }, "tape": {}, + "tapioca_flour": {}, "tea": { "category": "drinks", "icon": "tea" @@ -903,9 +957,11 @@ }, "vegetarian_cold_cuts": {}, "vinegar": {}, + "vitamin_tablets": {}, "vodka": { "category": "drinks" }, + "washing_gel": {}, "washing_powder": { "category": "hygiene" }, @@ -919,6 +975,7 @@ "wc_cleaner": { "category": "hygiene" }, + "wheat_flour": {}, "whipped_cream": { "category": "dairy" }, @@ -935,11 +992,15 @@ "category": "fruits_vegetables", "icon": "raspberry" }, + "wild_rice": {}, + "wildberry_lillet": {}, + "worcester_sauce": {}, "wrapping_paper": {}, "wraps": { "category": "bread" }, "yeast": {}, + "yeast_flakes": {}, "yoghurt": { "category": "dairy" }, @@ -957,4 +1018,4 @@ "category": "fruits_vegetables" } } -} +} \ No newline at end of file diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index 65c86927..ec9abd8d 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -17,12 +17,15 @@ "apple": "Apfel", "apple_pulp": "Apfelmark", "applesauce": "Apfelmus", + "apricots": "Aprikosen", "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Asiatische Eiernudeln", + "asian_noodles": "Asiatische Nudeln", "asparagus": "Spargel", "aspirin": "Aspirin", "avocado": "Avocado", + "baby_potatoes": "Drillinge", "baby_spinach": "Babyspinat", "bacon": "Speck", "baguette": "Baguette", @@ -80,11 +83,15 @@ "cheese": "Käse", "cherry_tomatoes": "Kirschtomaten", "chickpeas": "Kichererbsen", + "chicory": "Chicorée", "chili_oil": "Chili-Öl", + "chili_pepper": "Chilischote", "chips": "Chips", "chives": "Schnittlauch", "chocolate": "Schokolade", + "chocolate_chips": "Sckokoladenstückchen", "chopped_tomatoes": "Gehackte Tomaten", + "chunky_tomatoes": "Stückige Tomaten", "ciabatta": "Ciabatta", "cider_vinegar": "Apfelessig", "cilantro": "Koriander", @@ -103,6 +110,7 @@ "cornflakes": "Cornflakes", "cornstarch": "Speisestärke", "cornys": "Cornys", + "corriander": "Korriander", "cough_drops": "Hustenbonbons", "couscous": "Couscous", "covid_rapid_test": "COVID Schnelltest", @@ -113,23 +121,32 @@ "creme_fraiche": "Creme fraiche", "crepe_tape": "Crepesband", "crispbread": "Knäckebrot", + "granola": "Knuspermüsli", "cucumber": "Gurke", "cumin": "Cumin", + "curd": "Quark", "curry_paste": "Currypaste", "curry_powder": "Currypulver", "curry_sauce": "Currysoße", "dates": "Datteln", "dental_floss": "Zahnseide", + "deo": "Deo", "deodorant": "Deodorant", "detergent": "Waschmittel", + "detergent_sheets": "Waschmittelblätter", + "diarrhea_remedy": "Durchfallmittel", "dill": "Dill", "dishwasher_salt": "Spülmaschinensalz", "dishwasher_tabs": "Tabs für die Spülmaschine", "disinfection_spray": "Desinfektionsspray", "dried_tomatoes": "Getrocknete Tomaten", "edamame": "Edamame", + "egg_salad": "Eiersalat", + "egg_yolk": "Eigelb", "eggplant": "Aubergine", "eggs": "Eier", + "enoki_mushrooms": "Enoki Pilze", + "eyebrow_gel": "Augenbrauengel", "falafel": "Falafel", "falafel_powder": "Falafelpulver", "fanta": "Fanta", @@ -143,6 +160,7 @@ "frozen_fruit": "TK Obst", "frozen_pizza": "Tiefkühlpizza", "frozen_spinach": "TK Spinat", + "funeral_card": "Trauerkarte", "garam_masala": "Garam Masala", "garbage_bag": "Müllbeutel", "garlic": "Knoblauch", @@ -156,15 +174,19 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola_bar": "Müsliriegel", "grapes": "Trauben", "greek_yogurt": "Griechischer Joghurt", "green_asparagus": "Grüner Spargel", "green_chili": "Grüne Chili", "green_pesto": "grünes Pesto", "hair_gel": "Haargel", + "hair_ties": "Haargummis", "hair_wax": "Haar-Wachs", + "hand_soap": "Handseife", "handkerchief_box": "Taschentuchbox", "handkerchiefs": "Taschentücher", + "hard_cheese": "Hartkäse", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Haselnüsse", @@ -180,6 +202,7 @@ "iced_tea": "Eistee", "instant_soups": "Instant Suppen", "jam": "Konfitüre", + "jasmine_rice": "Jasminreis", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Kidneybohnen", @@ -192,21 +215,28 @@ "leaf_spinach": "Blattspinat", "leek": "Lauch", "lemon": "Zitrone", + "lemon_curd": "Lemon Curd", "lemon_juice": "Zitronensaft", "lemonade": "Limonade", "lemongrass": "Zitronengras", + "lentil_stew": "Linseneintopf", "lentils": "Linsen", "lentils_red": "Linsen rot", "lettuce": "Kopfsalat", "lillet": "Lillet", "lime": "Limette", "linguine": "Linguine", + "lip_care": "Lippenpflege", "low-fat_curd_cheese": "Magerquark", + "maggi": "Maggi", "magnesium": "Magnesium", "mango": "Mango", + "maple_syrup": "Ahornsirup", "margarine": "Margarine", "marjoram": "Majoran", "marshmallows": "Marshmallows", + "mascara": "Wimperntusche", + "mascarpone": "Mascarpone", "mask": "Maske", "mayonnaise": "Mayonnaise", "meat_substitute_product": "Fleischersatzprodukt", @@ -214,8 +244,10 @@ "milk": "Milch", "mint": "Minze", "mint_candy": "Minz-Bonbon", + "miso_paste": "Miso Paste", "mixed_vegetables": "Gemischtes Gemüse", "mochis": "Mochis", + "mold_remover": "Schimmelentferner", "mountain_cheese": "Bergkäse", "mouth_wash": "Mundspülung", "mozzarella": "Mozzarella", @@ -224,6 +256,7 @@ "mulled_wine": "Glühwein", "mushrooms": "Champignons", "mustard": "Senf", + "nail_file": "Nagelpfeile", "neutral_oil": "Neutrales Öl", "nori_sheets": "Nori Blätter", "nutmeg": "Muskatnuss", @@ -232,16 +265,20 @@ "oatmeal_cookies": "Haferkekse", "oatsome": "Oatsome", "obatzda": "Obatzda", + "oil": "Öl", "olive_oil": "Olivenöl", "olives": "Oliven", "onion": "Zwiebel", + "onion_powder": "Zwiebelpulver", "orange_juice": "Orangensaft", "oranges": "Orangen", "oregano": "Oregano", "organic_lemon": "Bio-Zitrone", "organic_waste_bags": "Biomülltüten", "pak_choi": "Pak Choi", + "pantyhose": "Strumpfhose", "paprika": "Paprika", + "paprika_seasoning": "Paprikagewürz", "pardina_lentils_dried": "Pardina Linsen getrocknet", "parmesan": "Parmesan", "parsley": "Petersilie", @@ -263,11 +300,13 @@ "pine_nuts": "Pinienkerne", "pineapple": "Ananas", "pita_bag": "Pitatasche", + "pita_bread": "Fladenbrot", "pizza": "Pizza", "pizza_dough": "Pizzateig", "plant_magarine": "Pflanzenmagarine", "plant_oil": "Pflanzenöl", "plaster": "Pflaster", + "pointed_peppers": "Spitzpaprika", "porcini_mushrooms": "Steinpilze", "potato_dumpling_dough": "Kartoffelkloßteig", "potato_wedges": "Kartoffelecken", @@ -288,8 +327,10 @@ "rapeseed_oil": "Rapsöl", "raspberries": "Himbeeren", "raspberry_syrup": "Himbeersirup", + "razor_blades": "Rasierklingen", "red_bull": "Red Bull", "red_chili": "Rote Chili", + "red_curry_paste": "Rote Currypaste", "red_lentils": "Rote Linsen", "red_onions": "Rote Zwiebeln", "red_pesto": "rotes Pesto", @@ -299,6 +340,7 @@ "ribbon_noodles": "Bandnudeln", "rice": "Reis", "rice_cakes": "Reiswaffeln", + "rice_paper": "Reispapier", "rice_ribbon_noodles": "Reisbandnudeln", "rice_vinegar": "Reis-Essig", "ricotta": "Ricotta", @@ -324,7 +366,6 @@ "scattered_cheese": "Streukäse", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "chocolate_chips": "Sckokoladenstückchen", "semolina_porridge": "Grießbrei", "sesame": "Sesam", "sesame_oil": "Sesamöl", @@ -341,10 +382,13 @@ "smoked_tofu": "Räuchertofu", "snacks": "Snacks", "soap": "Seife", + "soba_noodles": "Soba-Nudeln", "soft_drinks": "Softdrinks", "softdrinks": "Erfrischungsgetränke", + "soup_vegetables": "Suppengemüse", "sour_cream": "Schmand", "sour_cucumbers": "Saure Gurken", + "soy_cream": "Sojasahne", "soy_hack": "Sojahack", "soy_sauce": "Sojasauce", "soy_shred": "Soja Schnetzel", @@ -354,6 +398,7 @@ "spelt": "Dinkel", "spinach": "Spinat", "sponge_cloth": "Schwammlappen", + "sponge_fingers": "Löffelbiskuit", "sponge_wipes": "Schwammtücher", "sponges": "Schwämme", "spreading_cream": "Streichcreme", @@ -362,18 +407,24 @@ "sprouts": "Sprossen", "sriracha": "Sriracha", "strained_tomatoes": "Passierte Tomaten", + "strawberries": "Erdbeeren", "sugar": "Zucker", "summer_roll_paper": "Sommerrollen-Papier", + "sunflower_oil": "Sonnenblümenöl", "sunflower_seeds": "Sonnenblumenkerne", + "sunscreen": "Sonnencreme", "sushi_rice": "Sushi Reis", "swabian_ravioli": "Maultaschen", + "sweet_chili_sauce": "Sweet Chili Soße", "sweet_potato": "Süßkartoffel", "sweet_potatoes": "Süßkartoffeln", + "sweets": "Süßigkeiten", "table_salt": "Tafelsalz", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Mandarinen", "tape": "Klebeband", + "tapioca_flour": "Tapiokamehl", "tea": "Tee", "teriyaki_sauce": "Teriyaki-Soße", "thyme": "Thymian", @@ -401,20 +452,27 @@ "vegetables": "Gemüse", "vegetarian_cold_cuts": "vegetarischer Aufschnitt", "vinegar": "Essig", + "vitamin_tablets": "Vitamintabletten", "vodka": "Vodka", + "washing_gel": "Waschgel", "washing_powder": "Waschpulver", "water": "Wasser", "water_ice": "Wassereis", "watermelon": "Wassermelone", "wc_cleaner": "WC-Reiniger", + "wheat_flour": "Weizenmehl", "whipped_cream": "Schlagsahne", "white_wine": "Weißwein", "white_wine_vinegar": "Weißweinessig", "whole_canned_tomatoes": "Ganze Dosentomaten", "wild_berries": "Waldbeeren", + "wild_rice": "Wildreis", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Worcester Soße", "wrapping_paper": "Geschenkpapier", "wraps": "Wraps", "yeast": "Hefe", + "yeast_flakes": "Hefeflocken", "yoghurt": "Joghurt", "yogurt": "Joghurt", "yum_yum": "Yum Yum", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index 32137ff5..0475a81d 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -17,12 +17,15 @@ "apple": "Apple", "apple_pulp": "Apple pulp", "applesauce": "Applesauce", + "apricots": "Apricots", "apérol": "Apérol", "arugula": "Arugula", "asian_egg_noodles": "Asian egg noodles", + "asian_noodles": "Asian noodles", "asparagus": "Asparagus", "aspirin": "Aspirin", "avocado": "Avocado", + "baby_potatoes": "Triplets", "baby_spinach": "Baby spinach", "bacon": "Bacon", "baguette": "Baguette", @@ -80,11 +83,15 @@ "cheese": "Cheese", "cherry_tomatoes": "Cherry tomatoes", "chickpeas": "Chickpeas", + "chicory": "Chicory", "chili_oil": "Chili oil", + "chili_pepper": "Chili pepper", "chips": "Chips", "chives": "Chives", "chocolate": "Chocolate", + "chocolate_chips": "Chocolate chips", "chopped_tomatoes": "Chopped tomatoes", + "chunky_tomatoes": "Chunky tomatoes", "ciabatta": "Ciabatta", "cider_vinegar": "Cider vinegar", "cilantro": "Cilantro", @@ -103,6 +110,7 @@ "cornflakes": "Cornflakes", "cornstarch": "Cornstarch", "cornys": "Cornys", + "corriander": "Corriander", "cough_drops": "Cough drops", "couscous": "Couscous", "covid_rapid_test": "COVID rapid test", @@ -113,23 +121,32 @@ "creme_fraiche": "Creme fraiche", "crepe_tape": "Crepe tape", "crispbread": "Crispbread", + "granola": "Granola", "cucumber": "Cucumber", "cumin": "Cumin", + "curd": "Curd", "curry_paste": "Curry paste", "curry_powder": "Curry powder", "curry_sauce": "Curry sauce", "dates": "Dates", "dental_floss": "Dental floss", + "deo": "Deodorant", "deodorant": "Deodorant", "detergent": "Detergent", + "detergent_sheets": "Detergent sheets", + "diarrhea_remedy": "Diarrhea remedy", "dill": "Dill", "dishwasher_salt": "Dishwasher salt", "dishwasher_tabs": "Dishwasher tabs", "disinfection_spray": "Disinfection spray", "dried_tomatoes": "Dried tomatoes", "edamame": "Edamame", + "egg_salad": "Egg salad", + "egg_yolk": "Egg yolk", "eggplant": "Eggplant", "eggs": "Eggs", + "enoki_mushrooms": "Enoki mushrooms", + "eyebrow_gel": "Eyebrow gel", "falafel": "Falafel", "falafel_powder": "Falafel powder", "fanta": "Fanta", @@ -143,6 +160,7 @@ "frozen_fruit": "Frozen fruit", "frozen_pizza": "Frozen pizza", "frozen_spinach": "Frozen spinach", + "funeral_card": "Funeral card", "garam_masala": "Garam Masala", "garbage_bag": "Garbage bag", "garlic": "Garlic", @@ -156,15 +174,19 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola_bar": "Granola bar", "grapes": "Grapes", "greek_yogurt": "Greek yogurt", "green_asparagus": "Green asparagus", "green_chili": "Green chili", "green_pesto": "Green pesto", "hair_gel": "Hair gel", + "hair_ties": "Hair ties", "hair_wax": "Hair Wax", + "hand_soap": "Hand soap", "handkerchief_box": "Handkerchief box", "handkerchiefs": "Handkerchiefs", + "hard_cheese": "Hard cheese", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Hazelnuts", @@ -180,6 +202,7 @@ "iced_tea": "Iced tea", "instant_soups": "Instant soups", "jam": "Jam", + "jasmine_rice": "Jasmine rice", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Kidney beans", @@ -192,21 +215,28 @@ "leaf_spinach": "Leaf spinach", "leek": "Leek", "lemon": "Lemon", + "lemon_curd": "Lemon Curd", "lemon_juice": "Lemon juice", "lemonade": "Lemonade", "lemongrass": "Lemongrass", + "lentil_stew": "Lentil stew", "lentils": "Lentils", "lentils_red": "Red lentils", "lettuce": "Lettuce", "lillet": "Lillet", "lime": "Lime", "linguine": "Linguine", + "lip_care": "Lip Care", "low-fat_curd_cheese": "Low-fat curd cheese", + "maggi": "Maggi", "magnesium": "Magnesium", "mango": "Mango", + "maple_syrup": "Maple syrup", "margarine": "Margarine", "marjoram": "Marjoram", "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", "mask": "Mask", "mayonnaise": "Mayonnaise", "meat_substitute_product": "Meat substitute product", @@ -214,8 +244,10 @@ "milk": "Milk", "mint": "Mint", "mint_candy": "Mint candy", + "miso_paste": "Miso paste", "mixed_vegetables": "Mixed vegetables", "mochis": "Mochis", + "mold_remover": "Mold Remover", "mountain_cheese": "Mountain cheese", "mouth_wash": "Mouth wash", "mozzarella": "Mozzarella", @@ -224,6 +256,7 @@ "mulled_wine": "Mulled wine", "mushrooms": "Mushrooms", "mustard": "Mustard", + "nail_file": "Nail file", "neutral_oil": "Neutral oil", "nori_sheets": "Nori sheets", "nutmeg": "Nutmeg", @@ -232,16 +265,20 @@ "oatmeal_cookies": "Oatmeal cookies", "oatsome": "Oatsome", "obatzda": "Obatzda", + "oil": "Oil", "olive_oil": "Olive oil", "olives": "Olives", "onion": "Onion", + "onion_powder": "Onion powder", "orange_juice": "Orange juice", "oranges": "Oranges", "oregano": "Oregano", "organic_lemon": "Organic lemon", "organic_waste_bags": "Organic waste bags", "pak_choi": "Pak Choi", + "pantyhose": "Pantyhose", "paprika": "Paprika", + "paprika_seasoning": "Paprika seasoning", "pardina_lentils_dried": "Pardina lentils dried", "parmesan": "Parmesan", "parsley": "Parsley", @@ -263,11 +300,13 @@ "pine_nuts": "Pine nuts", "pineapple": "Pineapple", "pita_bag": "Pita bag", + "pita_bread": "Pita bread", "pizza": "Pizza", "pizza_dough": "Pizza dough", "plant_magarine": "Plant Magarine", "plant_oil": "Plant oil", "plaster": "Plaster", + "pointed_peppers": "Pointed peppers", "porcini_mushrooms": "Porcini mushrooms", "potato_dumpling_dough": "Potato dumpling dough", "potato_wedges": "Potato wedges", @@ -288,8 +327,10 @@ "rapeseed_oil": "Rapeseed oil", "raspberries": "Raspberries", "raspberry_syrup": "Raspberry syrup", + "razor_blades": "Razor blades", "red_bull": "Red Bull", "red_chili": "Red chili", + "red_curry_paste": "Red curry paste", "red_lentils": "Red lentils", "red_onions": "Red onions", "red_pesto": "Red pesto", @@ -299,6 +340,7 @@ "ribbon_noodles": "Ribbon noodles", "rice": "Rice", "rice_cakes": "Rice cakes", + "rice_paper": "Rice paper", "rice_ribbon_noodles": "Rice ribbon noodles", "rice_vinegar": "Rice vinegar", "ricotta": "Ricotta", @@ -324,7 +366,6 @@ "scattered_cheese": "Scattered cheese", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "chocolate_chips": "Chocolate chips", "semolina_porridge": "Semolina porridge", "sesame": "Sesame", "sesame_oil": "Sesame oil", @@ -341,10 +382,13 @@ "smoked_tofu": "Smoked tofu", "snacks": "Snacks", "soap": "Soap", + "soba_noodles": "Soba noodles", "soft_drinks": "Soft drinks", "softdrinks": "Softdrinks", + "soup_vegetables": "Soup vegetables", "sour_cream": "Sour cream", "sour_cucumbers": "Sour cucumbers", + "soy_cream": "Soy cream", "soy_hack": "Soy hack", "soy_sauce": "Soy sauce", "soy_shred": "Soy shred", @@ -354,6 +398,7 @@ "spelt": "Spelt", "spinach": "Spinach", "sponge_cloth": "Sponge cloth", + "sponge_fingers": "Sponge fingers", "sponge_wipes": "Sponge wipes", "sponges": "Sponges", "spreading_cream": "Spreading cream", @@ -362,18 +407,24 @@ "sprouts": "Sprouts", "sriracha": "Sriracha", "strained_tomatoes": "Strained tomatoes", + "strawberries": "Strawberries", "sugar": "Sugar", "summer_roll_paper": "Summer roll paper", + "sunflower_oil": "Sunflower oil", "sunflower_seeds": "Sunflower seeds", + "sunscreen": "Sunscreen", "sushi_rice": "Sushi rice", "swabian_ravioli": "Swabian ravioli", + "sweet_chili_sauce": "Sweet Chili Sauce", "sweet_potato": "Sweet potato", "sweet_potatoes": "Sweet potatoes", + "sweets": "Sweets", "table_salt": "Table salt", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Tangerines", "tape": "Tape", + "tapioca_flour": "Tapioca flour", "tea": "Tea", "teriyaki_sauce": "Teriyaki sauce", "thyme": "Thyme", @@ -401,20 +452,27 @@ "vegetables": "Vegetables", "vegetarian_cold_cuts": "vegetarian cold cuts", "vinegar": "Vinegar", + "vitamin_tablets": "Vitamin tablets", "vodka": "Vodka", + "washing_gel": "Washing gel", "washing_powder": "Washing powder", "water": "Water", "water_ice": "Water ice", "watermelon": "Watermelon", "wc_cleaner": "WC cleaner", + "wheat_flour": "Wheat flour", "whipped_cream": "Whipped cream", "white_wine": "White wine", "white_wine_vinegar": "White wine vinegar", "whole_canned_tomatoes": "Whole canned tomatoes", "wild_berries": "Wild berries", + "wild_rice": "Wild rice", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Worcester sauce", "wrapping_paper": "Wrapping paper", "wraps": "Wraps", "yeast": "Yeast", + "yeast_flakes": "Yeast flakes", "yoghurt": "Yoghurt", "yogurt": "Yogurt", "yum_yum": "Yum Yum", From 8147dcd7893fd319c7118fb23c33303c19d71818 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 1 Aug 2023 23:55:05 +0200 Subject: [PATCH 362/496] feat: improve default item/categories handling --- backend/app/models/category.py | 8 ++++- backend/app/models/item.py | 5 +++ backend/app/service/import_language.py | 19 ++++++---- backend/migrations/versions/3647c9eb1881_.py | 38 ++++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 backend/migrations/versions/3647c9eb1881_.py diff --git a/backend/app/models/category.py b/backend/app/models/category.py index 7ca478b5..71088caa 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -10,6 +10,7 @@ class Category(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) default = db.Column(db.Boolean, default=False) + default_key = db.Column(db.String(128)) ordering = db.Column(db.Integer, default=0) household_id = db.Column(db.Integer, db.ForeignKey( 'household.id'), nullable=False) @@ -27,16 +28,21 @@ def all_by_ordering(cls, household_id: int): return cls.query.filter(cls.household_id == household_id).order_by(cls.ordering, cls.name).all() @classmethod - def create_by_name(cls, household_id: int, name, default=False) -> Self: + def create_by_name(cls, household_id: int, name, default=False, default_key=None) -> Self: return cls( name=name, default=default, + default_key=default_key, household_id=household_id, ).save() @classmethod def find_by_name(cls, household_id: int, name: str) -> Self: return cls.query.filter(cls.name == name, cls.household_id == household_id).first() + + @classmethod + def find_by_default_key(cls, household_id: int, default_key: str) -> Self: + return cls.query.filter(cls.default_key == default_key, cls.household_id == household_id).first() @classmethod def find_by_id(cls, id: int) -> Self: diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 2d924708..4cacb848 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -13,6 +13,7 @@ class Item(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): icon = db.Column(db.String(128), nullable=True) category_id = db.Column(db.Integer, db.ForeignKey('category.id')) default = db.Column(db.Boolean, default=False) + default_key = db.Column(db.String(128)) household_id = db.Column(db.Integer, db.ForeignKey( 'household.id'), nullable=False) @@ -70,6 +71,10 @@ def create_by_name(cls, household_id: int, name: str, default: bool = False) -> def find_by_name(cls, household_id: int, name: str) -> Self: name = name.strip() return cls.query.filter(cls.household_id == household_id, cls.name == name).first() + + @classmethod + def find_by_default_key(cls, household_id: int, default_key: str) -> Self: + return cls.query.filter(cls.household_id == household_id, cls.default_key == default_key).first() @classmethod def find_by_id(cls, id) -> Self: diff --git a/backend/app/service/import_language.py b/backend/app/service/import_language.py index 5c7f2183..fc8c9d04 100644 --- a/backend/app/service/import_language.py +++ b/backend/app/service/import_language.py @@ -19,7 +19,7 @@ def importLanguage(household_id, lang, bulkSave=False): t0 = time.time() models: list[Item] = [] for key, name in data["items"].items(): - item = Item.find_by_name(household_id, name) + item = Item.find_by_default_key(household_id, key) or Item.find_by_name(household_id, name) if not item: # slow but needed to filter out duplicate names if bulkSave and any(i.name == name for i in models): @@ -28,19 +28,26 @@ def importLanguage(household_id, lang, bulkSave=False): item.name = name.strip() item.household_id = household_id item.default = True + item.default_key = key + + if not item.default_key: # migrate to new system + item.default_key = key if item.default: if key in attributes["items"] and "icon" in attributes["items"][key]: item.icon = attributes["items"][key]["icon"] # Category not already set for existing item and category set for template and category translation exist for language - if not item.category_id and key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: - category_name = data["categories"][attributes["items"] - [key]["category"]] - category = Category.find_by_name(household_id, category_name) + if key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: + category_key = attributes["items"][key]["category"] + category_name = data["categories"][category_key] + category = Category.find_by_default_key(household_id, category_key) or Category.find_by_name(household_id, category_name) if not category: category = Category.create_by_name( - household_id, category_name, True) + household_id, category_name, True, category_key) + if not category.default_key: # migrate to new system + category.default_key = category_key + category.save() item.category = category if not bulkSave: item.save(keepDefault=True) diff --git a/backend/migrations/versions/3647c9eb1881_.py b/backend/migrations/versions/3647c9eb1881_.py new file mode 100644 index 00000000..c5af7b42 --- /dev/null +++ b/backend/migrations/versions/3647c9eb1881_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 3647c9eb1881 +Revises: 5140d8f9339b +Create Date: 2023-08-01 23:48:05.570802 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3647c9eb1881' +down_revision = '5140d8f9339b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.add_column(sa.Column('default_key', sa.String(length=128), nullable=True)) + + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.add_column(sa.Column('default_key', sa.String(length=128), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_column('default_key') + + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.drop_column('default_key') + + # ### end Alembic commands ### From 67445d740606a73ebd86aa24d026d77a7035f1a5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 3 Aug 2023 09:59:28 +0200 Subject: [PATCH 363/496] feat: support item & category merging --- .../category/category_controller.py | 6 ++ backend/app/controller/category/schemas.py | 6 ++ .../app/controller/item/item_controller.py | 10 ++++ backend/app/controller/item/schemas.py | 10 ++++ backend/app/models/category.py | 31 +++++++++- backend/app/models/item.py | 58 ++++++++++++++++++- backend/app/service/import_language.py | 12 ++-- 7 files changed, 125 insertions(+), 8 deletions(-) diff --git a/backend/app/controller/category/category_controller.py b/backend/app/controller/category/category_controller.py index 4c641c19..d19541e7 100644 --- a/backend/app/controller/category/category_controller.py +++ b/backend/app/controller/category/category_controller.py @@ -52,6 +52,12 @@ def updateCategory(args, id): if 'ordering' in args 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 mergeCategory: + category.merge(mergeCategory) + return jsonify(category.obj_to_dict()) diff --git a/backend/app/controller/category/schemas.py b/backend/app/controller/category/schemas.py index 19e46341..1f1d7c3a 100644 --- a/backend/app/controller/category/schemas.py +++ b/backend/app/controller/category/schemas.py @@ -18,6 +18,12 @@ class UpdateCategory(Schema): validate=lambda i: i >= 0 ) + # 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, + ) + class DeleteCategory(Schema): name = fields.String( diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 88c799ac..ede138fb 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -79,5 +79,15 @@ def updateItem(args, id): raise InvalidUsage() if 'icon' in args: item.icon = args['icon'] + if 'name' in args and args['name'] != item.name: + newName: str = args['name'].strip() + if not Item.search_name(newName, item.household_id): + 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 mergeItem: + item.merge(mergeItem) + return jsonify(item.obj_to_dict()) diff --git a/backend/app/controller/item/schemas.py b/backend/app/controller/item/schemas.py index 94d6af7f..42a26afc 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -28,3 +28,13 @@ class Meta: 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, + ) diff --git a/backend/app/models/category.py b/backend/app/models/category.py index 71088caa..cbd13c90 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -39,7 +39,7 @@ def create_by_name(cls, household_id: int, name, default=False, default_key=None @classmethod def find_by_name(cls, household_id: int, name: str) -> Self: return cls.query.filter(cls.name == name, cls.household_id == household_id).first() - + @classmethod def find_by_default_key(cls, household_id: int, default_key: str) -> Self: return cls.query.filter(cls.default_key == default_key, cls.household_id == household_id).first() @@ -50,17 +50,18 @@ def find_by_id(cls, id: int) -> Self: def reorder(self, newIndex: int): cls = self.__class__ - self.ordering = newIndex l: list[cls] = cls.query.filter(cls.household_id == self.household_id).order_by( cls.ordering, cls.name).all() + self.ordering = min(newIndex, len(l) - 1) + oldIndex = list(map(lambda x: x.id, l)).index(self.id) if oldIndex < 0: raise Exception() # Something went wrong e = l.pop(oldIndex) - l.insert(newIndex, e) + l.insert(self.ordering, e) for i, category in enumerate(l): category.ordering = i @@ -71,3 +72,27 @@ def reorder(self, newIndex: int): except Exception as e: db.session.rollback() raise e + + def merge(self, other: Self) -> None: + if self.household_id != other.household_id: + return + + from app.models import Item + + if not self.default_key and other.default_key: + self.default_key = other.default_key + self.default = other.default + + for item in Item.query.filter(Item.category_id == other.id).all(): + item.category_id = self.id + db.session.add(item) + + try: + db.session.add(self) + db.session.commit() + other.delete() + except Exception as e: + db.session.rollback() + raise e + + self.reorder(self.ordering) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 4cacb848..c1219f7e 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -3,6 +3,7 @@ from app import db from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin from app.models.category import Category +from app.util import description_merger class Item(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): @@ -59,6 +60,61 @@ def save(self, keepDefault=False) -> Self: self.default = False return super().save() + def merge(self, other: Self) -> None: + if other.household_id != self.household_id: + return + + from app.models import RecipeItems + from app.models import History + from app.models import ShoppinglistItems + + if not self.default_key and other.default_key: + self.default_key = other.default_key + + if not self.category_id and other.category_id: + self.category_id = other.category_id + + if not self.icon and other.icon: + self.icon = other.icon + + for ri in RecipeItems.query.filter(RecipeItems.item_id == other.id).all(): + ri: RecipeItems + existingRi = RecipeItems.find_by_ids(ri.recipe_id, self.id) + if not existingRi: + ri.item_id = self.id + db.session.add(ri) + else: + existingRi.description = description_merger.merge( + existingRi.description, ri.description) + db.session.delete(ri) + db.session.add(existingRi) + + for si in ShoppinglistItems.query.filter(ShoppinglistItems.item_id == other.id).all(): + si: ShoppinglistItems + existingSi = ShoppinglistItems.find_by_ids( + si.shoppinglist_id, self.id) + if not existingSi: + si.item_id = self.id + db.session.add(si) + else: + existingSi.description = description_merger.merge( + existingSi.description, si.description) + db.session.delete(si) + db.session.add(existingSi) + + for history in History.query.filter(History.item_id == other.id).all(): + history.item_id = self.id + db.session.add(history) + + + try: + db.session.add(self) + db.session.commit() + other.delete() + except Exception as e: + db.session.rollback() + raise e + @classmethod def create_by_name(cls, household_id: int, name: str, default: bool = False) -> Self: return cls( @@ -71,7 +127,7 @@ def create_by_name(cls, household_id: int, name: str, default: bool = False) -> def find_by_name(cls, household_id: int, name: str) -> Self: name = name.strip() return cls.query.filter(cls.household_id == household_id, cls.name == name).first() - + @classmethod def find_by_default_key(cls, household_id: int, default_key: str) -> Self: return cls.query.filter(cls.household_id == household_id, cls.default_key == default_key).first() diff --git a/backend/app/service/import_language.py b/backend/app/service/import_language.py index fc8c9d04..35723a30 100644 --- a/backend/app/service/import_language.py +++ b/backend/app/service/import_language.py @@ -19,7 +19,8 @@ def importLanguage(household_id, lang, bulkSave=False): t0 = time.time() models: list[Item] = [] for key, name in data["items"].items(): - item = Item.find_by_default_key(household_id, key) or Item.find_by_name(household_id, name) + item = Item.find_by_default_key( + household_id, key) or Item.find_by_name(household_id, name) if not item: # slow but needed to filter out duplicate names if bulkSave and any(i.name == name for i in models): @@ -30,10 +31,12 @@ def importLanguage(household_id, lang, bulkSave=False): item.default = True item.default_key = key - if not item.default_key: # migrate to new system + if not item.default_key: # migrate to new system item.default_key = key if item.default: + item.name = name.strip() + if key in attributes["items"] and "icon" in attributes["items"][key]: item.icon = attributes["items"][key]["icon"] @@ -41,11 +44,12 @@ def importLanguage(household_id, lang, bulkSave=False): if key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: category_key = attributes["items"][key]["category"] category_name = data["categories"][category_key] - category = Category.find_by_default_key(household_id, category_key) or Category.find_by_name(household_id, category_name) + category = Category.find_by_default_key( + household_id, category_key) or Category.find_by_name(household_id, category_name) if not category: category = Category.create_by_name( household_id, category_name, True, category_key) - if not category.default_key: # migrate to new system + if not category.default_key: # migrate to new system category.default_key = category_key category.save() item.category = category From 51ccfca2d703856c8692f606be9c7240ad7d1544 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 3 Aug 2023 21:05:29 +0200 Subject: [PATCH 364/496] chore: upgrade requirements --- backend/requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 573a8ef1..884f7435 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,7 +22,7 @@ Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.2 Flask-Migrate==4.0.4 Flask-SQLAlchemy==3.0.5 -fonttools==4.41.1 +fonttools==4.42.0 greenlet==2.0.2 html-text==0.5.2 html5lib==1.1 @@ -46,7 +46,7 @@ mf2py==1.1.3 mlxtend==0.22.0 mypy-extensions==1.0.0 nltk==3.8.1 -numpy==1.25.1 +numpy==1.25.2 packaging==23.1 pandas==2.0.3 pathspec==0.11.2 @@ -69,9 +69,9 @@ python-dateutil==2.8.2 python-editor==1.0.4 pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 -rdflib==6.3.2 +rdflib==7.0.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.40.0 +recipe-scrapers==14.41.0 regex==2023.6.3 requests==2.31.0 scikit-learn==1.3.0 @@ -94,6 +94,6 @@ tzdata==2023.3 tzlocal==5.0.1 urllib3==2.0.4 uWSGI==2.0.22 -w3lib==2.1.1 +w3lib==2.1.2 webencodings==0.5.1 Werkzeug==2.3.6 From dd216c947edbb03c3b6e36aba8a3d8eb4652b471 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 3 Aug 2023 21:08:12 +0200 Subject: [PATCH 365/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Russian) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ru/ * Translated using Weblate (Russian) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ru/ * Translated using Weblate (English) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/en/ * Translated using Weblate (German) Currently translated at 100.0% (419 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/de/ * Translated using Weblate (Portuguese) Currently translated at 99.5% (417 of 419 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt/ * Translated using Weblate (French) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fr/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nb_NO/ * Translated using Weblate (Portuguese) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt_BR/ * Translated using Weblate (Spanish) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/id/ * Translated using Weblate (Russian) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ru/ * Translated using Weblate (Danish) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/da/ * Translated using Weblate (Polish) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pl/ * Translated using Weblate (Dutch) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nl/ * Translated using Weblate (Italian) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/it/ * Translated using Weblate (Finnish) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fi/ * Translated using Weblate (Greek) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/el/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/zh_Hans/ * Translated using Weblate (Turkish) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/tr/ * Translated using Weblate (Greek) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/el/ * Translated using Weblate (Portuguese) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt/ --------- Co-authored-by: gekenson Co-authored-by: FalseMetal Co-authored-by: Allan Nordhøy Co-authored-by: Tom Bursch Co-authored-by: BeardedWatermelon Co-authored-by: raphael lomonaco --- backend/templates/l10n/da.json | 62 +++++++++++++++++++++- backend/templates/l10n/de.json | 6 +-- backend/templates/l10n/el.json | 60 +++++++++++++++++++++- backend/templates/l10n/en.json | 4 +- backend/templates/l10n/es.json | 62 +++++++++++++++++++++- backend/templates/l10n/fi.json | 62 +++++++++++++++++++++- backend/templates/l10n/fr.json | 62 +++++++++++++++++++++- backend/templates/l10n/id.json | 58 +++++++++++++++++++++ backend/templates/l10n/it.json | 62 +++++++++++++++++++++- backend/templates/l10n/nb_NO.json | 62 +++++++++++++++++++++- backend/templates/l10n/nl.json | 62 +++++++++++++++++++++- backend/templates/l10n/pl.json | 60 +++++++++++++++++++++- backend/templates/l10n/pt.json | 80 +++++++++++++++++++++++++---- backend/templates/l10n/pt_BR.json | 64 +++++++++++++++++++++-- backend/templates/l10n/ru.json | 72 +++++++++++++++++++++++--- backend/templates/l10n/tr.json | 62 +++++++++++++++++++++- backend/templates/l10n/zh_Hans.json | 58 +++++++++++++++++++++ 17 files changed, 914 insertions(+), 44 deletions(-) diff --git a/backend/templates/l10n/da.json b/backend/templates/l10n/da.json index df3237f8..26306fff 100644 --- a/backend/templates/l10n/da.json +++ b/backend/templates/l10n/da.json @@ -17,12 +17,15 @@ "apple": "Æble", "apple_pulp": "Æblemos", "applesauce": "Æblemos", + "apricots": "Abrikoser", "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Asiatiske ægnudler", + "asian_noodles": "Asiatiske nudler", "asparagus": "Asparges", "aspirin": "Aspirin", "avocado": "Avocado", + "baby_potatoes": "Trillinger", "baby_spinach": "Babyspinat", "bacon": "Bacon", "baguette": "Baguette", @@ -80,12 +83,15 @@ "cheese": "Ost", "cherry_tomatoes": "Cherrytomater", "chickpeas": "Kikærter", + "chicory": "Cikorie", "chili_oil": "Chiliolie", + "chili_pepper": "Chilipeber", "chips": "Chips", "chives": "Purløg", "chocolate": "Chokolade", "chocolate_chips": "Chokoladestykker", "chopped_tomatoes": "Hakkede tomater", + "chunky_tomatoes": "Stærke tomater", "ciabatta": "Ciabatta", "cider_vinegar": "Æblecidereddike", "cilantro": "Cilantro", @@ -104,6 +110,7 @@ "cornflakes": "Cornflakes", "cornstarch": "Majsstivelse", "cornys": "Cornys", + "corriander": "Koriander", "cough_drops": "Hostedråber", "couscous": "Couscous", "covid_rapid_test": "COVID-sneltest", @@ -116,21 +123,29 @@ "crispbread": "Knækbrød", "cucumber": "Agurk", "cumin": "Spidskommen", + "curd": "Ostemasse", "curry_paste": "Karrypasta", "curry_powder": "Karrypulver", "curry_sauce": "Karrysauce", "dates": "Datoer", "dental_floss": "Tandtråd", + "deo": "Deodorant", "deodorant": "Deodorant", "detergent": "Vaskemiddel", + "detergent_sheets": "Vaskemiddelark", + "diarrhea_remedy": "Middel mod diarré", "dill": "Dild", "dishwasher_salt": "Salt til opvaskemaskine", "dishwasher_tabs": "Tabs til opvaskemaskine", "disinfection_spray": "Desinfektionsspray", "dried_tomatoes": "Tørrede tomater", "edamame": "Edamame", + "egg_salad": "Æggesalat", + "egg_yolk": "Æggeblomme", "eggplant": "Aubergine", "eggs": "Æg", + "enoki_mushrooms": "Enoki-svampe", + "eyebrow_gel": "Gel til øjenbryn", "falafel": "Falafel", "falafel_powder": "Falafel-pulver", "fanta": "Fanta", @@ -144,8 +159,9 @@ "frozen_fruit": "Frossen frugt", "frozen_pizza": "Frossen pizza", "frozen_spinach": "Frossen spinat", + "funeral_card": "Begravelseskort", "garam_masala": "Garam Masala", - "garbage_bag": "Affaldspose", + "garbage_bag": "Affaldsposer", "garlic": "Hvidløg", "garlic_dip": "Hvidløgsdip", "garlic_granules": "Hvidløg i granulatform", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Granola-bar", "grapes": "Druer", "greek_yogurt": "Græsk yoghurt", "green_asparagus": "Grønne asparges", "green_chili": "Grøn chili", "green_pesto": "Grøn pesto", "hair_gel": "Hårgel", + "hair_ties": "Hårbøjler", "hair_wax": "Hårvoks", + "hand_soap": "Håndsæbe", "handkerchief_box": "Lommetørklæde boks", "handkerchiefs": "Lommetørklæder", + "hard_cheese": "Hård ost", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Hasselnødder", @@ -176,11 +197,12 @@ "honey_wafers": "Honningvafler", "hot_dog_bun": "Hotdog-bolle", "ice_cream": "Is", - "ice_cube": "Isterning", + "ice_cube": "Isterninger", "iceberg_lettuce": "Iceberg-salat", "iced_tea": "Iste", "instant_soups": "Instant-supper", "jam": "Syltetøj", + "jasmine_rice": "Jasminris", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Kidneybønner", @@ -193,21 +215,28 @@ "leaf_spinach": "Bladspinat", "leek": "Porre", "lemon": "Citron", + "lemon_curd": "Citronfromage", "lemon_juice": "Citronsaft", "lemonade": "Lemonade", "lemongrass": "Citrongræs", + "lentil_stew": "Linsestuvning", "lentils": "Linser", "lentils_red": "Røde linser", "lettuce": "Salat", "lillet": "Lillet", "lime": "Lime", "linguine": "Linguine", + "lip_care": "Læbepleje", "low-fat_curd_cheese": "Ostemasse med lavt fedtindhold", + "maggi": "Maggi", "magnesium": "Magnesium", "mango": "Mango", + "maple_syrup": "Ahornsirup", "margarine": "Margarine", "marjoram": "Merian", "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", "mask": "Maske", "mayonnaise": "Mayonnaise", "meat_substitute_product": "Køderstatningsprodukt", @@ -215,8 +244,10 @@ "milk": "Mælk", "mint": "Mynte", "mint_candy": "Mint slik", + "miso_paste": "Miso-pasta", "mixed_vegetables": "Blandede grøntsager", "mochis": "Mochis", + "mold_remover": "Fjernelse af skimmelsvamp", "mountain_cheese": "Ost fra bjergene", "mouth_wash": "Mundskylning", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Gløgg", "mushrooms": "Svampe", "mustard": "Sennep", + "nail_file": "Neglefil", "neutral_oil": "Neutral olie", "nori_sheets": "Nori-ark", "nutmeg": "Muskatnød", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Havregrynskager", "oatsome": "Oatsome", "obatzda": "Obatzda", + "oil": "Olie", "olive_oil": "Olivenolie", "olives": "Oliven", "onion": "Løg", + "onion_powder": "Løgpulver", "orange_juice": "Appelsinjuice", "oranges": "Appelsiner", "oregano": "Oregano", "organic_lemon": "Økologisk citron", "organic_waste_bags": "Poser til organisk affald", "pak_choi": "Pak Choi", + "pantyhose": "Strømpebukser", "paprika": "Paprika", + "paprika_seasoning": "Paprika-krydderi", "pardina_lentils_dried": "Pardina-linser, tørrede", "parmesan": "Parmesan", "parsley": "Persille", @@ -264,11 +300,13 @@ "pine_nuts": "Pinjekerner", "pineapple": "Ananas", "pita_bag": "Pita-pose", + "pita_bread": "Pitabrød", "pizza": "Pizza", "pizza_dough": "Pizzadej", "plant_magarine": "Plante Magarine", "plant_oil": "Planteolie", "plaster": "Gips", + "pointed_peppers": "Spidse peberfrugter", "porcini_mushrooms": "Porcini-svampe", "potato_dumpling_dough": "Dej til kartoffelboller", "potato_wedges": "Kartoffelkiler", @@ -289,8 +327,10 @@ "rapeseed_oil": "Rapsolie", "raspberries": "Hindbær", "raspberry_syrup": "Hindbærsirup", + "razor_blades": "Barberblade", "red_bull": "Red Bull", "red_chili": "Rød chili", + "red_curry_paste": "Rød karrypasta", "red_lentils": "Røde linser", "red_onions": "Røde løg", "red_pesto": "Rød pesto", @@ -300,6 +340,7 @@ "ribbon_noodles": "Nudler med bånd", "rice": "Ris", "rice_cakes": "Ristkager", + "rice_paper": "Rispapir", "rice_ribbon_noodles": "Risbåndsnudler", "rice_vinegar": "Rice eddike", "ricotta": "Ricotta", @@ -341,10 +382,13 @@ "smoked_tofu": "Røget tofu", "snacks": "Snacks", "soap": "Sæbe", + "soba_noodles": "Soba-nudler", "soft_drinks": "Sodavand", "softdrinks": "Sodavand", + "soup_vegetables": "Suppe grøntsager", "sour_cream": "Creme fraiche", "sour_cucumbers": "Sure agurker", + "soy_cream": "Sojafløde", "soy_hack": "Soja hack", "soy_sauce": "Sojasovs", "soy_shred": "Soja strimler", @@ -354,6 +398,7 @@ "spelt": "Spelt", "spinach": "Spinat", "sponge_cloth": "Svampeklud", + "sponge_fingers": "Svampefingre", "sponge_wipes": "Svampeservietter", "sponges": "Svampe", "spreading_cream": "Smørcreme", @@ -362,18 +407,24 @@ "sprouts": "Spirer", "sriracha": "Sriracha", "strained_tomatoes": "Sigtede tomater", + "strawberries": "Jordbær", "sugar": "Sukker", "summer_roll_paper": "Sommerrullepapir", + "sunflower_oil": "Solsikkeolie", "sunflower_seeds": "Solsikkefrø", + "sunscreen": "Solcreme", "sushi_rice": "Sushiris", "swabian_ravioli": "Svabisk ravioli", + "sweet_chili_sauce": "Sød chilisauce", "sweet_potato": "Sød kartoffel", "sweet_potatoes": "Søde kartofler", + "sweets": "Slik", "table_salt": "Bordsalt", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Mandariner", "tape": "Bånd", + "tapioca_flour": "Tapiokamel", "tea": "Te", "teriyaki_sauce": "Teriyaki-sauce", "thyme": "Timian", @@ -401,20 +452,27 @@ "vegetables": "Grøntsager", "vegetarian_cold_cuts": "vegetarisk pålæg", "vinegar": "Eddike", + "vitamin_tablets": "Vitamintabletter", "vodka": "Vodka", + "washing_gel": "Vaskegel", "washing_powder": "Vaskepulver", "water": "Vand", "water_ice": "Vandis", "watermelon": "Vandmelon", "wc_cleaner": "WC-rengøringsmiddel", + "wheat_flour": "Hvedemel", "whipped_cream": "Flødeskum", "white_wine": "Hvidvin", "white_wine_vinegar": "Hvidvinseddike", "whole_canned_tomatoes": "Hele tomater på dåse", "wild_berries": "Vilde bær", + "wild_rice": "Vilde ris", + "wildberry_lillet": "Vildbær Lillet", + "worcester_sauce": "Worcester sauce", "wrapping_paper": "Indpakningspapir", "wraps": "Indpakninger", "yeast": "Gær", + "yeast_flakes": "Gærflager", "yoghurt": "Yoghurt", "yogurt": "Yoghurt", "yum_yum": "Yum Yum Yum", diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index ec9abd8d..e773e939 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -50,7 +50,7 @@ "birthday_card": "Geburtstagskarte", "black_beans": "Schwarze Bohnen", "bockwurst": "Bockwurst", - "bodywash": "Bodywash", + "bodywash": "Duschgel", "bread": "Brot", "breadcrumbs": "Paniermehl", "broccoli": "Brokkoli", @@ -59,8 +59,8 @@ "buffalo_mozzarella": "Büffelmozzarella", "buko": "Buko", "buns": "Buns", - "burger_buns": "Burger Buns", - "burger_patties": "Burger Patties", + "burger_buns": "Burgerbrötchen", + "burger_patties": "Burgerpatties", "burger_sauces": "Burgersaucen", "butter": "Butter", "butter_cookies": "Butterkekse", diff --git a/backend/templates/l10n/el.json b/backend/templates/l10n/el.json index cb4618c3..317105b1 100644 --- a/backend/templates/l10n/el.json +++ b/backend/templates/l10n/el.json @@ -17,12 +17,15 @@ "apple": "Μήλο", "apple_pulp": "Πολτός μήλου", "applesauce": "Σάλτσα μήλου", + "apricots": "Βερίκοκα", "apérol": "Άπερολ", "arugula": "Ρόκα", "asian_egg_noodles": "Ασιάτικα νούντλς αυγού", + "asian_noodles": "Ασιατικά νουντλς", "asparagus": "Σπαράγγια", "aspirin": "Ασπιρίνη", "avocado": "Αβοκάντο", + "baby_potatoes": "Τρίδυμα", "baby_spinach": "Baby σπανάκι", "bacon": "Μπέικον", "baguette": "Μπαγκέτα", @@ -80,12 +83,15 @@ "cheese": "Τυρί", "cherry_tomatoes": "Ντοματίνια", "chickpeas": "Ρεβύθια", + "chicory": "Ραδίκι", "chili_oil": "Λάδι Τσίλι", + "chili_pepper": "Πιπέρι τσίλι", "chips": "Πατατάκια", "chives": "Σχοινόπρασο", "chocolate": "Σοκολάτα", "chocolate_chips": "Κομματάκια σοκολάτας", "chopped_tomatoes": "Κομμένες ντομάτες", + "chunky_tomatoes": "Ντομάτες με κομμάτια", "ciabatta": "Τσιαμπάτα", "cider_vinegar": "Ξίδι μηλίτη", "cilantro": "Κόλιαντρο", @@ -104,6 +110,7 @@ "cornflakes": "Δημητριακά", "cornstarch": "Άμυλο καλαμποκιού", "cornys": "Κόρνις", + "corriander": "Κορύανδρος", "cough_drops": "Παστίλιες για τον βήχα", "couscous": "Κουσκους", "covid_rapid_test": "COVID ράπιντ τεστ", @@ -116,21 +123,29 @@ "crispbread": "Τραγανόψωμο", "cucumber": "Αγγούρι", "cumin": "Κύμινο", + "curd": "Τυρί", "curry_paste": "Πάστα κάρι", "curry_powder": "Σκόνη κάρι", "curry_sauce": "Σάλτσα κάρι", "dates": "Χουρμάδες", "dental_floss": "Οδοντικό νήμα", + "deo": "Αποσμητικό", "deodorant": "Αποσμητικό", "detergent": "Απορρυπαντικό", + "detergent_sheets": "Φύλλα απορρυπαντικού", + "diarrhea_remedy": "Θεραπεία διάρροιας", "dill": "Άνηθος", "dishwasher_salt": "Σκόνη πλυντηρίου πιάτων", "dishwasher_tabs": "Ταμπλέτες πλυντηρίου πιάτων", "disinfection_spray": "Απολυμαντικό σπρέι", "dried_tomatoes": "Αποξηραμένες ντομάτες", "edamame": "Εντάμαμε", + "egg_salad": "Σαλάτα με αυγά", + "egg_yolk": "Κρόκος αυγού", "eggplant": "Μελιτζάνα", "eggs": "Αυγά", + "enoki_mushrooms": "Μανιτάρια Enoki", + "eyebrow_gel": "Gel φρυδιών", "falafel": "Φαλάφελ", "falafel_powder": "Σκόνη φαλάφελ", "fanta": "Φάντα", @@ -144,8 +159,9 @@ "frozen_fruit": "Κατεψυγμένα φρούτα", "frozen_pizza": "Κατεψυγμένη πίτσα", "frozen_spinach": "Κατεψυγμένο σπανάκι", + "funeral_card": "Κάρτα κηδείας", "garam_masala": "Γκαράμ Μασάλα", - "garbage_bag": "Σακούλες σκουπιδιών", + "garbage_bag": "Σακούλες απορριμμάτων", "garlic": "Σκόρδο", "garlic_dip": "Σάλτσα σκόρδου", "garlic_granules": "Κόκκοι σκόρδου", @@ -157,15 +173,20 @@ "gochujang": "Κοτσουτζάν", "gorgonzola": "Γκοργκονζόλα", "gouda": "Γκούντα", + "granola": "Γκρανόλα", + "granola_bar": "Μπάρα γκρανόλα", "grapes": "Σταφύλια", "greek_yogurt": "Γιαούρτι", "green_asparagus": "Πράσινα σπαράγγια", "green_chili": "Πράσινο τσίλι", "green_pesto": "Πράσινο Πέστο", "hair_gel": "Τζέλ μαλλιών", + "hair_ties": "Γραβάτες μαλλιών", "hair_wax": "Κερί μαλλιών", + "hand_soap": "Σαπούνι χεριών", "handkerchief_box": "Κουτί χαρτομάντηλα", "handkerchiefs": "Χαρτομάντηλα", + "hard_cheese": "Σκληρό τυρί", "haribo": "Haribo", "harissa": "Harissa (καυτερή πάστα τσίλι)", "hazelnuts": "Φουντούκια", @@ -181,6 +202,7 @@ "iced_tea": "Κρύο Τσάι", "instant_soups": "Σούπες στιγμιαίες", "jam": "Μαρμελάδα", + "jasmine_rice": "Ρύζι γιασεμί", "katjes": "Τσίχλες βίγκαν", "ketchup": "Κέτσαπ", "kidney_beans": "Κόκκινα φασόλια", @@ -193,21 +215,28 @@ "leaf_spinach": "Φύλλο Σπανάκι", "leek": "Πράσο", "lemon": "Λεμόνι", + "lemon_curd": "Curd λεμονιού", "lemon_juice": "Χυμός λεμονιού", "lemonade": "Λεμονάδα", "lemongrass": "Λεμονόχορτο", + "lentil_stew": "Φακές στιφάδο", "lentils": "Φακές", "lentils_red": "Κόκκινες φακές", "lettuce": "Μαρούλι", "lillet": "Απεριτίφ", "lime": "Λάιμ", "linguine": "Λιγκουίνι", + "lip_care": "Φροντίδα χειλιών", "low-fat_curd_cheese": "Τυρί με χαμηλά λιπαρά", + "maggi": "Κύβος Maggi", "magnesium": "Μαγνήσιο", "mango": "Μάνγκο", + "maple_syrup": "Σιρόπι σφενδάμου", "margarine": "Μαργαρίνη", "marjoram": "Μαντζουράνα", "marshmallows": "Marshmallows", + "mascara": "Μάσκαρα", + "mascarpone": "Μασκαρπόνε", "mask": "Μάσκα", "mayonnaise": "Μαγιονέζα", "meat_substitute_product": "Υποκατάστατο κρέατος", @@ -215,8 +244,10 @@ "milk": "Γάλα", "mint": "Μέντα", "mint_candy": "Καραμέλα μέντας", + "miso_paste": "Πάστα Miso", "mixed_vegetables": "Ανάμεικτα λαχανικά", "mochis": "Mότσις", + "mold_remover": "Αφαίρεσης μούχλας", "mountain_cheese": "Τυρί βουνού", "mouth_wash": "Στοματικό διάλυμα", "mozzarella": "Μοτσαρέλα", @@ -225,6 +256,7 @@ "mulled_wine": "Ζεστό κρασί", "mushrooms": "Μανιτάρια", "mustard": "Μουστάρδα", + "nail_file": "Λίμα νυχιών", "neutral_oil": "Ουδέτερο λάδι", "nori_sheets": "Φύλλα από φύκια", "nutmeg": "Μοσχοκάρυδο", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Μπισκότα βρώμης", "oatsome": "Γάλα βρώμης", "obatzda": "Obatzda", + "oil": "Λάδι", "olive_oil": "Ελαιόλαδο", "olives": "Ελιές", "onion": "Κρεμμύδι", + "onion_powder": "Κρεμμύδι σε σκόνη", "orange_juice": "Πορτοκαλάδα", "oranges": "Πορτοκάλια", "oregano": "Ρίγανη", "organic_lemon": "Βιολογικό λεμόνι", "organic_waste_bags": "Οργανικές σακούλες σκουπιδιών", "pak_choi": "Μποκ τσόι", + "pantyhose": "Καλσόν", "paprika": "Πάπρικα", + "paprika_seasoning": "Καρύκευμα πάπρικας", "pardina_lentils_dried": "Αποξηραμένες φακές", "parmesan": "Παρμεζάνα", "parsley": "Μαϊντανός", @@ -264,11 +300,13 @@ "pine_nuts": "Κουκουνάρι", "pineapple": "Ανανάς", "pita_bag": "Σακούλα Πίτα", + "pita_bread": "Ψωμί πίτα", "pizza": "Πίτσα", "pizza_dough": "Ζύμη πίτσας", "plant_magarine": "Φυτική μαργαρίνη", "plant_oil": "Φυτικό λάδι", "plaster": "Γύψος", + "pointed_peppers": "Πιπεριές με αιχμή", "porcini_mushrooms": "Μανιτάρια πορτσίνι", "potato_dumpling_dough": "Ζύμη για ντάμπλινγκ πατάτας", "potato_wedges": "Κυδωνάτες πατάτες", @@ -289,8 +327,10 @@ "rapeseed_oil": "Κραμβέλαιο", "raspberries": "Βατόμουρα", "raspberry_syrup": "Σιρόπι βατόμουρου", + "razor_blades": "Λεπίδες ξυραφιού", "red_bull": "Red Bull", "red_chili": "Κόκκινο τσίλι", + "red_curry_paste": "Κόκκινη πάστα κάρυ", "red_lentils": "Κόκκινες φακές", "red_onions": "Κόκκινα κρεμμύδια", "red_pesto": "Κόκκινη πέστο", @@ -300,6 +340,7 @@ "ribbon_noodles": "Νούντλς κορδέλα", "rice": "Ρύζι", "rice_cakes": "Ρυζογκοφρέτες", + "rice_paper": "Χαρτί ρυζιού", "rice_ribbon_noodles": "Νούντλς κορδέλα από ρύζι", "rice_vinegar": "Ξύδι ρυζιού", "ricotta": "Ρικότα", @@ -341,10 +382,13 @@ "smoked_tofu": "Καπνιστό τόφου", "snacks": "Σνάκς", "soap": "Σαπούνι", + "soba_noodles": "Νουντλς σόμπα", "soft_drinks": "Αναψυκτικά", "softdrinks": "Αναψυκτικά", + "soup_vegetables": "Λαχανικά σούπας", "sour_cream": "Ξινή κρέμα", "sour_cucumbers": "Ξινά αγγούρια", + "soy_cream": "Κρέμα σόγιας", "soy_hack": "Σόγια", "soy_sauce": "Σάλτσα σόγιας", "soy_shred": "Τριμμένη σόγια", @@ -354,6 +398,7 @@ "spelt": "Όλυρα", "spinach": "Σπανάκι", "sponge_cloth": "Σφουγγαρόπανο", + "sponge_fingers": "Σφουγγαράκια", "sponge_wipes": "Μαντιλάκια σφουγγαριού", "sponges": "Σφουγγάρια", "spreading_cream": "Κρέμα αλειφόμμενη", @@ -362,18 +407,24 @@ "sprouts": "Βλαστάρια", "sriracha": "Σιράτσα", "strained_tomatoes": "Ντομάτες στραγγισμένες", + "strawberries": "Φράουλες", "sugar": "Ζάχαρη", "summer_roll_paper": "Φύλλο Spring Rolls", + "sunflower_oil": "Ηλιέλαιο", "sunflower_seeds": "Ηλιόσποροι", + "sunscreen": "Αντηλιακό", "sushi_rice": "Ρύζι για σούσι", "swabian_ravioli": "Σουηβικά ραβιόλια", + "sweet_chili_sauce": "Γλυκιά σάλτσα τσίλι", "sweet_potato": "Γλυκοπατάτα", "sweet_potatoes": "Γλυκοπατάτες", + "sweets": "Γλυκά", "table_salt": "Χοντρό αλάτι", "tagliatelle": "Ταλιατέλλες", "tahini": "Ταχίνι", "tangerines": "Μανταρίνια", "tape": "Ταινία", + "tapioca_flour": "Αλεύρι ταπιόκας", "tea": "Τσάι", "teriyaki_sauce": "Σάλτσα Τεριγιάκι", "thyme": "Θυμάρι", @@ -401,20 +452,27 @@ "vegetables": "Λαχανικά", "vegetarian_cold_cuts": "Χορτοφαγικά αλλαντικά", "vinegar": "Ξίδι", + "vitamin_tablets": "Ταμπλέτες βιταμινών", "vodka": "Βότκα", + "washing_gel": "Gel πλύσης", "washing_powder": "Σκόνη πλυσίματος", "water": "Νερό", "water_ice": "Παγωμένο νερό", "watermelon": "Καρπούζι", "wc_cleaner": "Καθαριστικό τουαλέτας", + "wheat_flour": "Αλεύρι σίτου", "whipped_cream": "Σαντιγύ", "white_wine": "Λευκό κρασί", "white_wine_vinegar": "Ξίδι λευκού κρασιού", "whole_canned_tomatoes": "Ντομάτες κονσέρβα", "wild_berries": "Άγρια μούρα", + "wild_rice": "Άγριο ρύζι", + "wildberry_lillet": "Άγριο μούρο Lillet", + "worcester_sauce": "Σάλτσα Worcester", "wrapping_paper": "Χαρτί περιτυλίγματος", "wraps": "Αραβικές πίτες", "yeast": "Μαγιά", + "yeast_flakes": "Νιφάδες μαγιάς", "yoghurt": "Γιαουρτάκι", "yogurt": "Κατσικίσιο γιαούρτι", "yum_yum": "Yum Yum Νούντλς", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index 0475a81d..e1f57087 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -162,7 +162,7 @@ "frozen_spinach": "Frozen spinach", "funeral_card": "Funeral card", "garam_masala": "Garam Masala", - "garbage_bag": "Garbage bag", + "garbage_bag": "Garbage bags", "garlic": "Garlic", "garlic_dip": "Garlic dip", "garlic_granules": "Garlic granules", @@ -197,7 +197,7 @@ "honey_wafers": "Honey wafers", "hot_dog_bun": "Hot dog bun", "ice_cream": "Ice cream", - "ice_cube": "Ice cube", + "ice_cube": "Ice cubes", "iceberg_lettuce": "Iceberg lettuce", "iced_tea": "Iced tea", "instant_soups": "Instant soups", diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index 001b83f6..a07794bc 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -17,12 +17,15 @@ "apple": "Manzana", "apple_pulp": "Pulpa de la manzana", "applesauce": "Compota de manzana", + "apricots": "Albaricoques", "apérol": "Aperol", "arugula": "Rúcula", "asian_egg_noodles": "Fideos asiáticos al huevo", + "asian_noodles": "Fideos asiáticos", "asparagus": "Espárragos", "aspirin": "Aspirina", "avocado": "Aguacate", + "baby_potatoes": "Trillizos", "baby_spinach": "Espinacas tiernas", "bacon": "Beicon", "baguette": "Baguette", @@ -80,12 +83,15 @@ "cheese": "Queso", "cherry_tomatoes": "Tomates cherry", "chickpeas": "Garbanzos", + "chicory": "Achicoria", "chili_oil": "Aceite de chile", + "chili_pepper": "Guindilla", "chips": "Patatas fritas", "chives": "Cebollino", "chocolate": "Chocolate", "chocolate_chips": "Chispas de chocolate", "chopped_tomatoes": "Tomates picados", + "chunky_tomatoes": "Tomates en trozos", "ciabatta": "Chapata", "cider_vinegar": "Vinagre de sidra", "cilantro": "Cilantro", @@ -104,6 +110,7 @@ "cornflakes": "Copos de maíz", "cornstarch": "Maicena", "cornys": "Cornys", + "corriander": "Cilantro", "cough_drops": "Pastillas para la tos", "couscous": "Cuscús", "covid_rapid_test": "Test rápido del COVID", @@ -116,21 +123,29 @@ "crispbread": "Pan crujiente", "cucumber": "Pepino", "cumin": "Comino", + "curd": "Cuajada", "curry_paste": "Pasta de curry", "curry_powder": "Curry en polvo", "curry_sauce": "Salsa de curry", "dates": "Fechas", "dental_floss": "Hilo dental", + "deo": "Desodorante", "deodorant": "Desodorante", "detergent": "Detergente", + "detergent_sheets": "Hojas de detergente", + "diarrhea_remedy": "Remedio para la diarrea", "dill": "Eneldo", "dishwasher_salt": "Sal para lavavajillas", "dishwasher_tabs": "Pastillas para el lavavajillas", "disinfection_spray": "Desinfectante en spray", "dried_tomatoes": "Tomates secos", "edamame": "Vainas de soja tiernas (Edamame)", + "egg_salad": "Ensalada de huevo", + "egg_yolk": "Yema de huevo", "eggplant": "Berenjena", "eggs": "Huevos", + "enoki_mushrooms": "Setas Enoki", + "eyebrow_gel": "Gel para cejas", "falafel": "Faláfel", "falafel_powder": "Falafel en polvo", "fanta": "Fanta", @@ -144,8 +159,9 @@ "frozen_fruit": "Fruta congelada", "frozen_pizza": "Pizza congelada", "frozen_spinach": "Espinacas congeladas", + "funeral_card": "Tarjeta funeraria", "garam_masala": "Garam Masala", - "garbage_bag": "Bolsa de basura", + "garbage_bag": "Bolsas de basura", "garlic": "Ajo", "garlic_dip": "Salsa de ajo", "garlic_granules": "Ajo granulado", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Queso Gorgonzola", "gouda": "Queso Gouda", + "granola": "Granola", + "granola_bar": "Barrita de cereales", "grapes": "Uvas", "greek_yogurt": "Yogur griego", "green_asparagus": "Espárragos verdes", "green_chili": "Guindilla verde", "green_pesto": "Pesto verde", "hair_gel": "Gomina", + "hair_ties": "Lazos para el pelo", "hair_wax": "Cera para el pelo", + "hand_soap": "Jabón de manos", "handkerchief_box": "Caja de pañuelos", "handkerchiefs": "Pañuelos", + "hard_cheese": "Queso duro", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Avellanas", @@ -176,11 +197,12 @@ "honey_wafers": "Barquillos de miel", "hot_dog_bun": "Panecillo para perritos calientes", "ice_cream": "Helados", - "ice_cube": "Cubito de hielo", + "ice_cube": "Cubitos de hielo", "iceberg_lettuce": "Lechuga iceberg", "iced_tea": "Té helado", "instant_soups": "Sopas instantáneas", "jam": "Mermelada", + "jasmine_rice": "Arroz jazmín", "katjes": "Katjes", "ketchup": "Kétchup", "kidney_beans": "Judías rojas (Frijoles)", @@ -193,21 +215,28 @@ "leaf_spinach": "Espinacas de hoja", "leek": "Puerro", "lemon": "Limón", + "lemon_curd": "Cuajada de limón", "lemon_juice": "Zumo de limón", "lemonade": "Limonada", "lemongrass": "Hierba limón", + "lentil_stew": "Guiso de lentejas", "lentils": "Lentejas", "lentils_red": "Lentejas rojas", "lettuce": "Lechuga", "lillet": "Lillet", "lime": "Lima", "linguine": "Linguine", + "lip_care": "Cuidado de los labios", "low-fat_curd_cheese": "Requesón bajo en grasa", + "maggi": "Maggi", "magnesium": "Magnesio", "mango": "Mango", + "maple_syrup": "Sirope de arce", "margarine": "Margarina", "marjoram": "Mejorana (Origanum majorana)", "marshmallows": "Malvaviscos", + "mascara": "Máscara", + "mascarpone": "Mascarpone", "mask": "Mascarilla", "mayonnaise": "Mayonesa", "meat_substitute_product": "Carne de origen vegetal (Tofu)", @@ -215,8 +244,10 @@ "milk": "Leche", "mint": "Menta", "mint_candy": "Caramelos de menta", + "miso_paste": "Pasta de miso", "mixed_vegetables": "Mezcla de verduras", "mochis": "Mochis", + "mold_remover": "Eliminador de moho", "mountain_cheese": "Queso de montaña", "mouth_wash": "Enjuague bucal", "mozzarella": "Queso Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Vino caliente", "mushrooms": "Setas", "mustard": "Mostaza", + "nail_file": "Lima de uñas", "neutral_oil": "Aceite neutro", "nori_sheets": "Hojas de nori", "nutmeg": "Nuez moscada", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Galletas de avena", "oatsome": "Avena", "obatzda": "Obatzda", + "oil": "Aceite", "olive_oil": "Aceite de oliva", "olives": "Aceitunas", "onion": "Cebolla", + "onion_powder": "Cebolla en polvo", "orange_juice": "Zumo de naranja", "oranges": "Naranjas", "oregano": "Orégano", "organic_lemon": "Limón ecológico", "organic_waste_bags": "Bolsas para residuos orgánicos", "pak_choi": "Col china o repollo chino", + "pantyhose": "Pantimedias", "paprika": "Pimentón", + "paprika_seasoning": "Condimento de pimentón", "pardina_lentils_dried": "Lentejas pardinas secas", "parmesan": "Parmesano", "parsley": "Perejil", @@ -264,11 +300,13 @@ "pine_nuts": "Piñones", "pineapple": "Piña", "pita_bag": "Bolsa de pita", + "pita_bread": "Pan de pita", "pizza": "Pizza", "pizza_dough": "Masa de pizza", "plant_magarine": "Margarina vegetal", "plant_oil": "Aceite vegetal", "plaster": "Yeso", + "pointed_peppers": "Pimientos puntiagudos", "porcini_mushrooms": "Champiñón al ajillo", "potato_dumpling_dough": "Masa de albóndigas de patata", "potato_wedges": "Cuñas de patata", @@ -289,8 +327,10 @@ "rapeseed_oil": "Aceite de colza", "raspberries": "Frambuesas", "raspberry_syrup": "Sirope de frambuesa", + "razor_blades": "Hojas de afeitar", "red_bull": "Red Bull", "red_chili": "Chili rojo", + "red_curry_paste": "Pasta de curry rojo", "red_lentils": "Lentejas rojas", "red_onions": "Cebollas rojas", "red_pesto": "Pesto rojo", @@ -300,6 +340,7 @@ "ribbon_noodles": "Cinta de fideos", "rice": "Arroz", "rice_cakes": "Pasteles de arroz", + "rice_paper": "Papel de arroz", "rice_ribbon_noodles": "Fideos con cinta de arroz", "rice_vinegar": "Vinagre de arroz", "ricotta": "Requesón", @@ -341,10 +382,13 @@ "smoked_tofu": "Tofu ahumado", "snacks": "Aperitivos", "soap": "Jabón", + "soba_noodles": "Fideos soba", "soft_drinks": "Refrescos", "softdrinks": "Refrescos", + "soup_vegetables": "Sopa de verduras", "sour_cream": "Crema agria", "sour_cucumbers": "Pepinos agrios", + "soy_cream": "Crema de soja", "soy_hack": "Hack de soja", "soy_sauce": "Salsa de soja", "soy_shred": "Triturado de soja", @@ -354,6 +398,7 @@ "spelt": "Espelta", "spinach": "Espinacas", "sponge_cloth": "Bayetas", + "sponge_fingers": "Dedos de esponja", "sponge_wipes": "Esponjas limpiadoras (de poliuretano)", "sponges": "Esponjas", "spreading_cream": "Crema para untar", @@ -362,18 +407,24 @@ "sprouts": "Brotes", "sriracha": "Sriracha", "strained_tomatoes": "Tomates escurridos", + "strawberries": "Fresas", "sugar": "Azúcar", "summer_roll_paper": "Rollo de papel de verano", + "sunflower_oil": "Aceite de girasol", "sunflower_seeds": "Semillas de girasol", + "sunscreen": "Protector solar", "sushi_rice": "Arroz para sushi", "swabian_ravioli": "Raviolis suabos", + "sweet_chili_sauce": "Salsa de chile dulce", "sweet_potato": "Boniato", "sweet_potatoes": "Boniatos", + "sweets": "Dulces", "table_salt": "Sal de mesa", "tagliatelle": "Tallarín", "tahini": "Tahini", "tangerines": "Mandarinas", "tape": "Cinta", + "tapioca_flour": "Harina de tapioca", "tea": "Té", "teriyaki_sauce": "Salsa Teriyaki", "thyme": "Tomillo", @@ -401,20 +452,27 @@ "vegetables": "Verduras", "vegetarian_cold_cuts": "fiambres vegetarianos", "vinegar": "Vinagre", + "vitamin_tablets": "Comprimidos vitamínicos", "vodka": "Vodka", + "washing_gel": "Gel de lavado", "washing_powder": "Detergente en polvo", "water": "Agua", "water_ice": "Hielo", "watermelon": "Sandía", "wc_cleaner": "Limpiador de WC", + "wheat_flour": "Harina de trigo", "whipped_cream": "Nata montada", "white_wine": "Vino blanco", "white_wine_vinegar": "Vinagre de vino blanco", "whole_canned_tomatoes": "Tomates enteros en conserva", "wild_berries": "Bayas silvestres", + "wild_rice": "Arroz salvaje", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Salsa Worcester", "wrapping_paper": "Papel de envolver", "wraps": "Wraps", "yeast": "Levadura", + "yeast_flakes": "Copos de levadura", "yoghurt": "Yogur", "yogurt": "Yogur", "yum_yum": "Ñam Ñam", diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json index aaf4b067..31a2e959 100644 --- a/backend/templates/l10n/fi.json +++ b/backend/templates/l10n/fi.json @@ -17,12 +17,15 @@ "apple": "Omena", "apple_pulp": "Omenasose", "applesauce": "Omenasose", + "apricots": "Aprikoosit", "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Munanuudelit", + "asian_noodles": "Aasialaiset nuudelit", "asparagus": "Parsa", "aspirin": "Aspiriini", "avocado": "Avocado", + "baby_potatoes": "Tripletit", "baby_spinach": "Babypinaatti", "bacon": "Pekoni", "baguette": "Patonki", @@ -80,12 +83,15 @@ "cheese": "Juusto", "cherry_tomatoes": "Kirsikkatomaatit", "chickpeas": "Kikherneet", + "chicory": "Sikuri", "chili_oil": "Chiliöljy", + "chili_pepper": "Chilipippuri", "chips": "Sipsit", "chives": "Ruohosipuli", "chocolate": "Suklaa", "chocolate_chips": "Suklaalastut", "chopped_tomatoes": "Pilkotut tomaatit", + "chunky_tomatoes": "Tomaattimurska", "ciabatta": "Ciabatta", "cider_vinegar": "Siideriviinietikka", "cilantro": "Korianteri", @@ -104,6 +110,7 @@ "cornflakes": "Maissihiutaleet", "cornstarch": "Maissitärkkelys", "cornys": "Cornys", + "corriander": "Korianteri", "cough_drops": "Yskänpastillit", "couscous": "Couscous", "covid_rapid_test": "COVID-pikatesti", @@ -116,21 +123,29 @@ "crispbread": "Näkkileipä", "cucumber": "Kurkku", "cumin": "Kumina", + "curd": "Juustomassa", "curry_paste": "Currytahna", "curry_powder": "Curryjauhe", "curry_sauce": "Currykastike", "dates": "Taatelit", "dental_floss": "Hammaslanka", + "deo": "Deodorantti", "deodorant": "Deodorantti", "detergent": "Pesuaine", + "detergent_sheets": "Pesuainelakanat", + "diarrhea_remedy": "Ripuli lääke", "dill": "Tilli", "dishwasher_salt": "Astianpesukoneen suola", "dishwasher_tabs": "Astianpesutabletit", "disinfection_spray": "Desinfektiosuihke", "dried_tomatoes": "Kuivatut tomaatit", "edamame": "Edamame-pavut", + "egg_salad": "Munasalaatti", + "egg_yolk": "Munankeltuainen", "eggplant": "Munakoiso", "eggs": "Kananmunat", + "enoki_mushrooms": "Enoki-sienet", + "eyebrow_gel": "Kulmageeli", "falafel": "Falafel", "falafel_powder": "Falafel-jauhe", "fanta": "Fanta", @@ -144,8 +159,9 @@ "frozen_fruit": "Pakastehedelmät", "frozen_pizza": "Pakastepizza", "frozen_spinach": "Pakastepinaatti", + "funeral_card": "Hautajaiskortti", "garam_masala": "Garam Masala", - "garbage_bag": "Roskapussit", + "garbage_bag": "Jätesäkit", "garlic": "Valkosipuli", "garlic_dip": "Valkosipulidippi", "garlic_granules": "Valkosipulirakeet", @@ -157,15 +173,20 @@ "gochujang": "Gochujang-chilitahna", "gorgonzola": "Gorgonzola-juusto", "gouda": "Goudajuusto", + "granola": "Granola", + "granola_bar": "Granola-patukka", "grapes": "Viinirypäleet", "greek_yogurt": "Kreikkalainen jogurtti", "green_asparagus": "Vihreä parsa", "green_chili": "Vihreä chili", "green_pesto": "Vihreä pesto", "hair_gel": "Hiusgeeli", + "hair_ties": "Hiussiteet", "hair_wax": "Hiusvaha", + "hand_soap": "Käsisaippua", "handkerchief_box": "Nenäliinalaatikko", "handkerchiefs": "Nenäliinat", + "hard_cheese": "Kova juusto", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Hasselpähkinät", @@ -176,11 +197,12 @@ "honey_wafers": "Hunajavohvelit", "hot_dog_bun": "Hot Dog-sämpylä", "ice_cream": "Jäätelö", - "ice_cube": "Jääpala", + "ice_cube": "Jääkuutiot", "iceberg_lettuce": "Jäävuorisalaatti", "iced_tea": "Jäätee", "instant_soups": "Pikakeitot", "jam": "Hillo", + "jasmine_rice": "Jasmiiniriisi", "katjes": "Katjes", "ketchup": "Ketsuppi", "kidney_beans": "Kidneypavut", @@ -193,21 +215,28 @@ "leaf_spinach": "Lehtipinaatti", "leek": "Purjo", "lemon": "Sitruuna", + "lemon_curd": "Sitruuna Curd", "lemon_juice": "Sitruunamehu", "lemonade": "Limonadi", "lemongrass": "Sitruunaruoho", + "lentil_stew": "Linssimuhennos", "lentils": "Linssit", "lentils_red": "Punaiset linssit", "lettuce": "Salaatti", "lillet": "Lillet", "lime": "Lime", "linguine": "Linguine", + "lip_care": "Huulien hoito", "low-fat_curd_cheese": "Vähärasvainen juusto", + "maggi": "Maggi", "magnesium": "Magnesium", "mango": "Mango", + "maple_syrup": "Vaahterasiirappi", "margarine": "Margariini", "marjoram": "Meiram", "marshmallows": "Vaahtokarkit", + "mascara": "Ripsiväri", + "mascarpone": "Mascarpone", "mask": "Maski", "mayonnaise": "Majoneesi", "meat_substitute_product": "Lihankorviketuote", @@ -215,8 +244,10 @@ "milk": "Maito", "mint": "Minttu", "mint_candy": "Minttukarkki", + "miso_paste": "Misotahna", "mixed_vegetables": "Vihannessekoitus", "mochis": "Mochis", + "mold_remover": "Homeen poistoainetta", "mountain_cheese": "Vuoristojuusto", "mouth_wash": "Suuvesi", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Glögi", "mushrooms": "Sienet", "mustard": "Sinappi", + "nail_file": "Kynsiviila", "neutral_oil": "Neutraali öljy", "nori_sheets": "Noriarkit", "nutmeg": "Muskottipähkinä", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Kaurakeksit", "oatsome": "Oatsome", "obatzda": "Obatzda", + "oil": "Öljy", "olive_oil": "Oliiviöljy", "olives": "Oliivit", "onion": "Sipuli", + "onion_powder": "Sipulijauhe", "orange_juice": "Appelsiinimehu", "oranges": "Appelsiinit", "oregano": "Oregano", "organic_lemon": "Luomusitruuna", "organic_waste_bags": "Biojätepussit", "pak_choi": "Pak Choi", + "pantyhose": "Sukkahousut", "paprika": "Paprika", + "paprika_seasoning": "Paprika mauste", "pardina_lentils_dried": "Kuivatut Pardina-linssit", "parmesan": "Parmesan", "parsley": "Persilja", @@ -264,11 +300,13 @@ "pine_nuts": "Pinjansiemenet", "pineapple": "Ananas", "pita_bag": "Pita-pussi", + "pita_bread": "Pita-leipä", "pizza": "Pizza", "pizza_dough": "Pizzataikina", "plant_magarine": "Kasvi Magarine", "plant_oil": "Kasviöljy", "plaster": "Laastari", + "pointed_peppers": "Pistetyt paprikat", "porcini_mushrooms": "Porcini-sienet", "potato_dumpling_dough": "Perunakimpaleiden taikina", "potato_wedges": "Lohkoperunat", @@ -289,8 +327,10 @@ "rapeseed_oil": "Rypsiöljy", "raspberries": "Vadelmat", "raspberry_syrup": "Vadelmasiirappi", + "razor_blades": "Partakoneen terät", "red_bull": "Red Bull", "red_chili": "Punainen chili", + "red_curry_paste": "Punainen currytahna", "red_lentils": "Punaiset linssit", "red_onions": "Punasipulit", "red_pesto": "Punainen pesto", @@ -300,6 +340,7 @@ "ribbon_noodles": "Nauhanuudelit", "rice": "Riisi", "rice_cakes": "Riisikakut", + "rice_paper": "Riisipaperi", "rice_ribbon_noodles": "Riisinauhanuudelit", "rice_vinegar": "Riisiviinietikka", "ricotta": "Ricotta", @@ -341,10 +382,13 @@ "smoked_tofu": "Savutofu", "snacks": "Herkut", "soap": "Saippua", + "soba_noodles": "Soba-nuudelit", "soft_drinks": "Alkoholittomat juomat", "softdrinks": "Alkoholittomat juomat", + "soup_vegetables": "Keittovihannekset", "sour_cream": "Hapankerma", "sour_cucumbers": "Hapanimelät kurkut", + "soy_cream": "Soijakerma", "soy_hack": "Soija hack", "soy_sauce": "Soijakastike", "soy_shred": "Soija silppua", @@ -354,6 +398,7 @@ "spelt": "Speltti", "spinach": "Pinaatti", "sponge_cloth": "Sieniliina", + "sponge_fingers": "Sienisormet", "sponge_wipes": "Sienipyyhkeet", "sponges": "Pesusienet", "spreading_cream": "Levitysvoide", @@ -362,18 +407,24 @@ "sprouts": "Idut", "sriracha": "Sriracha", "strained_tomatoes": "Siivilöidyt tomaatit", + "strawberries": "Mansikat", "sugar": "Sokeri", "summer_roll_paper": "Kesän rullapaperi", + "sunflower_oil": "Auringonkukkaöljy", "sunflower_seeds": "Auringonkukan siemenet", + "sunscreen": "Aurinkovoide", "sushi_rice": "Sushiriisi", "swabian_ravioli": "Swabian ravioli", + "sweet_chili_sauce": "Makea chilikastike", "sweet_potato": "Bataatti", "sweet_potatoes": "Bataatit", + "sweets": "Makeiset", "table_salt": "Pöytäsuola", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Mandariinit", "tape": "Teippi", + "tapioca_flour": "Tapiokajauhot", "tea": "Tee", "teriyaki_sauce": "Teriyaki-kastike", "thyme": "Timjami", @@ -401,20 +452,27 @@ "vegetables": "Kasvikset", "vegetarian_cold_cuts": "kasvissyöjä leikkeleitä", "vinegar": "Etikka", + "vitamin_tablets": "Vitamiinitabletit", "vodka": "Vodka", + "washing_gel": "Pesugeeli", "washing_powder": "Pesujauhe", "water": "Vesi", "water_ice": "Vesijää", "watermelon": "Vesimeloni", "wc_cleaner": "WC:n puhdistusaine", + "wheat_flour": "Vehnäjauho", "whipped_cream": "Kermavaahto", "white_wine": "Valkoviini", "white_wine_vinegar": "Valkoviinietikka", "whole_canned_tomatoes": "kokonaiset tomaattisäilykkeet", "wild_berries": "Metsämarjoja", + "wild_rice": "Villiriisi", + "wildberry_lillet": "Villimarja Lillet", + "worcester_sauce": "Worcester-kastike", "wrapping_paper": "Käärepaperi", "wraps": "Wrapit", "yeast": "Hiiva", + "yeast_flakes": "Hiivahiutaleet", "yoghurt": "Jogurtti", "yogurt": "Jogurtti", "yum_yum": "Nami nami", diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json index 3601f9c0..f51e6f4e 100644 --- a/backend/templates/l10n/fr.json +++ b/backend/templates/l10n/fr.json @@ -17,12 +17,15 @@ "apple": "Pomme", "apple_pulp": "Pulpe de pomme", "applesauce": "Compote de pommes", + "apricots": "Abricots", "apérol": "Apérol", "arugula": "Roquette", "asian_egg_noodles": "Nouilles asiatiques aux œufs", + "asian_noodles": "Nouilles asiatiques", "asparagus": "Asperges", "aspirin": "Aspirine", "avocado": "Avocat", + "baby_potatoes": "Triplés", "baby_spinach": "Jeunes épinards", "bacon": "Bacon", "baguette": "Baguette", @@ -80,12 +83,15 @@ "cheese": "Fromage", "cherry_tomatoes": "Tomates cerises", "chickpeas": "Pois chiches", + "chicory": "Chicorée", "chili_oil": "Huile de piment", + "chili_pepper": "Piment", "chips": "Chips", "chives": "Ciboulette", "chocolate": "Chocolat", "chocolate_chips": "Pépites de chocolat", "chopped_tomatoes": "Tomates coupées en morceaux", + "chunky_tomatoes": "Tomates en morceaux", "ciabatta": "Ciabatta", "cider_vinegar": "Vinaigre de cidre", "cilantro": "Coriandre", @@ -104,6 +110,7 @@ "cornflakes": "Cornflakes", "cornstarch": "Amidon de maïs", "cornys": "Cornys", + "corriander": "Corriandre", "cough_drops": "Gouttes contre la toux", "couscous": "Couscous", "covid_rapid_test": "Test rapide COVID", @@ -116,21 +123,29 @@ "crispbread": "Pain croustillant", "cucumber": "Concombre", "cumin": "Cumin", + "curd": "Caillé", "curry_paste": "Pâte de curry", "curry_powder": "Poudre de curry", "curry_sauce": "Sauce au curry", "dates": "Dates", "dental_floss": "Fil dentaire", + "deo": "Déodorant", "deodorant": "Déodorant", "detergent": "Détergent", + "detergent_sheets": "Feuilles de détergent", + "diarrhea_remedy": "Remède contre la diarrhée", "dill": "Aneth", "dishwasher_salt": "Sel pour lave-vaisselle", "dishwasher_tabs": "Languettes pour lave-vaisselle", "disinfection_spray": "Spray désinfectant", "dried_tomatoes": "Tomates séchées", "edamame": "Edamame", + "egg_salad": "Salade d'œufs", + "egg_yolk": "Jaune d'œuf", "eggplant": "Aubergine", "eggs": "Œufs", + "enoki_mushrooms": "Champignons Enoki", + "eyebrow_gel": "Gel pour sourcils", "falafel": "Falafel", "falafel_powder": "Poudre de falafel", "fanta": "Fanta", @@ -144,8 +159,9 @@ "frozen_fruit": "Fruits congelés", "frozen_pizza": "Pizza surgelée", "frozen_spinach": "Epinards surgelés", + "funeral_card": "Carte funéraire", "garam_masala": "Garam Masala", - "garbage_bag": "Sac à ordures", + "garbage_bag": "Sacs à ordures", "garlic": "Ail", "garlic_dip": "Trempette à l'ail", "garlic_granules": "Ail en granulés", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Barre de céréales", "grapes": "Raisins", "greek_yogurt": "Yogourt grec", "green_asparagus": "Asperges vertes", "green_chili": "Piment vert", "green_pesto": "Pesto vert", "hair_gel": "Gel pour cheveux", + "hair_ties": "Attaches pour cheveux", "hair_wax": "Cire pour cheveux", + "hand_soap": "Savon à main", "handkerchief_box": "Boîte à mouchoirs", "handkerchiefs": "Mouchoirs en papier", + "hard_cheese": "Fromage à pâte dure", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Noisettes", @@ -176,11 +197,12 @@ "honey_wafers": "Gaufres au miel", "hot_dog_bun": "Pain à hot-dog", "ice_cream": "Crème glacée", - "ice_cube": "Glaçon", + "ice_cube": "Glaçons", "iceberg_lettuce": "Laitue iceberg", "iced_tea": "Thé glacé", "instant_soups": "Soupes instantanées", "jam": "Confiture", + "jasmine_rice": "Riz au jasmin", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Haricots rouges", @@ -193,21 +215,28 @@ "leaf_spinach": "Epinards à feuilles", "leek": "Poireau", "lemon": "Citron", + "lemon_curd": "Caillé de citron", "lemon_juice": "Jus de citron", "lemonade": "Limonade", "lemongrass": "Lemongrass", + "lentil_stew": "Ragoût de lentilles", "lentils": "Lentilles", "lentils_red": "Lentilles rouges", "lettuce": "Laitue", "lillet": "Lillet", "lime": "Lime", "linguine": "Linguine", + "lip_care": "Soins des lèvres", "low-fat_curd_cheese": "Fromage blanc à faible teneur en matières grasses", + "maggi": "Maggi", "magnesium": "Magnésium", "mango": "Mangue", + "maple_syrup": "Sirop d'érable", "margarine": "Margarine", "marjoram": "Marjolaine", "marshmallows": "Guimauves", + "mascara": "Mascara", + "mascarpone": "Mascarpone", "mask": "Masque", "mayonnaise": "Mayonnaise", "meat_substitute_product": "Produit de substitution de la viande", @@ -215,8 +244,10 @@ "milk": "Lait", "mint": "Menthe", "mint_candy": "Bonbons à la menthe", + "miso_paste": "Pâte de miso", "mixed_vegetables": "Légumes mélangés", "mochis": "Mochis", + "mold_remover": "Démolisseur de moisissures", "mountain_cheese": "Fromage de montagne", "mouth_wash": "Bain de bouche", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Vin chaud", "mushrooms": "Champignons", "mustard": "Moutarde", + "nail_file": "Lime à ongles", "neutral_oil": "Huile neutre", "nori_sheets": "Feuilles de nori", "nutmeg": "Noix de muscade", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Biscuits à la farine d'avoine", "oatsome": "Avoine", "obatzda": "Obatzda", + "oil": "Huile", "olive_oil": "Huile d'olive", "olives": "Olives", "onion": "Oignon", + "onion_powder": "Oignon en poudre", "orange_juice": "Jus d'orange", "oranges": "Oranges", "oregano": "Origan", "organic_lemon": "Citron biologique", "organic_waste_bags": "Sacs à déchets organiques", "pak_choi": "Pak Choi", + "pantyhose": "Collants", "paprika": "Paprika", + "paprika_seasoning": "Assaisonnement au paprika", "pardina_lentils_dried": "Lentilles Pardina séchées", "parmesan": "Parmesan", "parsley": "Persil", @@ -264,11 +300,13 @@ "pine_nuts": "Pignons de pin", "pineapple": "Ananas", "pita_bag": "Sac à pita", + "pita_bread": "Pain pita", "pizza": "Pizza", "pizza_dough": "Pâte à pizza", "plant_magarine": "Magarine végétale", "plant_oil": "Huile végétale", "plaster": "Plâtre", + "pointed_peppers": "Poivrons pointus", "porcini_mushrooms": "Champignons Porcini", "potato_dumpling_dough": "Pâte à boulettes de pommes de terre", "potato_wedges": "Quartiers de pommes de terre", @@ -289,8 +327,10 @@ "rapeseed_oil": "Huile de colza", "raspberries": "Framboises", "raspberry_syrup": "Sirop de framboise", + "razor_blades": "Lames de rasoir", "red_bull": "Red Bull", "red_chili": "Piment rouge", + "red_curry_paste": "Pâte de curry rouge", "red_lentils": "Lentilles rouges", "red_onions": "Oignons rouges", "red_pesto": "Pesto rouge", @@ -300,6 +340,7 @@ "ribbon_noodles": "Nouilles en ruban", "rice": "Riz", "rice_cakes": "Gâteaux de riz", + "rice_paper": "Papier de riz", "rice_ribbon_noodles": "Nouilles en ruban de riz", "rice_vinegar": "Vinaigre de riz", "ricotta": "Ricotta", @@ -341,10 +382,13 @@ "smoked_tofu": "Tofu fumé", "snacks": "Snacks", "soap": "Savon", + "soba_noodles": "Nouilles Soba", "soft_drinks": "Boissons gazeuses", "softdrinks": "Boissons gazeuses", + "soup_vegetables": "Soupe de légumes", "sour_cream": "Crème aigre", "sour_cucumbers": "Concombres aigres", + "soy_cream": "Crème de soja", "soy_hack": "Le soja", "soy_sauce": "Sauce soja", "soy_shred": "Effilochage de soja", @@ -354,6 +398,7 @@ "spelt": "Épeautre", "spinach": "Epinards", "sponge_cloth": "Tissu éponge", + "sponge_fingers": "Doigts en éponge", "sponge_wipes": "Lingettes éponge", "sponges": "Éponges", "spreading_cream": "Crème à tartiner", @@ -362,18 +407,24 @@ "sprouts": "Sprouts", "sriracha": "Sriracha", "strained_tomatoes": "Tomates égouttées", + "strawberries": "Fraises", "sugar": "Sucre", "summer_roll_paper": "Rouleau de papier d'été", + "sunflower_oil": "Huile de tournesol", "sunflower_seeds": "Graines de tournesol", + "sunscreen": "Crème solaire", "sushi_rice": "Riz à sushi", "swabian_ravioli": "Raviolis souabes", + "sweet_chili_sauce": "Sauce chili douce", "sweet_potato": "Patate douce", "sweet_potatoes": "Patates douces", + "sweets": "Bonbons", "table_salt": "Sel de table", "tagliatelle": "Tagliatelles", "tahini": "Tahini", "tangerines": "Mandarines", "tape": "Ruban adhésif", + "tapioca_flour": "Farine de tapioca", "tea": "Thé", "teriyaki_sauce": "Sauce teriyaki", "thyme": "Thym", @@ -401,20 +452,27 @@ "vegetables": "Légumes", "vegetarian_cold_cuts": "charcuterie végétarienne", "vinegar": "Vinaigre", + "vitamin_tablets": "Comprimés de vitamines", "vodka": "Vodka", + "washing_gel": "Gel de lavage", "washing_powder": "Poudre à laver", "water": "Eau", "water_ice": "Glace d'eau", "watermelon": "Pastèque", "wc_cleaner": "Nettoyant pour WC", + "wheat_flour": "Farine de blé", "whipped_cream": "Crème fouettée", "white_wine": "Vin blanc", "white_wine_vinegar": "Vinaigre de vin blanc", "whole_canned_tomatoes": "Tomates entières en conserve", "wild_berries": "Baies sauvages", + "wild_rice": "Riz sauvage", + "wildberry_lillet": "Lillet aux baies sauvages", + "worcester_sauce": "Sauce Worcester", "wrapping_paper": "Papier d'emballage", "wraps": "Wraps", "yeast": "Levure", + "yeast_flakes": "Flocons de levure", "yoghurt": "Yaourt", "yogurt": "Yogourt", "yum_yum": "Miam miam", diff --git a/backend/templates/l10n/id.json b/backend/templates/l10n/id.json index 53dd1e2e..56d5a50b 100644 --- a/backend/templates/l10n/id.json +++ b/backend/templates/l10n/id.json @@ -17,12 +17,15 @@ "apple": "Apel", "apple_pulp": "Bubur apel", "applesauce": "Saus apel", + "apricots": "Aprikot", "apérol": "Apérol", "arugula": "Arugula", "asian_egg_noodles": "Mie telur Asia", + "asian_noodles": "Mie Asia", "asparagus": "Asparagus", "aspirin": "Aspirin", "avocado": "Alpukat", + "baby_potatoes": "Kembar tiga", "baby_spinach": "Bayam bayi", "bacon": "Bacon", "baguette": "Baguette", @@ -80,12 +83,15 @@ "cheese": "Keju", "cherry_tomatoes": "Tomat ceri", "chickpeas": "Buncis", + "chicory": "Sawi putih", "chili_oil": "Minyak cabai", + "chili_pepper": "Cabai", "chips": "Keripik", "chives": "Daun bawang", "chocolate": "Cokelat", "chocolate_chips": "Keripik cokelat", "chopped_tomatoes": "Tomat cincang", + "chunky_tomatoes": "Tomat tebal", "ciabatta": "Ciabatta", "cider_vinegar": "Cuka sari apel", "cilantro": "Ketumbar", @@ -104,6 +110,7 @@ "cornflakes": "Serpihan jagung", "cornstarch": "Tepung maizena", "cornys": "Cornys", + "corriander": "Corriander", "cough_drops": "Obat tetes batuk", "couscous": "Couscous", "covid_rapid_test": "Tes cepat COVID", @@ -116,21 +123,29 @@ "crispbread": "Roti Garing", "cucumber": "Mentimun", "cumin": "Jinten", + "curd": "Dadih", "curry_paste": "Pasta kari", "curry_powder": "Bubuk kari", "curry_sauce": "Saus kari", "dates": "Kurma", "dental_floss": "Benang gigi", + "deo": "Deodoran", "deodorant": "Deodoran", "detergent": "Deterjen", + "detergent_sheets": "Lembaran deterjen", + "diarrhea_remedy": "Obat diare", "dill": "Dill", "dishwasher_salt": "Garam pencuci piring", "dishwasher_tabs": "Tab pencuci piring", "disinfection_spray": "Semprotan desinfeksi", "dried_tomatoes": "Tomat kering", "edamame": "Edamame", + "egg_salad": "Salad telur", + "egg_yolk": "Kuning telur", "eggplant": "Terong", "eggs": "Telur", + "enoki_mushrooms": "Jamur Enoki", + "eyebrow_gel": "Gel alis", "falafel": "Falafel", "falafel_powder": "Bubuk falafel", "fanta": "Fanta", @@ -144,6 +159,7 @@ "frozen_fruit": "Buah beku", "frozen_pizza": "Pizza beku", "frozen_spinach": "Bayam beku", + "funeral_card": "Kartu pemakaman", "garam_masala": "Garam Masala", "garbage_bag": "Kantong sampah", "garlic": "Bawang putih", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Granola bar", "grapes": "Anggur", "greek_yogurt": "Yoghurt Yunani", "green_asparagus": "Asparagus hijau", "green_chili": "Cabai hijau", "green_pesto": "Pesto hijau", "hair_gel": "Gel rambut", + "hair_ties": "Ikatan rambut", "hair_wax": "Lilin Rambut", + "hand_soap": "Sabun tangan", "handkerchief_box": "Kotak saputangan", "handkerchiefs": "Saputangan", + "hard_cheese": "Keju keras", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Hazelnut", @@ -181,6 +202,7 @@ "iced_tea": "Es teh", "instant_soups": "Sup instan", "jam": "Selai", + "jasmine_rice": "Nasi melati", "katjes": "Katjes", "ketchup": "Kecap", "kidney_beans": "Kacang merah", @@ -193,21 +215,28 @@ "leaf_spinach": "Daun bayam", "leek": "Leek", "lemon": "Lemon", + "lemon_curd": "Lemon Curd", "lemon_juice": "Jus lemon", "lemonade": "Limun", "lemongrass": "Serai", + "lentil_stew": "Rebusan miju-miju", "lentils": "Lentil", "lentils_red": "Lentil merah", "lettuce": "Selada", "lillet": "Lillet", "lime": "Kapur", "linguine": "Linguine", + "lip_care": "Perawatan Bibir", "low-fat_curd_cheese": "Keju dadih rendah lemak", + "maggi": "Maggi", "magnesium": "Magnesium", "mango": "Mangga", + "maple_syrup": "Sirup maple", "margarine": "Margarin", "marjoram": "Marjoram", "marshmallows": "Marshmallow", + "mascara": "Maskara", + "mascarpone": "Mascarpone", "mask": "Topeng", "mayonnaise": "Mayones", "meat_substitute_product": "Produk pengganti daging", @@ -215,8 +244,10 @@ "milk": "Susu", "mint": "Mint", "mint_candy": "Permen mint", + "miso_paste": "Pasta miso", "mixed_vegetables": "Sayuran campuran", "mochis": "Mochis", + "mold_remover": "Penghilang Jamur", "mountain_cheese": "Keju gunung", "mouth_wash": "Cuci mulut", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Mulled wine", "mushrooms": "Jamur", "mustard": "Mustard", + "nail_file": "Kikir kuku", "neutral_oil": "Minyak netral", "nori_sheets": "Lembaran nori", "nutmeg": "Pala", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Kue gandum", "oatsome": "Oatsome", "obatzda": "Obatzda", + "oil": "Minyak", "olive_oil": "Minyak zaitun", "olives": "Zaitun", "onion": "Bawang", + "onion_powder": "Bubuk bawang", "orange_juice": "Jus jeruk", "oranges": "Jeruk", "oregano": "Oregano", "organic_lemon": "Lemon organik", "organic_waste_bags": "Kantong sampah organik", "pak_choi": "Pak Choi", + "pantyhose": "Pantyhose", "paprika": "Paprika", + "paprika_seasoning": "Bumbu paprika", "pardina_lentils_dried": "Lentil pardina dikeringkan", "parmesan": "Parmesan", "parsley": "Peterseli", @@ -264,11 +300,13 @@ "pine_nuts": "Kacang pinus", "pineapple": "Nanas", "pita_bag": "Tas pita", + "pita_bread": "Roti pita", "pizza": "Pizza", "pizza_dough": "Adonan pizza", "plant_magarine": "Tanaman Magarine", "plant_oil": "Minyak nabati", "plaster": "Plester", + "pointed_peppers": "Paprika runcing", "porcini_mushrooms": "Jamur porcini", "potato_dumpling_dough": "Adonan pangsit kentang", "potato_wedges": "Irisan kentang", @@ -289,8 +327,10 @@ "rapeseed_oil": "Minyak lobak", "raspberries": "Raspberry", "raspberry_syrup": "Sirup raspberry", + "razor_blades": "Pisau cukur", "red_bull": "Banteng Merah", "red_chili": "Cabai merah", + "red_curry_paste": "Pasta kari merah", "red_lentils": "Lentil merah", "red_onions": "Bawang merah", "red_pesto": "Pesto merah", @@ -300,6 +340,7 @@ "ribbon_noodles": "Mie pita", "rice": "Beras", "rice_cakes": "Kue beras", + "rice_paper": "Kertas beras", "rice_ribbon_noodles": "Mie pita nasi", "rice_vinegar": "Cuka beras", "ricotta": "Ricotta", @@ -341,10 +382,13 @@ "smoked_tofu": "Tahu asap", "snacks": "Makanan ringan", "soap": "Sabun", + "soba_noodles": "Mie soba", "soft_drinks": "Minuman ringan", "softdrinks": "Minuman ringan", + "soup_vegetables": "Sup sayuran", "sour_cream": "Krim asam", "sour_cucumbers": "Mentimun asam", + "soy_cream": "Krim kedelai", "soy_hack": "Peretasan kedelai", "soy_sauce": "Kecap", "soy_shred": "Rusaknya kedelai", @@ -354,6 +398,7 @@ "spelt": "Eja", "spinach": "Bayam", "sponge_cloth": "Kain spons", + "sponge_fingers": "Jari-jari spons", "sponge_wipes": "Tisu spons", "sponges": "Spons", "spreading_cream": "Menyebarkan krim", @@ -362,18 +407,24 @@ "sprouts": "Kecambah", "sriracha": "Sriracha", "strained_tomatoes": "Tomat yang disaring", + "strawberries": "Stroberi", "sugar": "Gula", "summer_roll_paper": "Kertas gulung musim panas", + "sunflower_oil": "Minyak bunga matahari", "sunflower_seeds": "Biji bunga matahari", + "sunscreen": "Tabir surya", "sushi_rice": "Nasi sushi", "swabian_ravioli": "Ravioli Swabia", + "sweet_chili_sauce": "Saus Cabai Manis", "sweet_potato": "Ubi jalar", "sweet_potatoes": "Ubi jalar", + "sweets": "Permen", "table_salt": "Garam meja", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Jeruk keprok", "tape": "Pita", + "tapioca_flour": "Tepung tapioka", "tea": "Teh", "teriyaki_sauce": "Saus teriyaki", "thyme": "Thyme", @@ -401,20 +452,27 @@ "vegetables": "Sayuran", "vegetarian_cold_cuts": "potongan daging dingin vegetarian", "vinegar": "Cuka", + "vitamin_tablets": "Tablet vitamin", "vodka": "Vodka", + "washing_gel": "Gel pencuci", "washing_powder": "Bubuk pencuci", "water": "Air", "water_ice": "Es air", "watermelon": "Semangka", "wc_cleaner": "Pembersih WC", + "wheat_flour": "Tepung terigu", "whipped_cream": "Krim kocok", "white_wine": "Anggur putih", "white_wine_vinegar": "Cuka anggur putih", "whole_canned_tomatoes": "Tomat kalengan utuh", "wild_berries": "Buah beri liar", + "wild_rice": "Beras liar", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Saus Worcester", "wrapping_paper": "Kertas pembungkus", "wraps": "Membungkus", "yeast": "Ragi", + "yeast_flakes": "Serpihan ragi", "yoghurt": "Yoghurt", "yogurt": "Yogurt", "yum_yum": "Yum Yum", diff --git a/backend/templates/l10n/it.json b/backend/templates/l10n/it.json index fe85ca9e..e916e2d0 100644 --- a/backend/templates/l10n/it.json +++ b/backend/templates/l10n/it.json @@ -17,12 +17,15 @@ "apple": "Mela", "apple_pulp": "Popla di mela", "applesauce": "Salsa di mela", + "apricots": "Albicocche", "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Noodles asiatici all'uovo", + "asian_noodles": "Tagliatelle asiatiche", "asparagus": "Asparagi", "aspirin": "Aspirina", "avocado": "Avocado", + "baby_potatoes": "Tripletta", "baby_spinach": "Spinaci baby", "bacon": "Pancetta", "baguette": "Baguette", @@ -80,12 +83,15 @@ "cheese": "Formaggio", "cherry_tomatoes": "Pomodori ciglieggina", "chickpeas": "Ceci", + "chicory": "Cicoria", "chili_oil": "Olio al peperoncino", + "chili_pepper": "Peperoncino", "chips": "Patatine", "chives": "Erba cipollina", "chocolate": "Cioccolata", "chocolate_chips": "Gocce di cioccolato", "chopped_tomatoes": "Polpa di pomodoro", + "chunky_tomatoes": "Pomodori a pezzi", "ciabatta": "Ciabatta", "cider_vinegar": "Aceto di mele", "cilantro": "Coriandolo", @@ -104,6 +110,7 @@ "cornflakes": "Cornflakes", "cornstarch": "Amido di mais", "cornys": "Cereali Cornys", + "corriander": "Corriandolo", "cough_drops": "Sciroppo per la tosse", "couscous": "Couscous", "covid_rapid_test": "Test rapido COVID", @@ -116,21 +123,29 @@ "crispbread": "Pane croccante", "cucumber": "Cetriolo", "cumin": "Cumino", + "curd": "Cagliata", "curry_paste": "Pasta di curry", "curry_powder": "Curry in polvere", "curry_sauce": "Salsa al curry", "dates": "Date", "dental_floss": "Filo interdentale", + "deo": "Deodorante", "deodorant": "Deodorante", "detergent": "Detergente", + "detergent_sheets": "Fogli di detersivo", + "diarrhea_remedy": "Rimedio contro la diarrea", "dill": "Aneto", "dishwasher_salt": "Sale per lavastoviglie", "dishwasher_tabs": "Pastiglie per lavastoviglie", "disinfection_spray": "Disinfestante spray", "dried_tomatoes": "Pomodori secchi", "edamame": "Edamame", + "egg_salad": "Insalata di uova", + "egg_yolk": "Tuorlo d'uovo", "eggplant": "Melanzana", "eggs": "Uova", + "enoki_mushrooms": "Funghi Enoki", + "eyebrow_gel": "Gel per sopracciglia", "falafel": "Falafel", "falafel_powder": "Preparato per Falafel", "fanta": "Fanta", @@ -144,8 +159,9 @@ "frozen_fruit": "Frutta surgelata", "frozen_pizza": "Pizza surgelata", "frozen_spinach": "Spinaci surgelati", + "funeral_card": "Biglietto funebre", "garam_masala": "Garam Masala", - "garbage_bag": "Sacchetto della spazzatura", + "garbage_bag": "Sacchetti per la spazzatura", "garlic": "Aglio", "garlic_dip": "Salsa all'aglio", "garlic_granules": "Aglio in granuli", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Barretta di granola", "grapes": "Uva", "greek_yogurt": "Yogurt greco", "green_asparagus": "Asparagi verdi", "green_chili": "Peperoncino verde", "green_pesto": "Pesto alla genovese", "hair_gel": "Gel per capelli", + "hair_ties": "Fascette per capelli", "hair_wax": "Cera per capelli", + "hand_soap": "Sapone per le mani", "handkerchief_box": "Scatola per fazzoletti", "handkerchiefs": "Fazzoletti", + "hard_cheese": "Formaggio a pasta dura", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Nocciole", @@ -176,11 +197,12 @@ "honey_wafers": "Cialde di miele", "hot_dog_bun": "Panino per hot dog", "ice_cream": "Gelato", - "ice_cube": "Cubetto di ghiaccio", + "ice_cube": "Cubetti di ghiaccio", "iceberg_lettuce": "Lattuga Iceberg", "iced_tea": "Tè freddo", "instant_soups": "Zuppe istantanee", "jam": "Marmellata", + "jasmine_rice": "Riso al gelsomino", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Fagioli renali", @@ -193,21 +215,28 @@ "leaf_spinach": "Spinaci in foglia", "leek": "Porro", "lemon": "Limone", + "lemon_curd": "Curd al limone", "lemon_juice": "Succo di limone", "lemonade": "Limonata", "lemongrass": "Citronella", + "lentil_stew": "Stufato di lenticchie", "lentils": "Lenticchie", "lentils_red": "Lenticchie rosse", "lettuce": "Lattuga", "lillet": "Lillet", "lime": "Calce", "linguine": "Linguine", + "lip_care": "Cura delle labbra", "low-fat_curd_cheese": "Formaggio cagliato a basso contenuto di grassi", + "maggi": "Maggi", "magnesium": "Magnesio", "mango": "Mango", + "maple_syrup": "Sciroppo d'acero", "margarine": "Margarina", "marjoram": "Maggiorana", "marshmallows": "Marshmallow", + "mascara": "Mascara", + "mascarpone": "Mascarpone", "mask": "Maschera", "mayonnaise": "Maionese", "meat_substitute_product": "Prodotto sostitutivo della carne", @@ -215,8 +244,10 @@ "milk": "Latte", "mint": "Menta", "mint_candy": "Caramelle alla menta", + "miso_paste": "Pasta di miso", "mixed_vegetables": "Verdure miste", "mochis": "Mochis", + "mold_remover": "Rimuovi muffa", "mountain_cheese": "Formaggio di montagna", "mouth_wash": "Lavaggio della bocca", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Vin brulè", "mushrooms": "Funghi", "mustard": "Senape", + "nail_file": "Lima per unghie", "neutral_oil": "Olio neutro", "nori_sheets": "Fogli di nori", "nutmeg": "Noce moscata", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Biscotti d'avena", "oatsome": "Avena", "obatzda": "Obatzda", + "oil": "Olio", "olive_oil": "Olio d'oliva", "olives": "Olive", "onion": "Cipolla", + "onion_powder": "Cipolla in polvere", "orange_juice": "Succo d'arancia", "oranges": "Arance", "oregano": "Origano", "organic_lemon": "Limone biologico", "organic_waste_bags": "Sacchetti per rifiuti organici", "pak_choi": "Pak Choi", + "pantyhose": "Collant", "paprika": "Paprika", + "paprika_seasoning": "Condimento alla paprika", "pardina_lentils_dried": "Lenticchie Pardina secche", "parmesan": "Parmigiano", "parsley": "Prezzemolo", @@ -264,11 +300,13 @@ "pine_nuts": "Pinoli", "pineapple": "Ananas", "pita_bag": "Sacchetto di pita", + "pita_bread": "Pane pita", "pizza": "Pizza", "pizza_dough": "Impasto per pizza", "plant_magarine": "Impianto Magarine", "plant_oil": "Olio vegetale", "plaster": "Gesso", + "pointed_peppers": "Peperoni a punta", "porcini_mushrooms": "Funghi porcini", "potato_dumpling_dough": "Impasto per gnocchi di patate", "potato_wedges": "Cunei di patate", @@ -289,8 +327,10 @@ "rapeseed_oil": "Olio di colza", "raspberries": "Lamponi", "raspberry_syrup": "Sciroppo di lamponi", + "razor_blades": "Lame di rasoio", "red_bull": "Red Bull", "red_chili": "Peperoncino rosso", + "red_curry_paste": "Pasta di curry rosso", "red_lentils": "Lenticchie rosse", "red_onions": "Cipolle rosse", "red_pesto": "Pesto rosso", @@ -300,6 +340,7 @@ "ribbon_noodles": "Tagliatelle a nastro", "rice": "Il riso", "rice_cakes": "Torte di riso", + "rice_paper": "Carta di riso", "rice_ribbon_noodles": "Tagliatelle a nastro di riso", "rice_vinegar": "Aceto di riso", "ricotta": "Ricotta", @@ -341,10 +382,13 @@ "smoked_tofu": "Tofu affumicato", "snacks": "Spuntini", "soap": "Sapone", + "soba_noodles": "Tagliatelle di soba", "soft_drinks": "Bevande analcoliche", "softdrinks": "Bevande analcoliche", + "soup_vegetables": "Zuppa di verdure", "sour_cream": "Panna acida", "sour_cucumbers": "Cetrioli acidi", + "soy_cream": "Crema di soia", "soy_hack": "Hackeraggio della soia", "soy_sauce": "Salsa di soia", "soy_shred": "Tritatutto di soia", @@ -354,6 +398,7 @@ "spelt": "Farro", "spinach": "Spinaci", "sponge_cloth": "Panno di spugna", + "sponge_fingers": "Dita di spugna", "sponge_wipes": "Salviette di spugna", "sponges": "Spugne", "spreading_cream": "Crema da spalmare", @@ -362,18 +407,24 @@ "sprouts": "Germogli", "sriracha": "Sriracha", "strained_tomatoes": "Pomodori filtrati", + "strawberries": "Fragole", "sugar": "Zucchero", "summer_roll_paper": "Carta in rotoli per l'estate", + "sunflower_oil": "Olio di girasole", "sunflower_seeds": "Semi di girasole", + "sunscreen": "Protezione solare", "sushi_rice": "Riso per sushi", "swabian_ravioli": "Ravioli svevi", + "sweet_chili_sauce": "Salsa al peperoncino dolce", "sweet_potato": "Patata dolce", "sweet_potatoes": "Patate dolci", + "sweets": "Dolci", "table_salt": "Sale da cucina", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Mandarini", "tape": "Nastro", + "tapioca_flour": "Farina di tapioca", "tea": "Tè", "teriyaki_sauce": "Salsa teriyaki", "thyme": "Timo", @@ -401,20 +452,27 @@ "vegetables": "Verdure", "vegetarian_cold_cuts": "salumi vegetariani", "vinegar": "Aceto", + "vitamin_tablets": "Compresse di vitamine", "vodka": "Vodka", + "washing_gel": "Gel di lavaggio", "washing_powder": "Detersivo in polvere", "water": "Acqua", "water_ice": "Ghiaccio d'acqua", "watermelon": "Anguria", "wc_cleaner": "Detergente per WC", + "wheat_flour": "Farina di frumento", "whipped_cream": "Panna montata", "white_wine": "Vino bianco", "white_wine_vinegar": "Aceto di vino bianco", "whole_canned_tomatoes": "Pomodori interi in scatola", "wild_berries": "Frutti di bosco", + "wild_rice": "Riso selvatico", + "wildberry_lillet": "Lillet alle bacche selvatiche", + "worcester_sauce": "Salsa Worcester", "wrapping_paper": "Carta da regalo", "wraps": "Avvolgimenti", "yeast": "Lievito", + "yeast_flakes": "Fiocchi di lievito", "yoghurt": "Yogurt", "yogurt": "Yogurt", "yum_yum": "Gnam gnam", diff --git a/backend/templates/l10n/nb_NO.json b/backend/templates/l10n/nb_NO.json index d390694e..aec32e86 100644 --- a/backend/templates/l10n/nb_NO.json +++ b/backend/templates/l10n/nb_NO.json @@ -17,12 +17,15 @@ "apple": "Eple", "apple_pulp": "Eplemasse", "applesauce": "Eplemos", + "apricots": "Aprikoser", "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Asiatiske eggnudler", + "asian_noodles": "Asiatiske nudler", "asparagus": "Asparges", "aspirin": "Aspirin", "avocado": "Avokado", + "baby_potatoes": "Trillinger", "baby_spinach": "Baby spinat", "bacon": "Bacon", "baguette": "Baguette", @@ -80,12 +83,15 @@ "cheese": "Ost", "cherry_tomatoes": "Cherrytomater", "chickpeas": "Kikerter", + "chicory": "Sikori", "chili_oil": "Chiliolje", + "chili_pepper": "Chilipepper", "chips": "Chips", "chives": "Gressløk", "chocolate": "Sjokolade", "chocolate_chips": "Sjokoladebiter", "chopped_tomatoes": "Hakkede tomater", + "chunky_tomatoes": "Grove tomater", "ciabatta": "Ciabatta", "cider_vinegar": "Cider eddik", "cilantro": "Koriander", @@ -104,6 +110,7 @@ "cornflakes": "Cornflakes", "cornstarch": "Maisstivelse", "cornys": "Cornys", + "corriander": "Koriander", "cough_drops": "Hostedråper", "couscous": "Couscous", "covid_rapid_test": "COVID-hurtigtest", @@ -116,21 +123,29 @@ "crispbread": "Knekkebrød", "cucumber": "Agurk", "cumin": "Kummin", + "curd": "Ostemasse", "curry_paste": "Karrypasta", "curry_powder": "Karrypulver", "curry_sauce": "Karrisaus", "dates": "Datoer", "dental_floss": "Tanntråd", + "deo": "Deodorant", "deodorant": "Deodorant", "detergent": "Vaskemiddel", + "detergent_sheets": "Vaskemiddelark", + "diarrhea_remedy": "Middel mot diaré", "dill": "Dill", "dishwasher_salt": "Oppvaskmaskinsalt", "dishwasher_tabs": "Tabs til oppvaskmaskin", "disinfection_spray": "Desinfeksjonsspray", "dried_tomatoes": "Tørkede tomater", "edamame": "Edamame", + "egg_salad": "Eggesalat", + "egg_yolk": "Eggeplomme", "eggplant": "Aubergine", "eggs": "Egg", + "enoki_mushrooms": "Enoki-sopp", + "eyebrow_gel": "Øyenbrynsgelé", "falafel": "Falafel", "falafel_powder": "Falafel pulver", "fanta": "Fanta", @@ -144,8 +159,9 @@ "frozen_fruit": "Frossen frukt", "frozen_pizza": "Frossen pizza", "frozen_spinach": "Frossen spinat", + "funeral_card": "Begravelseskort", "garam_masala": "Garam Masala", - "garbage_bag": "Søppelpose", + "garbage_bag": "Søppelposer", "garlic": "Hvitløk", "garlic_dip": "Hvitløksdipp", "garlic_granules": "Hvitløksgranulat", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Granola-bar", "grapes": "Druer", "greek_yogurt": "Gresk yoghurt", "green_asparagus": "Grønn asparges", "green_chili": "Grønn chili", "green_pesto": "Grønn pesto", "hair_gel": "Hårgelé", + "hair_ties": "Hårbånd", "hair_wax": "Hårvoks", + "hand_soap": "Håndsåpe", "handkerchief_box": "Lommetørkleboks", "handkerchiefs": "Lommetørklær", + "hard_cheese": "Hard ost", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Hasselnøtter", @@ -176,11 +197,12 @@ "honey_wafers": "Honning wafers", "hot_dog_bun": "Pølsebrød", "ice_cream": "Iskrem", - "ice_cube": "Isterning", + "ice_cube": "Isbiter", "iceberg_lettuce": "Isbergsalat", "iced_tea": "Iste", "instant_soups": "Instant supper", "jam": "Syltetøy", + "jasmine_rice": "Sjasminris", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Kidneybønner", @@ -193,21 +215,28 @@ "leaf_spinach": "Bladspinat", "leek": "Purre", "lemon": "Sitron", + "lemon_curd": "Lemon Curd", "lemon_juice": "Sitronsaft", "lemonade": "Limonade", "lemongrass": "Sitrongress", + "lentil_stew": "Linsestuing", "lentils": "Linser", "lentils_red": "Røde linser", "lettuce": "Salat", "lillet": "Lillet", "lime": "Kalk", "linguine": "Linguine", + "lip_care": "Leppepleie", "low-fat_curd_cheese": "Ost med lavt fettinnhold", + "maggi": "Maggi", "magnesium": "Magnesium", "mango": "Mango", + "maple_syrup": "Lønnesirup", "margarine": "Margarin", "marjoram": "Merian", "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", "mask": "Maske", "mayonnaise": "Majones", "meat_substitute_product": "Kjøtterstatningsprodukt", @@ -215,8 +244,10 @@ "milk": "Melk", "mint": "Mint", "mint_candy": "Mint godteri", + "miso_paste": "Misopasta", "mixed_vegetables": "Blandede grønnsaker", "mochis": "Mochis", + "mold_remover": "Muggfjerner", "mountain_cheese": "Fjellost", "mouth_wash": "Munnskyllemiddel", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Gløgg", "mushrooms": "Sopp", "mustard": "Sennep", + "nail_file": "Neglefil", "neutral_oil": "Nøytral olje", "nori_sheets": "Nori-ark", "nutmeg": "Muskatnøtt", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Havregrynkaker", "oatsome": "Oatsome", "obatzda": "Obatzda", + "oil": "Olje", "olive_oil": "Olivenolje", "olives": "Oliven", "onion": "Løk", + "onion_powder": "Løkpulver", "orange_juice": "Appelsinjuice", "oranges": "Appelsiner", "oregano": "Oregano", "organic_lemon": "Økologisk sitron", "organic_waste_bags": "Poser for organisk avfall", "pak_choi": "Pak Choi", + "pantyhose": "Strømpebukse", "paprika": "Paprika", + "paprika_seasoning": "Paprikakrydder", "pardina_lentils_dried": "Pardina linser tørket", "parmesan": "Parmesan", "parsley": "Persille", @@ -264,11 +300,13 @@ "pine_nuts": "Pinjekjerner", "pineapple": "Ananas", "pita_bag": "Pitapose", + "pita_bread": "Pitabrød", "pizza": "Pizza", "pizza_dough": "Pizzadeig", "plant_magarine": "Plant Magarine", "plant_oil": "Planteolje", "plaster": "Gips", + "pointed_peppers": "Spiss paprika", "porcini_mushrooms": "Porcini sopp", "potato_dumpling_dough": "Deig til potetboller", "potato_wedges": "Potetkiler", @@ -289,8 +327,10 @@ "rapeseed_oil": "Rapsolje", "raspberries": "Bringebær", "raspberry_syrup": "Bringebærsirup", + "razor_blades": "Barberblader", "red_bull": "Red Bull", "red_chili": "Rød chili", + "red_curry_paste": "Rød karripasta", "red_lentils": "Røde linser", "red_onions": "Rødløk", "red_pesto": "Rød pesto", @@ -300,6 +340,7 @@ "ribbon_noodles": "Båndnudler", "rice": "Ris", "rice_cakes": "Riskaker", + "rice_paper": "Rispapir", "rice_ribbon_noodles": "Risbåndnudler", "rice_vinegar": "Riseddik", "ricotta": "Ricotta", @@ -341,10 +382,13 @@ "smoked_tofu": "Røkt tofu", "snacks": "Snacks", "soap": "Såpe", + "soba_noodles": "Soba-nudler", "soft_drinks": "Brus", "softdrinks": "Brus", + "soup_vegetables": "Suppe med grønnsaker", "sour_cream": "Rømme", "sour_cucumbers": "Sure agurker", + "soy_cream": "Soyakrem", "soy_hack": "Soya-hack", "soy_sauce": "Soyasaus", "soy_shred": "Soyastrimler", @@ -354,6 +398,7 @@ "spelt": "Spelt", "spinach": "Spinat", "sponge_cloth": "Svampeklut", + "sponge_fingers": "Svampefingre", "sponge_wipes": "Svampeservietter", "sponges": "Svamper", "spreading_cream": "Påsmøring av krem", @@ -362,18 +407,24 @@ "sprouts": "Spirer", "sriracha": "Sriracha", "strained_tomatoes": "Silte tomater", + "strawberries": "Jordbær", "sugar": "Sukker", "summer_roll_paper": "Papir for sommerruller", + "sunflower_oil": "Solsikkeolje", "sunflower_seeds": "Solsikkefrø", + "sunscreen": "Solkrem", "sushi_rice": "Sushi ris", "swabian_ravioli": "Schwabisk ravioli", + "sweet_chili_sauce": "Søt chilisaus", "sweet_potato": "Søtpotet", "sweet_potatoes": "Søtpoteter", + "sweets": "Søtsaker", "table_salt": "Bordsalt", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Mandariner", "tape": "Tape", + "tapioca_flour": "Tapiokamel", "tea": "Te", "teriyaki_sauce": "Teriyakisaus", "thyme": "Timian", @@ -401,20 +452,27 @@ "vegetables": "Grønnsaker", "vegetarian_cold_cuts": "vegetarisk pålegg", "vinegar": "Eddik", + "vitamin_tablets": "Vitamintabletter", "vodka": "Vodka", + "washing_gel": "Vaskegel", "washing_powder": "Vaskepulver", "water": "Vann", "water_ice": "Vannis", "watermelon": "Vannmelon", "wc_cleaner": "WC-rengjøringsmiddel", + "wheat_flour": "Hvetemel", "whipped_cream": "Pisket krem", "white_wine": "Hvitvin", "white_wine_vinegar": "Hvitvinseddik", "whole_canned_tomatoes": "Hele hermetiske tomater", "wild_berries": "Ville bær", + "wild_rice": "Vill ris", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Worcestersaus", "wrapping_paper": "Innpakningspapir", "wraps": "Innpakning", "yeast": "Gjær", + "yeast_flakes": "Gjærflak", "yoghurt": "Yoghurt", "yogurt": "Yoghurt", "yum_yum": "Yum Yum", diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json index 51b1d4cc..6939d918 100644 --- a/backend/templates/l10n/nl.json +++ b/backend/templates/l10n/nl.json @@ -17,12 +17,15 @@ "apple": "Appel", "apple_pulp": "Appelpulp", "applesauce": "Appelmoes", + "apricots": "Abrikozen", "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Aziatische eiernoedels", + "asian_noodles": "Aziatische noedels", "asparagus": "Asperges", "aspirin": "Aspirine", "avocado": "Avocado", + "baby_potatoes": "Drieling", "baby_spinach": "Babyspinazie", "bacon": "Spek", "baguette": "Stokbrood", @@ -80,12 +83,15 @@ "cheese": "Kaas", "cherry_tomatoes": "Cherry tomaten", "chickpeas": "Kikkererwten", + "chicory": "Cichorei", "chili_oil": "Chili olie", + "chili_pepper": "Chilipeper", "chips": "Chips", "chives": "Bieslook", "chocolate": "Chocolade", "chocolate_chips": "Chocolade chips", "chopped_tomatoes": "Gehakte tomaten", + "chunky_tomatoes": "Grove tomaten", "ciabatta": "Ciabatta", "cider_vinegar": "Cider azijn", "cilantro": "Cilantro", @@ -104,6 +110,7 @@ "cornflakes": "Cornflakes", "cornstarch": "Maïszetmeel", "cornys": "Cornys", + "corriander": "Koriander", "cough_drops": "Hoestdruppels", "couscous": "Couscous", "covid_rapid_test": "COVID-sneltest", @@ -116,21 +123,29 @@ "crispbread": "Knäckebröd", "cucumber": "Komkommer", "cumin": "Komijn", + "curd": "Wrongel", "curry_paste": "Kerriepasta", "curry_powder": "Kerriepoeder", "curry_sauce": "Kerrie saus", "dates": "Data", "dental_floss": "Flosdraad", + "deo": "Deodorant", "deodorant": "Deodorant", "detergent": "Wasmiddel", + "detergent_sheets": "Vellen wasmiddel", + "diarrhea_remedy": "Middelen tegen diarree", "dill": "Dille", "dishwasher_salt": "Vaatwasser zout", "dishwasher_tabs": "Vaatwasser tabs", "disinfection_spray": "Ontsmettingsspray", "dried_tomatoes": "Gedroogde tomaten", "edamame": "Edamame", + "egg_salad": "Eiersalade", + "egg_yolk": "Eigeel", "eggplant": "Aubergine", "eggs": "Eieren", + "enoki_mushrooms": "Enoki paddenstoelen", + "eyebrow_gel": "Wenkbrauw gel", "falafel": "Falafel", "falafel_powder": "Falafel poeder", "fanta": "Fanta", @@ -144,8 +159,9 @@ "frozen_fruit": "Bevroren fruit", "frozen_pizza": "Bevroren pizza", "frozen_spinach": "Bevroren spinazie", + "funeral_card": "Begrafeniskaart", "garam_masala": "Garam Masala", - "garbage_bag": "Vuilniszak", + "garbage_bag": "Vuilniszakken", "garlic": "Knoflook", "garlic_dip": "Knoflook dip", "garlic_granules": "Knoflookgranulaat", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Mueslireep", "grapes": "Druiven", "greek_yogurt": "Griekse yoghurt", "green_asparagus": "Groene asperges", "green_chili": "Groene chili", "green_pesto": "Groene pesto", "hair_gel": "Haargel", + "hair_ties": "Haarbanden", "hair_wax": "Haarwas", + "hand_soap": "Handzeep", "handkerchief_box": "Zakdoek doos", "handkerchiefs": "Zakdoeken", + "hard_cheese": "Harde kaas", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Hazelnoten", @@ -176,11 +197,12 @@ "honey_wafers": "Honingwafels", "hot_dog_bun": "Hot dog broodje", "ice_cream": "IJs", - "ice_cube": "IJsblokje", + "ice_cube": "IJsblokjes", "iceberg_lettuce": "IJsbergsla", "iced_tea": "Ijsthee", "instant_soups": "Instant soepen", "jam": "Jam", + "jasmine_rice": "Jasmijnrijst", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Nierbonen", @@ -193,21 +215,28 @@ "leaf_spinach": "Bladspinazie", "leek": "Prei", "lemon": "Citroen", + "lemon_curd": "Citroen kwark", "lemon_juice": "Citroensap", "lemonade": "Limonade", "lemongrass": "Citroengras", + "lentil_stew": "Stoofpotje van linzen", "lentils": "Linzen", "lentils_red": "Rode linzen", "lettuce": "Sla", "lillet": "Lillet", "lime": "Kalk", "linguine": "Linguine", + "lip_care": "Lipverzorging", "low-fat_curd_cheese": "Magere kwark", + "maggi": "Maggi", "magnesium": "Magnesium", "mango": "Mango", + "maple_syrup": "Ahornsiroop", "margarine": "Margarine", "marjoram": "Marjolein", "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", "mask": "Masker", "mayonnaise": "Mayonaise", "meat_substitute_product": "Vleesvervangend product", @@ -215,8 +244,10 @@ "milk": "Melk", "mint": "Munt", "mint_candy": "Mint snoep", + "miso_paste": "Misopasta", "mixed_vegetables": "Gemengde groenten", "mochis": "Mochis", + "mold_remover": "Schimmelverwijderaar", "mountain_cheese": "Bergkaas", "mouth_wash": "Mondspoeling", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Glühwein", "mushrooms": "Champignons", "mustard": "Mosterd", + "nail_file": "Nagelvijl", "neutral_oil": "Neutrale olie", "nori_sheets": "Nori vellen", "nutmeg": "Nootmuskaat", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Havermoutkoekjes", "oatsome": "Oatsome", "obatzda": "Obatzda", + "oil": "Olie", "olive_oil": "Olijfolie", "olives": "Olijven", "onion": "Ui", + "onion_powder": "Uipoeder", "orange_juice": "Sinaasappelsap", "oranges": "Sinaasappels", "oregano": "Oregano", "organic_lemon": "Biologische citroen", "organic_waste_bags": "Zakken voor organisch afval", "pak_choi": "Pak Choi", + "pantyhose": "Kousenband", "paprika": "Paprika", + "paprika_seasoning": "Paprika kruiden", "pardina_lentils_dried": "Pardina linzen gedroogd", "parmesan": "Parmezaan", "parsley": "Peterselie", @@ -264,11 +300,13 @@ "pine_nuts": "Pijnboompitten", "pineapple": "Ananas", "pita_bag": "Pita zak", + "pita_bread": "Pitabrood", "pizza": "Pizza", "pizza_dough": "Pizzadeeg", "plant_magarine": "Plant Magarine", "plant_oil": "Plantaardige olie", "plaster": "Gips", + "pointed_peppers": "Puntpaprika's", "porcini_mushrooms": "Porcini paddestoelen", "potato_dumpling_dough": "Aardappel knoedel deeg", "potato_wedges": "Aardappelpartjes", @@ -289,8 +327,10 @@ "rapeseed_oil": "Koolzaadolie", "raspberries": "Frambozen", "raspberry_syrup": "Frambozensiroop", + "razor_blades": "Scheermesjes", "red_bull": "Red Bull", "red_chili": "Rode chili", + "red_curry_paste": "Rode currypasta", "red_lentils": "Rode linzen", "red_onions": "Rode uien", "red_pesto": "Rode pesto", @@ -300,6 +340,7 @@ "ribbon_noodles": "Lintnoedels", "rice": "Rijst", "rice_cakes": "Rijstwafels", + "rice_paper": "Rijstpapier", "rice_ribbon_noodles": "Rijstlint noedels", "rice_vinegar": "Rijstazijn", "ricotta": "Ricotta", @@ -341,10 +382,13 @@ "smoked_tofu": "Gerookte tofu", "snacks": "Snacks", "soap": "Zeep", + "soba_noodles": "Soba noedels", "soft_drinks": "Frisdranken", "softdrinks": "Frisdranken", + "soup_vegetables": "Soepgroenten", "sour_cream": "Zure room", "sour_cucumbers": "Zure komkommers", + "soy_cream": "Sojaroom", "soy_hack": "Soja hack", "soy_sauce": "Sojasaus", "soy_shred": "Sojasnippers", @@ -354,6 +398,7 @@ "spelt": "Spelt", "spinach": "Spinazie", "sponge_cloth": "Sponsdoek", + "sponge_fingers": "Sponsvingers", "sponge_wipes": "Sponsdoekjes", "sponges": "Sponzen", "spreading_cream": "Smeercrème", @@ -362,18 +407,24 @@ "sprouts": "Sprouts", "sriracha": "Sriracha", "strained_tomatoes": "Gezeefde tomaten", + "strawberries": "Aardbeien", "sugar": "Suiker", "summer_roll_paper": "Zomer rol papier", + "sunflower_oil": "Zonnebloemolie", "sunflower_seeds": "Zonnebloempitten", + "sunscreen": "Zonnebrandcrème", "sushi_rice": "Sushi rijst", "swabian_ravioli": "Zwabische ravioli", + "sweet_chili_sauce": "Zoete Chilisaus", "sweet_potato": "Zoete aardappel", "sweet_potatoes": "Zoete aardappelen", + "sweets": "Zoetigheden", "table_salt": "Tafelzout", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Mandarijnen", "tape": "Tape", + "tapioca_flour": "Tapiocameel", "tea": "Thee", "teriyaki_sauce": "Teriyaki saus", "thyme": "Tijm", @@ -401,20 +452,27 @@ "vegetables": "Groenten", "vegetarian_cold_cuts": "vegetarische vleeswaren", "vinegar": "Azijn", + "vitamin_tablets": "Vitamine tabletten", "vodka": "Wodka", + "washing_gel": "Wasgel", "washing_powder": "Waspoeder", "water": "Water", "water_ice": "Waterijs", "watermelon": "Watermeloen", "wc_cleaner": "WC-reiniger", + "wheat_flour": "Tarwebloem", "whipped_cream": "Slagroom", "white_wine": "Witte wijn", "white_wine_vinegar": "Witte wijnazijn", "whole_canned_tomatoes": "Hele tomaten in blik", "wild_berries": "Wilde bessen", + "wild_rice": "Wilde rijst", + "wildberry_lillet": "Wilde bosbes Lillet", + "worcester_sauce": "Worcestersaus", "wrapping_paper": "Inpakpapier", "wraps": "Wraps", "yeast": "Gist", + "yeast_flakes": "Gistvlokken", "yoghurt": "Yoghurt", "yogurt": "Yoghurt", "yum_yum": "Yum Yum", diff --git a/backend/templates/l10n/pl.json b/backend/templates/l10n/pl.json index 62cdf61c..a96c82ee 100644 --- a/backend/templates/l10n/pl.json +++ b/backend/templates/l10n/pl.json @@ -17,12 +17,15 @@ "apple": "Jabłko", "apple_pulp": "Przecier jabłkowy", "applesauce": "Mus jabłkowy", + "apricots": "Morele", "apérol": "Apérol", "arugula": "Rukola", "asian_egg_noodles": "Azjatycki makaron jajeczny", + "asian_noodles": "Makaron azjatycki", "asparagus": "Szparagi", "aspirin": "Aspiryna", "avocado": "Awokado", + "baby_potatoes": "Trojaczki", "baby_spinach": "Szpinak", "bacon": "Boczek", "baguette": "Bagietka", @@ -80,12 +83,15 @@ "cheese": "Ser", "cherry_tomatoes": "Pomidorki koktajlowe", "chickpeas": "Ciecierzyca", + "chicory": "Cykoria", "chili_oil": "Olej chili", + "chili_pepper": "Papryczka chili", "chips": "Frytki", "chives": "Szczypiorek", "chocolate": "Czekolada", "chocolate_chips": "Cząstki czekolady", "chopped_tomatoes": "Pokrojone pomidory", + "chunky_tomatoes": "Grube pomidory", "ciabatta": "Ciabatta", "cider_vinegar": "Ocet jabłkowy", "cilantro": "Kolendra", @@ -104,6 +110,7 @@ "cornflakes": "Płatki kukurydziane", "cornstarch": "Skrobia kukurydziana", "cornys": "Cornys", + "corriander": "Kolendra", "cough_drops": "Tabletki na kaszel", "couscous": "Kuskus", "covid_rapid_test": "Szybki test COVID", @@ -116,21 +123,29 @@ "crispbread": "Pieczywo chrupkie", "cucumber": "Ogórek", "cumin": "Kumin", + "curd": "Twaróg", "curry_paste": "Pasta curry", "curry_powder": "Curry", "curry_sauce": "Sos curry", "dates": "Daktyle", "dental_floss": "Nić dentystyczna", + "deo": "Dezodorant", "deodorant": "Dezodorant", "detergent": "Środek czyszczący", + "detergent_sheets": "Arkusze detergentu", + "diarrhea_remedy": "Środek na biegunkę", "dill": "Koper", "dishwasher_salt": "Sól do zmywarki", "dishwasher_tabs": "Tabletki do zmywarki", "disinfection_spray": "Spray do dezynfekcji", "dried_tomatoes": "Suszone pomidory", "edamame": "Edamame", + "egg_salad": "Sałatka jajeczna", + "egg_yolk": "Żółtko jaja", "eggplant": "Psianka podłużna", "eggs": "Jajka", + "enoki_mushrooms": "Grzyby enoki", + "eyebrow_gel": "Żel do brwi", "falafel": "Falafel", "falafel_powder": "Falafel w proszku", "fanta": "Fanta", @@ -144,6 +159,7 @@ "frozen_fruit": "Mrożone owoce", "frozen_pizza": "Mrożona pizza", "frozen_spinach": "Mrożony szpinak", + "funeral_card": "Karta pogrzebowa", "garam_masala": "Garam Masala", "garbage_bag": "Worki na śmieci", "garlic": "Czosnek", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Baton granola", "grapes": "Winogrona", "greek_yogurt": "Jogurt grecki", "green_asparagus": "Zielone szparagi", "green_chili": "Zielone chili", "green_pesto": "Zielone pesto", "hair_gel": "Żel do włosów", + "hair_ties": "Opaski do włosów", "hair_wax": "Wosk do włosów", + "hand_soap": "Mydło do rąk", "handkerchief_box": "Pudełko na chusteczki do nosa", "handkerchiefs": "Chusteczki do nosa", + "hard_cheese": "Twardy ser", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Orzechy laskowe", @@ -176,11 +197,12 @@ "honey_wafers": "Wafle miodowe", "hot_dog_bun": "Bułka do hot doga", "ice_cream": "Lody", - "ice_cube": "Kostka lodu", + "ice_cube": "Kostki lodu", "iceberg_lettuce": "Sałata lodowa", "iced_tea": "Mrożona herbata", "instant_soups": "Zupy błyskawiczne", "jam": "Dżem", + "jasmine_rice": "Ryż jaśminowy", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Fasola kidney", @@ -193,21 +215,28 @@ "leaf_spinach": "Szpinak liściasty", "leek": "Por", "lemon": "Cytryna", + "lemon_curd": "Lemon Curd", "lemon_juice": "Sok z cytryny", "lemonade": "Lemoniada", "lemongrass": "Trawa cytrynowa", + "lentil_stew": "Gulasz z soczewicy", "lentils": "Soczewica", "lentils_red": "Czerwona soczewica", "lettuce": "Sałata", "lillet": "Lillet", "lime": "Limonka", "linguine": "Linguine", + "lip_care": "Pielęgnacja ust", "low-fat_curd_cheese": "Twaróg o niskiej zawartości tłuszczu", + "maggi": "Maggi", "magnesium": "Magnez", "mango": "Mango", + "maple_syrup": "Syrop klonowy", "margarine": "Margaryna", "marjoram": "Majeranek", "marshmallows": "Marshmallows", + "mascara": "Tusz do rzęs", + "mascarpone": "Mascarpone", "mask": "Maska", "mayonnaise": "Majonez", "meat_substitute_product": "Produkt zastępujący mięso", @@ -215,8 +244,10 @@ "milk": "Mleko", "mint": "Mięta", "mint_candy": "Cukierki miętowe", + "miso_paste": "Pasta miso", "mixed_vegetables": "Mieszane warzywa", "mochis": "Mochis", + "mold_remover": "Środek do usuwania pleśni", "mountain_cheese": "Ser górski", "mouth_wash": "Płyn do płukania ust", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Grzane wino", "mushrooms": "Grzyby", "mustard": "Musztarda", + "nail_file": "Pilnik do paznokci", "neutral_oil": "Neutralny olej", "nori_sheets": "Arkusze nori", "nutmeg": "Gałka muszkatołowa", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Ciasteczka owsiane", "oatsome": "Oatsome", "obatzda": "Obatzda", + "oil": "Olej", "olive_oil": "Oliwa z oliwek", "olives": "Oliwki", "onion": "Cebula", + "onion_powder": "Cebula w proszku", "orange_juice": "Sok pomarańczowy", "oranges": "Pomarańcze", "oregano": "Oregano", "organic_lemon": "Organiczna cytryna", "organic_waste_bags": "Worki na odpady organiczne", "pak_choi": "Pak Choi", + "pantyhose": "Rajstopy", "paprika": "Papryka", + "paprika_seasoning": "Przyprawa paprykowa", "pardina_lentils_dried": "Suszona soczewica Pardina", "parmesan": "Parmezan", "parsley": "Pietruszka", @@ -264,11 +300,13 @@ "pine_nuts": "Orzeszki piniowe", "pineapple": "Ananas", "pita_bag": "Torba Pita", + "pita_bread": "Chleb pita", "pizza": "Pizza", "pizza_dough": "Ciasto na pizzę", "plant_magarine": "Roślina Magarine", "plant_oil": "Olej roślinny", "plaster": "Tynk", + "pointed_peppers": "Papryka ostra", "porcini_mushrooms": "Grzyby Porcini", "potato_dumpling_dough": "Ciasto na pyzy ziemniaczane", "potato_wedges": "Kliny ziemniaczane", @@ -289,8 +327,10 @@ "rapeseed_oil": "Olej rzepakowy", "raspberries": "Maliny", "raspberry_syrup": "Syrop malinowy", + "razor_blades": "Żyletki", "red_bull": "Red Bull", "red_chili": "Czerwone chili", + "red_curry_paste": "Czerwona pasta curry", "red_lentils": "Czerwona soczewica", "red_onions": "Czerwona cebula", "red_pesto": "Czerwone pesto", @@ -300,6 +340,7 @@ "ribbon_noodles": "Makaron wstążkowy", "rice": "Ryż", "rice_cakes": "Ciastka ryżowe", + "rice_paper": "Papier ryżowy", "rice_ribbon_noodles": "Makaron ryżowy wstążkowy", "rice_vinegar": "Ocet ryżowy", "ricotta": "Ser ricotta", @@ -341,10 +382,13 @@ "smoked_tofu": "Tofu wędzone", "snacks": "Przekąski", "soap": "Mydło", + "soba_noodles": "Makaron soba", "soft_drinks": "Napoje gazowane", "softdrinks": "Napoje gazowane", + "soup_vegetables": "Warzywa do zupy", "sour_cream": "Kwaśna śmietana", "sour_cucumbers": "Kwaśne ogórki", + "soy_cream": "Śmietanka sojowa", "soy_hack": "Hack na soję", "soy_sauce": "Sos sojowy", "soy_shred": "Rozdrobniona soja", @@ -354,6 +398,7 @@ "spelt": "Orkisz", "spinach": "Szpinak", "sponge_cloth": "Ściereczka gąbczasta", + "sponge_fingers": "Palce z gąbki", "sponge_wipes": "Gąbki do mycia", "sponges": "Gąbki", "spreading_cream": "Śmietana do smarowania", @@ -362,18 +407,24 @@ "sprouts": "Kiełki", "sriracha": "Sriracha", "strained_tomatoes": "Odcedzone pomidory", + "strawberries": "Truskawki", "sugar": "Cukier", "summer_roll_paper": "Papier w rolce na lato", + "sunflower_oil": "Olej słonecznikowy", "sunflower_seeds": "Nasiona słonecznika", + "sunscreen": "Filtr przeciwsłoneczny", "sushi_rice": "Ryż do sushi", "swabian_ravioli": "Maultaschen", + "sweet_chili_sauce": "Słodki sos chili", "sweet_potato": "Batat", "sweet_potatoes": "Bataty", + "sweets": "Słodycze", "table_salt": "Sól stołowa", "tagliatelle": "Makaron tagliatelle", "tahini": "Tahini", "tangerines": "Mandarynki", "tape": "Taśma klejąca", + "tapioca_flour": "Mąka z tapioki", "tea": "Herbata", "teriyaki_sauce": "Sos teriyaki", "thyme": "Tymianek", @@ -401,20 +452,27 @@ "vegetables": "Warzywa", "vegetarian_cold_cuts": "wędliny wegetariańskie", "vinegar": "Ocet", + "vitamin_tablets": "Tabletki witaminowe", "vodka": "Wódka", + "washing_gel": "Żel do mycia", "washing_powder": "Proszek do prania", "water": "Woda", "water_ice": "Lód wodny", "watermelon": "Arbuz", "wc_cleaner": "Środek do czyszczenia WC", + "wheat_flour": "Mąka pszenna", "whipped_cream": "Bita śmietana", "white_wine": "Białe wino", "white_wine_vinegar": "Ocet z białego wina", "whole_canned_tomatoes": "Całe pomidory w puszce", "wild_berries": "Dzikie jagody", + "wild_rice": "Dziki ryż", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Sos Worcester", "wrapping_paper": "Papier pakowy", "wraps": "Owijki", "yeast": "Drożdże", + "yeast_flakes": "Płatki drożdżowe", "yoghurt": "Jogurt", "yogurt": "Jogurt", "yum_yum": "Mniam mniam", diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json index d8b7e951..19503a4f 100644 --- a/backend/templates/l10n/pt.json +++ b/backend/templates/l10n/pt.json @@ -16,13 +16,16 @@ "amaretto": "Amaretto", "apple": "Maçã", "apple_pulp": "Polpa de maçã", - "applesauce": "Maçã", + "applesauce": "Sumo de maçã", + "apricots": "Damascos", "apérol": "Apérol", "arugula": "Rúcula", "asian_egg_noodles": "Macarrão asiático", + "asian_noodles": "Massa asiática", "asparagus": "Espargos", "aspirin": "Aspirina", "avocado": "Abacate", + "baby_potatoes": "Trigémeos", "baby_spinach": "Baby espinafre", "bacon": "Toucinho", "baguette": "Baguete", @@ -64,7 +67,7 @@ "button_cells": "Pilhas de relógio", "börek_cheese": "Queijo Börek", "cake": "Bolo", - "cake_icing": "Cobertura de bolo", + "cake_icing": "Cobertura do bolo", "cane_sugar": "Açúcar de Cana", "cannelloni": "Canelones", "canola_oil": "Óleo de Canola", @@ -76,16 +79,19 @@ "celeriac": "Aipo-rábano", "celery": "Aipo", "cereal_bar": "Barra Cereais", - "cheddar": "Cheddar", + "cheddar": "Chedar", "cheese": "Queijo", "cherry_tomatoes": "Tomates cherry", "chickpeas": "Grão de bico", + "chicory": "Chicória", "chili_oil": "Molho Chili", + "chili_pepper": "Pimenta malagueta", "chips": "Batata frita de pacote", "chives": "Cebolinho", "chocolate": "Chocolate", "chocolate_chips": "Pedaços de chocolate", "chopped_tomatoes": "Tomates picados", + "chunky_tomatoes": "Tomates em pedaços", "ciabatta": "Pão chapata", "cider_vinegar": "Vinagre de Cidra", "cilantro": "Coentros", @@ -101,36 +107,45 @@ "cookies": "Bolachas", "coriander": "Coentros", "corn": "Milho", - "cornflakes": "Cornflakes", + "cornflakes": "Flocos de Milho", "cornstarch": "Amido de milho", "cornys": "Cornys", + "corriander": "Coentros", "cough_drops": "Gotas para a tosse", - "couscous": "Couscous", + "couscous": "Couz-couz", "covid_rapid_test": "Teste rápido COVID", "cow's_milk": "Leite de vaca", "cream": "Natas", "cream_cheese": "Queijo creme", "creamed_spinach": "Esparregado", - "creme_fraiche": "Creme fraiche", + "creme_fraiche": "Creme Fresco", "crepe_tape": "Fita crepe", "crispbread": "Pão estaladiço", "cucumber": "Pepino", "cumin": "Cominhos", + "curd": "Requeijão", "curry_paste": "Massa de caril", "curry_powder": "Caril", "curry_sauce": "Molho de caril", "dates": "Tâmaras", "dental_floss": "Fio dental", + "deo": "Desodorizante", "deodorant": "Desodorizante", "detergent": "Detergente", + "detergent_sheets": "Folhas de detergente", + "diarrhea_remedy": "Remédio para a diarreia", "dill": "Endro", "dishwasher_salt": "Sal para máquina da loiça", "dishwasher_tabs": "Pastilhas para máquina da loiça", "disinfection_spray": "Spray desinfetante", "dried_tomatoes": "Tomates secos", "edamame": "Edamame", + "egg_salad": "Salada de ovo", + "egg_yolk": "Gema de ovo", "eggplant": "Beringela", "eggs": "Ovos", + "enoki_mushrooms": "Cogumelos Enoki", + "eyebrow_gel": "Gel para sobrancelhas", "falafel": "Falafel", "falafel_powder": "Falafel em pó", "fanta": "Fanta", @@ -144,8 +159,9 @@ "frozen_fruit": "Frutas congeladas", "frozen_pizza": "Pizza congelada", "frozen_spinach": "Espinafres congelados", + "funeral_card": "Cartão funerário", "garam_masala": "Garam Masala", - "garbage_bag": "Saco do lixo", + "garbage_bag": "Sacos do lixo", "garlic": "Alho", "garlic_dip": "Molho de alho", "garlic_granules": "Grânulos de alho", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Barra de cereais", "grapes": "Uvas", "greek_yogurt": "Iogurte grego", "green_asparagus": "Espargos", "green_chili": "Pimenta verde", "green_pesto": "Pesto verde", "hair_gel": "Gel para cabelo", + "hair_ties": "Laços para o cabelo", "hair_wax": "Cera para pelos", + "hand_soap": "Sabonete para as mãos", "handkerchief_box": "Caixa de lenços", "handkerchiefs": "Lenços de bolso", + "hard_cheese": "Queijo duro", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Avelãs", @@ -181,6 +202,7 @@ "iced_tea": "Ice tea", "instant_soups": "Sopa instantânea", "jam": "Geleia", + "jasmine_rice": "Arroz de jasmim", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Feijão vermelho", @@ -193,21 +215,28 @@ "leaf_spinach": "Espinafres de folha", "leek": "Alho francês", "lemon": "Limão", + "lemon_curd": "Coalhada de limão", "lemon_juice": "Sumo de limão", "lemonade": "Limonada", "lemongrass": "Erva Príncipe", + "lentil_stew": "Guisado de lentilhas", "lentils": "Lentilhas", "lentils_red": "Lentilhas vermelhas", "lettuce": "Alface", "lillet": "Lillet", "lime": "Lima", "linguine": "Linguine", + "lip_care": "Cuidados com os lábios", "low-fat_curd_cheese": "Requeijão magro", + "maggi": "Maggi", "magnesium": "Magnésio", "mango": "Manga", + "maple_syrup": "Xarope de ácer", "margarine": "Margarina", "marjoram": "Manjerona", "marshmallows": "Marshmallows", + "mascara": "Rímel", + "mascarpone": "Mascarpone", "mask": "Máscara", "mayonnaise": "Maionese", "meat_substitute_product": "Produto de substituição de carne", @@ -215,8 +244,10 @@ "milk": "Leite", "mint": "Menta", "mint_candy": "Doces de menta", + "miso_paste": "Pasta de missô", "mixed_vegetables": "Legumes mistos", "mochis": "Mochis", + "mold_remover": "Removedor de bolor", "mountain_cheese": "Queijo de montanha", "mouth_wash": "Elixir bocal", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Vinho quente", "mushrooms": "Cogumelos", "mustard": "Mostarda", + "nail_file": "Lima de unhas", "neutral_oil": "Óleo neutro", "nori_sheets": "Folhas Nori", "nutmeg": "Noz-moscada", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Bolachas de aveia", "oatsome": "Aveia", "obatzda": "Obatzda", + "oil": "Óleo", "olive_oil": "Azeite", "olives": "Azeitonas", "onion": "Cebola", + "onion_powder": "Cebola em pó", "orange_juice": "Sumo de laranja", "oranges": "Laranjas", "oregano": "Orégãos", "organic_lemon": "Limão biológico", "organic_waste_bags": "Sacos para resíduos orgânicos", "pak_choi": "Pak Choi", + "pantyhose": "Meias-calças", "paprika": "Paprica", + "paprika_seasoning": "Tempero de paprica", "pardina_lentils_dried": "Lentilhas Pardina secas", "parmesan": "Parmesão", "parsley": "Salsa", @@ -264,11 +300,13 @@ "pine_nuts": "Pinhões", "pineapple": "Ananás", "pita_bag": "Saco de pita", + "pita_bread": "Pão pita", "pizza": "Pizza", "pizza_dough": "Massa de pizza", "plant_magarine": "Planta Magarine", "plant_oil": "Óleo vegetal", "plaster": "Gesso", + "pointed_peppers": "Pimentos pontiagudos", "porcini_mushrooms": "Cogumelos Porcini", "potato_dumpling_dough": "Massa de bolinhos de batata", "potato_wedges": "Cunhas de batata", @@ -277,20 +315,22 @@ "powder": "Pó", "powdered_sugar": "Açúcar em pó", "processed_cheese": "Queijo fundido", - "prosecco": "Prosecco", + "prosecco": "Pró Seco", "puff_pastry": "Massa folhada", "pumpkin": "Abóbora", "pumpkin_seeds": "Sementes de abóbora", "quark": "Quark", - "quinoa": "Quinoa", - "radicchio": "Radicchio", + "quinoa": "Quinua", + "radicchio": "Radiquio", "radish": "Rabanete", - "ramen": "Ramen", + "ramen": "Lamen", "rapeseed_oil": "Óleo de colza", "raspberries": "Framboesas", "raspberry_syrup": "Xarope de framboesa", + "razor_blades": "Lâminas de barbear", "red_bull": "Red Bull", "red_chili": "Pimento vermelho", + "red_curry_paste": "Pasta de caril vermelho", "red_lentils": "Lentilhas vermelhas", "red_onions": "Cebolas vermelhas", "red_pesto": "Pesto vermelho", @@ -300,6 +340,7 @@ "ribbon_noodles": "Massa com fita", "rice": "Arroz", "rice_cakes": "Bolos de arroz", + "rice_paper": "Papel de arroz", "rice_ribbon_noodles": "Massa de arroz com fita", "rice_vinegar": "Vinagre de arroz", "ricotta": "Ricotta", @@ -341,10 +382,13 @@ "smoked_tofu": "Tofu fumado", "snacks": "Snacks", "soap": "Sabonete", + "soba_noodles": "Macarrão Soba", "soft_drinks": "Refrigerantes", "softdrinks": "Refrigerantes", + "soup_vegetables": "Sopa de legumes", "sour_cream": "Creme de leite", "sour_cucumbers": "Pepinos azedos", + "soy_cream": "Creme de soja", "soy_hack": "Hack de soja", "soy_sauce": "Molho de soja", "soy_shred": "Triturador de soja", @@ -354,6 +398,7 @@ "spelt": "Espelta", "spinach": "Espinafres", "sponge_cloth": "Pano de esponja", + "sponge_fingers": "Dedos de esponja", "sponge_wipes": "Toalhetes de esponja", "sponges": "Esponjas", "spreading_cream": "Creme para barrar", @@ -362,18 +407,24 @@ "sprouts": "Rebentos", "sriracha": "Sriracha", "strained_tomatoes": "Tomates coados", + "strawberries": "Morangos", "sugar": "Açúcar", "summer_roll_paper": "Papel em rolo para o verão", + "sunflower_oil": "Óleo de girassol", "sunflower_seeds": "Sementes de girassol", + "sunscreen": "Protetor solar", "sushi_rice": "Arroz de sushi", "swabian_ravioli": "Ravioli da Suábia", + "sweet_chili_sauce": "Molho de pimentão doce", "sweet_potato": "Batata doce", "sweet_potatoes": "Batatas doce", + "sweets": "Doces", "table_salt": "Sal de mesa", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Tangerinas", "tape": "Fita", + "tapioca_flour": "Farinha de tapioca", "tea": "Chá", "teriyaki_sauce": "Molho Teriyaki", "thyme": "Tomilho", @@ -401,20 +452,27 @@ "vegetables": "Legumes", "vegetarian_cold_cuts": "charcutaria vegetariana", "vinegar": "Vinagre", + "vitamin_tablets": "Comprimidos de vitaminas", "vodka": "Vodka", + "washing_gel": "Gel de lavagem", "washing_powder": "Pó de lavagem", "water": "Água", "water_ice": "Água gelada", "watermelon": "Melancia", "wc_cleaner": "Detergente de casa de banho", + "wheat_flour": "Farinha de trigo", "whipped_cream": "Nata batida", "white_wine": "Vinho branco", "white_wine_vinegar": "Vinagre de vinho branco", "whole_canned_tomatoes": "Tomates inteiros enlatados", "wild_berries": "Frutos silvestres", + "wild_rice": "Arroz selvagem", + "wildberry_lillet": "Lillet de amora silvestre", + "worcester_sauce": "Molho Worcester", "wrapping_paper": "Papel de embrulho", "wraps": "Embrulhos", "yeast": "Fermento", + "yeast_flakes": "Flocos de levedura", "yoghurt": "Iogurte", "yogurt": "Iogurte", "yum_yum": "Yum Yum", diff --git a/backend/templates/l10n/pt_BR.json b/backend/templates/l10n/pt_BR.json index 9b3cc905..c5c09da5 100644 --- a/backend/templates/l10n/pt_BR.json +++ b/backend/templates/l10n/pt_BR.json @@ -17,12 +17,15 @@ "apple": "Maçã", "apple_pulp": "Polpa de Maçã", "applesauce": "Molho de Maçã", + "apricots": "Damascos", "apérol": "Aperol", "arugula": "Rúcula", "asian_egg_noodles": "Macarrão com ovos asiáticos", + "asian_noodles": "Macarrão asiático", "asparagus": "Aspargos", "aspirin": "Aspirina", "avocado": "Abacate", + "baby_potatoes": "Trigêmeos", "baby_spinach": "Espinafre baby", "bacon": "Bacon", "baguette": "Baguete", @@ -80,11 +83,15 @@ "cheese": "Queijo", "cherry_tomatoes": "Tomates cereja", "chickpeas": "Chickpeas", + "chicory": "Chicória", "chili_oil": "Óleo de pimenta", + "chili_pepper": "Pimenta malagueta", "chips": "Fichas", "chives": "Cebolinho", "chocolate": "Chocolate", + "chocolate_chips": "Chocolate em pedaços", "chopped_tomatoes": "Tomates picados", + "chunky_tomatoes": "Tomates em pedaços", "ciabatta": "Ciabatta", "cider_vinegar": "Vinagre de cidra", "cilantro": "Cilantro", @@ -103,6 +110,7 @@ "cornflakes": "Cornflakes", "cornstarch": "Amido de milho", "cornys": "Cornys", + "corriander": "Coentro", "cough_drops": "Rebuçados para a tosse", "couscous": "Couscous", "covid_rapid_test": "Teste rápido COVID", @@ -115,21 +123,29 @@ "crispbread": "Crispbread", "cucumber": "Pepino", "cumin": "Cumin", + "curd": "Coalhada", "curry_paste": "Pasta de caril", "curry_powder": "Caril em pó", "curry_sauce": "Molho de caril", "dates": "Datas", "dental_floss": "Fio dental", + "deo": "Desodorante", "deodorant": "Desodorante", "detergent": "Detergente", + "detergent_sheets": "Folhas de detergente", + "diarrhea_remedy": "Remédio para diarreia", "dill": "Endro", "dishwasher_salt": "Sal de lava-louças", "dishwasher_tabs": "Abas para lava-louça", "disinfection_spray": "Spray de desinfecção", "dried_tomatoes": "Tomates secos", "edamame": "Edamame", + "egg_salad": "Salada de ovos", + "egg_yolk": "Gema de ovo", "eggplant": "Berinjela", "eggs": "Ovos", + "enoki_mushrooms": "Cogumelos Enoki", + "eyebrow_gel": "Gel para sobrancelhas", "falafel": "Falafel", "falafel_powder": "Pó de falafel", "fanta": "Fanta", @@ -143,8 +159,9 @@ "frozen_fruit": "Frutas congeladas", "frozen_pizza": "Pizza congelada", "frozen_spinach": "Espinafres congelados", + "funeral_card": "Cartão de funeral", "garam_masala": "Garam Masala", - "garbage_bag": "Saco do lixo", + "garbage_bag": "Sacos de lixo", "garlic": "Alho", "garlic_dip": "Molho de alho", "garlic_granules": "Grânulos de alho", @@ -156,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Barra de granola", "grapes": "Uvas", "greek_yogurt": "Iogurte grego", "green_asparagus": "Espargos verdes", "green_chili": "Pimenta verde", "green_pesto": "Pesto verde", "hair_gel": "Gel para cabelo", + "hair_ties": "Laços de cabelo", "hair_wax": "Cera para cabelos", + "hand_soap": "Sabonete para as mãos", "handkerchief_box": "Caixa de lenços de bolso", "handkerchiefs": "Lenços de assoar e de bolso", + "hard_cheese": "Queijo duro", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Avelãs", @@ -175,11 +197,12 @@ "honey_wafers": "Bolachas de mel", "hot_dog_bun": "Pão de cachorro-quente", "ice_cream": "Sorvete", - "ice_cube": "Cubo de gelo", + "ice_cube": "Cubos de gelo", "iceberg_lettuce": "Alface Iceberg", "iced_tea": "Chá gelado", "instant_soups": "Sopas instantâneas", "jam": "Jam", + "jasmine_rice": "Arroz jasmim", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Feijão para os rins", @@ -192,21 +215,28 @@ "leaf_spinach": "Espinafres de folha", "leek": "Leek", "lemon": "Limão", + "lemon_curd": "Coalhada de limão", "lemon_juice": "Suco de limão", "lemonade": "Limonada", "lemongrass": "Capim-limão", + "lentil_stew": "Ensopado de lentilha", "lentils": "Lentilhas", "lentils_red": "Lentilhas vermelhas", "lettuce": "Alface", "lillet": "Lillet", "lime": "Cal", "linguine": "Linguine", + "lip_care": "Cuidados com os lábios", "low-fat_curd_cheese": "Queijo de coalho de baixo teor de gordura", + "maggi": "Maggi", "magnesium": "Magnésio", "mango": "Manga", + "maple_syrup": "Xarope de bordo", "margarine": "Margarine", "marjoram": "Manjerona", "marshmallows": "Marshmallows", + "mascara": "Rímel", + "mascarpone": "Mascarpone", "mask": "Máscara", "mayonnaise": "Mayonnaise", "meat_substitute_product": "Produto substituto da carne", @@ -214,8 +244,10 @@ "milk": "Leite", "mint": "Casa da Moeda", "mint_candy": "Doces de menta", + "miso_paste": "Pasta de missô", "mixed_vegetables": "Vegetais mistos", "mochis": "Mochis", + "mold_remover": "Removedor de mofo", "mountain_cheese": "Queijo de montanha", "mouth_wash": "Lavagem bucal", "mozzarella": "Mozzarella", @@ -224,6 +256,7 @@ "mulled_wine": "Vinho de mesa", "mushrooms": "Cogumelos", "mustard": "Mostarda", + "nail_file": "Lixa de unha", "neutral_oil": "Óleo neutro", "nori_sheets": "Folhas Nori", "nutmeg": "Nutmeg", @@ -232,16 +265,20 @@ "oatmeal_cookies": "Biscoitos com aveia", "oatsome": "Oatsome", "obatzda": "Obatzda", + "oil": "Óleo", "olive_oil": "Azeite de oliva", "olives": "Azeitonas", "onion": "Cebola", + "onion_powder": "Cebola em pó", "orange_juice": "Suco de laranja", "oranges": "Laranjas", "oregano": "Orégano", "organic_lemon": "Limão orgânico", "organic_waste_bags": "Sacos para resíduos orgânicos", "pak_choi": "Pak Choi", + "pantyhose": "Meia-calça", "paprika": "Paprika", + "paprika_seasoning": "Tempero de páprica", "pardina_lentils_dried": "Pardina lentilhas secas", "parmesan": "Parmesão", "parsley": "Salsa", @@ -263,11 +300,13 @@ "pine_nuts": "Pinhões", "pineapple": "Abacaxi", "pita_bag": "Saco Pita", + "pita_bread": "Pão pita", "pizza": "Pizza", "pizza_dough": "Massa para pizza", "plant_magarine": "Planta Magarine", "plant_oil": "Óleo vegetal", "plaster": "Gesso", + "pointed_peppers": "Pimentos pontiagudos", "porcini_mushrooms": "Cogumelos Porcini", "potato_dumpling_dough": "Massa de bolinho de batata", "potato_wedges": "Cunhas de batata", @@ -288,8 +327,10 @@ "rapeseed_oil": "Óleo de colza", "raspberries": "Framboesas", "raspberry_syrup": "Xarope de framboesa", + "razor_blades": "Lâminas de barbear", "red_bull": "Red Bull", "red_chili": "Pimenta vermelha", + "red_curry_paste": "Pasta de curry vermelho", "red_lentils": "Lentilhas vermelhas", "red_onions": "Cebolas vermelhas", "red_pesto": "Pesto vermelho", @@ -299,6 +340,7 @@ "ribbon_noodles": "Macarrão de fita", "rice": "Arroz", "rice_cakes": "Bolos de arroz", + "rice_paper": "Papel de arroz", "rice_ribbon_noodles": "Macarrão com fitas de arroz", "rice_vinegar": "Vinagre de arroz", "ricotta": "Ricotta", @@ -324,7 +366,6 @@ "scattered_cheese": "Queijo disperso", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", - "chocolate_chips": "Chocolate em pedaços", "semolina_porridge": "Mingau de semolina", "sesame": "Sésamo", "sesame_oil": "Óleo de gergelim", @@ -341,10 +382,13 @@ "smoked_tofu": "Tofu defumado", "snacks": "Lanches", "soap": "Sabonete", + "soba_noodles": "Macarrão Soba", "soft_drinks": "Refrigerantes", "softdrinks": "Refrigerantes", + "soup_vegetables": "Sopa de legumes", "sour_cream": "Creme azedo", "sour_cucumbers": "Pepinos azedos", + "soy_cream": "Creme de soja", "soy_hack": "Hack de soja", "soy_sauce": "Molho de soja", "soy_shred": "Trituração de soja", @@ -354,6 +398,7 @@ "spelt": "Espelta", "spinach": "Espinafres", "sponge_cloth": "Pano de esponja", + "sponge_fingers": "Dedos de esponja", "sponge_wipes": "Toalhetes de esponja", "sponges": "Esponjas", "spreading_cream": "Creme de espalhamento", @@ -362,18 +407,24 @@ "sprouts": "Brotos", "sriracha": "Sriracha", "strained_tomatoes": "Tomates deformados", + "strawberries": "Morangos", "sugar": "Açúcar", "summer_roll_paper": "Papel em rolo de verão", + "sunflower_oil": "Óleo de girassol", "sunflower_seeds": "Sementes de girassol", + "sunscreen": "Protetor solar", "sushi_rice": "Arroz sushi", "swabian_ravioli": "Ravióli suábio", + "sweet_chili_sauce": "Molho de pimenta doce", "sweet_potato": "Batata doce", "sweet_potatoes": "Batata doce", + "sweets": "Doces", "table_salt": "Sal de mesa", "tagliatelle": "Tagliatelle", "tahini": "Tahini", "tangerines": "Tangerinas", "tape": "Fita", + "tapioca_flour": "Farinha de tapioca", "tea": "Chá", "teriyaki_sauce": "Molho Teriyaki", "thyme": "Tomilho", @@ -401,20 +452,27 @@ "vegetables": "Legumes", "vegetarian_cold_cuts": "frios vegetarianos", "vinegar": "Vinagre", + "vitamin_tablets": "Comprimidos de vitaminas", "vodka": "Vodca", + "washing_gel": "Gel de lavagem", "washing_powder": "Pó de lavagem", "water": "Água", "water_ice": "Gelo de água", "watermelon": "Melancia", "wc_cleaner": "Limpador de WC", + "wheat_flour": "Farinha de trigo", "whipped_cream": "Nata batida", "white_wine": "Vinho branco", "white_wine_vinegar": "Vinagre de vinho branco", "whole_canned_tomatoes": "Tomates inteiros enlatados", "wild_berries": "Frutos silvestres", + "wild_rice": "Arroz selvagem", + "wildberry_lillet": "Lillet de amora silvestre", + "worcester_sauce": "Molho Worcester", "wrapping_paper": "Papel de embrulho", "wraps": "Wraps", "yeast": "Levedura", + "yeast_flakes": "Flocos de levedura", "yoghurt": "Iogurte", "yogurt": "Iogurte", "yum_yum": "Yum Yum Yum", diff --git a/backend/templates/l10n/ru.json b/backend/templates/l10n/ru.json index b3f2bb5f..5d422c69 100644 --- a/backend/templates/l10n/ru.json +++ b/backend/templates/l10n/ru.json @@ -6,10 +6,10 @@ "drinks": "Напитки", "freezer": "❄️ Заморозка", "fruits_vegetables": "Фрукты и овощи", - "grain": "Крупы", + "grain": "🥟 Крупы", "hygiene": "Гигиена", - "refrigerated": "Охлажденное", - "snacks": "Закуски" + "refrigerated": "💧Охлажденное", + "snacks": "Снэки" }, "items": { "aioli": "Айоли", @@ -17,12 +17,15 @@ "apple": "Яблоко", "apple_pulp": "Яблочное пюре", "applesauce": "Яблочный сок", + "apricots": "Абрикосы", "apérol": "Апероль", "arugula": "Руккола", "asian_egg_noodles": "Азиатская яичная лапша", + "asian_noodles": "Азиатская лапша", "asparagus": "Спаржа", "aspirin": "Аспирин", "avocado": "Авокадо", + "baby_potatoes": "Тройняшки", "baby_spinach": "Молодой шпинат", "bacon": "Бекон", "baguette": "Багет", @@ -80,12 +83,15 @@ "cheese": "Сыр", "cherry_tomatoes": "Помидоры Черри", "chickpeas": "Нут", + "chicory": "Цикорий", "chili_oil": "Масло чили", + "chili_pepper": "Перец чили", "chips": "Чипсы", "chives": "Зеленый лук", "chocolate": "Шоколад", "chocolate_chips": "Шоколадные чипсы", "chopped_tomatoes": "Томаты резаные", + "chunky_tomatoes": "Крупноплодные томаты", "ciabatta": "Чиабатта", "cider_vinegar": "Яблочный уксус", "cilantro": "Кинза", @@ -104,6 +110,7 @@ "cornflakes": "Кукурузные хлопья", "cornstarch": "Кукурузный крахмал", "cornys": "Cornys", + "corriander": "Кориандр", "cough_drops": "Капли от кашля", "couscous": "Кускус", "covid_rapid_test": "Экспресс-тест COVID", @@ -116,21 +123,29 @@ "crispbread": "Хлебцы", "cucumber": "Огурцы", "cumin": "Тмин", + "curd": "Творог", "curry_paste": "Паста карри", "curry_powder": "Карри", "curry_sauce": "Соус карри", "dates": "Даты", "dental_floss": "Зубная нить", + "deo": "Дезодорант", "deodorant": "Дезодорант", - "detergent": "Стиральный порошок", + "detergent": "Моющее средство", + "detergent_sheets": "Листы с моющими средствами", + "diarrhea_remedy": "Средство от диареи", "dill": "Укроп", "dishwasher_salt": "Соль для посудомойки", "dishwasher_tabs": "Таблетки для посудомойки", "disinfection_spray": "Дезинфицирующий спрей", "dried_tomatoes": "Сушеные помидоры", "edamame": "Эдамаме", + "egg_salad": "Яичный салат", + "egg_yolk": "Яичный желток", "eggplant": "Баклажаны", "eggs": "Яйца", + "enoki_mushrooms": "Грибы эноки", + "eyebrow_gel": "Гель для бровей", "falafel": "Фалафель", "falafel_powder": "Порошок для фалафеля", "fanta": "Фанта", @@ -144,6 +159,7 @@ "frozen_fruit": "Замороженные фрукты", "frozen_pizza": "Замороженная пицца", "frozen_spinach": "Замороженный шпинат", + "funeral_card": "Похоронная карточка", "garam_masala": "Гарам Масала", "garbage_bag": "Мешки для мусора", "garlic": "Чеснок", @@ -157,15 +173,20 @@ "gochujang": "Гочуджан", "gorgonzola": "Горгонзола", "gouda": "Гауда", + "granola": "Гранола", + "granola_bar": "Батончик с гранолой", "grapes": "Виноград", "greek_yogurt": "Греческий йогурт", "green_asparagus": "Зеленая спаржа", "green_chili": "Зеленый перец чили", "green_pesto": "Зеленый песто", "hair_gel": "Гель для волос", + "hair_ties": "Завязки для волос", "hair_wax": "Воск для волос", + "hand_soap": "Ручное мыло", "handkerchief_box": "Коробка для носовых платков", "handkerchiefs": "Носовые платки", + "hard_cheese": "Твердый сыр", "haribo": "Haribo", "harissa": "Харисса", "hazelnuts": "Фундук", @@ -176,11 +197,12 @@ "honey_wafers": "Медовые вафли", "hot_dog_bun": "Булочки для хот-догов", "ice_cream": "Мороженое", - "ice_cube": "Лед в кубиках", + "ice_cube": "Кубики льда", "iceberg_lettuce": "Салат Айсберг", "iced_tea": "Холодный чай", "instant_soups": "Супы быстрого приготовления", "jam": "Джем", + "jasmine_rice": "Жасминовый рис", "katjes": "Katjes", "ketchup": "Кетчуп", "kidney_beans": "Почечная фасоль", @@ -193,21 +215,28 @@ "leaf_spinach": "Листья шпината", "leek": "Лук-порей", "lemon": "Лимоны", + "lemon_curd": "Лимонный творог", "lemon_juice": "Лимонный сок", "lemonade": "Лимонад", "lemongrass": "Лемонграсс", + "lentil_stew": "Тушеная чечевица", "lentils": "Чечевица", "lentils_red": "Красная чечевица", "lettuce": "Зелень салата", "lillet": "Lillet", "lime": "Лайм", "linguine": "Макароны лингуини", + "lip_care": "Уход за губами", "low-fat_curd_cheese": "Обезжиренный творог", + "maggi": "Maggi", "magnesium": "Магний", "mango": "Манго", + "maple_syrup": "Кленовый сироп", "margarine": "Маргарин", "marjoram": "Майоран", "marshmallows": "Маршмэллоу", + "mascara": "Тушь для ресниц", + "mascarpone": "Маскарпоне", "mask": "Маска", "mayonnaise": "Майонез", "meat_substitute_product": "Продукт, заменяющий мясо", @@ -215,8 +244,10 @@ "milk": "Молоко", "mint": "Мята", "mint_candy": "Мятные леденцы", + "miso_paste": "Паста мисо", "mixed_vegetables": "Овощная смесь", "mochis": "Мочис", + "mold_remover": "Средство для удаления плесени", "mountain_cheese": "Горный сыр", "mouth_wash": "Полоскание рта", "mozzarella": "Моцарелла", @@ -225,6 +256,7 @@ "mulled_wine": "Глинтвейн", "mushrooms": "Грибы", "mustard": "Горчица", + "nail_file": "Пилка для ногтей", "neutral_oil": "Рафинированное масло", "nori_sheets": "Водоросли нори", "nutmeg": "Мускатный орех", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Овсяное печенье", "oatsome": "Овес", "obatzda": "Обацда", + "oil": "Нефть", "olive_oil": "Оливковое масло", "olives": "Оливки", "onion": "Лук", + "onion_powder": "Луковый порошок", "orange_juice": "Апельсиновый сок", "oranges": "Апельсины", "oregano": "Орегано", "organic_lemon": "Органический лимон", "organic_waste_bags": "Мешки для органических отходов", "pak_choi": "Пак Чой", + "pantyhose": "Колготки", "paprika": "Паприка", + "paprika_seasoning": "Приправа паприка", "pardina_lentils_dried": "Чечевица пардина сушеная", "parmesan": "Пармезан", "parsley": "Петрушка", @@ -257,18 +293,20 @@ "penne": "Макароны перья", "pepper": "Перец", "pepper_mill": "Мельница для перца", - "peppers": "Перец", + "peppers": "Перцы", "persian_rice": "Персидский рис", "pesto": "Песто", "pilsner": "Пиво пилснер", "pine_nuts": "Кедровые орешки", "pineapple": "Ананасы", "pita_bag": "Мешок лаваша", + "pita_bread": "Лаваш", "pizza": "Пицца", "pizza_dough": "Тесто для пиццы", "plant_magarine": "Растение Магарин", "plant_oil": "Растительное масло", "plaster": "Пластырь", + "pointed_peppers": "Остроконечные перцы", "porcini_mushrooms": "Белые грибы", "potato_dumpling_dough": "Тесто для картофельных клецок", "potato_wedges": "Картофельные дольки", @@ -289,8 +327,10 @@ "rapeseed_oil": "Рапсовое масло", "raspberries": "Малина", "raspberry_syrup": "Малиновый сироп", + "razor_blades": "Бритвенные лезвия", "red_bull": "Ред Булл", "red_chili": "Красный перец чили", + "red_curry_paste": "Красная паста карри", "red_lentils": "Красная чечевица", "red_onions": "Красный лук", "red_pesto": "Красный песто", @@ -300,6 +340,7 @@ "ribbon_noodles": "Ленточная лапша", "rice": "Рис", "rice_cakes": "Рисовые лепешки", + "rice_paper": "Рисовая бумага", "rice_ribbon_noodles": "Рисовая ленточная лапша", "rice_vinegar": "Рисовый уксус", "ricotta": "Рикотта", @@ -339,12 +380,15 @@ "sliced_cheese": "Сыр в нарезке", "smoked_paprika": "Копченая паприка", "smoked_tofu": "Копченый тофу", - "snacks": "Закуски", + "snacks": "Снэки", "soap": "Мыло", + "soba_noodles": "Лапша соба", "soft_drinks": "Лимонады", "softdrinks": "Прохладительные напитки", + "soup_vegetables": "Суп овощной", "sour_cream": "Сметана", "sour_cucumbers": "Маринованные огурцы", + "soy_cream": "Соевый крем", "soy_hack": "Взлом сои", "soy_sauce": "Соевый соус", "soy_shred": "Измельчение сои", @@ -354,6 +398,7 @@ "spelt": "Полба", "spinach": "Шпинат", "sponge_cloth": "Губчатая ткань", + "sponge_fingers": "Губчатые пальцы", "sponge_wipes": "Губчатые салфетки", "sponges": "Губка", "spreading_cream": "Распределяющий крем", @@ -362,18 +407,24 @@ "sprouts": "Проростки", "sriracha": "Шрирача", "strained_tomatoes": "Процеженные помидоры", + "strawberries": "Клубника", "sugar": "Сахар", "summer_roll_paper": "Летняя рулонная бумага", + "sunflower_oil": "Подсолнечное масло", "sunflower_seeds": "Семечки", + "sunscreen": "Солнцезащитный крем", "sushi_rice": "Рис для суши", "swabian_ravioli": "Швабские равиоли", + "sweet_chili_sauce": "Сладкий соус чили", "sweet_potato": "Батат", "sweet_potatoes": "Батат", + "sweets": "Сладости", "table_salt": "Поваренная соль", "tagliatelle": "Тальятелле", "tahini": "Тахина", "tangerines": "Мандарины", "tape": "Лента", + "tapioca_flour": "Мука из тапиоки", "tea": "Чай", "teriyaki_sauce": "Соус терияки", "thyme": "Тимьян", @@ -401,20 +452,27 @@ "vegetables": "Овощи", "vegetarian_cold_cuts": "вегетарианские холодные закуски", "vinegar": "Уксус", + "vitamin_tablets": "Витаминные таблетки", "vodka": "Водка", + "washing_gel": "Моющий гель", "washing_powder": "Стиральный порошок", "water": "Вода", "water_ice": "Водяной лед", "watermelon": "Арбуз", "wc_cleaner": "Очиститель унитаза", + "wheat_flour": "Пшеничная мука", "whipped_cream": "Взбитые сливки", "white_wine": "Белое вино", "white_wine_vinegar": "Белый винный уксус", "whole_canned_tomatoes": "Консервированные помидоры", "wild_berries": "Дикие ягоды", + "wild_rice": "Дикий рис", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "Вустерский соус", "wrapping_paper": "Оберточная бумага", "wraps": "Обертывания", "yeast": "Дрожжи", + "yeast_flakes": "Дрожжевые хлопья", "yoghurt": "Йогурт", "yogurt": "Йогурт", "yum_yum": "Ням-ням", diff --git a/backend/templates/l10n/tr.json b/backend/templates/l10n/tr.json index 7372c39c..f2a9a3c2 100644 --- a/backend/templates/l10n/tr.json +++ b/backend/templates/l10n/tr.json @@ -17,12 +17,15 @@ "apple": "Elma", "apple_pulp": "Posalı Elma", "applesauce": "Elma püresi", + "apricots": "Kayısı", "apérol": "Aperol", "arugula": "Roka", "asian_egg_noodles": "Yumurtalı Noodle", + "asian_noodles": "Asya eriştesi", "asparagus": "Kuşkonmaz", "aspirin": "Aspirin", "avocado": "Avokado", + "baby_potatoes": "Üçüzler", "baby_spinach": "Bebek Ispanak", "bacon": "Pastırma", "baguette": "Baget Ekmek", @@ -80,12 +83,15 @@ "cheese": "Peynir", "cherry_tomatoes": "Kiraz Domates", "chickpeas": "Nohut", + "chicory": "Hindiba", "chili_oil": "Biberli Yağ", + "chili_pepper": "Acı biber", "chips": "Cips", "chives": "Frenksoğanı", "chocolate": "Çikolata", "chocolate_chips": "Damla Çikolata", "chopped_tomatoes": "Doğranmış Domates", + "chunky_tomatoes": "Tıknaz domatesler", "ciabatta": "Ciabatta Ekmeği", "cider_vinegar": "Elma Sirkesi", "cilantro": "Kişniş", @@ -104,6 +110,7 @@ "cornflakes": "Mısır gevreği", "cornstarch": "Mısır Nişastası", "cornys": "Ballı Tahıl", + "corriander": "Corriander", "cough_drops": "Boğaz Pastili", "couscous": "Kuskus", "covid_rapid_test": "COVID hızlı testi", @@ -116,21 +123,29 @@ "crispbread": "Kraker", "cucumber": "Hıyar", "cumin": "Kimyon", + "curd": "Lor", "curry_paste": "Köri Ezmesi", "curry_powder": "Köri Tozu", "curry_sauce": "Köri Sosu", "dates": "Hurma", "dental_floss": "Diş İpi", + "deo": "Deodorant", "deodorant": "Deodorant", "detergent": "Deterjan", + "detergent_sheets": "Deterjan tabakaları", + "diarrhea_remedy": "İshal ilacı", "dill": "Dereotu", "dishwasher_salt": "Bulaşık Makinesi Tuzu", "dishwasher_tabs": "Bulaşık Tableti", "disinfection_spray": "Dezenfektan Sprey", "dried_tomatoes": "Kurutulmuş Domates", "edamame": "Edamame", + "egg_salad": "Yumurta salatası", + "egg_yolk": "Yumurta sarısı", "eggplant": "Patlıcan", "eggs": "Yumurta", + "enoki_mushrooms": "Enoki mantarları", + "eyebrow_gel": "Kaş jeli", "falafel": "Falafel", "falafel_powder": "Falafel Unu", "fanta": "Sarı Gazoz", @@ -144,8 +159,9 @@ "frozen_fruit": "Dondurulmuş Meyve", "frozen_pizza": "Dondurulmuş Pizza", "frozen_spinach": "Dondurulmuş Ispanak", + "funeral_card": "Cenaze kartı", "garam_masala": "Garam Masala", - "garbage_bag": "Çöp Torbası", + "garbage_bag": "Çöp torbaları", "garlic": "Sarımsak", "garlic_dip": "Sarımsaklı Dip Sos", "garlic_granules": "Sarımsak Granül", @@ -157,15 +173,20 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola Peyniri", "gouda": "Gouda Peyniri", + "granola": "Granola", + "granola_bar": "Granola bar", "grapes": "Üzüm", "greek_yogurt": "Yunan Yoğurdu", "green_asparagus": "Yeşil Kuşkonmaz", "green_chili": "Kıl Biber", "green_pesto": "Yeşil Pesto", "hair_gel": "Saç Jölesi", + "hair_ties": "Saç bağları", "hair_wax": "Saç Sabitleyici", + "hand_soap": "El sabunu", "handkerchief_box": "Mendil Kutusu", "handkerchiefs": "Mendil", + "hard_cheese": "Sert peynir", "haribo": "Jelibon", "harissa": "Harissa Sosu", "hazelnuts": "Fındık", @@ -176,11 +197,12 @@ "honey_wafers": "Ballı Gofret", "hot_dog_bun": "Sandviç Ekmeği", "ice_cream": "Dondurma", - "ice_cube": "Buz Küpü", + "ice_cube": "Buz küpleri", "iceberg_lettuce": "Atom Marul", "iced_tea": "Buzlu Çay", "instant_soups": "Çabuk Çorba", "jam": "Reçel", + "jasmine_rice": "Yasemin pirinci", "katjes": "Katjes Jelibon", "ketchup": "Ketçap", "kidney_beans": "Barbunya", @@ -193,21 +215,28 @@ "leaf_spinach": "Yaprak Ispanak", "leek": "Pırasa", "lemon": "Limon", + "lemon_curd": "Limonlu Lor", "lemon_juice": "Limon Suyu", "lemonade": "Limonata", "lemongrass": "Limon Otu", + "lentil_stew": "Mercimek yahnisi", "lentils": "Mercimek", "lentils_red": "Kırmızı mercimek", "lettuce": "Marul", "lillet": "Lillet", "lime": "Misket Limonu", "linguine": "Uzun Erişte", + "lip_care": "Dudak Bakımı", "low-fat_curd_cheese": "Böreklik Lor", + "maggi": "Maggi", "magnesium": "Magnezyum", "mango": "Mango", + "maple_syrup": "Akçaağaç şurubu", "margarine": "Margarin", "marjoram": "Mercanköşk", "marshmallows": "Marşmelov", + "mascara": "Maskara", + "mascarpone": "Mascarpone", "mask": "Maske", "mayonnaise": "Mayonez", "meat_substitute_product": "Et İkamesi", @@ -215,8 +244,10 @@ "milk": "Süt", "mint": "Nane", "mint_candy": "Mint Şeker", + "miso_paste": "Miso ezmesi", "mixed_vegetables": "Karışık Sebze", "mochis": "Mochis", + "mold_remover": "Küf Sökücü", "mountain_cheese": "Dağ Peyniri", "mouth_wash": "Ağız Çalkalama Suyu", "mozzarella": "Mozzarella", @@ -225,6 +256,7 @@ "mulled_wine": "Sıcak şarap", "mushrooms": "Mantar", "mustard": "Hardal", + "nail_file": "Tırnak törpüsü", "neutral_oil": "Kızartma Yağı", "nori_sheets": "Nori Yaprağı", "nutmeg": "Muskat", @@ -233,16 +265,20 @@ "oatmeal_cookies": "Yulaf Kurabiyesi", "oatsome": "Yulafsütü", "obatzda": "Obatzda", + "oil": "Yağ", "olive_oil": "Zeytinyağı", "olives": "Zeytin", "onion": "Soğan", + "onion_powder": "Soğan tozu", "orange_juice": "Portakal suyu", "oranges": "Portakal", "oregano": "Güveyotu", "organic_lemon": "Organik limon", "organic_waste_bags": "Organik Çöp Torbası", "pak_choi": "Pak Çoi", + "pantyhose": "Külotlu Çorap", "paprika": "Kırmızı biber", + "paprika_seasoning": "Kırmızı biber baharatı", "pardina_lentils_dried": "İspanyol Mercimeği", "parmesan": "Parmesan", "parsley": "Maydanoz", @@ -264,11 +300,13 @@ "pine_nuts": "Çam Fıstığı", "pineapple": "Ananas", "pita_bag": "Pita Poşedi", + "pita_bread": "Pide ekmeği", "pizza": "Pizza", "pizza_dough": "Pizza hamuru", "plant_magarine": "Bitkisel Margarin", "plant_oil": "Bitkisel Yağ", "plaster": "Alçı", + "pointed_peppers": "Sivri biberler", "porcini_mushrooms": "Porcini Mantarı", "potato_dumpling_dough": "Pişi Hamuru", "potato_wedges": "Patates Dilimi", @@ -289,8 +327,10 @@ "rapeseed_oil": "Kolza Yağı", "raspberries": "Ahududu", "raspberry_syrup": "Ahududu Şurubu", + "razor_blades": "Tıraş bıçakları", "red_bull": "Red Bull", "red_chili": "Kırmızı acı biber", + "red_curry_paste": "Kırmızı köri ezmesi", "red_lentils": "Kırmızı mercimek", "red_onions": "Mor Soğan", "red_pesto": "Kırmızı Pesto", @@ -300,6 +340,7 @@ "ribbon_noodles": "Fiyonk Noodle", "rice": "Pirinç", "rice_cakes": "Pirinç Keki", + "rice_paper": "Pirinç kağıdı", "rice_ribbon_noodles": "Pirinç Fiyonk Noodle", "rice_vinegar": "Pirinç Sirkesi", "ricotta": "Ricotta Peyniri", @@ -341,10 +382,13 @@ "smoked_tofu": "Füme tofu", "snacks": "Atıştırmalık", "soap": "Sabun", + "soba_noodles": "Soba eriştesi", "soft_drinks": "Meşrubat", "softdrinks": "Meşrubat", + "soup_vegetables": "Çorba sebzeleri", "sour_cream": "Ekşi Krema", "sour_cucumbers": "Kornişon Turşu", + "soy_cream": "Soya kreması", "soy_hack": "Soya", "soy_sauce": "Soya Sosu", "soy_shred": "Soya Dilimi", @@ -354,6 +398,7 @@ "spelt": "Kavuzlu Buğday", "spinach": "Ispanak", "sponge_cloth": "Sünger bez", + "sponge_fingers": "Sünger parmaklar", "sponge_wipes": "Sarı Bez", "sponges": "Sünger", "spreading_cream": "Sürülebilir Peynir", @@ -362,18 +407,24 @@ "sprouts": "Filiz", "sriracha": "Şiraka Acı Sos", "strained_tomatoes": "Süzme Domates", + "strawberries": "Çilek", "sugar": "Şeker", "summer_roll_paper": "Summer Lavaş", + "sunflower_oil": "Ayçiçek yağı", "sunflower_seeds": "Ay Çekirdeği", + "sunscreen": "Güneş Kremi", "sushi_rice": "Suşi pirinci", "swabian_ravioli": "Svabya mantısı", + "sweet_chili_sauce": "Tatlı Acı Sos", "sweet_potato": "Tatlı Patates", "sweet_potatoes": "Tatlı patates", + "sweets": "Tatlılar", "table_salt": "Softa Tuzu", "tagliatelle": "Tagliatelle", "tahini": "Tahin", "tangerines": "Mandalina", "tape": "Bant", + "tapioca_flour": "Tapyoka unu", "tea": "Çay", "teriyaki_sauce": "Teriyaki sosu", "thyme": "Kekik", @@ -401,20 +452,27 @@ "vegetables": "Sebze", "vegetarian_cold_cuts": "Vejetaryen Yemek", "vinegar": "Sirke", + "vitamin_tablets": "Vitamin tabletleri", "vodka": "Votka", + "washing_gel": "Yıkama jeli", "washing_powder": "Toz Deterjan", "water": "Su", "water_ice": "Dondurulmuş Tatlı", "watermelon": "Karpuz", "wc_cleaner": "Tuvalet Temizleyici", + "wheat_flour": "Buğday unu", "whipped_cream": "Krem Şanti", "white_wine": "Beyaz şarap", "white_wine_vinegar": "Beyaz Şarap Sirkesi", "whole_canned_tomatoes": "Konserve Bütün Domates", "wild_berries": "Yabani Yemiş", + "wild_rice": "Yabani pirinç", + "wildberry_lillet": "Yabanmersini Lillet", + "worcester_sauce": "Worcester Sos", "wrapping_paper": "Ambalaj kağıdı", "wraps": "Dürüm", "yeast": "Maya", + "yeast_flakes": "Maya gevreği", "yoghurt": "Yoğurt", "yogurt": "Yoğurt", "yum_yum": "Yum Yum", diff --git a/backend/templates/l10n/zh_Hans.json b/backend/templates/l10n/zh_Hans.json index 57a8236e..f0997eb3 100644 --- a/backend/templates/l10n/zh_Hans.json +++ b/backend/templates/l10n/zh_Hans.json @@ -17,12 +17,15 @@ "apple": "苹果", "apple_pulp": "苹果酱", "applesauce": "苹果酱", + "apricots": "杏子", "apérol": "Apérol 利口酒", "arugula": "芝麻菜", "asian_egg_noodles": "亚洲鸡蛋面", + "asian_noodles": "亚洲面条", "asparagus": "芦笋", "aspirin": "阿司匹林", "avocado": "牛油果", + "baby_potatoes": "三胞胎", "baby_spinach": "嫩叶菠菜", "bacon": "培根", "baguette": "法棍面包", @@ -80,12 +83,15 @@ "cheese": "奶酪", "cherry_tomatoes": "樱桃西红柿", "chickpeas": "鹰嘴豆", + "chicory": "菊苣", "chili_oil": "辣椒油", + "chili_pepper": "辣椒", "chips": "薯片", "chives": "韭菜", "chocolate": "巧克力", "chocolate_chips": "巧克力片", "chopped_tomatoes": "切碎的西红柿", + "chunky_tomatoes": "大块番茄", "ciabatta": "玉米饼(Ciabatta", "cider_vinegar": "苹果醋", "cilantro": "芫荽", @@ -104,6 +110,7 @@ "cornflakes": "玉米片", "cornstarch": "玉米淀粉", "cornys": "科尼人", + "corriander": "芫荽", "cough_drops": "咳嗽滴剂", "couscous": "库斯库斯", "covid_rapid_test": "COVID快速检测", @@ -116,21 +123,29 @@ "crispbread": "脆皮面包", "cucumber": "黄瓜", "cumin": "孜然", + "curd": "凝乳", "curry_paste": "咖喱酱", "curry_powder": "咖喱粉", "curry_sauce": "咖喱酱", "dates": "日期", "dental_floss": "牙线", + "deo": "除臭剂", "deodorant": "除臭剂", "detergent": "洗涤剂", + "detergent_sheets": "洗涤剂床单", + "diarrhea_remedy": "泻药", "dill": "莳萝", "dishwasher_salt": "洗碗机用盐", "dishwasher_tabs": "洗碗机标签", "disinfection_spray": "消毒喷雾", "dried_tomatoes": "西红柿干", "edamame": "毛豆", + "egg_salad": "鸡蛋沙拉", + "egg_yolk": "蛋黄", "eggplant": "茄子", "eggs": "鸡蛋", + "enoki_mushrooms": "金菇", + "eyebrow_gel": "眉毛凝胶", "falafel": "法拉斐尔", "falafel_powder": "法拉斐尔粉", "fanta": "芬达", @@ -144,6 +159,7 @@ "frozen_fruit": "冷冻水果", "frozen_pizza": "冷冻比萨饼", "frozen_spinach": "冷冻菠菜", + "funeral_card": "葬礼卡", "garam_masala": "嘎拉玛沙拉", "garbage_bag": "垃圾袋", "garlic": "大蒜", @@ -157,15 +173,20 @@ "gochujang": "五味子", "gorgonzola": "戈尔贡佐拉奶酪", "gouda": "高达", + "granola": "格兰诺拉麦片", + "granola_bar": "燕麦棒", "grapes": "葡萄", "greek_yogurt": "希腊酸奶", "green_asparagus": "绿芦笋", "green_chili": "绿辣椒", "green_pesto": "绿酱", "hair_gel": "发胶", + "hair_ties": "发带", "hair_wax": "发蜡", + "hand_soap": "洗手液", "handkerchief_box": "手帕盒", "handkerchiefs": "手帕", + "hard_cheese": "硬质奶酪", "haribo": "哈里博", "harissa": "哈里萨", "hazelnuts": "榛子", @@ -181,6 +202,7 @@ "iced_tea": "冰茶", "instant_soups": "速溶汤", "jam": "果酱", + "jasmine_rice": "茉莉香米", "katjes": "卡捷斯", "ketchup": "番茄酱", "kidney_beans": "肾豆", @@ -193,21 +215,28 @@ "leaf_spinach": "菠菜叶", "leek": "韭菜", "lemon": "柠檬", + "lemon_curd": "柠檬凝乳", "lemon_juice": "柠檬汁", "lemonade": "柠檬水", "lemongrass": "柠檬草", + "lentil_stew": "炖扁豆", "lentils": "扁豆", "lentils_red": "红扁豆", "lettuce": "莴苣", "lillet": "利莱", "lime": "石灰", "linguine": "意大利面条", + "lip_care": "唇部护理", "low-fat_curd_cheese": "低脂凝乳干酪", + "maggi": "玛吉", "magnesium": "镁", "mango": "芒果", + "maple_syrup": "枫糖浆", "margarine": "人造黄油", "marjoram": "马兰花", "marshmallows": "棉花糖", + "mascara": "睫毛膏", + "mascarpone": "马斯卡彭奶酪", "mask": "面罩", "mayonnaise": "蛋黄酱", "meat_substitute_product": "肉类替代产品", @@ -215,8 +244,10 @@ "milk": "牛奶", "mint": "薄荷糖", "mint_candy": "薄荷糖", + "miso_paste": "味噌糊", "mixed_vegetables": "混合蔬菜", "mochis": "莫奇斯", + "mold_remover": "除霉剂", "mountain_cheese": "山地奶酪", "mouth_wash": "漱口水", "mozzarella": "莫扎里拉奶酪", @@ -225,6 +256,7 @@ "mulled_wine": "闷酒", "mushrooms": "蘑菇", "mustard": "芥末酱", + "nail_file": "指甲锉", "neutral_oil": "中性油", "nori_sheets": "紫菜片", "nutmeg": "肉豆蔻", @@ -233,16 +265,20 @@ "oatmeal_cookies": "燕麦饼干", "oatsome": "燕麦", "obatzda": "Obatzda", + "oil": "石油", "olive_oil": "橄榄油", "olives": "橄榄", "onion": "洋葱", + "onion_powder": "洋葱粉", "orange_juice": "橙汁", "oranges": "橙子", "oregano": "牛至", "organic_lemon": "有机柠檬", "organic_waste_bags": "有机废物袋", "pak_choi": "白菜", + "pantyhose": "连裤袜", "paprika": "红辣椒", + "paprika_seasoning": "红辣椒调味料", "pardina_lentils_dried": "帕迪纳小扁豆干", "parmesan": "帕玛森", "parsley": "欧芹", @@ -264,11 +300,13 @@ "pine_nuts": "松子", "pineapple": "菠萝", "pita_bag": "皮塔袋", + "pita_bread": "皮塔饼", "pizza": "匹萨", "pizza_dough": "披萨面团", "plant_magarine": "植物人马加里", "plant_oil": "植物油", "plaster": "石膏", + "pointed_peppers": "尖椒", "porcini_mushrooms": "牛肝菌", "potato_dumpling_dough": "马铃薯饺子面团", "potato_wedges": "马铃薯楔子", @@ -289,8 +327,10 @@ "rapeseed_oil": "油菜籽油", "raspberries": "覆盆子", "raspberry_syrup": "覆盆子糖浆", + "razor_blades": "剃须刀片", "red_bull": "红牛", "red_chili": "红辣椒", + "red_curry_paste": "红咖喱酱", "red_lentils": "红扁豆", "red_onions": "红洋葱", "red_pesto": "红色香蒜酱", @@ -300,6 +340,7 @@ "ribbon_noodles": "带状面条", "rice": "大米", "rice_cakes": "米饼", + "rice_paper": "宣纸", "rice_ribbon_noodles": "米带面", "rice_vinegar": "米醋", "ricotta": "蓖麻籽油", @@ -341,10 +382,13 @@ "smoked_tofu": "熏制豆腐", "snacks": "小吃", "soap": "肥皂", + "soba_noodles": "荞麦面", "soft_drinks": "软饮料", "softdrinks": "软饮料", + "soup_vegetables": "蔬菜汤", "sour_cream": "酸奶油", "sour_cucumbers": "酸黄瓜", + "soy_cream": "大豆奶油", "soy_hack": "大豆黑客", "soy_sauce": "酱油", "soy_shred": "大豆丝", @@ -354,6 +398,7 @@ "spelt": "斯佩尔特", "spinach": "菠菜", "sponge_cloth": "海棉布", + "sponge_fingers": "海绵手指", "sponge_wipes": "海绵擦拭", "sponges": "海棉", "spreading_cream": "涂抹式奶油", @@ -362,18 +407,24 @@ "sprouts": "萌芽", "sriracha": "斯里拉查 (Sriracha)", "strained_tomatoes": "稀释的西红柿", + "strawberries": "草莓", "sugar": "糖", "summer_roll_paper": "夏季卷纸", + "sunflower_oil": "葵花籽油", "sunflower_seeds": "葵花籽", + "sunscreen": "防晒霜", "sushi_rice": "寿司米", "swabian_ravioli": "斯瓦比亚的馄饨", + "sweet_chili_sauce": "甜辣椒酱", "sweet_potato": "红薯", "sweet_potatoes": "红薯", + "sweets": "糖果", "table_salt": "食用盐", "tagliatelle": "塔利亚特面团", "tahini": "塔希尼", "tangerines": "橘子", "tape": "录像带", + "tapioca_flour": "木薯粉", "tea": "茶叶", "teriyaki_sauce": "照烧酱", "thyme": "百里香", @@ -401,20 +452,27 @@ "vegetables": "蔬菜", "vegetarian_cold_cuts": "素食冷盘", "vinegar": "醋", + "vitamin_tablets": "维生素片", "vodka": "伏特加", + "washing_gel": "洗涤凝胶", "washing_powder": "洗衣粉", "water": "水", "water_ice": "水冰", "watermelon": "西瓜", "wc_cleaner": "厕所清洁剂", + "wheat_flour": "小麦粉", "whipped_cream": "鲜奶油", "white_wine": "白葡萄酒", "white_wine_vinegar": "白葡萄酒醋", "whole_canned_tomatoes": "完整的罐装西红柿", "wild_berries": "野生浆果", + "wild_rice": "野生稻", + "wildberry_lillet": "Wildberry Lillet", + "worcester_sauce": "喼汁", "wrapping_paper": "包装纸", "wraps": "包裹", "yeast": "酵母菌", + "yeast_flakes": "酵母片", "yoghurt": "酸奶", "yogurt": "酸奶", "yum_yum": "百胜", From d790095284eeaef0e599f725fbce78d650c42bc4 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 3 Aug 2023 21:11:21 +0200 Subject: [PATCH 366/496] Prepare release 75 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 84aa4eef..cc6ae373 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,7 +14,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 74 +BACKEND_VERSION = 75 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From de7882abb4ab2e1a6500141e74eccec260e361de Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 17 Aug 2023 16:49:45 +0200 Subject: [PATCH 367/496] feat: Add socket.io --- backend/Dockerfile | 5 +- backend/app/__init__.py | 3 +- backend/app/config.py | 7 +++ .../shoppinglist/shoppinglist_controller.py | 26 +++++++-- backend/app/helpers/__init__.py | 2 + backend/app/helpers/socket_jwt_required.py | 25 +++++++++ backend/app/helpers/validate_socket_args.py | 23 ++++++++ backend/app/models/history.py | 2 +- backend/app/sockets/__init__.py | 2 + backend/app/sockets/connection_socket.py | 18 ++++++ backend/app/sockets/schemas.py | 13 +++++ backend/app/sockets/shoppinglist_socket.py | 56 +++++++++++++++++++ backend/requirements.txt | 10 ++++ backend/wsgi.ini | 3 +- backend/wsgi.py | 7 ++- 15 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 backend/app/helpers/socket_jwt_required.py create mode 100644 backend/app/helpers/validate_socket_args.py create mode 100644 backend/app/sockets/__init__.py create mode 100644 backend/app/sockets/connection_socket.py create mode 100644 backend/app/sockets/schemas.py create mode 100644 backend/app/sockets/shoppinglist_socket.py diff --git a/backend/Dockerfile b/backend/Dockerfile index e1f4f5b4..35a5f832 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,7 @@ RUN apt-get update \ && apt-get install --yes --no-install-recommends \ gcc g++ libffi-dev libpcre3-dev build-essential cargo \ libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build \ - autoconf automake zlib1g-dev libjpeg62-turbo-dev + autoconf automake zlib1g-dev libjpeg62-turbo-dev libssl-dev # Create virtual enviroment RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel @@ -45,9 +45,8 @@ HEALTHCHECK --interval=60s --timeout=3s CMD curl -f http://localhost/api/health/ ENV STORAGE_PATH='/data' ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' ENV DEBUG='False' -ENV HTTP_PORT=80 RUN chmod u+x ./entrypoint.sh -CMD ["wsgi.ini"] +CMD ["wsgi.ini -gevent 100"] ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 072894c8..ee343e34 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,6 +1,7 @@ -from app.config import app, jwt +from app.config import app, jwt, socketio from app.config import db from app.config import scheduler from app.controller import * +from app.sockets import * from app.jobs import * from app.api import * diff --git a/backend/app/config.py b/backend/app/config.py index cc6ae373..a837c9ec 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ from datetime import timedelta +from flask_socketio import SocketIO from sqlalchemy import MetaData from sqlalchemy.engine import URL from werkzeug.exceptions import MethodNotAllowed @@ -64,6 +65,7 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['MAX_CONTENT_LENGTH'] = 32 * 1000 * 1000 # 32MB max upload +app.config['SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') # SQLAlchemy app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False @@ -87,6 +89,7 @@ migrate = Migrate(app, db, render_as_batch=True) bcrypt = Bcrypt(app) jwt = JWTManager(app) +socketio = SocketIO(app, json=app.json, logger=True, cors_allowed_origins=os.getenv('FRONT_URL')) scheduler = APScheduler() # enable for debugging jobs: ../scheduler/jobs to see scheduled jobs @@ -138,3 +141,7 @@ def unhandled_exception(e: Exception): @app.errorhandler(404) def not_found(error): return "Requested resource not found", 404 + +@socketio.on_error_default +def default_socket_error_handler(e): + app.logger.error(e) \ No newline at end of file diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index bbe96c58..2ebd2bff 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -8,6 +8,7 @@ from app.errors import NotFoundRequest, InvalidUsage from datetime import datetime, timedelta, timezone import app.util.description_merger as description_merger +from app import socketio shoppinglist = Blueprint('shoppinglist', __name__) @@ -193,6 +194,11 @@ def addShoppinglistItemByName(args, id): History.create_added(shoppinglist, item, description) + socketio.emit("shoppinglist_item:add", { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict() + }, to=shoppinglist.household_id) + return jsonify(item.obj_to_dict()) @@ -205,8 +211,12 @@ def removeShoppinglistItem(args, id): raise NotFoundRequest() shoppinglist.checkAuthorized() - removeShoppinglistItem( + con = removeShoppinglistItem( shoppinglist, args['item_id'], args['removed_at'] if 'removed_at' in args else None) + if con: socketio.emit("shoppinglist_item:remove", { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict() + }, to=shoppinglist.household_id) return jsonify({'msg': "DONE"}) @@ -221,19 +231,23 @@ def removeShoppinglistItems(args, id): shoppinglist.checkAuthorized() for arg in args['items']: - removeShoppinglistItem( + con = removeShoppinglistItem( shoppinglist, arg['item_id'], arg['removed_at'] if 'removed_at' in arg else None) + if con: socketio.emit("shoppinglist_item:remove", { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict() + }, to=shoppinglist.household_id) return jsonify({'msg': "DONE"}) -def removeShoppinglistItem(shoppinglist: Shoppinglist, item_id: int, removed_at: int = None) -> bool: +def removeShoppinglistItem(shoppinglist: Shoppinglist, item_id: int, removed_at: int = None) -> ShoppinglistItems: item = Item.find_by_id(item_id) if not item: - return False + return None con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if not con: - return False + return None description = con.description con.delete() @@ -244,7 +258,7 @@ def removeShoppinglistItem(shoppinglist: Shoppinglist, item_id: int, removed_at: History.create_dropped( shoppinglist, item, description, removed_at_datetime) - return True + return con @shoppinglist.route('//recipeitems', methods=['POST']) diff --git a/backend/app/helpers/__init__.py b/backend/app/helpers/__init__.py index 354060fd..94a29af7 100644 --- a/backend/app/helpers/__init__.py +++ b/backend/app/helpers/__init__.py @@ -2,5 +2,7 @@ from .db_model_authorize_mixin import DbModelAuthorizeMixin from .timestamp_mixin import TimestampMixin from .validate_args import validate_args +from .validate_socket_args import validate_socket_args from .server_admin_required import server_admin_required from .authorize_household import authorize_household, RequiredRights +from .socket_jwt_required import socket_jwt_required diff --git a/backend/app/helpers/socket_jwt_required.py b/backend/app/helpers/socket_jwt_required.py new file mode 100644 index 00000000..971fd716 --- /dev/null +++ b/backend/app/helpers/socket_jwt_required.py @@ -0,0 +1,25 @@ +from functools import wraps +from flask import request + +from flask_jwt_extended import verify_jwt_in_request +from flask_socketio import disconnect + + +def socket_jwt_required( + optional: bool = False, + fresh: bool = False, + refresh: bool = False, +): + def wrapper(fn): + @wraps(fn) + def decorator(*args, **kwargs): + try: + verify_jwt_in_request(optional, fresh, refresh) + except: + disconnect() + return + return fn(*args, **kwargs) + + return decorator + + return wrapper diff --git a/backend/app/helpers/validate_socket_args.py b/backend/app/helpers/validate_socket_args.py new file mode 100644 index 00000000..ab597499 --- /dev/null +++ b/backend/app/helpers/validate_socket_args.py @@ -0,0 +1,23 @@ +from marshmallow.exceptions import ValidationError +from app.errors import InvalidUsage +from flask import request +from functools import wraps + + +def validate_socket_args(schema_cls): + def validate(func): + @wraps(func) + def func_wrapper(*args, **kwargs): + if not schema_cls: + raise Exception("Invalid usage. Schema class missing") + + try: + arguments = schema_cls().load(args[0]) + except ValidationError as exc: + raise InvalidUsage('{}'.format(exc)) + + return func(arguments, **kwargs) + + return func_wrapper + + return validate diff --git a/backend/app/models/history.py b/backend/app/models/history.py index 52e006fc..1733bf4d 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -49,7 +49,7 @@ def create_dropped(cls, shoppinglist, item, description='', created_at=None) -> item_id=item.id, status=Status.DROPPED, description=description, - created_at=created_at or datetime.utcnow + created_at=created_at ).save() def obj_to_item_dict(self) -> dict: diff --git a/backend/app/sockets/__init__.py b/backend/app/sockets/__init__.py new file mode 100644 index 00000000..45d18859 --- /dev/null +++ b/backend/app/sockets/__init__.py @@ -0,0 +1,2 @@ +from .shoppinglist_socket import * +from .connection_socket import * diff --git a/backend/app/sockets/connection_socket.py b/backend/app/sockets/connection_socket.py new file mode 100644 index 00000000..8eb903c3 --- /dev/null +++ b/backend/app/sockets/connection_socket.py @@ -0,0 +1,18 @@ +from flask_jwt_extended import current_user +from flask_socketio import join_room + +from app.helpers import socket_jwt_required +from app import socketio + + +@socketio.on('connect') +@socket_jwt_required() +def on_connect(): + for household in current_user.households: + join_room(household.household_id) + + +@socketio.on('reconnect') +@socket_jwt_required() +def on_reconnect(): + pass diff --git a/backend/app/sockets/schemas.py b/backend/app/sockets/schemas.py new file mode 100644 index 00000000..2b54fd81 --- /dev/null +++ b/backend/app/sockets/schemas.py @@ -0,0 +1,13 @@ +from marshmallow import Schema, fields + + +class shoppinglist_item_add(Schema): + shoppinglist_id = fields.Integer(required=True) + name = fields.String( + required=True + ) + description = fields.String() + +class shoppinglist_item_remove(Schema): + shoppinglist_id = fields.Integer(required=True) + item_id = fields.Integer(required=True) \ No newline at end of file diff --git a/backend/app/sockets/shoppinglist_socket.py b/backend/app/sockets/shoppinglist_socket.py new file mode 100644 index 00000000..c24af4ce --- /dev/null +++ b/backend/app/sockets/shoppinglist_socket.py @@ -0,0 +1,56 @@ +from flask_jwt_extended import current_user +from flask_socketio import emit +from app.controller.shoppinglist.shoppinglist_controller import removeShoppinglistItem +from app.errors import NotFoundRequest + +from app.helpers import socket_jwt_required, validate_socket_args +from app.models import Shoppinglist, Item, ShoppinglistItems, History +from app import socketio +from .schemas import shoppinglist_item_add, shoppinglist_item_remove + + +@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']) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + item = Item.find_by_name(shoppinglist.household_id, args['name']) + if not item: + 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 '' + con = ShoppinglistItems(description=description) + con.created_by = current_user.id + con.item = item + con.shoppinglist = shoppinglist + con.save() + + History.create_added(shoppinglist, item, description) + + emit("shoppinglist_item:add", { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict() + }, to=shoppinglist.household_id) + + +@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']) + if not shoppinglist: + raise NotFoundRequest() + shoppinglist.checkAuthorized() + + con = removeShoppinglistItem(shoppinglist, args['item_id']) + if con: + emit('shoppinglist_item:remove', { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict() + }, to=shoppinglist.household_id) diff --git a/backend/requirements.txt b/backend/requirements.txt index 884f7435..8dbd14cc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,7 @@ attrs==23.1.0 autopep8==2.0.2 bcrypt==4.0.1 beautifulsoup4==4.12.2 +bidict==0.22.1 black==23.1a1 blinker==1.6.2 certifi==2023.7.22 @@ -21,9 +22,12 @@ Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.2 Flask-Migrate==4.0.4 +Flask-SocketIO==5.3.5 Flask-SQLAlchemy==3.0.5 fonttools==4.42.0 +gevent==23.7.0 greenlet==2.0.2 +h11==0.14.0 html-text==0.5.2 html5lib==1.1 idna==3.4 @@ -67,6 +71,8 @@ pytest==7.4.0 python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 +python-engineio==4.5.1 +python-socketio==5.8.0 pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==7.0.0 @@ -77,6 +83,7 @@ requests==2.31.0 scikit-learn==1.3.0 scipy==1.11.1 setuptools-scm==7.1.0 +simple-websocket==0.10.1 six==1.16.0 soupsieve==2.4.1 SQLAlchemy==2.0.19 @@ -97,3 +104,6 @@ uWSGI==2.0.22 w3lib==2.1.2 webencodings==0.5.1 Werkzeug==2.3.6 +wsproto==1.2.0 +zope.event==5.0 +zope.interface==6.0 diff --git a/backend/wsgi.ini b/backend/wsgi.ini index 41871f26..e56d395b 100644 --- a/backend/wsgi.ini +++ b/backend/wsgi.ini @@ -2,6 +2,7 @@ strict = true master = true enable-threads = true +http-websockets = true lazy-apps=true vacuum = true single-interpreter = true @@ -11,7 +12,5 @@ chmod-socket = 664 wsgi-file = wsgi.py callable = app -http = 0.0.0.0:$(HTTP_PORT) -http-keepalive = true socket = 0.0.0.0:5000 procname-prefix-spaced = kitchenowl \ No newline at end of file diff --git a/backend/wsgi.py b/backend/wsgi.py index f78dae05..9a31af33 100644 --- a/backend/wsgi.py +++ b/backend/wsgi.py @@ -1,4 +1,7 @@ -from app import app +import gevent.monkey +gevent.monkey.patch_all() + +from app import app, socketio import os from app.config import UPLOAD_FOLDER @@ -6,4 +9,4 @@ if __name__ == "__main__": if not os.path.exists(UPLOAD_FOLDER): os.makedirs(UPLOAD_FOLDER) - app.run(debug=True) + socketio.run(app, debug=True) From 8667925183d7567cec4a74e2860450426b8c27d5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 17 Aug 2023 17:21:28 +0200 Subject: [PATCH 368/496] fix: Improve socket.io API --- .../household/household_controller.py | 4 ++- .../shoppinglist/shoppinglist_controller.py | 29 +++++++++++++------ backend/app/helpers/db_model_mixin.py | 4 ++- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index 0cb7e912..810a47c8 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -7,6 +7,7 @@ from app.service.import_language import importLanguage from app.service.file_has_access_or_download import file_has_access_or_download from .schemas import AddHousehold, UpdateHousehold, UpdateHouseholdMember +from flask_socketio import close_room household = Blueprint('household', __name__) @@ -99,7 +100,8 @@ def updateHousehold(args, household_id): @jwt_required() @authorize_household(required=RequiredRights.ADMIN) def deleteHouseholdById(household_id): - Household.delete_by_id(household_id) + if Household.delete_by_id(household_id): + close_room(household_id) return jsonify({'msg': 'DONE'}) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 2ebd2bff..213eba26 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -72,6 +72,10 @@ def updateItemDescription(args, id, item_id): con.description = args['description'] or '' con.save() + socketio.emit("shoppinglist_item:add", { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict() + }, to=shoppinglist.household_id) return jsonify(con.obj_to_item_dict()) @@ -213,10 +217,11 @@ def removeShoppinglistItem(args, id): con = removeShoppinglistItem( shoppinglist, args['item_id'], args['removed_at'] if 'removed_at' in args else None) - if con: socketio.emit("shoppinglist_item:remove", { - "item": con.obj_to_item_dict(), - "shoppinglist": shoppinglist.obj_to_dict() - }, to=shoppinglist.household_id) + if con: + socketio.emit("shoppinglist_item:remove", { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict() + }, to=shoppinglist.household_id) return jsonify({'msg': "DONE"}) @@ -233,10 +238,11 @@ def removeShoppinglistItems(args, id): for arg in args['items']: con = removeShoppinglistItem( shoppinglist, arg['item_id'], arg['removed_at'] if 'removed_at' in arg else None) - if con: socketio.emit("shoppinglist_item:remove", { - "item": con.obj_to_item_dict(), - "shoppinglist": shoppinglist.obj_to_dict() - }, to=shoppinglist.household_id) + if con: + socketio.emit("shoppinglist_item:remove", { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict() + }, to=shoppinglist.household_id) return jsonify({'msg': "DONE"}) @@ -289,7 +295,12 @@ def addRecipeItems(args, id): db.session.add(con) db.session.add( - History.create_added_without_save(shoppinglist, item)) + History.create_added_without_save(shoppinglist, item, description)) + + socketio.emit("shoppinglist_item:add", { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict() + }, to=shoppinglist.household_id) db.session.commit() except Exception as e: diff --git a/backend/app/helpers/db_model_mixin.py b/backend/app/helpers/db_model_mixin.py index b7d48993..de48a6a4 100644 --- a/backend/app/helpers/db_model_mixin.py +++ b/backend/app/helpers/db_model_mixin.py @@ -54,10 +54,12 @@ def find_by_id(cls, target_id: int) -> Self: return cls.query.filter(cls.id == target_id).first() @classmethod - def delete_by_id(cls, target_id: int): + def delete_by_id(cls, target_id: int) -> bool: mc = cls.find_by_id(target_id) if mc: mc.delete() + return True + return False @classmethod def all(cls) -> list[Self]: From 5ad88044ae1a6493e1322d591bf15a5fbed2bf04 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 17 Aug 2023 18:15:33 +0200 Subject: [PATCH 369/496] docs: update example docker-compose --- backend/docker-compose-postgres.yml | 17 ++++++++++------- backend/docker-compose.yml | 13 +++---------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/backend/docker-compose-postgres.yml b/backend/docker-compose-postgres.yml index a5752217..fd1d2734 100644 --- a/backend/docker-compose-postgres.yml +++ b/backend/docker-compose-postgres.yml @@ -8,13 +8,18 @@ services: POSTGRES_USER: kitchenowl POSTGRES_PASSWORD: example volumes: - - kitchenowl_data:/var/lib/postgresql/data + - kitchenowl_db:/var/lib/postgresql/data networks: - default + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 30s + timeout: 60s + retries: 5 + start_period: 80s front: image: tombursch/kitchenowl-web:latest - # environment: - # - BACK_URL=back:5000 # Optional should not be changed unless you know what youre doing + restart: unless-stopped ports: - "80:80" depends_on: @@ -24,8 +29,7 @@ services: back: image: tombursch/kitchenowl:latest restart: unless-stopped - # ports: # Optional - # - "5000:5000" # uwsgi protocol + #command: wsgi.ini --gevent 2000 #default: 100 networks: - default environment: @@ -35,11 +39,10 @@ services: DB_NAME: kitchenowl DB_USER: kitchenowl DB_PASSWORD: example - # FRONT_URL: http://localhost # Optional should not be changed unless you know what youre doing depends_on: - db volumes: - - kitchenowl_data:/data + - kitchenowl_files:/data volumes: kitchenowl_files: diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 6d456eb0..ac11ce26 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -2,22 +2,18 @@ version: "3" services: front: image: tombursch/kitchenowl-web:latest + restart: unless-stopped # environment: - # - BACK_URL=back:5000 # Optional should not be changed unless you know what youre doing + # - BACK_URL=back:5000 # Change this if you rename the containers ports: - "80:80" depends_on: - back - networks: - - default back: image: tombursch/kitchenowl:latest restart: unless-stopped - # ports: # Optional - # - "80:80" # http protocol + # ports: # Should only be needed if you're not using docker-compose # - "5000:5000" # uwsgi protocol - networks: - - default environment: - JWT_SECRET_KEY=PLEASE_CHANGE_ME # - FRONT_URL=http://localhost # Optional should not be changed unless you know what youre doing @@ -26,6 +22,3 @@ services: volumes: kitchenowl_data: - -networks: - default: From c5caac0c34ee1e251cb32f98e03d448151a1bf1f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 20 Aug 2023 21:57:41 +0200 Subject: [PATCH 370/496] chore: upgrade requirements --- backend/requirements.txt | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 8dbd14cc..f6bed33f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,6 @@ -alembic==1.11.1 +alembic==1.11.3 appdirs==1.4.4 -APScheduler==3.10.1 +APScheduler==3.10.4 attrs==23.1.0 autopep8==2.0.2 bcrypt==4.0.1 @@ -11,7 +11,7 @@ blinker==1.6.2 certifi==2023.7.22 cffi==1.15.1 charset-normalizer==3.2.0 -click==8.1.6 +click==8.1.7 contourpy==1.1.0 cycler==0.11.0 dbscan1d==0.2.2 @@ -24,19 +24,19 @@ Flask-JWT-Extended==4.5.2 Flask-Migrate==4.0.4 Flask-SocketIO==5.3.5 Flask-SQLAlchemy==3.0.5 -fonttools==4.42.0 +fonttools==4.42.1 gevent==23.7.0 greenlet==2.0.2 h11==0.14.0 html-text==0.5.2 html5lib==1.1 idna==3.4 -ingredient-parser-nlp==0.1.0b3 +ingredient-parser-nlp==0.1.0b4 iniconfig==2.0.0 isodate==0.6.1 itsdangerous==2.1.2 Jinja2==3.1.2 -joblib==1.3.1 +joblib==1.3.2 jstyleson==0.0.2 kiwisolver==1.4.4 lark==1.1.7 @@ -59,7 +59,7 @@ platformdirs==3.10.0 pluggy==1.2.0 prometheus-client==0.17.1 prometheus-flask-exporter==0.22.4 -psycopg2-binary==2.9.6 +psycopg2-binary==2.9.7 py==1.11.0 pycodestyle==2.11.0 pycparser==2.21 @@ -77,22 +77,22 @@ pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==7.0.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.41.0 -regex==2023.6.3 +recipe-scrapers==14.42.0 +regex==2023.8.8 requests==2.31.0 scikit-learn==1.3.0 -scipy==1.11.1 +scipy==1.11.2 setuptools-scm==7.1.0 simple-websocket==0.10.1 six==1.16.0 soupsieve==2.4.1 -SQLAlchemy==2.0.19 +SQLAlchemy==2.0.20 threadpoolctl==3.2.0 toml==0.10.2 tomli==2.0.1 -tqdm==4.65.0 +tqdm==4.66.1 typed-ast==1.5.5 -types-beautifulsoup4==4.12.0.5 +types-beautifulsoup4==4.12.0.6 types-html5lib==1.1.11.15 types-requests==2.31.0.2 types-urllib3==1.26.25.14 @@ -103,7 +103,7 @@ urllib3==2.0.4 uWSGI==2.0.22 w3lib==2.1.2 webencodings==0.5.1 -Werkzeug==2.3.6 +Werkzeug==2.3.7 wsproto==1.2.0 zope.event==5.0 zope.interface==6.0 From f545d9ec57ccc019947370d09b4e1f266fb220a2 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 23 Aug 2023 18:04:51 +0200 Subject: [PATCH 371/496] chore: upgrade requirements --- backend/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index f6bed33f..de55d3d2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -17,7 +17,7 @@ cycler==0.11.0 dbscan1d==0.2.2 extruct==0.16.0 flake8==6.1.0 -Flask==2.3.2 +Flask==2.3.3 Flask-APScheduler==1.12.4 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.2 @@ -71,7 +71,7 @@ pytest==7.4.0 python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 -python-engineio==4.5.1 +python-engineio==4.6.1 python-socketio==5.8.0 pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 From 4fc4c91b8c1267b426a0ce513306a3d0ce9fc7e2 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 23 Aug 2023 18:11:15 +0200 Subject: [PATCH 372/496] fix: dockerfile --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 35a5f832..80a2d5c2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -48,5 +48,5 @@ ENV DEBUG='False' RUN chmod u+x ./entrypoint.sh -CMD ["wsgi.ini -gevent 100"] +CMD ["wsgi.ini --gevent 100"] ENTRYPOINT ["./entrypoint.sh"] From 4c60c93bc48004e97cb53bb20a92a8d13bafe407 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 23 Aug 2023 18:55:49 +0200 Subject: [PATCH 373/496] fix: Healthcheck --- backend/Dockerfile | 4 ++-- backend/requirements.txt | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 80a2d5c2..88f48850 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -40,7 +40,7 @@ COPY migrations /usr/src/kitchenowl/migrations WORKDIR /usr/src/kitchenowl VOLUME ["/data"] -HEALTHCHECK --interval=60s --timeout=3s CMD curl -f http://localhost/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V || exit 1 +HEALTHCHECK --interval=60s --timeout=3s CMD uwsgi_curl localhost:5000 /api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgiV || exit 1 ENV STORAGE_PATH='/data' ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' @@ -48,5 +48,5 @@ ENV DEBUG='False' RUN chmod u+x ./entrypoint.sh -CMD ["wsgi.ini --gevent 100"] +CMD ["wsgi.ini" "--gevent 200"] ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/requirements.txt b/backend/requirements.txt index de55d3d2..dc7e405a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -26,6 +26,7 @@ Flask-SocketIO==5.3.5 Flask-SQLAlchemy==3.0.5 fonttools==4.42.1 gevent==23.7.0 +gevent-websocket==0.10.1 greenlet==2.0.2 h11==0.14.0 html-text==0.5.2 @@ -101,6 +102,7 @@ tzdata==2023.3 tzlocal==5.0.1 urllib3==2.0.4 uWSGI==2.0.22 +uwsgi-tools==1.1.1 w3lib==2.1.2 webencodings==0.5.1 Werkzeug==2.3.7 From 52589897d2e75d81906333ac205950d063c53ef6 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 23 Aug 2023 19:28:11 +0200 Subject: [PATCH 374/496] fix: dockerfile --- backend/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 88f48850..7811f800 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -40,7 +40,7 @@ COPY migrations /usr/src/kitchenowl/migrations WORKDIR /usr/src/kitchenowl VOLUME ["/data"] -HEALTHCHECK --interval=60s --timeout=3s CMD uwsgi_curl localhost:5000 /api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgiV || exit 1 +HEALTHCHECK --interval=60s --timeout=3s CMD uwsgi_curl localhost:5000 /api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V || exit 1 ENV STORAGE_PATH='/data' ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' @@ -48,5 +48,5 @@ ENV DEBUG='False' RUN chmod u+x ./entrypoint.sh -CMD ["wsgi.ini" "--gevent 200"] +CMD ["wsgi.ini" "--gevent" "200"] ENTRYPOINT ["./entrypoint.sh"] From 3a0580a5f1396a262e625b52b64b22a138429925 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 24 Aug 2023 10:11:28 +0200 Subject: [PATCH 375/496] fix: Dockerfile CMD --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 7811f800..14f287d0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -48,5 +48,5 @@ ENV DEBUG='False' RUN chmod u+x ./entrypoint.sh -CMD ["wsgi.ini" "--gevent" "200"] +CMD ["wsgi.ini", "--gevent", "200"] ENTRYPOINT ["./entrypoint.sh"] From e0295df35268c426133e6bcf8f52a6e789ddacfa Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 27 Aug 2023 21:41:08 +0200 Subject: [PATCH 376/496] chore: cleanup an improved debugging --- backend/app/config.py | 8 +++++--- backend/requirements.txt | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index a837c9ec..b00eb332 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -89,7 +89,8 @@ migrate = Migrate(app, db, render_as_batch=True) bcrypt = Bcrypt(app) jwt = JWTManager(app) -socketio = SocketIO(app, json=app.json, logger=True, cors_allowed_origins=os.getenv('FRONT_URL')) +socketio = SocketIO(app, json=app.json, logger=app.logger, + cors_allowed_origins=os.getenv('FRONT_URL')) scheduler = APScheduler() # enable for debugging jobs: ../scheduler/jobs to see scheduled jobs @@ -103,7 +104,7 @@ def add_cors_headers(response): if not request.referrer: return response r = request.referrer[:-1] - url = os.environ['FRONT_URL'] if 'FRONT_URL' in os.environ else None + url = os.getenv('FRONT_URL') if app.debug or url and r == url: response.headers.add('Access-Control-Allow-Origin', r) response.headers.add('Access-Control-Allow-Credentials', 'true') @@ -142,6 +143,7 @@ def unhandled_exception(e: Exception): def not_found(error): return "Requested resource not found", 404 + @socketio.on_error_default def default_socket_error_handler(e): - app.logger.error(e) \ No newline at end of file + app.logger.error(e) diff --git a/backend/requirements.txt b/backend/requirements.txt index dc7e405a..302f94f4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,8 @@ click==8.1.7 contourpy==1.1.0 cycler==0.11.0 dbscan1d==0.2.2 +dnspython==2.4.2 +eventlet==0.33.3 extruct==0.16.0 flake8==6.1.0 Flask==2.3.3 @@ -26,7 +28,6 @@ Flask-SocketIO==5.3.5 Flask-SQLAlchemy==3.0.5 fonttools==4.42.1 gevent==23.7.0 -gevent-websocket==0.10.1 greenlet==2.0.2 h11==0.14.0 html-text==0.5.2 @@ -84,7 +85,6 @@ requests==2.31.0 scikit-learn==1.3.0 scipy==1.11.2 setuptools-scm==7.1.0 -simple-websocket==0.10.1 six==1.16.0 soupsieve==2.4.1 SQLAlchemy==2.0.20 From f69276e7d758008b0a53bd3816bc297e50655005 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 28 Aug 2023 14:50:50 +0200 Subject: [PATCH 377/496] fix: revert add eventlet for debugging --- backend/requirements.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 302f94f4..206c7ae2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,8 +15,6 @@ click==8.1.7 contourpy==1.1.0 cycler==0.11.0 dbscan1d==0.2.2 -dnspython==2.4.2 -eventlet==0.33.3 extruct==0.16.0 flake8==6.1.0 Flask==2.3.3 From 5181794ce50c5f39c234b0c67103b69926ac8002 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 29 Aug 2023 20:04:28 +0200 Subject: [PATCH 378/496] feat: Add Prometheus metrics --- backend/app/config.py | 17 ++++++++++++++++- backend/requirements.txt | 1 + 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index b00eb332..93207b42 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -2,10 +2,14 @@ from flask_socketio import SocketIO from sqlalchemy import MetaData from sqlalchemy.engine import URL +from prometheus_client import multiprocess +from prometheus_client.core import CollectorRegistry +from prometheus_flask_exporter import PrometheusMetrics from werkzeug.exceptions import MethodNotAllowed from app.errors import NotFoundRequest, UnauthorizedRequest, ForbiddenRequest, InvalidUsage from app.util import KitchenOwlJSONProvider from flask import Flask, request +from flask_basicauth import BasicAuth from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from flask_bcrypt import Bcrypt @@ -27,6 +31,8 @@ OPEN_REGISTRATION = os.getenv('OPEN_REGISTRATION', "False").lower() == "true" EMAIL_MANDATORY = os.getenv('EMAIL_MANDATORY', "False").lower() == "true" +COLLECT_METRICS = os.getenv('COLLECT_METRICS', "False").lower() == "true" + DB_URL = URL.create( os.getenv('DB_DRIVER', "sqlite"), username=os.getenv('DB_USER'), @@ -73,7 +79,10 @@ app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') app.config["JWT_ACCESS_TOKEN_EXPIRES"] = JWT_ACCESS_TOKEN_EXPIRES app.config["JWT_REFRESH_TOKEN_EXPIRES"] = JWT_REFRESH_TOKEN_EXPIRES - +if COLLECT_METRICS: + # BASIC_AUTH + app.config['BASIC_AUTH_USERNAME'] = os.getenv('METRICS_USER', "kitchenowl") + app.config['BASIC_AUTH_PASSWORD'] = os.getenv('METRICS_PASSWORD', "ZqQtidgC5n3YXb") convention = { "ix": 'ix_%(column_0_label)s', @@ -91,6 +100,12 @@ jwt = JWTManager(app) socketio = SocketIO(app, json=app.json, logger=app.logger, cors_allowed_origins=os.getenv('FRONT_URL')) +if COLLECT_METRICS: + basic_auth = BasicAuth(app) + registry = CollectorRegistry() + multiprocess.MultiProcessCollector(registry, path='/tmp') + metrics = PrometheusMetrics(app, registry=registry, path="/metrics/", metrics_decorator=basic_auth.required) + metrics.info('app_info', 'Application info', version=BACKEND_VERSION) scheduler = APScheduler() # enable for debugging jobs: ../scheduler/jobs to see scheduled jobs diff --git a/backend/requirements.txt b/backend/requirements.txt index 206c7ae2..96cc04de 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,6 +19,7 @@ extruct==0.16.0 flake8==6.1.0 Flask==2.3.3 Flask-APScheduler==1.12.4 +Flask-BasicAuth==0.2.0 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.2 Flask-Migrate==4.0.4 From 1c0e84f6504551467bc64654ba7c83c626f934c8 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Tue, 29 Aug 2023 20:08:58 +0200 Subject: [PATCH 379/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#46) * Added translation using Weblate (Czech) * Translated using Weblate (Dutch) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nl/ * Translated using Weblate (Finnish) Currently translated at 99.1% (473 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fi/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt_BR/ --------- Co-authored-by: numbersixcze Co-authored-by: pizzapim Co-authored-by: teemue Co-authored-by: Robert de Abreu Viana --- backend/templates/l10n/cs.json | 1 + backend/templates/l10n/fi.json | 35 +++++++++++----------- backend/templates/l10n/nl.json | 50 +++++++++++++++---------------- backend/templates/l10n/pt_BR.json | 14 ++++----- 4 files changed, 50 insertions(+), 50 deletions(-) create mode 100644 backend/templates/l10n/cs.json diff --git a/backend/templates/l10n/cs.json b/backend/templates/l10n/cs.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/backend/templates/l10n/cs.json @@ -0,0 +1 @@ +{} diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json index 31a2e959..06e7f360 100644 --- a/backend/templates/l10n/fi.json +++ b/backend/templates/l10n/fi.json @@ -132,8 +132,7 @@ "deo": "Deodorantti", "deodorant": "Deodorantti", "detergent": "Pesuaine", - "detergent_sheets": "Pesuainelakanat", - "diarrhea_remedy": "Ripuli lääke", + "diarrhea_remedy": "Ripulilääke", "dill": "Tilli", "dishwasher_salt": "Astianpesukoneen suola", "dishwasher_tabs": "Astianpesutabletit", @@ -181,7 +180,7 @@ "green_chili": "Vihreä chili", "green_pesto": "Vihreä pesto", "hair_gel": "Hiusgeeli", - "hair_ties": "Hiussiteet", + "hair_ties": "Hiuslenkit", "hair_wax": "Hiusvaha", "hand_soap": "Käsisaippua", "handkerchief_box": "Nenäliinalaatikko", @@ -210,7 +209,7 @@ "kitchen_towels": "Keittiöpyyhkeet", "kohlrabi": "Kyssäkaali", "lasagna": "Lasagne", - "lasagna_noodles": "Lasagnan nuudelit", + "lasagna_noodles": "Lasagnepasta", "lasagna_plates": "Lasagnelevyt", "leaf_spinach": "Lehtipinaatti", "leek": "Purjo", @@ -226,7 +225,7 @@ "lillet": "Lillet", "lime": "Lime", "linguine": "Linguine", - "lip_care": "Huulien hoito", + "lip_care": "Huulirasva", "low-fat_curd_cheese": "Vähärasvainen juusto", "maggi": "Maggi", "magnesium": "Magnesium", @@ -246,8 +245,8 @@ "mint_candy": "Minttukarkki", "miso_paste": "Misotahna", "mixed_vegetables": "Vihannessekoitus", - "mochis": "Mochis", - "mold_remover": "Homeen poistoainetta", + "mochis": "Mochit", + "mold_remover": "Homeenpoistoaine", "mountain_cheese": "Vuoristojuusto", "mouth_wash": "Suuvesi", "mozzarella": "Mozzarella", @@ -257,7 +256,7 @@ "mushrooms": "Sienet", "mustard": "Sinappi", "nail_file": "Kynsiviila", - "neutral_oil": "Neutraali öljy", + "neutral_oil": "Öljy", "nori_sheets": "Noriarkit", "nutmeg": "Muskottipähkinä", "oat_milk": "Kaurajuoma", @@ -278,14 +277,14 @@ "pak_choi": "Pak Choi", "pantyhose": "Sukkahousut", "paprika": "Paprika", - "paprika_seasoning": "Paprika mauste", + "paprika_seasoning": "Paprikamauste", "pardina_lentils_dried": "Kuivatut Pardina-linssit", "parmesan": "Parmesan", "parsley": "Persilja", "pasta": "Pasta", "peach": "Persikka", "peanut_butter": "Maapähkinävoi", - "peanut_flips": "Maapähkinä Flips", + "peanut_flips": "Maapähkinänaksut", "peanut_oil": "Maapähkinäöljy", "peanuts": "Maapähkinät", "pears": "Päärynät", @@ -306,7 +305,7 @@ "plant_magarine": "Kasvi Magarine", "plant_oil": "Kasviöljy", "plaster": "Laastari", - "pointed_peppers": "Pistetyt paprikat", + "pointed_peppers": "Suippopaprikat", "porcini_mushrooms": "Porcini-sienet", "potato_dumpling_dough": "Perunakimpaleiden taikina", "potato_wedges": "Lohkoperunat", @@ -327,7 +326,7 @@ "rapeseed_oil": "Rypsiöljy", "raspberries": "Vadelmat", "raspberry_syrup": "Vadelmasiirappi", - "razor_blades": "Partakoneen terät", + "razor_blades": "Partaterät", "red_bull": "Red Bull", "red_chili": "Punainen chili", "red_curry_paste": "Punainen currytahna", @@ -352,7 +351,7 @@ "rosemary": "Rosmariini", "saffron_threads": "Sahramilangat", "sage": "Salvia", - "saitan_powder": "Saitan-jauhe", + "saitan_powder": "Seitan-jauhe", "salad_mix": "Salaattisekoitus", "salad_seeds_mix": "Salaattisiemensekoitus", "salt": "Suola", @@ -367,7 +366,7 @@ "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", "semolina_porridge": "Mannapuuro", - "sesame": "Seesami", + "sesame": "Seesam", "sesame_oil": "Seesamöljy", "shallot": "Salottisipuli", "shampoo": "Shampoo", @@ -387,7 +386,7 @@ "softdrinks": "Alkoholittomat juomat", "soup_vegetables": "Keittovihannekset", "sour_cream": "Hapankerma", - "sour_cucumbers": "Hapanimelät kurkut", + "sour_cucumbers": "Hapakurkut", "soy_cream": "Soijakerma", "soy_hack": "Soija hack", "soy_sauce": "Soijakastike", @@ -409,7 +408,7 @@ "strained_tomatoes": "Siivilöidyt tomaatit", "strawberries": "Mansikat", "sugar": "Sokeri", - "summer_roll_paper": "Kesän rullapaperi", + "summer_roll_paper": "Kesärullapaperi", "sunflower_oil": "Auringonkukkaöljy", "sunflower_seeds": "Auringonkukan siemenet", "sunscreen": "Aurinkovoide", @@ -450,7 +449,7 @@ "vegetable_oil": "Kasvisöljy", "vegetable_onion": "Kasvissipuli", "vegetables": "Kasvikset", - "vegetarian_cold_cuts": "kasvissyöjä leikkeleitä", + "vegetarian_cold_cuts": "Kasvipohjaiset leikkeleet", "vinegar": "Etikka", "vitamin_tablets": "Vitamiinitabletit", "vodka": "Vodka", @@ -464,7 +463,7 @@ "whipped_cream": "Kermavaahto", "white_wine": "Valkoviini", "white_wine_vinegar": "Valkoviinietikka", - "whole_canned_tomatoes": "kokonaiset tomaattisäilykkeet", + "whole_canned_tomatoes": "Kokonaiset säilyketomaatit", "wild_berries": "Metsämarjoja", "wild_rice": "Villiriisi", "wildberry_lillet": "Villimarja Lillet", diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json index 6939d918..cbe72583 100644 --- a/backend/templates/l10n/nl.json +++ b/backend/templates/l10n/nl.json @@ -50,7 +50,7 @@ "birthday_card": "Verjaardagskaart", "black_beans": "Zwarte bonen", "bockwurst": "Bockworst", - "bodywash": "Zeep", + "bodywash": "Body wash", "bread": "Brood", "breadcrumbs": "Paneermeel", "broccoli": "Broccoli", @@ -66,7 +66,7 @@ "butter_cookies": "Boterkoekjes", "button_cells": "Knoopbatterij", "börek_cheese": "Börek kaas", - "cake": "Cake", + "cake": "Taart", "cake_icing": "Taart glazuur", "cane_sugar": "Rietsuiker", "cannelloni": "Cannelloni", @@ -94,7 +94,7 @@ "chunky_tomatoes": "Grove tomaten", "ciabatta": "Ciabatta", "cider_vinegar": "Cider azijn", - "cilantro": "Cilantro", + "cilantro": "Koriander", "cinnamon": "Kaneel", "cinnamon_stick": "Kaneelstokje", "cocktail_sauce": "Cocktailsaus", @@ -117,9 +117,9 @@ "cow's_milk": "Koemelk", "cream": "Crème", "cream_cheese": "Roomkaas", - "creamed_spinach": "Spinazie", + "creamed_spinach": "Spinazie a la crème", "creme_fraiche": "Crème fraiche", - "crepe_tape": "Crepe tape", + "crepe_tape": "Afplaktape", "crispbread": "Knäckebröd", "cucumber": "Komkommer", "cumin": "Komijn", @@ -166,15 +166,15 @@ "garlic_dip": "Knoflook dip", "garlic_granules": "Knoflookgranulaat", "gherkins": "Augurken", - "ginger": "Ginger", + "ginger": "Gember", "glass_noodles": "Glazen noedels", "gluten": "Gluten", "gnocchi": "Gnocchi", "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", - "gouda": "Gouda", + "gouda": "Goudse kaas", "granola": "Granola", - "granola_bar": "Mueslireep", + "granola_bar": "Granolareep", "grapes": "Druiven", "greek_yogurt": "Griekse yoghurt", "green_asparagus": "Groene asperges", @@ -205,7 +205,7 @@ "jasmine_rice": "Jasmijnrijst", "katjes": "Katjes", "ketchup": "Ketchup", - "kidney_beans": "Nierbonen", + "kidney_beans": "Kidneyboon", "kitchen_roll": "Keukenrol", "kitchen_towels": "Keukenhanddoeken", "kohlrabi": "Koolrabi", @@ -246,7 +246,7 @@ "mint_candy": "Mint snoep", "miso_paste": "Misopasta", "mixed_vegetables": "Gemengde groenten", - "mochis": "Mochis", + "mochis": "Mochi", "mold_remover": "Schimmelverwijderaar", "mountain_cheese": "Bergkaas", "mouth_wash": "Mondspoeling", @@ -275,17 +275,17 @@ "oregano": "Oregano", "organic_lemon": "Biologische citroen", "organic_waste_bags": "Zakken voor organisch afval", - "pak_choi": "Pak Choi", + "pak_choi": "Paksoi", "pantyhose": "Kousenband", "paprika": "Paprika", - "paprika_seasoning": "Paprika kruiden", + "paprika_seasoning": "Paprikakruiden", "pardina_lentils_dried": "Pardina linzen gedroogd", "parmesan": "Parmezaan", "parsley": "Peterselie", "pasta": "Pasta", "peach": "Perzik", "peanut_butter": "Pindakaas", - "peanut_flips": "Peanut Flips", + "peanut_flips": "Pinda flips", "peanut_oil": "Pindaolie", "peanuts": "Pinda's", "pears": "Peren", @@ -296,14 +296,14 @@ "peppers": "Paprika's", "persian_rice": "Perzische rijst", "pesto": "Pesto", - "pilsner": "Pilsner", + "pilsner": "Pils", "pine_nuts": "Pijnboompitten", "pineapple": "Ananas", "pita_bag": "Pita zak", "pita_bread": "Pitabrood", "pizza": "Pizza", "pizza_dough": "Pizzadeeg", - "plant_magarine": "Plant Magarine", + "plant_magarine": "Plantaardige margarine", "plant_oil": "Plantaardige olie", "plaster": "Gips", "pointed_peppers": "Puntpaprika's", @@ -319,9 +319,9 @@ "puff_pastry": "Bladerdeeg", "pumpkin": "Pompoen", "pumpkin_seeds": "Pompoenpitten", - "quark": "Quark", + "quark": "Kwark", "quinoa": "Quinoa", - "radicchio": "Radicchio", + "radicchio": "Roodlof", "radish": "Radijs", "ramen": "Ramen", "rapeseed_oil": "Koolzaadolie", @@ -362,7 +362,7 @@ "sausage": "Worst", "sausages": "Worstjes", "savoy_cabbage": "Savooiekool", - "scallion": "Scallion", + "scallion": "Bosui", "scattered_cheese": "Verspreide kaas", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", @@ -392,7 +392,7 @@ "soy_hack": "Soja hack", "soy_sauce": "Sojasaus", "soy_shred": "Sojasnippers", - "spaetzle": "Spaetzle", + "spaetzle": "Spätzle", "spaghetti": "Spaghetti", "sparkling_water": "Sprankelend water", "spelt": "Spelt", @@ -404,12 +404,12 @@ "spreading_cream": "Smeercrème", "spring_onions": "Lente-uitjes", "sprite": "Sprite", - "sprouts": "Sprouts", + "sprouts": "Spruiten", "sriracha": "Sriracha", "strained_tomatoes": "Gezeefde tomaten", "strawberries": "Aardbeien", "sugar": "Suiker", - "summer_roll_paper": "Zomer rol papier", + "summer_roll_paper": "Rijstpapier", "sunflower_oil": "Zonnebloemolie", "sunflower_seeds": "Zonnebloempitten", "sunscreen": "Zonnebrandcrème", @@ -421,24 +421,24 @@ "sweets": "Zoetigheden", "table_salt": "Tafelzout", "tagliatelle": "Tagliatelle", - "tahini": "Tahini", + "tahini": "Tahin", "tangerines": "Mandarijnen", "tape": "Tape", "tapioca_flour": "Tapiocameel", "tea": "Thee", "teriyaki_sauce": "Teriyaki saus", "thyme": "Tijm", - "toast": "Toast", + "toast": "Geroosterd brood", "tofu": "Tofu", "toilet_paper": "Toiletpapier", "tomato_juice": "Tomatensap", "tomato_paste": "Tomatenpasta", "tomato_sauce": "Tomatensaus", "tomatoes": "Tomaten", - "tonic_water": "Tonic water", + "tonic_water": "Tonic", "toothpaste": "Tandpasta", "tortellini": "Tortellini", - "tortilla_chips": "Tortilla Chips", + "tortilla_chips": "Tortillachips", "tuna": "Tonijn", "turmeric": "Kurkuma", "tzatziki": "Tzatziki", diff --git a/backend/templates/l10n/pt_BR.json b/backend/templates/l10n/pt_BR.json index c5c09da5..480c8e8b 100644 --- a/backend/templates/l10n/pt_BR.json +++ b/backend/templates/l10n/pt_BR.json @@ -2,7 +2,7 @@ "categories": { "bread": "🍞 Padaria", "canned": "🥫 Comida Enlatada", - "dairy": "🥛 Derivados de Leite", + "dairy": "Laticínios", "drinks": "🍹 Bebidas", "freezer": "❄️ Congelados", "fruits_vegetables": "🥬 Frutas e Vegetais", @@ -49,17 +49,17 @@ "beetroot": "Beterraba", "birthday_card": "Cartão de aniversário", "black_beans": "Feijão preto", - "bockwurst": "Bockwurst", + "bockwurst": "Salsicha", "bodywash": "Lavagem do corpo", "bread": "Pão", - "breadcrumbs": "Breadcrumbs", + "breadcrumbs": "Farinha de rosca", "broccoli": "Brócolis", "brown_sugar": "Açúcar mascavo", "brussels_sprouts": "Couve-de-bruxelas", "buffalo_mozzarella": "Mozzarella de búfalo", "buko": "Buko", "buns": "Pãezinhos", - "burger_buns": "Burger Buns", + "burger_buns": "Pães para hambúrguer", "burger_patties": "Hambúrgueres Patties", "burger_sauces": "Molhos para hambúrgueres", "butter": "Manteiga", @@ -71,18 +71,18 @@ "cane_sugar": "Açúcar de cana", "cannelloni": "Cannelloni", "canola_oil": "Óleo de canola", - "cardamom": "Cardamom", + "cardamom": "Cardamomo", "carrots": "Cenouras", "cashews": "Cajus", "cat_treats": "Gatos", "cauliflower": "Couve-flor", - "celeriac": "Celeriac", + "celeriac": "aipo", "celery": "Aipo", "cereal_bar": "Barra de cereais", "cheddar": "Cheddar", "cheese": "Queijo", "cherry_tomatoes": "Tomates cereja", - "chickpeas": "Chickpeas", + "chickpeas": "grão de bico", "chicory": "Chicória", "chili_oil": "Óleo de pimenta", "chili_pepper": "Pimenta malagueta", From 5549c91b4a1544c472799225d6f32c78d8027011 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 29 Aug 2023 21:57:52 +0200 Subject: [PATCH 380/496] feat: Add Czech --- backend/app/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/config.py b/backend/app/config.py index 93207b42..79a951c5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -47,6 +47,7 @@ SUPPORTED_LANGUAGES = { 'en': 'English', + 'cs': 'čeština', 'da': 'Dansk', 'de': 'Deutsch', 'el': 'Ελληνικά', From 7d5227f5e0648085b063677f9bce26fcba92f23c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 30 Aug 2023 17:45:26 +0200 Subject: [PATCH 381/496] fix: return full recipe on creation and update --- backend/app/controller/recipe/recipe_controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index e409a277..30f4913d 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -77,7 +77,7 @@ def addRecipe(args, household_id): con.tag = tag con.recipe = recipe con.save() - return jsonify(recipe.obj_to_dict()) + return jsonify(recipe.obj_to_full_dict()) @recipe.route('/', methods=['POST']) @@ -144,7 +144,7 @@ def updateRecipe(args, id): # noqa: C901 con.tag = tag con.recipe = recipe con.save() - return jsonify(recipe.obj_to_dict()) + return jsonify(recipe.obj_to_full_dict()) @recipe.route('/', methods=['DELETE']) @@ -165,7 +165,7 @@ def deleteRecipeById(id): def searchRecipeByName(args, household_id): if 'only_ids' in args and args['only_ids']: return jsonify([e.id for e in Recipe.search_name(household_id, args['query'])]) - return jsonify([e.obj_to_dict() for e in Recipe.search_name(household_id, args['query'])]) + return jsonify([e.obj_to_full_dict() for e in Recipe.search_name(household_id, args['query'])]) @recipeHousehold.route('/filter', methods=['POST']) From e07e0b38171bb5e954517b6d0bc728686e7143bf Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 31 Aug 2023 14:56:39 +0200 Subject: [PATCH 382/496] feat: Add image blurhash --- .../controller/upload/upload_controller.py | 13 +++- backend/app/models/expense.py | 10 ++- backend/app/models/file.py | 1 + backend/app/models/household.py | 2 + backend/app/models/recipe.py | 2 + .../service/file_has_access_or_download.py | 15 ++++- backend/manage.py | 17 ++++- backend/migrations/versions/ade9ad0be1a5_.py | 63 +++++++++++++++++++ backend/requirements.txt | 1 + 9 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/versions/ade9ad0be1a5_.py diff --git a/backend/app/controller/upload/upload_controller.py b/backend/app/controller/upload/upload_controller.py index aa0bad68..40bc84f7 100644 --- a/backend/app/controller/upload/upload_controller.py +++ b/backend/app/controller/upload/upload_controller.py @@ -4,6 +4,8 @@ from flask import jsonify, Blueprint, send_from_directory, request from flask_jwt_extended import current_user, jwt_required from werkzeug.utils import secure_filename +import blurhash +from PIL import Image from app.config import UPLOAD_FOLDER from app.errors import ForbiddenRequest, NotFoundRequest @@ -28,8 +30,17 @@ def upload_file(): if file and allowed_file(file.filename): filename = secure_filename(str(uuid.uuid4()) + '.' + file.filename.rsplit('.', 1)[1].lower()) - f = File(filename=filename, created_by=current_user.id).save() file.save(os.path.join(UPLOAD_FOLDER, filename)) + blur = None + try: + with Image.open(os.path.join(UPLOAD_FOLDER, filename)) as image: + image.thumbnail((100, 100)) + blur = blurhash.encode(image, x_components=4, y_components=3) + except FileNotFoundError: + return None + except Exception: + pass + f = File(filename=filename, blur_hash=blur, created_by=current_user.id).save() return jsonify(f.obj_to_dict()) raise Exception("Invalid usage.") diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index e55cb0e5..68849da6 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -23,8 +23,14 @@ class Expense(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): 'ExpensePaidFor', back_populates='expense', cascade="all, delete-orphan") photo_file = db.relationship("File", back_populates='expense', uselist=False) - def obj_to_full_dict(self) -> dict: + def obj_to_dict(self) -> dict: res = super().obj_to_dict() + if self.photo_file: + res['photo_hash'] = self.photo_file.blur_hash + return res + + def obj_to_full_dict(self) -> dict: + res = self.obj_to_dict() paidFor = ExpensePaidFor.query.filter(ExpensePaidFor.expense_id == self.id).join( ExpensePaidFor.user).order_by( ExpensePaidFor.expense_id).all() @@ -32,7 +38,7 @@ def obj_to_full_dict(self) -> dict: if (self.category): res['category'] = self.category.obj_to_full_dict() return res - + def obj_to_export_dict(self) -> dict: res = { 'name': self.name, diff --git a/backend/app/models/file.py b/backend/app/models/file.py index 584cf028..263477b6 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -11,6 +11,7 @@ class File(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): __tablename__ = 'file' filename = db.Column(db.String(), primary_key=True) + blur_hash = db.Column(db.String(length=40), nullable=True) created_by = db.Column(db.Integer, db.ForeignKey( 'user.id'), nullable=True) diff --git a/backend/app/models/household.py b/backend/app/models/household.py index 22f72609..d8f03d54 100644 --- a/backend/app/models/household.py +++ b/backend/app/models/household.py @@ -39,6 +39,8 @@ def obj_to_dict(self) -> dict: res = super().obj_to_dict() res['member'] = [m.obj_to_user_dict() for m in getattr(self, 'member')] res['default_shopping_list'] = self.shoppinglists[0].obj_to_dict() + if self.photo_file: + res['photo_hash'] = self.photo_file.blur_hash return res def obj_to_export_dict(self) -> dict: diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index d0b056aa..e4c04c51 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -41,6 +41,8 @@ def obj_to_dict(self) -> dict: res = super().obj_to_dict() res['planned'] = len(self.plans) > 0 res['planned_days'] = [plan.day for plan in self.plans if plan.day >= 0] + if self.photo_file: + res['photo_hash'] = self.photo_file.blur_hash return res def obj_to_full_dict(self) -> dict: diff --git a/backend/app/service/file_has_access_or_download.py b/backend/app/service/file_has_access_or_download.py index 63904081..91200f33 100644 --- a/backend/app/service/file_has_access_or_download.py +++ b/backend/app/service/file_has_access_or_download.py @@ -1,6 +1,8 @@ import os import uuid import requests +import blurhash +from PIL import Image from app.util.filename_validator import allowed_file from app.config import UPLOAD_FOLDER from app.models import File @@ -19,9 +21,20 @@ def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str: ext = guess_extension(resp.headers['content-type']) if ext and allowed_file('file' + ext): filename = secure_filename(str(uuid.uuid4()) + ext) - File(filename=filename, created_by=current_user.id).save() with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as o: o.write(resp.content) + blur = None + try: + with Image.open(os.path.join(UPLOAD_FOLDER, filename)) as image: + image.thumbnail((100, 100)) + blur = blurhash.encode( + image, x_components=4, y_components=3) + except FileNotFoundError: + return None + except Exception: + pass + File(filename=filename, blur_hash=blur, + created_by=current_user.id).save() return filename elif newPhoto is not None: if not newPhoto: diff --git a/backend/manage.py b/backend/manage.py index c69784c8..cf207d06 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -1,15 +1,30 @@ from os import listdir from os.path import isfile, join +import blurhash +from PIL import Image from app import app, db from app.config import UPLOAD_FOLDER from app.jobs import jobs from app.models import User, File, Household, HouseholdMember from app.service.delete_unused import deleteEmptyHouseholds, deleteUnusedFiles + def importFiles(): try: filesInUploadFolder = [f for f in listdir(UPLOAD_FOLDER) if isfile(join(UPLOAD_FOLDER, f))] - files = [File(filename=f) for f in filesInUploadFolder if not File.find(f)] + def createFile(filename: str) -> File: + blur = None + try: + with Image.open(join(UPLOAD_FOLDER, filename)) as image: + image.thumbnail((100, 100)) + blur = blurhash.encode( + image, x_components=4, y_components=3) + except FileNotFoundError: + pass + except Exception: + pass + return File(filename=filename, blur_hash=blur) + files = [createFile(f) for f in filesInUploadFolder if not File.find(f)] db.session.bulk_save_objects(files) db.session.commit() diff --git a/backend/migrations/versions/ade9ad0be1a5_.py b/backend/migrations/versions/ade9ad0be1a5_.py new file mode 100644 index 00000000..335f1864 --- /dev/null +++ b/backend/migrations/versions/ade9ad0be1a5_.py @@ -0,0 +1,63 @@ +"""empty message + +Revision ID: ade9ad0be1a5 +Revises: 3647c9eb1881 +Create Date: 2023-08-31 13:57:34.979533 + +""" +import os +from alembic import op +import blurhash +from PIL import Image +import sqlalchemy as sa +from sqlalchemy import orm + +from app.config import UPLOAD_FOLDER + +DeclarativeBase = orm.declarative_base() + + +# revision identifiers, used by Alembic. +revision = 'ade9ad0be1a5' +down_revision = '3647c9eb1881' +branch_labels = None +depends_on = None + + +class File(DeclarativeBase): + __tablename__ = 'file' + filename = sa.Column(sa.String(), primary_key=True) + blur_hash = sa.Column(sa.String(length=40), nullable=True) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('file', schema=None) as batch_op: + batch_op.add_column(sa.Column('blur_hash', sa.String(length=40), nullable=True)) + + bind = op.get_bind() + session = orm.Session(bind=bind) + for file in session.query(File).all(): + try: + with Image.open(os.path.join(UPLOAD_FOLDER, file.filename)) as image: + image.thumbnail((100, 100)) + file.blur_hash = blurhash.encode(image, x_components=4, y_components=3) + session.add(file) + except FileNotFoundError: + session.delete(file) + except Exception: + pass + try: + session.commit() + except Exception as e: + session.rollback() + raise e + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('file', schema=None) as batch_op: + batch_op.drop_column('blur_hash') + + # ### end Alembic commands ### diff --git a/backend/requirements.txt b/backend/requirements.txt index 96cc04de..22daf0a1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,6 +8,7 @@ beautifulsoup4==4.12.2 bidict==0.22.1 black==23.1a1 blinker==1.6.2 +blurhash-python==1.2.1 certifi==2023.7.22 cffi==1.15.1 charset-normalizer==3.2.0 From b9a112dc648f932fadf1683c4f29a9a1455f5add Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 31 Aug 2023 15:32:09 +0200 Subject: [PATCH 383/496] fix: migrate recipe scraper --- .../app/controller/recipe/recipe_controller.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 30f4913d..7bdfbaa6 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -217,15 +217,15 @@ def scrapeRecipe(args, household_id): items = {} for ingredient in scraper.ingredients(): parsed = parse_ingredient(ingredient) - item = Item.find_by_name(household_id, parsed['name']) - if not item: - item = Item(name=parsed['name']) - - items[ingredient] = item.obj_to_dict() | { - "description": ' '.join( - filter(None, [parsed['quantity'] + parsed['unit'], parsed['comment']])), - "optional": False, - } + item = Item.find_by_name(household_id, parsed.name) + if item: + items[ingredient] = item.obj_to_dict() | { + "description": ' '.join( + filter(None, [parsed.quantity + parsed.unit, parsed.comment])), + "optional": False, + } + else: + items[ingredient] = None return jsonify({ 'recipe': recipe.obj_to_dict(), 'items': items, From 88c80f94f0ea5ea1f08917139e8f52e33844995d Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 1 Sep 2023 12:09:08 +0200 Subject: [PATCH 384/496] Translations update from Hosted Weblate (TomBursch/kitchenowl-backend#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Russian) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/ru/ * Translated using Weblate (Spanish) Currently translated at 100.0% (477 of 477 strings) Translation: KitchenOwl/Default Items Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ --------- Co-authored-by: Tom Bursch Co-authored-by: Sergio González --- backend/templates/l10n/es.json | 2 +- backend/templates/l10n/ru.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index a07794bc..81e93643 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -8,7 +8,7 @@ "fruits_vegetables": "🥬 Frutas y verduras", "grain": "🥟 Productos de cereales", "hygiene": "🚽 Higiene", - "refrigerated": "💧 Refrigerado", + "refrigerated": "💧 Refrigerados", "snacks": "🥜 Aperitivos" }, "items": { diff --git a/backend/templates/l10n/ru.json b/backend/templates/l10n/ru.json index 5d422c69..8afc83cf 100644 --- a/backend/templates/l10n/ru.json +++ b/backend/templates/l10n/ru.json @@ -228,7 +228,7 @@ "linguine": "Макароны лингуини", "lip_care": "Уход за губами", "low-fat_curd_cheese": "Обезжиренный творог", - "maggi": "Maggi", + "maggi": "Магги", "magnesium": "Магний", "mango": "Манго", "maple_syrup": "Кленовый сироп", From 25abfa3f87681f1cc266db03c33c9c95a952acf1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 1 Sep 2023 12:09:32 +0200 Subject: [PATCH 385/496] feat: improve default items --- backend/entrypoint.sh | 2 +- backend/manage_default_items.py | 129 +++++++ backend/templates/attributes.json | 336 ++++++++++++------ .../fill_attributes_with_missing_items.py | 25 -- backend/templates/l10n/en.json | 4 +- backend/upgrade_default_items.py | 10 +- 6 files changed, 368 insertions(+), 138 deletions(-) create mode 100644 backend/manage_default_items.py delete mode 100644 backend/templates/fill_attributes_with_missing_items.py diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 6bfc197f..e017d772 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/sh mkdir -p $STORAGE_PATH/upload flask db upgrade -#python upgrade_default_items.py +python upgrade_default_items.py uwsgi "$@" \ No newline at end of file diff --git a/backend/manage_default_items.py b/backend/manage_default_items.py new file mode 100644 index 00000000..6bd9709c --- /dev/null +++ b/backend/manage_default_items.py @@ -0,0 +1,129 @@ +import argparse +import json +import os +import requests + +from sqlalchemy import desc, func +from app import app +from app.models import Item, Category, Household + + +BASE_PATH = os.path.dirname(os.path.abspath(__file__)) +DEEPL_AUTH_KEY = "" + + +def update_names(saveToFile: bool = False): + def nameToKey(name: str) -> str: + return name.lower().strip().replace(" ", "_") + for household in Household.query.filter(Household.language != None).all(): + lang_code = household.language + add_items = [item.obj_to_export_dict() for item in household.items] + + # read en file + with open(BASE_PATH + "/templates/l10n/en.json", encoding="utf8") as f: + en = json.load(f) + + # translate original file (used as the key) and write to file + if lang_code != "en" and not DEEPL_AUTH_KEY: + continue + if lang_code != "en": + deepl_supported_lang: list = [v['language'].lower() for v in json.loads(requests.get("https://api-free.deepl.com/v2/languages?type=source", + headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)] + if lang_code not in deepl_supported_lang: + print(f"Source language '{lang_code}' not supported by deepl") + continue + + if os.path.exists(BASE_PATH + "/templates/l10n/" + lang_code + ".json"): + with open(BASE_PATH + "/templates/l10n/" + lang_code + ".json", "r", encoding="utf8") as f: + content = f.read() + if content: + source = json.loads(content) + else: + source = {} + else: + source = {} + + if "items" not in source: + source["items"] = {} + + for item in add_items: + item["original"] = item["name"] + item["name"] = json.loads(requests.post("https://api-free.deepl.com/v2/translate", {"target_lang": "EN-US", "source_lang": lang_code.upper(), "text": item["name"]}, + headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)['translations'][0]["text"] + + if (nameToKey(item["name"]) not in source["items"]): + source["items"][nameToKey(item["name"])] = item["original"] + + with open(BASE_PATH + "/templates/l10n/" + lang_code + ".json", "w", encoding="utf8") as f: + f.write(json.dumps(source, ensure_ascii=False, + indent=2, sort_keys=True)) + + for item in add_items: + if (nameToKey(item["name"]) not in en["items"]): + en["items"][nameToKey(item["name"])] = item["name"] + + with open(BASE_PATH + "/templates/l10n/en.json", "w", encoding="utf8") as f: + f.write(json.dumps(en, ensure_ascii=False, indent=2, sort_keys=True)) + + +def update_attributes(saveToFile: bool = False): + # read files + with open(BASE_PATH + "/templates/l10n/en.json", encoding="utf8") as f: + en: dict = json.load(f) + with open(BASE_PATH + "/templates/attributes.json", encoding="utf8") as f: + attr: dict = json.load(f) + + unkownKeys = [] + # Remove unkown keys from attributes file + for key in attr["items"].keys(): + if key not in en["items"]: + unkownKeys.append(key) + for key in unkownKeys: + attr["items"].pop(key) + + # Find item icons + for key in en["items"].keys(): + # Add key to map + if key not in attr["items"]: + attr["items"][key] = {} + # Find icon consesus + iconItem = Item.query.with_entities(Item.icon, func.count().label('count')).filter( + Item.default_key == key, Item.icon != None).group_by(Item.icon).order_by(desc("count")).first() + if iconItem: + attr["items"][key]['icon'] = iconItem.icon + + # Find item categories + for key in en["items"].keys(): + filterQuery = Item.query.with_entities(Item.category_id).filter( + Item.default_key == key, Item.category_id != None).scalar_subquery() + itemCategory = Category.query.with_entities(Category.default_key, func.count( + ).label('count')).filter(Category.id.in_(filterQuery), Category.default_key != None).group_by(Category.default_key).order_by(desc("count")).first() + if itemCategory: + attr["items"][key]['category'] = itemCategory.default_key + + jsonContent = json.dumps(attr, ensure_ascii=False, + indent=2, sort_keys=True) + if (saveToFile): + with open(BASE_PATH + "/templates/attributes.json", "w", encoding="utf8") as f: + f.write(jsonContent) + else: + print(jsonContent) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog='python manage_default_items.py', + description='This programms queries the current kitchenowl installation for updated template items (names & icon & category)', + ) + parser.add_argument('-s', '--save', action='store_true', + help="saves the output directly to the templates folder") + parser.add_argument('-n', '--names', action='store_true', + help="collects item names") + parser.add_argument('-a', '--attributes', action='store_true', + help="collects attributes") + args = parser.parse_args() + with app.app_context(): + if (args.names and args.save): + update_names(args.save) + if (args.attributes): + update_attributes(args.save) diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index 8ff9f053..667bdfca 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -1,6 +1,8 @@ { "items": { - "aioli": {}, + "aioli": { + "icon": "garlic" + }, "amaretto": { "category": "drinks" }, @@ -8,7 +10,6 @@ "category": "fruits_vegetables", "icon": "apple" }, - "apple_cider_vinegar": {}, "apple_pulp": { "icon": "apple" }, @@ -23,7 +24,9 @@ "arugula": { "category": "fruits_vegetables" }, - "asian_egg_noodles": {}, + "asian_egg_noodles": { + "icon": "noodles" + }, "asian_noodles": {}, "asparagus": { "category": "fruits_vegetables", @@ -36,7 +39,10 @@ "category": "fruits_vegetables", "icon": "avocado" }, - "baby_potatoes": {}, + "baby_potatoes": { + "category": "fruits_vegetables", + "icon": "potato" + }, "baby_spinach": { "category": "fruits_vegetables", "icon": "spinach" @@ -48,14 +54,12 @@ "category": "bread", "icon": "bread" }, - "baguettes": { - "category": "bread", - "icon": "bread" - }, "bakefish": {}, "baking_cocoa": {}, "baking_mix": {}, - "baking_paper": {}, + "baking_paper": { + "category": "hygiene" + }, "baking_powder": {}, "baking_soda": {}, "baking_yeast": {}, @@ -70,10 +74,14 @@ "basmati_rice": { "icon": "grains-of-rice" }, - "bathroom_cleaner": {}, + "bathroom_cleaner": { + "icon": "spray" + }, "batteries": {}, "bay_leaf": {}, - "beans": {}, + "beans": { + "icon": "peas" + }, "beer": { "category": "drinks", "icon": "beer-bottle" @@ -122,7 +130,8 @@ "burger_patties": {}, "burger_sauces": {}, "butter": { - "category": "dairy" + "category": "dairy", + "icon": "butter" }, "butter_cookies": { "icon": "cookies" @@ -136,19 +145,26 @@ "cake_icing": {}, "cane_sugar": {}, "cannelloni": {}, - "canola_oil": {}, + "canola_oil": { + "icon": "plastic_bottle" + }, "cardamom": {}, "carrots": { "category": "fruits_vegetables", "icon": "carrot" }, - "cashews": {}, - "cat_treats": {}, + "cashews": { + "icon": "nut" + }, + "cat_treats": { + "category": "hygiene" + }, "cauliflower": { "category": "fruits_vegetables" }, "celeriac": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "kohlrabi" }, "celery": { "category": "fruits_vegetables", @@ -194,8 +210,12 @@ }, "cider_vinegar": {}, "cilantro": {}, - "cinnamon": {}, - "cinnamon_stick": {}, + "cinnamon": { + "icon": "cinnamon_sticks" + }, + "cinnamon_stick": { + "icon": "cinnamon_sticks" + }, "cocktail_sauce": {}, "cocktail_tomatoes": { "category": "fruits_vegetables", @@ -225,7 +245,10 @@ "cornflakes": {}, "cornstarch": {}, "cornys": {}, - "corriander": {}, + "corriander": { + "category": "fruits_vegetables", + "icon": "natural_food" + }, "cough_drops": {}, "couscous": {}, "covid_rapid_test": {}, @@ -234,7 +257,8 @@ "icon": "milk-carton" }, "cream": { - "category": "dairy" + "category": "dairy", + "icon": "whipped_cream" }, "cream_cheese": { "category": "dairy" @@ -252,7 +276,9 @@ "icon": "cucumber" }, "cumin": {}, - "curd": {}, + "curd": { + "category": "dairy" + }, "curry_paste": {}, "curry_powder": {}, "curry_sauce": {}, @@ -262,12 +288,16 @@ "dental_floss": { "category": "hygiene" }, - "deo": {}, + "deo": { + "category": "hygiene", + "icon": "spray" + }, "deodorant": { "category": "hygiene" }, "detergent": { - "category": "hygiene" + "category": "hygiene", + "icon": "soap_bubble" }, "detergent_sheets": {}, "diarrhea_remedy": {}, @@ -281,7 +311,9 @@ "disinfection_spray": { "category": "hygiene" }, - "dried_tomatoes": {}, + "dried_tomatoes": { + "icon": "tomato" + }, "edamame": { "icon": "peas" }, @@ -308,16 +340,19 @@ "ffp2": { "category": "hygiene" }, - "fish_sticks": {}, + "fish_sticks": { + "icon": "fish_food" + }, "flour": { - "icon": "wheat" + "icon": "flour" }, "flushing": {}, "fresh_chili_pepper": { "category": "fruits_vegetables" }, "frozen_berries": { - "category": "freezer" + "category": "freezer", + "icon": "strawberry" }, "frozen_fruit": { "category": "freezer" @@ -335,9 +370,6 @@ "garbage_bag": { "category": "hygiene" }, - "garbage_bags": { - "category": "hygiene" - }, "garlic": { "category": "fruits_vegetables", "icon": "garlic" @@ -357,7 +389,9 @@ "gluten": { "category": "bread" }, - "gnocchi": {}, + "gnocchi": { + "icon": "potato" + }, "gochujang": {}, "gorgonzola": { "category": "dairy" @@ -373,7 +407,8 @@ "icon": "grapes" }, "greek_yogurt": { - "category": "dairy" + "category": "dairy", + "icon": "yogurt" }, "green_asparagus": { "category": "fruits_vegetables" @@ -383,15 +418,22 @@ "hair_gel": {}, "hair_ties": {}, "hair_wax": {}, - "hand_soap": {}, - "handkerchief_box": {}, + "hand_soap": { + "category": "hygiene" + }, + "handkerchief_box": { + "category": "hygiene", + "icon": "wipes" + }, "handkerchiefs": {}, "hard_cheese": {}, "haribo": { "category": "snacks" }, "harissa": {}, - "hazelnuts": {}, + "hazelnuts": { + "icon": "nut" + }, "head_of_lettuce": { "category": "fruits_vegetables", "icon": "lettuce" @@ -400,13 +442,16 @@ "herb_cream_cheese": { "category": "dairy" }, - "honey": {}, + "honey": { + "icon": "honey" + }, "honey_wafers": {}, "hot_dog_bun": { "category": "bread" }, "ice_cream": { - "category": "freezer" + "category": "freezer", + "icon": "whipped_cream" }, "ice_cube": {}, "iceberg_lettuce": { @@ -418,14 +463,19 @@ }, "instant_soups": {}, "jam": {}, - "jasmine_rice": {}, + "jasmine_rice": { + "icon": "grains_of_rice" + }, "katjes": {}, "ketchup": { "icon": "ketchup" }, - "kidney_beans": {}, + "kidney_beans": { + "icon": "can_soup" + }, "kitchen_roll": { - "category": "hygiene" + "category": "hygiene", + "icon": "wipes" }, "kitchen_towels": { "category": "hygiene" @@ -438,7 +488,8 @@ "lasagna_noodles": {}, "lasagna_plates": {}, "leaf_spinach": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "spinach" }, "leek": { "category": "fruits_vegetables", @@ -453,16 +504,19 @@ "icon": "citrus" }, "lemonade": { - "category": "drinks" + "category": "drinks", + "icon": "cola" }, "lemongrass": { "category": "fruits_vegetables" }, - "lenses": {}, - "lenses_red": {}, "lentil_stew": {}, - "lentils": {}, - "lentils_red": {}, + "lentils": { + "icon": "lentil" + }, + "lentils_red": { + "icon": "lentil" + }, "lettuce": { "category": "fruits_vegetables", "icon": "lettuce" @@ -475,9 +529,12 @@ "icon": "citrus" }, "linguine": {}, - "lip_care": {}, + "lip_care": { + "category": "hygiene" + }, "low-fat_curd_cheese": { - "category": "dairy" + "category": "dairy", + "icon": "yogurt" }, "maggi": {}, "magnesium": {}, @@ -486,7 +543,8 @@ }, "maple_syrup": {}, "margarine": { - "category": "dairy" + "category": "dairy", + "icon": "butter" }, "marjoram": {}, "marshmallows": { @@ -497,7 +555,9 @@ "mask": { "category": "hygiene" }, - "mayonnaise": {}, + "mayonnaise": { + "icon": "mustard" + }, "meat_substitute_product": {}, "microfiber_cloth": {}, "milk": { @@ -509,7 +569,9 @@ }, "mint_candy": {}, "miso_paste": {}, - "mixed_vegetables": {}, + "mixed_vegetables": { + "category": "fruits_vegetables" + }, "mochis": {}, "mold_remover": {}, "mountain_cheese": { @@ -519,8 +581,12 @@ "mouth_wash": { "category": "hygiene" }, - "mozzarella": {}, - "muesli": {}, + "mozzarella": { + "icon": "mozzarella" + }, + "muesli": { + "icon": "cereal" + }, "muesli_bar": {}, "mulled_wine": { "category": "drinks" @@ -532,14 +598,15 @@ "mustard": {}, "nail_file": {}, "neutral_oil": {}, - "nori_leaves": {}, "nori_sheets": {}, "nutmeg": {}, "oat_milk": { "category": "dairy", "icon": "milk-carton" }, - "oatmeal": {}, + "oatmeal": { + "icon": "wheat" + }, "oatmeal_cookies": {}, "oatsome": {}, "obatzda": { @@ -547,6 +614,7 @@ }, "oil": {}, "olive_oil": { + "category": "bread", "icon": "olive" }, "olives": { @@ -558,10 +626,6 @@ "icon": "onion" }, "onion_powder": {}, - "onions": { - "category": "fruits_vegetables", - "icon": "onion" - }, "orange_juice": { "category": "drinks", "icon": "orange" @@ -576,7 +640,9 @@ "category": "fruits_vegetables", "icon": "citrus" }, - "organic_waste_bags": {}, + "organic_waste_bags": { + "category": "hygiene" + }, "pak_choi": { "category": "fruits_vegetables" }, @@ -588,9 +654,13 @@ "paprika_seasoning": {}, "pardina_lentils_dried": {}, "parmesan": { - "category": "dairy" + "category": "dairy", + "icon": "cheese" + }, + "parsley": { + "category": "fruits_vegetables", + "icon": "basil" }, - "parsley": {}, "pasta": { "category": "grain", "icon": "penne" @@ -607,9 +677,6 @@ "peanut_oil": { "icon": "peanuts" }, - "peanutbutter": { - "icon": "peanuts" - }, "peanuts": { "category": "snacks", "icon": "peanuts" @@ -625,7 +692,9 @@ "icon": "penne" }, "pepper": {}, - "pepper_mill": {}, + "pepper_mill": { + "category": "fruits_vegetables" + }, "peppers": { "category": "fruits_vegetables" }, @@ -644,7 +713,10 @@ "icon": "pineapple" }, "pita_bag": {}, - "pita_bread": {}, + "pita_bread": { + "category": "bread", + "icon": "bread_loaf" + }, "pizza": { "icon": "salami-pizza" }, @@ -654,9 +726,13 @@ "plant_magarine": { "category": "dairy" }, - "plant_oil": {}, + "plant_oil": { + "category": "fruits_vegetables" + }, "plaster": {}, - "pointed_peppers": {}, + "pointed_peppers": { + "category": "fruits_vegetables" + }, "porcini_mushrooms": { "category": "fruits_vegetables", "icon": "mushroom" @@ -707,13 +783,17 @@ "category": "drinks", "icon": "raspberry" }, - "razor_blades": {}, + "razor_blades": { + "icon": "razor" + }, "red_bull": { "category": "drinks" }, "red_chili": {}, "red_curry_paste": {}, - "red_lentils": {}, + "red_lentils": { + "icon": "grains_of_rice" + }, "red_onions": { "category": "fruits_vegetables", "icon": "onion" @@ -736,13 +816,22 @@ "rice_ribbon_noodles": {}, "rice_vinegar": {}, "ricotta": {}, - "rinse_tabs": {}, - "rinsing_agent": {}, - "risotto_rice": {}, + "rinse_tabs": { + "category": "hygiene" + }, + "rinsing_agent": { + "category": "hygiene" + }, + "risotto_rice": { + "icon": "grains_of_rice" + }, "rocket": { "category": "fruits_vegetables" }, - "roll": {}, + "roll": { + "category": "bread", + "icon": "bread" + }, "rosemary": {}, "saffron_threads": {}, "sage": {}, @@ -753,7 +842,9 @@ }, "salad_seeds_mix": {}, "salt": {}, - "salt_mill": {}, + "salt_mill": { + "category": "fruits_vegetables" + }, "sambal_oelek": {}, "sauce": {}, "sausage": { @@ -773,7 +864,9 @@ "schlemmerfilet": {}, "schupfnudeln": {}, "semolina_porridge": {}, - "sesame": {}, + "sesame": { + "icon": "lentil" + }, "sesame_oil": {}, "shallot": { "category": "fruits_vegetables", @@ -798,10 +891,6 @@ "sieved_tomatoes": { "icon": "tomato" }, - "slice_cheese": { - "category": "dairy", - "icon": "cheese" - }, "sliced_cheese": { "category": "dairy", "icon": "cheese" @@ -809,14 +898,17 @@ "smoked_paprika": { "icon": "paprika" }, - "smoked_tofu": {}, + "smoked_tofu": { + "icon": "natural_food" + }, "snacks": { "category": "snacks" }, "soap": {}, "soba_noodles": {}, "soft_drinks": { - "category": "drinks" + "category": "drinks", + "icon": "cola" }, "softdrinks": { "category": "drinks" @@ -828,10 +920,12 @@ "sour_cucumbers": { "icon": "cucumber" }, - "soy_cream": {}, + "soy_cream": { + "icon": "tetra_pak" + }, "soy_hack": {}, "soy_sauce": { - "icon": "soy-sauce" + "icon": "soy_sauce" }, "soy_shred": { "icon": "soy" @@ -874,8 +968,13 @@ "strained_tomatoes": { "icon": "tomato" }, - "strawberries": {}, - "sugar": {}, + "strawberries": { + "category": "fruits_vegetables", + "icon": "strawberry" + }, + "sugar": { + "icon": "sugar" + }, "summer_roll_paper": {}, "sunflower_oil": {}, "sunflower_seeds": {}, @@ -893,7 +992,9 @@ "category": "fruits_vegetables", "icon": "sweet-potato" }, - "sweets": {}, + "sweets": { + "icon": "candy_cane" + }, "table_salt": {}, "tagliatelle": {}, "tahini": {}, @@ -906,13 +1007,17 @@ "category": "drinks", "icon": "tea" }, - "teriyaki_sauce": {}, + "teriyaki_sauce": { + "icon": "soy_sauce" + }, "thyme": {}, "toast": { "category": "bread", - "icon": "bread-loaf" + "icon": "bread_loaf" + }, + "tofu": { + "icon": "natural_food" }, - "tofu": {}, "toilet_paper": {}, "tomato_juice": { "icon": "tomato" @@ -931,7 +1036,8 @@ "category": "drinks" }, "toothpaste": { - "category": "hygiene" + "category": "hygiene", + "icon": "tooth_cleaning_kit" }, "tortellini": {}, "tortilla_chips": {}, @@ -942,12 +1048,14 @@ "tzatziki": {}, "udon_noodles": {}, "uht_milk": { - "category": "dairy" + "category": "dairy", + "icon": "milk_carton" }, "vanilla_sugar": {}, - "vegetable broth": {}, "vegetable_bouillon_cube": {}, - "vegetable_broth": {}, + "vegetable_broth": { + "icon": "mayonnaise" + }, "vegetable_oil": {}, "vegetable_onion": { "category": "fruits_vegetables" @@ -957,23 +1065,33 @@ }, "vegetarian_cold_cuts": {}, "vinegar": {}, - "vitamin_tablets": {}, + "vitamin_tablets": { + "category": "bread", + "icon": "pills" + }, "vodka": { "category": "drinks" }, - "washing_gel": {}, + "washing_gel": { + "category": "hygiene", + "icon": "soap_bubble" + }, "washing_powder": { - "category": "hygiene" + "category": "hygiene", + "icon": "soap_bubble" }, "water": { - "category": "drinks" + "category": "drinks", + "icon": "water" }, "water_ice": {}, "watermelon": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "melon" }, "wc_cleaner": { - "category": "hygiene" + "category": "hygiene", + "icon": "spray" }, "wheat_flour": {}, "whipped_cream": { @@ -981,7 +1099,7 @@ }, "white_wine": { "category": "drinks", - "icon": "wine-bottle" + "icon": "wine_bottle" }, "white_wine_vinegar": {}, "whole_canned_tomatoes": { @@ -992,7 +1110,9 @@ "category": "fruits_vegetables", "icon": "raspberry" }, - "wild_rice": {}, + "wild_rice": { + "icon": "grains_of_rice" + }, "wildberry_lillet": {}, "worcester_sauce": {}, "wrapping_paper": {}, @@ -1002,12 +1122,15 @@ "yeast": {}, "yeast_flakes": {}, "yoghurt": { - "category": "dairy" + "category": "dairy", + "icon": "yogurt" }, "yogurt": { "category": "dairy" }, - "yum_yum": {}, + "yum_yum": { + "icon": "noodles" + }, "zewa": { "category": "hygiene" }, @@ -1015,7 +1138,8 @@ "category": "hygiene" }, "zucchini": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "cucumber" } } } \ No newline at end of file diff --git a/backend/templates/fill_attributes_with_missing_items.py b/backend/templates/fill_attributes_with_missing_items.py deleted file mode 100644 index 25a63683..00000000 --- a/backend/templates/fill_attributes_with_missing_items.py +++ /dev/null @@ -1,25 +0,0 @@ -import requests -import json -import os - - -BASE_PATH = os.path.dirname(os.path.abspath(__file__)) - -def main(): - # read files - with open(BASE_PATH + "/l10n/en.json", encoding="utf8") as f: - en:dict = json.load(f) - with open(BASE_PATH + "/attributes.json", encoding="utf8") as f: - attr:dict = json.load(f) - - for key in en["items"].keys(): - if key not in attr["items"]: - attr["items"][key] = {} - - - with open(BASE_PATH + "/attributes.json", "w", encoding="utf8") as f: - f.write(json.dumps(attr, ensure_ascii=False, indent=2, sort_keys=True)) - - -if __name__ == "__main__": - main() diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index e1f57087..1fd10679 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -121,7 +121,6 @@ "creme_fraiche": "Creme fraiche", "crepe_tape": "Crepe tape", "crispbread": "Crispbread", - "granola": "Granola", "cucumber": "Cucumber", "cumin": "Cumin", "curd": "Curd", @@ -174,6 +173,7 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Granola", "granola_bar": "Granola bar", "grapes": "Grapes", "greek_yogurt": "Greek yogurt", @@ -480,4 +480,4 @@ "zinc_cream": "Zinc cream", "zucchini": "Zucchini" } -} +} \ No newline at end of file diff --git a/backend/upgrade_default_items.py b/backend/upgrade_default_items.py index 9cb3eaf0..d1c1772b 100644 --- a/backend/upgrade_default_items.py +++ b/backend/upgrade_default_items.py @@ -1,11 +1,13 @@ from app import app +from app.errors import NotFoundRequest from app.models import Household from app.service.import_language import importLanguage if __name__ == "__main__": with app.app_context(): - for household in Household.all(): - if household.language: - importLanguage(household.id, - household.language, bulkSave=True) + for household in Household.query.filter(Household.language != None).all(): + try: + importLanguage(household.id, household.language, bulkSave=True) + except NotFoundRequest: + pass From 3bebcad4b391bb57f5c91301fee0459a22fad987 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 3 Sep 2023 14:29:41 +0200 Subject: [PATCH 386/496] chore: upgrade requirements --- backend/requirements.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 22daf0a1..850ddb19 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,8 +1,8 @@ -alembic==1.11.3 +alembic==1.12.0 appdirs==1.4.4 APScheduler==3.10.4 attrs==23.1.0 -autopep8==2.0.2 +autopep8==2.0.4 bcrypt==4.0.1 beautifulsoup4==4.12.2 bidict==0.22.1 @@ -27,7 +27,7 @@ Flask-Migrate==4.0.4 Flask-SocketIO==5.3.5 Flask-SQLAlchemy==3.0.5 fonttools==4.42.1 -gevent==23.7.0 +gevent==23.9.0 greenlet==2.0.2 h11==0.14.0 html-text==0.5.2 @@ -40,7 +40,7 @@ itsdangerous==2.1.2 Jinja2==3.1.2 joblib==1.3.2 jstyleson==0.0.2 -kiwisolver==1.4.4 +kiwisolver==1.4.5 lark==1.1.7 lxml==4.9.3 Mako==1.2.4 @@ -54,11 +54,11 @@ mypy-extensions==1.0.0 nltk==3.8.1 numpy==1.25.2 packaging==23.1 -pandas==2.0.3 +pandas==2.1.0 pathspec==0.11.2 Pillow==10.0.0 platformdirs==3.10.0 -pluggy==1.2.0 +pluggy==1.3.0 prometheus-client==0.17.1 prometheus-flask-exporter==0.22.4 psycopg2-binary==2.9.7 @@ -69,7 +69,7 @@ pyflakes==3.1.0 PyJWT==2.8.0 pyparsing==3.0.9 pyRdfa3==3.5.3 -pytest==7.4.0 +pytest==7.4.1 python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 @@ -79,14 +79,14 @@ pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==7.0.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.42.0 +recipe-scrapers==14.44.1 regex==2023.8.8 requests==2.31.0 scikit-learn==1.3.0 scipy==1.11.2 setuptools-scm==7.1.0 six==1.16.0 -soupsieve==2.4.1 +soupsieve==2.5 SQLAlchemy==2.0.20 threadpoolctl==3.2.0 toml==0.10.2 From 0632e54202efd1d49c1eab8b1e776d336dea53f6 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 4 Sep 2023 13:54:38 +0200 Subject: [PATCH 387/496] Prepare release 76 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 79a951c5..2e58da3e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 75 +BACKEND_VERSION = 76 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From cf955d2059f0716fba1928ec5aebe228efc31464 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 4 Sep 2023 15:01:58 +0200 Subject: [PATCH 388/496] fix: improve upgrade robustness --- backend/app/service/recalculate_blurhash.py | 28 ++++++++++++++++++++ backend/manage.py | 4 +++ backend/migrations/versions/ade9ad0be1a5_.py | 12 +++++---- 3 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 backend/app/service/recalculate_blurhash.py diff --git a/backend/app/service/recalculate_blurhash.py b/backend/app/service/recalculate_blurhash.py new file mode 100644 index 00000000..7f7d2a62 --- /dev/null +++ b/backend/app/service/recalculate_blurhash.py @@ -0,0 +1,28 @@ +import os +from app.config import UPLOAD_FOLDER, db, app +from app.models import File +import blurhash +from PIL import Image + + +def recalculateBlurhashes(updateAll: bool = False) -> int: + files = File.all() if updateAll else File.query.filter(File.blur_hash == None).all() + for file in files: + try: + with Image.open(os.path.join(UPLOAD_FOLDER, file.filename)) as image: + image.thumbnail((100, 100)) + file.blur_hash = blurhash.encode( + image, x_components=4, y_components=3) + db.session.add(file) + except FileNotFoundError: + db.session.delete(file) + except Exception: + pass + try: + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + app.logger.info(f"Updated {len(files)} files") + return len(files) diff --git a/backend/manage.py b/backend/manage.py index cf207d06..117b44b5 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -7,6 +7,7 @@ from app.jobs import jobs from app.models import User, File, Household, HouseholdMember from app.service.delete_unused import deleteEmptyHouseholds, deleteUnusedFiles +from app.service.recalculate_blurhash import recalculateBlurhashes def importFiles(): @@ -121,12 +122,15 @@ def manageFiles(): What next? 1. Import files 2. Delete unused files + 3. Generate missing blur-hashes (q) Go back""") selection = input("Your selection (q):") if selection == "1": importFiles() elif selection == "2": print(f"Deleted {deleteUnusedFiles()} unused files") + elif selection == "3": + print(f"Updated {recalculateBlurhashes()} files") else: return diff --git a/backend/migrations/versions/ade9ad0be1a5_.py b/backend/migrations/versions/ade9ad0be1a5_.py index 335f1864..671187ae 100644 --- a/backend/migrations/versions/ade9ad0be1a5_.py +++ b/backend/migrations/versions/ade9ad0be1a5_.py @@ -10,9 +10,9 @@ import blurhash from PIL import Image import sqlalchemy as sa -from sqlalchemy import orm +from sqlalchemy import inspect, orm -from app.config import UPLOAD_FOLDER +from app.config import UPLOAD_FOLDER, db DeclarativeBase = orm.declarative_base() @@ -32,12 +32,14 @@ class File(DeclarativeBase): def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('file', schema=None) as batch_op: - batch_op.add_column(sa.Column('blur_hash', sa.String(length=40), nullable=True)) + inspector = inspect(db.engine) + if not any(c['name'] == 'blur_hash' for c in inspector.get_columns('file')): + with op.batch_alter_table('file', schema=None) as batch_op: + batch_op.add_column(sa.Column('blur_hash', sa.String(length=40), nullable=True)) bind = op.get_bind() session = orm.Session(bind=bind) - for file in session.query(File).all(): + for file in session.query(File).filter(File.blur_hash == None).all(): try: with Image.open(os.path.join(UPLOAD_FOLDER, file.filename)) as image: image.thumbnail((100, 100)) From fac955a3cf57d24ed1d020552b4213a6ec587534 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 4 Sep 2023 15:20:22 +0200 Subject: [PATCH 389/496] fix: print household upgrade progress --- backend/upgrade_default_items.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/upgrade_default_items.py b/backend/upgrade_default_items.py index d1c1772b..4919619e 100644 --- a/backend/upgrade_default_items.py +++ b/backend/upgrade_default_items.py @@ -1,3 +1,4 @@ +from tqdm import tqdm from app import app from app.errors import NotFoundRequest from app.models import Household @@ -6,7 +7,7 @@ if __name__ == "__main__": with app.app_context(): - for household in Household.query.filter(Household.language != None).all(): + for household in tqdm(Household.query.filter(Household.language != None).all(), desc="Upgrading households..."): try: importLanguage(household.id, household.language, bulkSave=True) except NotFoundRequest: From c63a10adc1c1bca5694d76cac5f2b5793020164c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 7 Sep 2023 09:46:04 +0200 Subject: [PATCH 390/496] fix: Metrics group by endpoint --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 2e58da3e..fec74e2e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -105,7 +105,7 @@ basic_auth = BasicAuth(app) registry = CollectorRegistry() multiprocess.MultiProcessCollector(registry, path='/tmp') - metrics = PrometheusMetrics(app, registry=registry, path="/metrics/", metrics_decorator=basic_auth.required) + metrics = PrometheusMetrics(app, registry=registry, path="/metrics/", metrics_decorator=basic_auth.required, group_by='endpoint') metrics.info('app_info', 'Application info', version=BACKEND_VERSION) scheduler = APScheduler() From eda2f68c630fb1d57f040ef0df2518e9bca7bce8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 7 Sep 2023 09:46:14 +0200 Subject: [PATCH 391/496] chore: upgrade requirements --- backend/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 850ddb19..f7b39f6a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,7 +24,7 @@ Flask-BasicAuth==0.2.0 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.2 Flask-Migrate==4.0.4 -Flask-SocketIO==5.3.5 +Flask-SocketIO==5.3.6 Flask-SQLAlchemy==3.0.5 fonttools==4.42.1 gevent==23.9.0 @@ -73,13 +73,13 @@ pytest==7.4.1 python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 -python-engineio==4.6.1 -python-socketio==5.8.0 +python-engineio==4.7.0 +python-socketio==5.9.0 pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==7.0.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.44.1 +recipe-scrapers==14.45.0 regex==2023.8.8 requests==2.31.0 scikit-learn==1.3.0 From fc73eaea3d310a0cabbca021a9fd902ec00ec067 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 14 Sep 2023 12:40:45 +0200 Subject: [PATCH 392/496] chore: upgrade requirements --- backend/requirements.txt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index f7b39f6a..5aabda86 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -23,12 +23,12 @@ Flask-APScheduler==1.12.4 Flask-BasicAuth==0.2.0 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.2 -Flask-Migrate==4.0.4 +Flask-Migrate==4.0.5 Flask-SocketIO==5.3.6 -Flask-SQLAlchemy==3.0.5 +Flask-SQLAlchemy==3.1.1 fonttools==4.42.1 -gevent==23.9.0 -greenlet==2.0.2 +gevent==23.9.1 +greenlet==3.0.0rc3 h11==0.14.0 html-text==0.5.2 html5lib==1.1 @@ -46,7 +46,7 @@ lxml==4.9.3 Mako==1.2.4 MarkupSafe==2.1.3 marshmallow==3.20.1 -matplotlib==3.7.2 +matplotlib==3.7.3 mccabe==0.7.0 mf2py==1.1.3 mlxtend==0.22.0 @@ -67,13 +67,13 @@ pycodestyle==2.11.0 pycparser==2.21 pyflakes==3.1.0 PyJWT==2.8.0 -pyparsing==3.0.9 +pyparsing==3.1.1 pyRdfa3==3.5.3 -pytest==7.4.1 +pytest==7.4.2 python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 -python-engineio==4.7.0 +python-engineio==4.7.1 python-socketio==5.9.0 pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 From e7d1095b4272e78a6fb80ccca9d0aea76f924cf9 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 15 Sep 2023 07:58:14 +0200 Subject: [PATCH 393/496] fix: Rename item and error on update description (TomBursch/kitchenowl-backend#48) --- backend/app/controller/item/item_controller.py | 2 +- .../app/controller/shoppinglist/shoppinglist_controller.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index ede138fb..bb17b615 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -81,7 +81,7 @@ def updateItem(args, id): item.icon = args['icon'] if 'name' in args and args['name'] != item.name: newName: str = args['name'].strip() - if not Item.search_name(newName, item.household_id): + if not Item.find_by_name(item.household_id, newName): item.name = newName item.save() diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 213eba26..8fd8789b 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -74,8 +74,8 @@ def updateItemDescription(args, id, item_id): con.save() socketio.emit("shoppinglist_item:add", { "item": con.obj_to_item_dict(), - "shoppinglist": shoppinglist.obj_to_dict() - }, to=shoppinglist.household_id) + "shoppinglist": con.shoppinglist.obj_to_dict() + }, to=con.shoppinglist.household_id) return jsonify(con.obj_to_item_dict()) From 7ff924ba9701eb97658b3279842a26b705dd7919 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 15 Sep 2023 08:41:27 +0200 Subject: [PATCH 394/496] Translated using Weblate (Czech) (TomBursch/kitchenowl-backend#49) Currently translated at 3.5% (17 of 477 strings) Translated using Weblate (Czech) Currently translated at 2.5% (12 of 477 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (477 of 477 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/cs/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nl/ Translation: KitchenOwl/Default Items Co-authored-by: Tom Bursch --- backend/templates/l10n/cs.json | 25 ++++++++++++++++++++++++- backend/templates/l10n/nl.json | 20 ++++++++++---------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/backend/templates/l10n/cs.json b/backend/templates/l10n/cs.json index 0967ef42..633db086 100644 --- a/backend/templates/l10n/cs.json +++ b/backend/templates/l10n/cs.json @@ -1 +1,24 @@ -{} +{ + "categories": { + "bread": "🍞 Chlebové zboží", + "canned": "🥫 Konzervované jídlo", + "dairy": "🥛 Mlékárna", + "drinks": "🍹 Nápoje", + "freezer": "❄️ Mrazák", + "fruits_vegetables": "🥬 Ovoce a zelenina", + "grain": "🥟 Obilné výrobky", + "hygiene": "🚽 Hygiena", + "refrigerated": "💧 Chlazené", + "snacks": "🥜 Občerstvení" + }, + "items": { + "apple": "Jablko", + "aspirin": "Aspirin", + "avocado": "Avokádo", + "bacon": "Slanina", + "baguette": "Bageta", + "bananas": "Banány", + "beans": "Fazole", + "beer": "Pivo" + } +} diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json index cbe72583..9ba0f6b4 100644 --- a/backend/templates/l10n/nl.json +++ b/backend/templates/l10n/nl.json @@ -1,15 +1,15 @@ { "categories": { - "bread": "Brood artikelen", - "canned": "Ingeblikt eten", - "dairy": "Zuivel", - "drinks": "Drinken", - "freezer": "Vriezer", - "fruits_vegetables": "Fruit en Groenten", - "grain": "Graanproducten", - "hygiene": "Hygiëne", - "refrigerated": "Gekoeld", - "snacks": "Snacks" + "bread": "🍞 Brood artikelen", + "canned": "🥫 Ingeblikt eten", + "dairy": "🥛 Zuivel", + "drinks": "🍹 Drinken", + "freezer": "❄️ Vriezer", + "fruits_vegetables": "🥬 Fruit en Groenten", + "grain": "🥟 Graanproducten", + "hygiene": "🚽 Hygiëne", + "refrigerated": "💧 Gekoeld", + "snacks": "🥜 Snacks" }, "items": { "aioli": "Aioli", From a67386b882e7ac43bf2ebc385bcb56db483793ff Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 15 Sep 2023 08:44:10 +0200 Subject: [PATCH 395/496] Prepare release 77 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index fec74e2e..e390214f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 76 +BACKEND_VERSION = 77 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From b3f4349c90f39da5b609d87af34562bb575407a7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 13:51:40 +0200 Subject: [PATCH 396/496] chore: upgrade requirements --- backend/requirements.txt | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5aabda86..bb90a4e2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,23 +10,23 @@ black==23.1a1 blinker==1.6.2 blurhash-python==1.2.1 certifi==2023.7.22 -cffi==1.15.1 -charset-normalizer==3.2.0 +cffi==1.16.0 +charset-normalizer==3.3.0 click==8.1.7 -contourpy==1.1.0 -cycler==0.11.0 +contourpy==1.1.1 +cycler==0.12.0 dbscan1d==0.2.2 extruct==0.16.0 flake8==6.1.0 Flask==2.3.3 -Flask-APScheduler==1.12.4 +Flask-APScheduler==1.13.0 Flask-BasicAuth==0.2.0 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.2 Flask-Migrate==4.0.5 Flask-SocketIO==5.3.6 Flask-SQLAlchemy==3.1.1 -fonttools==4.42.1 +fonttools==4.43.0 gevent==23.9.1 greenlet==3.0.0rc3 h11==0.14.0 @@ -46,22 +46,22 @@ lxml==4.9.3 Mako==1.2.4 MarkupSafe==2.1.3 marshmallow==3.20.1 -matplotlib==3.7.3 +matplotlib==3.8.0 mccabe==0.7.0 mf2py==1.1.3 -mlxtend==0.22.0 +mlxtend==0.23.0 mypy-extensions==1.0.0 nltk==3.8.1 -numpy==1.25.2 -packaging==23.1 -pandas==2.1.0 +numpy==1.26.0 +packaging==23.2 +pandas==2.1.1 pathspec==0.11.2 -Pillow==10.0.0 -platformdirs==3.10.0 +Pillow==10.0.1 +platformdirs==3.11.0 pluggy==1.3.0 prometheus-client==0.17.1 prometheus-flask-exporter==0.22.4 -psycopg2-binary==2.9.7 +psycopg2-binary==2.9.8 py==1.11.0 pycodestyle==2.11.0 pycparser==2.21 @@ -79,15 +79,15 @@ pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==7.0.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.45.0 +recipe-scrapers==14.49.4 regex==2023.8.8 requests==2.31.0 -scikit-learn==1.3.0 -scipy==1.11.2 -setuptools-scm==7.1.0 +scikit-learn==1.3.1 +scipy==1.11.3 +setuptools-scm==8.0.4 six==1.16.0 soupsieve==2.5 -SQLAlchemy==2.0.20 +SQLAlchemy==2.0.21 threadpoolctl==3.2.0 toml==0.10.2 tomli==2.0.1 @@ -95,17 +95,17 @@ tqdm==4.66.1 typed-ast==1.5.5 types-beautifulsoup4==4.12.0.6 types-html5lib==1.1.11.15 -types-requests==2.31.0.2 +types-requests==2.31.0.7 types-urllib3==1.26.25.14 -typing_extensions==4.7.1 +typing_extensions==4.8.0 tzdata==2023.3 tzlocal==5.0.1 -urllib3==2.0.4 +urllib3==2.0.6 uWSGI==2.0.22 uwsgi-tools==1.1.1 w3lib==2.1.2 webencodings==0.5.1 -Werkzeug==2.3.7 +Werkzeug==3.0.0 wsproto==1.2.0 zope.event==5.0 zope.interface==6.0 From 610e18c83b9ca9cb3a752e116a7d496e92d021f8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 14:28:36 +0200 Subject: [PATCH 397/496] fix: PostgreSQL Migration on empty DB (TomBursch/kitchenowl-backend#52) --- backend/migrations/versions/ade9ad0be1a5_.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/migrations/versions/ade9ad0be1a5_.py b/backend/migrations/versions/ade9ad0be1a5_.py index 671187ae..6705f2ee 100644 --- a/backend/migrations/versions/ade9ad0be1a5_.py +++ b/backend/migrations/versions/ade9ad0be1a5_.py @@ -33,7 +33,9 @@ class File(DeclarativeBase): def upgrade(): # ### commands auto generated by Alembic - please adjust! ### inspector = inspect(db.engine) - if not any(c['name'] == 'blur_hash' for c in inspector.get_columns('file')): + # workaround since the inspector can only return existing tables which they don't if upgrade is run on an empty DB + # Only add the row if it does not exists (e.g. if the migration/hash calculation failed and is restarted) + if not 'file' in inspector.get_table_names() or not any(c['name'] == 'blur_hash' for c in inspector.get_columns('file')): with op.batch_alter_table('file', schema=None) as batch_op: batch_op.add_column(sa.Column('blur_hash', sa.String(length=40), nullable=True)) From 5c0d1e779a5e75ca2ede211a40512b6262901b76 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 18:08:33 +0200 Subject: [PATCH 398/496] feat: improve PostgreSQL item search --- backend/app/models/item.py | 9 +++-- backend/migrations/versions/dedd014b6a59_.py | 38 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/versions/dedd014b6a59_.py diff --git a/backend/app/models/item.py b/backend/app/models/item.py index c1219f7e..369dfd03 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -1,5 +1,7 @@ from __future__ import annotations from typing import Self + +from sqlalchemy import func from app import db from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin from app.models.category import Category @@ -16,7 +18,7 @@ class Item(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): default = db.Column(db.Boolean, default=False) default_key = db.Column(db.String(128)) household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False) + 'household.id'), nullable=False, index=True) household = db.relationship("Household", uselist=False) category = db.relationship("Category") @@ -106,7 +108,6 @@ def merge(self, other: Self) -> None: history.item_id = self.id db.session.add(history) - try: db.session.add(self) db.session.commit() @@ -139,6 +140,10 @@ def find_by_id(cls, id) -> Self: @classmethod def search_name(cls, name: str, household_id: int) -> list[Self]: item_count = 11 + print(db.engine.name) + if "postgresql" in db.engine.name: + return cls.query.filter(cls.household_id == household_id).order_by(func.levenshtein(func.lower(cls.name), name.lower()), cls.support.desc()).limit(item_count) + found = [] # name is a regex diff --git a/backend/migrations/versions/dedd014b6a59_.py b/backend/migrations/versions/dedd014b6a59_.py new file mode 100644 index 00000000..de995268 --- /dev/null +++ b/backend/migrations/versions/dedd014b6a59_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: dedd014b6a59 +Revises: ade9ad0be1a5 +Create Date: 2023-10-03 17:33:03.605572 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'dedd014b6a59' +down_revision = 'ade9ad0be1a5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + bind = op.get_bind() + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_item_household_id'), ['household_id'], unique=False) + if "postgresql" in bind.engine.name: + op.execute("CREATE EXTENSION fuzzystrmatch") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + bind = op.get_bind() + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_item_household_id')) + if "postgresql" in bind.engine.name: + op.execute("DROP EXTENSION fuzzystrmatch") + + # ### end Alembic commands ### From 305244a6fdad28c09af6f4549289fc5bf4ead7be Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 18:08:59 +0200 Subject: [PATCH 399/496] feat: Create user with management script --- backend/manage.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/manage.py b/backend/manage.py index 117b44b5..f2461057 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -55,21 +55,26 @@ def manageUsers(): print(""" What next? 1. List all users - 2. Update user - 3. Delete user + 2. Create user + 3. Update user + 4. Delete user (q) Go back""") selection = input("Your selection (q):") if selection == "1": for u in User.all(): print(f"@{u.username} ({u.email}): {u.name} (server admin: {u.admin})") elif selection == "2": + username = input("Enter the username:") + password = input("Enter the password:") + User.create(username, password, username) + elif selection == "3": username = input("Enter the username:") user = User.find_by_username(username) if not user: print("No user found with that username") else: updateUser(user) - elif selection == "3": + elif selection == "4": username = input("Enter the username:") user = User.find_by_username(username) if not user: From d14692bfe3a97e41b3d35d0a11d660214cdad0ca Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 18:14:34 +0200 Subject: [PATCH 400/496] Allow skip of default item upgrade --- backend/entrypoint.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index e017d772..ce9fa6e7 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,5 +1,7 @@ #!/bin/sh mkdir -p $STORAGE_PATH/upload flask db upgrade -python upgrade_default_items.py +if [ "${SKIP_UPGRADE_DEFAULT_ITEMS,,}" != "true" ]; then + python upgrade_default_items.py +fi uwsgi "$@" \ No newline at end of file From 99690fb5d8801f1dc542777f17e7164bacea26ee Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Tue, 3 Oct 2023 19:14:51 +0200 Subject: [PATCH 401/496] Translated using Weblate (Czech) (TomBursch/kitchenowl-backend#50) Currently translated at 9.0% (43 of 477 strings) Translated using Weblate (Swedish) Currently translated at 44.4% (212 of 477 strings) Added translation using Weblate (Swedish) Translated using Weblate (Hungarian) Currently translated at 100.0% (477 of 477 strings) Added translation using Weblate (Hungarian) Translated using Weblate (Dutch) Currently translated at 100.0% (477 of 477 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/cs/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/hu/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nl/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/sv/ Translation: KitchenOwl/Default Items Co-authored-by: Gergely Jankovics Co-authored-by: Leon Co-authored-by: Patrik W Co-authored-by: Tom Bursch --- backend/templates/l10n/cs.json | 30 +- backend/templates/l10n/hu.json | 483 +++++++++++++++++++++++++++++++++ backend/templates/l10n/nl.json | 2 +- backend/templates/l10n/sv.json | 218 +++++++++++++++ 4 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 backend/templates/l10n/hu.json create mode 100644 backend/templates/l10n/sv.json diff --git a/backend/templates/l10n/cs.json b/backend/templates/l10n/cs.json index 633db086..fc1eddbc 100644 --- a/backend/templates/l10n/cs.json +++ b/backend/templates/l10n/cs.json @@ -7,18 +7,44 @@ "freezer": "❄️ Mrazák", "fruits_vegetables": "🥬 Ovoce a zelenina", "grain": "🥟 Obilné výrobky", - "hygiene": "🚽 Hygiena", + "hygiene": "🚽 Drogerie", "refrigerated": "💧 Chlazené", "snacks": "🥜 Občerstvení" }, "items": { "apple": "Jablko", + "apricots": "Meruňky", + "arugula": "Rukola", + "asian_egg_noodles": "Asijské vaječné nudle", + "asian_noodles": "Asijské nudle", + "asparagus": "Chřest", "aspirin": "Aspirin", "avocado": "Avokádo", + "baby_spinach": "Špenát", "bacon": "Slanina", "baguette": "Bageta", + "baking_cocoa": "Kakao", + "baking_mix": "Směs na pečení", + "baking_paper": "Pečící papír", + "baking_powder": "Prášek do pečiva", + "baking_soda": "Jedlá soda", + "baking_yeast": "Kvasnice", + "balsamic_vinegar": "Balsamicový ocet", "bananas": "Banány", + "basil": "Bazalka", + "basmati_rice": "Basmati rýže", + "batteries": "Baterie", + "bay_leaf": "Bobkový list", "beans": "Fazole", - "beer": "Pivo" + "beer": "Pivo", + "beetroot": "Červená řepa", + "birthday_card": "Narozeninové přání", + "black_beans": "Černé fazole", + "bread": "Chléb", + "breadcrumbs": "Chlebové drobky", + "broccoli": "Brokolice", + "brown_sugar": "Hnědý cukr", + "brussels_sprouts": "Růžičková kapusta", + "burger_buns": "Hamburgerové bulky" } } diff --git a/backend/templates/l10n/hu.json b/backend/templates/l10n/hu.json new file mode 100644 index 00000000..7b6c964c --- /dev/null +++ b/backend/templates/l10n/hu.json @@ -0,0 +1,483 @@ +{ + "categories": { + "bread": "🍞 Kenyér félék", + "canned": "🥫Konzervek", + "dairy": "🥛 Tejtermékek", + "drinks": "🍹 Italok", + "freezer": "❄️ Fagyasztott termékek", + "fruits_vegetables": "🥬 Zöldségek és gyümölcsök", + "grain": "🥟 Szemes termékek", + "hygiene": "🚽 Higéniás termékek", + "refrigerated": "💧 Hűtött termékek", + "snacks": "🥜 Nasi" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Alma", + "apple_pulp": "Alma szósz", + "applesauce": "Almaszósz", + "apricots": "Sárgabarack", + "apérol": "Aperol", + "arugula": "Rukkola", + "asian_egg_noodles": "Ázsiai tojásos tészta", + "asian_noodles": "Ázsiai tészta", + "asparagus": "Spárga", + "aspirin": "Aszpirin", + "avocado": "Avokádó", + "baby_potatoes": "Újkrumpli", + "baby_spinach": "Bébi spenót", + "bacon": "Szalonna", + "baguette": "Bagett", + "bakefish": "Hal", + "baking_cocoa": "Kakaópor", + "baking_mix": "Sütőpor", + "baking_paper": "Sütőpapír", + "baking_powder": "Sütő por", + "baking_soda": "Szódabikarbon", + "baking_yeast": "Élesztő", + "balsamic_vinegar": "Balzsam ecet", + "bananas": "Banán", + "basil": "Bazsalikom", + "basmati_rice": "Basmati rizs", + "bathroom_cleaner": "Fürdőszoba tisztító", + "batteries": "Elem", + "bay_leaf": "Babérlevél", + "beans": "Bab", + "beer": "Sör", + "beet": "Cékla", + "beetroot": "Cékla", + "birthday_card": "Szülinapi kártya", + "black_beans": "Fekete bab", + "bockwurst": "Virsli", + "bodywash": "Tusfürdő", + "bread": "Kenyér", + "breadcrumbs": "Kenyérmorzsa", + "broccoli": "Brokkoli", + "brown_sugar": "Barna cukor", + "brussels_sprouts": "Kelbimbó", + "buffalo_mozzarella": "Bivaly mozzarella", + "buko": "Buko", + "buns": "Zsemle", + "burger_buns": "Hamburger zsemle", + "burger_patties": "Hamburger hús", + "burger_sauces": "Hamburger szósz", + "butter": "Vaj", + "butter_cookies": "Vajas süti", + "button_cells": "Gombok", + "börek_cheese": "Börek sajt", + "cake": "Torta", + "cake_icing": "Torta krém", + "cane_sugar": "Nádcukor", + "cannelloni": "Cannelloni tészta", + "canola_oil": "Canola olaj", + "cardamom": "Kardamom", + "carrots": "Répa", + "cashews": "Kesudió", + "cat_treats": "Macska nasi", + "cauliflower": "Karfiol", + "celeriac": "Zellerszár", + "celery": "Zeller", + "cereal_bar": "Müzli szelet", + "cheddar": "Cheddar sajt", + "cheese": "Sajt", + "cherry_tomatoes": "Koktél paradicsom", + "chickpeas": "Csicseri borsó", + "chicory": "Cikória", + "chili_oil": "Csili olaj", + "chili_pepper": "Csili paprika", + "chips": "Csipsz", + "chives": "Metélőhagyma", + "chocolate": "Csokoládé", + "chocolate_chips": "Csokis süti", + "chopped_tomatoes": "Apritott paradicsom", + "chunky_tomatoes": "Darabos paradicsom", + "ciabatta": "Ciabatta", + "cider_vinegar": "Almaecet", + "cilantro": "Koriander", + "cinnamon": "Fahéj", + "cinnamon_stick": "Fahéj rúd", + "cocktail_sauce": "Koktél szósz", + "cocktail_tomatoes": "Koktélparadicsom", + "coconut_flakes": "Kokusz reszelék", + "coconut_milk": "Kókusz tej", + "coconut_oil": "Kókusz olaj", + "colorful_sprinkles": "Szines cukorszórás", + "concealer": "Alapozó", + "cookies": "Sütik", + "coriander": "Koriander mag", + "corn": "Kukorica", + "cornflakes": "Kukorica pehely", + "cornstarch": "Kukorica keményítő", + "cornys": "Cornys", + "corriander": "Korriander", + "cough_drops": "Köhögés elleni cukor", + "couscous": "Kuszkusz", + "covid_rapid_test": "COVID gyorsteszt", + "cow's_milk": "Tehéntej", + "cream": "Tejszín", + "cream_cheese": "Krémsajt", + "creamed_spinach": "Spenót főzelék", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Palacsinta lap", + "crispbread": "Kétszersült", + "cucumber": "Uborka", + "cumin": "Kömény", + "curd": "Vaniliasodó", + "curry_paste": "Curry krém", + "curry_powder": "Curry por", + "curry_sauce": "Curry szósz", + "dates": "Datolya", + "dental_floss": "Fogselyem", + "deo": "Dezodor", + "deodorant": "Dezodor", + "detergent": "Mosószer", + "detergent_sheets": "Tisztító lapok", + "diarrhea_remedy": "Hasmenés elleni gyógyszer", + "dill": "Petrezselyem", + "dishwasher_salt": "Mosógatógép só", + "dishwasher_tabs": "Mosógatógép tabbleta", + "disinfection_spray": "Fertötlenítő", + "dried_tomatoes": "Szárított paradicsom", + "edamame": "Edamame bab", + "egg_salad": "Tojás saláta", + "egg_yolk": "Tojás sárgája", + "eggplant": "Padlizsán", + "eggs": "Tojások", + "enoki_mushrooms": "Enoki gomba", + "eyebrow_gel": "Szemhélyfesték", + "falafel": "Falafel", + "falafel_powder": "Falafel por", + "fanta": "Fanta", + "feta": "Feta sajt", + "ffp2": "FFP2 maszk", + "fish_sticks": "Halrudacskák", + "flour": "Liszt", + "flushing": "Lefolyótisztitó", + "fresh_chili_pepper": "Friss chili paprika", + "frozen_berries": "Fagyasztott bogyók", + "frozen_fruit": "Fagyasztott gyümölcsök", + "frozen_pizza": "Fagyasztott pizza", + "frozen_spinach": "Fagyasztott spenót", + "funeral_card": "Temetésre kártya", + "garam_masala": "GaramMAsala", + "garbage_bag": "Szemetes zsák", + "garlic": "Fokhagyma", + "garlic_dip": "Fokhagyma szósz", + "garlic_granules": "Fokhagyma granulátum", + "gherkins": "Gherkin", + "ginger": "Gyömbér", + "glass_noodles": "Üveg tészta", + "gluten": "Glutén", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola saláta", + "gouda": "Gouda sajt", + "granola": "Granola müzli", + "granola_bar": "Granola szelet", + "grapes": "Szöllő", + "greek_yogurt": "Görög joghurt", + "green_asparagus": "Zöld spárga", + "green_chili": "Zöld csilli", + "green_pesto": "Zöld pesto", + "hair_gel": "Hajzselé", + "hair_ties": "Hajgumi", + "hair_wax": "Wax", + "hand_soap": "Szappan", + "handkerchief_box": "Zsebkendő", + "handkerchiefs": "Kéztörlő", + "hard_cheese": "Kemény sajt", + "haribo": "Gumicukor", + "harissa": "Harissa paszta", + "hazelnuts": "Törökmogyoró", + "head_of_lettuce": "Saláta fej", + "herb_baguettes": "Fűszeres bagett", + "herb_cream_cheese": "Fűszeres krémsajt", + "honey": "Méz", + "honey_wafers": "Mézes holland szelet", + "hot_dog_bun": "Hotdog kifli", + "ice_cream": "Jégkrém", + "ice_cube": "Jég", + "iceberg_lettuce": "Jégsaláta", + "iced_tea": "Jeges tea", + "instant_soups": "Instant leves", + "jam": "Lekvár", + "jasmine_rice": "Jázmin rizs", + "katjes": "Katjes", + "ketchup": "Kecsap", + "kidney_beans": "Vese Bab", + "kitchen_roll": "Konyhai kéztörlő", + "kitchen_towels": "Konyha ruha", + "kohlrabi": "Kohlrabi", + "lasagna": "Lasagna tészta", + "lasagna_noodles": "Lasagna tésztaalap", + "lasagna_plates": "Lasagna tészta", + "leaf_spinach": "Spenót levél", + "leek": "Póréhagyma", + "lemon": "Citrom", + "lemon_curd": "Cirtom héj", + "lemon_juice": "Citromlé", + "lemonade": "Limonádé", + "lemongrass": "Citromfű", + "lentil_stew": "Lencsefőzelék", + "lentils": "Lencse", + "lentils_red": "Vöröslencse", + "lettuce": "Saláta", + "lillet": "Szamóca", + "lime": "Lime", + "linguine": "Hosszúmetélt", + "lip_care": "Ajakbalzsam", + "low-fat_curd_cheese": "Zsírsazegény túró", + "maggi": "Maggi kocka", + "magnesium": "Magnézium", + "mango": "Mangó", + "maple_syrup": "Juharszirup", + "margarine": "Margarin", + "marjoram": "Majoranna", + "marshmallows": "Pillecukor", + "mascara": "Smink", + "mascarpone": "Mascarpone", + "mask": "Éjjeli maszk", + "mayonnaise": "Majonéz", + "meat_substitute_product": "Húshelyetesítő", + "microfiber_cloth": "Mikroszálas törlőkendő", + "milk": "Tej", + "mint": "Menta", + "mint_candy": "Mentás cukor", + "miso_paste": "Miso paszta", + "mixed_vegetables": "Vegyes zöldség", + "mochis": "Mocsi", + "mold_remover": "Penészeltávolító", + "mountain_cheese": "Hegyi sajt", + "mouth_wash": "Szájvíz", + "mozzarella": "Mozzarella sajt", + "muesli": "Müzli", + "muesli_bar": "Müzli szelet", + "mulled_wine": "Kannás bor", + "mushrooms": "Gomba", + "mustard": "Mustár", + "nail_file": "Köröm reszelő", + "neutral_oil": "Napfraforgó olaj", + "nori_sheets": "Nori lapok", + "nutmeg": "Szerecsendió", + "oat_milk": "Zabtej", + "oatmeal": "Zabkása", + "oatmeal_cookies": "Zabos süti", + "oatsome": "Zabpehely", + "obatzda": "Obatzda", + "oil": "Olaj", + "olive_oil": "Oliva olaj", + "olives": "Oliva bogyó", + "onion": "Hagyma", + "onion_powder": "Hagyma por", + "orange_juice": "Narancslé", + "oranges": "Narancs", + "oregano": "Oregano", + "organic_lemon": "Bio citrom", + "organic_waste_bags": "Lebomló szemeteszsák", + "pak_choi": "Pak Choi", + "pantyhose": "harisnya", + "paprika": "Paprika", + "paprika_seasoning": "Piros paprika", + "pardina_lentils_dried": "Szárított lencse", + "parmesan": "Parmezán sajt", + "parsley": "Petrezselyem", + "pasta": "Tészta", + "peach": "Barack", + "peanut_butter": "Földimogyoró krém", + "peanut_flips": "Mogyorós Flip", + "peanut_oil": "Mogyoró olaj", + "peanuts": "Földimogyoró", + "pears": "Körte", + "peas": "Borsó", + "penne": "Penne tészta", + "pepper": "Bors", + "pepper_mill": "Bors szóró", + "peppers": "Paprikák", + "persian_rice": "Perzsa rizs", + "pesto": "Pesto", + "pilsner": "Világos sör", + "pine_nuts": "Fenyőmag", + "pineapple": "Ananász", + "pita_bag": "Pita", + "pita_bread": "Pita kenyér", + "pizza": "Pizza", + "pizza_dough": "Pizza tészta", + "plant_magarine": "Növényi margarin", + "plant_oil": "növényi olaj", + "plaster": "Sebtapasz", + "pointed_peppers": "Hegyes erős paprika", + "porcini_mushrooms": "Porcini gomba", + "potato_dumpling_dough": "Krumpligombóc tészta", + "potato_wedges": "Sültkrumpli", + "potatoes": "Krumpli", + "potting_soil": "Ültető föld", + "powder": "Por", + "powdered_sugar": "Porcukor", + "processed_cheese": "Trapista sajt", + "prosecco": "Pezsgő bor", + "puff_pastry": "Leveles tészta", + "pumpkin": "Tök", + "pumpkin_seeds": "Tökmag", + "quark": "Quark", + "quinoa": "Quinoa", + "radicchio": "Cikória saláta", + "radish": "Retek", + "ramen": "Ramen", + "rapeseed_oil": "Szölömag olaj", + "raspberries": "Málna", + "raspberry_syrup": "Málna szirup", + "razor_blades": "Borotva penge", + "red_bull": "REdBull", + "red_chili": "Vörös Csili", + "red_curry_paste": "Vörös curry paszta", + "red_lentils": "Vörös lencse", + "red_onions": "Vöröshagyma", + "red_pesto": "Vörös pesto", + "red_wine": "Vörös bor", + "red_wine_vinegar": "Vörösbor ecet", + "rhubarb": "Rebarbara", + "ribbon_noodles": "Szalag tészta", + "rice": "Rizs", + "rice_cakes": "Rizs süti", + "rice_paper": "Rizspapir", + "rice_ribbon_noodles": "Rizs szalag tészta", + "rice_vinegar": "Rizs ecet", + "ricotta": "Ricotta sajt", + "rinse_tabs": "Tisaztitó tabletta", + "rinsing_agent": "Tisztitószer", + "risotto_rice": "Rizottó rizs", + "rocket": "Rakéta", + "roll": "Tekercs", + "rosemary": "Rozmaring", + "saffron_threads": "Sáfrányszál", + "sage": "Zsálya", + "saitan_powder": "Saitan por", + "salad_mix": "Saláta keverék", + "salad_seeds_mix": "Saláta mag keverék", + "salt": "Só", + "salt_mill": "Só malom", + "sambal_oelek": "Sambal oelek fűszerpástétom", + "sauce": "Szósz", + "sausage": "Kolbász", + "sausages": "Kolbászok", + "savoy_cabbage": "Savanyú káposzta", + "scallion": "Metélő hagyma", + "scattered_cheese": "Sajtkrém", + "schlemmerfilet": "Halfilé", + "schupfnudeln": "Nudli", + "semolina_porridge": "Semolina kása", + "sesame": "Szezámmag", + "sesame_oil": "Szezámmag olaj", + "shallot": "Sonka hagyma", + "shampoo": "Sampon", + "shawarma_spice": "Shawarma fűszer", + "shiitake_mushroom": "Shiitake gomba", + "shoe_insoles": "Cipőfűző", + "shower_gel": "Tusfürdő", + "shredded_cheese": "Reszelt sajt", + "sieved_tomatoes": "Szárított paradicsom", + "sliced_cheese": "Szeletelt sajt", + "smoked_paprika": "Füstölt pirospaprika", + "smoked_tofu": "Füstölt tofu", + "snacks": "Nasik", + "soap": "Szappan", + "soba_noodles": "Soba tészta", + "soft_drinks": "Üditő", + "softdrinks": "Szénsavas italok", + "soup_vegetables": "Leves zöldség", + "sour_cream": "Tejföl", + "sour_cucumbers": "Savanyú uborka", + "soy_cream": "Szója krém", + "soy_hack": "Szója hack", + "soy_sauce": "Szója szósz", + "soy_shred": "Szója darab", + "spaetzle": "Nokedli", + "spaghetti": "Spagetti", + "sparkling_water": "Ásvány víz", + "spelt": "Köles", + "spinach": "Spenót", + "sponge_cloth": "Mosogató rongy", + "sponge_fingers": "Babapiskóta", + "sponge_wipes": "Törlőkendő", + "sponges": "Szivacsok", + "spreading_cream": "Vajkrém", + "spring_onions": "Újhagyma", + "sprite": "sprite", + "sprouts": "Csírák", + "sriracha": "Sriracha szósz", + "strained_tomatoes": "Passzírozott paradicsom", + "strawberries": "Eper", + "sugar": "Cukor", + "summer_roll_paper": "Tavaszi tekercs", + "sunflower_oil": "Napraforgó olaj", + "sunflower_seeds": "Napraforgó mag", + "sunscreen": "Napvédő krém", + "sushi_rice": "Szusi rizs", + "swabian_ravioli": "Töltött tészta", + "sweet_chili_sauce": "Édes Csili szósz", + "sweet_potato": "Édesburgonya", + "sweet_potatoes": "Édesburgonyák", + "sweets": "Édességek", + "table_salt": "Asztali só", + "tagliatelle": "Tagliatelle tészta", + "tahini": "Tahini krém", + "tangerines": "Mandarin", + "tape": "Szalag", + "tapioca_flour": "Tápióka liszt", + "tea": "TEa", + "teriyaki_sauce": "Teriyaki szósz", + "thyme": "Kakukkfű", + "toast": "Piritós", + "tofu": "Tofu", + "toilet_paper": "WC papír", + "tomato_juice": "Paradicsomlé", + "tomato_paste": "Paradicsomkrém", + "tomato_sauce": "Paradicsom szósz", + "tomatoes": "Paradicsomok", + "tonic_water": "Tonic", + "toothpaste": "Fogkrém", + "tortellini": "Tortellini tészta", + "tortilla_chips": "Tortilla csipsz", + "tuna": "Tonhal", + "turmeric": "Kurkuma", + "tzatziki": "Tzatziki öntet", + "udon_noodles": "Udon tészta", + "uht_milk": "UHT tej", + "vanilla_sugar": "Vaniliás cukor", + "vegetable_bouillon_cube": "Zöldséges leveskocka", + "vegetable_broth": "Zöldség alaplé", + "vegetable_oil": "Zöldség olaj", + "vegetable_onion": "Zöld hagyma", + "vegetables": "Zöldségek", + "vegetarian_cold_cuts": "Vegetáriánus hideg vágás", + "vinegar": "Ecet", + "vitamin_tablets": "Vitaminok", + "vodka": "Vodka", + "washing_gel": "Mosógél", + "washing_powder": "Mosószer", + "water": "Víz", + "water_ice": "Vízjég", + "watermelon": "Dinnye", + "wc_cleaner": "WC tisztító", + "wheat_flour": "Fehér liszt", + "whipped_cream": "Habtejszín", + "white_wine": "Fehérbor", + "white_wine_vinegar": "Fehérbor ecet", + "whole_canned_tomatoes": "Egész koncerv paradicsom", + "wild_berries": "Erdei gyümölcsök", + "wild_rice": "Vad rizs", + "wildberry_lillet": "Ribizli", + "worcester_sauce": "Worchester szósz", + "wrapping_paper": "Csomagoló papír", + "wraps": "Tortilla lapok", + "yeast": "Élesztő", + "yeast_flakes": "Szárított élesztő", + "yoghurt": "Joghurt", + "yogurt": "Joghurt", + "yum_yum": "Yum Yum", + "zewa": "Zewa", + "zinc_cream": "Cink krém", + "zucchini": "Cukkini" + } +} diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json index 9ba0f6b4..2f082445 100644 --- a/backend/templates/l10n/nl.json +++ b/backend/templates/l10n/nl.json @@ -205,7 +205,7 @@ "jasmine_rice": "Jasmijnrijst", "katjes": "Katjes", "ketchup": "Ketchup", - "kidney_beans": "Kidneyboon", + "kidney_beans": "Kidneybonen", "kitchen_roll": "Keukenrol", "kitchen_towels": "Keukenhanddoeken", "kohlrabi": "Koolrabi", diff --git a/backend/templates/l10n/sv.json b/backend/templates/l10n/sv.json new file mode 100644 index 00000000..61132ac8 --- /dev/null +++ b/backend/templates/l10n/sv.json @@ -0,0 +1,218 @@ +{ + "categories": { + "bread": "🍞 Brödvaror", + "canned": "🥫 Konserverad Mat", + "dairy": "🥛 Mjölkprodukt", + "drinks": "🍹 Drinkar", + "freezer": "❄️ Frys", + "fruits_vegetables": "🥬 Frukt och görnsaker", + "grain": "🥟 Spannmåls Produkter", + "hygiene": "🚽 Hygien", + "refrigerated": "💧 Kylvaror", + "snacks": "🥜 Snacks" + }, + "items": { + "aioli": "Aioli", + "amaretto": "Amaretto", + "apple": "Äpple", + "apple_pulp": "Äpplekött", + "applesauce": "Äppelmos", + "apricots": "Aprikos", + "apérol": "Apérol", + "arugula": "Ruccola", + "asian_egg_noodles": "Äggnudlar från Asien", + "asian_noodles": "Nudlar från Asien", + "asparagus": "Sparris", + "aspirin": "Aspirin", + "avocado": "Avokado", + "baby_potatoes": "Trillingar", + "baby_spinach": "Baby spenat", + "bacon": "Bacon", + "baguette": "Baguette", + "bakefish": "Ugnsbakad fisk", + "baking_cocoa": "Bak kakao", + "baking_mix": "Bakmix", + "baking_paper": "Bakplåtspapper", + "baking_powder": "Bakpulver", + "baking_soda": "Bakpulver", + "baking_yeast": "Bakjäst", + "balsamic_vinegar": "Balsam vinäger", + "bananas": "Bananer", + "basil": "Basilika", + "basmati_rice": "Basmati ris", + "bathroom_cleaner": "Badrumsrengöring", + "batteries": "Batterier", + "bay_leaf": "Lagerblad", + "beans": "Bönor", + "beer": "Öl", + "beet": "Beta", + "beetroot": "Rödbeta", + "birthday_card": "Födelsedagskort", + "black_beans": "Svarta bönor", + "bockwurst": "Bockwurst", + "bodywash": "Bodywash", + "bread": "Bröd", + "breadcrumbs": "Brödsmulor", + "broccoli": "Broccoli", + "brown_sugar": "Brunt socker", + "brussels_sprouts": "Brysselkål", + "buffalo_mozzarella": "Buffalo mozzarella", + "buko": "Buko", + "buns": "Bullar", + "burger_buns": "Hamburgare bröd", + "burger_patties": "Hamburgare biffar", + "burger_sauces": "Hamburgarsås", + "butter": "Smör", + "butter_cookies": "Smörkakor", + "button_cells": "Knappcell", + "börek_cheese": "Börek ost", + "cake": "Tårta", + "cake_icing": "Tårtglasyr", + "cane_sugar": "Rörsocker", + "cannelloni": "Cannelloni", + "canola_oil": "Canolaolja", + "cardamom": "Kardemumma", + "carrots": "Morötter", + "cashews": "Cashewnötter", + "cat_treats": "Kattgodis", + "cauliflower": "Blomkål", + "celeriac": "Rotselleri", + "celery": "Selleri", + "cereal_bar": "Cornflakes bar", + "cheddar": "Cheddar", + "cheese": "Ost", + "cherry_tomatoes": "Körsbärstomater", + "chickpeas": "Kikärtor", + "chicory": "Cikoria", + "chili_oil": "Chiliolja", + "chili_pepper": "Chilipeppar", + "chips": "Chips", + "chives": "Gräslök", + "chocolate": "Choklad", + "chocolate_chips": "Chokladbitar", + "chopped_tomatoes": "Hackade tomater", + "chunky_tomatoes": "Klumpiga tomater", + "ciabatta": "Cibatta", + "cider_vinegar": "Cidervinäger", + "cilantro": "Koriander", + "cinnamon": "Kanel", + "cinnamon_stick": "Kanelstång", + "cocktail_sauce": "Cocktailsås", + "cocktail_tomatoes": "Cocktailtomater", + "coconut_flakes": "Kokosflingor", + "coconut_milk": "Kokosmjölk", + "coconut_oil": "Kokosolja", + "colorful_sprinkles": "Färgrikt strössel", + "concealer": "Concealer", + "cookies": "Kakor", + "coriander": "Koriander", + "corn": "Majs", + "cornflakes": "Cornflakes", + "cornstarch": "Majsstärkelse", + "cornys": "Cornys", + "corriander": "Koriander", + "cough_drops": "Halstabletter", + "couscous": "Couscous", + "covid_rapid_test": "COVID snabbtest", + "cow's_milk": "Mjölk från ko", + "cream": "Grädde", + "cream_cheese": "Färskost", + "creamed_spinach": "Stuvad spenat", + "creme_fraiche": "Creme fraiche", + "crepe_tape": "Crepe tejp", + "crispbread": "Hårtbröd", + "cucumber": "Gruka", + "cumin": "Kummin", + "curd": "Ostmassa", + "curry_paste": "Currypasta", + "curry_powder": "Curry pulver", + "curry_sauce": "Curry sås", + "dates": "Dadlar", + "dental_floss": "Tandtråd", + "deo": "Deodorant", + "deodorant": "Deodorant", + "detergent": "Rengöringsmedel", + "detergent_sheets": "Tvättlappar", + "diarrhea_remedy": "Läkemedel mot diarré", + "dill": "Dill", + "dishwasher_salt": "Diskmaskinssalt", + "dishwasher_tabs": "Diskmaskinstabletter", + "disinfection_spray": "Desinfektionsspray", + "dried_tomatoes": "Torkadetomater", + "edamame": "Edamame", + "egg_salad": "Äggsallad", + "egg_yolk": "Ägg gula", + "eggplant": "aubergine", + "eggs": "Ägg", + "enoki_mushrooms": "Enokisvampar", + "eyebrow_gel": "Ögonbrynsgelé", + "falafel": "Falafel", + "falafel_powder": "Falafelpulver", + "fanta": "Fanta", + "feta": "Feta", + "ffp2": "FFP2", + "fish_sticks": "Fiskpinnar", + "flour": "Mjöl", + "flushing": "Spolning", + "fresh_chili_pepper": "Färsk chilipeppar", + "frozen_berries": "Frysta bär", + "frozen_fruit": "Fryst frukt", + "frozen_pizza": "Fryspizza", + "frozen_spinach": "Fryst spenat", + "funeral_card": "Begravningskort", + "garam_masala": "Garam Masala", + "garbage_bag": "Soppåsar", + "garlic": "Vitlök", + "garlic_dip": "Vitlöksdip", + "garlic_granules": "Vitlöksgranulat", + "gherkins": "Gurkor", + "ginger": "Ingefära", + "glass_noodles": "Glasnudlar", + "gluten": "Gluten", + "gnocchi": "Gnocchi", + "gochujang": "Gochujang", + "gorgonzola": "Gorgonzola", + "gouda": "Gouda", + "granola": "Granola", + "granola_bar": "Granolabar", + "grapes": "Vindruvor", + "greek_yogurt": "Grekisk Yoghurt", + "green_asparagus": "Grönsparis", + "green_chili": "Grön chili", + "green_pesto": "Grön pesto", + "hair_gel": "Hårgelé", + "hair_ties": "Hårsnodd", + "hair_wax": "Hårvax", + "hand_soap": "Handtvål", + "handkerchief_box": "Näsdukslåda", + "handkerchiefs": "Näsduk", + "hard_cheese": "Hård ost", + "haribo": "Haribo", + "harissa": "Harissa", + "hazelnuts": "Hasselnötter", + "head_of_lettuce": "Salladshuvud", + "herb_baguettes": "Örtbaugetter", + "herb_cream_cheese": "Örtfärskost", + "honey": "Honung", + "honey_wafers": "Honungsrån", + "hot_dog_bun": "Korvbröd", + "ice_cream": "Glass", + "ice_cube": "Isbitar", + "iceberg_lettuce": "Isbergssallad", + "iced_tea": "Iste", + "instant_soups": "Instantsoppa", + "jam": "Sylt", + "jasmine_rice": "Jasmine ris", + "katjes": "Katjes", + "ketchup": "Ketchup", + "kidney_beans": "Kidneybönor", + "kitchen_roll": "Köksrulle", + "kitchen_towels": "Hushållspapper", + "kohlrabi": "Kålrabbi", + "lasagna": "Lasagne", + "lasagna_noodles": "Lasagne nudlar", + "lasagna_plates": "Lasagne plattor", + "peppers": "Peppar", + "persian_rice": "Persisktris" + } +} From 424dfd2eaef3be95165d0c42c489478340dd8815 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 19:15:26 +0200 Subject: [PATCH 402/496] feat: Add Hungarian and Swedish --- backend/app/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/config.py b/backend/app/config.py index e390214f..c4e335b4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -54,6 +54,7 @@ 'es': 'Español', 'fi': 'Suomi', 'fr': 'Français', + 'hu': 'Magyar nyelv', 'id': 'Bahasa Indonesia', 'it': 'Italiano', 'nb_NO': 'Bokmål', @@ -62,6 +63,7 @@ 'pt': 'Português', 'pt_BR': 'Português Brasileiro', 'ru': 'русский язык', + 'sv': 'Svenska', 'tr': 'Türkçe', 'zh_Hans': '简化字', } From 150d3492a2906a1f513d0141067a5583fc50f582 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 19:16:16 +0200 Subject: [PATCH 403/496] Prepare release 78 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index c4e335b4..93f8ebda 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 77 +BACKEND_VERSION = 78 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 9620ba84d63dff0e3ee097e7d6a3213975b79685 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 23:01:37 +0200 Subject: [PATCH 404/496] fix: entrypoint --- backend/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index ce9fa6e7..17e5d5e6 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/sh mkdir -p $STORAGE_PATH/upload flask db upgrade -if [ "${SKIP_UPGRADE_DEFAULT_ITEMS,,}" != "true" ]; then +if [ "${SKIP_UPGRADE_DEFAULT_ITEMS}" != "true" ] && [ "${SKIP_UPGRADE_DEFAULT_ITEMS}" != "True" ]; then python upgrade_default_items.py fi uwsgi "$@" \ No newline at end of file From 40c476ce8bb9fdb6fa13406eb8f9d2ce52aef2e3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 23:01:48 +0200 Subject: [PATCH 405/496] feat: improve PostgreSQL search --- backend/app/models/item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 369dfd03..fff3f42e 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -142,7 +142,7 @@ def search_name(cls, name: str, household_id: int) -> list[Self]: item_count = 11 print(db.engine.name) if "postgresql" in db.engine.name: - return cls.query.filter(cls.household_id == household_id).order_by(func.levenshtein(func.lower(cls.name), name.lower()), cls.support.desc()).limit(item_count) + return cls.query.filter(cls.household_id == household_id).order_by(func.levenshtein(func.lower(func.substring(cls.name, 0, len(name))), name.lower()), cls.support.desc()).limit(item_count) found = [] From 3d4541b7abc10aef566fd92ee46c5d9e4090442e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 3 Oct 2023 23:02:06 +0200 Subject: [PATCH 406/496] Prepare release 79 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 93f8ebda..3c3d2e1e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 78 +BACKEND_VERSION = 79 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 007f5ab2b7c6b178dd1910464787cfe9efb70e19 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 4 Oct 2023 12:36:50 +0200 Subject: [PATCH 407/496] fix: PostgreSQL search --- backend/app/models/item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index fff3f42e..add4e001 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -142,7 +142,7 @@ def search_name(cls, name: str, household_id: int) -> list[Self]: item_count = 11 print(db.engine.name) if "postgresql" in db.engine.name: - return cls.query.filter(cls.household_id == household_id).order_by(func.levenshtein(func.lower(func.substring(cls.name, 0, len(name))), name.lower()), cls.support.desc()).limit(item_count) + return cls.query.filter(cls.household_id == household_id, func.levenshtein(func.lower(func.substring(cls.name, 1, len(name))), name.lower()) < 4).order_by(func.levenshtein(func.lower(func.substring(cls.name, 1, len(name))), name.lower()), cls.support.desc()).limit(item_count) found = [] From d6c5d853df87bda38ed55ed9837c85dc336c71e2 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 4 Oct 2023 12:37:52 +0200 Subject: [PATCH 408/496] feat: Add indices for household ids --- backend/app/models/category.py | 2 +- backend/app/models/expense.py | 2 +- backend/app/models/planner.py | 2 +- backend/app/models/recipe.py | 2 +- backend/app/models/shoppinglist.py | 2 +- backend/migrations/versions/c63508852dd1_.py | 56 ++++++++++++++++++++ 6 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/versions/c63508852dd1_.py diff --git a/backend/app/models/category.py b/backend/app/models/category.py index cbd13c90..bc311c1f 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -13,7 +13,7 @@ class Category(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): default_key = db.Column(db.String(128)) ordering = db.Column(db.Integer, default=0) household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False) + 'household.id'), nullable=False, index=True) household = db.relationship("Household", uselist=False) items = db.relationship( diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index 68849da6..27c05c7e 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -14,7 +14,7 @@ class Expense(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): category_id = db.Column(db.Integer, db.ForeignKey('expense_category.id')) photo = db.Column(db.String(), db.ForeignKey('file.filename')) paid_by_id = db.Column(db.Integer, db.ForeignKey('user.id')) - household_id = db.Column(db.Integer, db.ForeignKey('household.id'), nullable=False) + household_id = db.Column(db.Integer, db.ForeignKey('household.id'), nullable=False, index=True) household = db.relationship("Household", uselist=False) category = db.relationship("ExpenseCategory") diff --git a/backend/app/models/planner.py b/backend/app/models/planner.py index 5f8419c3..c0804ac8 100644 --- a/backend/app/models/planner.py +++ b/backend/app/models/planner.py @@ -12,7 +12,7 @@ class Planner(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): day = db.Column(db.Integer, primary_key=True) yields = db.Column(db.Integer) household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False) + 'household.id'), nullable=False, index=True) household = db.relationship("Household", uselist=False) recipe = db.relationship("Recipe", back_populates="plans") diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index e4c04c51..298015e9 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -23,7 +23,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): suggestion_score = db.Column(db.Integer, server_default='0') suggestion_rank = db.Column(db.Integer, server_default='0') household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False) + 'household.id'), nullable=False, index=True) household = db.relationship("Household", uselist=False) recipe_history = db.relationship( diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py index 73c2397c..f6059297 100644 --- a/backend/app/models/shoppinglist.py +++ b/backend/app/models/shoppinglist.py @@ -9,7 +9,7 @@ class Shoppinglist(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) - household_id = db.Column(db.Integer, db.ForeignKey('household.id'), nullable=False) + household_id = db.Column(db.Integer, db.ForeignKey('household.id'), nullable=False, index=True) household = db.relationship("Household", uselist=False) items = db.relationship('ShoppinglistItems', cascade="all, delete-orphan") diff --git a/backend/migrations/versions/c63508852dd1_.py b/backend/migrations/versions/c63508852dd1_.py new file mode 100644 index 00000000..5f6988da --- /dev/null +++ b/backend/migrations/versions/c63508852dd1_.py @@ -0,0 +1,56 @@ +"""empty message + +Revision ID: c63508852dd1 +Revises: dedd014b6a59 +Create Date: 2023-10-04 12:36:55.881848 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c63508852dd1' +down_revision = 'dedd014b6a59' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_category_household_id'), ['household_id'], unique=False) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_expense_household_id'), ['household_id'], unique=False) + + with op.batch_alter_table('planner', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_planner_household_id'), ['household_id'], unique=False) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_recipe_household_id'), ['household_id'], unique=False) + + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_shoppinglist_household_id'), ['household_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shoppinglist', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_shoppinglist_household_id')) + + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_recipe_household_id')) + + with op.batch_alter_table('planner', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_planner_household_id')) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_expense_household_id')) + + with op.batch_alter_table('category', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_category_household_id')) + + # ### end Alembic commands ### From 72154c0dff4070a5906a09a7284483752dc72a6c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 4 Oct 2023 20:49:46 +0200 Subject: [PATCH 409/496] feat: Allow overwrite when importing --- backend/app/controller/exportimport/import_controller.py | 2 +- backend/app/controller/exportimport/schemas.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index 6e1669d2..a546fba3 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -29,7 +29,7 @@ def importData(args, household_id): if "recipes" in args: for recipe in args['recipes']: - importRecipe(household_id, recipe) + importRecipe(household_id, recipe, args['recipe_overide'] if 'recipe_overide' in args else False) if "expenses" in args: for expense in args['expenses']: diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index 517a966f..5757c2cc 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -80,6 +80,7 @@ class Category(Schema): items = fields.List(fields.Nested(Item)) recipes = fields.List(fields.Nested(Recipe)) + recipe_overide = fields.Boolean() expenses = fields.List(fields.Nested(Expense)) member = fields.List(fields.String()) shoppinglists = fields.List(fields.String()) From c24d57f96571253fdab056a45e9caa155708eb37 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 4 Oct 2023 20:53:08 +0200 Subject: [PATCH 410/496] fix: variable name typo --- backend/app/controller/exportimport/import_controller.py | 2 +- backend/app/controller/exportimport/schemas.py | 2 +- backend/app/service/importServices/import_recipe.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index a546fba3..6ec1f804 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -29,7 +29,7 @@ def importData(args, household_id): if "recipes" in args: for recipe in args['recipes']: - importRecipe(household_id, recipe, args['recipe_overide'] if 'recipe_overide' in args else False) + importRecipe(household_id, recipe, args['recipe_overwrite'] if 'recipe_overwrite' in args else False) if "expenses" in args: for expense in args['expenses']: diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index 5757c2cc..21af3ff9 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -80,7 +80,7 @@ class Category(Schema): items = fields.List(fields.Nested(Item)) recipes = fields.List(fields.Nested(Recipe)) - recipe_overide = fields.Boolean() + recipe_overwrite = fields.Boolean() expenses = fields.List(fields.Nested(Expense)) member = fields.List(fields.String()) shoppinglists = fields.List(fields.String()) diff --git a/backend/app/service/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py index e54559b8..59c98de3 100644 --- a/backend/app/service/importServices/import_recipe.py +++ b/backend/app/service/importServices/import_recipe.py @@ -3,10 +3,10 @@ from app.service.file_has_access_or_download import file_has_access_or_download -def importRecipe(household_id: int, args: dict, override: bool = False): +def importRecipe(household_id: int, args: dict, overwrite: bool = False): recipeNameCount = 0 recipe = Recipe.find_by_name(household_id, args['name']) - if recipe and not override: + if recipe and not overwrite: recipeNameCount = 1 + \ Recipe.query.filter(Recipe.household_id == household_id, Recipe.name.ilike( args['name'] + " (_%)")).count() From 10b5aed348a4994b7bbd45d92ebe2b705fc3ad81 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 6 Oct 2023 13:13:57 +0200 Subject: [PATCH 411/496] Translated using Weblate (German) (TomBursch/kitchenowl-backend#53) Currently translated at 100.0% (477 of 477 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/de/ Translation: KitchenOwl/Default Items Co-authored-by: Tom Bursch --- backend/templates/l10n/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index e773e939..7990d0fe 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -121,7 +121,6 @@ "creme_fraiche": "Creme fraiche", "crepe_tape": "Crepesband", "crispbread": "Knäckebrot", - "granola": "Knuspermüsli", "cucumber": "Gurke", "cumin": "Cumin", "curd": "Quark", @@ -174,6 +173,7 @@ "gochujang": "Gochujang", "gorgonzola": "Gorgonzola", "gouda": "Gouda", + "granola": "Knuspermüsli", "granola_bar": "Müsliriegel", "grapes": "Trauben", "greek_yogurt": "Griechischer Joghurt", @@ -410,7 +410,7 @@ "strawberries": "Erdbeeren", "sugar": "Zucker", "summer_roll_paper": "Sommerrollen-Papier", - "sunflower_oil": "Sonnenblümenöl", + "sunflower_oil": "Sonnenblumenöl", "sunflower_seeds": "Sonnenblumenkerne", "sunscreen": "Sonnencreme", "sushi_rice": "Sushi Reis", From 3891693b6b863ba9dca4dc7d97df406d94b5e625 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 6 Oct 2023 13:14:21 +0200 Subject: [PATCH 412/496] Prepare release 80 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 3c3d2e1e..cd70bea8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 79 +BACKEND_VERSION = 80 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 0986b089db9444b085a79168e0f373d0f4bd17db Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 16 Oct 2023 20:56:14 +0200 Subject: [PATCH 413/496] chore: upgrade requirements --- backend/requirements.txt | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index bb90a4e2..64bdb809 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,14 +7,14 @@ bcrypt==4.0.1 beautifulsoup4==4.12.2 bidict==0.22.1 black==23.1a1 -blinker==1.6.2 +blinker==1.6.3 blurhash-python==1.2.1 certifi==2023.7.22 cffi==1.16.0 charset-normalizer==3.3.0 click==8.1.7 contourpy==1.1.1 -cycler==0.12.0 +cycler==0.12.1 dbscan1d==0.2.2 extruct==0.16.0 flake8==6.1.0 @@ -22,11 +22,11 @@ Flask==2.3.3 Flask-APScheduler==1.13.0 Flask-BasicAuth==0.2.0 Flask-Bcrypt==1.0.1 -Flask-JWT-Extended==4.5.2 +Flask-JWT-Extended==4.5.3 Flask-Migrate==4.0.5 Flask-SocketIO==5.3.6 Flask-SQLAlchemy==3.1.1 -fonttools==4.43.0 +fonttools==4.43.1 gevent==23.9.1 greenlet==3.0.0rc3 h11==0.14.0 @@ -52,18 +52,18 @@ mf2py==1.1.3 mlxtend==0.23.0 mypy-extensions==1.0.0 nltk==3.8.1 -numpy==1.26.0 +numpy==1.26.1 packaging==23.2 pandas==2.1.1 pathspec==0.11.2 -Pillow==10.0.1 +Pillow==10.1.0 platformdirs==3.11.0 pluggy==1.3.0 prometheus-client==0.17.1 prometheus-flask-exporter==0.22.4 -psycopg2-binary==2.9.8 +psycopg2-binary==2.9.9 py==1.11.0 -pycodestyle==2.11.0 +pycodestyle==2.11.1 pycparser==2.21 pyflakes==3.1.0 PyJWT==2.8.0 @@ -73,13 +73,13 @@ pytest==7.4.2 python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 -python-engineio==4.7.1 -python-socketio==5.9.0 +python-engineio==4.8.0 +python-socketio==5.10.0 pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==7.0.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.49.4 +recipe-scrapers==14.51.0 regex==2023.8.8 requests==2.31.0 scikit-learn==1.3.1 @@ -87,7 +87,7 @@ scipy==1.11.3 setuptools-scm==8.0.4 six==1.16.0 soupsieve==2.5 -SQLAlchemy==2.0.21 +SQLAlchemy==2.0.22 threadpoolctl==3.2.0 toml==0.10.2 tomli==2.0.1 @@ -95,11 +95,11 @@ tqdm==4.66.1 typed-ast==1.5.5 types-beautifulsoup4==4.12.0.6 types-html5lib==1.1.11.15 -types-requests==2.31.0.7 +types-requests==2.31.0.9 types-urllib3==1.26.25.14 typing_extensions==4.8.0 tzdata==2023.3 -tzlocal==5.0.1 +tzlocal==5.1 urllib3==2.0.6 uWSGI==2.0.22 uwsgi-tools==1.1.1 @@ -108,4 +108,4 @@ webencodings==0.5.1 Werkzeug==3.0.0 wsproto==1.2.0 zope.event==5.0 -zope.interface==6.0 +zope.interface==6.1 From 035c371aec77b3476fb1b68797cf3643c849179e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 17 Oct 2023 15:58:49 +0200 Subject: [PATCH 414/496] fix: UWSGI listen to IPv6 (TomBursch/kitchenowl-backend#55) --- backend/wsgi.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/wsgi.ini b/backend/wsgi.ini index e56d395b..a01bb792 100644 --- a/backend/wsgi.ini +++ b/backend/wsgi.ini @@ -12,5 +12,5 @@ chmod-socket = 664 wsgi-file = wsgi.py callable = app -socket = 0.0.0.0:5000 +socket = [::]:5000 procname-prefix-spaced = kitchenowl \ No newline at end of file From c362be8b46dc1374568b4beada1eb1611732587f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 18 Oct 2023 14:16:02 +0200 Subject: [PATCH 415/496] chore: upgrade requirements --- backend/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 64bdb809..f690a94a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -95,12 +95,12 @@ tqdm==4.66.1 typed-ast==1.5.5 types-beautifulsoup4==4.12.0.6 types-html5lib==1.1.11.15 -types-requests==2.31.0.9 +types-requests==2.31.0.10 types-urllib3==1.26.25.14 typing_extensions==4.8.0 tzdata==2023.3 tzlocal==5.1 -urllib3==2.0.6 +urllib3==2.0.7 uWSGI==2.0.22 uwsgi-tools==1.1.1 w3lib==2.1.2 From a92c6db8c07024164a22a90b014c65270ae98c61 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 18 Oct 2023 14:16:26 +0200 Subject: [PATCH 416/496] feat: Expense overview pagination and tag merging --- .../controller/expense/expense_controller.py | 11 +++++++++-- backend/app/controller/expense/schemas.py | 14 ++++++++++++++ backend/app/controller/tag/schemas.py | 6 ++++++ backend/app/controller/tag/tag_controller.py | 6 ++++++ backend/app/models/expense_category.py | 17 +++++++++++++++++ backend/app/models/tag.py | 19 ++++++++++++++++++- 6 files changed, 70 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 32ea91f1..a6e68c53 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -192,6 +192,7 @@ def getExpenseOverview(args, household_id): steps = args['steps'] if 'steps' in args else 5 frame = args['frame'] if args['frame'] != None else 2 + page = args['page'] if 'page' in args and args['page'] != None else 0 factor = 1 query = Expense.query\ @@ -242,9 +243,9 @@ def getOverviewForStepAgo(stepAgo: int): .all() } - value = [getOverviewForStepAgo(i) for i in range(0, steps)] + value = [getOverviewForStepAgo(i) for i in range(page * steps, steps + page * steps)] - byStep = {i: {category: (value[i][category] if category in value[i] else 0.0) + byStep = {i + page * steps: {category: (value[i][category] if category in value[i] else 0.0) for category in categories} for i in range(0, steps)} return jsonify(byStep) @@ -289,4 +290,10 @@ def updateExpenseCategory(args, id): category.color = args['color'] category.save() + + if 'merge_category_id' in args and args['merge_category_id'] != id: + mergeCategory = ExpenseCategory.find_by_id(args['merge_category_id']) + if mergeCategory: + category.merge(mergeCategory) + return jsonify(category.obj_to_dict()) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 159a8f18..448aa596 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -93,12 +93,26 @@ class UpdateExpenseCategory(Schema): allow_none=True ) + # 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, + ) + class GetExpenseOverview(Schema): + # household = 0, personal = 1 view = fields.Integer() + # daily = 0, weekly = 1, montly = 2, yearly = 3 frame = fields.Integer( validate=lambda a: a >= 0 and a <= 3 ) + # how many frames are looked at steps = fields.Integer( validate=lambda a: a > 0 ) + # used for pagination (i.e. start of steps, now=0) + page = fields.Integer( + validate=lambda a: a >= 0, + allow_none=True, + ) diff --git a/backend/app/controller/tag/schemas.py b/backend/app/controller/tag/schemas.py index 047336e6..4af28d7a 100644 --- a/backend/app/controller/tag/schemas.py +++ b/backend/app/controller/tag/schemas.py @@ -12,3 +12,9 @@ class UpdateTag(Schema): name = fields.String( validate=lambda a: a and not a.isspace() ) + + # 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, + ) diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py index 50f15bf8..c613e153 100644 --- a/backend/app/controller/tag/tag_controller.py +++ b/backend/app/controller/tag/tag_controller.py @@ -66,6 +66,12 @@ def updateTag(args, id): 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 mergeTag: + tag.merge(mergeTag) + return jsonify(tag.obj_to_dict()) diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py index 55b39b7a..73b5b327 100644 --- a/backend/app/models/expense_category.py +++ b/backend/app/models/expense_category.py @@ -27,6 +27,23 @@ def obj_to_export_dict(self) -> dict: 'color': self.color, } + def merge(self, other: Self) -> None: + if self.household_id != other.household_id: + return + + from app.models import Expense + + for expense in Expense.query.filter(Expense.category_id == other.id).all(): + expense.category_id = self.id + db.session.add(expense) + + try: + db.session.commit() + other.delete() + except Exception as e: + db.session.rollback() + raise e + @classmethod def find_by_name(cls, houshold_id: int, name: str) -> Self: return cls.query.filter(cls.name == name, cls.household_id == houshold_id).first() diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py index 1dce7b9c..50a59879 100644 --- a/backend/app/models/tag.py +++ b/backend/app/models/tag.py @@ -13,11 +13,28 @@ class Tag(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): 'household.id'), nullable=False) household = db.relationship("Household", uselist=False) - recipes = db.relationship('RecipeTags', back_populates='tag') + recipes = db.relationship('RecipeTags', back_populates='tag', cascade="all, delete-orphan") def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() return res + + def merge(self, other: Self) -> None: + if self.household_id != other.household_id: + return + + from app.models import RecipeTags + + for rectag in RecipeTags.query.filter(RecipeTags.tag_id == other.id, RecipeTags.recipe_id.notin_(db.session.query(RecipeTags.recipe_id).filter(RecipeTags.tag_id == self.id).subquery().select())).all(): + rectag.tag_id = self.id + db.session.add(rectag) + + try: + db.session.commit() + other.delete() + except Exception as e: + db.session.rollback() + raise e @classmethod def create_by_name(cls, household_id: int, name: str) -> Self: From 7f52b1b5d24f5cbc5395a473f68e1c1c0c7a4e18 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 18 Oct 2023 16:11:43 +0200 Subject: [PATCH 417/496] Translated using Weblate (Swedish) (TomBursch/kitchenowl-backend#54) Currently translated at 57.8% (276 of 477 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/sv/ Translation: KitchenOwl/Default Items Co-authored-by: Patrik W --- backend/templates/l10n/sv.json | 64 ++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/backend/templates/l10n/sv.json b/backend/templates/l10n/sv.json index 61132ac8..b407f745 100644 --- a/backend/templates/l10n/sv.json +++ b/backend/templates/l10n/sv.json @@ -212,6 +212,70 @@ "lasagna": "Lasagne", "lasagna_noodles": "Lasagne nudlar", "lasagna_plates": "Lasagne plattor", + "leaf_spinach": "Bladspenat", + "leek": "Purjolök", + "lemon": "Citron", + "lemon_curd": "Lemon Curd", + "lemon_juice": "Citron juice", + "lemonade": "Citronsaft", + "lemongrass": "Citrongrös", + "lentil_stew": "Linsgryta", + "lentils": "Linser", + "lentils_red": "Röda linser", + "lettuce": "Sallad", + "lillet": "Lillet", + "lime": "Lime", + "linguine": "Linguine", + "lip_care": "Läppvård", + "low-fat_curd_cheese": "Lågfetts kvarg", + "maggi": "Maggi", + "magnesium": "Magnesium", + "mango": "Mango", + "maple_syrup": "Lönnsirap", + "margarine": "Margarin", + "marjoram": "Mejram", + "marshmallows": "Marshmallows", + "mascara": "Mascara", + "mascarpone": "Mascarpone", + "mask": "Mask", + "mayonnaise": "Majonnäs", + "meat_substitute_product": "Köttersättningsprodukt", + "microfiber_cloth": "Mikrofiberduk", + "milk": "Mjölk", + "mint": "Mint", + "mint_candy": "Mintgodis", + "miso_paste": "Misopasta", + "mixed_vegetables": "Blandade grönsaker", + "mold_remover": "Mögelborttagare", + "mountain_cheese": "Bergsost", + "mouth_wash": "Munskölj", + "mozzarella": "Mozzarella", + "muesli": "Müsli", + "muesli_bar": "Müsli bar", + "mulled_wine": "Glögg", + "mushrooms": "Svamp", + "mustard": "Senap", + "nail_file": "Nagelfil", + "neutral_oil": "Naturell olja", + "nori_sheets": "Nori lakan", + "nutmeg": "Muskot", + "oat_milk": "Havredryck", + "oatmeal": "Havregröt", + "oatmeal_cookies": "Havrekakor", + "oil": "Olja", + "olive_oil": "Olivolja", + "olives": "Oliver", + "onion": "Lök", + "onion_powder": "Lökpulver", + "orange_juice": "Apelsinjuice", + "oranges": "Apelsiner", + "oregano": "Oregano", + "organic_lemon": "Ekologisk citron", + "organic_waste_bags": "Nerbrytningsbara avfallspåsar", + "pak_choi": "Pak Choi", + "pantyhose": "Strumpbyxor", + "paprika": "Paprika", + "paprika_seasoning": "Paptrikakrydda", "peppers": "Peppar", "persian_rice": "Persisktris" } From fddcc0a3bf5e45dcef6a979b7c322b5aacdfd9ef Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 18 Oct 2023 16:12:37 +0200 Subject: [PATCH 418/496] Prepare release 81 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index cd70bea8..a45bb524 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 80 +BACKEND_VERSION = 81 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 15542c9f5f05c4a312721df445c2e151592dcc86 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 24 Oct 2023 14:11:01 +0200 Subject: [PATCH 419/496] chore: upgrade requirements --- backend/requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index f690a94a..f329de6e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,7 +11,7 @@ blinker==1.6.3 blurhash-python==1.2.1 certifi==2023.7.22 cffi==1.16.0 -charset-normalizer==3.3.0 +charset-normalizer==3.3.1 click==8.1.7 contourpy==1.1.1 cycler==0.12.1 @@ -33,7 +33,7 @@ h11==0.14.0 html-text==0.5.2 html5lib==1.1 idna==3.4 -ingredient-parser-nlp==0.1.0b4 +ingredient-parser-nlp==0.1.0b5 iniconfig==2.0.0 isodate==0.6.1 itsdangerous==2.1.2 @@ -41,7 +41,7 @@ Jinja2==3.1.2 joblib==1.3.2 jstyleson==0.0.2 kiwisolver==1.4.5 -lark==1.1.7 +lark==1.1.8 lxml==4.9.3 Mako==1.2.4 MarkupSafe==2.1.3 @@ -82,7 +82,7 @@ rdflib-jsonld==0.6.2 recipe-scrapers==14.51.0 regex==2023.8.8 requests==2.31.0 -scikit-learn==1.3.1 +scikit-learn==1.3.2 scipy==1.11.3 setuptools-scm==8.0.4 six==1.16.0 @@ -99,7 +99,7 @@ types-requests==2.31.0.10 types-urllib3==1.26.25.14 typing_extensions==4.8.0 tzdata==2023.3 -tzlocal==5.1 +tzlocal==5.2 urllib3==2.0.7 uWSGI==2.0.22 uwsgi-tools==1.1.1 From 6fcfc229b275fb44ad9802311333ab6731d1c191 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 2 Nov 2023 17:00:02 +0100 Subject: [PATCH 420/496] fix: recipe suggestions --- backend/app/jobs/recipe_suggestions.py | 90 ++++---------------------- 1 file changed, 11 insertions(+), 79 deletions(-) diff --git a/backend/app/jobs/recipe_suggestions.py b/backend/app/jobs/recipe_suggestions.py index e34343b8..ae42f01b 100644 --- a/backend/app/jobs/recipe_suggestions.py +++ b/backend/app/jobs/recipe_suggestions.py @@ -1,98 +1,30 @@ +from sqlalchemy import func from app.models import Recipe, RecipeHistory from app import app, db import datetime from app.models.recipe_history import Status -# minimum hours on planner until a recipe is considered to have been cooked -MEAL_THRESHOLD = 3 - - -def findMealInstancesFromHistory(household_id: int): - return findMealInstances( - RecipeHistory.query.filter(RecipeHistory.status == Status.ADDED, RecipeHistory.household_id == household_id).all(), - RecipeHistory.query.filter(RecipeHistory.status == Status.DROPPED, RecipeHistory.household_id == household_id).all()) - - -def findMealInstances(added, dropped): - # pointers for added and dropped recipes - # both lists are marched through together in chronological order - added_pointer = 0 - dropped_pointer = 0 - # key:recipe_id, value:created_at - added_recipes = dict() - - # meals that are considered to have been cooked - meals = list() - - while added_pointer < len(added) and dropped_pointer < len(dropped): - # add the currently added recipe to the dict added_recipes - current_added = added[added_pointer] - added_recipes[current_added.recipe_id] = current_added.created_at - added_pointer += 1 - - # look for whether the current dropped recipe has yet been added - current_dropped = dropped[dropped_pointer] - while (current_dropped.recipe_id in added_recipes): - # compute duration while recipe on the planner - added_time = added_recipes[current_dropped.recipe_id] - dropped_time = current_dropped.created_at - # if duration threshold is met, consider it as a cooked meal - if (dropped_time - added_time >= - datetime.timedelta(hours=MEAL_THRESHOLD)): - meal = { - "recipe_id": current_dropped.recipe_id, - "cooked_at": dropped_time} - meals.append(meal) - - # proceed to next dropped recipe - added_recipes.pop(current_dropped.recipe_id) - dropped_pointer += 1 - # break if no more dropped recipes - if not (dropped_pointer < len(dropped)): - break - current_dropped = dropped[dropped_pointer] - app.logger.info("meal instances are identified") - return meals - def computeRecipeSuggestions(household_id: int): - meal_instances = findMealInstancesFromHistory(household_id) - # group meals by their id - meal_hist = dict() - for m in meal_instances: - id = m["recipe_id"] - if id not in meal_hist: - meal_hist[id] = [] - meal_hist[id].append(m["cooked_at"]) - + historyCount = RecipeHistory.query.with_entities(RecipeHistory.recipe_id, func.count().label('count')).filter( + RecipeHistory.status == Status.ADDED, + RecipeHistory.household_id == household_id, + RecipeHistory.created_at >= datetime.datetime.utcnow() - datetime.timedelta(days=182), + RecipeHistory.created_at <= datetime.datetime.utcnow() - datetime.timedelta(days=7), + ).group_by(RecipeHistory.recipe_id).all() # 0) reset all suggestion scores for r in Recipe.all_from_household(household_id): r.suggestion_score = 0 db.session.add(r) # 1) count cooked instances in last six months - six_months_ago = datetime.datetime.utcnow() - datetime.timedelta(days=182) - for id in meal_hist: - cooking_count = 0 - for cooked in meal_hist[id]: - if cooked > six_months_ago: - cooking_count += 1 - # set suggestion_score to cooking_count - r = Recipe.find_by_id(id) - r.suggestion_score = cooking_count + for e in historyCount: + r = Recipe.find_by_id(e.recipe_id) + if not r: continue + r.suggestion_score = e.count db.session.add(r) - # 2) do not suggest recent meals - week_ago = datetime.datetime.utcnow() - datetime.timedelta(days=7) - # find recently cooked meals - for id in meal_hist: - for cooked in meal_hist[id]: - if cooked > week_ago: - r = Recipe.find_by_id(id) - r.suggestion_score = 0 - db.session.add(r) - # commit changes to db db.session.commit() app.logger.info("computed and stored new suggestion scores") From 8134431f12ba4da16e33388aa515a12c0d9dca1f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 2 Nov 2023 17:00:24 +0100 Subject: [PATCH 421/496] feat: return recipes where item is optional --- backend/app/controller/item/item_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index bb17b615..4ad4335f 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -35,7 +35,7 @@ def getItemRecipes(id): raise NotFoundRequest() item.checkAuthorized() recipe = RecipeItems.query.filter( - RecipeItems.item_id == id, RecipeItems.optional == False).join( # noqa + RecipeItems.item_id == id).join( # noqa RecipeItems.recipe).order_by( Recipe.name).all() return jsonify([e.obj_to_recipe_dict() for e in recipe]) From 013274b4a9979a03b52d154a1dd32d793564c775 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 2 Nov 2023 17:04:25 +0100 Subject: [PATCH 422/496] chore: upgrade requirements --- backend/requirements.txt | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index f329de6e..f2f02e52 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -alembic==1.12.0 +alembic==1.12.1 appdirs==1.4.4 APScheduler==3.10.4 attrs==23.1.0 @@ -7,11 +7,11 @@ bcrypt==4.0.1 beautifulsoup4==4.12.2 bidict==0.22.1 black==23.1a1 -blinker==1.6.3 +blinker==1.7.0 blurhash-python==1.2.1 certifi==2023.7.22 cffi==1.16.0 -charset-normalizer==3.3.1 +charset-normalizer==3.3.2 click==8.1.7 contourpy==1.1.1 cycler==0.12.1 @@ -46,7 +46,7 @@ lxml==4.9.3 Mako==1.2.4 MarkupSafe==2.1.3 marshmallow==3.20.1 -matplotlib==3.8.0 +matplotlib==3.8.1 mccabe==0.7.0 mf2py==1.1.3 mlxtend==0.23.0 @@ -54,13 +54,13 @@ mypy-extensions==1.0.0 nltk==3.8.1 numpy==1.26.1 packaging==23.2 -pandas==2.1.1 +pandas==2.1.2 pathspec==0.11.2 Pillow==10.1.0 platformdirs==3.11.0 pluggy==1.3.0 -prometheus-client==0.17.1 -prometheus-flask-exporter==0.22.4 +prometheus-client==0.18.0 +prometheus-flask-exporter==0.23.0 psycopg2-binary==2.9.9 py==1.11.0 pycodestyle==2.11.1 @@ -69,7 +69,7 @@ pyflakes==3.1.0 PyJWT==2.8.0 pyparsing==3.1.1 pyRdfa3==3.5.3 -pytest==7.4.2 +pytest==7.4.3 python-crfsuite==0.9.9 python-dateutil==2.8.2 python-editor==1.0.4 @@ -79,7 +79,7 @@ pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==7.0.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.51.0 +recipe-scrapers==14.52.0 regex==2023.8.8 requests==2.31.0 scikit-learn==1.3.2 @@ -87,13 +87,13 @@ scipy==1.11.3 setuptools-scm==8.0.4 six==1.16.0 soupsieve==2.5 -SQLAlchemy==2.0.22 +SQLAlchemy==2.0.23 threadpoolctl==3.2.0 toml==0.10.2 tomli==2.0.1 tqdm==4.66.1 typed-ast==1.5.5 -types-beautifulsoup4==4.12.0.6 +types-beautifulsoup4==4.12.0.7 types-html5lib==1.1.11.15 types-requests==2.31.0.10 types-urllib3==1.26.25.14 @@ -101,11 +101,11 @@ typing_extensions==4.8.0 tzdata==2023.3 tzlocal==5.2 urllib3==2.0.7 -uWSGI==2.0.22 +uWSGI==2.0.23 uwsgi-tools==1.1.1 w3lib==2.1.2 webencodings==0.5.1 -Werkzeug==3.0.0 +Werkzeug==3.0.1 wsproto==1.2.0 zope.event==5.0 zope.interface==6.1 From e66a1a80ae1f6e2cfee953c8939a6dd44efa0521 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 2 Nov 2023 17:15:06 +0100 Subject: [PATCH 423/496] fix: tests --- backend/tests/test_recipe_suggestions.py | 69 ------------------------ 1 file changed, 69 deletions(-) delete mode 100644 backend/tests/test_recipe_suggestions.py diff --git a/backend/tests/test_recipe_suggestions.py b/backend/tests/test_recipe_suggestions.py deleted file mode 100644 index 69c89711..00000000 --- a/backend/tests/test_recipe_suggestions.py +++ /dev/null @@ -1,69 +0,0 @@ -from app.jobs.recipe_suggestions import findMealInstances -import pytest -import datetime - - -# print(RecipeHistory.find_added()) - -start_time = datetime.datetime(2021, 8, 14, 14) - - -def time_diff(h): - return datetime.timedelta(hours=h) - - -Added = [ - {"recipe_id": 1, "created_at": start_time}, - {"recipe_id": 2, "created_at": start_time+time_diff(2)}, - {"recipe_id": 3, "created_at": start_time+time_diff(2)}, - {"recipe_id": 4, "created_at": start_time+time_diff(2)}, - {"recipe_id": 5, "created_at": start_time+time_diff(2)}, - {"recipe_id": 6, "created_at": start_time+time_diff(2)}, - {"recipe_id": 7, "created_at": start_time+time_diff(2)}, - {"recipe_id": 1, "created_at": start_time+time_diff(3)}, -] - -Dropped = [ - {"recipe_id": 1, "created_at": start_time+time_diff(3)}, - {"recipe_id": 2, "created_at": start_time+time_diff(3)}, - {"recipe_id": 5, "created_at": start_time+time_diff(5)}, - {"recipe_id": 4, "created_at": start_time+time_diff(5)}, - {"recipe_id": 7, "created_at": start_time+time_diff(5)}, - {"recipe_id": 1, "created_at": start_time+time_diff(6)}, -] - -ExpectedMeals = [ - {"recipe_id": 1, "cooked_at": start_time+time_diff(3)}, - {"recipe_id": 5, "cooked_at": start_time+time_diff(5)}, - {"recipe_id": 4, "cooked_at": start_time+time_diff(5)}, - {"recipe_id": 7, "cooked_at": start_time+time_diff(5)}, - {"recipe_id": 1, "cooked_at": start_time+time_diff(6)}, -] - -# used to access dict with object syntax - - -class objectview(object): - def __init__(self, d): - self.__dict__ = d - - -@pytest.mark.parametrize("added,dropped,expectedMeals", [ - # empty added list - ([], [], []), - # empty dropped list - (Added[:1], [], []), - # single meal - (Added[:1], Dropped[:1], ExpectedMeals[:1]), - # single meal but dropped recipes left - (Added[:1], Dropped[:1]+Dropped[:1], ExpectedMeals[:1]), - # no meal as duration too short - (Added[1:2], Dropped[1:2], []), - # complete example - (Added, Dropped, ExpectedMeals), -]) -def testFindMealInstances(added, dropped, expectedMeals): - actualMeals = findMealInstances( - [objectview(a) for a in added], - [objectview(d) for d in dropped]) - assert actualMeals == expectedMeals From 35ee38a047e4cc8cd964703272be1bc8b9be30c5 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 2 Nov 2023 22:06:34 +0100 Subject: [PATCH 424/496] Translated using Weblate (Swedish) (TomBursch/kitchenowl-backend#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 58.0% (277 of 477 strings) Translated using Weblate (Finnish) Currently translated at 100.0% (477 of 477 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fi/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/sv/ Translation: KitchenOwl/Default Items Co-authored-by: Petri Hämäläinen Co-authored-by: Tom Bursch --- backend/templates/l10n/fi.json | 41 +++++++++++++++++----------------- backend/templates/l10n/sv.json | 1 + 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json index 06e7f360..04b7c6e4 100644 --- a/backend/templates/l10n/fi.json +++ b/backend/templates/l10n/fi.json @@ -112,7 +112,7 @@ "cornys": "Cornys", "corriander": "Korianteri", "cough_drops": "Yskänpastillit", - "couscous": "Couscous", + "couscous": "Kuskus", "covid_rapid_test": "COVID-pikatesti", "cow's_milk": "Lehmänmaito", "cream": "Kerma", @@ -132,6 +132,7 @@ "deo": "Deodorantti", "deodorant": "Deodorantti", "detergent": "Pesuaine", + "detergent_sheets": "Huuhteluliina", "diarrhea_remedy": "Ripulilääke", "dill": "Tilli", "dishwasher_salt": "Astianpesukoneen suola", @@ -214,7 +215,7 @@ "leaf_spinach": "Lehtipinaatti", "leek": "Purjo", "lemon": "Sitruuna", - "lemon_curd": "Sitruuna Curd", + "lemon_curd": "Sitruunatahna", "lemon_juice": "Sitruunamehu", "lemonade": "Limonadi", "lemongrass": "Sitruunaruoho", @@ -226,7 +227,7 @@ "lime": "Lime", "linguine": "Linguine", "lip_care": "Huulirasva", - "low-fat_curd_cheese": "Vähärasvainen juusto", + "low-fat_curd_cheese": "Vähärasvainen juustomassa", "maggi": "Maggi", "magnesium": "Magnesium", "mango": "Mango", @@ -302,12 +303,12 @@ "pita_bread": "Pita-leipä", "pizza": "Pizza", "pizza_dough": "Pizzataikina", - "plant_magarine": "Kasvi Magarine", + "plant_magarine": "Kasvipohjainen margariini", "plant_oil": "Kasviöljy", "plaster": "Laastari", "pointed_peppers": "Suippopaprikat", "porcini_mushrooms": "Porcini-sienet", - "potato_dumpling_dough": "Perunakimpaleiden taikina", + "potato_dumpling_dough": "Perunanyyttitaikina", "potato_wedges": "Lohkoperunat", "potatoes": "Perunat", "potting_soil": "Ruukkumulta", @@ -315,7 +316,7 @@ "powdered_sugar": "Tomusokeri", "processed_cheese": "Sulatejuusto", "prosecco": "Prosecco", - "puff_pastry": "Leivonnaiset", + "puff_pastry": "Lehtitaikinaleivonnaiset", "pumpkin": "Kurpitsa", "pumpkin_seeds": "Kurpitsan siemenet", "quark": "Rahka", @@ -343,7 +344,7 @@ "rice_ribbon_noodles": "Riisinauhanuudelit", "rice_vinegar": "Riisiviinietikka", "ricotta": "Ricotta", - "rinse_tabs": "Huuhtele välilehdet", + "rinse_tabs": "Puhdistustabletti", "rinsing_agent": "Huuhteluaine", "risotto_rice": "Risottoriisi", "rocket": "Raketti", @@ -361,8 +362,8 @@ "sausage": "Makkara", "sausages": "Makkarat", "savoy_cabbage": "Savoijinkaali", - "scallion": "Sipuli", - "scattered_cheese": "Hajallaan oleva juusto", + "scallion": "Kevätsipuli", + "scattered_cheese": "Juustolevite", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", "semolina_porridge": "Mannapuuro", @@ -375,7 +376,7 @@ "shoe_insoles": "Kengänpohjalliset", "shower_gel": "Suihkugeeli", "shredded_cheese": "Juustoraaste", - "sieved_tomatoes": "Seulotut tomaatit", + "sieved_tomatoes": "Paseraattu tomaatti", "sliced_cheese": "Juustoviipaleet", "smoked_paprika": "Savustettu paprika", "smoked_tofu": "Savutofu", @@ -388,24 +389,24 @@ "sour_cream": "Hapankerma", "sour_cucumbers": "Hapakurkut", "soy_cream": "Soijakerma", - "soy_hack": "Soija hack", + "soy_hack": "Soija", "soy_sauce": "Soijakastike", - "soy_shred": "Soija silppua", - "spaetzle": "Spetzle", - "spaghetti": "Spaghetti", + "soy_shred": "Soijarouhe", + "spaetzle": "Spätzle", + "spaghetti": "Spagetti", "sparkling_water": "Hiilihapotettu vesi", "spelt": "Speltti", "spinach": "Pinaatti", "sponge_cloth": "Sieniliina", - "sponge_fingers": "Sienisormet", - "sponge_wipes": "Sienipyyhkeet", + "sponge_fingers": "Savoiardi", + "sponge_wipes": "Hankaussieni", "sponges": "Pesusienet", "spreading_cream": "Levitysvoide", "spring_onions": "Kevätsipulit", "sprite": "Sprite", "sprouts": "Idut", "sriracha": "Sriracha", - "strained_tomatoes": "Siivilöidyt tomaatit", + "strained_tomatoes": "Paseerattu tomaatti", "strawberries": "Mansikat", "sugar": "Sokeri", "summer_roll_paper": "Kesärullapaperi", @@ -431,7 +432,7 @@ "tofu": "Tofu", "toilet_paper": "Vessapaperi", "tomato_juice": "Tomaattimehu", - "tomato_paste": "Tomaattimurska", + "tomato_paste": "Tomaattipyree", "tomato_sauce": "Tomaattikastike", "tomatoes": "Tomaatit", "tonic_water": "Tonic-vesi", @@ -447,7 +448,7 @@ "vegetable_bouillon_cube": "Kasvisliemikuutio", "vegetable_broth": "Kasvisliemi", "vegetable_oil": "Kasvisöljy", - "vegetable_onion": "Kasvissipuli", + "vegetable_onion": "Sipuli", "vegetables": "Kasvikset", "vegetarian_cold_cuts": "Kasvipohjaiset leikkeleet", "vinegar": "Etikka", @@ -456,7 +457,7 @@ "washing_gel": "Pesugeeli", "washing_powder": "Pesujauhe", "water": "Vesi", - "water_ice": "Vesijää", + "water_ice": "Jäävesi", "watermelon": "Vesimeloni", "wc_cleaner": "WC:n puhdistusaine", "wheat_flour": "Vehnäjauho", diff --git a/backend/templates/l10n/sv.json b/backend/templates/l10n/sv.json index b407f745..e1ca14f9 100644 --- a/backend/templates/l10n/sv.json +++ b/backend/templates/l10n/sv.json @@ -276,6 +276,7 @@ "pantyhose": "Strumpbyxor", "paprika": "Paprika", "paprika_seasoning": "Paptrikakrydda", + "pasta": "Pasta", "peppers": "Peppar", "persian_rice": "Persisktris" } From 5f136190630ae7aaf281264dc1bd0314d8c5db05 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 2 Nov 2023 23:52:15 +0100 Subject: [PATCH 425/496] feat: improve default items --- backend/app/config.py | 6 +- backend/app/models/item.py | 1 - backend/app/service/import_language.py | 5 +- backend/manage_default_items.py | 102 ++++++++--------- backend/templates/add_items_from_server.py | 106 ------------------ backend/templates/attributes.json | 123 +++++++++++++++++---- backend/templates/l10n/da.json | 1 - backend/templates/l10n/de.json | 21 +++- backend/templates/l10n/el.json | 1 - backend/templates/l10n/en.json | 19 +++- backend/templates/l10n/es.json | 1 - backend/templates/l10n/fi.json | 1 - backend/templates/l10n/fr.json | 1 - backend/templates/l10n/hu.json | 1 - backend/templates/l10n/id.json | 1 - backend/templates/l10n/it.json | 1 - backend/templates/l10n/nb_NO.json | 1 - backend/templates/l10n/nl.json | 1 - backend/templates/l10n/pl.json | 1 - backend/templates/l10n/pt.json | 1 - backend/templates/l10n/pt_BR.json | 1 - backend/templates/l10n/ru.json | 1 - backend/templates/l10n/tr.json | 1 - backend/templates/l10n/zh_Hans.json | 1 - 24 files changed, 188 insertions(+), 211 deletions(-) delete mode 100644 backend/templates/add_items_from_server.py diff --git a/backend/app/config.py b/backend/app/config.py index a45bb524..c1671974 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,7 +24,8 @@ APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) -UPLOAD_FOLDER = os.getenv('STORAGE_PATH', PROJECT_DIR) + '/upload' +STORAGE_PATH = os.getenv('STORAGE_PATH', PROJECT_DIR) +UPLOAD_FOLDER = STORAGE_PATH + '/upload' ALLOWED_FILE_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} PRIVACY_POLICY_URL = os.getenv('PRIVACY_POLICY_URL') @@ -38,8 +39,7 @@ username=os.getenv('DB_USER'), password=os.getenv('DB_PASSWORD'), host=os.getenv('DB_HOST'), - database=os.getenv('DB_NAME', os.getenv( - 'STORAGE_PATH', PROJECT_DIR) + "/database.db"), + database=os.getenv('DB_NAME', STORAGE_PATH + "/database.db"), ) JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index add4e001..8aec5d8e 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -140,7 +140,6 @@ def find_by_id(cls, id) -> Self: @classmethod def search_name(cls, name: str, household_id: int) -> list[Self]: item_count = 11 - print(db.engine.name) if "postgresql" in db.engine.name: return cls.query.filter(cls.household_id == household_id, func.levenshtein(func.lower(func.substring(cls.name, 1, len(name))), name.lower()) < 4).order_by(func.levenshtein(func.lower(func.substring(cls.name, 1, len(name))), name.lower()), cls.support.desc()).limit(item_count) diff --git a/backend/app/service/import_language.py b/backend/app/service/import_language.py index 35723a30..a8735b38 100644 --- a/backend/app/service/import_language.py +++ b/backend/app/service/import_language.py @@ -22,7 +22,7 @@ def importLanguage(household_id, lang, bulkSave=False): item = Item.find_by_default_key( household_id, key) or Item.find_by_name(household_id, name) if not item: - # slow but needed to filter out duplicate names + # needed to filter out duplicate names if bulkSave and any(i.name == name for i in models): continue item = Item() @@ -35,7 +35,8 @@ def importLanguage(household_id, lang, bulkSave=False): item.default_key = key if item.default: - item.name = name.strip() + if item.name != name.strip() and not Item.find_by_name(household_id, name) and not any(i.name == name for i in models): + item.name = name.strip() if key in attributes["items"] and "icon" in attributes["items"][key]: item.icon = attributes["items"][key]["icon"] diff --git a/backend/manage_default_items.py b/backend/manage_default_items.py index 6bd9709c..5dbebe97 100644 --- a/backend/manage_default_items.py +++ b/backend/manage_default_items.py @@ -5,68 +5,51 @@ from sqlalchemy import desc, func from app import app +from app.config import STORAGE_PATH from app.models import Item, Category, Household BASE_PATH = os.path.dirname(os.path.abspath(__file__)) -DEEPL_AUTH_KEY = "" +EXPORT_FOLDER = STORAGE_PATH + "/export" +DEEPL_AUTH_KEY = os.getenv('DEEPL_AUTH_KEY', "") -def update_names(saveToFile: bool = False): +def update_names(saveToTemplate: bool = False, consensus_count: int = 2): + default_items = {} def nameToKey(name: str) -> str: return name.lower().strip().replace(" ", "_") - for household in Household.query.filter(Household.language != None).all(): - lang_code = household.language - add_items = [item.obj_to_export_dict() for item in household.items] - - # read en file - with open(BASE_PATH + "/templates/l10n/en.json", encoding="utf8") as f: - en = json.load(f) - - # translate original file (used as the key) and write to file - if lang_code != "en" and not DEEPL_AUTH_KEY: - continue - if lang_code != "en": - deepl_supported_lang: list = [v['language'].lower() for v in json.loads(requests.get("https://api-free.deepl.com/v2/languages?type=source", - headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)] - if lang_code not in deepl_supported_lang: - print(f"Source language '{lang_code}' not supported by deepl") - continue - - if os.path.exists(BASE_PATH + "/templates/l10n/" + lang_code + ".json"): - with open(BASE_PATH + "/templates/l10n/" + lang_code + ".json", "r", encoding="utf8") as f: - content = f.read() - if content: - source = json.loads(content) - else: - source = {} - else: - source = {} - - if "items" not in source: - source["items"] = {} - - for item in add_items: - item["original"] = item["name"] - item["name"] = json.loads(requests.post("https://api-free.deepl.com/v2/translate", {"target_lang": "EN-US", "source_lang": lang_code.upper(), "text": item["name"]}, + def loadLang(lang: str): + default_items[lang] = {"items": {} } + if os.path.exists(BASE_PATH + "/templates/l10n/" + lang + ".json"): + with open(BASE_PATH + "/templates/l10n/" + lang + ".json", 'r', encoding="utf8") as f: + default_items[lang] = json.loads(f.read()) + supported_lang: list = [v['language'].lower() for v in json.loads(requests.get("https://api-free.deepl.com/v2/languages?type=source", + headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)] if DEEPL_AUTH_KEY else ['en'] + loadLang('en') + + items = Item.query.with_entities(Item.name, func.count().label('count'), Household.language).filter(Item.default_key == None, Household.language.in_(supported_lang)).join(Household, isouter=True).group_by(Item.name, Household.language).having(func.count().label('count') >= consensus_count).order_by(desc("count")).all() + for item in items: + if item.language == "en": + if not nameToKey(item.name) in default_items["en"]['items']: + default_items["en"]['items'][nameToKey(item.name)] = item.name + else: + if not item.language in default_items: + loadLang(item.language) + engl_name = json.loads(requests.post("https://api-free.deepl.com/v2/translate", {"target_lang": "EN-US", "source_lang": item.language.upper(), "text": item.name}, headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)['translations'][0]["text"] + if not nameToKey(engl_name) in default_items[item.language]['items']: + default_items[item.language]['items'][nameToKey(engl_name)] = item.name + if not nameToKey(engl_name) in default_items["en"]['items']: + default_items["en"]['items'][nameToKey(engl_name)] = engl_name - if (nameToKey(item["name"]) not in source["items"]): - source["items"][nameToKey(item["name"])] = item["original"] - with open(BASE_PATH + "/templates/l10n/" + lang_code + ".json", "w", encoding="utf8") as f: - f.write(json.dumps(source, ensure_ascii=False, - indent=2, sort_keys=True)) + folder = BASE_PATH + "/templates/l10n/" if saveToTemplate else (EXPORT_FOLDER + "/") + for key, content in default_items.items(): + with open(folder + key + ".json", "w", encoding="utf8") as f: + f.write(json.dumps(content, ensure_ascii=False, indent=2, sort_keys=True)) - for item in add_items: - if (nameToKey(item["name"]) not in en["items"]): - en["items"][nameToKey(item["name"])] = item["name"] - with open(BASE_PATH + "/templates/l10n/en.json", "w", encoding="utf8") as f: - f.write(json.dumps(en, ensure_ascii=False, indent=2, sort_keys=True)) - - -def update_attributes(saveToFile: bool = False): +def update_attributes(saveToTemplate: bool = False): # read files with open(BASE_PATH + "/templates/l10n/en.json", encoding="utf8") as f: en: dict = json.load(f) @@ -103,11 +86,12 @@ def update_attributes(saveToFile: bool = False): jsonContent = json.dumps(attr, ensure_ascii=False, indent=2, sort_keys=True) - if (saveToFile): + if saveToTemplate: with open(BASE_PATH + "/templates/attributes.json", "w", encoding="utf8") as f: f.write(jsonContent) else: - print(jsonContent) + with open(EXPORT_FOLDER + "/attributes.json", "w", encoding="utf8") as f: + f.write(jsonContent) if __name__ == "__main__": @@ -121,9 +105,15 @@ def update_attributes(saveToFile: bool = False): help="collects item names") parser.add_argument('-a', '--attributes', action='store_true', help="collects attributes") + parser.add_argument('-c' '--consensus', type=int, default=2, help="Minimum number of households to have this item for it to be considered default") args = parser.parse_args() - with app.app_context(): - if (args.names and args.save): - update_names(args.save) - if (args.attributes): - update_attributes(args.save) + if not args.names and not args.attributes: + parser.print_help() + else: + if args.save and not os.path.exists(EXPORT_FOLDER): + os.makedirs(EXPORT_FOLDER) + with app.app_context(): + if (args.names): + update_names(args.save, args.c__consensus) + if (args.attributes): + update_attributes(args.save) diff --git a/backend/templates/add_items_from_server.py b/backend/templates/add_items_from_server.py deleted file mode 100644 index 03b8d686..00000000 --- a/backend/templates/add_items_from_server.py +++ /dev/null @@ -1,106 +0,0 @@ -import requests -import json -import os - - -SERVER_URL = "http://localhost:5000" -TOKEN = "" -HOUSEHOLD_ID = 1 - -DEEPL_AUTH_KEY = "" -SOURCE_LANG_CODE = None # only used if the household has no language assigned - -BASE_PATH = os.path.dirname(os.path.abspath(__file__)) - - -def nameToKey(name: str) -> str: - return name.lower().strip().replace(" ", "_") - - -def main(): - if not SERVER_URL or not TOKEN: - print("Server is not configured") - return - if not HOUSEHOLD_ID: - print("Household not configured") - return - - # Get household from server - household: dict = json.loads(requests.get( - SERVER_URL + "/api/household/" + str(HOUSEHOLD_ID), headers={'Authorization': 'Bearer ' + TOKEN}).content) - - if not household: - print("Could not find household") - return - - lang_code = household['language'] or SOURCE_LANG_CODE - if not lang_code: - print("Household has no language") - return - print("Selected household '" + - household['name'] + "' with language code '" + lang_code + "'") - confirm = input("Confirm (y):").lower() or "y" - if not confirm == "y": - print("Abort") - return - - if lang_code != "en" and not DEEPL_AUTH_KEY: - print("For languages other than english an deepl token is required! Make sure the source languages is supported: https://www.deepl.com/docs-api/translate-text/translate-text/") - return - - # Get item export from server - add_items: list = json.loads(requests.get( - SERVER_URL + "/api/household/" + str(HOUSEHOLD_ID) + "/export/items", headers={'Authorization': 'Bearer ' + TOKEN}).content)["items"] - - if not add_items: - print("An error occured") - return - - # read en file - with open(BASE_PATH + "/l10n/en.json", encoding="utf8") as f: - en = json.load(f) - - # translate original file (used as the key) and write to file - if lang_code != "en": - deepl_supported_lang: list = [v['language'].lower() for v in json.loads(requests.get("https://api-free.deepl.com/v2/languages?type=source", - headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)] - if lang_code not in deepl_supported_lang: - print("Source language not supported by deepl") - return - - - if os.path.exists(BASE_PATH + "/l10n/" + lang_code + ".json"): - with open(BASE_PATH + "/l10n/" + lang_code + ".json", "r", encoding="utf8") as f: - content = f.read() - if content: - source = json.loads(content) - else: - source = {} - else: - source = {} - - if "items" not in source: - source["items"] = {} - - for item in add_items: - item["original"] = item["name"] - item["name"] = json.loads(requests.post("https://api-free.deepl.com/v2/translate", {"target_lang": "EN-US", "source_lang": lang_code.upper(), "text": item["name"]}, - headers={'Authorization': 'DeepL-Auth-Key ' + DEEPL_AUTH_KEY}).content)['translations'][0]["text"] - - if (nameToKey(item["name"]) not in source["items"]): - source["items"][nameToKey(item["name"])] = item["original"] - - with open(BASE_PATH + "/l10n/" + lang_code + ".json", "w", encoding="utf8") as f: - f.write(json.dumps(source, ensure_ascii=False, - indent=2, sort_keys=True)) - - for item in add_items: - if (nameToKey(item["name"]) not in en["items"]): - en["items"][nameToKey(item["name"])] = item["name"] - - with open(BASE_PATH + "/l10n/en.json", "w", encoding="utf8") as f: - f.write(json.dumps(en, ensure_ascii=False, indent=2, sort_keys=True)) - - -if __name__ == "__main__": - main() diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index 667bdfca..c1b00221 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -1,5 +1,8 @@ { "items": { + "agave_syrup": { + "icon": "honey" + }, "aioli": { "icon": "garlic" }, @@ -16,7 +19,10 @@ "applesauce": { "icon": "apple" }, - "apricots": {}, + "apricots": { + "category": "fruits_vegetables", + "icon": "apricot" + }, "apérol": { "category": "drinks", "icon": "wine-bottle" @@ -27,7 +33,9 @@ "asian_egg_noodles": { "icon": "noodles" }, - "asian_noodles": {}, + "asian_noodles": { + "icon": "noodles" + }, "asparagus": { "category": "fruits_vegetables", "icon": "asparagus" @@ -54,13 +62,20 @@ "category": "bread", "icon": "bread" }, - "bakefish": {}, - "baking_cocoa": {}, + "bakefish": { + "icon": "fish_food" + }, + "baking_cocoa": { + "category": "dairy", + "icon": "chocolate_bar" + }, "baking_mix": {}, "baking_paper": { "category": "hygiene" }, - "baking_powder": {}, + "baking_powder": { + "category": "dairy" + }, "baking_soda": {}, "baking_yeast": {}, "balsamic_vinegar": {}, @@ -96,6 +111,7 @@ }, "birthday_card": {}, "black_beans": {}, + "blister_plaster": {}, "bockwurst": { "icon": "sausage" }, @@ -111,7 +127,10 @@ "category": "fruits_vegetables", "icon": "broccoli" }, - "brown_sugar": {}, + "brown_sugar": { + "category": "dairy", + "icon": "sugar" + }, "brussels_sprouts": { "category": "fruits_vegetables" }, @@ -136,13 +155,16 @@ "butter_cookies": { "icon": "cookies" }, + "butternut_squash": {}, "button_cells": {}, "börek_cheese": { "category": "dairy", "icon": "cheese" }, "cake": {}, - "cake_icing": {}, + "cake_icing": { + "category": "dairy" + }, "cane_sugar": {}, "cannelloni": {}, "canola_oil": { @@ -231,7 +253,13 @@ "coconut_oil": { "icon": "coconut" }, - "colorful_sprinkles": {}, + "coffee_powder": { + "icon": "coffee_beans" + }, + "colorful_sprinkles": { + "category": "dairy", + "icon": "candy_cane" + }, "concealer": {}, "cookies": { "category": "snacks", @@ -242,13 +270,18 @@ "category": "fruits_vegetables", "icon": "corn" }, - "cornflakes": {}, - "cornstarch": {}, + "cornflakes": { + "icon": "cereal" + }, + "cornstarch": { + "category": "dairy" + }, "cornys": {}, "corriander": { "category": "fruits_vegetables", "icon": "natural_food" }, + "cotton_rounds": {}, "cough_drops": {}, "couscous": {}, "covid_rapid_test": {}, @@ -314,6 +347,7 @@ "dried_tomatoes": { "icon": "tomato" }, + "dry_yeast": {}, "edamame": { "icon": "peas" }, @@ -327,7 +361,10 @@ "category": "dairy", "icon": "eggs" }, - "enoki_mushrooms": {}, + "enoki_mushrooms": { + "category": "fruits_vegetables", + "icon": "mushroom" + }, "eyebrow_gel": {}, "falafel": {}, "falafel_powder": {}, @@ -344,6 +381,7 @@ "icon": "fish_food" }, "flour": { + "category": "dairy", "icon": "flour" }, "flushing": {}, @@ -354,6 +392,9 @@ "category": "freezer", "icon": "strawberry" }, + "frozen_broccoli": { + "icon": "broccoli" + }, "frozen_fruit": { "category": "freezer" }, @@ -385,7 +426,12 @@ "category": "fruits_vegetables", "icon": "ginger" }, - "glass_noodles": {}, + "ginger_ale": { + "icon": "cola" + }, + "glass_noodles": { + "icon": "noodles" + }, "gluten": { "category": "bread" }, @@ -418,6 +464,12 @@ "hair_gel": {}, "hair_ties": {}, "hair_wax": {}, + "ham": { + "icon": "jamon" + }, + "ham_cubes": { + "icon": "jamon" + }, "hand_soap": { "category": "hygiene" }, @@ -439,6 +491,7 @@ "icon": "lettuce" }, "herb_baguettes": {}, + "herb_butter": {}, "herb_cream_cheese": { "category": "dairy" }, @@ -532,6 +585,7 @@ "lip_care": { "category": "hygiene" }, + "liqueur": {}, "low-fat_curd_cheese": { "category": "dairy", "icon": "yogurt" @@ -597,6 +651,7 @@ }, "mustard": {}, "nail_file": {}, + "nail_polish_remover": {}, "neutral_oil": {}, "nori_sheets": {}, "nutmeg": {}, @@ -607,7 +662,9 @@ "oatmeal": { "icon": "wheat" }, - "oatmeal_cookies": {}, + "oatmeal_cookies": { + "icon": "cookies" + }, "oatsome": {}, "obatzda": { "category": "refrigerated" @@ -631,6 +688,7 @@ "icon": "orange" }, "oranges": { + "category": "fruits_vegetables", "icon": "orange" }, "oregano": { @@ -644,9 +702,14 @@ "category": "hygiene" }, "pak_choi": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "lettuce" }, "pantyhose": {}, + "papaya": { + "category": "fruits_vegetables", + "icon": "papaya" + }, "paprika": { "category": "fruits_vegetables", "icon": "paprika" @@ -669,6 +732,7 @@ "category": "fruits_vegetables" }, "peanut_butter": { + "category": "dairy", "icon": "peanuts" }, "peanut_flips": { @@ -863,7 +927,9 @@ }, "schlemmerfilet": {}, "schupfnudeln": {}, - "semolina_porridge": {}, + "semolina_porridge": { + "category": "dairy" + }, "sesame": { "icon": "lentil" }, @@ -891,6 +957,9 @@ "sieved_tomatoes": { "icon": "tomato" }, + "skyr": { + "icon": "yogurt" + }, "sliced_cheese": { "category": "dairy", "icon": "cheese" @@ -902,18 +971,20 @@ "icon": "natural_food" }, "snacks": { - "category": "snacks" + "category": "snacks", + "icon": "peanuts" + }, + "soap": { + "category": "hygiene" }, - "soap": {}, "soba_noodles": {}, "soft_drinks": { "category": "drinks", "icon": "cola" }, - "softdrinks": { - "category": "drinks" + "soup_vegetables": { + "category": "fruits_vegetables" }, - "soup_vegetables": {}, "sour_cream": { "category": "dairy" }, @@ -973,6 +1044,7 @@ "icon": "strawberry" }, "sugar": { + "category": "dairy", "icon": "sugar" }, "summer_roll_paper": {}, @@ -1018,7 +1090,10 @@ "tofu": { "icon": "natural_food" }, - "toilet_paper": {}, + "toilet_paper": { + "category": "hygiene", + "icon": "toilet_paper" + }, "tomato_juice": { "icon": "tomato" }, @@ -1072,6 +1147,9 @@ "vodka": { "category": "drinks" }, + "walnuts": { + "category": "dairy" + }, "washing_gel": { "category": "hygiene", "icon": "soap_bubble" @@ -1117,7 +1195,8 @@ "worcester_sauce": {}, "wrapping_paper": {}, "wraps": { - "category": "bread" + "category": "bread", + "icon": "nachos" }, "yeast": {}, "yeast_flakes": {}, diff --git a/backend/templates/l10n/da.json b/backend/templates/l10n/da.json index 26306fff..aa7f522c 100644 --- a/backend/templates/l10n/da.json +++ b/backend/templates/l10n/da.json @@ -384,7 +384,6 @@ "soap": "Sæbe", "soba_noodles": "Soba-nudler", "soft_drinks": "Sodavand", - "softdrinks": "Sodavand", "soup_vegetables": "Suppe grøntsager", "sour_cream": "Creme fraiche", "sour_cucumbers": "Sure agurker", diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index 7990d0fe..aaba3179 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -12,6 +12,7 @@ "snacks": "🥜 Snacks" }, "items": { + "agave_syrup": "Agavendicksaft", "aioli": "Aioli", "amaretto": "Amaretto", "apple": "Apfel", @@ -49,6 +50,7 @@ "beetroot": "Rote Bete", "birthday_card": "Geburtstagskarte", "black_beans": "Schwarze Bohnen", + "blister_plaster": "Blasenpflaster", "bockwurst": "Bockwurst", "bodywash": "Duschgel", "bread": "Brot", @@ -64,6 +66,7 @@ "burger_sauces": "Burgersaucen", "butter": "Butter", "butter_cookies": "Butterkekse", + "butternut_squash": "Butternut-Kürbis", "button_cells": "Knopfzellen", "börek_cheese": "Börek Käse", "cake": "Kuchen", @@ -102,6 +105,7 @@ "coconut_flakes": "Kokosraspel", "coconut_milk": "Kokosnuss-Milch", "coconut_oil": "Kokosöl", + "coffee_powder": "Kaffeepulver", "colorful_sprinkles": "Bunte Streusel", "concealer": "Concealer", "cookies": "Kekse", @@ -111,6 +115,7 @@ "cornstarch": "Speisestärke", "cornys": "Cornys", "corriander": "Korriander", + "cotton_rounds": "Wattepads", "cough_drops": "Hustenbonbons", "couscous": "Couscous", "covid_rapid_test": "COVID Schnelltest", @@ -139,6 +144,7 @@ "dishwasher_tabs": "Tabs für die Spülmaschine", "disinfection_spray": "Desinfektionsspray", "dried_tomatoes": "Getrocknete Tomaten", + "dry_yeast": "Trockenhefe", "edamame": "Edamame", "egg_salad": "Eiersalat", "egg_yolk": "Eigelb", @@ -156,6 +162,7 @@ "flushing": "Spülung", "fresh_chili_pepper": "Frische Chilischote", "frozen_berries": "TK Beeren", + "frozen_broccoli": "TK Brokkoli", "frozen_fruit": "TK Obst", "frozen_pizza": "Tiefkühlpizza", "frozen_spinach": "TK Spinat", @@ -167,6 +174,7 @@ "garlic_granules": "Knoblauch Granulat", "gherkins": "Gewürzgurken", "ginger": "Ingwer", + "ginger_ale": "Ginger Ale", "glass_noodles": "Glasnudeln", "gluten": "Gluten", "gnocchi": "Gnocchi", @@ -183,6 +191,8 @@ "hair_gel": "Haargel", "hair_ties": "Haargummis", "hair_wax": "Haar-Wachs", + "ham": "Schinken", + "ham_cubes": "Schinkenwürfel", "hand_soap": "Handseife", "handkerchief_box": "Taschentuchbox", "handkerchiefs": "Taschentücher", @@ -192,6 +202,7 @@ "hazelnuts": "Haselnüsse", "head_of_lettuce": "Salatkopf", "herb_baguettes": "Kräuterbaguettes", + "herb_butter": "Kräuterbutter", "herb_cream_cheese": "Kräuterfrischkäse", "honey": "Honig", "honey_wafers": "Honigwaffeln", @@ -227,6 +238,7 @@ "lime": "Limette", "linguine": "Linguine", "lip_care": "Lippenpflege", + "liqueur": "Likör", "low-fat_curd_cheese": "Magerquark", "maggi": "Maggi", "magnesium": "Magnesium", @@ -257,6 +269,7 @@ "mushrooms": "Champignons", "mustard": "Senf", "nail_file": "Nagelpfeile", + "nail_polish_remover": "Nagellackentferner", "neutral_oil": "Neutrales Öl", "nori_sheets": "Nori Blätter", "nutmeg": "Muskatnuss", @@ -277,6 +290,7 @@ "organic_waste_bags": "Biomülltüten", "pak_choi": "Pak Choi", "pantyhose": "Strumpfhose", + "papaya": "Papaya", "paprika": "Paprika", "paprika_seasoning": "Paprikagewürz", "pardina_lentils_dried": "Pardina Linsen getrocknet", @@ -377,14 +391,14 @@ "shower_gel": "Duschgel", "shredded_cheese": "Geriebener Käse", "sieved_tomatoes": "Gesiebte Tomaten", + "skyr": "Skyr", "sliced_cheese": "Scheibenkäse", "smoked_paprika": "Smoked Paprika", "smoked_tofu": "Räuchertofu", "snacks": "Snacks", "soap": "Seife", "soba_noodles": "Soba-Nudeln", - "soft_drinks": "Softdrinks", - "softdrinks": "Erfrischungsgetränke", + "soft_drinks": "Erfrischungsgetränke", "soup_vegetables": "Suppengemüse", "sour_cream": "Schmand", "sour_cucumbers": "Saure Gurken", @@ -454,6 +468,7 @@ "vinegar": "Essig", "vitamin_tablets": "Vitamintabletten", "vodka": "Vodka", + "walnuts": "Walnüsse", "washing_gel": "Waschgel", "washing_powder": "Waschpulver", "water": "Wasser", @@ -480,4 +495,4 @@ "zinc_cream": "Zinkcreme", "zucchini": "Zucchini" } -} +} \ No newline at end of file diff --git a/backend/templates/l10n/el.json b/backend/templates/l10n/el.json index 317105b1..278438b6 100644 --- a/backend/templates/l10n/el.json +++ b/backend/templates/l10n/el.json @@ -384,7 +384,6 @@ "soap": "Σαπούνι", "soba_noodles": "Νουντλς σόμπα", "soft_drinks": "Αναψυκτικά", - "softdrinks": "Αναψυκτικά", "soup_vegetables": "Λαχανικά σούπας", "sour_cream": "Ξινή κρέμα", "sour_cucumbers": "Ξινά αγγούρια", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index 1fd10679..f868dbba 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -12,6 +12,7 @@ "snacks": "🥜 Snacks" }, "items": { + "agave_syrup": "Agave syrup", "aioli": "Aioli", "amaretto": "Amaretto", "apple": "Apple", @@ -49,6 +50,7 @@ "beetroot": "Beetroot", "birthday_card": "Birthday card", "black_beans": "Black beans", + "blister_plaster": "Blister plaster", "bockwurst": "Bockwurst", "bodywash": "Bodywash", "bread": "Bread", @@ -64,6 +66,7 @@ "burger_sauces": "Burger sauces", "butter": "Butter", "butter_cookies": "Butter cookies", + "butternut_squash": "Butternut squash", "button_cells": "Button cells", "börek_cheese": "Börek cheese", "cake": "Cake", @@ -102,6 +105,7 @@ "coconut_flakes": "Coconut flakes", "coconut_milk": "Coconut milk", "coconut_oil": "Coconut oil", + "coffee_powder": "Coffee powder", "colorful_sprinkles": "Colorful sprinkles", "concealer": "Concealer", "cookies": "Cookies", @@ -111,6 +115,7 @@ "cornstarch": "Cornstarch", "cornys": "Cornys", "corriander": "Corriander", + "cotton_rounds": "Cotton rounds", "cough_drops": "Cough drops", "couscous": "Couscous", "covid_rapid_test": "COVID rapid test", @@ -139,6 +144,7 @@ "dishwasher_tabs": "Dishwasher tabs", "disinfection_spray": "Disinfection spray", "dried_tomatoes": "Dried tomatoes", + "dry_yeast": "Dry yeast", "edamame": "Edamame", "egg_salad": "Egg salad", "egg_yolk": "Egg yolk", @@ -156,6 +162,7 @@ "flushing": "Flushing", "fresh_chili_pepper": "Fresh chili pepper", "frozen_berries": "Frozen berries", + "frozen_broccoli": "Frozen broccoli", "frozen_fruit": "Frozen fruit", "frozen_pizza": "Frozen pizza", "frozen_spinach": "Frozen spinach", @@ -167,6 +174,7 @@ "garlic_granules": "Garlic granules", "gherkins": "Gherkins", "ginger": "Ginger", + "ginger_ale": "Ginger ale", "glass_noodles": "Glass noodles", "gluten": "Gluten", "gnocchi": "Gnocchi", @@ -183,6 +191,8 @@ "hair_gel": "Hair gel", "hair_ties": "Hair ties", "hair_wax": "Hair Wax", + "ham": "Ham", + "ham_cubes": "Ham cubes", "hand_soap": "Hand soap", "handkerchief_box": "Handkerchief box", "handkerchiefs": "Handkerchiefs", @@ -192,6 +202,7 @@ "hazelnuts": "Hazelnuts", "head_of_lettuce": "Head of lettuce", "herb_baguettes": "Herb baguettes", + "herb_butter": "Herb butter", "herb_cream_cheese": "Herb cream cheese", "honey": "Honey", "honey_wafers": "Honey wafers", @@ -227,6 +238,7 @@ "lime": "Lime", "linguine": "Linguine", "lip_care": "Lip Care", + "liqueur": "Liqueur", "low-fat_curd_cheese": "Low-fat curd cheese", "maggi": "Maggi", "magnesium": "Magnesium", @@ -257,10 +269,11 @@ "mushrooms": "Mushrooms", "mustard": "Mustard", "nail_file": "Nail file", + "nail_polish_remover": "Nail polish remover", "neutral_oil": "Neutral oil", "nori_sheets": "Nori sheets", "nutmeg": "Nutmeg", - "oat_milk": "Oat drink", + "oat_milk": "Oat milk", "oatmeal": "Oatmeal", "oatmeal_cookies": "Oatmeal cookies", "oatsome": "Oatsome", @@ -277,6 +290,7 @@ "organic_waste_bags": "Organic waste bags", "pak_choi": "Pak Choi", "pantyhose": "Pantyhose", + "papaya": "Papaya", "paprika": "Paprika", "paprika_seasoning": "Paprika seasoning", "pardina_lentils_dried": "Pardina lentils dried", @@ -377,6 +391,7 @@ "shower_gel": "Shower gel", "shredded_cheese": "Shredded cheese", "sieved_tomatoes": "Sieved tomatoes", + "skyr": "Skyr", "sliced_cheese": "Sliced cheese", "smoked_paprika": "Smoked paprika", "smoked_tofu": "Smoked tofu", @@ -384,7 +399,6 @@ "soap": "Soap", "soba_noodles": "Soba noodles", "soft_drinks": "Soft drinks", - "softdrinks": "Softdrinks", "soup_vegetables": "Soup vegetables", "sour_cream": "Sour cream", "sour_cucumbers": "Sour cucumbers", @@ -454,6 +468,7 @@ "vinegar": "Vinegar", "vitamin_tablets": "Vitamin tablets", "vodka": "Vodka", + "walnuts": "Walnuts", "washing_gel": "Washing gel", "washing_powder": "Washing powder", "water": "Water", diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index 81e93643..4273a6ff 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -384,7 +384,6 @@ "soap": "Jabón", "soba_noodles": "Fideos soba", "soft_drinks": "Refrescos", - "softdrinks": "Refrescos", "soup_vegetables": "Sopa de verduras", "sour_cream": "Crema agria", "sour_cucumbers": "Pepinos agrios", diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json index 04b7c6e4..4752ac70 100644 --- a/backend/templates/l10n/fi.json +++ b/backend/templates/l10n/fi.json @@ -384,7 +384,6 @@ "soap": "Saippua", "soba_noodles": "Soba-nuudelit", "soft_drinks": "Alkoholittomat juomat", - "softdrinks": "Alkoholittomat juomat", "soup_vegetables": "Keittovihannekset", "sour_cream": "Hapankerma", "sour_cucumbers": "Hapakurkut", diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json index f51e6f4e..0c886870 100644 --- a/backend/templates/l10n/fr.json +++ b/backend/templates/l10n/fr.json @@ -384,7 +384,6 @@ "soap": "Savon", "soba_noodles": "Nouilles Soba", "soft_drinks": "Boissons gazeuses", - "softdrinks": "Boissons gazeuses", "soup_vegetables": "Soupe de légumes", "sour_cream": "Crème aigre", "sour_cucumbers": "Concombres aigres", diff --git a/backend/templates/l10n/hu.json b/backend/templates/l10n/hu.json index 7b6c964c..4527c94f 100644 --- a/backend/templates/l10n/hu.json +++ b/backend/templates/l10n/hu.json @@ -384,7 +384,6 @@ "soap": "Szappan", "soba_noodles": "Soba tészta", "soft_drinks": "Üditő", - "softdrinks": "Szénsavas italok", "soup_vegetables": "Leves zöldség", "sour_cream": "Tejföl", "sour_cucumbers": "Savanyú uborka", diff --git a/backend/templates/l10n/id.json b/backend/templates/l10n/id.json index 56d5a50b..8ac1235a 100644 --- a/backend/templates/l10n/id.json +++ b/backend/templates/l10n/id.json @@ -384,7 +384,6 @@ "soap": "Sabun", "soba_noodles": "Mie soba", "soft_drinks": "Minuman ringan", - "softdrinks": "Minuman ringan", "soup_vegetables": "Sup sayuran", "sour_cream": "Krim asam", "sour_cucumbers": "Mentimun asam", diff --git a/backend/templates/l10n/it.json b/backend/templates/l10n/it.json index e916e2d0..d6ada33a 100644 --- a/backend/templates/l10n/it.json +++ b/backend/templates/l10n/it.json @@ -384,7 +384,6 @@ "soap": "Sapone", "soba_noodles": "Tagliatelle di soba", "soft_drinks": "Bevande analcoliche", - "softdrinks": "Bevande analcoliche", "soup_vegetables": "Zuppa di verdure", "sour_cream": "Panna acida", "sour_cucumbers": "Cetrioli acidi", diff --git a/backend/templates/l10n/nb_NO.json b/backend/templates/l10n/nb_NO.json index aec32e86..def61e4b 100644 --- a/backend/templates/l10n/nb_NO.json +++ b/backend/templates/l10n/nb_NO.json @@ -384,7 +384,6 @@ "soap": "Såpe", "soba_noodles": "Soba-nudler", "soft_drinks": "Brus", - "softdrinks": "Brus", "soup_vegetables": "Suppe med grønnsaker", "sour_cream": "Rømme", "sour_cucumbers": "Sure agurker", diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json index 2f082445..4fe6c9be 100644 --- a/backend/templates/l10n/nl.json +++ b/backend/templates/l10n/nl.json @@ -384,7 +384,6 @@ "soap": "Zeep", "soba_noodles": "Soba noedels", "soft_drinks": "Frisdranken", - "softdrinks": "Frisdranken", "soup_vegetables": "Soepgroenten", "sour_cream": "Zure room", "sour_cucumbers": "Zure komkommers", diff --git a/backend/templates/l10n/pl.json b/backend/templates/l10n/pl.json index a96c82ee..71da14d3 100644 --- a/backend/templates/l10n/pl.json +++ b/backend/templates/l10n/pl.json @@ -384,7 +384,6 @@ "soap": "Mydło", "soba_noodles": "Makaron soba", "soft_drinks": "Napoje gazowane", - "softdrinks": "Napoje gazowane", "soup_vegetables": "Warzywa do zupy", "sour_cream": "Kwaśna śmietana", "sour_cucumbers": "Kwaśne ogórki", diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json index 19503a4f..fee86d35 100644 --- a/backend/templates/l10n/pt.json +++ b/backend/templates/l10n/pt.json @@ -384,7 +384,6 @@ "soap": "Sabonete", "soba_noodles": "Macarrão Soba", "soft_drinks": "Refrigerantes", - "softdrinks": "Refrigerantes", "soup_vegetables": "Sopa de legumes", "sour_cream": "Creme de leite", "sour_cucumbers": "Pepinos azedos", diff --git a/backend/templates/l10n/pt_BR.json b/backend/templates/l10n/pt_BR.json index 480c8e8b..05a0e88c 100644 --- a/backend/templates/l10n/pt_BR.json +++ b/backend/templates/l10n/pt_BR.json @@ -384,7 +384,6 @@ "soap": "Sabonete", "soba_noodles": "Macarrão Soba", "soft_drinks": "Refrigerantes", - "softdrinks": "Refrigerantes", "soup_vegetables": "Sopa de legumes", "sour_cream": "Creme azedo", "sour_cucumbers": "Pepinos azedos", diff --git a/backend/templates/l10n/ru.json b/backend/templates/l10n/ru.json index 8afc83cf..973afbc0 100644 --- a/backend/templates/l10n/ru.json +++ b/backend/templates/l10n/ru.json @@ -384,7 +384,6 @@ "soap": "Мыло", "soba_noodles": "Лапша соба", "soft_drinks": "Лимонады", - "softdrinks": "Прохладительные напитки", "soup_vegetables": "Суп овощной", "sour_cream": "Сметана", "sour_cucumbers": "Маринованные огурцы", diff --git a/backend/templates/l10n/tr.json b/backend/templates/l10n/tr.json index f2a9a3c2..be79d3e4 100644 --- a/backend/templates/l10n/tr.json +++ b/backend/templates/l10n/tr.json @@ -384,7 +384,6 @@ "soap": "Sabun", "soba_noodles": "Soba eriştesi", "soft_drinks": "Meşrubat", - "softdrinks": "Meşrubat", "soup_vegetables": "Çorba sebzeleri", "sour_cream": "Ekşi Krema", "sour_cucumbers": "Kornişon Turşu", diff --git a/backend/templates/l10n/zh_Hans.json b/backend/templates/l10n/zh_Hans.json index f0997eb3..1d5ca8a9 100644 --- a/backend/templates/l10n/zh_Hans.json +++ b/backend/templates/l10n/zh_Hans.json @@ -384,7 +384,6 @@ "soap": "肥皂", "soba_noodles": "荞麦面", "soft_drinks": "软饮料", - "softdrinks": "软饮料", "soup_vegetables": "蔬菜汤", "sour_cream": "酸奶油", "sour_cucumbers": "酸黄瓜", From 4f85bc7fd46ccc9d563769a9d2d71da81255d3a8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 3 Nov 2023 00:06:23 +0100 Subject: [PATCH 426/496] Prepare release 82 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index c1671974..878139a3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 81 +BACKEND_VERSION = 82 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 30fed0ff2639181d9fb34651bebc32b2333f4d73 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 3 Nov 2023 13:04:21 +0100 Subject: [PATCH 427/496] fix: scrape recipes (TomBursch/kitchenowl-backend#61) * fix: scrape recipes * fix: import --- .../controller/recipe/recipe_controller.py | 186 ++++++++++-------- 1 file changed, 103 insertions(+), 83 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 7bdfbaa6..afab8fbe 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -1,30 +1,38 @@ import re -from app.errors import NotFoundRequest +from app.errors import NotFoundRequest, InvalidUsage from app.models.recipe import RecipeItems, RecipeTags from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required from app.helpers import validate_args, authorize_household from app.models import Recipe, Item, Tag from recipe_scrapers import scrape_me -from recipe_scrapers._exceptions import SchemaOrgException +from recipe_scrapers._exceptions import SchemaOrgException, NoSchemaFoundInWildMode from ingredient_parser import parse_ingredient from app.service.file_has_access_or_download import file_has_access_or_download -from .schemas import SearchByNameRequest, AddRecipe, UpdateRecipe, GetAllFilterRequest, ScrapeRecipe +from .schemas import ( + SearchByNameRequest, + AddRecipe, + UpdateRecipe, + GetAllFilterRequest, + ScrapeRecipe, +) -recipe = Blueprint('recipe', __name__) -recipeHousehold = Blueprint('recipe', __name__) +recipe = Blueprint("recipe", __name__) +recipeHousehold = Blueprint("recipe", __name__) -@recipeHousehold.route('', methods=['GET']) +@recipeHousehold.route("", methods=["GET"]) @jwt_required() @authorize_household() def getAllRecipes(household_id): - return jsonify([e.obj_to_full_dict() for e in Recipe.all_from_household_by_name(household_id)]) + return jsonify( + [e.obj_to_full_dict() for e in Recipe.all_from_household_by_name(household_id)] + ) -@recipe.route('/', methods=['GET']) +@recipe.route("/", methods=["GET"]) @jwt_required() def getRecipeById(id): recipe = Recipe.find_by_id(id) @@ -34,42 +42,41 @@ def getRecipeById(id): return jsonify(recipe.obj_to_full_dict()) -@recipeHousehold.route('', methods=['POST']) +@recipeHousehold.route("", methods=["POST"]) @jwt_required() @authorize_household() @validate_args(AddRecipe) def addRecipe(args, household_id): recipe = Recipe() - recipe.name = args['name'] - recipe.description = args['description'] + recipe.name = args["name"] + 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 'photo' in args and args['photo'] != recipe.photo: - recipe.photo = file_has_access_or_download(args['photo'], recipe.photo) + 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 "photo" in args and args["photo"] != recipe.photo: + recipe.photo = file_has_access_or_download(args["photo"], recipe.photo) recipe.save() - if 'items' in args: - for recipeItem in args['items']: - item = Item.find_by_name(household_id, recipeItem['name']) + if "items" in args: + 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 "tags" in args: + for tagName in args["tags"]: tag = Tag.find_by_name(household_id, tagName) if not tag: tag = Tag.create_by_name(household_id, tagName) @@ -80,7 +87,7 @@ def addRecipe(args, household_id): return jsonify(recipe.obj_to_full_dict()) -@recipe.route('/', methods=['POST']) +@recipe.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateRecipe) def updateRecipe(args, id): # noqa: C901 @@ -89,52 +96,51 @@ def updateRecipe(args, id): # noqa: C901 raise NotFoundRequest() recipe.checkAuthorized() - if 'name' in args: - recipe.name = args['name'] - 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 'photo' in args and args['photo'] != recipe.photo: - recipe.photo = file_has_access_or_download(args['photo'], recipe.photo) + if "name" in args: + recipe.name = args["name"] + 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 "photo" in args and args["photo"] != recipe.photo: + recipe.photo = file_has_access_or_download(args["photo"], recipe.photo) recipe.save() - if 'items' in args: + if "items" in args: 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 "description" in recipeItem: + con.description = recipeItem["description"] + if "optional" in recipeItem: + 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 "tags" in args: 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) @@ -147,7 +153,7 @@ def updateRecipe(args, id): # noqa: C901 return jsonify(recipe.obj_to_full_dict()) -@recipe.route('/', methods=['DELETE']) +@recipe.route("/", methods=["DELETE"]) @jwt_required() def deleteRecipeById(id): recipe = Recipe.find_by_id(id) @@ -155,33 +161,43 @@ def deleteRecipeById(id): raise NotFoundRequest() recipe.checkAuthorized() recipe.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@recipeHousehold.route('/search', methods=['GET']) +@recipeHousehold.route("/search", methods=["GET"]) @jwt_required() @authorize_household() @validate_args(SearchByNameRequest) def searchRecipeByName(args, household_id): - if 'only_ids' in args and args['only_ids']: - return jsonify([e.id for e in Recipe.search_name(household_id, args['query'])]) - return jsonify([e.obj_to_full_dict() for e in Recipe.search_name(household_id, args['query'])]) + if "only_ids" in args and args["only_ids"]: + return jsonify([e.id for e in Recipe.search_name(household_id, args["query"])]) + return jsonify( + [e.obj_to_full_dict() for e in Recipe.search_name(household_id, args["query"])] + ) -@recipeHousehold.route('/filter', methods=['POST']) +@recipeHousehold.route("/filter", methods=["POST"]) @jwt_required() @authorize_household() @validate_args(GetAllFilterRequest) def getAllFiltered(args, household_id): - return jsonify([e.obj_to_full_dict() for e in Recipe.all_by_name_with_filter(household_id, args["filter"])]) + return jsonify( + [ + e.obj_to_full_dict() + for e in Recipe.all_by_name_with_filter(household_id, args["filter"]) + ] + ) -@recipeHousehold.route('/scrape', methods=['GET', 'POST']) +@recipeHousehold.route("/scrape", methods=["GET", "POST"]) @jwt_required() @authorize_household() @validate_args(ScrapeRecipe) def scrapeRecipe(args, household_id): - scraper = scrape_me(args['url'], wild_mode=True) + try: + scraper = scrape_me(args["url"], wild_mode=True) + except NoSchemaFoundInWildMode: + raise InvalidUsage() recipe = Recipe() recipe.name = scraper.title() try: @@ -202,7 +218,7 @@ def scrapeRecipe(args, household_id): recipe.yields = int(yields.group()) except (NotImplementedError, ValueError, SchemaOrgException): pass - description = '' + description = "" try: description = scraper.description() + "\n\n" except (NotImplementedError, ValueError, SchemaOrgException): @@ -213,20 +229,24 @@ def scrapeRecipe(args, household_id): pass recipe.description = description recipe.photo = scraper.image() - recipe.source = args['url'] + recipe.source = args["url"] items = {} for ingredient in scraper.ingredients(): parsed = parse_ingredient(ingredient) - item = Item.find_by_name(household_id, parsed.name) + name = parsed.name.text if parsed.name else ingredient + item = Item.find_by_name(household_id, name) if item: items[ingredient] = item.obj_to_dict() | { - "description": ' '.join( - filter(None, [parsed.quantity + parsed.unit, parsed.comment])), + "description": " ".join( + filter(None, [parsed.quantity + parsed.unit, parsed.comment]) + ), "optional": False, } else: - items[ingredient] = None - return jsonify({ - 'recipe': recipe.obj_to_dict(), - 'items': items, - }) + items[ingredient] = None + return jsonify( + { + "recipe": recipe.obj_to_dict(), + "items": items, + } + ) From 93c982ccf3143ea4d14d36d4bd78920b11a52441 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 3 Nov 2023 13:06:23 +0100 Subject: [PATCH 428/496] Format code using black --- backend/app/api/register_controller.py | 64 +++-- backend/app/config.py | 126 ++++---- .../analytics/analytics_controller.py | 23 +- .../app/controller/auth/auth_controller.py | 93 +++--- backend/app/controller/auth/schemas.py | 16 +- .../category/category_controller.py | 36 +-- backend/app/controller/category/schemas.py | 19 +- .../controller/expense/expense_controller.py | 271 +++++++++++------- backend/app/controller/expense/schemas.py | 84 ++---- .../exportimport/export_controller.py | 26 +- .../exportimport/import_controller.py | 29 +- .../app/controller/exportimport/schemas.py | 54 ++-- backend/app/controller/health_controller.py | 29 +- .../household/household_controller.py | 87 +++--- backend/app/controller/household/schemas.py | 12 +- .../app/controller/item/item_controller.py | 61 ++-- backend/app/controller/item/schemas.py | 15 +- .../onboarding/onboarding_controller.py | 17 +- backend/app/controller/onboarding/schemas.py | 5 +- .../controller/planner/planner_controller.py | 42 +-- backend/app/controller/planner/schemas.py | 12 +- backend/app/controller/recipe/schemas.py | 51 +--- .../settings/settings_controller.py | 6 +- .../app/controller/shoppinglist/schemas.py | 37 +-- .../shoppinglist/shoppinglist_controller.py | 240 ++++++++++------ backend/app/controller/tag/schemas.py | 9 +- backend/app/controller/tag/tag_controller.py | 42 +-- .../controller/upload/upload_controller.py | 25 +- backend/app/controller/user/schemas.py | 14 +- .../app/controller/user/user_controller.py | 80 +++--- backend/app/errors/__init__.py | 3 +- backend/app/helpers/authorize_household.py | 22 +- backend/app/helpers/db_list_type.py | 2 +- .../app/helpers/db_model_authorize_mixin.py | 5 +- backend/app/helpers/db_model_mixin.py | 9 +- backend/app/helpers/db_set_type.py | 2 +- backend/app/helpers/server_admin_required.py | 6 +- backend/app/helpers/timestamp_mixin.py | 6 +- backend/app/helpers/validate_args.py | 6 +- backend/app/helpers/validate_socket_args.py | 2 +- backend/app/jobs/cluster_shoppings.py | 10 +- backend/app/jobs/item_ordering.py | 8 +- backend/app/jobs/item_suggestions.py | 45 +-- backend/app/jobs/jobs.py | 11 +- backend/app/jobs/recipe_suggestions.py | 24 +- backend/app/models/association.py | 28 +- backend/app/models/category.py | 35 ++- backend/app/models/expense.py | 77 ++--- backend/app/models/expense_category.py | 16 +- backend/app/models/file.py | 18 +- backend/app/models/history.py | 56 ++-- backend/app/models/household.py | 86 +++--- backend/app/models/item.py | 109 +++++-- backend/app/models/planner.py | 22 +- backend/app/models/recipe.py | 201 ++++++++----- backend/app/models/recipe_history.py | 40 ++- backend/app/models/settings.py | 2 +- backend/app/models/shoppinglist.py | 44 +-- backend/app/models/tag.py | 25 +- backend/app/models/token.py | 76 +++-- backend/app/models/user.py | 96 ++++--- .../service/file_has_access_or_download.py | 13 +- .../app/service/importServices/__init__.py | 2 +- .../service/importServices/import_expense.py | 32 +-- .../app/service/importServices/import_item.py | 13 +- .../service/importServices/import_recipe.py | 61 ++-- .../importServices/import_shoppinglist.py | 1 - backend/app/service/import_language.py | 31 +- backend/app/service/recalculate_balances.py | 39 ++- backend/app/service/recalculate_blurhash.py | 3 +- backend/app/sockets/connection_socket.py | 4 +- backend/app/sockets/schemas.py | 7 +- backend/app/sockets/shoppinglist_socket.py | 40 +-- backend/app/util/description_merger.py | 113 ++++---- backend/app/util/description_splitter.py | 55 ++-- backend/app/util/filename_validator.py | 6 +- backend/app/util/multi_dict_list.py | 2 +- 77 files changed, 1767 insertions(+), 1372 deletions(-) diff --git a/backend/app/api/register_controller.py b/backend/app/api/register_controller.py index f70fec95..e5d7b314 100644 --- a/backend/app/api/register_controller.py +++ b/backend/app/api/register_controller.py @@ -3,31 +3,45 @@ import app.controller as api # Register Endpoints -apiv1 = Blueprint('api', __name__) +apiv1 = Blueprint("api", __name__) -api.household.register_blueprint(api.export, url_prefix='//export') -api.household.register_blueprint(api.importBP, url_prefix='//import') -api.household.register_blueprint(api.categoryHousehold, url_prefix='//category') -api.household.register_blueprint(api.plannerHousehold, url_prefix='//planner') -api.household.register_blueprint(api.expenseHousehold, url_prefix='//expense') -api.household.register_blueprint(api.itemHousehold, url_prefix='//item') -api.household.register_blueprint(api.recipeHousehold, url_prefix='//recipe') -api.household.register_blueprint(api.shoppinglistHousehold, url_prefix='//shoppinglist') -api.household.register_blueprint(api.tagHousehold, url_prefix='//tag') +api.household.register_blueprint(api.export, url_prefix="//export") +api.household.register_blueprint(api.importBP, url_prefix="//import") +api.household.register_blueprint( + api.categoryHousehold, url_prefix="//category" +) +api.household.register_blueprint( + api.plannerHousehold, url_prefix="//planner" +) +api.household.register_blueprint( + api.expenseHousehold, url_prefix="//expense" +) +api.household.register_blueprint( + api.itemHousehold, url_prefix="//item" +) +api.household.register_blueprint( + api.recipeHousehold, url_prefix="//recipe" +) +api.household.register_blueprint( + api.shoppinglistHousehold, url_prefix="//shoppinglist" +) +api.household.register_blueprint(api.tagHousehold, url_prefix="//tag") -apiv1.register_blueprint(api.health, url_prefix='/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V') -apiv1.register_blueprint(api.auth, url_prefix='/auth') -apiv1.register_blueprint(api.household, url_prefix='/household') -apiv1.register_blueprint(api.category, url_prefix='/category') -apiv1.register_blueprint(api.expense, url_prefix='/expense') -apiv1.register_blueprint(api.item, url_prefix='/item') -apiv1.register_blueprint(api.onboarding, url_prefix='/onboarding') -apiv1.register_blueprint(api.recipe, url_prefix='/recipe') -apiv1.register_blueprint(api.settings, url_prefix='/settings') -apiv1.register_blueprint(api.shoppinglist, url_prefix='/shoppinglist') -apiv1.register_blueprint(api.tag, url_prefix='/tag') -apiv1.register_blueprint(api.user, url_prefix='/user') -apiv1.register_blueprint(api.upload, url_prefix='/upload') -apiv1.register_blueprint(api.analytics, url_prefix='/analytics') +apiv1.register_blueprint( + api.health, url_prefix="/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V" +) +apiv1.register_blueprint(api.auth, url_prefix="/auth") +apiv1.register_blueprint(api.household, url_prefix="/household") +apiv1.register_blueprint(api.category, url_prefix="/category") +apiv1.register_blueprint(api.expense, url_prefix="/expense") +apiv1.register_blueprint(api.item, url_prefix="/item") +apiv1.register_blueprint(api.onboarding, url_prefix="/onboarding") +apiv1.register_blueprint(api.recipe, url_prefix="/recipe") +apiv1.register_blueprint(api.settings, url_prefix="/settings") +apiv1.register_blueprint(api.shoppinglist, url_prefix="/shoppinglist") +apiv1.register_blueprint(api.tag, url_prefix="/tag") +apiv1.register_blueprint(api.user, url_prefix="/user") +apiv1.register_blueprint(api.upload, url_prefix="/upload") +apiv1.register_blueprint(api.analytics, url_prefix="/analytics") -app.register_blueprint(apiv1, url_prefix='/api') +app.register_blueprint(apiv1, url_prefix="/api") diff --git a/backend/app/config.py b/backend/app/config.py index 878139a3..a9b05c3a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,7 +6,12 @@ from prometheus_client.core import CollectorRegistry from prometheus_flask_exporter import PrometheusMetrics from werkzeug.exceptions import MethodNotAllowed -from app.errors import NotFoundRequest, UnauthorizedRequest, ForbiddenRequest, InvalidUsage +from app.errors import ( + NotFoundRequest, + UnauthorizedRequest, + ForbiddenRequest, + InvalidUsage, +) from app.util import KitchenOwlJSONProvider from flask import Flask, request from flask_basicauth import BasicAuth @@ -24,75 +29,75 @@ APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) -STORAGE_PATH = os.getenv('STORAGE_PATH', PROJECT_DIR) -UPLOAD_FOLDER = STORAGE_PATH + '/upload' -ALLOWED_FILE_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} +STORAGE_PATH = os.getenv("STORAGE_PATH", PROJECT_DIR) +UPLOAD_FOLDER = STORAGE_PATH + "/upload" +ALLOWED_FILE_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif"} -PRIVACY_POLICY_URL = os.getenv('PRIVACY_POLICY_URL') -OPEN_REGISTRATION = os.getenv('OPEN_REGISTRATION', "False").lower() == "true" -EMAIL_MANDATORY = os.getenv('EMAIL_MANDATORY', "False").lower() == "true" +PRIVACY_POLICY_URL = os.getenv("PRIVACY_POLICY_URL") +OPEN_REGISTRATION = os.getenv("OPEN_REGISTRATION", "False").lower() == "true" +EMAIL_MANDATORY = os.getenv("EMAIL_MANDATORY", "False").lower() == "true" -COLLECT_METRICS = os.getenv('COLLECT_METRICS', "False").lower() == "true" +COLLECT_METRICS = os.getenv("COLLECT_METRICS", "False").lower() == "true" DB_URL = URL.create( - os.getenv('DB_DRIVER', "sqlite"), - username=os.getenv('DB_USER'), - password=os.getenv('DB_PASSWORD'), - host=os.getenv('DB_HOST'), - database=os.getenv('DB_NAME', STORAGE_PATH + "/database.db"), + os.getenv("DB_DRIVER", "sqlite"), + username=os.getenv("DB_USER"), + password=os.getenv("DB_PASSWORD"), + host=os.getenv("DB_HOST"), + database=os.getenv("DB_NAME", STORAGE_PATH + "/database.db"), ) JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) SUPPORTED_LANGUAGES = { - 'en': 'English', - 'cs': 'čeština', - 'da': 'Dansk', - 'de': 'Deutsch', - 'el': 'Ελληνικά', - 'es': 'Español', - 'fi': 'Suomi', - 'fr': 'Français', - 'hu': 'Magyar nyelv', - 'id': 'Bahasa Indonesia', - 'it': 'Italiano', - 'nb_NO': 'Bokmål', - 'nl': 'Nederlands', - 'pl': 'Polski', - 'pt': 'Português', - 'pt_BR': 'Português Brasileiro', - 'ru': 'русский язык', - 'sv': 'Svenska', - 'tr': 'Türkçe', - 'zh_Hans': '简化字', + "en": "English", + "cs": "čeština", + "da": "Dansk", + "de": "Deutsch", + "el": "Ελληνικά", + "es": "Español", + "fi": "Suomi", + "fr": "Français", + "hu": "Magyar nyelv", + "id": "Bahasa Indonesia", + "it": "Italiano", + "nb_NO": "Bokmål", + "nl": "Nederlands", + "pl": "Polski", + "pt": "Português", + "pt_BR": "Português Brasileiro", + "ru": "русский язык", + "sv": "Svenska", + "tr": "Türkçe", + "zh_Hans": "简化字", } Flask.json_provider_class = KitchenOwlJSONProvider app = Flask(__name__) -app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER -app.config['MAX_CONTENT_LENGTH'] = 32 * 1000 * 1000 # 32MB max upload -app.config['SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER +app.config["MAX_CONTENT_LENGTH"] = 32 * 1000 * 1000 # 32MB max upload +app.config["SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "super-secret") # SQLAlchemy -app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config["SQLALCHEMY_DATABASE_URI"] = DB_URL +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False # JWT -app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'super-secret') +app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "super-secret") app.config["JWT_ACCESS_TOKEN_EXPIRES"] = JWT_ACCESS_TOKEN_EXPIRES app.config["JWT_REFRESH_TOKEN_EXPIRES"] = JWT_REFRESH_TOKEN_EXPIRES if COLLECT_METRICS: # BASIC_AUTH - app.config['BASIC_AUTH_USERNAME'] = os.getenv('METRICS_USER', "kitchenowl") - app.config['BASIC_AUTH_PASSWORD'] = os.getenv('METRICS_PASSWORD', "ZqQtidgC5n3YXb") + app.config["BASIC_AUTH_USERNAME"] = os.getenv("METRICS_USER", "kitchenowl") + app.config["BASIC_AUTH_PASSWORD"] = os.getenv("METRICS_PASSWORD", "ZqQtidgC5n3YXb") convention = { - "ix": 'ix_%(column_0_label)s', + "ix": "ix_%(column_0_label)s", "uq": "uq_%(table_name)s_%(column_0_name)s", "ck": "ck_%(table_name)s_%(constraint_name)s", "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", - "pk": "pk_%(table_name)s" + "pk": "pk_%(table_name)s", } metadata = MetaData(naming_convention=convention) @@ -101,14 +106,21 @@ migrate = Migrate(app, db, render_as_batch=True) bcrypt = Bcrypt(app) jwt = JWTManager(app) -socketio = SocketIO(app, json=app.json, logger=app.logger, - cors_allowed_origins=os.getenv('FRONT_URL')) +socketio = SocketIO( + app, json=app.json, logger=app.logger, cors_allowed_origins=os.getenv("FRONT_URL") +) if COLLECT_METRICS: basic_auth = BasicAuth(app) registry = CollectorRegistry() - multiprocess.MultiProcessCollector(registry, path='/tmp') - metrics = PrometheusMetrics(app, registry=registry, path="/metrics/", metrics_decorator=basic_auth.required, group_by='endpoint') - metrics.info('app_info', 'Application info', version=BACKEND_VERSION) + multiprocess.MultiProcessCollector(registry, path="/tmp") + metrics = PrometheusMetrics( + app, + registry=registry, + path="/metrics/", + metrics_decorator=basic_auth.required, + group_by="endpoint", + ) + metrics.info("app_info", "Application info", version=BACKEND_VERSION) scheduler = APScheduler() # enable for debugging jobs: ../scheduler/jobs to see scheduled jobs @@ -122,17 +134,17 @@ def add_cors_headers(response): if not request.referrer: return response r = request.referrer[:-1] - url = os.getenv('FRONT_URL') + url = os.getenv("FRONT_URL") if app.debug or url and r == url: - response.headers.add('Access-Control-Allow-Origin', r) - response.headers.add('Access-Control-Allow-Credentials', 'true') - response.headers.add('Access-Control-Allow-Headers', 'Content-Type') - response.headers.add('Access-Control-Allow-Headers', 'Cache-Control') + response.headers.add("Access-Control-Allow-Origin", r) + response.headers.add("Access-Control-Allow-Credentials", "true") + response.headers.add("Access-Control-Allow-Headers", "Content-Type") + response.headers.add("Access-Control-Allow-Headers", "Cache-Control") + response.headers.add("Access-Control-Allow-Headers", "X-Requested-With") + response.headers.add("Access-Control-Allow-Headers", "Authorization") response.headers.add( - 'Access-Control-Allow-Headers', 'X-Requested-With') - response.headers.add('Access-Control-Allow-Headers', 'Authorization') - response.headers.add('Access-Control-Allow-Methods', - 'GET, POST, OPTIONS, PUT, DELETE') + "Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE" + ) return response diff --git a/backend/app/controller/analytics/analytics_controller.py b/backend/app/controller/analytics/analytics_controller.py index 2f1c672c..ce5003c8 100644 --- a/backend/app/controller/analytics/analytics_controller.py +++ b/backend/app/controller/analytics/analytics_controller.py @@ -7,18 +7,23 @@ from flask_jwt_extended import jwt_required -analytics = Blueprint('analytics', __name__) +analytics = Blueprint("analytics", __name__) -@analytics.route('', methods=['GET']) +@analytics.route("", methods=["GET"]) @jwt_required() @server_admin_required() def getBaseAnalytics(): statvfs = os.statvfs(UPLOAD_FOLDER) - return jsonify({ - "total_users": User.count(), - "active_users": db.session.query(Token.user_id).filter(Token.type == 'refresh').group_by(Token.user_id).count(), - "total_households": Household.count(), - "free_storage": statvfs.f_frsize * statvfs.f_bavail, - "available_storage": statvfs.f_frsize * statvfs.f_blocks, - }) + return jsonify( + { + "total_users": User.count(), + "active_users": db.session.query(Token.user_id) + .filter(Token.type == "refresh") + .group_by(Token.user_id) + .count(), + "total_households": Household.count(), + "free_storage": statvfs.f_frsize * statvfs.f_bavail, + "available_storage": statvfs.f_frsize * statvfs.f_blocks, + } + ) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 722f90e4..fff568b7 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -7,7 +7,7 @@ from .schemas import Login, Signup, CreateLongLivedToken from app.config import jwt, OPEN_REGISTRATION -auth = Blueprint('auth', __name__) +auth = Blueprint("auth", __name__) # Callback function to check if a JWT exists in the database blocklist @@ -15,7 +15,7 @@ def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool: jti = jwt_payload["jti"] token = Token.find_by_jti(jti) - if (token is not None): + if token is not None: token.last_used_at = datetime.utcnow() token.save() @@ -39,17 +39,20 @@ def user_lookup_callback(_jwt_header, jwt_data) -> User: return User.find_by_id(identity) -@auth.route('', methods=['POST']) +@auth.route("", methods=["POST"]) @validate_args(Login) def login(args): - username = args['username'].lower() + username = args["username"].lower() user = User.find_by_username(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 attemp with wrong username or password'.format(request.remote_addr)) + message="Unauthorized: IP {} login attemp with wrong username or password".format( + request.remote_addr + ) + ) device = "Unkown" if "device" in args: - device = args['device'] + device = args["device"] # Create refresh token refreshToken, refreshModel = Token.create_refresh_token(user, device) @@ -57,35 +60,33 @@ def login(args): # Create first access token accesssToken, _ = Token.create_access_token(user, refreshModel) - return jsonify({ - 'access_token': accesssToken, - 'refresh_token': refreshToken - }) + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) if OPEN_REGISTRATION: - @auth.route('signup', methods=['POST']) + + @auth.route("signup", methods=["POST"]) @validate_args(Signup) def signup(args): - username = args['username'].strip().lower().replace(" ", "") + username = args["username"].strip().lower().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']) + 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, + name=args["name"], + password=args["password"], + email=args["email"] if "email" in args else None, ) device = "Unkown" if "device" in args: - device = args['device'] + device = args["device"] # Create refresh token refreshToken, refreshModel = Token.create_refresh_token(user, device) @@ -93,80 +94,80 @@ def signup(args): # Create first access token accesssToken, _ = Token.create_access_token(user, refreshModel) - return jsonify({ - 'access_token': accesssToken, - 'refresh_token': refreshToken - }) + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) -@auth.route('/refresh', methods=['GET']) +@auth.route("/refresh", methods=["GET"]) @jwt_required(refresh=True) def refresh(): user = current_user if not user: raise UnauthorizedRequest( - message='Unauthorized: IP {} refresh attemp with wrong username or password'.format(request.remote_addr)) + message="Unauthorized: IP {} refresh attemp with wrong username or password".format( + request.remote_addr + ) + ) - refreshModel = Token.find_by_jti(get_jwt()['jti']) + refreshModel = Token.find_by_jti(get_jwt()["jti"]) # Refresh token rotation refreshToken, refreshModel = Token.create_refresh_token( - user, oldRefreshToken=refreshModel) + user, oldRefreshToken=refreshModel + ) # Create access token accesssToken, _ = Token.create_access_token(user, refreshModel) - return jsonify({ - 'access_token': accesssToken, - 'refresh_token': refreshToken - }) + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) -@auth.route('', methods=['DELETE']) +@auth.route("", methods=["DELETE"]) @jwt_required() def logout(): jwt = get_jwt() - token = Token.find_by_jti(jwt['jti']) + token = Token.find_by_jti(jwt["jti"]) if not token: raise UnauthorizedRequest( - message='Unauthorized: IP {}'.format(request.remote_addr)) + message="Unauthorized: IP {}".format(request.remote_addr) + ) - if token.type == 'access': + if token.type == "access": token.refresh_token.delete() else: token.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@auth.route('llt', methods=['POST']) +@auth.route("llt", methods=["POST"]) @jwt_required() @validate_args(CreateLongLivedToken) def createLongLivedToken(args): user = current_user if not user: raise UnauthorizedRequest( - message='Unauthorized: IP {}'.format(request.remote_addr)) + message="Unauthorized: IP {}".format(request.remote_addr) + ) - llToken, _ = Token.create_longlived_token(user, args['device']) + llToken, _ = Token.create_longlived_token(user, args["device"]) - return jsonify({ - 'longlived_token': llToken - }) + return jsonify({"longlived_token": llToken}) -@auth.route('llt/', methods=['DELETE']) +@auth.route("llt/", methods=["DELETE"]) @jwt_required() def deleteLongLivedToken(id): user = current_user if not user: raise UnauthorizedRequest( - message='Unauthorized: IP {}'.format(request.remote_addr)) + message="Unauthorized: IP {}".format(request.remote_addr) + ) token = Token.find_by_id(id) - if (token.user_id != user.id or token.type != 'llt'): + if token.user_id != user.id or token.type != "llt": raise UnauthorizedRequest( - message='Unauthorized: IP {}'.format(request.remote_addr)) + message="Unauthorized: IP {}".format(request.remote_addr) + ) token.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py index d2c2a7fa..75050f2b 100644 --- a/backend/app/controller/auth/schemas.py +++ b/backend/app/controller/auth/schemas.py @@ -4,10 +4,7 @@ class Login(Schema): - username = 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()) password = fields.String( required=True, validate=lambda a: a and not a.isspace(), @@ -19,20 +16,17 @@ class Login(Schema): load_only=True, ) + class Signup(Schema): username = fields.String( - required=True, - validate=lambda a: a and not a.isspace() and not "@" in a + 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 , + 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() - ) + 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(), diff --git a/backend/app/controller/category/category_controller.py b/backend/app/controller/category/category_controller.py index d19541e7..865fa89a 100644 --- a/backend/app/controller/category/category_controller.py +++ b/backend/app/controller/category/category_controller.py @@ -5,18 +5,18 @@ from app.models import Category from .schemas import AddCategory, DeleteCategory, UpdateCategory -category = Blueprint('category', __name__) -categoryHousehold = Blueprint('category', __name__) +category = Blueprint("category", __name__) +categoryHousehold = Blueprint("category", __name__) -@categoryHousehold.route('', methods=['GET']) +@categoryHousehold.route("", methods=["GET"]) @jwt_required() @authorize_household() def getAllCategories(household_id): return jsonify([e.obj_to_dict() for e in Category.all_by_ordering(household_id)]) -@category.route('/', methods=['GET']) +@category.route("/", methods=["GET"]) @jwt_required() def getCategory(id): category = Category.find_by_id(id) @@ -26,19 +26,19 @@ def getCategory(id): return jsonify(category.obj_to_dict()) -@categoryHousehold.route('', methods=['POST']) +@categoryHousehold.route("", methods=["POST"]) @jwt_required() @authorize_household() @validate_args(AddCategory) def addCategory(args, 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()) -@category.route('/', methods=['POST', 'PATCH']) +@category.route("/", methods=["POST", "PATCH"]) @jwt_required() @validate_args(UpdateCategory) def updateCategory(args, id): @@ -47,21 +47,21 @@ def updateCategory(args, id): 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 "name" in args: + category.name = args["name"] + if "ordering" in args 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 "merge_category_id" in args and args["merge_category_id"] != id: + mergeCategory = Category.find_by_id(args["merge_category_id"]) if mergeCategory: category.merge(mergeCategory) return jsonify(category.obj_to_dict()) -@category.route('/', methods=['DELETE']) +@category.route("/", methods=["DELETE"]) @jwt_required() def deleteCategoryById(id): category = Category.find_by_id(id) @@ -70,17 +70,17 @@ def deleteCategoryById(id): category.checkAuthorized() category.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@categoryHousehold.route('', methods=['DELETE']) +@categoryHousehold.route("", methods=["DELETE"]) @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) + category = Category.find_by_name(args["name"], household_id) if category: category.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) raise NotFoundRequest() diff --git a/backend/app/controller/category/schemas.py b/backend/app/controller/category/schemas.py index 1f1d7c3a..bef43cd0 100644 --- a/backend/app/controller/category/schemas.py +++ b/backend/app/controller/category/schemas.py @@ -4,19 +4,13 @@ class AddCategory(Schema): class Meta: unknown = EXCLUDE - name = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + + 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 - ) + name = fields.String(validate=lambda a: a and not a.isspace()) + ordering = fields.Integer(validate=lambda i: i >= 0) # if set this merges the specified category into this category thus combining them to one merge_category_id = fields.Integer( @@ -26,7 +20,4 @@ class UpdateCategory(Schema): class DeleteCategory(Schema): - name = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index a6e68c53..b226b416 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -12,40 +12,59 @@ from app.models import Expense, ExpensePaidFor, ExpenseCategory, HouseholdMember from app.service.recalculate_balances import recalculateBalances from app.service.file_has_access_or_download import file_has_access_or_download -from .schemas import GetExpenses, AddExpense, UpdateExpense, AddExpenseCategory, UpdateExpenseCategory, GetExpenseOverview +from .schemas import ( + GetExpenses, + AddExpense, + UpdateExpense, + AddExpenseCategory, + UpdateExpenseCategory, + GetExpenseOverview, +) -expense = Blueprint('expense', __name__) -expenseHousehold = Blueprint('expense', __name__) +expense = Blueprint("expense", __name__) +expenseHousehold = Blueprint("expense", __name__) -@expenseHousehold.route('', methods=['GET']) +@expenseHousehold.route("", methods=["GET"]) @jwt_required() @authorize_household() @validate_args(GetExpenses) def getAllExpenses(args, household_id): filter = [Expense.household_id == household_id] - if 'startAfterId' in args: - filter.append(Expense.id < args['startAfterId']) - - if 'view' in args and args['view'] == 1: - subquery = db.session.query(ExpensePaidFor.expense_id).filter( - ExpensePaidFor.user_id == current_user.id).scalar_subquery() + if "startAfterId" in args: + filter.append(Expense.id < args["startAfterId"]) + + if "view" in args and args["view"] == 1: + subquery = ( + db.session.query(ExpensePaidFor.expense_id) + .filter(ExpensePaidFor.user_id == current_user.id) + .scalar_subquery() + ) filter.append(Expense.id.in_(subquery)) - if 'filter' in args: - if None in args['filter']: - filter.append(or_(Expense.category_id == None, - Expense.category_id.in_(args['filter']))) + if "filter" in args: + if None in args["filter"]: + filter.append( + or_( + Expense.category_id == None, Expense.category_id.in_(args["filter"]) + ) + ) else: - filter.append(Expense.category_id.in_(args['filter'])) - - return jsonify([e.obj_to_full_dict() for e - in Expense.query.order_by(desc(Expense.date)).filter(*filter) - .join(Expense.category, isouter=True).limit(30).all() - ]) + filter.append(Expense.category_id.in_(args["filter"])) + + return jsonify( + [ + e.obj_to_full_dict() + for e in Expense.query.order_by(desc(Expense.date)) + .filter(*filter) + .join(Expense.category, isouter=True) + .limit(30) + .all() + ] + ) -@expense.route('/', methods=['GET']) +@expense.route("/", methods=["GET"]) @jwt_required() def getExpenseById(id): expense = Expense.find_by_id(id) @@ -55,51 +74,51 @@ def getExpenseById(id): return jsonify(expense.obj_to_full_dict()) -@expenseHousehold.route('', methods=['POST']) +@expenseHousehold.route("", methods=["POST"]) @jwt_required() @authorize_household() @validate_args(AddExpense) def addExpense(args, household_id): - member = HouseholdMember.find_by_ids(household_id, args['paid_by']['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 '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 "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"]) expense.category = category 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 con.save() - member_for.expense_balance = ( - member_for.expense_balance or 0) - (con.factor / factor_sum) * expense.amount + member_for.expense_balance = (member_for.expense_balance or 0) - ( + con.factor / factor_sum + ) * expense.amount member_for.save() return jsonify(expense.obj_to_dict()) -@expense.route('/', methods=['POST']) +@expense.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateExpense) def updateExpense(args, id): # noqa: C901 @@ -108,43 +127,42 @@ def updateExpense(args, id): # noqa: C901 raise NotFoundRequest() expense.checkAuthorized() - if 'name' in args: - expense.name = args['name'] - if 'amount' in args: - expense.amount = args['amount'] - 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 "name" in args: + expense.name = args["name"] + if "amount" in args: + expense.amount = args["amount"] + 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"]) expense.category = category else: expense.category = None - if 'paid_by' in args: + if "paid_by" in args: member = HouseholdMember.find_by_ids( - expense.household_id, args['paid_by']['id']) + expense.household_id, args["paid_by"]["id"] + ) if member: expense.paid_by_id = member.user_id expense.save() - if 'paid_for' in args: + if "paid_for" in args: 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 "factor" in user_data and user_data["factor"]: + 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 @@ -153,7 +171,7 @@ def updateExpense(args, id): # noqa: C901 return jsonify(expense.obj_to_dict()) -@expense.route('/', methods=['DELETE']) +@expense.route("/", methods=["DELETE"]) @jwt_required() def deleteExpenseById(id): expense = Expense.find_by_id(id) @@ -163,52 +181,74 @@ def deleteExpenseById(id): expense.delete() recalculateBalances(expense.household_id) - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@expenseHousehold.route('/recalculate-balances') +@expenseHousehold.route("/recalculate-balances") @jwt_required() @authorize_household(required=RequiredRights.ADMIN) def calculateBalances(household_id): recalculateBalances(household_id) -@expenseHousehold.route('/categories', methods=['GET']) +@expenseHousehold.route("/categories", methods=["GET"]) @jwt_required() @authorize_household() def getExpenseCategories(household_id): - return jsonify([e.obj_to_dict() for e in ExpenseCategory.all_from_household_by_name(household_id)]) + return jsonify( + [ + e.obj_to_dict() + for e in ExpenseCategory.all_from_household_by_name(household_id) + ] + ) -@expenseHousehold.route('/overview', methods=['GET']) +@expenseHousehold.route("/overview", methods=["GET"]) @jwt_required() @authorize_household() @validate_args(GetExpenseOverview) def getExpenseOverview(args, household_id): categories = list( - map(lambda x: x.id, ExpenseCategory.all_from_household_by_name(household_id))) + map(lambda x: x.id, ExpenseCategory.all_from_household_by_name(household_id)) + ) categories.append(-1) thisMonthStart = datetime.utcnow().date().replace(day=1) - steps = args['steps'] if 'steps' in args else 5 - frame = args['frame'] if args['frame'] != None else 2 - page = args['page'] if 'page' in args and args['page'] != None else 0 + steps = args["steps"] if "steps" in args else 5 + frame = args["frame"] if args["frame"] != None else 2 + page = args["page"] if "page" in args and args["page"] != None else 0 factor = 1 - query = Expense.query\ - .filter(Expense.household_id == household_id)\ - .group_by(Expense.category_id, ExpenseCategory.id)\ + query = ( + Expense.query.filter(Expense.household_id == household_id) + .group_by(Expense.category_id, ExpenseCategory.id) .join(Expense.category, isouter=True) - - if ('view' in args and args['view'] == 1): - filterQuery = db.session.query(ExpensePaidFor.expense_id).filter( - ExpensePaidFor.user_id == current_user.id).scalar_subquery() - - s1 = ExpensePaidFor.query.with_entities(ExpensePaidFor.expense_id.label("expense_id"), func.sum( - ExpensePaidFor.factor).label('total')).group_by(ExpensePaidFor.expense_id).subquery() - s2 = ExpensePaidFor.query.with_entities(ExpensePaidFor.expense_id.label("expense_id"), (ExpensePaidFor.factor.cast( - db.Float) / s1.c.total).label('factor')).filter(ExpensePaidFor.user_id == current_user.id)\ - .join(s1, ExpensePaidFor.expense_id == s1.c.expense_id).subquery() + ) + + if "view" in args and args["view"] == 1: + filterQuery = ( + db.session.query(ExpensePaidFor.expense_id) + .filter(ExpensePaidFor.user_id == current_user.id) + .scalar_subquery() + ) + + s1 = ( + ExpensePaidFor.query.with_entities( + ExpensePaidFor.expense_id.label("expense_id"), + func.sum(ExpensePaidFor.factor).label("total"), + ) + .group_by(ExpensePaidFor.expense_id) + .subquery() + ) + s2 = ( + ExpensePaidFor.query.with_entities( + ExpensePaidFor.expense_id.label("expense_id"), + (ExpensePaidFor.factor.cast(db.Float) / s1.c.total).label("factor"), + ) + .filter(ExpensePaidFor.user_id == current_user.id) + .join(s1, ExpensePaidFor.expense_id == s1.c.expense_id) + .subquery() + ) factor = s2.c.factor @@ -221,50 +261,63 @@ def getFilterForStepAgo(stepAgo: int): start = datetime.utcnow().date() - timedelta(days=stepAgo) end = start + timedelta(hours=24) elif frame == 1: - start = datetime.utcnow().date() - relativedelta(days=7, - weekday=calendar.MONDAY, weeks=stepAgo) + start = datetime.utcnow().date() - relativedelta( + days=7, weekday=calendar.MONDAY, weeks=stepAgo + ) end = start + timedelta(days=7) elif frame == 2: start = thisMonthStart - relativedelta(months=stepAgo) end = start + relativedelta(months=1) elif frame == 3: - start = datetime.utcnow().date().replace( - day=1, month=1) - relativedelta(years=stepAgo) + start = datetime.utcnow().date().replace(day=1, month=1) - relativedelta( + years=stepAgo + ) end = start + relativedelta(years=1) return Expense.date >= start, Expense.date <= end def getOverviewForStepAgo(stepAgo: int): return { - (e.id or -1): (float(e.balance) or 0) for e in - query - .with_entities(ExpenseCategory.id.label("id"), func.sum(Expense.amount * factor).label("balance")) + (e.id or -1): (float(e.balance) or 0) + for e in query.with_entities( + ExpenseCategory.id.label("id"), + func.sum(Expense.amount * factor).label("balance"), + ) .filter(*getFilterForStepAgo(stepAgo)) .all() } - value = [getOverviewForStepAgo(i) for i in range(page * steps, steps + page * steps)] + value = [ + getOverviewForStepAgo(i) for i in range(page * steps, steps + page * steps) + ] - byStep = {i + page * steps: {category: (value[i][category] if category in value[i] else 0.0) - for category in categories} for i in range(0, steps)} + byStep = { + i + + page + * steps: { + category: (value[i][category] if category in value[i] else 0.0) + for category in categories + } + for i in range(0, steps) + } return jsonify(byStep) -@expenseHousehold.route('/categories', methods=['POST']) +@expenseHousehold.route("/categories", methods=["POST"]) @jwt_required() @authorize_household() @validate_args(AddExpenseCategory) def addExpenseCategory(args, household_id): category = ExpenseCategory() - category.name = args['name'] - category.color = args['color'] + category.name = args["name"] + category.color = args["color"] category.household_id = household_id category.save() return jsonify(category.obj_to_dict()) -@expense.route('/categories/', methods=['DELETE']) +@expense.route("/categories/", methods=["DELETE"]) @jwt_required() def deleteExpenseCategoryById(id): category = ExpenseCategory.find_by_id(id) @@ -272,10 +325,10 @@ def deleteExpenseCategoryById(id): raise NotFoundRequest() category.checkAuthorized() category.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@expense.route('/categories/', methods=['POST']) +@expense.route("/categories/", methods=["POST"]) @jwt_required() @validate_args(UpdateExpenseCategory) def updateExpenseCategory(args, id): @@ -284,15 +337,15 @@ def updateExpenseCategory(args, id): raise NotFoundRequest() category.checkAuthorized() - if 'name' in args: - category.name = args['name'] - if 'color' in args: - category.color = args['color'] + if "name" in args: + category.name = args["name"] + if "color" in args: + category.color = args["color"] category.save() - if 'merge_category_id' in args and args['merge_category_id'] != id: - mergeCategory = ExpenseCategory.find_by_id(args['merge_category_id']) + if "merge_category_id" in args 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 448aa596..56cae5ad 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -12,86 +12,50 @@ def _deserialize(self, value, attr, data, **kwargs): class GetExpenses(Schema): view = fields.Integer() - startAfterId = fields.Integer( - validate=lambda a: a >= 0 - ) - filter = MultiDictList(CustomInteger( - allow_none=True - )) + startAfterId = fields.Integer(validate=lambda a: a >= 0) + filter = MultiDictList(CustomInteger(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 - ) + 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) date = fields.Integer() photo = fields.String() - category = fields.Integer( - allow_none=True - ) + 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) + paid_for = fields.List( + fields.Nested(User()), required=True, validate=lambda a: len(a) > 0 + ) 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 - ) + 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() date = fields.Integer() photo = fields.String() - category = fields.Integer( - allow_none=True - ) + category = fields.Integer(allow_none=True) paid_by = fields.Nested(User()) paid_for = fields.List(fields.Nested(User())) 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 - ) + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + color = fields.Integer(validate=lambda i: i >= 0, 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 - ) + name = fields.String(validate=lambda a: a and not a.isspace()) + color = fields.Integer(validate=lambda i: i >= 0, allow_none=True) # if set this merges the specified category into this category thus combining them to one merge_category_id = fields.Integer( @@ -104,13 +68,9 @@ class GetExpenseOverview(Schema): # household = 0, personal = 1 view = fields.Integer() # daily = 0, weekly = 1, montly = 2, yearly = 3 - frame = fields.Integer( - validate=lambda a: a >= 0 and a <= 3 - ) + frame = fields.Integer(validate=lambda a: a >= 0 and a <= 3) # how many frames are looked at - steps = fields.Integer( - validate=lambda a: a > 0 - ) + steps = fields.Integer(validate=lambda a: a > 0) # used for pagination (i.e. start of steps, now=0) page = fields.Integer( validate=lambda a: a >= 0, diff --git a/backend/app/controller/exportimport/export_controller.py b/backend/app/controller/exportimport/export_controller.py index cf07099a..cdd082dc 100644 --- a/backend/app/controller/exportimport/export_controller.py +++ b/backend/app/controller/exportimport/export_controller.py @@ -4,10 +4,10 @@ from app.helpers import authorize_household from app.models import Item, Recipe, Household -export = Blueprint('export', __name__) +export = Blueprint("export", __name__) -@export.route('', methods=['GET']) +@export.route("", methods=["GET"]) @jwt_required() @authorize_household() def getExportAll(household_id): @@ -18,15 +18,29 @@ def getExportAll(household_id): return household.obj_to_export_dict() -@export.route('/items', methods=['GET']) +@export.route("/items", methods=["GET"]) @jwt_required() @authorize_household() def getExportItems(household_id): - return jsonify({"items": [e.obj_to_export_dict() for e in Item.all_from_household_by_name(household_id)]}) + return jsonify( + { + "items": [ + e.obj_to_export_dict() + for e in Item.all_from_household_by_name(household_id) + ] + } + ) -@export.route('/recipes', methods=['GET']) +@export.route("/recipes", methods=["GET"]) @jwt_required() @authorize_household() def getExportRecipes(household_id): - return jsonify({"recipes": [e.obj_to_export_dict() for e in Recipe.all_from_household_by_name(household_id)]}) + return jsonify( + { + "recipes": [ + e.obj_to_export_dict() + for e in Recipe.all_from_household_by_name(household_id) + ] + } + ) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index 6ec1f804..8cdae92b 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -1,17 +1,22 @@ import time from app.config import app from app.models import Household -from app.service.importServices import importItem, importRecipe, importExpense, importShoppinglist +from app.service.importServices import ( + importItem, + importRecipe, + importExpense, + importShoppinglist, +) from app.service.recalculate_balances import recalculateBalances from .schemas import ImportSchema from app.helpers import validate_args, authorize_household from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required -importBP = Blueprint('import', __name__) +importBP = Blueprint("import", __name__) -@importBP.route('', methods=['POST']) +@importBP.route("", methods=["POST"]) @jwt_required() @authorize_household() @validate_args(ImportSchema) @@ -19,26 +24,30 @@ def importData(args, household_id): household = Household.find_by_id(household_id) if not household: return - + app.logger.info("Starting import...") t0 = time.time() if "items" in args: - for item in args['items']: + for item in args["items"]: importItem(household, item) if "recipes" in args: - for recipe in args['recipes']: - importRecipe(household_id, recipe, args['recipe_overwrite'] if 'recipe_overwrite' in args else False) + for recipe in args["recipes"]: + importRecipe( + household_id, + recipe, + args["recipe_overwrite"] if "recipe_overwrite" in args else False, + ) if "expenses" in args: - for expense in args['expenses']: + for expense in args["expenses"]: importExpense(household, expense) recalculateBalances(household.id) if "shoppinglists" in args: - for shoppinglist in args['shoppinglists']: + for shoppinglist in args["shoppinglists"]: importShoppinglist(household, shoppinglist) app.logger.info(f"Import took: {(time.time() - t0):.3f}s") - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index 21af3ff9..47a077ed 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -4,39 +4,25 @@ 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() - ) + 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='' + 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='' - ) + 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) @@ -49,31 +35,23 @@ class RecipeItem(Schema): 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 + 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() + 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() - ) + 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_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) diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index a28d5f1b..07505f9c 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -1,26 +1,33 @@ from flask import jsonify, Blueprint -from app.config import BACKEND_VERSION, MIN_FRONTEND_VERSION, PRIVACY_POLICY_URL, OPEN_REGISTRATION, EMAIL_MANDATORY +from app.config import ( + BACKEND_VERSION, + MIN_FRONTEND_VERSION, + PRIVACY_POLICY_URL, + OPEN_REGISTRATION, + EMAIL_MANDATORY, +) from app.models import Settings from app.config import SUPPORTED_LANGUAGES -health = Blueprint('health', __name__) +health = Blueprint("health", __name__) -@health.route('', methods=['GET']) +@health.route("", methods=["GET"]) def get_health(): info = { - 'msg': "OK", - 'version': BACKEND_VERSION, - 'min_frontend_version': MIN_FRONTEND_VERSION, + "msg": "OK", + "version": BACKEND_VERSION, + "min_frontend_version": MIN_FRONTEND_VERSION, } if PRIVACY_POLICY_URL: - info['privacy_policy'] = PRIVACY_POLICY_URL + info["privacy_policy"] = PRIVACY_POLICY_URL if OPEN_REGISTRATION: - info['open_registration'] = True + info["open_registration"] = True if EMAIL_MANDATORY: - info['email_mandatory'] = True + info["email_mandatory"] = True return jsonify(info) -@health.route('/supported-languages', methods=['GET']) + +@health.route("/supported-languages", methods=["GET"]) def getSupportedLanguages(): - return jsonify(SUPPORTED_LANGUAGES) \ No newline at end of file + return jsonify(SUPPORTED_LANGUAGES) diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index 810a47c8..56c51d75 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -9,16 +9,21 @@ from .schemas import AddHousehold, UpdateHousehold, UpdateHouseholdMember from flask_socketio import close_room -household = Blueprint('household', __name__) +household = Blueprint("household", __name__) -@household.route('', methods=['GET']) +@household.route("", methods=["GET"]) @jwt_required() def getUserHouseholds(): - return jsonify([e.household.obj_to_dict() for e in HouseholdMember.find_by_user(current_user.id)]) + return jsonify( + [ + e.household.obj_to_dict() + for e in HouseholdMember.find_by_user(current_user.id) + ] + ) -@household.route('/', methods=['GET']) +@household.route("/", methods=["GET"]) @jwt_required() @authorize_household() def getHousehold(household_id): @@ -28,22 +33,22 @@ def getHousehold(household_id): return jsonify(household.obj_to_dict()) -@household.route('', methods=['POST']) +@household.route("", methods=["POST"]) @jwt_required() @validate_args(AddHousehold) def addHousehold(args): 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'] + 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"] household.save() member = HouseholdMember() @@ -52,10 +57,12 @@ def addHousehold(args): member.owner = True member.save() - if 'member' in args: - for uid in args['member']: - if uid == current_user.id: continue - if not User.find_by_id(uid): continue + if "member" in args: + for uid in args["member"]: + if uid == current_user.id: + continue + if not User.find_by_id(uid): + continue member = HouseholdMember() member.household_id = household.id member.user_id = uid @@ -69,7 +76,7 @@ def addHousehold(args): return jsonify(household.obj_to_dict()) -@household.route('/', methods=['POST']) +@household.route("/", methods=["POST"]) @jwt_required() @authorize_household(required=RequiredRights.ADMIN) @validate_args(UpdateHousehold) @@ -78,34 +85,38 @@ def updateHousehold(args, 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 'language' in args and not household.language and args['language'] in SUPPORTED_LANGUAGES: - household.language = args['language'] + 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 ( + "language" in args + and not household.language + and args["language"] in SUPPORTED_LANGUAGES + ): + household.language = args["language"] 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 "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"] household.save() return jsonify(household.obj_to_dict()) -@household.route('/', methods=['DELETE']) +@household.route("/", methods=["DELETE"]) @jwt_required() @authorize_household(required=RequiredRights.ADMIN) def deleteHouseholdById(household_id): if Household.delete_by_id(household_id): close_room(household_id) - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@household.route('//member/', methods=['PUT']) +@household.route("//member/", methods=["PUT"]) @jwt_required() @authorize_household(required=RequiredRights.ADMIN) @validate_args(UpdateHouseholdMember) @@ -127,11 +138,11 @@ def putHouseholdMember(args, household_id, user_id): return jsonify(hm.obj_to_user_dict()) -@household.route('//member/', methods=['DELETE']) +@household.route("//member/", methods=["DELETE"]) @jwt_required() @authorize_household(required=RequiredRights.ADMIN_OR_SELF) def deleteHouseholdMember(household_id, user_id): hm = HouseholdMember.find_by_ids(household_id, user_id) if hm: hm.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) diff --git a/backend/app/controller/household/schemas.py b/backend/app/controller/household/schemas.py index 5670709a..9bb52c2c 100644 --- a/backend/app/controller/household/schemas.py +++ b/backend/app/controller/household/schemas.py @@ -4,10 +4,8 @@ class AddHousehold(Schema): class Meta: unknown = EXCLUDE - name = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) photo = fields.String() language = fields.String() planner_feature = fields.Boolean() @@ -19,9 +17,8 @@ class Meta: class UpdateHousehold(Schema): class Meta: unknown = EXCLUDE - name = fields.String( - validate=lambda a: a and not a.isspace() - ) + + name = fields.String(validate=lambda a: a and not a.isspace()) photo = fields.String() language = fields.String() planner_feature = fields.Boolean() @@ -32,4 +29,5 @@ class Meta: class UpdateHouseholdMember(Schema): class Meta: unknown = EXCLUDE + admin = fields.Boolean() diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 4ad4335f..04840707 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -6,18 +6,20 @@ from app.models import Item, RecipeItems, Recipe, Category from .schemas import SearchByNameRequest, UpdateItem -item = Blueprint('item', __name__) -itemHousehold = Blueprint('item', __name__) +item = Blueprint("item", __name__) +itemHousehold = Blueprint("item", __name__) -@itemHousehold.route('', methods=['GET']) +@itemHousehold.route("", methods=["GET"]) @jwt_required() @authorize_household() def getAllItems(household_id): - return jsonify([e.obj_to_dict() for e in Item.all_by_name_with_filter(household_id)]) + return jsonify( + [e.obj_to_dict() for e in Item.all_by_name_with_filter(household_id)] + ) -@item.route('/', methods=['GET']) +@item.route("/", methods=["GET"]) @jwt_required() def getItem(id): item = Item.find_by_id(id) @@ -27,21 +29,23 @@ def getItem(id): return jsonify(item.obj_to_dict()) -@item.route('//recipes', methods=['GET']) +@item.route("//recipes", methods=["GET"]) @jwt_required() def getItemRecipes(id): item = Item.find_by_id(id) if not item: raise NotFoundRequest() item.checkAuthorized() - recipe = RecipeItems.query.filter( - RecipeItems.item_id == id).join( # noqa - RecipeItems.recipe).order_by( - Recipe.name).all() + recipe = ( + RecipeItems.query.filter(RecipeItems.item_id == id) + .join(RecipeItems.recipe) # noqa + .order_by(Recipe.name) + .all() + ) return jsonify([e.obj_to_recipe_dict() for e in recipe]) -@item.route('/', methods=['DELETE']) +@item.route("/", methods=["DELETE"]) @jwt_required() def deleteItemById(id): item = Item.find_by_id(id) @@ -49,19 +53,24 @@ def deleteItemById(id): raise NotFoundRequest() item.checkAuthorized() item.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@itemHousehold.route('/search', methods=['GET']) +@itemHousehold.route("/search", methods=["GET"]) @jwt_required() @authorize_household() @validate_args(SearchByNameRequest) def searchItemByName(args, household_id): - query, description = description_splitter.split(args['query']) - return jsonify([e.obj_to_dict() | {"description": description} for e in Item.search_name(query, household_id)]) + query, description = description_splitter.split(args["query"]) + return jsonify( + [ + e.obj_to_dict() | {"description": description} + for e in Item.search_name(query, household_id) + ] + ) -@item.route('/', methods=['POST']) +@item.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateItem) def updateItem(args, id): @@ -70,23 +79,23 @@ def updateItem(args, id): raise NotFoundRequest() item.checkAuthorized() - if 'category' in args: - if not args['category']: + 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']) + 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() + if "icon" in args: + item.icon = args["icon"] + if "name" in args and args["name"] != item.name: + newName: str = args["name"].strip() 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 "merge_item_id" in args 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 42a26afc..c0fefb4e 100644 --- a/backend/app/controller/item/schemas.py +++ b/backend/app/controller/item/schemas.py @@ -2,10 +2,7 @@ class SearchByNameRequest(Schema): - query = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + query = fields.String(required=True, validate=lambda a: a and not a.isspace()) class UpdateItem(Schema): @@ -15,13 +12,9 @@ class Meta: 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() - ) + + 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( diff --git a/backend/app/controller/onboarding/onboarding_controller.py b/backend/app/controller/onboarding/onboarding_controller.py index 42089592..196b5c8a 100644 --- a/backend/app/controller/onboarding/onboarding_controller.py +++ b/backend/app/controller/onboarding/onboarding_controller.py @@ -3,26 +3,26 @@ from app.models import User, Token from .schemas import OnboardSchema -onboarding = Blueprint('onboarding', __name__) +onboarding = Blueprint("onboarding", __name__) -@onboarding.route('', methods=['GET']) +@onboarding.route("", methods=["GET"]) def isOnboarding(): onboarding = User.count() == 0 return jsonify({"onboarding": onboarding}) -@onboarding.route('', methods=['POST']) +@onboarding.route("", methods=["POST"]) @validate_args(OnboardSchema) def onboard(args): if User.count() > 0: - return jsonify({'msg': "Onboarding not allowed"}), 403 + 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'] + device = args["device"] # Create refresh token refreshToken, refreshModel = Token.create_refresh_token(user, device) @@ -30,7 +30,4 @@ def onboard(args): # Create first access token accesssToken, _ = Token.create_access_token(user, refreshModel) - return jsonify({ - 'access_token': accesssToken, - 'refresh_token': refreshToken - }) + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) diff --git a/backend/app/controller/onboarding/schemas.py b/backend/app/controller/onboarding/schemas.py index e62f27a6..ecc5af6e 100644 --- a/backend/app/controller/onboarding/schemas.py +++ b/backend/app/controller/onboarding/schemas.py @@ -3,10 +3,7 @@ class OnboardSchema(Schema): - name = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + 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, diff --git a/backend/app/controller/planner/planner_controller.py b/backend/app/controller/planner/planner_controller.py index cfdd5ba0..14cb5fe4 100644 --- a/backend/app/controller/planner/planner_controller.py +++ b/backend/app/controller/planner/planner_controller.py @@ -6,21 +6,26 @@ from app.models import Recipe, RecipeHistory, Planner from .schemas import AddPlannedRecipe, RemovePlannedRecipe -plannerHousehold = Blueprint('planner', __name__) +plannerHousehold = Blueprint("planner", __name__) -@plannerHousehold.route('/recipes', methods=['GET']) +@plannerHousehold.route("/recipes", methods=["GET"]) @jwt_required() @authorize_household() def getAllPlannedRecipes(household_id): - plannedRecipes = db.session.query(Planner.recipe_id).filter(Planner.household_id == household_id).group_by( - Planner.recipe_id).scalar_subquery() - recipes = Recipe.query.filter(Recipe.id.in_( - plannedRecipes)).order_by(Recipe.name).all() + plannedRecipes = ( + db.session.query(Planner.recipe_id) + .filter(Planner.household_id == household_id) + .group_by(Planner.recipe_id) + .scalar_subquery() + ) + recipes = ( + Recipe.query.filter(Recipe.id.in_(plannedRecipes)).order_by(Recipe.name).all() + ) return jsonify([e.obj_to_full_dict() for e in recipes]) -@plannerHousehold.route('', methods=['GET']) +@plannerHousehold.route("", methods=["GET"]) @jwt_required() @authorize_household() def getPlanner(household_id): @@ -28,20 +33,19 @@ def getPlanner(household_id): return jsonify([e.obj_to_full_dict() for e in plans]) -@plannerHousehold.route('/recipe', methods=['POST']) +@plannerHousehold.route("/recipe", methods=["POST"]) @jwt_required() @authorize_household() @validate_args(AddPlannedRecipe) def addPlannedRecipe(args, household_id): - recipe = Recipe.find_by_id(args['recipe_id']) + recipe = Recipe.find_by_id(args["recipe_id"]) if not recipe: raise NotFoundRequest() - day = args['day'] if 'day' in args else -1 + day = args["day"] if "day" in args else -1 planner = Planner.find_by_day(household_id, recipe_id=recipe.id, day=day) if not planner: if day >= 0: - old = Planner.find_by_day( - household_id, recipe_id=recipe.id, day=-1) + old = Planner.find_by_day(household_id, recipe_id=recipe.id, day=-1) if old: old.delete() elif len(recipe.plans) > 0: @@ -50,8 +54,8 @@ def addPlannedRecipe(args, household_id): planner.recipe_id = recipe.id planner.household_id = household_id planner.day = day - if 'yields' in args: - planner.yields = args['yields'] + if "yields" in args: + planner.yields = args["yields"] planner.save() RecipeHistory.create_added(recipe, household_id) @@ -59,7 +63,7 @@ def addPlannedRecipe(args, household_id): return jsonify(recipe.obj_to_dict()) -@plannerHousehold.route('/recipe/', methods=['DELETE']) +@plannerHousehold.route("/recipe/", methods=["DELETE"]) @jwt_required() @authorize_household() @validate_args(RemovePlannedRecipe) @@ -68,7 +72,7 @@ def removePlannedRecipeById(args, household_id, id): if not recipe: raise NotFoundRequest() - day = args['day'] if 'day' in args else -1 + day = args["day"] if "day" in args else -1 planner = Planner.find_by_day(household_id, recipe_id=recipe.id, day=day) if planner: planner.delete() @@ -76,7 +80,7 @@ def removePlannedRecipeById(args, household_id, id): return jsonify(recipe.obj_to_dict()) -@plannerHousehold.route('/recent-recipes', methods=['GET']) +@plannerHousehold.route("/recent-recipes", methods=["GET"]) @jwt_required() @authorize_household() def getRecentRecipes(household_id): @@ -84,7 +88,7 @@ def getRecentRecipes(household_id): return jsonify([e.recipe.obj_to_full_dict() for e in recipes]) -@plannerHousehold.route('/suggested-recipes', methods=['GET']) +@plannerHousehold.route("/suggested-recipes", methods=["GET"]) @jwt_required() @authorize_household() def getSuggestedRecipes(household_id): @@ -99,7 +103,7 @@ def getSuggestedRecipes(household_id): return jsonify([r.obj_to_full_dict() for r in suggested_recipes]) -@plannerHousehold.route('/refresh-suggested-recipes', methods=['GET', 'POST']) +@plannerHousehold.route("/refresh-suggested-recipes", methods=["GET", "POST"]) @jwt_required() @authorize_household() def getRefreshedSuggestedRecipes(household_id): diff --git a/backend/app/controller/planner/schemas.py b/backend/app/controller/planner/schemas.py index cfb20fa8..40e6a090 100644 --- a/backend/app/controller/planner/schemas.py +++ b/backend/app/controller/planner/schemas.py @@ -5,16 +5,20 @@ class AddPlannedRecipe(Schema): class Meta: unknown = EXCLUDE + recipe_id = fields.Integer( required=True, ) - day = fields.Integer(validate=Range( - min=0, min_inclusive=True, max=6, max_inclusive=True)) + 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 - day = fields.Integer(validate=Range( - min=0, min_inclusive=True, max=6, max_inclusive=True)) + + day = fields.Integer( + validate=Range(min=0, min_inclusive=True, max=6, max_inclusive=True) + ) diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index 2b46dd49..201c9575 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -3,24 +3,12 @@ class AddRecipe(Schema): 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 = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) - description = fields.String( - validate=lambda a: a is not None - ) + 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 = 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) @@ -33,19 +21,12 @@ class RecipeItem(Schema): class UpdateRecipe(Schema): class RecipeItem(Schema): - name = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + 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 - ) + 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) @@ -57,10 +38,7 @@ class RecipeItem(Schema): class SearchByNameRequest(Schema): - query = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + query = fields.String(required=True, validate=lambda a: a and not a.isspace()) only_ids = fields.Boolean( default=False, ) @@ -71,9 +49,7 @@ class GetAllFilterRequest(Schema): class AddItemByName(Schema): - name = fields.String( - required=True - ) + name = fields.String(required=True) description = fields.String() @@ -84,7 +60,4 @@ class RemoveItem(Schema): class ScrapeRecipe(Schema): - url = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + url = fields.String(required=True, validate=lambda a: a and not a.isspace()) diff --git a/backend/app/controller/settings/settings_controller.py b/backend/app/controller/settings/settings_controller.py index 376246f8..a96d3d56 100644 --- a/backend/app/controller/settings/settings_controller.py +++ b/backend/app/controller/settings/settings_controller.py @@ -4,10 +4,10 @@ from flask_jwt_extended import jwt_required from app.models import Settings -settings = Blueprint('settings', __name__) +settings = Blueprint("settings", __name__) -@settings.route('', methods=['POST']) +@settings.route("", methods=["POST"]) @jwt_required() @server_admin_required() def setSettings(): @@ -16,7 +16,7 @@ def setSettings(): return jsonify(settings.obj_to_dict()) -@settings.route('', methods=['GET']) +@settings.route("", methods=["GET"]) @jwt_required() def getSettings(): return jsonify(Settings.get().obj_to_dict()) diff --git a/backend/app/controller/shoppinglist/schemas.py b/backend/app/controller/shoppinglist/schemas.py index e1ce70c3..69486170 100644 --- a/backend/app/controller/shoppinglist/schemas.py +++ b/backend/app/controller/shoppinglist/schemas.py @@ -2,9 +2,7 @@ class AddItemByName(Schema): - name = fields.String( - required=True - ) + name = fields.String(required=True) description = fields.String() @@ -12,32 +10,21 @@ 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 - ) + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) + description = fields.String(load_default="") + optional = fields.Boolean(load_default=True) items = fields.List(fields.Nested(RecipeItem)) class CreateList(Schema): - name = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) class UpdateList(Schema): - name = fields.String( - validate=lambda a: a and not a.isspace() - ) + name = fields.String(validate=lambda a: a and not a.isspace()) class GetItems(Schema): @@ -45,16 +32,11 @@ class GetItems(Schema): class GetRecentItems(Schema): - limit = fields.Integer( - load_default=9, - validate=lambda x: x > 0 and x <= 60 - ) + limit = fields.Integer(load_default=9, validate=lambda x: x > 0 and x <= 60) class UpdateDescription(Schema): - description = fields.String( - required=True - ) + description = fields.String(required=True) class RemoveItem(Schema): @@ -68,6 +50,7 @@ class RemoveItems(Schema): class RecipeItem(Schema): class Meta: unknown = EXCLUDE + item_id = fields.Integer( required=True, ) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 8fd8789b..0abfa531 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -1,29 +1,47 @@ from flask import jsonify, Blueprint from flask_jwt_extended import current_user, jwt_required from app import db -from app.models import Item, Shoppinglist, History, Status, Association, ShoppinglistItems +from app.models import ( + Item, + Shoppinglist, + History, + Status, + Association, + ShoppinglistItems, +) from app.helpers import validate_args, authorize_household -from .schemas import (RemoveItem, UpdateDescription, - AddItemByName, CreateList, AddRecipeItems, GetItems, UpdateList, GetRecentItems, RemoveItems) +from .schemas import ( + RemoveItem, + UpdateDescription, + AddItemByName, + CreateList, + AddRecipeItems, + GetItems, + UpdateList, + GetRecentItems, + RemoveItems, +) from app.errors import NotFoundRequest, InvalidUsage from datetime import datetime, timedelta, timezone import app.util.description_merger as description_merger from app import socketio -shoppinglist = Blueprint('shoppinglist', __name__) -shoppinglistHousehold = Blueprint('shoppinglist', __name__) +shoppinglist = Blueprint("shoppinglist", __name__) +shoppinglistHousehold = Blueprint("shoppinglist", __name__) -@shoppinglistHousehold.route('', methods=['POST']) +@shoppinglistHousehold.route("", methods=["POST"]) @jwt_required() @authorize_household() @validate_args(CreateList) def createShoppinglist(args, household_id): - return jsonify(Shoppinglist(name=args['name'], household_id=household_id).save().obj_to_dict()) + return jsonify( + Shoppinglist(name=args["name"], household_id=household_id).save().obj_to_dict() + ) -@shoppinglistHousehold.route('', methods=['GET']) +@shoppinglistHousehold.route("", methods=["GET"]) @jwt_required() @authorize_household() def getShoppinglists(household_id): @@ -31,7 +49,7 @@ def getShoppinglists(household_id): return jsonify([e.obj_to_dict() for e in shoppinglists]) -@shoppinglist.route('/', methods=['POST']) +@shoppinglist.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateList) def updateShoppinglist(args, id): @@ -40,14 +58,14 @@ def updateShoppinglist(args, id): raise NotFoundRequest() shoppinglist.checkAuthorized() - if 'name' in args: - shoppinglist.name = args['name'] + if "name" in args: + shoppinglist.name = args["name"] shoppinglist.save() return jsonify(shoppinglist.obj_to_dict()) -@shoppinglist.route('/', methods=['DELETE']) +@shoppinglist.route("/", methods=["DELETE"]) @jwt_required() def deleteShoppinglist(id): shoppinglist = Shoppinglist.find_by_id(id) @@ -58,10 +76,10 @@ def deleteShoppinglist(id): raise InvalidUsage() shoppinglist.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@shoppinglist.route('//item/', methods=['POST']) +@shoppinglist.route("//item/", methods=["POST"]) @jwt_required() @validate_args(UpdateDescription) def updateItemDescription(args, id, item_id): @@ -70,16 +88,20 @@ def updateItemDescription(args, id, item_id): raise NotFoundRequest() con.shoppinglist.checkAuthorized() - con.description = args['description'] or '' + con.description = args["description"] or "" con.save() - socketio.emit("shoppinglist_item:add", { - "item": con.obj_to_item_dict(), - "shoppinglist": con.shoppinglist.obj_to_dict() - }, to=con.shoppinglist.household_id) + socketio.emit( + "shoppinglist_item:add", + { + "item": con.obj_to_item_dict(), + "shoppinglist": con.shoppinglist.obj_to_dict(), + }, + to=con.shoppinglist.household_id, + ) return jsonify(con.obj_to_item_dict()) -@shoppinglist.route('//items', methods=['GET']) +@shoppinglist.route("//items", methods=["GET"]) @jwt_required() @validate_args(GetItems) def getAllShoppingListItems(args, id): @@ -89,19 +111,22 @@ def getAllShoppingListItems(args, id): shoppinglist.checkAuthorized() orderby = [Item.name] - if ('orderby' in args): - if (args['orderby'] == 1): + if "orderby" in args: + if args["orderby"] == 1: orderby = [Item.ordering == 0, Item.ordering] - elif (args['orderby'] == 2): + elif args["orderby"] == 2: orderby = [Item.name] - items = ShoppinglistItems.query.filter( - ShoppinglistItems.shoppinglist_id == id).join( - ShoppinglistItems.item).order_by(*orderby, Item.name).all() + items = ( + ShoppinglistItems.query.filter(ShoppinglistItems.shoppinglist_id == id) + .join(ShoppinglistItems.item) + .order_by(*orderby, Item.name) + .all() + ) return jsonify([e.obj_to_item_dict() for e in items]) -@shoppinglist.route('//recent-items', methods=['GET']) +@shoppinglist.route("//recent-items", methods=["GET"]) @jwt_required() @validate_args(GetRecentItems) def getRecentItems(args, id): @@ -111,29 +136,42 @@ def getRecentItems(args, id): shoppinglist.checkAuthorized() items = History.get_recent(id, args["limit"]) - return jsonify([e.item.obj_to_dict() | {'description': e.description} for e in items]) + return jsonify( + [e.item.obj_to_dict() | {"description": e.description} for e in items] + ) def getSuggestionsBasedOnLastAddedItems(id, item_count): suggestions = [] # subquery for item ids which are on the shoppinglist - subquery = db.session.query(ShoppinglistItems.item_id).filter( - ShoppinglistItems.shoppinglist_id == id).subquery() + subquery = ( + db.session.query(ShoppinglistItems.item_id) + .filter(ShoppinglistItems.shoppinglist_id == id) + .subquery() + ) # suggestion based on recently added items ten_minutes_back = datetime.now() - timedelta(minutes=10) - recently_added = History.query.filter( - History.shoppinglist_id == id, - History.status == Status.ADDED, - History.created_at > ten_minutes_back).order_by( - History.created_at.desc()).limit(3) + recently_added = ( + History.query.filter( + History.shoppinglist_id == id, + History.status == Status.ADDED, + History.created_at > ten_minutes_back, + ) + .order_by(History.created_at.desc()) + .limit(3) + ) for recent in recently_added: - assocs = Association.query.filter( - Association.antecedent_id == recent.id, - Association.consequent_id.notin_(subquery)).order_by( - Association.lift.desc()).limit(item_count) + assocs = ( + Association.query.filter( + Association.antecedent_id == recent.id, + Association.consequent_id.notin_(subquery), + ) + .order_by(Association.lift.desc()) + .limit(item_count) + ) for rule in assocs: suggestions.append(rule.consequent) item_count -= 1 @@ -145,17 +183,23 @@ def getSuggestionsBasedOnFrequency(id, item_count): suggestions = [] # subquery for item ids which are on the shoppinglist - subquery = db.session.query(ShoppinglistItems.item_id).filter( - ShoppinglistItems.shoppinglist_id == id).subquery() + subquery = ( + db.session.query(ShoppinglistItems.item_id) + .filter(ShoppinglistItems.shoppinglist_id == id) + .subquery() + ) # suggestion based on overall frequency if item_count > 0: - suggestions = Item.query.filter(Item.id.notin_(subquery)).order_by( - Item.support.desc(), Item.name).limit(item_count) + suggestions = ( + Item.query.filter(Item.id.notin_(subquery)) + .order_by(Item.support.desc(), Item.name) + .limit(item_count) + ) return suggestions -@shoppinglist.route('//suggested-items', methods=['GET']) +@shoppinglist.route("//suggested-items", methods=["GET"]) @jwt_required() def getSuggestedItems(id): shoppinglist = Shoppinglist.find_by_id(id) @@ -166,15 +210,15 @@ def getSuggestedItems(id): item_suggestion_count = 9 suggestions = [] - suggestions += getSuggestionsBasedOnLastAddedItems( - id, item_suggestion_count) + suggestions += getSuggestionsBasedOnLastAddedItems(id, item_suggestion_count) suggestions += getSuggestionsBasedOnFrequency( - id, item_suggestion_count - len(suggestions)) + id, item_suggestion_count - len(suggestions) + ) return jsonify([item.obj_to_dict() for item in suggestions]) -@shoppinglist.route('//add-item-by-name', methods=['POST']) +@shoppinglist.route("//add-item-by-name", methods=["POST"]) @jwt_required() @validate_args(AddItemByName) def addShoppinglistItemByName(args, id): @@ -183,13 +227,13 @@ def addShoppinglistItemByName(args, id): 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 "description" in args else "" con = ShoppinglistItems(description=description) con.created_by = current_user.id con.item = item @@ -198,15 +242,19 @@ def addShoppinglistItemByName(args, id): History.create_added(shoppinglist, item, description) - socketio.emit("shoppinglist_item:add", { - "item": con.obj_to_item_dict(), - "shoppinglist": shoppinglist.obj_to_dict() - }, to=shoppinglist.household_id) + socketio.emit( + "shoppinglist_item:add", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) return jsonify(item.obj_to_dict()) -@shoppinglist.route('//item', methods=['DELETE']) +@shoppinglist.route("//item", methods=["DELETE"]) @jwt_required() @validate_args(RemoveItem) def removeShoppinglistItem(args, id): @@ -216,17 +264,24 @@ def removeShoppinglistItem(args, id): shoppinglist.checkAuthorized() con = removeShoppinglistItem( - shoppinglist, args['item_id'], args['removed_at'] if 'removed_at' in args else None) + shoppinglist, + args["item_id"], + args["removed_at"] if "removed_at" in args else None, + ) if con: - socketio.emit("shoppinglist_item:remove", { - "item": con.obj_to_item_dict(), - "shoppinglist": shoppinglist.obj_to_dict() - }, to=shoppinglist.household_id) + socketio.emit( + "shoppinglist_item:remove", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) - return jsonify({'msg': "DONE"}) + return jsonify({"msg": "DONE"}) -@shoppinglist.route('//items', methods=['DELETE']) +@shoppinglist.route("//items", methods=["DELETE"]) @jwt_required() @validate_args(RemoveItems) def removeShoppinglistItems(args, id): @@ -235,19 +290,28 @@ def removeShoppinglistItems(args, id): raise NotFoundRequest() shoppinglist.checkAuthorized() - for arg in args['items']: + for arg in args["items"]: con = removeShoppinglistItem( - shoppinglist, arg['item_id'], arg['removed_at'] if 'removed_at' in arg else None) + shoppinglist, + arg["item_id"], + arg["removed_at"] if "removed_at" in arg else None, + ) if con: - socketio.emit("shoppinglist_item:remove", { - "item": con.obj_to_item_dict(), - "shoppinglist": shoppinglist.obj_to_dict() - }, to=shoppinglist.household_id) + socketio.emit( + "shoppinglist_item:remove", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) - return jsonify({'msg': "DONE"}) + return jsonify({"msg": "DONE"}) -def removeShoppinglistItem(shoppinglist: Shoppinglist, item_id: int, removed_at: int = None) -> ShoppinglistItems: +def removeShoppinglistItem( + shoppinglist: Shoppinglist, item_id: int, removed_at: int = None +) -> ShoppinglistItems: item = Item.find_by_id(item_id) if not item: return None @@ -259,15 +323,13 @@ def removeShoppinglistItem(shoppinglist: Shoppinglist, item_id: int, removed_at: removed_at_datetime = None if removed_at: - removed_at_datetime = datetime.fromtimestamp( - removed_at/1000, timezone.utc) + removed_at_datetime = datetime.fromtimestamp(removed_at / 1000, timezone.utc) - History.create_dropped( - shoppinglist, item, description, removed_at_datetime) + History.create_dropped(shoppinglist, item, description, removed_at_datetime) return con -@shoppinglist.route('//recipeitems', methods=['POST']) +@shoppinglist.route("//recipeitems", methods=["POST"]) @jwt_required() @validate_args(AddRecipeItems) def addRecipeItems(args, id): @@ -277,15 +339,16 @@ def addRecipeItems(args, id): 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: - description = recipeItem['description'] + description = recipeItem["description"] con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id) if con: # merge descriptions con.description = description_merger.merge( - con.description, description) + con.description, description + ) db.session.add(con) else: con = ShoppinglistItems(description=description) @@ -295,12 +358,17 @@ def addRecipeItems(args, id): db.session.add(con) db.session.add( - History.create_added_without_save(shoppinglist, item, description)) - - socketio.emit("shoppinglist_item:add", { - "item": con.obj_to_item_dict(), - "shoppinglist": shoppinglist.obj_to_dict() - }, to=shoppinglist.household_id) + History.create_added_without_save(shoppinglist, item, description) + ) + + socketio.emit( + "shoppinglist_item:add", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) db.session.commit() except Exception as e: diff --git a/backend/app/controller/tag/schemas.py b/backend/app/controller/tag/schemas.py index 4af28d7a..f0e2de7c 100644 --- a/backend/app/controller/tag/schemas.py +++ b/backend/app/controller/tag/schemas.py @@ -2,16 +2,11 @@ class AddTag(Schema): - name = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + name = fields.String(required=True, validate=lambda a: a and not a.isspace()) class UpdateTag(Schema): - name = fields.String( - validate=lambda a: a and not a.isspace() - ) + name = fields.String(validate=lambda a: a and not a.isspace()) # if set this merges the specified tag into this tag thus combining them to one merge_tag_id = fields.Integer( diff --git a/backend/app/controller/tag/tag_controller.py b/backend/app/controller/tag/tag_controller.py index c613e153..749f1205 100644 --- a/backend/app/controller/tag/tag_controller.py +++ b/backend/app/controller/tag/tag_controller.py @@ -5,18 +5,20 @@ from app.models import Tag, RecipeTags, Recipe from .schemas import AddTag, UpdateTag -tag = Blueprint('tag', __name__) -tagHousehold = Blueprint('tag', __name__) +tag = Blueprint("tag", __name__) +tagHousehold = Blueprint("tag", __name__) -@tagHousehold.route('', methods=['GET']) +@tagHousehold.route("", methods=["GET"]) @jwt_required() @authorize_household() def getAllTags(household_id): - return jsonify([e.obj_to_dict() for e in Tag.all_from_household_by_name(household_id)]) + return jsonify( + [e.obj_to_dict() for e in Tag.all_from_household_by_name(household_id)] + ) -@tag.route('/', methods=['GET']) +@tag.route("/", methods=["GET"]) @jwt_required() def getTag(id): tag = Tag.find_by_id(id) @@ -26,7 +28,7 @@ def getTag(id): return jsonify(tag.obj_to_dict()) -@tag.route('//recipes', methods=['GET']) +@tag.route("//recipes", methods=["GET"]) @jwt_required() def getTagRecipes(id): tag = Tag.find_by_id(id) @@ -34,26 +36,28 @@ def getTagRecipes(id): raise NotFoundRequest() tag.checkAuthorized() - tags = RecipeTags.query.filter( - RecipeTags.tag_id == id).join( - RecipeTags.recipe).order_by( - Recipe.name).all() + tags = ( + RecipeTags.query.filter(RecipeTags.tag_id == id) + .join(RecipeTags.recipe) + .order_by(Recipe.name) + .all() + ) return jsonify([e.recipe.obj_to_dict() for e in tags]) -@tagHousehold.route('', methods=['POST']) +@tagHousehold.route("", methods=["POST"]) @jwt_required() @authorize_household() @validate_args(AddTag) def addTag(args, 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()) -@tag.route('/', methods=['POST']) +@tag.route("/", methods=["POST"]) @jwt_required() @validate_args(UpdateTag) def updateTag(args, id): @@ -62,20 +66,20 @@ def updateTag(args, id): raise NotFoundRequest() tag.checkAuthorized() - if 'name' in args: - tag.name = args['name'] + if "name" in args: + 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 "merge_tag_id" in args and args["merge_tag_id"] != id: + mergeTag = Tag.find_by_id(args["merge_tag_id"]) if mergeTag: tag.merge(mergeTag) return jsonify(tag.obj_to_dict()) -@tag.route('/', methods=['DELETE']) +@tag.route("/", methods=["DELETE"]) @jwt_required() def deleteTagById(id): tag = Tag.find_by_id(id) @@ -83,4 +87,4 @@ def deleteTagById(id): raise NotFoundRequest() tag.checkAuthorized() tag.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) diff --git a/backend/app/controller/upload/upload_controller.py b/backend/app/controller/upload/upload_controller.py index 40bc84f7..7b2a7b55 100644 --- a/backend/app/controller/upload/upload_controller.py +++ b/backend/app/controller/upload/upload_controller.py @@ -12,24 +12,25 @@ from app.models import File from app.util.filename_validator import allowed_file -upload = Blueprint('upload', __name__) +upload = Blueprint("upload", __name__) -@upload.route('', methods=['POST']) +@upload.route("", methods=["POST"]) @jwt_required() def upload_file(): - if 'file' not in request.files: - return jsonify({'msg': 'missing file'}) + if "file" not in request.files: + return jsonify({"msg": "missing file"}) - file = request.files['file'] + file = request.files["file"] # If the user does not select a file, the browser submits an # empty file without a filename. - if file.filename == '': - return jsonify({'msg': 'missing filename'}) + if file.filename == "": + return jsonify({"msg": "missing filename"}) if file and allowed_file(file.filename): - filename = secure_filename(str(uuid.uuid4()) + '.' + - file.filename.rsplit('.', 1)[1].lower()) + filename = secure_filename( + str(uuid.uuid4()) + "." + file.filename.rsplit(".", 1)[1].lower() + ) file.save(os.path.join(UPLOAD_FOLDER, filename)) blur = None try: @@ -46,7 +47,7 @@ def upload_file(): raise Exception("Invalid usage.") -@upload.route('', methods=['GET']) +@upload.route("", methods=["GET"]) @jwt_required() def download_file(filename): filename = secure_filename(filename) @@ -65,9 +66,9 @@ def download_file(filename): household_id = f.expense.household_id f.checkAuthorized(household_id=household_id) elif f.created_by and current_user and f.created_by == current_user.id: - pass # created by user can access his pictures + pass # created by user can access his pictures elif f.profile_picture: - pass # profile pictures are public + pass # profile pictures are public else: raise ForbiddenRequest() diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py index b90cb029..99dfa494 100644 --- a/backend/app/controller/user/schemas.py +++ b/backend/app/controller/user/schemas.py @@ -4,10 +4,7 @@ class CreateUser(Schema): - name = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + 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, @@ -26,9 +23,7 @@ class CreateUser(Schema): class UpdateUser(Schema): - name = fields.String( - validate=lambda a: a and not a.isspace() - ) + 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, @@ -48,7 +43,4 @@ class UpdateUser(Schema): class SearchByNameRequest(Schema): - query = fields.String( - required=True, - validate=lambda a: a and not a.isspace() - ) + query = fields.String(required=True, validate=lambda a: a and not a.isspace()) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 55d62e3c..0d004636 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -8,23 +8,23 @@ from .schemas import CreateUser, UpdateUser, SearchByNameRequest -user = Blueprint('user', __name__) +user = Blueprint("user", __name__) -@user.route('/all', methods=['GET']) +@user.route("/all", methods=["GET"]) @jwt_required() @server_admin_required() def getAllUsers(): return jsonify([e.obj_to_dict(include_email=True) for e in User.all_by_name()]) -@user.route('', methods=['GET']) +@user.route("", methods=["GET"]) @jwt_required() def getLoggedInUser(): return jsonify(current_user.obj_to_full_dict()) -@user.route('/', methods=['GET']) +@user.route("/", methods=["GET"]) @jwt_required() @server_admin_required() def getUserById(id): @@ -34,18 +34,16 @@ def getUserById(id): return jsonify(user.obj_to_dict(include_email=True)) -@user.route('', methods=['DELETE']) +@user.route("", methods=["DELETE"]) @jwt_required() def deleteUser(): if not current_user: - raise UnauthorizedRequest( - message='Cannot delete this user' - ) + raise UnauthorizedRequest(message="Cannot delete this user") current_user.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@user.route('/', methods=['DELETE']) +@user.route("/", methods=["DELETE"]) @jwt_required() @server_admin_required() def deleteUserById(id): @@ -53,29 +51,29 @@ def deleteUserById(id): if not user: raise NotFoundRequest() user.delete() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@user.route('', methods=['POST']) +@user.route("", methods=["POST"]) @jwt_required() @validate_args(UpdateUser) 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: - user.email = args['email'].strip() - if 'photo' in args and user.photo != args['photo']: - user.photo = file_has_access_or_download(args['photo'], user.photo) + if "name" in args: + user.name = args["name"].strip() + if "password" in args: + user.set_password(args["password"]) + if "email" in args: + user.email = args["email"].strip() + if "photo" in args and user.photo != args["photo"]: + user.photo = file_has_access_or_download(args["photo"], user.photo) user.save() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@user.route('/', methods=['POST']) +@user.route("/", methods=["POST"]) @jwt_required() @server_admin_required() @validate_args(UpdateUser) @@ -83,36 +81,36 @@ def updateUserById(args, id): 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: - user.email = args['email'].strip() - 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 "name" in args: + user.name = args["name"].strip() + if "password" in args: + user.set_password(args["password"]) + if "email" in args: + user.email = args["email"].strip() + 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"] user.save() - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@user.route('/new', methods=['POST']) +@user.route("/new", methods=["POST"]) @jwt_required() @server_admin_required() @validate_args(CreateUser) def createUser(args): User.create( - args['username'], - args['password'], - args['name'], - email=args['email'] if 'email' in args else None, + args["username"], + args["password"], + args["name"], + email=args["email"] if "email" in args else None, ) - return jsonify({'msg': 'DONE'}) + return jsonify({"msg": "DONE"}) -@user.route('/search', methods=['GET']) +@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'])]) + return jsonify([e.obj_to_dict() for e in User.search_name(args["query"])]) diff --git a/backend/app/errors/__init__.py b/backend/app/errors/__init__.py index d550de46..90d1eeee 100644 --- a/backend/app/errors/__init__.py +++ b/backend/app/errors/__init__.py @@ -9,8 +9,7 @@ def __init__(self, message="Invalid usage"): class UnauthorizedRequest(Exception): def __init__(self, message=""): - message = message or 'Authorization required. IP {}'.format( - request.remote_addr) + message = message or "Authorization required. IP {}".format(request.remote_addr) super(UnauthorizedRequest, self).__init__(message) self.message = message diff --git a/backend/app/helpers/authorize_household.py b/backend/app/helpers/authorize_household.py index 5ab29151..d4c3b0e4 100644 --- a/backend/app/helpers/authorize_household.py +++ b/backend/app/helpers/authorize_household.py @@ -15,27 +15,39 @@ def authorize_household(required: RequiredRights = RequiredRights.MEMBER) -> any def wrapper(func): @wraps(func) def decorator(*args, **kwargs): - if not 'household_id' in kwargs: + if not "household_id" in kwargs: raise Exception("Wrong usage of authorize_household") - if required == RequiredRights.ADMIN_OR_SELF and not 'user_id' in kwargs: + if required == RequiredRights.ADMIN_OR_SELF and not "user_id" in kwargs: raise Exception("Wrong usage of authorize_household") if not current_user: raise UnauthorizedRequest() if current_user.admin: return func(*args, **kwargs) # case server admin - if required == RequiredRights.ADMIN_OR_SELF and current_user.id == kwargs['user_id']: + if ( + required == RequiredRights.ADMIN_OR_SELF + and current_user.id == kwargs["user_id"] + ): return func(*args, **kwargs) # case ressource deals with self member = HouseholdMember.find_by_ids( - kwargs['household_id'], current_user.id) + kwargs["household_id"], current_user.id + ) if required == RequiredRights.MEMBER and member: return func(*args, **kwargs) # case member - if (required == RequiredRights.ADMIN or required == RequiredRights.ADMIN_OR_SELF) and member and (member.admin or member.owner): + if ( + ( + required == RequiredRights.ADMIN + or required == RequiredRights.ADMIN_OR_SELF + ) + and member + and (member.admin or member.owner) + ): return func(*args, **kwargs) # case admin raise ForbiddenRequest() return decorator + return wrapper diff --git a/backend/app/helpers/db_list_type.py b/backend/app/helpers/db_list_type.py index d1704a17..60e95cae 100644 --- a/backend/app/helpers/db_list_type.py +++ b/backend/app/helpers/db_list_type.py @@ -10,7 +10,7 @@ def process_bind_param(self, value, dialect): if type(value) is list: return json.dumps(value) else: - return '[]' + return "[]" def process_result_value(self, value, dialect) -> set: if type(value) is str: diff --git a/backend/app/helpers/db_model_authorize_mixin.py b/backend/app/helpers/db_model_authorize_mixin.py index 9eed1550..592917db 100644 --- a/backend/app/helpers/db_model_authorize_mixin.py +++ b/backend/app/helpers/db_model_authorize_mixin.py @@ -9,12 +9,13 @@ def checkAuthorized(self, requires_admin=False, household_id: int = None): Checks if current user ist authorized to access this model. Throws and unauthorized exception if not IMPORTANT: requires household_id """ - if not household_id and not hasattr(self, 'household_id'): + if not household_id and not hasattr(self, "household_id"): raise Exception("Wrong usage of authorize_household") if not current_user: raise UnauthorizedRequest() member = app.models.household.HouseholdMember.find_by_ids( - household_id or self.household_id, current_user.id) + household_id or self.household_id, current_user.id + ) if not current_user.admin: if not member or requires_admin and not (member.admin or member.owner): raise ForbiddenRequest() diff --git a/backend/app/helpers/db_model_mixin.py b/backend/app/helpers/db_model_mixin.py index de48a6a4..e3b0d436 100644 --- a/backend/app/helpers/db_model_mixin.py +++ b/backend/app/helpers/db_model_mixin.py @@ -4,7 +4,6 @@ class DbModelMixin(object): - def save(self) -> Self: """ Persist changes to current instance in db @@ -25,7 +24,9 @@ def delete(self): db.session.delete(self) db.session.commit() - def obj_to_dict(self, skip_columns: list[str] = None, include_columns: list[str] = None) -> dict: + def obj_to_dict( + self, skip_columns: list[str] | None = None, include_columns: list[str] | None = None + ) -> dict: d = {} for column in self.__table__.columns: d[column.name] = getattr(self, column.name) @@ -90,7 +91,9 @@ def all_from_household_by_name(cls, household_id: int) -> list[Self]: Return all instances of model IMPORTANT: requires household_id and name column """ - return cls.query.filter(cls.household_id == household_id).order_by(cls.name).all() + return ( + cls.query.filter(cls.household_id == household_id).order_by(cls.name).all() + ) @classmethod def count(cls) -> int: diff --git a/backend/app/helpers/db_set_type.py b/backend/app/helpers/db_set_type.py index 3fe2ac2b..31e8c194 100644 --- a/backend/app/helpers/db_set_type.py +++ b/backend/app/helpers/db_set_type.py @@ -10,7 +10,7 @@ def process_bind_param(self, value, dialect): if type(value) is set: return json.dumps(list(value)) else: - return '[]' + return "[]" def process_result_value(self, value, dialect) -> set: if type(value) is str: diff --git a/backend/app/helpers/server_admin_required.py b/backend/app/helpers/server_admin_required.py index f5f8a80e..935db58f 100644 --- a/backend/app/helpers/server_admin_required.py +++ b/backend/app/helpers/server_admin_required.py @@ -10,10 +10,12 @@ def wrapper(func): def decorator(*args, **kwargs): if not current_user or not current_user.admin: raise ForbiddenRequest( - message='Elevated rights required. IP {}'.format( - request.remote_addr) + message="Elevated rights required. IP {}".format( + request.remote_addr + ) ) return func(*args, **kwargs) return decorator + return wrapper diff --git a/backend/app/helpers/timestamp_mixin.py b/backend/app/helpers/timestamp_mixin.py index ab93e0d9..9a92f44c 100644 --- a/backend/app/helpers/timestamp_mixin.py +++ b/backend/app/helpers/timestamp_mixin.py @@ -6,15 +6,13 @@ class TimestampMixin(object): """ Provides the :attr:`created_at` and :attr:`updated_at` audit timestamps """ + #: Timestamp for when this instance was created in UTC created_at = Column(DateTime, default=datetime.utcnow, nullable=False) #: Timestamp for when this instance was last updated in UTC updated_at = Column( - DateTime, - default=datetime.utcnow, - onupdate=datetime.utcnow, - nullable=False + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False ) created_at._creation_order = 9998 diff --git a/backend/app/helpers/validate_args.py b/backend/app/helpers/validate_args.py index 39de88bc..a5c42572 100644 --- a/backend/app/helpers/validate_args.py +++ b/backend/app/helpers/validate_args.py @@ -11,17 +11,17 @@ def func_wrapper(*args, **kwargs): if not schema_cls: raise Exception("Invalid usage. Schema class missing") - if request.method == 'GET': + if request.method == "GET": request_data = request.args load_fn = schema_cls().load else: - request_data = request.data.decode('utf-8') + request_data = request.data.decode("utf-8") load_fn = schema_cls().loads try: arguments = load_fn(request_data) except ValidationError as exc: - raise InvalidUsage('{}'.format(exc)) + raise InvalidUsage("{}".format(exc)) return func(arguments, *args, **kwargs) diff --git a/backend/app/helpers/validate_socket_args.py b/backend/app/helpers/validate_socket_args.py index ab597499..5e690e4f 100644 --- a/backend/app/helpers/validate_socket_args.py +++ b/backend/app/helpers/validate_socket_args.py @@ -14,7 +14,7 @@ def func_wrapper(*args, **kwargs): try: arguments = schema_cls().load(args[0]) except ValidationError as exc: - raise InvalidUsage('{}'.format(exc)) + raise InvalidUsage("{}".format(exc)) return func(arguments, **kwargs) diff --git a/backend/app/jobs/cluster_shoppings.py b/backend/app/jobs/cluster_shoppings.py index 5a29cf48..fd59ea71 100644 --- a/backend/app/jobs/cluster_shoppings.py +++ b/backend/app/jobs/cluster_shoppings.py @@ -9,7 +9,7 @@ def clusterShoppings(shoppinglist_id: int) -> list: dropped = History.find_dropped_by_shoppinglist_id(shoppinglist_id) - if (len(dropped) == 0): + if len(dropped) == 0: app.logger.info("no history to investigate") return None @@ -24,7 +24,7 @@ def clusterShoppings(shoppinglist_id: int) -> list: dbs = DBSCAN1D(eps=eps, min_samples=min_samples) labels = dbs.fit_predict(timestamps) - if (len(labels) == 0): + if len(labels) == 0: app.logger.info("no shopping instances identified") return None @@ -37,11 +37,9 @@ def clusterShoppings(shoppinglist_id: int) -> list: clusters[label].append(i) # indices to list of itemlists for each found shopping instance - shopping_instances = [[dropped[i].item_id for i in cluster] - for cluster in clusters] + shopping_instances = [[dropped[i].item_id for i in cluster] for cluster in clusters] # remove duplicates in the instances - shopping_instances = [list(set(instance)) - for instance in shopping_instances] + shopping_instances = [list(set(instance)) for instance in shopping_instances] return shopping_instances diff --git a/backend/app/jobs/item_ordering.py b/backend/app/jobs/item_ordering.py index 93312151..d9c41b3f 100644 --- a/backend/app/jobs/item_ordering.py +++ b/backend/app/jobs/item_ordering.py @@ -15,7 +15,7 @@ def findItemOrdering(shopping_instances): item_id = order[ord] item = Item.find_by_id(item_id) if item: - item.ordering = ord+1 + item.ordering = ord + 1 db.session.add(item) # commit changes to db @@ -47,15 +47,14 @@ def updateMatrix(self, lst: list): self.matrix.append([0 for i in range(len(self.indices))]) # cost of ranking in current list - cost = (1-self.decay) / len(lst) + cost = (1 - self.decay) / len(lst) # iterate the current list for i in range(len(lst)): index = self.item_dict[lst[i]] # decay old costs with factor decay - self.matrix[index] = list( - map(lambda x: x * self.decay, self.matrix[index])) + self.matrix[index] = list(map(lambda x: x * self.decay, self.matrix[index])) # increase incoming cost for all preceeding items in the current list predecessors = lst[:i] @@ -68,7 +67,6 @@ def topologicalSort(self) -> list: order = [] for iter in range(len(mtx)): - # cost of an item is the sum of its incoming costs costs = list(map(sum, mtx)) diff --git a/backend/app/jobs/item_suggestions.py b/backend/app/jobs/item_suggestions.py index 15ac4bf0..b85d9176 100644 --- a/backend/app/jobs/item_suggestions.py +++ b/backend/app/jobs/item_suggestions.py @@ -8,7 +8,6 @@ def findItemSuggestions(shopping_instances): - if not shopping_instances or len(shopping_instances) == 0: return @@ -18,15 +17,14 @@ def findItemSuggestions(shopping_instances): store = pd.DataFrame(te_ary, columns=te.columns_) # compute the frequent itemsets with minimal support 0.1 - frequent_itemsets = apriori( - store, min_support=0.001, use_colnames=True, max_len=2) + frequent_itemsets = apriori(store, min_support=0.001, use_colnames=True, max_len=2) app.logger.info("apriori finished") # extract support for single items - single_items = frequent_itemsets[frequent_itemsets['itemsets'].apply( - len) == 1] - single_items.insert(0, "single", [list(tup)[0] - for tup in single_items["itemsets"]], False) + single_items = frequent_itemsets[frequent_itemsets["itemsets"].apply(len) == 1] + single_items.insert( + 0, "single", [list(tup)[0] for tup in single_items["itemsets"]], False + ) # store support values for index, row in single_items.iterrows(): @@ -41,27 +39,32 @@ def findItemSuggestions(shopping_instances): app.logger.info("frequency of single items was stored") # compute all association rules with lift > 1.2 and confidence > 0.1 - association_rules = arule( - frequent_itemsets, metric='lift', min_threshold=1.2) - association_rules = association_rules[association_rules['confidence'] > 0.1] + association_rules = arule(frequent_itemsets, metric="lift", min_threshold=1.2) + association_rules = association_rules[association_rules["confidence"] > 0.1] # extract rules with single antecedent and single consequent - single_rules = association_rules[(association_rules["antecedents"].apply( - len) == 1) & (association_rules["consequents"].apply(len) == 1)] - single_rules.insert(0, "antecedent", [list( - tup)[0] for tup in single_rules["antecedents"]], True) - single_rules.insert(1, "consequent", [list( - tup)[0] for tup in single_rules["consequents"]], True) + single_rules = association_rules[ + (association_rules["antecedents"].apply(len) == 1) + & (association_rules["consequents"].apply(len) == 1) + ] + single_rules.insert( + 0, "antecedent", [list(tup)[0] for tup in single_rules["antecedents"]], True + ) + single_rules.insert( + 1, "consequent", [list(tup)[0] for tup in single_rules["consequents"]], True + ) # delete all previous associations Association.delete_all() # store all new associations for index, rule in single_rules.iterrows(): - a = Association(antecedent_id=rule["antecedent"], - consequent_id=rule["consequent"], - support=rule["support"], - confidence=rule["confidence"], - lift=rule["lift"]) + a = Association( + antecedent_id=rule["antecedent"], + consequent_id=rule["consequent"], + support=rule["support"], + confidence=rule["confidence"], + lift=rule["lift"], + ) db.session.add(a) app.logger.info("associations rules of size 2 were updated") diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index d9e5ab0e..7b9a0155 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -8,14 +8,19 @@ # # for debugging run: FLASK_DEBUG=True python manage.py -@scheduler.task('cron', id='everyDay', day_of_week='*', hour='3') + +@scheduler.task("cron", id="everyDay", day_of_week="*", hour="3") def daily(): with app.app_context(): app.logger.info("--- daily analysis is starting ---") # task for all households for household in Household.all(): # shopping tasks - shopping_instances = clusterShoppings(Shoppinglist.query.filter(Shoppinglist.household_id == household.id).first().id) + shopping_instances = clusterShoppings( + Shoppinglist.query.filter(Shoppinglist.household_id == household.id) + .first() + .id + ) if shopping_instances: findItemOrdering(shopping_instances) findItemSuggestions(shopping_instances) @@ -26,7 +31,7 @@ def daily(): app.logger.info("--- daily analysis is completed ---") -@scheduler.task('interval', id='every30min', minutes=30) +@scheduler.task("interval", id="every30min", minutes=30) def halfHourly(): with app.app_context(): # Remove expired Tokens diff --git a/backend/app/jobs/recipe_suggestions.py b/backend/app/jobs/recipe_suggestions.py index ae42f01b..f0cabfbf 100644 --- a/backend/app/jobs/recipe_suggestions.py +++ b/backend/app/jobs/recipe_suggestions.py @@ -7,12 +7,21 @@ def computeRecipeSuggestions(household_id: int): - historyCount = RecipeHistory.query.with_entities(RecipeHistory.recipe_id, func.count().label('count')).filter( - RecipeHistory.status == Status.ADDED, - RecipeHistory.household_id == household_id, - RecipeHistory.created_at >= datetime.datetime.utcnow() - datetime.timedelta(days=182), - RecipeHistory.created_at <= datetime.datetime.utcnow() - datetime.timedelta(days=7), - ).group_by(RecipeHistory.recipe_id).all() + historyCount = ( + RecipeHistory.query.with_entities( + RecipeHistory.recipe_id, func.count().label("count") + ) + .filter( + RecipeHistory.status == Status.ADDED, + RecipeHistory.household_id == household_id, + RecipeHistory.created_at + >= datetime.datetime.utcnow() - datetime.timedelta(days=182), + RecipeHistory.created_at + <= datetime.datetime.utcnow() - datetime.timedelta(days=7), + ) + .group_by(RecipeHistory.recipe_id) + .all() + ) # 0) reset all suggestion scores for r in Recipe.all_from_household(household_id): r.suggestion_score = 0 @@ -21,7 +30,8 @@ def computeRecipeSuggestions(household_id: int): # 1) count cooked instances in last six months for e in historyCount: r = Recipe.find_by_id(e.recipe_id) - if not r: continue + if not r: + continue r.suggestion_score = e.count db.session.add(r) diff --git a/backend/app/models/association.py b/backend/app/models/association.py index 903863a4..6d9b3600 100644 --- a/backend/app/models/association.py +++ b/backend/app/models/association.py @@ -4,20 +4,28 @@ class Association(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'association' + __tablename__ = "association" id = db.Column(db.Integer, primary_key=True) - antecedent_id = db.Column(db.Integer, db.ForeignKey('item.id')) - consequent_id = db.Column(db.Integer, db.ForeignKey('item.id')) + antecedent_id = db.Column(db.Integer, db.ForeignKey("item.id")) + consequent_id = db.Column(db.Integer, db.ForeignKey("item.id")) support = db.Column(db.Float) confidence = db.Column(db.Float) lift = db.Column(db.Float) - antecedent = db.relationship("Item", uselist=False, foreign_keys=[ - antecedent_id], back_populates="antecedents") - consequent = db.relationship("Item", uselist=False, foreign_keys=[ - consequent_id], back_populates="consequents") + antecedent = db.relationship( + "Item", + uselist=False, + foreign_keys=[antecedent_id], + back_populates="antecedents", + ) + consequent = db.relationship( + "Item", + uselist=False, + foreign_keys=[consequent_id], + back_populates="consequents", + ) @classmethod def create(cls, antecedent_id, consequent_id, support, confidence, lift): @@ -26,12 +34,14 @@ def create(cls, antecedent_id, consequent_id, support, confidence, lift): consequent_id=consequent_id, support=support, confidence=confidence, - lift=lift + lift=lift, ).save() @classmethod def find_by_antecedent(cls, antecedent_id): - return cls.query.filter(cls.antecedent_id == antecedent_id).order_by(cls.lift.desc()) + return cls.query.filter(cls.antecedent_id == antecedent_id).order_by( + cls.lift.desc() + ) @classmethod def find_all(cls) -> list[Self]: diff --git a/backend/app/models/category.py b/backend/app/models/category.py index bc311c1f..72ba7ba1 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -5,19 +5,19 @@ class Category(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): - __tablename__ = 'category' + __tablename__ = "category" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) default = db.Column(db.Boolean, default=False) default_key = db.Column(db.String(128)) ordering = db.Column(db.Integer, default=0) - household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False, index=True) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) household = db.relationship("Household", uselist=False) - items = db.relationship( - 'Item', back_populates='category') + items = db.relationship("Item", back_populates="category") def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() @@ -25,10 +25,16 @@ def obj_to_full_dict(self) -> dict: @classmethod def all_by_ordering(cls, household_id: int): - return cls.query.filter(cls.household_id == household_id).order_by(cls.ordering, cls.name).all() + return ( + cls.query.filter(cls.household_id == household_id) + .order_by(cls.ordering, cls.name) + .all() + ) @classmethod - def create_by_name(cls, household_id: int, name, default=False, default_key=None) -> Self: + def create_by_name( + cls, household_id: int, name, default=False, default_key=None + ) -> Self: return cls( name=name, default=default, @@ -38,11 +44,15 @@ def create_by_name(cls, household_id: int, name, default=False, default_key=None @classmethod def find_by_name(cls, household_id: int, name: str) -> Self: - return cls.query.filter(cls.name == name, cls.household_id == household_id).first() + return cls.query.filter( + cls.name == name, cls.household_id == household_id + ).first() @classmethod def find_by_default_key(cls, household_id: int, default_key: str) -> Self: - return cls.query.filter(cls.default_key == default_key, cls.household_id == household_id).first() + return cls.query.filter( + cls.default_key == default_key, cls.household_id == household_id + ).first() @classmethod def find_by_id(cls, id: int) -> Self: @@ -51,8 +61,11 @@ def find_by_id(cls, id: int) -> Self: def reorder(self, newIndex: int): cls = self.__class__ - l: list[cls] = cls.query.filter(cls.household_id == self.household_id).order_by( - cls.ordering, cls.name).all() + l: list[cls] = ( + cls.query.filter(cls.household_id == self.household_id) + .order_by(cls.ordering, cls.name) + .all() + ) self.ordering = min(newIndex, len(l) - 1) diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index 27c05c7e..f4cffe62 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -5,51 +5,59 @@ class Expense(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): - __tablename__ = 'expense' + __tablename__ = "expense" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) amount = db.Column(db.Float()) date = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - category_id = db.Column(db.Integer, db.ForeignKey('expense_category.id')) - photo = db.Column(db.String(), db.ForeignKey('file.filename')) - paid_by_id = db.Column(db.Integer, db.ForeignKey('user.id')) - household_id = db.Column(db.Integer, db.ForeignKey('household.id'), nullable=False, index=True) + category_id = db.Column(db.Integer, db.ForeignKey("expense_category.id")) + photo = db.Column(db.String(), db.ForeignKey("file.filename")) + paid_by_id = db.Column(db.Integer, db.ForeignKey("user.id")) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) household = db.relationship("Household", uselist=False) category = db.relationship("ExpenseCategory") paid_by = db.relationship("User") paid_for = db.relationship( - 'ExpensePaidFor', back_populates='expense', cascade="all, delete-orphan") - photo_file = db.relationship("File", back_populates='expense', uselist=False) + "ExpensePaidFor", back_populates="expense", cascade="all, delete-orphan" + ) + photo_file = db.relationship("File", back_populates="expense", uselist=False) def obj_to_dict(self) -> dict: res = super().obj_to_dict() if self.photo_file: - res['photo_hash'] = self.photo_file.blur_hash + res["photo_hash"] = self.photo_file.blur_hash return res def obj_to_full_dict(self) -> dict: res = self.obj_to_dict() - paidFor = ExpensePaidFor.query.filter(ExpensePaidFor.expense_id == self.id).join( - ExpensePaidFor.user).order_by( - ExpensePaidFor.expense_id).all() - res['paid_for'] = [e.obj_to_dict() for e in paidFor] - if (self.category): - res['category'] = self.category.obj_to_full_dict() + paidFor = ( + ExpensePaidFor.query.filter(ExpensePaidFor.expense_id == self.id) + .join(ExpensePaidFor.user) + .order_by(ExpensePaidFor.expense_id) + .all() + ) + res["paid_for"] = [e.obj_to_dict() for e in paidFor] + if self.category: + res["category"] = self.category.obj_to_full_dict() return res def obj_to_export_dict(self) -> dict: res = { - 'name': self.name, - 'amount': self.amount, - 'date': self.date, - 'photo': self.photo, - 'paid_for': [{'factor': e.factor, 'username': e.user.username} for e in self.paid_for], - 'paid_by': self.paid_by.username, + "name": self.name, + "amount": self.amount, + "date": self.date, + "photo": self.photo, + "paid_for": [ + {"factor": e.factor, "username": e.user.username} for e in self.paid_for + ], + "paid_by": self.paid_by.username, } - if (self.category): - res['category'] = self.category.obj_to_export_dict() + if self.category: + res["category"] = self.category.obj_to_export_dict() return res @classmethod @@ -58,27 +66,30 @@ def find_by_name(cls, name) -> Self: @classmethod def find_by_id(cls, id) -> Self: - return cls.query.filter(cls.id == id).join(Expense.category, isouter=True).first() + return ( + cls.query.filter(cls.id == id).join(Expense.category, isouter=True).first() + ) class ExpensePaidFor(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'expense_paid_for' + __tablename__ = "expense_paid_for" - expense_id = db.Column(db.Integer, db.ForeignKey( - 'expense.id'), primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + expense_id = db.Column(db.Integer, db.ForeignKey("expense.id"), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) factor = db.Column(db.Integer()) - expense = db.relationship("Expense", back_populates='paid_for') - user = db.relationship("User", back_populates='expenses_paid_for') + expense = db.relationship("Expense", back_populates="paid_for") + user = db.relationship("User", back_populates="expenses_paid_for") def obj_to_user_dict(self): res = self.user.obj_to_dict() - res['factor'] = getattr(self, 'factor') - res['created_at'] = getattr(self, 'created_at') - res['updated_at'] = getattr(self, 'updated_at') + res["factor"] = getattr(self, "factor") + res["created_at"] = getattr(self, "created_at") + res["updated_at"] = getattr(self, "updated_at") return res @classmethod def find_by_ids(cls, expense_id, user_id) -> list[Self]: - return cls.query.filter(cls.expense_id == expense_id, cls.user_id == user_id).first() + return cls.query.filter( + cls.expense_id == expense_id, cls.user_id == user_id + ).first() diff --git a/backend/app/models/expense_category.py b/backend/app/models/expense_category.py index 73b5b327..7810897e 100644 --- a/backend/app/models/expense_category.py +++ b/backend/app/models/expense_category.py @@ -5,17 +5,15 @@ class ExpenseCategory(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): - __tablename__ = 'expense_category' + __tablename__ = "expense_category" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) color = db.Column(db.BigInteger) - household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False) + household_id = db.Column(db.Integer, db.ForeignKey("household.id"), nullable=False) household = db.relationship("Household", uselist=False) - expenses = db.relationship( - 'Expense', back_populates='category') + expenses = db.relationship("Expense", back_populates="category") def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() @@ -23,8 +21,8 @@ def obj_to_full_dict(self) -> dict: def obj_to_export_dict(self) -> dict: return { - 'name': self.name, - 'color': self.color, + "name": self.name, + "color": self.color, } def merge(self, other: Self) -> None: @@ -46,7 +44,9 @@ def merge(self, other: Self) -> None: @classmethod def find_by_name(cls, houshold_id: int, name: str) -> Self: - return cls.query.filter(cls.name == name, cls.household_id == houshold_id).first() + return cls.query.filter( + cls.name == name, cls.household_id == houshold_id + ).first() @classmethod def find_by_id(cls, id: int) -> Self: diff --git a/backend/app/models/file.py b/backend/app/models/file.py index 263477b6..c4d420cb 100644 --- a/backend/app/models/file.py +++ b/backend/app/models/file.py @@ -8,21 +8,18 @@ class File(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): - __tablename__ = 'file' + __tablename__ = "file" filename = db.Column(db.String(), primary_key=True) blur_hash = db.Column(db.String(length=40), nullable=True) - created_by = db.Column(db.Integer, db.ForeignKey( - 'user.id'), nullable=True) + created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) - created_by_user = db.relationship( - "User", foreign_keys=[created_by], uselist=False) + created_by_user = db.relationship("User", foreign_keys=[created_by], uselist=False) household = db.relationship("Household", uselist=False) recipe = db.relationship("Recipe", uselist=False) expense = db.relationship("Expense", uselist=False) - profile_picture = db.relationship( - "User", foreign_keys=[User.photo], uselist=False) + profile_picture = db.relationship("User", foreign_keys=[User.photo], uselist=False) def delete(self): """ @@ -33,7 +30,12 @@ def delete(self): db.session.commit() def isUnused(self) -> bool: - return not self.household and not self.recipe and not self.expense and not self.profile_picture + return ( + not self.household + and not self.recipe + and not self.expense + and not self.profile_picture + ) @classmethod def find(cls, filename: str) -> Self: diff --git a/backend/app/models/history.py b/backend/app/models/history.py index 1733bf4d..3ad2065e 100644 --- a/backend/app/models/history.py +++ b/backend/app/models/history.py @@ -14,56 +14,62 @@ class Status(enum.Enum): class History(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'history' + __tablename__ = "history" id = db.Column(db.Integer, primary_key=True) - shoppinglist_id = db.Column(db.Integer, db.ForeignKey( - 'shoppinglist.id')) - item_id = db.Column(db.Integer, db.ForeignKey('item.id')) + shoppinglist_id = db.Column(db.Integer, db.ForeignKey("shoppinglist.id")) + item_id = db.Column(db.Integer, db.ForeignKey("item.id")) item = db.relationship("Item", uselist=False, back_populates="history") shoppinglist = db.relationship( - "Shoppinglist", uselist=False, back_populates="history") + "Shoppinglist", uselist=False, back_populates="history" + ) status = db.Column(db.Enum(Status)) - description = db.Column('description', db.String()) + description = db.Column("description", db.String()) @classmethod - def create_added_without_save(cls, shoppinglist, item, description='') -> Self: + def create_added_without_save(cls, shoppinglist, item, description="") -> Self: return cls( shoppinglist_id=shoppinglist.id, item_id=item.id, status=Status.ADDED, - description=description + description=description, ) @classmethod - def create_added(cls, shoppinglist, item, description='') -> Self: + def create_added(cls, shoppinglist, item, description="") -> Self: return cls.create_added_without_save(shoppinglist, item, description).save() @classmethod - def create_dropped(cls, shoppinglist, item, description='', created_at=None) -> Self: + def create_dropped( + cls, shoppinglist, item, description="", created_at=None + ) -> Self: return cls( shoppinglist_id=shoppinglist.id, item_id=item.id, status=Status.DROPPED, description=description, - created_at=created_at + created_at=created_at, ).save() def obj_to_item_dict(self) -> dict: res = self.item.obj_to_dict() - res['timestamp'] = getattr(self, 'created_at') + res["timestamp"] = getattr(self, "created_at") return res @classmethod def find_added_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]: - return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.status == Status.ADDED).all() + return cls.query.filter( + cls.shoppinglist_id == shoppinglist_id, cls.status == Status.ADDED + ).all() @classmethod def find_dropped_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]: - return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.status == Status.DROPPED).all() + return cls.query.filter( + cls.shoppinglist_id == shoppinglist_id, cls.status == Status.DROPPED + ).all() @classmethod def find_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]: @@ -75,9 +81,19 @@ def find_all(cls) -> list[Self]: @classmethod def get_recent(cls, shoppinglist_id: int, limit: int = 9) -> list[Self]: - sq = db.session.query( - ShoppinglistItems.item_id).subquery().select() - sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED).filter( - cls.item_id.notin_(sq)).group_by(cls.item_id).join(cls.item).subquery().select() - return cls.query.filter( - cls.shoppinglist_id == shoppinglist_id).filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(limit) + sq = db.session.query(ShoppinglistItems.item_id).subquery().select() + sq2 = ( + db.session.query(func.max(cls.id)) + .filter(cls.status == Status.DROPPED) + .filter(cls.item_id.notin_(sq)) + .group_by(cls.item_id) + .join(cls.item) + .subquery() + .select() + ) + return ( + cls.query.filter(cls.shoppinglist_id == shoppinglist_id) + .filter(cls.id.in_(sq2)) + .order_by(cls.id.desc()) + .limit(limit) + ) diff --git a/backend/app/models/household.py b/backend/app/models/household.py index d8f03d54..177dd1a9 100644 --- a/backend/app/models/household.py +++ b/backend/app/models/household.py @@ -5,11 +5,11 @@ class Household(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'household' + __tablename__ = "household" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128), nullable=False) - photo = db.Column(db.String(), db.ForeignKey('file.filename')) + photo = db.Column(db.String(), db.ForeignKey("file.filename")) language = db.Column(db.String()) planner_feature = db.Column(db.Boolean(), nullable=False, default=True) expenses_feature = db.Column(db.Boolean(), nullable=False, default=True) @@ -17,75 +17,85 @@ class Household(db.Model, DbModelMixin, TimestampMixin): view_ordering = db.Column(DbListType(), default=list()) items = db.relationship( - 'Item', back_populates='household', cascade="all, delete-orphan") + "Item", back_populates="household", cascade="all, delete-orphan" + ) shoppinglists = db.relationship( - 'Shoppinglist', back_populates='household', cascade="all, delete-orphan") + "Shoppinglist", back_populates="household", cascade="all, delete-orphan" + ) categories = db.relationship( - 'Category', back_populates='household', cascade="all, delete-orphan") + "Category", back_populates="household", cascade="all, delete-orphan" + ) recipes = db.relationship( - 'Recipe', back_populates='household', cascade="all, delete-orphan") + "Recipe", back_populates="household", cascade="all, delete-orphan" + ) tags = db.relationship( - 'Tag', back_populates='household', cascade="all, delete-orphan") + "Tag", back_populates="household", cascade="all, delete-orphan" + ) expenses = db.relationship( - 'Expense', back_populates='household', cascade="all, delete-orphan") + "Expense", back_populates="household", cascade="all, delete-orphan" + ) expenseCategories = db.relationship( - 'ExpenseCategory', back_populates='household', cascade="all, delete-orphan") + "ExpenseCategory", back_populates="household", cascade="all, delete-orphan" + ) member = db.relationship( - 'HouseholdMember', back_populates='household', cascade="all, delete-orphan") - photo_file = db.relationship( - "File", back_populates='household', uselist=False) + "HouseholdMember", back_populates="household", cascade="all, delete-orphan" + ) + photo_file = db.relationship("File", back_populates="household", uselist=False) def obj_to_dict(self) -> dict: res = super().obj_to_dict() - res['member'] = [m.obj_to_user_dict() for m in getattr(self, 'member')] - res['default_shopping_list'] = self.shoppinglists[0].obj_to_dict() + res["member"] = [m.obj_to_user_dict() for m in getattr(self, "member")] + res["default_shopping_list"] = self.shoppinglists[0].obj_to_dict() if self.photo_file: - res['photo_hash'] = self.photo_file.blur_hash + res["photo_hash"] = self.photo_file.blur_hash return res def obj_to_export_dict(self) -> dict: return { - 'name': self.name, - 'language': self.language, - 'view_ordering': self.view_ordering, - 'planner_feature': self.planner_feature, - 'expenses_feature': self.expenses_feature, - 'member': [m.user.username for m in getattr(self, 'member')], - 'shoppinglists': [s.name for s in self.shoppinglists], - 'recipes': [s.obj_to_export_dict() for s in self.recipes], - 'items': [s.obj_to_export_dict() for s in self.items], - 'expenses': [s.obj_to_export_dict() for s in self.expenses], + "name": self.name, + "language": self.language, + "view_ordering": self.view_ordering, + "planner_feature": self.planner_feature, + "expenses_feature": self.expenses_feature, + "member": [m.user.username for m in getattr(self, "member")], + "shoppinglists": [s.name for s in self.shoppinglists], + "recipes": [s.obj_to_export_dict() for s in self.recipes], + "items": [s.obj_to_export_dict() for s in self.items], + "expenses": [s.obj_to_export_dict() for s in self.expenses], } class HouseholdMember(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'household_member' + __tablename__ = "household_member" - household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), primary_key=True + ) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) owner = db.Column(db.Boolean(), default=False, nullable=False) admin = db.Column(db.Boolean(), default=False, nullable=False) expense_balance = db.Column(db.Float(), default=0, nullable=False) - household = db.relationship("Household", back_populates='member') - user = db.relationship("User", back_populates='households') + household = db.relationship("Household", back_populates="member") + user = db.relationship("User", back_populates="households") def obj_to_user_dict(self) -> dict: res = self.user.obj_to_dict() - res['owner'] = getattr(self, 'owner') - res['admin'] = getattr(self, 'admin') - res['expense_balance'] = getattr(self, 'expense_balance') + res["owner"] = getattr(self, "owner") + res["admin"] = getattr(self, "admin") + res["expense_balance"] = getattr(self, "expense_balance") return res def delete(self): if len(self.household.member) <= 1: self.household.delete() elif self.owner: - newOwner = next((m for m in self.household.member if m.admin and m != self), next( - (m for m in self.household.member if m != self))) + newOwner = next( + (m for m in self.household.member if m.admin and m != self), + next((m for m in self.household.member if m != self)), + ) newOwner.admin = True newOwner.owner = True newOwner.save() @@ -95,7 +105,9 @@ def delete(self): @classmethod def find_by_ids(cls, household_id: int, user_id: int) -> Self: - return cls.query.filter(cls.household_id == household_id, cls.user_id == user_id).first() + return cls.query.filter( + cls.household_id == household_id, cls.user_id == user_id + ).first() @classmethod def find_by_household(cls, household_id: int) -> list[Self]: diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 8aec5d8e..ed7460a0 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -9,42 +9,54 @@ class Item(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): - __tablename__ = 'item' + __tablename__ = "item" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) icon = db.Column(db.String(128), nullable=True) - category_id = db.Column(db.Integer, db.ForeignKey('category.id')) + category_id = db.Column(db.Integer, db.ForeignKey("category.id")) default = db.Column(db.Boolean, default=False) default_key = db.Column(db.String(128)) - household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False, index=True) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) household = db.relationship("Household", uselist=False) category = db.relationship("Category") recipes = db.relationship( - 'RecipeItems', back_populates='item', cascade="all, delete-orphan") + "RecipeItems", back_populates="item", cascade="all, delete-orphan" + ) shoppinglists = db.relationship( - 'ShoppinglistItems', back_populates='item', cascade="all, delete-orphan") + "ShoppinglistItems", back_populates="item", cascade="all, delete-orphan" + ) # determines order of items in the shoppinglist - ordering = db.Column(db.Integer, server_default='0') + ordering = db.Column(db.Integer, server_default="0") # frequency of item, used for item suggestions - support = db.Column(db.Float, server_default='0.0') + support = db.Column(db.Float, server_default="0.0") history = db.relationship( - "History", back_populates="item", cascade="all, delete-orphan") + "History", back_populates="item", cascade="all, delete-orphan" + ) antecedents = db.relationship( - "Association", back_populates="antecedent", foreign_keys='Association.antecedent_id', cascade="all, delete-orphan") + "Association", + back_populates="antecedent", + foreign_keys="Association.antecedent_id", + cascade="all, delete-orphan", + ) consequents = db.relationship( - "Association", back_populates="consequent", foreign_keys='Association.consequent_id', cascade="all, delete-orphan") + "Association", + back_populates="consequent", + foreign_keys="Association.consequent_id", + cascade="all, delete-orphan", + ) def obj_to_dict(self) -> dict: res = super().obj_to_dict() if self.category_id: category = Category.find_by_id(self.category_id) - res['category'] = category.obj_to_dict() + res["category"] = category.obj_to_dict() return res def obj_to_export_dict(self) -> dict: @@ -87,20 +99,23 @@ def merge(self, other: Self) -> None: db.session.add(ri) else: existingRi.description = description_merger.merge( - existingRi.description, ri.description) + existingRi.description, ri.description + ) db.session.delete(ri) db.session.add(existingRi) - for si in ShoppinglistItems.query.filter(ShoppinglistItems.item_id == other.id).all(): + for si in ShoppinglistItems.query.filter( + ShoppinglistItems.item_id == other.id + ).all(): si: ShoppinglistItems - existingSi = ShoppinglistItems.find_by_ids( - si.shoppinglist_id, self.id) + existingSi = ShoppinglistItems.find_by_ids(si.shoppinglist_id, self.id) if not existingSi: si.item_id = self.id db.session.add(si) else: existingSi.description = description_merger.merge( - existingSi.description, si.description) + existingSi.description, si.description + ) db.session.delete(si) db.session.add(existingSi) @@ -117,7 +132,9 @@ def merge(self, other: Self) -> None: raise e @classmethod - def create_by_name(cls, household_id: int, name: str, default: bool = False) -> Self: + def create_by_name( + cls, household_id: int, name: str, default: bool = False + ) -> Self: return cls( name=name.strip(), default=default, @@ -127,11 +144,15 @@ def create_by_name(cls, household_id: int, name: str, default: bool = False) -> @classmethod def find_by_name(cls, household_id: int, name: str) -> Self: name = name.strip() - return cls.query.filter(cls.household_id == household_id, cls.name == name).first() + return cls.query.filter( + cls.household_id == household_id, cls.name == name + ).first() @classmethod def find_by_default_key(cls, household_id: int, default_key: str) -> Self: - return cls.query.filter(cls.household_id == household_id, cls.default_key == default_key).first() + return cls.query.filter( + cls.household_id == household_id, cls.default_key == default_key + ).first() @classmethod def find_by_id(cls, id) -> Self: @@ -141,28 +162,54 @@ def find_by_id(cls, id) -> Self: def search_name(cls, name: str, household_id: int) -> list[Self]: item_count = 11 if "postgresql" in db.engine.name: - return cls.query.filter(cls.household_id == household_id, func.levenshtein(func.lower(func.substring(cls.name, 1, len(name))), name.lower()) < 4).order_by(func.levenshtein(func.lower(func.substring(cls.name, 1, len(name))), name.lower()), cls.support.desc()).limit(item_count) + return ( + cls.query.filter( + cls.household_id == household_id, + func.levenshtein( + func.lower(func.substring(cls.name, 1, len(name))), name.lower() + ) + < 4, + ) + .order_by( + func.levenshtein( + func.lower(func.substring(cls.name, 1, len(name))), name.lower() + ), + cls.support.desc(), + ) + .limit(item_count) + ) found = [] # name is a regex - if '*' in name or '?' in name or '%' in name or '_' in name: - looking_for = name.replace('*', '%').replace('?', '_') - found = cls.query.filter(cls.name.ilike(looking_for), cls.household_id == household_id).order_by( - cls.support.desc()).limit(item_count).all() + if "*" in name or "?" in name or "%" in name or "_" in name: + looking_for = name.replace("*", "%").replace("?", "_") + found = ( + cls.query.filter( + cls.name.ilike(looking_for), cls.household_id == household_id + ) + .order_by(cls.support.desc()) + .limit(item_count) + .all() + ) return found # name is no regex - starts_with = '{0}%'.format(name) - contains = '%{0}%'.format(name) + starts_with = "{0}%".format(name) + contains = "%{0}%".format(name) one_error = [] for index in range(len(name)): - name_one_error = name[:index]+'_'+name[index+1:] - one_error.append('%{0}%'.format(name_one_error)) + name_one_error = name[:index] + "_" + name[index + 1 :] + one_error.append("%{0}%".format(name_one_error)) for looking_for in [starts_with, contains] + one_error: - res = cls.query.filter(cls.name.ilike(looking_for), cls.household_id == household_id).order_by( - cls.support.desc(), cls.name).all() + res = ( + cls.query.filter( + cls.name.ilike(looking_for), cls.household_id == household_id + ) + .order_by(cls.support.desc(), cls.name) + .all() + ) for r in res: if r not in found: found.append(r) diff --git a/backend/app/models/planner.py b/backend/app/models/planner.py index c0804ac8..0b9c1019 100644 --- a/backend/app/models/planner.py +++ b/backend/app/models/planner.py @@ -5,31 +5,35 @@ class Planner(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): - __tablename__ = 'planner' + __tablename__ = "planner" - recipe_id = db.Column(db.Integer, db.ForeignKey( - 'recipe.id'), primary_key=True) + recipe_id = db.Column(db.Integer, db.ForeignKey("recipe.id"), primary_key=True) day = db.Column(db.Integer, primary_key=True) yields = db.Column(db.Integer) - household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False, index=True) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) household = db.relationship("Household", uselist=False) recipe = db.relationship("Recipe", back_populates="plans") def obj_to_full_dict(self) -> dict: res = self.obj_to_dict() - res['recipe'] = self.recipe.obj_to_full_dict() + res["recipe"] = self.recipe.obj_to_full_dict() return res - + @classmethod def all_from_household(cls, household_id: int) -> list[Self]: """ Return all instances of model IMPORTANT: requires household_id column """ - return cls.query.filter(cls.household_id == household_id).order_by(cls.day).all() + return ( + cls.query.filter(cls.household_id == household_id).order_by(cls.day).all() + ) @classmethod def find_by_day(cls, household_id: int, recipe_id: int, day: int) -> Self: - return cls.query.filter(cls.household_id == household_id, cls.recipe_id == recipe_id, cls.day == day).first() + return cls.query.filter( + cls.household_id == household_id, cls.recipe_id == recipe_id, cls.day == day + ).first() diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index 298015e9..b602a561 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -9,61 +9,77 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): - __tablename__ = 'recipe' + __tablename__ = "recipe" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) description = db.Column(db.String()) - photo = db.Column(db.String(), db.ForeignKey('file.filename')) + photo = db.Column(db.String(), db.ForeignKey("file.filename")) time = db.Column(db.Integer) cook_time = db.Column(db.Integer) prep_time = db.Column(db.Integer) yields = db.Column(db.Integer) source = db.Column(db.String()) - suggestion_score = db.Column(db.Integer, server_default='0') - suggestion_rank = db.Column(db.Integer, server_default='0') - household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False, index=True) + suggestion_score = db.Column(db.Integer, server_default="0") + suggestion_rank = db.Column(db.Integer, server_default="0") + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) household = db.relationship("Household", uselist=False) recipe_history = db.relationship( - "RecipeHistory", back_populates="recipe", cascade="all, delete-orphan") + "RecipeHistory", back_populates="recipe", cascade="all, delete-orphan" + ) items = db.relationship( - 'RecipeItems', back_populates='recipe', cascade="all, delete-orphan") + "RecipeItems", back_populates="recipe", cascade="all, delete-orphan" + ) tags = db.relationship( - 'RecipeTags', back_populates='recipe', cascade="all, delete-orphan") + "RecipeTags", back_populates="recipe", cascade="all, delete-orphan" + ) plans = db.relationship( - 'Planner', back_populates='recipe', cascade="all, delete-orphan") - photo_file = db.relationship( - "File", back_populates='recipe', uselist=False) + "Planner", back_populates="recipe", cascade="all, delete-orphan" + ) + photo_file = db.relationship("File", back_populates="recipe", uselist=False) def obj_to_dict(self) -> dict: res = super().obj_to_dict() - res['planned'] = len(self.plans) > 0 - res['planned_days'] = [plan.day for plan in self.plans if plan.day >= 0] + res["planned"] = len(self.plans) > 0 + res["planned_days"] = [plan.day for plan in self.plans if plan.day >= 0] if self.photo_file: - res['photo_hash'] = self.photo_file.blur_hash + res["photo_hash"] = self.photo_file.blur_hash return res def obj_to_full_dict(self) -> dict: res = self.obj_to_dict() - items = RecipeItems.query.filter(RecipeItems.recipe_id == self.id).join( - RecipeItems.item).order_by( - Item.name).all() - res['items'] = [e.obj_to_item_dict() for e in items] - tags = RecipeTags.query.filter(RecipeTags.recipe_id == self.id).join( - RecipeTags.tag).order_by( - Tag.name).all() - res['tags'] = [e.obj_to_item_dict() for e in tags] + items = ( + RecipeItems.query.filter(RecipeItems.recipe_id == self.id) + .join(RecipeItems.item) + .order_by(Item.name) + .all() + ) + res["items"] = [e.obj_to_item_dict() for e in items] + tags = ( + RecipeTags.query.filter(RecipeTags.recipe_id == self.id) + .join(RecipeTags.tag) + .order_by(Tag.name) + .all() + ) + res["tags"] = [e.obj_to_item_dict() for e in tags] return res def obj_to_export_dict(self) -> dict: - items = RecipeItems.query.filter(RecipeItems.recipe_id == self.id).join( - RecipeItems.item).order_by( - Item.name).all() - tags = RecipeTags.query.filter(RecipeTags.recipe_id == self.id).join( - RecipeTags.tag).order_by( - Tag.name).all() + items = ( + RecipeItems.query.filter(RecipeItems.recipe_id == self.id) + .join(RecipeItems.item) + .order_by(Item.name) + .all() + ) + tags = ( + RecipeTags.query.filter(RecipeTags.recipe_id == self.id) + .join(RecipeTags.tag) + .order_by(Tag.name) + .all() + ) res = { "name": self.name, "description": self.description, @@ -73,7 +89,14 @@ def obj_to_export_dict(self) -> dict: "prep_time": self.prep_time, "yields": self.yields, "source": self.source, - "items": [{"name": e.item.name, "description": e.description, "optional": e.optional} for e in items], + "items": [ + { + "name": e.item.name, + "description": e.description, + "optional": e.optional, + } + for e in items + ], "tags": [e.tag.name for e in tags], } return res @@ -86,7 +109,8 @@ def compute_suggestion_ranking(cls, household_id: int): db.session.add(r) # get all recipes with positive suggestion_score recipes = cls.query.filter( - cls.household_id == household_id, cls.suggestion_score != 0).all() + cls.household_id == household_id, cls.suggestion_score != 0 + ).all() # compute the initial sum of all suggestion_scores suggestion_sum = 0 for r in recipes: @@ -96,7 +120,7 @@ def compute_suggestion_ranking(cls, household_id: int): while len(recipes) > 0: choose = randint(1, suggestion_sum) to_be_removed = -1 - for (i, r) in enumerate(recipes): + for i, r in enumerate(recipes): choose -= r.suggestion_score if choose <= 0: r.suggestion_rank = current_rank @@ -109,15 +133,27 @@ def compute_suggestion_ranking(cls, household_id: int): db.session.commit() @classmethod - def find_suggestions(cls, household_id: int,) -> list[Self]: - sq = db.session.query(Planner.recipe_id).group_by( - Planner.recipe_id).scalar_subquery() - return cls.query.filter(cls.household_id == household_id, cls.id.notin_(sq)).filter( # noqa - cls.suggestion_rank > 0).order_by(cls.suggestion_rank).all() + def find_suggestions( + cls, + household_id: int, + ) -> list[Self]: + sq = ( + db.session.query(Planner.recipe_id) + .group_by(Planner.recipe_id) + .scalar_subquery() + ) + return ( + cls.query.filter(cls.household_id == household_id, cls.id.notin_(sq)) + .filter(cls.suggestion_rank > 0) # noqa + .order_by(cls.suggestion_rank) + .all() + ) @classmethod def find_by_name(cls, household_id: int, name: str) -> Self: - return cls.query.filter(cls.household_id == household_id, cls.name == name).first() + return cls.query.filter( + cls.household_id == household_id, cls.name == name + ).first() @classmethod def find_by_id(cls, id: int) -> Self: @@ -125,73 +161,90 @@ def find_by_id(cls, id: int) -> Self: @classmethod def search_name(cls, household_id: int, name: str) -> list[Self]: - if '*' in name or '_' in name: - looking_for = name.replace('_', '__')\ - .replace('*', '%')\ - .replace('?', '_') + if "*" in name or "_" in name: + looking_for = name.replace("_", "__").replace("*", "%").replace("?", "_") else: - looking_for = '%{0}%'.format(name) - return cls.query.filter(cls.household_id == household_id, cls.name.ilike(looking_for)).order_by(cls.name).all() + looking_for = "%{0}%".format(name) + return ( + cls.query.filter( + cls.household_id == household_id, cls.name.ilike(looking_for) + ) + .order_by(cls.name) + .all() + ) @classmethod - def all_by_name_with_filter(cls, household_id: int, filter: list[str]) -> list[Self]: - sq = db.session.query(RecipeTags.recipe_id).join(RecipeTags.tag).filter( - Tag.name.in_(filter)).subquery() - return db.session.query(cls).filter(cls.household_id == household_id, cls.id.in_(sq)).order_by(cls.name).all() + def all_by_name_with_filter( + cls, household_id: int, filter: list[str] + ) -> list[Self]: + sq = ( + db.session.query(RecipeTags.recipe_id) + .join(RecipeTags.tag) + .filter(Tag.name.in_(filter)) + .subquery() + ) + return ( + db.session.query(cls) + .filter(cls.household_id == household_id, cls.id.in_(sq)) + .order_by(cls.name) + .all() + ) class RecipeItems(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'recipe_items' + __tablename__ = "recipe_items" - recipe_id = db.Column(db.Integer, db.ForeignKey( - 'recipe.id'), primary_key=True) - item_id = db.Column(db.Integer, db.ForeignKey('item.id'), primary_key=True) - description = db.Column('description', db.String()) - optional = db.Column('optional', db.Boolean) + recipe_id = db.Column(db.Integer, db.ForeignKey("recipe.id"), primary_key=True) + item_id = db.Column(db.Integer, db.ForeignKey("item.id"), primary_key=True) + description = db.Column("description", db.String()) + optional = db.Column("optional", db.Boolean) - item = db.relationship("Item", back_populates='recipes') - recipe = db.relationship("Recipe", back_populates='items') + item = db.relationship("Item", back_populates="recipes") + recipe = db.relationship("Recipe", back_populates="items") def obj_to_item_dict(self) -> dict: res = self.item.obj_to_dict() - res['description'] = getattr(self, 'description') - res['optional'] = getattr(self, 'optional') - res['created_at'] = getattr(self, 'created_at') - res['updated_at'] = getattr(self, 'updated_at') + res["description"] = getattr(self, "description") + res["optional"] = getattr(self, "optional") + res["created_at"] = getattr(self, "created_at") + res["updated_at"] = getattr(self, "updated_at") return res def obj_to_recipe_dict(self) -> dict: res = self.recipe.obj_to_dict() - res['items'] = [ + res["items"] = [ { - 'id': getattr(self, 'item_id'), - 'description': getattr(self, 'description'), - 'optional': getattr(self, 'optional'), + "id": getattr(self, "item_id"), + "description": getattr(self, "description"), + "optional": getattr(self, "optional"), } ] return res @classmethod def find_by_ids(cls, recipe_id: int, item_id: int) -> Self: - return cls.query.filter(cls.recipe_id == recipe_id, cls.item_id == item_id).first() + return cls.query.filter( + cls.recipe_id == recipe_id, cls.item_id == item_id + ).first() class RecipeTags(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'recipe_tags' + __tablename__ = "recipe_tags" - recipe_id = db.Column(db.Integer, db.ForeignKey( - 'recipe.id'), primary_key=True) - tag_id = db.Column(db.Integer, db.ForeignKey('tag.id'), primary_key=True) + recipe_id = db.Column(db.Integer, db.ForeignKey("recipe.id"), primary_key=True) + tag_id = db.Column(db.Integer, db.ForeignKey("tag.id"), primary_key=True) - tag = db.relationship("Tag", back_populates='recipes') - recipe = db.relationship("Recipe", back_populates='tags') + tag = db.relationship("Tag", back_populates="recipes") + recipe = db.relationship("Recipe", back_populates="tags") def obj_to_item_dict(self) -> dict: res = self.tag.obj_to_dict() - res['created_at'] = getattr(self, 'created_at') - res['updated_at'] = getattr(self, 'updated_at') + res["created_at"] = getattr(self, "created_at") + res["updated_at"] = getattr(self, "updated_at") return res @classmethod def find_by_ids(cls, recipe_id: int, tag_id: int) -> Self: - return cls.query.filter(cls.recipe_id == recipe_id, cls.tag_id == tag_id).first() + return cls.query.filter( + cls.recipe_id == recipe_id, cls.tag_id == tag_id + ).first() diff --git a/backend/app/models/recipe_history.py b/backend/app/models/recipe_history.py index 55dbff5f..222313c9 100644 --- a/backend/app/models/recipe_history.py +++ b/backend/app/models/recipe_history.py @@ -14,17 +14,15 @@ class Status(enum.Enum): class RecipeHistory(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'recipe_history' + __tablename__ = "recipe_history" id = db.Column(db.Integer, primary_key=True) - recipe_id = db.Column(db.Integer, db.ForeignKey('recipe.id')) - household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False) + recipe_id = db.Column(db.Integer, db.ForeignKey("recipe.id")) + household_id = db.Column(db.Integer, db.ForeignKey("household.id"), nullable=False) household = db.relationship("Household", uselist=False) - recipe = db.relationship("Recipe", uselist=False, - back_populates="recipe_history") + recipe = db.relationship("Recipe", uselist=False, back_populates="recipe_history") status = db.Column(db.Enum(Status)) @@ -46,16 +44,20 @@ def create_dropped(cls, recipe: Recipe, household_id: int) -> Self: def obj_to_item_dict(self) -> dict: res = self.item.obj_to_dict() - res['timestamp'] = getattr(self, 'created_at') + res["timestamp"] = getattr(self, "created_at") return res @classmethod def find_added(cls, household_id: int) -> list[Self]: - return cls.query.filter(cls.household_id == household_id, cls.status == Status.ADDED).all() + return cls.query.filter( + cls.household_id == household_id, cls.status == Status.ADDED + ).all() @classmethod def find_dropped(cls, household_id: int) -> list[Self]: - return cls.query.filter(cls.household_id == household_id, cls.status == Status.DROPPED).all() + return cls.query.filter( + cls.household_id == household_id, cls.status == Status.DROPPED + ).all() @classmethod def find_all(cls, household_id: int) -> list[Self]: @@ -63,8 +65,20 @@ def find_all(cls, household_id: int) -> list[Self]: @classmethod def get_recent(cls, household_id: int) -> list[Self]: - sq = db.session.query(Planner.recipe_id).group_by(Planner.recipe_id).filter( - Planner.household_id == household_id).subquery().select() - sq2 = db.session.query(func.max(cls.id)).filter(cls.status == Status.DROPPED, cls.household_id == household_id).filter( - cls.recipe_id.notin_(sq)).group_by(cls.recipe_id).join(cls.recipe).subquery().select() + sq = ( + db.session.query(Planner.recipe_id) + .group_by(Planner.recipe_id) + .filter(Planner.household_id == household_id) + .subquery() + .select() + ) + sq2 = ( + db.session.query(func.max(cls.id)) + .filter(cls.status == Status.DROPPED, cls.household_id == household_id) + .filter(cls.recipe_id.notin_(sq)) + .group_by(cls.recipe_id) + .join(cls.recipe) + .subquery() + .select() + ) return cls.query.filter(cls.id.in_(sq2)).order_by(cls.id.desc()).limit(9) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index e8718ab2..f025e46a 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -4,7 +4,7 @@ class Settings(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'settings' + __tablename__ = "settings" id = db.Column(db.Integer, primary_key=True, nullable=False) diff --git a/backend/app/models/shoppinglist.py b/backend/app/models/shoppinglist.py index f6059297..ad0b710a 100644 --- a/backend/app/models/shoppinglist.py +++ b/backend/app/models/shoppinglist.py @@ -4,48 +4,56 @@ class Shoppinglist(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): - __tablename__ = 'shoppinglist' + __tablename__ = "shoppinglist" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) - household_id = db.Column(db.Integer, db.ForeignKey('household.id'), nullable=False, index=True) + household_id = db.Column( + db.Integer, db.ForeignKey("household.id"), nullable=False, index=True + ) household = db.relationship("Household", uselist=False) - items = db.relationship('ShoppinglistItems', cascade="all, delete-orphan") + items = db.relationship("ShoppinglistItems", cascade="all, delete-orphan") history = db.relationship( - "History", back_populates="shoppinglist", cascade="all, delete-orphan") + "History", back_populates="shoppinglist", cascade="all, delete-orphan" + ) @classmethod def getDefault(cls, household_id: int) -> Self: - return cls.query.filter(cls.household_id == household_id).order_by(cls.id).first() + return ( + cls.query.filter(cls.household_id == household_id).order_by(cls.id).first() + ) def isDefault(self) -> bool: return self.id == self.getDefault(self.household_id).id class ShoppinglistItems(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'shoppinglist_items' + __tablename__ = "shoppinglist_items" - shoppinglist_id = db.Column(db.Integer, db.ForeignKey( - 'shoppinglist.id'), primary_key=True) - item_id = db.Column(db.Integer, db.ForeignKey('item.id'), primary_key=True) - description = db.Column('description', db.String()) - created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + shoppinglist_id = db.Column( + db.Integer, db.ForeignKey("shoppinglist.id"), primary_key=True + ) + item_id = db.Column(db.Integer, db.ForeignKey("item.id"), primary_key=True) + description = db.Column("description", db.String()) + created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) - item = db.relationship("Item", back_populates='shoppinglists') - shoppinglist = db.relationship("Shoppinglist", back_populates='items') + item = db.relationship("Item", back_populates="shoppinglists") + shoppinglist = db.relationship("Shoppinglist", back_populates="items") created_by_user = db.relationship("User", foreign_keys=[created_by], uselist=False) def obj_to_item_dict(self) -> dict: res = self.item.obj_to_dict() - res['description'] = getattr(self, 'description') - res['created_at'] = getattr(self, 'created_at') - res['updated_at'] = getattr(self, 'updated_at') - res['created_by'] = getattr(self, 'created_by') + res["description"] = getattr(self, "description") + res["created_at"] = getattr(self, "created_at") + res["updated_at"] = getattr(self, "updated_at") + res["created_by"] = getattr(self, "created_by") return res @classmethod def find_by_ids(cls, shoppinglist_id: int, item_id: int) -> Self: - return cls.query.filter(cls.shoppinglist_id == shoppinglist_id, cls.item_id == item_id).first() + return cls.query.filter( + cls.shoppinglist_id == shoppinglist_id, cls.item_id == item_id + ).first() diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py index 50a59879..1601cdd5 100644 --- a/backend/app/models/tag.py +++ b/backend/app/models/tag.py @@ -4,28 +4,37 @@ class Tag(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): - __tablename__ = 'tag' + __tablename__ = "tag" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) - household_id = db.Column(db.Integer, db.ForeignKey( - 'household.id'), nullable=False) + household_id = db.Column(db.Integer, db.ForeignKey("household.id"), nullable=False) household = db.relationship("Household", uselist=False) - recipes = db.relationship('RecipeTags', back_populates='tag', cascade="all, delete-orphan") + recipes = db.relationship( + "RecipeTags", back_populates="tag", cascade="all, delete-orphan" + ) def obj_to_full_dict(self) -> dict: res = super().obj_to_dict() return res - + def merge(self, other: Self) -> None: if self.household_id != other.household_id: return from app.models import RecipeTags - for rectag in RecipeTags.query.filter(RecipeTags.tag_id == other.id, RecipeTags.recipe_id.notin_(db.session.query(RecipeTags.recipe_id).filter(RecipeTags.tag_id == self.id).subquery().select())).all(): + for rectag in RecipeTags.query.filter( + RecipeTags.tag_id == other.id, + RecipeTags.recipe_id.notin_( + db.session.query(RecipeTags.recipe_id) + .filter(RecipeTags.tag_id == self.id) + .subquery() + .select() + ), + ).all(): rectag.tag_id = self.id db.session.add(rectag) @@ -45,7 +54,9 @@ def create_by_name(cls, household_id: int, name: str) -> Self: @classmethod def find_by_name(cls, household_id: int, name: str) -> Self: - return cls.query.filter(cls.household_id == household_id, cls.name == name).first() + return cls.query.filter( + cls.household_id == household_id, cls.name == name + ).first() @classmethod def find_by_id(cls, id: int) -> Self: diff --git a/backend/app/models/token.py b/backend/app/models/token.py index 20f7bac5..0283c3ee 100644 --- a/backend/app/models/token.py +++ b/backend/app/models/token.py @@ -12,28 +12,30 @@ class Token(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'token' + __tablename__ = "token" id = db.Column(db.Integer, primary_key=True) jti = db.Column(db.String(36), nullable=False, index=True) type = db.Column(db.String(16), nullable=False) name = db.Column(db.String(), nullable=False) last_used_at = db.Column(db.DateTime) - refresh_token_id = db.Column( - db.Integer, db.ForeignKey('token.id'), nullable=True) - user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + refresh_token_id = db.Column(db.Integer, db.ForeignKey("token.id"), nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) created_tokens = db.relationship( - "Token", back_populates='refresh_token', cascade="all, delete-orphan") + "Token", back_populates="refresh_token", cascade="all, delete-orphan" + ) refresh_token = db.relationship("Token", remote_side=[id]) user = db.relationship("User") def obj_to_dict(self, skip_columns=None, include_columns=None) -> dict: if skip_columns: - skip_columns = skip_columns + ['jti'] + skip_columns = skip_columns + ["jti"] else: - skip_columns = ['jti'] - return super().obj_to_dict(skip_columns=skip_columns, include_columns=include_columns) + skip_columns = ["jti"] + return super().obj_to_dict( + skip_columns=skip_columns, include_columns=include_columns + ) @classmethod def find_by_jti(cls, jti: str) -> Self: @@ -42,22 +44,30 @@ def find_by_jti(cls, jti: str) -> Self: @classmethod def delete_expired_refresh(cls): filter_before = datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES - for token in db.session.query(cls).filter(cls.created_at <= filter_before, cls.type == - 'refresh', ~cls.created_tokens.any()).all(): + for token in ( + db.session.query(cls) + .filter( + cls.created_at <= filter_before, + cls.type == "refresh", + ~cls.created_tokens.any(), + ) + .all() + ): token.delete_token_familiy(commit=False) db.session.commit() @classmethod def delete_expired_access(cls): filter_before = datetime.utcnow() - JWT_ACCESS_TOKEN_EXPIRES - db.session.query(cls).filter(cls.created_at <= - filter_before, cls.type == 'access').delete() + db.session.query(cls).filter( + cls.created_at <= filter_before, cls.type == "access" + ).delete() db.session.commit() # Delete oldest refresh token -> log out device # Used e.g. when a refresh token is used twice def delete_token_familiy(self, commit=True): - if (self.type != 'refresh'): + if self.type != "refresh": return token = self while token: @@ -70,21 +80,29 @@ def delete_token_familiy(self, commit=True): db.session.commit() def has_created_refresh_token(self) -> bool: - return db.session.query(Token).filter(Token.refresh_token_id == self.id, Token.type == 'refresh').count() > 0 + return ( + db.session.query(Token) + .filter(Token.refresh_token_id == self.id, Token.type == "refresh") + .count() + > 0 + ) def delete_created_access_tokens(self): - if (self.type != 'refresh'): + if self.type != "refresh": return - db.session.query(Token).filter(Token.refresh_token_id == - self.id, Token.type == 'access').delete() + db.session.query(Token).filter( + Token.refresh_token_id == self.id, Token.type == "access" + ).delete() db.session.commit() @classmethod - def create_access_token(cls, user: User, refreshTokenModel: Self) -> Tuple[any, Self]: + def create_access_token( + cls, user: User, refreshTokenModel: Self + ) -> Tuple[any, Self]: accesssToken = create_access_token(identity=user) model = cls() model.jti = get_jti(accesssToken) - model.type = 'access' + model.type = "access" model.name = refreshTokenModel.name model.user = user model.refresh_token = refreshTokenModel @@ -92,20 +110,28 @@ def create_access_token(cls, user: User, refreshTokenModel: Self) -> Tuple[any, return accesssToken, model @classmethod - def create_refresh_token(cls, user: User, device: str = None, oldRefreshToken: Self = None) -> Tuple[any, Self]: + def create_refresh_token( + cls, user: User, device: str = None, oldRefreshToken: Self = None + ) -> Tuple[any, Self]: assert device or oldRefreshToken - if (oldRefreshToken and (oldRefreshToken.type != 'refresh' or oldRefreshToken.has_created_refresh_token())): + if oldRefreshToken and ( + oldRefreshToken.type != "refresh" + or oldRefreshToken.has_created_refresh_token() + ): oldRefreshToken.delete_token_familiy() raise UnauthorizedRequest( - message='Unauthorized: IP {} reused the same refresh token, loging out user'.format(request.remote_addr)) + message="Unauthorized: IP {} reused the same refresh token, loging out user".format( + request.remote_addr + ) + ) refreshToken = create_refresh_token(identity=user) model = cls() model.jti = get_jti(refreshToken) - model.type = 'refresh' + model.type = "refresh" model.name = device or oldRefreshToken.name model.user = user - if (oldRefreshToken): + if oldRefreshToken: oldRefreshToken.delete_created_access_tokens() model.refresh_token = oldRefreshToken model.save() @@ -116,7 +142,7 @@ def create_longlived_token(cls, user: User, device: str) -> Tuple[any, Self]: accesssToken = create_access_token(identity=user, expires_delta=False) model = cls() model.jti = get_jti(accesssToken) - model.type = 'llt' + model.type = "llt" model.name = device model.user = user model.save() diff --git a/backend/app/models/user.py b/backend/app/models/user.py index af7d51aa..e15e5d76 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -7,62 +7,73 @@ class User(db.Model, DbModelMixin, TimestampMixin): - __tablename__ = 'user' + __tablename__ = "user" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128)) username = db.Column(db.String(256), unique=True, nullable=False) email = db.Column(db.String(256), unique=True, nullable=True) password = db.Column(db.String(256), nullable=False) - photo = db.Column(db.String(), db.ForeignKey( - 'file.filename', use_alter=True)) + photo = db.Column(db.String(), db.ForeignKey("file.filename", use_alter=True)) admin = db.Column(db.Boolean(), default=False) tokens = db.relationship( - 'Token', back_populates='user', cascade="all, delete-orphan") + "Token", back_populates="user", cascade="all, delete-orphan" + ) households = db.relationship( - 'HouseholdMember', back_populates='user', cascade="all, delete-orphan") + "HouseholdMember", back_populates="user", cascade="all, delete-orphan" + ) expenses_paid = db.relationship( - 'Expense', back_populates='paid_by', cascade="all, delete-orphan") + "Expense", back_populates="paid_by", cascade="all, delete-orphan" + ) expenses_paid_for = db.relationship( - 'ExpensePaidFor', back_populates='user', cascade="all, delete-orphan") + "ExpensePaidFor", back_populates="user", cascade="all, delete-orphan" + ) photo_file = db.relationship( - "File", back_populates='profile_picture', foreign_keys=[photo], uselist=False) + "File", back_populates="profile_picture", foreign_keys=[photo], uselist=False + ) def check_password(self, password: str) -> bool: return bcrypt.check_password_hash(self.password, password) def set_password(self, password: str): - self.password = bcrypt.generate_password_hash(password).decode('utf-8') - - def set_password(self, password: str): - self.password = bcrypt.generate_password_hash(password).decode('utf-8') - - def obj_to_dict(self, include_email: bool = False, skip_columns: list[str] = None, include_columns: list[str] = None) -> dict: + self.password = bcrypt.generate_password_hash(password).decode("utf-8") + + def obj_to_dict( + self, + include_email: bool = False, + skip_columns: list[str] | None = None, + include_columns: list[str] | None = None, + ) -> dict: if skip_columns: - skip_columns = skip_columns + ['password'] + skip_columns = skip_columns + ["password"] else: - skip_columns = ['password'] + skip_columns = ["password"] if not include_email: - skip_columns += ['email'] + skip_columns += ["email"] if not current_user or not current_user.admin: # Filter out admin status if current user is not an admin - skip_columns = skip_columns + ['admin'] + skip_columns = skip_columns + ["admin"] - return super().obj_to_dict(skip_columns=skip_columns, include_columns=include_columns) + return super().obj_to_dict( + skip_columns=skip_columns, include_columns=include_columns + ) def obj_to_full_dict(self) -> dict: from .token import Token + res = self.obj_to_dict() - res['admin'] = self.admin - res['email'] = self.email - tokens = Token.query.filter(Token.user_id == self.id, Token.type != - 'access', ~Token.created_tokens.any(Token.type == 'refresh')).all() - res['tokens'] = [e.obj_to_dict( - skip_columns=['user_id']) for e in tokens] + res["admin"] = self.admin + res["email"] = self.email + tokens = Token.query.filter( + Token.user_id == self.id, + Token.type != "access", + ~Token.created_tokens.any(Token.type == "refresh"), + ).all() + res["tokens"] = [e.obj_to_dict(skip_columns=["user_id"]) for e in tokens] return res def delete(self): @@ -70,11 +81,15 @@ def delete(self): Delete this instance of model from db """ from app.models import File + for f in File.query.filter(File.created_by == self.id).all(): f.created_by = None f.save() from app.models import ShoppinglistItems - for s in ShoppinglistItems.query.filter(ShoppinglistItems.created_by == self.id).all(): + + for s in ShoppinglistItems.query.filter( + ShoppinglistItems.created_by == self.id + ).all(): s.created_by = None s.save() super().delete() @@ -88,21 +103,32 @@ def find_by_email(cls, email: str) -> Self: return cls.query.filter(cls.email == email.strip()).first() @classmethod - def create(cls, username: str, password: str, name: str, email: str | None = None, admin: bool = False) -> Self: + def create( + cls, + username: str, + password: str, + name: str, + email: str | None = None, + admin: bool = False, + ) -> Self: return cls( username=username.lower().strip().replace(" ", ""), - password=bcrypt.generate_password_hash(password).decode('utf-8'), + password=bcrypt.generate_password_hash(password).decode("utf-8"), name=name.strip(), email=email.strip() if email else None, - admin=admin + admin=admin, ).save() @classmethod def search_name(cls, name: str) -> list[Self]: - if '*' in name or '_' in name: - looking_for = name.replace('_', '__')\ - .replace('*', '%')\ - .replace('?', '_') + if "*" in name or "_" in name: + looking_for = name.replace("_", "__").replace("*", "%").replace("?", "_") else: - looking_for = '%{0}%'.format(name) - return cls.query.filter(cls.name.ilike(looking_for) | cls.username.ilike(looking_for)).order_by(cls.name).limit(15) + looking_for = "%{0}%".format(name) + return ( + cls.query.filter( + cls.name.ilike(looking_for) | cls.username.ilike(looking_for) + ) + .order_by(cls.name) + .limit(15) + ) diff --git a/backend/app/service/file_has_access_or_download.py b/backend/app/service/file_has_access_or_download.py index 91200f33..9af22925 100644 --- a/backend/app/service/file_has_access_or_download.py +++ b/backend/app/service/file_has_access_or_download.py @@ -15,11 +15,12 @@ def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str: Downloads the file if the url is an external URL or checks if the user has access to the file on this server If the user has no access oldPhoto is returned """ - if newPhoto is not None and '/' in newPhoto: + if newPhoto is not None and "/" in newPhoto: from mimetypes import guess_extension + resp = requests.get(newPhoto) - ext = guess_extension(resp.headers['content-type']) - if ext and allowed_file('file' + ext): + ext = guess_extension(resp.headers["content-type"]) + if ext and allowed_file("file" + ext): filename = secure_filename(str(uuid.uuid4()) + ext) with open(os.path.join(UPLOAD_FOLDER, filename), "wb") as o: o.write(resp.content) @@ -27,14 +28,12 @@ def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str: try: with Image.open(os.path.join(UPLOAD_FOLDER, filename)) as image: image.thumbnail((100, 100)) - blur = blurhash.encode( - image, x_components=4, y_components=3) + blur = blurhash.encode(image, x_components=4, y_components=3) except FileNotFoundError: return None except Exception: pass - File(filename=filename, blur_hash=blur, - created_by=current_user.id).save() + File(filename=filename, blur_hash=blur, created_by=current_user.id).save() return filename elif newPhoto is not None: if not newPhoto: diff --git a/backend/app/service/importServices/__init__.py b/backend/app/service/importServices/__init__.py index f2755cac..d18c39f0 100644 --- a/backend/app/service/importServices/__init__.py +++ b/backend/app/service/importServices/__init__.py @@ -1,4 +1,4 @@ from .import_recipe import importRecipe from .import_expense import importExpense from .import_shoppinglist import importShoppinglist -from. import_item import importItem \ No newline at end of file +from .import_item import importItem diff --git a/backend/app/service/importServices/import_expense.py b/backend/app/service/importServices/import_expense.py index 2a148751..525eca0c 100644 --- a/backend/app/service/importServices/import_expense.py +++ b/backend/app/service/importServices/import_expense.py @@ -1,4 +1,3 @@ - from datetime import datetime, timezone from app.models import Household, Expense, ExpensePaidFor, ExpenseCategory from app.service.file_has_access_or_download import file_has_access_or_download @@ -7,37 +6,38 @@ def importExpense(household: Household, args: dict): expense = Expense() expense.household = household - expense.name = args['name'] - expense.date = datetime.fromtimestamp( - args['date']/1000, timezone.utc) - expense.amount = args['amount'] - if 'photo' in args: - expense.photo = file_has_access_or_download(args['photo']) - if 'category' in args: - category = ExpenseCategory.find_by_name( - household.id, args['category']['name']) + expense.name = args["name"] + expense.date = datetime.fromtimestamp(args["date"] / 1000, timezone.utc) + expense.amount = args["amount"] + if "photo" in args: + expense.photo = file_has_access_or_download(args["photo"]) + if "category" in args: + category = ExpenseCategory.find_by_name(household.id, args["category"]["name"]) if not category: category = ExpenseCategory() - category.name = args['category']['name'] - category.color = args['category']['color'] + category.name = args["category"]["name"] + category.color = args["category"]["color"] category.household_id = household.id category = category.save() expense.category = category paid_by = next( - (x for x in household.member if x.user.username == args['paid_by']), None) + (x for x in household.member if x.user.username == args["paid_by"]), None + ) if paid_by: expense.paid_by_id = paid_by.user_id expense.save() - for paid_for in args['paid_for']: + for paid_for in args["paid_for"]: paid_for_member = next( - (x for x in household.member if x.user.username == paid_for['username']), None) + (x for x in household.member if x.user.username == paid_for["username"]), + None, + ) if not paid_for_member: continue con = ExpensePaidFor() con.expense = expense con.user_id = paid_for_member.user_id - con.factor = paid_for['factor'] + con.factor = paid_for["factor"] con.save() diff --git a/backend/app/service/importServices/import_item.py b/backend/app/service/importServices/import_item.py index 965ffe0c..dbc49142 100644 --- a/backend/app/service/importServices/import_item.py +++ b/backend/app/service/importServices/import_item.py @@ -2,19 +2,16 @@ def importItem(household: Household, args: dict): - item = Item.find_by_name(household.id, args['name']) + item = Item.find_by_name(household.id, args["name"]) if not item: item = Item() - item.name = args['name'] + item.name = args["name"] item.household = household if "icon" in args: - item.icon = args['icon'] + item.icon = args["icon"] if "category" in args and not item.category_id: - category = Category.find_by_name( - household.id, args['category']) + category = Category.find_by_name(household.id, args["category"]) if not category: - category = Category.create_by_name( - household.id, - args['category']) + category = Category.create_by_name(household.id, args["category"]) item.category = category item.save() diff --git a/backend/app/service/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py index 59c98de3..69826aba 100644 --- a/backend/app/service/importServices/import_recipe.py +++ b/backend/app/service/importServices/import_recipe.py @@ -1,53 +1,54 @@ - from app.models import Recipe, RecipeTags, RecipeItems, Item, Tag from app.service.file_has_access_or_download import file_has_access_or_download def importRecipe(household_id: int, args: dict, overwrite: bool = False): recipeNameCount = 0 - recipe = Recipe.find_by_name(household_id, args['name']) + recipe = Recipe.find_by_name(household_id, args["name"]) if recipe and not overwrite: - recipeNameCount = 1 + \ - Recipe.query.filter(Recipe.household_id == household_id, Recipe.name.ilike( - args['name'] + " (_%)")).count() + recipeNameCount = ( + 1 + + Recipe.query.filter( + Recipe.household_id == household_id, + Recipe.name.ilike(args["name"] + " (_%)"), + ).count() + ) recipe = None if not recipe: recipe = Recipe() recipe.household_id = household_id - recipe.name = args['name'] + \ - (f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "") - 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 'photo' in args: - recipe.photo = file_has_access_or_download(args['photo']) + recipe.name = args["name"] + ( + f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "" + ) + 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 "photo" in args: + recipe.photo = file_has_access_or_download(args["photo"]) recipe.save() - - if 'items' in args: - for recipeItem in args['items']: - item = Item.find_by_name(household_id, recipeItem['name']) + if "items" in args: + 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 "tags" in args: + for tagName in args["tags"]: tag = Tag.find_by_name(household_id, tagName) if not tag: tag = Tag.create_by_name(household_id, tagName) diff --git a/backend/app/service/importServices/import_shoppinglist.py b/backend/app/service/importServices/import_shoppinglist.py index 3b218b0b..561bf5d6 100644 --- a/backend/app/service/importServices/import_shoppinglist.py +++ b/backend/app/service/importServices/import_shoppinglist.py @@ -1,4 +1,3 @@ - from app.models import Household, Shoppinglist diff --git a/backend/app/service/import_language.py b/backend/app/service/import_language.py index a8735b38..06a06ee2 100644 --- a/backend/app/service/import_language.py +++ b/backend/app/service/import_language.py @@ -8,19 +8,20 @@ def importLanguage(household_id, lang, bulkSave=False): - file_path = f'{APP_DIR}/../templates/l10n/{lang}.json' + file_path = f"{APP_DIR}/../templates/l10n/{lang}.json" if lang not in SUPPORTED_LANGUAGES or not exists(file_path): - raise NotFoundRequest('Language code not supported') - with open(file_path, 'r') as f: + raise NotFoundRequest("Language code not supported") + with open(file_path, "r") as f: data = json.load(f) - with open(f'{APP_DIR}/../templates/attributes.json', 'r') as f: + with open(f"{APP_DIR}/../templates/attributes.json", "r") as f: attributes = json.load(f) t0 = time.time() models: list[Item] = [] for key, name in data["items"].items(): - item = Item.find_by_default_key( - household_id, key) or Item.find_by_name(household_id, name) + item = Item.find_by_default_key(household_id, key) or Item.find_by_name( + household_id, name + ) if not item: # needed to filter out duplicate names if bulkSave and any(i.name == name for i in models): @@ -35,21 +36,31 @@ def importLanguage(household_id, lang, bulkSave=False): item.default_key = key if item.default: - if item.name != name.strip() and not Item.find_by_name(household_id, name) and not any(i.name == name for i in models): + if ( + item.name != name.strip() + and not Item.find_by_name(household_id, name) + and not any(i.name == name for i in models) + ): item.name = name.strip() if key in attributes["items"] and "icon" in attributes["items"][key]: item.icon = attributes["items"][key]["icon"] # Category not already set for existing item and category set for template and category translation exist for language - if key in attributes["items"] and "category" in attributes["items"][key] and attributes["items"][key]["category"] in data["categories"]: + if ( + key in attributes["items"] + and "category" in attributes["items"][key] + and attributes["items"][key]["category"] in data["categories"] + ): category_key = attributes["items"][key]["category"] category_name = data["categories"][category_key] category = Category.find_by_default_key( - household_id, category_key) or Category.find_by_name(household_id, category_name) + household_id, category_key + ) or Category.find_by_name(household_id, category_name) if not category: category = Category.create_by_name( - household_id, category_name, True, category_key) + household_id, category_name, True, category_key + ) if not category.default_key: # migrate to new system category.default_key = category_key category.save() diff --git a/backend/app/service/recalculate_balances.py b/backend/app/service/recalculate_balances.py index ded089e1..06884c3a 100644 --- a/backend/app/service/recalculate_balances.py +++ b/backend/app/service/recalculate_balances.py @@ -5,13 +5,34 @@ def recalculateBalances(household_id): for member in HouseholdMember.find_by_household(household_id): - member.expense_balance = float(Expense.query.with_entities(func.sum( - Expense.amount).label("balance")).filter(Expense.paid_by_id == member.user_id, Expense.household_id == household_id).first().balance or 0) - for paid_for in ExpensePaidFor.query.filter(ExpensePaidFor.user_id == member.user_id, ExpensePaidFor.expense_id.in_(db.session.query(Expense.id).filter( - Expense.household_id == household_id).scalar_subquery())).all(): - factor_sum = Expense.query.with_entities(func.sum( - ExpensePaidFor.factor).label("factor_sum"))\ - .filter(ExpensePaidFor.expense_id == paid_for.expense_id).first().factor_sum - member.expense_balance = member.expense_balance - \ - (paid_for.factor / factor_sum) * paid_for.expense.amount + member.expense_balance = float( + Expense.query.with_entities(func.sum(Expense.amount).label("balance")) + .filter( + Expense.paid_by_id == member.user_id, + Expense.household_id == household_id, + ) + .first() + .balance + or 0 + ) + for paid_for in ExpensePaidFor.query.filter( + ExpensePaidFor.user_id == member.user_id, + ExpensePaidFor.expense_id.in_( + db.session.query(Expense.id) + .filter(Expense.household_id == household_id) + .scalar_subquery() + ), + ).all(): + factor_sum = ( + Expense.query.with_entities( + func.sum(ExpensePaidFor.factor).label("factor_sum") + ) + .filter(ExpensePaidFor.expense_id == paid_for.expense_id) + .first() + .factor_sum + ) + member.expense_balance = ( + member.expense_balance + - (paid_for.factor / factor_sum) * paid_for.expense.amount + ) member.save() diff --git a/backend/app/service/recalculate_blurhash.py b/backend/app/service/recalculate_blurhash.py index 7f7d2a62..a9a5248c 100644 --- a/backend/app/service/recalculate_blurhash.py +++ b/backend/app/service/recalculate_blurhash.py @@ -11,8 +11,7 @@ def recalculateBlurhashes(updateAll: bool = False) -> int: try: with Image.open(os.path.join(UPLOAD_FOLDER, file.filename)) as image: image.thumbnail((100, 100)) - file.blur_hash = blurhash.encode( - image, x_components=4, y_components=3) + file.blur_hash = blurhash.encode(image, x_components=4, y_components=3) db.session.add(file) except FileNotFoundError: db.session.delete(file) diff --git a/backend/app/sockets/connection_socket.py b/backend/app/sockets/connection_socket.py index 8eb903c3..6cc5e435 100644 --- a/backend/app/sockets/connection_socket.py +++ b/backend/app/sockets/connection_socket.py @@ -5,14 +5,14 @@ from app import socketio -@socketio.on('connect') +@socketio.on("connect") @socket_jwt_required() def on_connect(): for household in current_user.households: join_room(household.household_id) -@socketio.on('reconnect') +@socketio.on("reconnect") @socket_jwt_required() def on_reconnect(): pass diff --git a/backend/app/sockets/schemas.py b/backend/app/sockets/schemas.py index 2b54fd81..8fde11ec 100644 --- a/backend/app/sockets/schemas.py +++ b/backend/app/sockets/schemas.py @@ -3,11 +3,10 @@ class shoppinglist_item_add(Schema): shoppinglist_id = fields.Integer(required=True) - name = fields.String( - required=True - ) + name = fields.String(required=True) description = fields.String() + class shoppinglist_item_remove(Schema): shoppinglist_id = fields.Integer(required=True) - item_id = fields.Integer(required=True) \ No newline at end of file + item_id = fields.Integer(required=True) diff --git a/backend/app/sockets/shoppinglist_socket.py b/backend/app/sockets/shoppinglist_socket.py index c24af4ce..d949ab8f 100644 --- a/backend/app/sockets/shoppinglist_socket.py +++ b/backend/app/sockets/shoppinglist_socket.py @@ -9,22 +9,22 @@ from .schemas import shoppinglist_item_add, shoppinglist_item_remove -@socketio.on('shoppinglist_item:add') +@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']) + 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 "description" in args else "" con = ShoppinglistItems(description=description) con.created_by = current_user.id con.item = item @@ -33,24 +33,32 @@ def on_add(args): History.create_added(shoppinglist, item, description) - emit("shoppinglist_item:add", { - "item": con.obj_to_item_dict(), - "shoppinglist": shoppinglist.obj_to_dict() - }, to=shoppinglist.household_id) + emit( + "shoppinglist_item:add", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) -@socketio.on('shoppinglist_item:remove') +@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']) + 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', { - "item": con.obj_to_item_dict(), - "shoppinglist": shoppinglist.obj_to_dict() - }, to=shoppinglist.household_id) + emit( + "shoppinglist_item:remove", + { + "item": con.obj_to_item_dict(), + "shoppinglist": shoppinglist.obj_to_dict(), + }, + to=shoppinglist.household_id, + ) diff --git a/backend/app/util/description_merger.py b/backend/app/util/description_merger.py index 88acdcd3..80e14885 100644 --- a/backend/app/util/description_merger.py +++ b/backend/app/util/description_merger.py @@ -3,7 +3,7 @@ from lark.visitors import Interpreter import re -grammar = r''' +grammar = r""" start: ","* item (","+ item)* item: NUMBER? unit? @@ -19,7 +19,7 @@ %ignore WS %import common (_EXP, INT, WS) -''' +""" class TreeItem(Tree): @@ -39,9 +39,16 @@ def unitIsCount(self) -> bool: return not self.unit or self.unit.children[0].type == "COUNT" def sameUnit(self, other: Self) -> bool: - return (self.unitIsCount() and other.unitIsCount()) or (self.unit and other.unit and - (self.unit.children[0].type == other.unit.children[0].type and not other.unit.children[0].type == "DESCRIPTION" - or self.unit.children[0].lower().strip() == other.unit.children[0].lower().strip())) + return (self.unitIsCount() and other.unitIsCount()) or ( + self.unit + and other.unit + and ( + self.unit.children[0].type == other.unit.children[0].type + and not other.unit.children[0].type == "DESCRIPTION" + or self.unit.children[0].lower().strip() + == other.unit.children[0].lower().strip() + ) + ) class T(Transformer): @@ -60,7 +67,7 @@ def item(self, item: Tree): if res and child.children[0].type == "DESCRIPTION": res += " " res += self.visit(child) - elif child.type == 'NUMBER': + elif child.type == "NUMBER": value = round(child.value, 5) res += str(int(value)) if value.is_integer() else f"{value}" return res @@ -88,12 +95,16 @@ def merge(description: str, added: str) -> str: addTree = transformer.transform(parser.parse(added)) for item in addTree.children: - targetItem: TreeItem = next(desTree.find_pred(lambda t: t.data == "item" and item.sameUnit(t)), None) + targetItem: TreeItem = next( + desTree.find_pred(lambda t: t.data == "item" and item.sameUnit(t)), None + ) if not targetItem: # No item with same unit desTree.children.append(item) else: # Found item with same unit - if not targetItem.number: # Add number if not present and space behind it if description + if ( + not targetItem.number + ): # Add number if not present and space behind it if description targetItem.number = Token("NUMBER", 1) targetItem.children.insert(0, targetItem.number) @@ -104,44 +115,44 @@ def merge(description: str, added: str) -> str: elif unit and unit.children[0].type == "SI_VOLUME": merge_SI_Volume(targetItem, item) else: - targetItem.number.value = targetItem.number.value + \ - (item.number.value if item.number else 1.0) + targetItem.number.value = targetItem.number.value + ( + item.number.value if item.number else 1.0 + ) return Printer().visit(desTree) def clean(input: str) -> str: input = re.sub( - '¼|½|¾|⅐|⅑|⅒|⅓|⅔|⅕|⅖|⅗|⅘|⅙|⅚|⅛|⅜|⅝|⅞', + "¼|½|¾|⅐|⅑|⅒|⅓|⅔|⅕|⅖|⅗|⅘|⅙|⅚|⅛|⅜|⅝|⅞", lambda match: { - '¼': '0.25', - '½': '0.5', - '¾': '0.75', - '⅐': '0.142857142857', - '⅑': '0.111111111111', - '⅒': '0.1', - '⅓': '0.333333333333', - '⅔': '0.666666666667', - '⅕': '0.2', - '⅖': '0.4', - '⅗': '0.6', - '⅘': '0.8', - '⅙': '0.166666666667', - '⅚': '0.833333333333', - '⅛': '0.125', - '⅜': '0.375', - '⅝': '0.625', - '⅞': '0.875', + "¼": "0.25", + "½": "0.5", + "¾": "0.75", + "⅐": "0.142857142857", + "⅑": "0.111111111111", + "⅒": "0.1", + "⅓": "0.333333333333", + "⅔": "0.666666666667", + "⅕": "0.2", + "⅖": "0.4", + "⅗": "0.6", + "⅘": "0.8", + "⅙": "0.166666666667", + "⅚": "0.833333333333", + "⅛": "0.125", + "⅜": "0.375", + "⅝": "0.625", + "⅞": "0.875", }.get(match.group(), match.group), - input + input, ) # replace 1/2 with .5 input = re.sub( - r'(\d+((\.)\d+)?)\/(\d+((\.)\d+)?)', - lambda match: str(float(match.group(1)) / - float(match.group(4))), - input + r"(\d+((\.)\d+)?)\/(\d+((\.)\d+)?)", + lambda match: str(float(match.group(1)) / float(match.group(4))), + input, ) return input @@ -149,30 +160,32 @@ def clean(input: str) -> str: def merge_SI_Volume(base: TreeItem, add: TreeItem) -> None: def toMl(x: float, unit: str): - return {'ml': x, 'l': 1000*x}.get(unit.lower()) + return {"ml": x, "l": 1000 * x}.get(unit.lower()) - base.number.value = toMl(base.number.value, base.unit.children[0]) + \ - toMl(add.number.value if add.number else 1.0, add.unit.children[0]) - base.unit.children[0] = base.unit.children[0].update(value='ml') + base.number.value = toMl(base.number.value, base.unit.children[0]) + toMl( + add.number.value if add.number else 1.0, add.unit.children[0] + ) + base.unit.children[0] = base.unit.children[0].update(value="ml") # Simplify if possible - if (base.number.value/1000).is_integer(): - base.number.value = base.number.value/1000 - base.unit.children[0] = base.unit.children[0].update(value='L') + if (base.number.value / 1000).is_integer(): + base.number.value = base.number.value / 1000 + base.unit.children[0] = base.unit.children[0].update(value="L") def merge_SI_Weight(base: TreeItem, add: TreeItem) -> None: def toG(x: float, unit: str): - return {'mg': x/1000, 'g': x, 'kg': 1000*x}.get(unit.lower()) + return {"mg": x / 1000, "g": x, "kg": 1000 * x}.get(unit.lower()) - base.number.value = toG(base.number.value, base.unit.children[0]) + \ - toG(add.number.value if add.number else 1.0, add.unit.children[0]) - base.unit.children[0] = base.unit.children[0].update(value='g') + base.number.value = toG(base.number.value, base.unit.children[0]) + toG( + add.number.value if add.number else 1.0, add.unit.children[0] + ) + base.unit.children[0] = base.unit.children[0].update(value="g") # Simplify when possible if base.number.value < 1: - base.number.value = base.number.value*1000 - base.unit.children[0] = base.unit.children[0].update(value='mg') - elif (base.number.value/1000).is_integer(): - base.number.value = base.number.value/1000 - base.unit.children[0] = base.unit.children[0].update(value='kg') + base.number.value = base.number.value * 1000 + base.unit.children[0] = base.unit.children[0].update(value="mg") + elif (base.number.value / 1000).is_integer(): + base.number.value = base.number.value / 1000 + base.unit.children[0] = base.unit.children[0].update(value="kg") diff --git a/backend/app/util/description_splitter.py b/backend/app/util/description_splitter.py index f72342ac..fc1b6ed2 100644 --- a/backend/app/util/description_splitter.py +++ b/backend/app/util/description_splitter.py @@ -1,9 +1,9 @@ -from typing import Self, Tuple +from typing import Tuple from lark import Lark, Transformer, Tree, Token from lark.visitors import Interpreter import re -grammar = r''' +grammar = r""" start: (NUMBER unit?)? NAME? (NUMBER unit?)? unit: COUNT | SI_WEIGHT | SI_VOLUME @@ -18,7 +18,7 @@ %ignore WS %import common (_EXP, INT, WS) -''' +""" class TreeItem(Tree): @@ -52,7 +52,7 @@ def start(self, start: Tree): for child in start.children: if isinstance(child, Tree): res += self.visit(child) - elif child.type == 'NUMBER': + elif child.type == "NUMBER": value = round(child.value, 5) res += str(int(value)) if value.is_integer() else f"{value}" return res @@ -78,36 +78,35 @@ def split(query: str) -> Tuple[str, str]: def clean(input: str) -> str: input = re.sub( - '¼|½|¾|⅐|⅑|⅒|⅓|⅔|⅕|⅖|⅗|⅘|⅙|⅚|⅛|⅜|⅝|⅞', + "¼|½|¾|⅐|⅑|⅒|⅓|⅔|⅕|⅖|⅗|⅘|⅙|⅚|⅛|⅜|⅝|⅞", lambda match: { - '¼': '0.25', - '½': '0.5', - '¾': '0.75', - '⅐': '0.142857142857', - '⅑': '0.111111111111', - '⅒': '0.1', - '⅓': '0.333333333333', - '⅔': '0.666666666667', - '⅕': '0.2', - '⅖': '0.4', - '⅗': '0.6', - '⅘': '0.8', - '⅙': '0.166666666667', - '⅚': '0.833333333333', - '⅛': '0.125', - '⅜': '0.375', - '⅝': '0.625', - '⅞': '0.875', + "¼": "0.25", + "½": "0.5", + "¾": "0.75", + "⅐": "0.142857142857", + "⅑": "0.111111111111", + "⅒": "0.1", + "⅓": "0.333333333333", + "⅔": "0.666666666667", + "⅕": "0.2", + "⅖": "0.4", + "⅗": "0.6", + "⅘": "0.8", + "⅙": "0.166666666667", + "⅚": "0.833333333333", + "⅛": "0.125", + "⅜": "0.375", + "⅝": "0.625", + "⅞": "0.875", }.get(match.group(), match.group), - input + input, ) # replace 1/2 with .5 input = re.sub( - r'(\d+((\.)\d+)?)\/(\d+((\.)\d+)?)', - lambda match: str(float(match.group(1)) / - float(match.group(4))), - input + r"(\d+((\.)\d+)?)\/(\d+((\.)\d+)?)", + lambda match: str(float(match.group(1)) / float(match.group(4))), + input, ) return input diff --git a/backend/app/util/filename_validator.py b/backend/app/util/filename_validator.py index bd593dec..595c46bd 100644 --- a/backend/app/util/filename_validator.py +++ b/backend/app/util/filename_validator.py @@ -2,5 +2,7 @@ def allowed_file(filename): - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_FILE_EXTENSIONS + return ( + "." in filename + and filename.rsplit(".", 1)[1].lower() in ALLOWED_FILE_EXTENSIONS + ) diff --git a/backend/app/util/multi_dict_list.py b/backend/app/util/multi_dict_list.py index ff9331b4..4c455fd9 100644 --- a/backend/app/util/multi_dict_list.py +++ b/backend/app/util/multi_dict_list.py @@ -3,6 +3,6 @@ class MultiDictList(marshmallow.fields.List): def _deserialize(self, value, attr, data, **kwargs): - if isinstance(data, dict) and hasattr(data, 'getlist'): + if isinstance(data, dict) and hasattr(data, "getlist"): value = data.getlist(attr) return super()._deserialize(value, attr, data, **kwargs) From 63e8ab167f4c2a6d83847c6439a110b655615c9b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 3 Nov 2023 13:19:03 +0100 Subject: [PATCH 429/496] fix: remove unsupported recipe error message from logs --- backend/app/controller/recipe/recipe_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index afab8fbe..31a08136 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -197,7 +197,7 @@ def scrapeRecipe(args, household_id): try: scraper = scrape_me(args["url"], wild_mode=True) except NoSchemaFoundInWildMode: - raise InvalidUsage() + return "Unsupported website", 400 recipe = Recipe() recipe.name = scraper.title() try: From 47fbb2c36d811a07fe83a3761e894959ec259141 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 3 Nov 2023 16:48:29 +0100 Subject: [PATCH 430/496] Prepare release 83 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index a9b05c3a..8b38a2bb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,7 +24,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 82 +BACKEND_VERSION = 83 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 9e2faf603b184c21fe546003cf89cb8d544fc46d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Nov 2023 21:04:57 +0100 Subject: [PATCH 431/496] fix: Recipe scrape --- backend/app/controller/recipe/recipe_controller.py | 7 ++++--- backend/app/models/item.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 31a08136..1d87ba50 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -236,10 +236,11 @@ def scrapeRecipe(args, household_id): name = parsed.name.text if parsed.name else ingredient item = Item.find_by_name(household_id, name) if item: + description = f"{parsed.amount[0].quantity if len(parsed.amount) > 0 else ''} {parsed.amount[0].unit if len(parsed.amount) > 0 else ''}" + # description = description + (" " if description else "") + (parsed.comment.text if parsed.comment else "") # Usually cooking instructions + items[ingredient] = item.obj_to_dict() | { - "description": " ".join( - filter(None, [parsed.quantity + parsed.unit, parsed.comment]) - ), + "description": description, "optional": False, } else: diff --git a/backend/app/models/item.py b/backend/app/models/item.py index ed7460a0..3a6245d3 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -145,7 +145,7 @@ def create_by_name( def find_by_name(cls, household_id: int, name: str) -> Self: name = name.strip() return cls.query.filter( - cls.household_id == household_id, cls.name == name + cls.household_id == household_id, func.lower(cls.name) == name.lower() ).first() @classmethod From ab995377cd5c7b1858ead88a375fea394c08a91e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Nov 2023 21:09:39 +0100 Subject: [PATCH 432/496] feat: add default items --- backend/templates/attributes.json | 6 ++++++ backend/templates/l10n/de.json | 2 ++ backend/templates/l10n/en.json | 2 ++ 3 files changed, 10 insertions(+) diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index c1b00221..8718e6f3 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -97,6 +97,12 @@ "beans": { "icon": "peas" }, + "beef": { + "icon": "beef" + }, + "beef_broth": { + "icon": "mayonnaise" + }, "beer": { "category": "drinks", "icon": "beer-bottle" diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index aaba3179..14eb98a5 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -45,6 +45,8 @@ "batteries": "Batterien", "bay_leaf": "Lorbeerblatt", "beans": "Bohnen", + "beef": "Rinderfleisch", + "beef_broth": "Rinderbrühe", "beer": "Bier", "beet": "Rote Beete", "beetroot": "Rote Bete", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index f868dbba..be31806b 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -45,6 +45,8 @@ "batteries": "Batteries", "bay_leaf": "Bay leaf", "beans": "Beans", + "beef": "Beef", + "beef_broth": "Beef broth", "beer": "Beer", "beet": "Beet", "beetroot": "Beetroot", From fefad8fbe0ec49d6e447593edec5b50b4710c4ce Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Nov 2023 21:16:34 +0100 Subject: [PATCH 433/496] chore: upgrade requirements --- backend/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index f2f02e52..a24f2953 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,7 +13,7 @@ certifi==2023.7.22 cffi==1.16.0 charset-normalizer==3.3.2 click==8.1.7 -contourpy==1.1.1 +contourpy==1.2.0 cycler==0.12.1 dbscan1d==0.2.2 extruct==0.16.0 @@ -26,14 +26,14 @@ Flask-JWT-Extended==4.5.3 Flask-Migrate==4.0.5 Flask-SocketIO==5.3.6 Flask-SQLAlchemy==3.1.1 -fonttools==4.43.1 +fonttools==4.44.0 gevent==23.9.1 greenlet==3.0.0rc3 h11==0.14.0 html-text==0.5.2 html5lib==1.1 idna==3.4 -ingredient-parser-nlp==0.1.0b5 +ingredient-parser-nlp==0.1.0b6 iniconfig==2.0.0 isodate==0.6.1 itsdangerous==2.1.2 @@ -80,7 +80,7 @@ pytz-deprecation-shim==0.1.0.post0 rdflib==7.0.0 rdflib-jsonld==0.6.2 recipe-scrapers==14.52.0 -regex==2023.8.8 +regex==2023.10.3 requests==2.31.0 scikit-learn==1.3.2 scipy==1.11.3 From ced09a81f13b3463fbd69468d983c48ef713967c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sun, 5 Nov 2023 21:16:51 +0100 Subject: [PATCH 434/496] Prepare release 84 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 8b38a2bb..033497f6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,7 +24,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 83 +BACKEND_VERSION = 84 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 50d256c959ad9857222987b4a84ec9a2cc9b91d3 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 7 Nov 2023 14:17:07 +0100 Subject: [PATCH 435/496] chore: upgrade requirements --- backend/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index a24f2953..43dfdfe4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,8 +18,8 @@ cycler==0.12.1 dbscan1d==0.2.2 extruct==0.16.0 flake8==6.1.0 -Flask==2.3.3 -Flask-APScheduler==1.13.0 +Flask==3.0.0 +Flask-APScheduler==1.13.1 Flask-BasicAuth==0.2.0 Flask-Bcrypt==1.0.1 Flask-JWT-Extended==4.5.3 From 1381627b69b979d6f9fd468f8f7de2db3a60a538 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 7 Nov 2023 15:10:21 +0100 Subject: [PATCH 436/496] feat: Reset password & verify mail --- backend/app/config.py | 7 +- .../analytics/analytics_controller.py | 1 + .../app/controller/auth/auth_controller.py | 2 +- backend/app/controller/user/schemas.py | 21 +++ .../app/controller/user/user_controller.py | 79 ++++++++++- backend/app/jobs/jobs.py | 3 +- backend/app/models/__init__.py | 2 + backend/app/models/challenge_mail_verify.py | 37 ++++++ .../app/models/challenge_password_reset.py | 44 +++++++ backend/app/models/user.py | 13 +- backend/app/service/mail.py | 124 ++++++++++++++++++ backend/migrations/versions/8f12363abaaf_.py | 50 +++++++ 12 files changed, 371 insertions(+), 12 deletions(-) create mode 100644 backend/app/models/challenge_mail_verify.py create mode 100644 backend/app/models/challenge_password_reset.py create mode 100644 backend/app/service/mail.py create mode 100644 backend/migrations/versions/8f12363abaaf_.py diff --git a/backend/app/config.py b/backend/app/config.py index 033497f6..29b5994c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,6 +33,8 @@ UPLOAD_FOLDER = STORAGE_PATH + "/upload" ALLOWED_FILE_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif"} +FRONT_URL = os.getenv("FRONT_URL") + PRIVACY_POLICY_URL = os.getenv("PRIVACY_POLICY_URL") OPEN_REGISTRATION = os.getenv("OPEN_REGISTRATION", "False").lower() == "true" EMAIL_MANDATORY = os.getenv("EMAIL_MANDATORY", "False").lower() == "true" @@ -107,7 +109,7 @@ bcrypt = Bcrypt(app) jwt = JWTManager(app) socketio = SocketIO( - app, json=app.json, logger=app.logger, cors_allowed_origins=os.getenv("FRONT_URL") + app, json=app.json, logger=app.logger, cors_allowed_origins=FRONT_URL ) if COLLECT_METRICS: basic_auth = BasicAuth(app) @@ -134,8 +136,7 @@ def add_cors_headers(response): if not request.referrer: return response r = request.referrer[:-1] - url = os.getenv("FRONT_URL") - if app.debug or url and r == url: + if app.debug or FRONT_URL and r == FRONT_URL: response.headers.add("Access-Control-Allow-Origin", r) response.headers.add("Access-Control-Allow-Credentials", "true") response.headers.add("Access-Control-Allow-Headers", "Content-Type") diff --git a/backend/app/controller/analytics/analytics_controller.py b/backend/app/controller/analytics/analytics_controller.py index ce5003c8..97a41f54 100644 --- a/backend/app/controller/analytics/analytics_controller.py +++ b/backend/app/controller/analytics/analytics_controller.py @@ -18,6 +18,7 @@ def getBaseAnalytics(): return jsonify( { "total_users": User.count(), + "verified_users": User.query.filter(User.email_verified == True).count(), "active_users": db.session.query(Token.user_id) .filter(Token.type == "refresh") .group_by(Token.user_id) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index fff568b7..f2b81664 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -131,7 +131,7 @@ def logout(): ) if token.type == "access": - token.refresh_token.delete() + token.refresh_token.delete_token_familiy() else: token.delete() diff --git a/backend/app/controller/user/schemas.py b/backend/app/controller/user/schemas.py index 99dfa494..ab449a71 100644 --- a/backend/app/controller/user/schemas.py +++ b/backend/app/controller/user/schemas.py @@ -44,3 +44,24 @@ class UpdateUser(Schema): class SearchByNameRequest(Schema): query = fields.String(required=True, validate=lambda a: a and not a.isspace()) + + +class ConfirmMail(Schema): + token = fields.String(required=True, validate=lambda a: a and not a.isspace()) + + +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 ForgotPassword(Schema): + email = fields.String( + required=True, + validate=lambda a: a and not a.isspace() and "@" in a, + load_only=True, + ) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 0d004636..713f1b81 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -3,9 +3,17 @@ from app.helpers import validate_args from flask import jsonify, Blueprint from flask_jwt_extended import current_user, jwt_required -from app.models import User +from app.models import User, ChallengeMailVerify, ChallengePasswordReset +from app.service import mail from app.service.file_has_access_or_download import file_has_access_or_download -from .schemas import CreateUser, UpdateUser, SearchByNameRequest +from .schemas import ( + CreateUser, + ResetPassword, + UpdateUser, + SearchByNameRequest, + ConfirmMail, + ForgotPassword, +) user = Blueprint("user", __name__) @@ -65,8 +73,12 @@ def updateUser(args): user.name = args["name"].strip() if "password" in args: user.set_password(args["password"]) - if "email" in args: + if "email" in args and args["email"].strip() != user.email: user.email = args["email"].strip() + user.email_verified = False + ChallengeMailVerify.delete_by_user(user) + if mail.mailConfigured(): + mail.sendVerificationMail(user, ChallengeMailVerify.create_challenge(user)) if "photo" in args and user.photo != args["photo"]: user.photo = file_has_access_or_download(args["photo"], user.photo) user.save() @@ -85,8 +97,10 @@ def updateUserById(args, id): user.name = args["name"].strip() if "password" in args: user.set_password(args["password"]) - if "email" in args: + if "email" in args and args["email"].strip() != user.email: 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: @@ -114,3 +128,60 @@ def createUser(args): @validate_args(SearchByNameRequest) def searchUser(args): return jsonify([e.obj_to_dict() for e in User.search_name(args["query"])]) + + +@user.route("/resend-verification-mail", methods=["POST"]) +@jwt_required() +def resendVerificationMail(): + user: User = current_user + if not user: + raise NotFoundRequest() + + if not mail.mailConfigured(): + raise Exception("Mail service not configured") + + if not user.email_verified: + mail.sendVerificationMail(user, ChallengeMailVerify.create_challenge(user)) + return jsonify({"msg": "DONE"}) + + +@user.route("/confirm-mail", methods=["POST"]) +@validate_args(ConfirmMail) +def confirmMail(args): + challenge = ChallengeMailVerify.find_by_challenge(args["token"]) + if not challenge: + raise NotFoundRequest() + user: User = challenge.user + user.email_verified = True + user.save() + ChallengeMailVerify.delete_by_user(user) + + return jsonify({"msg": "DONE"}) + + +@user.route("/reset-password", methods=["POST"]) +@validate_args(ResetPassword) +def resetPassword(args): + challenge = ChallengePasswordReset.find_by_challenge(args["token"]) + if not challenge: + raise NotFoundRequest() + user: User = challenge.user + user.set_password(args["password"]) + user.save() + ChallengePasswordReset.delete_by_user(user) + return jsonify({"msg": "DONE"}) + + +@user.route("/forgot-password", methods=["POST"]) +@validate_args(ForgotPassword) +def forgotPassword(args): + if not mail.mailConfigured(): + raise Exception("Mail service not configured") + + user = User.find_by_email(args["email"]) + if not user: + return jsonify({"msg": "DONE"}) + + # if user.email_verified: + mail.sendPasswordResetMail(user, ChallengePasswordReset.create_challenge(user)) + return jsonify({"msg": "DONE"}) diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index 7b9a0155..2b9ab91c 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -1,6 +1,6 @@ from app.jobs.recipe_suggestions import computeRecipeSuggestions from app import app, scheduler -from app.models import Token, Household, Shoppinglist, Recipe +from app.models import Token, Household, Shoppinglist, Recipe, ChallengePasswordReset from .item_ordering import findItemOrdering from .item_suggestions import findItemSuggestions from .cluster_shoppings import clusterShoppings @@ -37,3 +37,4 @@ def halfHourly(): # Remove expired Tokens Token.delete_expired_access() Token.delete_expired_refresh() + ChallengePasswordReset.delete_expired() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 33a2c001..ca8d4cb9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -14,3 +14,5 @@ from .token import Token from .household import Household, HouseholdMember from .file import File +from .challenge_mail_verify import ChallengeMailVerify +from .challenge_password_reset import ChallengePasswordReset diff --git a/backend/app/models/challenge_mail_verify.py b/backend/app/models/challenge_mail_verify.py new file mode 100644 index 00000000..c981f35c --- /dev/null +++ b/backend/app/models/challenge_mail_verify.py @@ -0,0 +1,37 @@ +from __future__ import annotations +import hashlib +from typing import Self +import uuid +from app import db +from app.config import bcrypt +from app.helpers import DbModelMixin, TimestampMixin +from app.models.user import User + + +class ChallengeMailVerify(db.Model, DbModelMixin, TimestampMixin): + challenge_hash = db.Column(db.String(256), primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey("user.id"), nullable=False, index=True + ) + + user = db.relationship("User") + + @classmethod + def find_by_challenge(cls, challenge: str) -> Self: + return cls.query.filter( + cls.challenge_hash == hashlib.sha256(bytes(challenge, "utf-8")).hexdigest() + ).first() + + @classmethod + def create_challenge(cls, user: User) -> str: + challenge = uuid.uuid4().hex + cls( + challenge_hash=hashlib.sha256(bytes(challenge, "utf-8")).hexdigest(), + user_id=user.id, + ).save() + return challenge + + @classmethod + def delete_by_user(cls, user: User): + cls.query.filter(cls.user_id == user.id).delete() + db.session.commit() diff --git a/backend/app/models/challenge_password_reset.py b/backend/app/models/challenge_password_reset.py new file mode 100644 index 00000000..d30bc3ec --- /dev/null +++ b/backend/app/models/challenge_password_reset.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from datetime import datetime, timedelta +import hashlib +from typing import Self +import uuid +from app import db +from app.config import bcrypt +from app.helpers import DbModelMixin, TimestampMixin +from app.models.user import User + + +class ChallengePasswordReset(db.Model, DbModelMixin, TimestampMixin): + challenge_hash = db.Column(db.String(256), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + + user = db.relationship("User") + + @classmethod + def find_by_challenge(cls, challenge: str) -> Self: + filter_before = datetime.utcnow() - timedelta(hours=3) + return cls.query.filter( + cls.challenge_hash == hashlib.sha256(bytes(challenge, "utf-8")).hexdigest(), + cls.created_at >= filter_before, + ).first() + + @classmethod + def create_challenge(cls, user: User) -> str: + challenge = uuid.uuid4().hex + cls( + challenge_hash=hashlib.sha256(bytes(challenge, "utf-8")).hexdigest(), + user_id=user.id, + ).save() + return challenge + + @classmethod + def delete_by_user(cls, user: User): + cls.query.filter(cls.user_id == user.id).delete() + db.session.commit() + + @classmethod + def delete_expired(cls): + filter_before = datetime.utcnow() - timedelta(hours=3) + db.session.query(cls).filter(cls.created_at <= filter_before).delete() + db.session.commit() diff --git a/backend/app/models/user.py b/backend/app/models/user.py index e15e5d76..cc3739c9 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -16,11 +16,19 @@ class User(db.Model, DbModelMixin, TimestampMixin): password = db.Column(db.String(256), nullable=False) photo = db.Column(db.String(), db.ForeignKey("file.filename", use_alter=True)) admin = db.Column(db.Boolean(), default=False) + email_verified = db.Column(db.Boolean(), default=False) tokens = db.relationship( "Token", back_populates="user", cascade="all, delete-orphan" ) + password_reset_challenge = db.relationship( + "ChallengePasswordReset", back_populates="user", cascade="all, delete-orphan" + ) + verify_mail_challenge = db.relationship( + "ChallengeMailVerify", back_populates="user", cascade="all, delete-orphan" + ) + households = db.relationship( "HouseholdMember", back_populates="user", cascade="all, delete-orphan" ) @@ -52,7 +60,7 @@ def obj_to_dict( else: skip_columns = ["password"] if not include_email: - skip_columns += ["email"] + skip_columns += ["email", "email_verified"] if not current_user or not current_user.admin: # Filter out admin status if current user is not an admin @@ -65,9 +73,8 @@ def obj_to_dict( def obj_to_full_dict(self) -> dict: from .token import Token - res = self.obj_to_dict() + res = self.obj_to_dict(include_email=True) res["admin"] = self.admin - res["email"] = self.email tokens = Token.query.filter( Token.user_id == self.id, Token.type != "access", diff --git a/backend/app/service/mail.py b/backend/app/service/mail.py new file mode 100644 index 00000000..7fbd7949 --- /dev/null +++ b/backend/app/service/mail.py @@ -0,0 +1,124 @@ +import smtplib, ssl, os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from app.config import FRONT_URL +from app.models import User + +SMTP_HOST = os.getenv("SMTP_HOST") +SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) +SMTP_USER = os.getenv("SMTP_USER") +SMTP_PASS = os.getenv("SMTP_PASS") +SMTP_FROM = os.getenv("SMTP_FROM") +SMTP_REPLY_TO = os.getenv("SMTP_REPLY_TO") + +context = ssl.create_default_context() + +mail_configured: bool = None + + +def mailConfigured(): + global mail_configured + if mail_configured != None: + return mail_configured + if ( + not SMTP_HOST + or not SMTP_PORT + or not SMTP_USER + or not SMTP_PASS + or not SMTP_FROM + ): + mail_configured = False + return mail_configured + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) as server: + server.login(SMTP_USER, SMTP_PASS) + mail_configured = True + except Exception: + mail_configured = False + return mail_configured + + +def sendMail(to: str, message: MIMEMultipart): + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) as server: + server.login(SMTP_USER, SMTP_PASS) + message["From"] = SMTP_FROM + message["To"] = to + if SMTP_REPLY_TO: + message["Reply-To"] = SMTP_REPLY_TO + server.sendmail(SMTP_FROM, to, message.as_string()) + + +def sendVerificationMail(user: User, token: str): + if not user.email or not token: + return + + verifyLink = FRONT_URL + "/#/confirm-email?t=" + token + + message = MIMEMultipart("alternative") + message["Subject"] = "Verify Email" + text = """\ +Hi {name} (@{username}), + +Verify your email so we know it's really you and you don't loose access to your account. +Verify email address: {link} + +Have any questions? Check out https://kitchenowl.org/privacy/""".format( + name=user.name, username=user.username, link=verifyLink + ) + html = """\ + + +

Hi {name} (@{username}),
+ Verify your email so we know it's really you and you don't loose access to your account.
+ Verify email address
+ Have any questions? Check out our Privacy Policy +

+ + + """.format( + name=user.name, username=user.username, link=verifyLink + ) + # The email client will try to render the last part first + message.attach(MIMEText(text, "plain")) + message.attach(MIMEText(html, "html")) + sendMail(user.email, message) + + +def sendPasswordResetMail(user: User, token: str): + if not user.email or not token: + return + + resetLink = FRONT_URL + "/#/reset-password?t=" + token + + message = MIMEMultipart("alternative") + message["Subject"] = "Reset password" + text = """\ +Hi {name} (@{username}), + +We received a request to change your password. This link is valid for three hours. +Reset password: {link} + +If you didn't request a password reset, you can ignore this message and continue to use your current password. + +Have any questions? Check out https://kitchenowl.org/privacy/""".format( + name=user.name, username=user.username, link=resetLink + ) + html = """\ + + +

Hi {name} (@{username}),
+ We received a request to change your password. This link is valid for three hours.
+ Reset password
+ If you didn't request a password reset, you can ignore this message and continue to use your current password.
+ + Have any questions? Check out our Privacy Policy +

+ + + """.format( + name=user.name, username=user.username, link=resetLink + ) + # The email client will try to render the last part first + message.attach(MIMEText(text, "plain")) + message.attach(MIMEText(html, "html")) + sendMail(user.email, message) diff --git a/backend/migrations/versions/8f12363abaaf_.py b/backend/migrations/versions/8f12363abaaf_.py new file mode 100644 index 00000000..1fd98ae6 --- /dev/null +++ b/backend/migrations/versions/8f12363abaaf_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: 8f12363abaaf +Revises: c63508852dd1 +Create Date: 2023-11-06 14:46:20.697901 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8f12363abaaf' +down_revision = 'c63508852dd1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('challenge_mail_verify', + sa.Column('challenge_hash', sa.String(length=256), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_challenge_mail_verify_user_id_user')), + sa.PrimaryKeyConstraint('challenge_hash', name=op.f('pk_challenge_mail_verify')) + ) + op.create_table('challenge_password_reset', + sa.Column('challenge_hash', sa.String(length=256), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_challenge_password_reset_user_id_user')), + sa.PrimaryKeyConstraint('challenge_hash', name=op.f('pk_challenge_password_reset')) + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('email_verified', sa.Boolean(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('email_verified') + + op.drop_table('challenge_password_reset') + op.drop_table('challenge_mail_verify') + # ### end Alembic commands ### From 2931fd1a9577626a1fa7765613b90265bc8840bc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 7 Nov 2023 15:18:34 +0100 Subject: [PATCH 437/496] feat: add manage default items script --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 14f287d0..78a2037c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -33,7 +33,7 @@ COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" # Setup KitchenOwl -COPY wsgi.ini wsgi.py entrypoint.sh manage.py upgrade_default_items.py /usr/src/kitchenowl/ +COPY wsgi.ini wsgi.py entrypoint.sh manage.py manage_default_items.py upgrade_default_items.py /usr/src/kitchenowl/ COPY app /usr/src/kitchenowl/app COPY templates /usr/src/kitchenowl/templates COPY migrations /usr/src/kitchenowl/migrations From 522afc5e0aa61b089512d4205058c5b95c4f61a2 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 7 Nov 2023 15:25:56 +0100 Subject: [PATCH 438/496] feat: add default items --- backend/templates/attributes.json | 15 ++++++++++++--- backend/templates/l10n/de.json | 1 + backend/templates/l10n/en.json | 1 + 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index 8718e6f3..125f2b06 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -161,7 +161,9 @@ "butter_cookies": { "icon": "cookies" }, - "butternut_squash": {}, + "butternut_squash": { + "icon": "pumpkin" + }, "button_cells": {}, "börek_cheese": { "category": "dairy", @@ -497,7 +499,9 @@ "icon": "lettuce" }, "herb_baguettes": {}, - "herb_butter": {}, + "herb_butter": { + "icon": "butter" + }, "herb_cream_cheese": { "category": "dairy" }, @@ -539,6 +543,10 @@ "kitchen_towels": { "category": "hygiene" }, + "kiwi": { + "category": "fruits_vegetables", + "icon": "kiwi" + }, "kohlrabi": { "category": "fruits_vegetables", "icon": "kohlrabi" @@ -599,7 +607,8 @@ "maggi": {}, "magnesium": {}, "mango": { - "category": "fruits_vegetables" + "category": "fruits_vegetables", + "icon": "plum" }, "maple_syrup": {}, "margarine": { diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index 14eb98a5..50d6c4ff 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -221,6 +221,7 @@ "kidney_beans": "Kidneybohnen", "kitchen_roll": "Küchenrolle", "kitchen_towels": "Küchenhandtücher", + "kiwi": "Kiwi", "kohlrabi": "Kohlrabi", "lasagna": "Lasagne", "lasagna_noodles": "Lasagnenudeln", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index be31806b..d2aa09fc 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -221,6 +221,7 @@ "kidney_beans": "Kidney beans", "kitchen_roll": "Kitchen roll", "kitchen_towels": "Kitchen towels", + "kiwi": "Kiwi", "kohlrabi": "Kohlrabi", "lasagna": "Lasagna", "lasagna_noodles": "Lasagna noodles", From 1afc0052e91e38b172952e27eb33f6c6bd3352db Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 9 Nov 2023 16:29:34 +0100 Subject: [PATCH 439/496] fix: smaller bugs --- backend/app/models/challenge_mail_verify.py | 4 +--- backend/migrations/versions/6c669d9ec3bd_.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/app/models/challenge_mail_verify.py b/backend/app/models/challenge_mail_verify.py index c981f35c..25702bfd 100644 --- a/backend/app/models/challenge_mail_verify.py +++ b/backend/app/models/challenge_mail_verify.py @@ -10,9 +10,7 @@ class ChallengeMailVerify(db.Model, DbModelMixin, TimestampMixin): challenge_hash = db.Column(db.String(256), primary_key=True) - user_id = db.Column( - db.Integer, db.ForeignKey("user.id"), nullable=False, index=True - ) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) user = db.relationship("User") diff --git a/backend/migrations/versions/6c669d9ec3bd_.py b/backend/migrations/versions/6c669d9ec3bd_.py index 23ebd9ba..b3d09a9d 100644 --- a/backend/migrations/versions/6c669d9ec3bd_.py +++ b/backend/migrations/versions/6c669d9ec3bd_.py @@ -201,7 +201,7 @@ def upgrade(): household.created_at = datetime.utcnow() household.updated_at = datetime.utcnow() - users = session.query(User) + users = session.query(User).all() for user in users: hm = HouseholdMember() hm.created_at = datetime.utcnow() From 75684c6d7d31213b2267c205c88603c266f4505e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 9 Nov 2023 16:58:41 +0100 Subject: [PATCH 440/496] feat: expense exclude from statistics --- .../controller/expense/expense_controller.py | 9 ++- backend/app/controller/expense/schemas.py | 2 + backend/app/models/expense.py | 1 + backend/migrations/versions/ee2ba4d37d8b_.py | 56 +++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/ee2ba4d37d8b_.py diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index b226b416..731b4005 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -94,6 +94,8 @@ def addExpense(args, household_id): 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"] expense.paid_by_id = member.user_id expense.save() member.expense_balance = (member.expense_balance or 0) + expense.amount @@ -141,6 +143,8 @@ def updateExpense(args, id): # noqa: C901 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"] @@ -220,7 +224,10 @@ def getExpenseOverview(args, household_id): factor = 1 query = ( - Expense.query.filter(Expense.household_id == household_id) + Expense.query.filter( + Expense.household_id == household_id, + Expense.exclude_from_statistics == False, + ) .group_by(Expense.category_id, ExpenseCategory.id) .join(Expense.category, isouter=True) ) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index 56cae5ad..ece07bdd 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -31,6 +31,7 @@ class User(Schema): paid_for = fields.List( fields.Nested(User()), required=True, validate=lambda a: len(a) > 0 ) + exclude_from_statistics = fields.Boolean() class UpdateExpense(Schema): @@ -46,6 +47,7 @@ class User(Schema): 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): diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index f4cffe62..9132f7f4 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -17,6 +17,7 @@ class Expense(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin): household_id = db.Column( db.Integer, db.ForeignKey("household.id"), nullable=False, index=True ) + exclude_from_statistics = db.Column(db.Boolean, default=False, nullable=False) household = db.relationship("Household", uselist=False) category = db.relationship("ExpenseCategory") diff --git a/backend/migrations/versions/ee2ba4d37d8b_.py b/backend/migrations/versions/ee2ba4d37d8b_.py new file mode 100644 index 00000000..9fec0152 --- /dev/null +++ b/backend/migrations/versions/ee2ba4d37d8b_.py @@ -0,0 +1,56 @@ +"""empty message + +Revision ID: ee2ba4d37d8b +Revises: 8f12363abaaf +Create Date: 2023-11-09 16:20:23.973472 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm + +DeclarativeBase = orm.declarative_base() + +# revision identifiers, used by Alembic. +revision = 'ee2ba4d37d8b' +down_revision = '8f12363abaaf' +branch_labels = None +depends_on = None + + +class Expense(DeclarativeBase): + __tablename__ = 'expense' + id = sa.Column(sa.Integer, primary_key=True) + exclude_from_statistics = sa.Column(sa.Boolean, default=False, nullable=True) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + bind = op.get_bind() + session = orm.Session(bind=bind) + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.add_column(sa.Column('exclude_from_statistics', sa.Boolean(), nullable=True)) + + expenses = session.query(Expense).all() + for expense in expenses: + expense.exclude_from_statistics = False + try: + session.bulk_save_objects(expenses) + session.commit() + except Exception as e: + session.rollback() + raise e + + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.alter_column('exclude_from_statistics', nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('expense', schema=None) as batch_op: + batch_op.drop_column('exclude_from_statistics') + + # ### end Alembic commands ### From 5591091f3b5a295d8034351f19087e1c0d0000e1 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 13 Nov 2023 10:50:15 +0100 Subject: [PATCH 441/496] Translated using Weblate (Spanish) (TomBursch/kitchenowl-backend#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (Italian) Currently translated at 99.3% (492 of 495 strings) Translated using Weblate (Danish) Currently translated at 98.9% (490 of 495 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (494 of 494 strings) Translated using Weblate (Finnish) Currently translated at 100.0% (492 of 492 strings) Translated using Weblate (Finnish) Currently translated at 96.9% (477 of 492 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (492 of 492 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (477 of 477 strings) Translated using Weblate (English) Currently translated at 100.0% (477 of 477 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/da/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/en/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fi/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/it/ Translation: KitchenOwl/Default Items Co-authored-by: Petri Hämäläinen Co-authored-by: Tom Bursch Co-authored-by: gallegonovato --- backend/templates/l10n/da.json | 18 ++++++++++++++++-- backend/templates/l10n/en.json | 4 ++-- backend/templates/l10n/es.json | 23 +++++++++++++++++++++-- backend/templates/l10n/fi.json | 20 ++++++++++++++++++-- backend/templates/l10n/it.json | 20 ++++++++++++++++++-- 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/backend/templates/l10n/da.json b/backend/templates/l10n/da.json index aa7f522c..05d3198b 100644 --- a/backend/templates/l10n/da.json +++ b/backend/templates/l10n/da.json @@ -12,6 +12,7 @@ "snacks": "🥜 Snacks" }, "items": { + "agave_syrup": "Agave Sirup", "aioli": "Aioli", "amaretto": "Amaretto", "apple": "Æble", @@ -102,6 +103,7 @@ "coconut_flakes": "Kokosnøddeflager", "coconut_milk": "Kokosmælk", "coconut_oil": "Kokosolie", + "coffee_powder": "Kaffe Pulver", "colorful_sprinkles": "Farverige drys", "concealer": "Concealer", "cookies": "Cookies", @@ -111,6 +113,7 @@ "cornstarch": "Majsstivelse", "cornys": "Cornys", "corriander": "Koriander", + "cotton_rounds": "Vatkugler", "cough_drops": "Hostedråber", "couscous": "Couscous", "covid_rapid_test": "COVID-sneltest", @@ -139,6 +142,7 @@ "dishwasher_tabs": "Tabs til opvaskemaskine", "disinfection_spray": "Desinfektionsspray", "dried_tomatoes": "Tørrede tomater", + "dry_yeast": "Tør gær", "edamame": "Edamame", "egg_salad": "Æggesalat", "egg_yolk": "Æggeblomme", @@ -156,6 +160,7 @@ "flushing": "Skylning", "fresh_chili_pepper": "Frisk chilipeber", "frozen_berries": "Frosne bær", + "frozen_broccoli": "Frossen broccoli", "frozen_fruit": "Frossen frugt", "frozen_pizza": "Frossen pizza", "frozen_spinach": "Frossen spinat", @@ -167,6 +172,7 @@ "garlic_granules": "Hvidløg i granulatform", "gherkins": "Agurker", "ginger": "Ingefær", + "ginger_ale": "Ginger ale", "glass_noodles": "Glasnudler", "gluten": "Gluten", "gnocchi": "Gnocchi", @@ -183,6 +189,8 @@ "hair_gel": "Hårgel", "hair_ties": "Hårbøjler", "hair_wax": "Hårvoks", + "ham": "Skinke", + "ham_cubes": "Skinke terninger", "hand_soap": "Håndsæbe", "handkerchief_box": "Lommetørklæde boks", "handkerchiefs": "Lommetørklæder", @@ -192,6 +200,7 @@ "hazelnuts": "Hasselnødder", "head_of_lettuce": "Hoved af salat", "herb_baguettes": "Baguettes med urter", + "herb_butter": "Krydder smør", "herb_cream_cheese": "Krydderurter flødeost", "honey": "Honning", "honey_wafers": "Honningvafler", @@ -227,6 +236,7 @@ "lime": "Lime", "linguine": "Linguine", "lip_care": "Læbepleje", + "liqueur": "Likør", "low-fat_curd_cheese": "Ostemasse med lavt fedtindhold", "maggi": "Maggi", "magnesium": "Magnesium", @@ -257,10 +267,11 @@ "mushrooms": "Svampe", "mustard": "Sennep", "nail_file": "Neglefil", + "nail_polish_remover": "Neglefjerner", "neutral_oil": "Neutral olie", "nori_sheets": "Nori-ark", "nutmeg": "Muskatnød", - "oat_milk": "Havredrik", + "oat_milk": "Havremælk", "oatmeal": "Havregryn", "oatmeal_cookies": "Havregrynskager", "oatsome": "Oatsome", @@ -277,6 +288,7 @@ "organic_waste_bags": "Poser til organisk affald", "pak_choi": "Pak Choi", "pantyhose": "Strømpebukser", + "papaya": "Papaya", "paprika": "Paprika", "paprika_seasoning": "Paprika-krydderi", "pardina_lentils_dried": "Pardina-linser, tørrede", @@ -377,6 +389,7 @@ "shower_gel": "Brusegel", "shredded_cheese": "Revet ost", "sieved_tomatoes": "Tomater, sigtet", + "skyr": "Skyr", "sliced_cheese": "Skiveskåret ost", "smoked_paprika": "Røget paprika", "smoked_tofu": "Røget tofu", @@ -388,7 +401,7 @@ "sour_cream": "Creme fraiche", "sour_cucumbers": "Sure agurker", "soy_cream": "Sojafløde", - "soy_hack": "Soja hack", + "soy_hack": "Soya mince", "soy_sauce": "Sojasovs", "soy_shred": "Soja strimler", "spaetzle": "Spätzle", @@ -453,6 +466,7 @@ "vinegar": "Eddike", "vitamin_tablets": "Vitamintabletter", "vodka": "Vodka", + "walnuts": "Valnødder", "washing_gel": "Vaskegel", "washing_powder": "Vaskepulver", "water": "Vand", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index d2aa09fc..9c91f74d 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -406,7 +406,7 @@ "sour_cream": "Sour cream", "sour_cucumbers": "Sour cucumbers", "soy_cream": "Soy cream", - "soy_hack": "Soy hack", + "soy_hack": "Soy mince", "soy_sauce": "Soy sauce", "soy_shred": "Soy shred", "spaetzle": "Spaetzle", @@ -498,4 +498,4 @@ "zinc_cream": "Zinc cream", "zucchini": "Zucchini" } -} \ No newline at end of file +} diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index 4273a6ff..b1696848 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -12,6 +12,7 @@ "snacks": "🥜 Aperitivos" }, "items": { + "agave_syrup": "Aguamiel", "aioli": "Alioli", "amaretto": "Amaretto", "apple": "Manzana", @@ -44,11 +45,14 @@ "batteries": "Pilas", "bay_leaf": "Hoja de laurel", "beans": "Judías", + "beef": "Ternera", + "beef_broth": "Caldo de carne", "beer": "Cerveza", "beet": "Remolacha", "beetroot": "Remolacha", "birthday_card": "Tarjeta de cumpleaños", "black_beans": "Frijol negro", + "blister_plaster": "Protector de ampollas", "bockwurst": "Bockwurst", "bodywash": "Gel de baño", "bread": "Pan", @@ -64,6 +68,7 @@ "burger_sauces": "Salsas para hamburguesas", "butter": "Mantequilla", "butter_cookies": "Galletas de mantequilla", + "butternut_squash": "Calabaza moscada", "button_cells": "Pilas de botón", "börek_cheese": "Queso Börek", "cake": "Pastel", @@ -102,6 +107,7 @@ "coconut_flakes": "Copos de coco", "coconut_milk": "Leche de coco", "coconut_oil": "Aceite de coco", + "coffee_powder": "Café en polvo", "colorful_sprinkles": "Virutas de colores", "concealer": "Corrector", "cookies": "Cookies", @@ -111,6 +117,7 @@ "cornstarch": "Maicena", "cornys": "Cornys", "corriander": "Cilantro", + "cotton_rounds": "Círculos de algodón", "cough_drops": "Pastillas para la tos", "couscous": "Cuscús", "covid_rapid_test": "Test rápido del COVID", @@ -139,6 +146,7 @@ "dishwasher_tabs": "Pastillas para el lavavajillas", "disinfection_spray": "Desinfectante en spray", "dried_tomatoes": "Tomates secos", + "dry_yeast": "Levadura seca", "edamame": "Vainas de soja tiernas (Edamame)", "egg_salad": "Ensalada de huevo", "egg_yolk": "Yema de huevo", @@ -156,6 +164,7 @@ "flushing": "Enjuague", "fresh_chili_pepper": "Guindilla fresca", "frozen_berries": "Bayas congeladas", + "frozen_broccoli": "Brócoli congelado", "frozen_fruit": "Fruta congelada", "frozen_pizza": "Pizza congelada", "frozen_spinach": "Espinacas congeladas", @@ -167,6 +176,7 @@ "garlic_granules": "Ajo granulado", "gherkins": "Pepinillos", "ginger": "Jengibre", + "ginger_ale": "Ginger ale", "glass_noodles": "Fideos de vidrio", "gluten": "Gluten", "gnocchi": "Ñoqui", @@ -183,6 +193,8 @@ "hair_gel": "Gomina", "hair_ties": "Lazos para el pelo", "hair_wax": "Cera para el pelo", + "ham": "Jamón", + "ham_cubes": "Taquitos de jamón", "hand_soap": "Jabón de manos", "handkerchief_box": "Caja de pañuelos", "handkerchiefs": "Pañuelos", @@ -192,6 +204,7 @@ "hazelnuts": "Avellanas", "head_of_lettuce": "Cogollo de lechuga", "herb_baguettes": "Baguettes de hierbas", + "herb_butter": "Mantequilla de hierbas", "herb_cream_cheese": "Crema de queso a las hierbas", "honey": "Miel", "honey_wafers": "Barquillos de miel", @@ -208,6 +221,7 @@ "kidney_beans": "Judías rojas (Frijoles)", "kitchen_roll": "Papel de cocina", "kitchen_towels": "Paños de cocina", + "kiwi": "Kiwi", "kohlrabi": "Colinabo", "lasagna": "Lasaña", "lasagna_noodles": "Fideos para lasaña", @@ -227,6 +241,7 @@ "lime": "Lima", "linguine": "Linguine", "lip_care": "Cuidado de los labios", + "liqueur": "Licor", "low-fat_curd_cheese": "Requesón bajo en grasa", "maggi": "Maggi", "magnesium": "Magnesio", @@ -257,10 +272,11 @@ "mushrooms": "Setas", "mustard": "Mostaza", "nail_file": "Lima de uñas", + "nail_polish_remover": "Quitaesmalte", "neutral_oil": "Aceite neutro", "nori_sheets": "Hojas de nori", "nutmeg": "Nuez moscada", - "oat_milk": "Bebida de avena", + "oat_milk": "Leche de avena", "oatmeal": "Harina de avena", "oatmeal_cookies": "Galletas de avena", "oatsome": "Avena", @@ -277,6 +293,7 @@ "organic_waste_bags": "Bolsas para residuos orgánicos", "pak_choi": "Col china o repollo chino", "pantyhose": "Pantimedias", + "papaya": "Papaya", "paprika": "Pimentón", "paprika_seasoning": "Condimento de pimentón", "pardina_lentils_dried": "Lentejas pardinas secas", @@ -377,6 +394,7 @@ "shower_gel": "Gel de ducha", "shredded_cheese": "Queso rallado", "sieved_tomatoes": "Tomates tamizados", + "skyr": "Skyr", "sliced_cheese": "Queso en lonchas", "smoked_paprika": "Pimentón ahumado", "smoked_tofu": "Tofu ahumado", @@ -388,7 +406,7 @@ "sour_cream": "Crema agria", "sour_cucumbers": "Pepinos agrios", "soy_cream": "Crema de soja", - "soy_hack": "Hack de soja", + "soy_hack": "Carne picada de soja", "soy_sauce": "Salsa de soja", "soy_shred": "Triturado de soja", "spaetzle": "Späeztle", @@ -453,6 +471,7 @@ "vinegar": "Vinagre", "vitamin_tablets": "Comprimidos vitamínicos", "vodka": "Vodka", + "walnuts": "Nuez", "washing_gel": "Gel de lavado", "washing_powder": "Detergente en polvo", "water": "Agua", diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json index 4752ac70..774cde4d 100644 --- a/backend/templates/l10n/fi.json +++ b/backend/templates/l10n/fi.json @@ -12,6 +12,7 @@ "snacks": "🥜 Herkut" }, "items": { + "agave_syrup": "Agavesiirappi", "aioli": "Aioli", "amaretto": "Amaretto", "apple": "Omena", @@ -49,6 +50,7 @@ "beetroot": "Punajuuri", "birthday_card": "Syntymäpäiväkortti", "black_beans": "Mustapavut", + "blister_plaster": "Rakkolaastari", "bockwurst": "Bockwurst", "bodywash": "Vartalosaippua", "bread": "Leipä", @@ -64,6 +66,7 @@ "burger_sauces": "Hampurilaiskastikkeet", "butter": "Voi", "butter_cookies": "Voikeksit", + "butternut_squash": "Pähkinäkurpitsa", "button_cells": "Nappiparistot", "börek_cheese": "Börek-juusto", "cake": "Kakku", @@ -102,6 +105,7 @@ "coconut_flakes": "Kookoshiutaleet", "coconut_milk": "Kookosmaito", "coconut_oil": "Kookosöljy", + "coffee_powder": "Jauhettu kahvi", "colorful_sprinkles": "Koristerakeet", "concealer": "Peitevoide", "cookies": "Keksit", @@ -111,6 +115,7 @@ "cornstarch": "Maissitärkkelys", "cornys": "Cornys", "corriander": "Korianteri", + "cotton_rounds": "Pyöreät vanulaput", "cough_drops": "Yskänpastillit", "couscous": "Kuskus", "covid_rapid_test": "COVID-pikatesti", @@ -139,6 +144,7 @@ "dishwasher_tabs": "Astianpesutabletit", "disinfection_spray": "Desinfektiosuihke", "dried_tomatoes": "Kuivatut tomaatit", + "dry_yeast": "Kuivahiiva", "edamame": "Edamame-pavut", "egg_salad": "Munasalaatti", "egg_yolk": "Munankeltuainen", @@ -156,6 +162,7 @@ "flushing": "Huuhtelu", "fresh_chili_pepper": "Tuore chilipippuri", "frozen_berries": "Pakastemarjat", + "frozen_broccoli": "Pakasteparsakaali", "frozen_fruit": "Pakastehedelmät", "frozen_pizza": "Pakastepizza", "frozen_spinach": "Pakastepinaatti", @@ -167,6 +174,7 @@ "garlic_granules": "Valkosipulirakeet", "gherkins": "Maustekurkut", "ginger": "Inkivääri", + "ginger_ale": "Inkivääriolut", "glass_noodles": "Lasinuudelit", "gluten": "Gluteenijauho", "gnocchi": "Gnocchi", @@ -183,6 +191,8 @@ "hair_gel": "Hiusgeeli", "hair_ties": "Hiuslenkit", "hair_wax": "Hiusvaha", + "ham": "Kinkku", + "ham_cubes": "Kinkkukuutiot", "hand_soap": "Käsisaippua", "handkerchief_box": "Nenäliinalaatikko", "handkerchiefs": "Nenäliinat", @@ -192,6 +202,7 @@ "hazelnuts": "Hasselpähkinät", "head_of_lettuce": "Salaatinlehdet", "herb_baguettes": "Yrttipatongit", + "herb_butter": "Yrttivoi", "herb_cream_cheese": "Yrttituorejuusto", "honey": "Hunaja", "honey_wafers": "Hunajavohvelit", @@ -227,6 +238,7 @@ "lime": "Lime", "linguine": "Linguine", "lip_care": "Huulirasva", + "liqueur": "Likööri", "low-fat_curd_cheese": "Vähärasvainen juustomassa", "maggi": "Maggi", "magnesium": "Magnesium", @@ -257,6 +269,7 @@ "mushrooms": "Sienet", "mustard": "Sinappi", "nail_file": "Kynsiviila", + "nail_polish_remover": "Kynsilakanpoistoaine", "neutral_oil": "Öljy", "nori_sheets": "Noriarkit", "nutmeg": "Muskottipähkinä", @@ -277,6 +290,7 @@ "organic_waste_bags": "Biojätepussit", "pak_choi": "Pak Choi", "pantyhose": "Sukkahousut", + "papaya": "Papaija", "paprika": "Paprika", "paprika_seasoning": "Paprikamauste", "pardina_lentils_dried": "Kuivatut Pardina-linssit", @@ -377,6 +391,7 @@ "shower_gel": "Suihkugeeli", "shredded_cheese": "Juustoraaste", "sieved_tomatoes": "Paseraattu tomaatti", + "skyr": "Skyr", "sliced_cheese": "Juustoviipaleet", "smoked_paprika": "Savustettu paprika", "smoked_tofu": "Savutofu", @@ -388,9 +403,9 @@ "sour_cream": "Hapankerma", "sour_cucumbers": "Hapakurkut", "soy_cream": "Soijakerma", - "soy_hack": "Soija", + "soy_hack": "Soijarouhe", "soy_sauce": "Soijakastike", - "soy_shred": "Soijarouhe", + "soy_shred": "Soijasuikale", "spaetzle": "Spätzle", "spaghetti": "Spagetti", "sparkling_water": "Hiilihapotettu vesi", @@ -453,6 +468,7 @@ "vinegar": "Etikka", "vitamin_tablets": "Vitamiinitabletit", "vodka": "Vodka", + "walnuts": "Saksanpähkinä", "washing_gel": "Pesugeeli", "washing_powder": "Pesujauhe", "water": "Vesi", diff --git a/backend/templates/l10n/it.json b/backend/templates/l10n/it.json index d6ada33a..02d28e0a 100644 --- a/backend/templates/l10n/it.json +++ b/backend/templates/l10n/it.json @@ -12,6 +12,7 @@ "snacks": "🥜 Spuntini" }, "items": { + "agave_syrup": "Sciroppo d'agave", "aioli": "Aioli", "amaretto": "Amaretto", "apple": "Mela", @@ -49,6 +50,7 @@ "beetroot": "Rape", "birthday_card": "Biglietto da compleanno", "black_beans": "Fagioli neri", + "blister_plaster": "Cerotto per vesciche", "bockwurst": "Wurstel", "bodywash": "Detergente corpo", "bread": "Pane", @@ -64,6 +66,7 @@ "burger_sauces": "Salse per hamburger", "butter": "Burro", "butter_cookies": "Biscotti al burro", + "butternut_squash": "Zucca trombetta", "button_cells": "Pile a bottone", "börek_cheese": "Formaggio Börek", "cake": "Torta", @@ -102,6 +105,7 @@ "coconut_flakes": "Fiocchi di cocco", "coconut_milk": "Latte di cocco", "coconut_oil": "Olio di cocco", + "coffee_powder": "Caffè in polvere", "colorful_sprinkles": "Confettini colorati", "concealer": "Cancellina", "cookies": "Biscotti", @@ -111,6 +115,7 @@ "cornstarch": "Amido di mais", "cornys": "Cereali Cornys", "corriander": "Corriandolo", + "cotton_rounds": "Batuffoli di cotone", "cough_drops": "Sciroppo per la tosse", "couscous": "Couscous", "covid_rapid_test": "Test rapido COVID", @@ -139,6 +144,7 @@ "dishwasher_tabs": "Pastiglie per lavastoviglie", "disinfection_spray": "Disinfestante spray", "dried_tomatoes": "Pomodori secchi", + "dry_yeast": "Lievito secco", "edamame": "Edamame", "egg_salad": "Insalata di uova", "egg_yolk": "Tuorlo d'uovo", @@ -156,6 +162,7 @@ "flushing": "Risciacquo", "fresh_chili_pepper": "Peperoncino fresco", "frozen_berries": "Bacche surgelate", + "frozen_broccoli": "Broccoli surgelati", "frozen_fruit": "Frutta surgelata", "frozen_pizza": "Pizza surgelata", "frozen_spinach": "Spinaci surgelati", @@ -167,6 +174,7 @@ "garlic_granules": "Aglio in granuli", "gherkins": "Cetriolini", "ginger": "Zenzero", + "ginger_ale": "Ginger ale", "glass_noodles": "Glass noodles", "gluten": "Glutine", "gnocchi": "Gnocchi", @@ -183,6 +191,8 @@ "hair_gel": "Gel per capelli", "hair_ties": "Fascette per capelli", "hair_wax": "Cera per capelli", + "ham": "Prosciutto cotto", + "ham_cubes": "Prosciutto cotto a dadini", "hand_soap": "Sapone per le mani", "handkerchief_box": "Scatola per fazzoletti", "handkerchiefs": "Fazzoletti", @@ -192,6 +202,7 @@ "hazelnuts": "Nocciole", "head_of_lettuce": "Testa di lattuga", "herb_baguettes": "Baguette alle erbe", + "herb_butter": "Burro alle erbe", "herb_cream_cheese": "Crema di formaggio alle erbe", "honey": "Il miele", "honey_wafers": "Cialde di miele", @@ -227,6 +238,7 @@ "lime": "Calce", "linguine": "Linguine", "lip_care": "Cura delle labbra", + "liqueur": "Liquore", "low-fat_curd_cheese": "Formaggio cagliato a basso contenuto di grassi", "maggi": "Maggi", "magnesium": "Magnesio", @@ -257,10 +269,11 @@ "mushrooms": "Funghi", "mustard": "Senape", "nail_file": "Lima per unghie", + "nail_polish_remover": "Acetone per smalto unghie", "neutral_oil": "Olio neutro", "nori_sheets": "Fogli di nori", "nutmeg": "Noce moscata", - "oat_milk": "Bevanda all'avena", + "oat_milk": "Latte d'avena", "oatmeal": "Farina d'avena", "oatmeal_cookies": "Biscotti d'avena", "oatsome": "Avena", @@ -277,6 +290,7 @@ "organic_waste_bags": "Sacchetti per rifiuti organici", "pak_choi": "Pak Choi", "pantyhose": "Collant", + "papaya": "Papaya", "paprika": "Paprika", "paprika_seasoning": "Condimento alla paprika", "pardina_lentils_dried": "Lenticchie Pardina secche", @@ -377,6 +391,7 @@ "shower_gel": "Gel doccia", "shredded_cheese": "Formaggio a pezzetti", "sieved_tomatoes": "Pomodori setacciati", + "skyr": "Skyr", "sliced_cheese": "Formaggio a fette", "smoked_paprika": "Paprika affumicata", "smoked_tofu": "Tofu affumicato", @@ -388,7 +403,7 @@ "sour_cream": "Panna acida", "sour_cucumbers": "Cetrioli acidi", "soy_cream": "Crema di soia", - "soy_hack": "Hackeraggio della soia", + "soy_hack": "Macinato di soia", "soy_sauce": "Salsa di soia", "soy_shred": "Tritatutto di soia", "spaetzle": "Spaetzle", @@ -453,6 +468,7 @@ "vinegar": "Aceto", "vitamin_tablets": "Compresse di vitamine", "vodka": "Vodka", + "walnuts": "Noci", "washing_gel": "Gel di lavaggio", "washing_powder": "Detersivo in polvere", "water": "Acqua", From c2df78b117c55da8cd3d72c0ded5d56516586591 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 13 Nov 2023 10:50:32 +0100 Subject: [PATCH 442/496] chore: upgrade requirements --- backend/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 43dfdfe4..7935954a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -43,7 +43,7 @@ jstyleson==0.0.2 kiwisolver==1.4.5 lark==1.1.8 lxml==4.9.3 -Mako==1.2.4 +Mako==1.3.0 MarkupSafe==2.1.3 marshmallow==3.20.1 matplotlib==3.8.1 @@ -52,12 +52,12 @@ mf2py==1.1.3 mlxtend==0.23.0 mypy-extensions==1.0.0 nltk==3.8.1 -numpy==1.26.1 +numpy==1.26.2 packaging==23.2 -pandas==2.1.2 +pandas==2.1.3 pathspec==0.11.2 Pillow==10.1.0 -platformdirs==3.11.0 +platformdirs==4.0.0 pluggy==1.3.0 prometheus-client==0.18.0 prometheus-flask-exporter==0.23.0 From ed8a7dddb0d87cd4d2e3980d9a6062ce5bbdefb7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 13 Nov 2023 10:50:54 +0100 Subject: [PATCH 443/496] Prepare release 85 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 29b5994c..88a4a727 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,7 +24,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 84 +BACKEND_VERSION = 85 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 5dc050ca497ff245ebab623b4f416bd2d080798e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 15 Nov 2023 14:31:45 +0100 Subject: [PATCH 444/496] feat: OIDC support (TomBursch/kitchenowl-backend#62) --- backend/app/config.py | 47 +++++ .../app/controller/auth/auth_controller.py | 188 +++++++++++++++++- backend/app/controller/auth/schemas.py | 30 +++ backend/app/controller/health_controller.py | 3 +- backend/app/jobs/jobs.py | 3 +- backend/app/models/__init__.py | 1 + backend/app/models/oidc.py | 47 +++++ backend/app/models/user.py | 17 +- backend/app/service/mail.py | 4 +- backend/migrations/versions/9b45d9dd5b8e_.py | 64 ++++++ backend/requirements.txt | 12 ++ 11 files changed, 405 insertions(+), 11 deletions(-) create mode 100644 backend/app/models/oidc.py create mode 100644 backend/migrations/versions/9b45d9dd5b8e_.py diff --git a/backend/app/config.py b/backend/app/config.py index 88a4a727..c0270ebf 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ from datetime import timedelta +from http import client from flask_socketio import SocketIO from sqlalchemy import MetaData from sqlalchemy.engine import URL @@ -13,6 +14,9 @@ InvalidUsage, ) from app.util import KitchenOwlJSONProvider +from oic.oic import Client +from oic.oic.message import RegistrationResponse +from oic.utils.authn.client import CLIENT_AUTHN_METHOD from flask import Flask, request from flask_basicauth import BasicAuth from flask_migrate import Migrate @@ -52,6 +56,16 @@ JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) +OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID") +OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET") +OIDC_ISSUER = os.getenv("OIDC_ISSUER") + +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") + +APPLE_CLIENT_ID = os.getenv("APPLE_CLIENT_ID") +APPLE_CLIENT_SECRET = os.getenv("APPLE_CLIENT_SECRET") + SUPPORTED_LANGUAGES = { "en": "English", "cs": "čeština", @@ -111,6 +125,39 @@ socketio = SocketIO( app, json=app.json, logger=app.logger, cors_allowed_origins=FRONT_URL ) +oidc_clients = {} +if FRONT_URL: + if OIDC_CLIENT_ID and OIDC_CLIENT_SECRET and OIDC_ISSUER: + client = Client(client_authn_method=CLIENT_AUTHN_METHOD) + client.provider_config(OIDC_ISSUER) + client.store_registration_info( + RegistrationResponse( + client_id=OIDC_CLIENT_ID, client_secret=OIDC_CLIENT_SECRET + ) + ) + oidc_clients["custom"] = client + if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET: + client = Client(client_authn_method=CLIENT_AUTHN_METHOD) + client.provider_config("https://accounts.google.com/") + client.store_registration_info( + RegistrationResponse( + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, + ) + ) + oidc_clients["google"] = client + if APPLE_CLIENT_ID and APPLE_CLIENT_SECRET: + client = Client(client_authn_method=CLIENT_AUTHN_METHOD) + client.provider_config("https://appleid.apple.com/") + client.store_registration_info( + RegistrationResponse( + client_id=APPLE_CLIENT_ID, + client_secret=APPLE_CLIENT_SECRET, + ) + ) + oidc_clients["apple"] = client + + if COLLECT_METRICS: basic_auth = BasicAuth(app) registry = CollectorRegistry() diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index f2b81664..0e737696 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -1,11 +1,17 @@ from datetime import datetime +import re +import uuid + +from oic import rndstr +from oic.oic.message import AuthorizationResponse +from oic.oauth2.message import ErrorResponse from app.helpers import validate_args from flask import jsonify, Blueprint, request from flask_jwt_extended import current_user, jwt_required, get_jwt -from app.models import User, Token -from app.errors import UnauthorizedRequest, InvalidUsage -from .schemas import Login, Signup, CreateLongLivedToken -from app.config import jwt, OPEN_REGISTRATION +from app.models import User, Token, OIDCLink, OIDCRequest +from app.errors import NotFoundRequest, UnauthorizedRequest, InvalidUsage +from .schemas import Login, Signup, CreateLongLivedToken, GetOIDCLoginUrl, LoginOIDC +from app.config import EMAIL_MANDATORY, FRONT_URL, jwt, OPEN_REGISTRATION, oidc_clients auth = Blueprint("auth", __name__) @@ -171,3 +177,177 @@ def deleteLongLivedToken(id): token.delete() return jsonify({"msg": "DONE"}) + + +if FRONT_URL and len(oidc_clients) > 0: + + @auth.route("oidc", methods=["GET"]) + @jwt_required(optional=True) + @validate_args(GetOIDCLoginUrl) + def getOIDCLoginUrl(args): + provider = args["provider"] if "provider" in args else "custom" + if not provider in oidc_clients: + raise NotFoundRequest() + client = oidc_clients[provider] + if not client: + raise UnauthorizedRequest( + message="Unauthorized: IP {} get login url for unknown OIDC provider".format( + request.remote_addr + ) + ) + state = rndstr() + nonce = rndstr() + redirect_uri = ( + "kitchenowl://" if args["kitchenowl_scheme"] else FRONT_URL + ) + "/signin/redirect" + args = { + "client_id": client.client_id, + "response_type": "code", + "scope": ["openid", "profile", "email"], + "nonce": nonce, + "state": state, + "redirect_uri": redirect_uri, + } + + auth_req = client.construct_AuthorizationRequest(request_args=args) + login_url = auth_req.request(client.authorization_endpoint) + OIDCRequest( + state=state, + provider=provider, + nonce=nonce, + redirect_uri=redirect_uri, + user_id=current_user.id if current_user else None, + ).save() + return jsonify({"login_url": login_url, "state": state, "nonce": nonce}) + + @auth.route("callback", methods=["POST"]) + @jwt_required(optional=True) + @validate_args(LoginOIDC) + def loginWithOIDC(args): + # Validate oidc login + oidc_request = OIDCRequest.find_by_state(args["state"]) + if not oidc_request: + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp with unknown OIDC state".format( + request.remote_addr + ) + ) + provider = oidc_request.provider + client = oidc_clients[provider] + if not client: + oidc_request.delete() + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp with unknown OIDC provider".format( + request.remote_addr + ) + ) + + if oidc_request.user != current_user: + if not current_user: + return "Request invalid: user not signed in for link request", 400 + oidc_request.delete() + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp for a different account".format( + request.remote_addr + ) + ) + + client.parse_response( + AuthorizationResponse, + info={"code": args["code"], "state": oidc_request.state}, + sformat="dict", + ) + + tokenResponse = client.do_access_token_request( + scope=["openid", "profile", "email"], + state=oidc_request.state, + request_args={ + "code": args["code"], + "redirect_uri": oidc_request.redirect_uri, + }, + authn_method="client_secret_basic", + ) + if isinstance(tokenResponse, ErrorResponse): + oidc_request.delete() + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp for OIDC failed".format( + request.remote_addr + ) + ) + userinfo = tokenResponse["id_token"] + if userinfo["nonce"] != oidc_request.nonce: + raise UnauthorizedRequest( + message="Unauthorized: IP {} login attemp for OIDC failed: mismatched nonce".format( + request.remote_addr + ) + ) + oidc_request.delete() + + # find user or create one + oidcLink = OIDCLink.find_by_ids(userinfo["sub"], provider) + if current_user: + if oidcLink and oidcLink.user_id != current_user.id: + return ( + "Request invalid: oidc account already linked with other kitchenowl account", + 400, + ) + if oidcLink: + return jsonify({"msg": "DONE"}) + + if provider in map(lambda l: l.provider, current_user.oidc_links): + return "Request invalid: provider already linked with account", 400 + + oidcLink = OIDCLink( + sub=userinfo["sub"], provider=provider, user_id=current_user.id + ).save() + oidcLink.user = current_user + if not oidcLink: + if "email" in userinfo: + if User.find_by_email(userinfo["email"].strip()): + return "Request invalid: email", 400 + elif EMAIL_MANDATORY: + return "Request invalid: email", 400 + + username = ( + userinfo["name"].lower().strip().replace(" ", "") + if "name" in userinfo + else None + ) + if not username or User.find_by_username(username): + username = userinfo["sub"].lower().strip().replace(" ", "") + if User.find_by_username(username): + username = uuid.uuid4().hex + newUser = User( + username=username, + name=userinfo["name"].strip() + if "name" in userinfo + else userinfo["sub"], + email=userinfo["email"].strip() if "email" in userinfo else None, + email_verified=userinfo["email_verified"] + if "email_verified" in userinfo + else False, + photo=userinfo["picture"] if "picture" in userinfo else None, + ).save() + oidcLink = OIDCLink( + sub=userinfo["sub"], provider=provider, user_id=newUser.id + ).save() + oidcLink.user = newUser + + user: User = oidcLink.user + + # Don't login already logged in user + if current_user: + return jsonify({"msg": "DONE"}) + + # login user + device = "Unkown" + if "device" in args: + device = args["device"] + + # Create refresh token + refreshToken, refreshModel = Token.create_refresh_token(user, device) + + # Create first access token + accesssToken, _ = Token.create_access_token(user, refreshModel) + + return jsonify({"access_token": accesssToken, "refresh_token": refreshToken}) diff --git a/backend/app/controller/auth/schemas.py b/backend/app/controller/auth/schemas.py index 75050f2b..bc321cea 100644 --- a/backend/app/controller/auth/schemas.py +++ b/backend/app/controller/auth/schemas.py @@ -45,3 +45,33 @@ class CreateLongLivedToken(Schema): validate=lambda a: a and not a.isspace(), load_only=True, ) + + +class GetOIDCLoginUrl(Schema): + provider = fields.String( + validate=lambda a: a and not a.isspace(), + load_only=True, + ) + kitchenowl_scheme = fields.Boolean( + required=False, + default=False, + load_only=True, + ) + + +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, + ) diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 07505f9c..d3991ed0 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -7,7 +7,7 @@ EMAIL_MANDATORY, ) from app.models import Settings -from app.config import SUPPORTED_LANGUAGES +from app.config import SUPPORTED_LANGUAGES, oidc_clients health = Blueprint("health", __name__) @@ -18,6 +18,7 @@ def get_health(): "msg": "OK", "version": BACKEND_VERSION, "min_frontend_version": MIN_FRONTEND_VERSION, + "oidc_provider": list(oidc_clients.keys()) } if PRIVACY_POLICY_URL: info["privacy_policy"] = PRIVACY_POLICY_URL diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index 2b9ab91c..f5c17616 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -1,6 +1,6 @@ from app.jobs.recipe_suggestions import computeRecipeSuggestions from app import app, scheduler -from app.models import Token, Household, Shoppinglist, Recipe, ChallengePasswordReset +from app.models import Token, Household, Shoppinglist, Recipe, ChallengePasswordReset, OIDCRequest from .item_ordering import findItemOrdering from .item_suggestions import findItemSuggestions from .cluster_shoppings import clusterShoppings @@ -38,3 +38,4 @@ def halfHourly(): Token.delete_expired_access() Token.delete_expired_refresh() ChallengePasswordReset.delete_expired() + OIDCRequest.delete_expired() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ca8d4cb9..cf89bf33 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,3 +16,4 @@ from .file import File from .challenge_mail_verify import ChallengeMailVerify from .challenge_password_reset import ChallengePasswordReset +from .oidc import OIDCLink, OIDCRequest diff --git a/backend/app/models/oidc.py b/backend/app/models/oidc.py new file mode 100644 index 00000000..2bbab0e9 --- /dev/null +++ b/backend/app/models/oidc.py @@ -0,0 +1,47 @@ +from datetime import datetime, timedelta +from typing import Self + +from app import db +from app.helpers import DbModelMixin, TimestampMixin + + +class OIDCLink(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "oidc_link" + + sub = db.Column(db.String(256), primary_key=True) + provider = db.Column(db.String(24), primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey("user.id"), nullable=False, index=True + ) + + user = db.relationship("User", back_populates="oidc_links") + + @classmethod + def find_by_ids(cls, sub: str, provider: str) -> Self: + return cls.query.filter(cls.sub == sub, cls.provider == provider).first() + + +class OIDCRequest(db.Model, DbModelMixin, TimestampMixin): + __tablename__ = "oidc_request" + + state = db.Column(db.String(256), primary_key=True) + provider = db.Column(db.String(24), primary_key=True) + nonce = db.Column(db.String(256), nullable=False) + redirect_uri = db.Column(db.String(256), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + user = db.relationship("User", back_populates="oidc_link_requests") + + @classmethod + def find_by_state(cls, state: str) -> Self: + filter_before = datetime.utcnow() - timedelta(minutes=7) + return cls.query.filter( + cls.state == state, + cls.created_at >= filter_before, + ).first() + + @classmethod + def delete_expired(cls): + filter_before = datetime.utcnow() - timedelta(minutes=7) + db.session.query(cls).filter(cls.created_at <= filter_before).delete() + db.session.commit() diff --git a/backend/app/models/user.py b/backend/app/models/user.py index cc3739c9..fc57293f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,7 +13,7 @@ class User(db.Model, DbModelMixin, TimestampMixin): name = db.Column(db.String(128)) username = db.Column(db.String(256), unique=True, nullable=False) email = db.Column(db.String(256), unique=True, nullable=True) - password = db.Column(db.String(256), nullable=False) + password = db.Column(db.String(256), nullable=True) photo = db.Column(db.String(), db.ForeignKey("file.filename", use_alter=True)) admin = db.Column(db.Boolean(), default=False) email_verified = db.Column(db.Boolean(), default=False) @@ -43,8 +43,16 @@ class User(db.Model, DbModelMixin, TimestampMixin): "File", back_populates="profile_picture", foreign_keys=[photo], uselist=False ) + oidc_links = db.relationship( + "OIDCLink", back_populates="user", cascade="all, delete-orphan" + ) + oidc_link_requests = db.relationship( + "OIDCRequest", back_populates="user", cascade="all, delete-orphan" + ) + + def check_password(self, password: str) -> bool: - return bcrypt.check_password_hash(self.password, password) + return self.password and bcrypt.check_password_hash(self.password, password) def set_password(self, password: str): self.password = bcrypt.generate_password_hash(password).decode("utf-8") @@ -81,6 +89,7 @@ def obj_to_full_dict(self) -> dict: ~Token.created_tokens.any(Token.type == "refresh"), ).all() res["tokens"] = [e.obj_to_dict(skip_columns=["user_id"]) for e in tokens] + res["oidc_links"] = [e.provider for e in self.oidc_links] return res def delete(self): @@ -120,7 +129,9 @@ def create( ) -> Self: return cls( username=username.lower().strip().replace(" ", ""), - password=bcrypt.generate_password_hash(password).decode("utf-8"), + password=bcrypt.generate_password_hash(password).decode("utf-8") + if password + else None, name=name.strip(), email=email.strip() if email else None, admin=admin, diff --git a/backend/app/service/mail.py b/backend/app/service/mail.py index 7fbd7949..f891c636 100644 --- a/backend/app/service/mail.py +++ b/backend/app/service/mail.py @@ -52,7 +52,7 @@ def sendVerificationMail(user: User, token: str): if not user.email or not token: return - verifyLink = FRONT_URL + "/#/confirm-email?t=" + token + verifyLink = FRONT_URL + "/confirm-email?t=" + token message = MIMEMultipart("alternative") message["Subject"] = "Verify Email" @@ -88,7 +88,7 @@ def sendPasswordResetMail(user: User, token: str): if not user.email or not token: return - resetLink = FRONT_URL + "/#/reset-password?t=" + token + resetLink = FRONT_URL + "/reset-password?t=" + token message = MIMEMultipart("alternative") message["Subject"] = "Reset password" diff --git a/backend/migrations/versions/9b45d9dd5b8e_.py b/backend/migrations/versions/9b45d9dd5b8e_.py new file mode 100644 index 00000000..f059e5eb --- /dev/null +++ b/backend/migrations/versions/9b45d9dd5b8e_.py @@ -0,0 +1,64 @@ +"""empty message + +Revision ID: 9b45d9dd5b8e +Revises: ee2ba4d37d8b +Create Date: 2023-11-15 12:01:18.288028 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9b45d9dd5b8e' +down_revision = 'ee2ba4d37d8b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('oidc_link', + sa.Column('sub', sa.String(length=256), nullable=False), + sa.Column('provider', sa.String(length=24), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oidc_link_user_id_user')), + sa.PrimaryKeyConstraint('sub', 'provider', name=op.f('pk_oidc_link')) + ) + with op.batch_alter_table('oidc_link', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_oidc_link_user_id'), ['user_id'], unique=False) + + op.create_table('oidc_request', + sa.Column('state', sa.String(length=256), nullable=False), + sa.Column('provider', sa.String(length=24), nullable=False), + sa.Column('nonce', sa.String(length=256), nullable=False), + sa.Column('redirect_uri', sa.String(length=256), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_oidc_request_user_id_user')), + sa.PrimaryKeyConstraint('state', 'provider', name=op.f('pk_oidc_request')) + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('password', + existing_type=sa.VARCHAR(length=256), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('password', + existing_type=sa.VARCHAR(length=256), + nullable=False) + + op.drop_table('oidc_request') + with op.batch_alter_table('oidc_link', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_oidc_link_user_id')) + + op.drop_table('oidc_link') + # ### end Alembic commands ### diff --git a/backend/requirements.txt b/backend/requirements.txt index 7935954a..9370ce29 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,5 @@ alembic==1.12.1 +annotated-types==0.6.0 appdirs==1.4.4 APScheduler==3.10.4 attrs==23.1.0 @@ -14,8 +15,10 @@ cffi==1.16.0 charset-normalizer==3.3.2 click==8.1.7 contourpy==1.2.0 +cryptography==41.0.5 cycler==0.12.1 dbscan1d==0.2.2 +defusedxml==0.7.1 extruct==0.16.0 flake8==6.1.0 Flask==3.0.0 @@ -27,6 +30,7 @@ Flask-Migrate==4.0.5 Flask-SocketIO==5.3.6 Flask-SQLAlchemy==3.1.1 fonttools==4.44.0 +future==0.18.3 gevent==23.9.1 greenlet==3.0.0rc3 h11==0.14.0 @@ -53,6 +57,7 @@ mlxtend==0.23.0 mypy-extensions==1.0.0 nltk==3.8.1 numpy==1.26.2 +oic==1.6.1 packaging==23.2 pandas==2.1.3 pathspec==0.11.2 @@ -65,13 +70,19 @@ psycopg2-binary==2.9.9 py==1.11.0 pycodestyle==2.11.1 pycparser==2.21 +pycryptodomex==3.19.0 +pydantic==2.4.2 +pydantic-settings==2.0.3 +pydantic_core==2.10.1 pyflakes==3.1.0 +pyjwkest==1.4.2 PyJWT==2.8.0 pyparsing==3.1.1 pyRdfa3==3.5.3 pytest==7.4.3 python-crfsuite==0.9.9 python-dateutil==2.8.2 +python-dotenv==1.0.0 python-editor==1.0.4 python-engineio==4.8.0 python-socketio==5.10.0 @@ -85,6 +96,7 @@ requests==2.31.0 scikit-learn==1.3.2 scipy==1.11.3 setuptools-scm==8.0.4 +simple-websocket==1.0.0 six==1.16.0 soupsieve==2.5 SQLAlchemy==2.0.23 From 57ffde705cc7635c0695337850bfea5c59545e2d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 15 Nov 2023 15:27:30 +0100 Subject: [PATCH 445/496] fix: username stripping --- backend/app/controller/auth/auth_controller.py | 10 +++++----- backend/app/controller/user/user_controller.py | 2 +- backend/app/models/user.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 0e737696..180b17ca 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -48,7 +48,7 @@ def user_lookup_callback(_jwt_header, jwt_data) -> User: @auth.route("", methods=["POST"]) @validate_args(Login) def login(args): - username = args["username"].lower() + username = args["username"].lower().replace(" ", ""), user = User.find_by_username(username) if not user or not user.check_password(args["password"]): raise UnauthorizedRequest( @@ -74,7 +74,7 @@ def login(args): @auth.route("signup", methods=["POST"]) @validate_args(Signup) def signup(args): - username = args["username"].strip().lower().replace(" ", "") + username = args["username"].lower().replace(" ", "") user = User.find_by_username(username) if user: return "Request invalid: username", 400 @@ -309,13 +309,13 @@ def loginWithOIDC(args): return "Request invalid: email", 400 username = ( - userinfo["name"].lower().strip().replace(" ", "") + userinfo["name"].lower().replace(" ", "").replace("@", "") if "name" in userinfo else None ) if not username or User.find_by_username(username): - username = userinfo["sub"].lower().strip().replace(" ", "") - if User.find_by_username(username): + username = userinfo["sub"].lower().replace(" ", "").replace("@", "") + if not username or User.find_by_username(username): username = uuid.uuid4().hex newUser = User( username=username, diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 713f1b81..fa4889a6 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -115,7 +115,7 @@ def updateUserById(args, id): @validate_args(CreateUser) def createUser(args): User.create( - args["username"], + args["username"].replace(" ", ""), args["password"], args["name"], email=args["email"] if "email" in args else None, diff --git a/backend/app/models/user.py b/backend/app/models/user.py index fc57293f..94cd2cf0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -128,7 +128,7 @@ def create( admin: bool = False, ) -> Self: return cls( - username=username.lower().strip().replace(" ", ""), + username=username.lower().replace(" ", ""), password=bcrypt.generate_password_hash(password).decode("utf-8") if password else None, From e1388ff35051bd9a0925bb441a0143d76c5680cf Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 15 Nov 2023 16:01:39 +0100 Subject: [PATCH 446/496] feat: update mail service --- backend/app/controller/auth/auth_controller.py | 5 ++++- backend/app/models/challenge_mail_verify.py | 1 - backend/app/models/challenge_password_reset.py | 1 - backend/app/service/mail.py | 16 ++++++++++------ backend/manage.py | 13 ++++++++++++- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 180b17ca..83284665 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -8,8 +8,9 @@ from app.helpers import validate_args from flask import jsonify, Blueprint, request from flask_jwt_extended import current_user, jwt_required, get_jwt -from app.models import User, Token, OIDCLink, OIDCRequest +from app.models import User, Token, OIDCLink, OIDCRequest, ChallengeMailVerify from app.errors import NotFoundRequest, UnauthorizedRequest, InvalidUsage +from app.service import mail from .schemas import Login, Signup, CreateLongLivedToken, GetOIDCLoginUrl, LoginOIDC from app.config import EMAIL_MANDATORY, FRONT_URL, jwt, OPEN_REGISTRATION, oidc_clients @@ -89,6 +90,8 @@ def signup(args): password=args["password"], email=args["email"] if "email" in args else None, ) + if mail.mailConfigured(): + mail.sendVerificationMail(user, ChallengeMailVerify.create_challenge(user)) device = "Unkown" if "device" in args: diff --git a/backend/app/models/challenge_mail_verify.py b/backend/app/models/challenge_mail_verify.py index 25702bfd..64a99365 100644 --- a/backend/app/models/challenge_mail_verify.py +++ b/backend/app/models/challenge_mail_verify.py @@ -3,7 +3,6 @@ from typing import Self import uuid from app import db -from app.config import bcrypt from app.helpers import DbModelMixin, TimestampMixin from app.models.user import User diff --git a/backend/app/models/challenge_password_reset.py b/backend/app/models/challenge_password_reset.py index d30bc3ec..c03436c1 100644 --- a/backend/app/models/challenge_password_reset.py +++ b/backend/app/models/challenge_password_reset.py @@ -4,7 +4,6 @@ from typing import Self import uuid from app import db -from app.config import bcrypt from app.helpers import DbModelMixin, TimestampMixin from app.models.user import User diff --git a/backend/app/service/mail.py b/backend/app/service/mail.py index f891c636..7f4fbc70 100644 --- a/backend/app/service/mail.py +++ b/backend/app/service/mail.py @@ -68,9 +68,11 @@ def sendVerificationMail(user: User, token: str): html = """\ -

Hi {name} (@{username}),
+

Hi {name} (@{username}),

+ Verify your email so we know it's really you and you don't loose access to your account.
- Verify email address
+ Verify email address

+ Have any questions? Check out our Privacy Policy

@@ -106,10 +108,12 @@ def sendPasswordResetMail(user: User, token: str): html = """\ -

Hi {name} (@{username}),
- We received a request to change your password. This link is valid for three hours.
- Reset password
- If you didn't request a password reset, you can ignore this message and continue to use your current password.
+

Hi {name} (@{username}),

+ + We received a request to change your password. This link is valid for three hours:
+ Reset password

+ + If you didn't request a password reset, you can ignore this message and continue to use your current password.

Have any questions? Check out our Privacy Policy

diff --git a/backend/manage.py b/backend/manage.py index f2461057..8f75325e 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -5,7 +5,8 @@ from app import app, db from app.config import UPLOAD_FOLDER from app.jobs import jobs -from app.models import User, File, Household, HouseholdMember +from app.models import User, File, Household, HouseholdMember, ChallengeMailVerify +from app.service import mail from app.service.delete_unused import deleteEmptyHouseholds, deleteUnusedFiles from app.service.recalculate_blurhash import recalculateBlurhashes @@ -58,6 +59,7 @@ def manageUsers(): 2. Create user 3. Update user 4. Delete user + 5. Send verification mail to unverified users (q) Go back""") selection = input("Your selection (q):") if selection == "1": @@ -81,6 +83,15 @@ def manageUsers(): print("No user found with that username") else: user.delete() + elif selection == "5": + if not mail.mailConfigured(): + print("Mail service not configured") + continue + print("Sending mails...") + users = User.query.filter(User.email_verified == False).all() + for user in users: + if len(user.verify_mail_challenge) == 0: + mail.sendVerificationMail(user, ChallengeMailVerify.create_challenge(user)) else: return From 591d8891e365492cb8ec011d5939b59723c9b83d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 16 Nov 2023 11:12:46 +0100 Subject: [PATCH 447/496] fix: login --- backend/app/controller/auth/auth_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 83284665..c1ee15e4 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -49,7 +49,7 @@ def user_lookup_callback(_jwt_header, jwt_data) -> User: @auth.route("", methods=["POST"]) @validate_args(Login) def login(args): - username = args["username"].lower().replace(" ", ""), + username = args["username"].lower().replace(" ", "") user = User.find_by_username(username) if not user or not user.check_password(args["password"]): raise UnauthorizedRequest( From 8f08d49b88d722af0e06303bc9d1536c7e459a01 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 16 Nov 2023 15:36:34 +0100 Subject: [PATCH 448/496] Translated using Weblate (Greek) (TomBursch/kitchenowl-backend#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (German) Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (Finnish) Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (French) Currently translated at 99.7% (494 of 495 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (Italian) Currently translated at 99.3% (492 of 495 strings) Translated using Weblate (Danish) Currently translated at 98.9% (490 of 495 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (494 of 494 strings) Translated using Weblate (Finnish) Currently translated at 100.0% (492 of 492 strings) Translated using Weblate (Finnish) Currently translated at 96.9% (477 of 492 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (492 of 492 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (477 of 477 strings) Translated using Weblate (English) Currently translated at 100.0% (477 of 477 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/da/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/de/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/el/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/en/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fi/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fr/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/it/ Translation: KitchenOwl/Default Items Co-authored-by: BeardedWatermelon Co-authored-by: KosmosMonk Co-authored-by: Mathias Co-authored-by: Petri Hämäläinen Co-authored-by: Tom Bursch Co-authored-by: gallegonovato --- backend/templates/l10n/de.json | 2 +- backend/templates/l10n/el.json | 23 +++++++++++++++++++++-- backend/templates/l10n/fi.json | 3 +++ backend/templates/l10n/fr.json | 21 ++++++++++++++++++++- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index 50d6c4ff..bb913c17 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -498,4 +498,4 @@ "zinc_cream": "Zinkcreme", "zucchini": "Zucchini" } -} \ No newline at end of file +} diff --git a/backend/templates/l10n/el.json b/backend/templates/l10n/el.json index 278438b6..4e6efcd7 100644 --- a/backend/templates/l10n/el.json +++ b/backend/templates/l10n/el.json @@ -12,6 +12,7 @@ "snacks": "🥜 Σνάκ" }, "items": { + "agave_syrup": "Σιρόπι Αγαύης", "aioli": "Αιόλι", "amaretto": "Αμαρέττο", "apple": "Μήλο", @@ -44,11 +45,14 @@ "batteries": "Μπαταρίες", "bay_leaf": "Δάφνη", "beans": "Φασόλια", + "beef": "Μοσχάρι", + "beef_broth": "Ζωμός μοσχαρίσιος", "beer": "Μπίρα", "beet": "Παντζάρι", "beetroot": "Ρίζα παντζαριού", "birthday_card": "Κάρτα γενεθλίων", "black_beans": "Μαύρα φασόλια", + "blister_plaster": "Επίδεσμος φουσκάλας", "bockwurst": "Λουκάνικο Bockwurst", "bodywash": "Αφρόλουτρο", "bread": "Ψωμί", @@ -64,6 +68,7 @@ "burger_sauces": "Σάλτσες Μπέργκερ", "butter": "Βούτυρο", "butter_cookies": "Μπισκότα βουτύρου", + "butternut_squash": "Σκουός Κολοκύθας", "button_cells": "Μπαταρίες ρολογιού", "börek_cheese": "Τυρί Börek", "cake": "Κέικ", @@ -102,6 +107,7 @@ "coconut_flakes": "Νιφάδες καρύδας", "coconut_milk": "Γάλα καρύδας", "coconut_oil": "Λάδι καρύδας", + "coffee_powder": "Σκόνη καφέ", "colorful_sprinkles": "Πολύχρωμες τρούφες", "concealer": "Κονσίλερ", "cookies": "Μπισκότα", @@ -111,6 +117,7 @@ "cornstarch": "Άμυλο καλαμποκιού", "cornys": "Κόρνις", "corriander": "Κορύανδρος", + "cotton_rounds": "Δίσκοι ντεμακιγιάζ", "cough_drops": "Παστίλιες για τον βήχα", "couscous": "Κουσκους", "covid_rapid_test": "COVID ράπιντ τεστ", @@ -139,6 +146,7 @@ "dishwasher_tabs": "Ταμπλέτες πλυντηρίου πιάτων", "disinfection_spray": "Απολυμαντικό σπρέι", "dried_tomatoes": "Αποξηραμένες ντομάτες", + "dry_yeast": "Ξηρά μαγιά", "edamame": "Εντάμαμε", "egg_salad": "Σαλάτα με αυγά", "egg_yolk": "Κρόκος αυγού", @@ -156,6 +164,7 @@ "flushing": "Καθαριστικά τουαλέτας", "fresh_chili_pepper": "Φρέσκια πιπεριά τσίλι", "frozen_berries": "Κατεψυγμένα μούρα", + "frozen_broccoli": "Κατεψυγμένο μπρόκολο", "frozen_fruit": "Κατεψυγμένα φρούτα", "frozen_pizza": "Κατεψυγμένη πίτσα", "frozen_spinach": "Κατεψυγμένο σπανάκι", @@ -167,6 +176,7 @@ "garlic_granules": "Κόκκοι σκόρδου", "gherkins": "Αγγουράκια", "ginger": "Τζίντζερ", + "ginger_ale": "Μπίρα Τζίντζερ", "glass_noodles": "Νουντλς φασολιού", "gluten": "Γλουτένη", "gnocchi": "Νιόκι", @@ -183,6 +193,8 @@ "hair_gel": "Τζέλ μαλλιών", "hair_ties": "Γραβάτες μαλλιών", "hair_wax": "Κερί μαλλιών", + "ham": "Χοιρομέρι", + "ham_cubes": "Χοιρομέρι σε κύβους", "hand_soap": "Σαπούνι χεριών", "handkerchief_box": "Κουτί χαρτομάντηλα", "handkerchiefs": "Χαρτομάντηλα", @@ -192,6 +204,7 @@ "hazelnuts": "Φουντούκια", "head_of_lettuce": "Κεφάλι μαρουλιού", "herb_baguettes": "Μπαγκέτες με μπαχαρικά", + "herb_butter": "Βούτυρο μπαχαρικών", "herb_cream_cheese": "Κρέμα τυριού με μπαχαρικά", "honey": "Μέλι", "honey_wafers": "Γκοφρέτες μελιού", @@ -208,6 +221,7 @@ "kidney_beans": "Κόκκινα φασόλια", "kitchen_roll": "Χαρτί κουζίνας", "kitchen_towels": "Πετσέτες κουζίνας", + "kiwi": "Kiwi", "kohlrabi": "Γογγυλοκράμβη", "lasagna": "Λαζάνια", "lasagna_noodles": "Νούντλς λαζάνια", @@ -227,6 +241,7 @@ "lime": "Λάιμ", "linguine": "Λιγκουίνι", "lip_care": "Φροντίδα χειλιών", + "liqueur": "Λικέρ", "low-fat_curd_cheese": "Τυρί με χαμηλά λιπαρά", "maggi": "Κύβος Maggi", "magnesium": "Μαγνήσιο", @@ -257,10 +272,11 @@ "mushrooms": "Μανιτάρια", "mustard": "Μουστάρδα", "nail_file": "Λίμα νυχιών", + "nail_polish_remover": "Ασετόν", "neutral_oil": "Ουδέτερο λάδι", "nori_sheets": "Φύλλα από φύκια", "nutmeg": "Μοσχοκάρυδο", - "oat_milk": "Ποτό βρώμης", + "oat_milk": "Γάλα βρώμης", "oatmeal": "Βρώμη", "oatmeal_cookies": "Μπισκότα βρώμης", "oatsome": "Γάλα βρώμης", @@ -277,6 +293,7 @@ "organic_waste_bags": "Οργανικές σακούλες σκουπιδιών", "pak_choi": "Μποκ τσόι", "pantyhose": "Καλσόν", + "papaya": "Παπάγια", "paprika": "Πάπρικα", "paprika_seasoning": "Καρύκευμα πάπρικας", "pardina_lentils_dried": "Αποξηραμένες φακές", @@ -377,6 +394,7 @@ "shower_gel": "Αφρόλουτρο τζελ", "shredded_cheese": "Τριμμένο τυρί", "sieved_tomatoes": "Κοσκινισμένες ντομάτες", + "skyr": "Ισλανδικό γιαούρτι", "sliced_cheese": "Τυρί σε φέτες", "smoked_paprika": "Καπνιστή πάπρικα", "smoked_tofu": "Καπνιστό τόφου", @@ -388,7 +406,7 @@ "sour_cream": "Ξινή κρέμα", "sour_cucumbers": "Ξινά αγγούρια", "soy_cream": "Κρέμα σόγιας", - "soy_hack": "Σόγια", + "soy_hack": "Κιμάς Σόγιας", "soy_sauce": "Σάλτσα σόγιας", "soy_shred": "Τριμμένη σόγια", "spaetzle": "Spaetzle (Νούντλς)", @@ -453,6 +471,7 @@ "vinegar": "Ξίδι", "vitamin_tablets": "Ταμπλέτες βιταμινών", "vodka": "Βότκα", + "walnuts": "Καρύδια", "washing_gel": "Gel πλύσης", "washing_powder": "Σκόνη πλυσίματος", "water": "Νερό", diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json index 774cde4d..bee5f0d3 100644 --- a/backend/templates/l10n/fi.json +++ b/backend/templates/l10n/fi.json @@ -45,6 +45,8 @@ "batteries": "Paristot", "bay_leaf": "Laakerinlehti", "beans": "Pavut", + "beef": "Naudanliha", + "beef_broth": "Lihaliemi", "beer": "Olut", "beet": "Juurikas", "beetroot": "Punajuuri", @@ -219,6 +221,7 @@ "kidney_beans": "Kidneypavut", "kitchen_roll": "Talouspaperi", "kitchen_towels": "Keittiöpyyhkeet", + "kiwi": "Kiivi", "kohlrabi": "Kyssäkaali", "lasagna": "Lasagne", "lasagna_noodles": "Lasagnepasta", diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json index 0c886870..a1c5507f 100644 --- a/backend/templates/l10n/fr.json +++ b/backend/templates/l10n/fr.json @@ -12,6 +12,7 @@ "snacks": "🥜 Collations" }, "items": { + "agave_syrup": "Sirop d'agave", "aioli": "Aïoli", "amaretto": "Amaretto", "apple": "Pomme", @@ -44,11 +45,14 @@ "batteries": "Piles", "bay_leaf": "Feuilles de laurier", "beans": "Haricots", + "beef": "Bœuf", + "beef_broth": "Bouillon de bœuf", "beer": "Bière", "beet": "Betterave", "beetroot": "Betterave rouge", "birthday_card": "Carte d'anniversaire", "black_beans": "Haricots noirs", + "blister_plaster": "Pansement pour ampoules", "bockwurst": "Bockwurst", "bodywash": "Bain de corps", "bread": "Pain", @@ -64,6 +68,7 @@ "burger_sauces": "Sauces pour hamburgers", "butter": "Beurre", "butter_cookies": "Biscuits au beurre", + "butternut_squash": "Purée de butternut", "button_cells": "Cellules boutons", "börek_cheese": "Fromage Börek", "cake": "Gâteau", @@ -102,6 +107,7 @@ "coconut_flakes": "Flocons de noix de coco", "coconut_milk": "Lait de coco", "coconut_oil": "Huile de noix de coco", + "coffee_powder": "Café en poudre", "colorful_sprinkles": "Saupoudrage coloré", "concealer": "Correcteur de teint", "cookies": "Cookies", @@ -111,6 +117,7 @@ "cornstarch": "Amidon de maïs", "cornys": "Cornys", "corriander": "Corriandre", + "cotton_rounds": "Cotons démaquillants", "cough_drops": "Gouttes contre la toux", "couscous": "Couscous", "covid_rapid_test": "Test rapide COVID", @@ -139,6 +146,7 @@ "dishwasher_tabs": "Languettes pour lave-vaisselle", "disinfection_spray": "Spray désinfectant", "dried_tomatoes": "Tomates séchées", + "dry_yeast": "Levure sèche", "edamame": "Edamame", "egg_salad": "Salade d'œufs", "egg_yolk": "Jaune d'œuf", @@ -156,6 +164,7 @@ "flushing": "Chasse d'eau", "fresh_chili_pepper": "Piment cili frais", "frozen_berries": "Baies congelées", + "frozen_broccoli": "Brocoli surgelé", "frozen_fruit": "Fruits congelés", "frozen_pizza": "Pizza surgelée", "frozen_spinach": "Epinards surgelés", @@ -167,6 +176,7 @@ "garlic_granules": "Ail en granulés", "gherkins": "Cornichons", "ginger": "Gingembre", + "ginger_ale": "Bière au gingembre", "glass_noodles": "Nouilles en verre", "gluten": "Gluten", "gnocchi": "Gnocchi", @@ -183,6 +193,8 @@ "hair_gel": "Gel pour cheveux", "hair_ties": "Attaches pour cheveux", "hair_wax": "Cire pour cheveux", + "ham": "Jambon", + "ham_cubes": "Lardons", "hand_soap": "Savon à main", "handkerchief_box": "Boîte à mouchoirs", "handkerchiefs": "Mouchoirs en papier", @@ -192,6 +204,7 @@ "hazelnuts": "Noisettes", "head_of_lettuce": "Tête de laitue", "herb_baguettes": "Baguettes aux herbes", + "herb_butter": "Beurre aux herbes", "herb_cream_cheese": "Fromage frais aux herbes", "honey": "Miel", "honey_wafers": "Gaufres au miel", @@ -208,6 +221,7 @@ "kidney_beans": "Haricots rouges", "kitchen_roll": "Rouleau de cuisine", "kitchen_towels": "Torchons de cuisine", + "kiwi": "Kiwi", "kohlrabi": "Chou-rave", "lasagna": "Lasagnes", "lasagna_noodles": "Nouilles à lasagnes", @@ -227,6 +241,7 @@ "lime": "Lime", "linguine": "Linguine", "lip_care": "Soins des lèvres", + "liqueur": "Liqueur", "low-fat_curd_cheese": "Fromage blanc à faible teneur en matières grasses", "maggi": "Maggi", "magnesium": "Magnésium", @@ -257,6 +272,7 @@ "mushrooms": "Champignons", "mustard": "Moutarde", "nail_file": "Lime à ongles", + "nail_polish_remover": "Dissolvant", "neutral_oil": "Huile neutre", "nori_sheets": "Feuilles de nori", "nutmeg": "Noix de muscade", @@ -277,6 +293,7 @@ "organic_waste_bags": "Sacs à déchets organiques", "pak_choi": "Pak Choi", "pantyhose": "Collants", + "papaya": "Papaye", "paprika": "Paprika", "paprika_seasoning": "Assaisonnement au paprika", "pardina_lentils_dried": "Lentilles Pardina séchées", @@ -377,6 +394,7 @@ "shower_gel": "Gel douche", "shredded_cheese": "Fromage râpé", "sieved_tomatoes": "Tomates tamisées", + "skyr": "Skyr", "sliced_cheese": "Fromage en tranches", "smoked_paprika": "Paprika fumé", "smoked_tofu": "Tofu fumé", @@ -388,7 +406,7 @@ "sour_cream": "Crème aigre", "sour_cucumbers": "Concombres aigres", "soy_cream": "Crème de soja", - "soy_hack": "Le soja", + "soy_hack": "Soja haché", "soy_sauce": "Sauce soja", "soy_shred": "Effilochage de soja", "spaetzle": "Spaetzle", @@ -453,6 +471,7 @@ "vinegar": "Vinaigre", "vitamin_tablets": "Comprimés de vitamines", "vodka": "Vodka", + "walnuts": "Noix", "washing_gel": "Gel de lavage", "washing_powder": "Poudre à laver", "water": "Eau", From 949372db736a138ec7eb253d9d4cca0d73413e65 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 16 Nov 2023 15:45:02 +0100 Subject: [PATCH 449/496] Prepare release 86 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index c0270ebf..37ba5d6e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -28,7 +28,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 85 +BACKEND_VERSION = 86 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 1699682a339c3c857a861041255b74dc9c0b42cc Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 16 Nov 2023 18:45:37 +0100 Subject: [PATCH 450/496] fix: OIDC --- backend/app/controller/auth/auth_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index c1ee15e4..7251e31f 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -201,7 +201,7 @@ def getOIDCLoginUrl(args): state = rndstr() nonce = rndstr() redirect_uri = ( - "kitchenowl://" if args["kitchenowl_scheme"] else FRONT_URL + "kitchenowl://" if "kitchenowl_scheme" in args and args["kitchenowl_scheme"] else FRONT_URL ) + "/signin/redirect" args = { "client_id": client.client_id, From 83265e443f00c9c214637952729a4116439fbbf8 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 16 Nov 2023 18:45:48 +0100 Subject: [PATCH 451/496] Prepare release 87 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 37ba5d6e..cbddebc5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -28,7 +28,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 86 +BACKEND_VERSION = 87 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From ba01b608762776c0a12924edf7b75a78a06cb1de Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 21 Nov 2023 10:22:52 +0100 Subject: [PATCH 452/496] fix: Use preferred username on OIDC account creation --- backend/app/controller/auth/auth_controller.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 7251e31f..f85d1b58 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -201,7 +201,9 @@ def getOIDCLoginUrl(args): state = rndstr() nonce = rndstr() redirect_uri = ( - "kitchenowl://" if "kitchenowl_scheme" in args and args["kitchenowl_scheme"] else FRONT_URL + "kitchenowl://" + if "kitchenowl_scheme" in args and args["kitchenowl_scheme"] + else FRONT_URL ) + "/signin/redirect" args = { "client_id": client.client_id, @@ -312,14 +314,20 @@ def loginWithOIDC(args): return "Request invalid: email", 400 username = ( - userinfo["name"].lower().replace(" ", "").replace("@", "") - if "name" in userinfo + userinfo["preferred_username"].lower().replace(" ", "").replace("@", "") + if "preferred_username" in userinfo else None ) if not username or User.find_by_username(username): - username = userinfo["sub"].lower().replace(" ", "").replace("@", "") + username = ( + userinfo["name"].lower().replace(" ", "").replace("@", "") + if "name" in userinfo + else None + ) if not username or User.find_by_username(username): - username = uuid.uuid4().hex + username = userinfo["sub"].lower().replace(" ", "").replace("@", "") + if not username or User.find_by_username(username): + username = uuid.uuid4().hex newUser = User( username=username, name=userinfo["name"].strip() From 60185194929a418bec30e32c3f976faf107be18c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 21 Nov 2023 11:35:16 +0100 Subject: [PATCH 453/496] fix: Update analytics endpoint --- backend/app/controller/analytics/analytics_controller.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/app/controller/analytics/analytics_controller.py b/backend/app/controller/analytics/analytics_controller.py index 97a41f54..530a67f7 100644 --- a/backend/app/controller/analytics/analytics_controller.py +++ b/backend/app/controller/analytics/analytics_controller.py @@ -26,5 +26,9 @@ def getBaseAnalytics(): "total_households": Household.count(), "free_storage": statvfs.f_frsize * statvfs.f_bavail, "available_storage": statvfs.f_frsize * statvfs.f_blocks, + "households": { + "expense_feature": Household.query.filter(Household.expenses_feature == True).count(), + "planner_feature": Household.query.filter(Household.planner_feature == True).count(), + }, } ) From 543c5db0f1cb3aec4fa641e783eb185d6c115d4f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 23 Nov 2023 02:20:44 +0100 Subject: [PATCH 454/496] feat: update expense API --- .../controller/expense/expense_controller.py | 62 ++++++++++++------- backend/app/controller/expense/schemas.py | 2 + 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 731b4005..47ffc179 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -33,6 +33,16 @@ def getAllExpenses(args, household_id): filter = [Expense.household_id == household_id] if "startAfterId" in args: filter.append(Expense.id < args["startAfterId"]) + if "startAfterDate" in args: + filter.append( + Expense.date + < datetime.fromtimestamp(args["startAfterDate"] / 1000, timezone.utc) + ) + if "endBeforeDate" in args: + filter.append( + Expense.date + > datetime.fromtimestamp(args["endBeforeDate"] / 1000, timezone.utc) + ) if "view" in args and args["view"] == 1: subquery = ( @@ -212,10 +222,6 @@ def getExpenseCategories(household_id): @authorize_household() @validate_args(GetExpenseOverview) def getExpenseOverview(args, household_id): - categories = list( - map(lambda x: x.id, ExpenseCategory.all_from_household_by_name(household_id)) - ) - categories.append(-1) thisMonthStart = datetime.utcnow().date().replace(day=1) steps = args["steps"] if "steps" in args else 5 @@ -223,7 +229,7 @@ def getExpenseOverview(args, household_id): page = args["page"] if "page" in args and args["page"] != None else 0 factor = 1 - query = ( + by_category_query = ( Expense.query.filter( Expense.household_id == household_id, Expense.exclude_from_statistics == False, @@ -231,6 +237,10 @@ def getExpenseOverview(args, household_id): .group_by(Expense.category_id, ExpenseCategory.id) .join(Expense.category, isouter=True) ) + by_day_query = Expense.query.filter( + Expense.household_id == household_id, + Expense.exclude_from_statistics == False, + ).group_by(func.strftime("%Y-%m-%d", Expense.date)) if "view" in args and args["view"] == 1: filterQuery = ( @@ -259,7 +269,10 @@ def getExpenseOverview(args, household_id): factor = s2.c.factor - query = query.filter(Expense.id.in_(filterQuery)).join(s2) + by_category_query = by_category_query.filter(Expense.id.in_(filterQuery)).join( + s2 + ) + by_day_query = by_day_query.filter(Expense.id.in_(filterQuery)).join(s2) def getFilterForStepAgo(stepAgo: int): start = None @@ -285,27 +298,28 @@ def getFilterForStepAgo(stepAgo: int): def getOverviewForStepAgo(stepAgo: int): return { - (e.id or -1): (float(e.balance) or 0) - for e in query.with_entities( - ExpenseCategory.id.label("id"), - func.sum(Expense.amount * factor).label("balance"), - ) - .filter(*getFilterForStepAgo(stepAgo)) - .all() + "by_category": { + (e.id or -1): (float(e.balance) or 0) + for e in by_category_query.with_entities( + ExpenseCategory.id.label("id"), + func.sum(Expense.amount * factor).label("balance"), + ) + .filter(*getFilterForStepAgo(stepAgo)) + .all() + }, + "by_day": { + e.day: (float(e.balance) or 0) + for e in by_day_query.with_entities( + func.strftime("%Y-%m-%d", Expense.date).label("day"), + func.sum(Expense.amount * factor).label("balance"), + ) + .filter(*getFilterForStepAgo(stepAgo)) + .all() + }, } - value = [ - getOverviewForStepAgo(i) for i in range(page * steps, steps + page * steps) - ] - byStep = { - i - + page - * steps: { - category: (value[i][category] if category in value[i] else 0.0) - for category in categories - } - for i in range(0, steps) + i: getOverviewForStepAgo(i) for i in range(page * steps, steps + page * steps) } return jsonify(byStep) diff --git a/backend/app/controller/expense/schemas.py b/backend/app/controller/expense/schemas.py index ece07bdd..08af3593 100644 --- a/backend/app/controller/expense/schemas.py +++ b/backend/app/controller/expense/schemas.py @@ -13,6 +13,8 @@ def _deserialize(self, 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)) From 6d826589534cc189459a61be5dd407c264039ad1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 23 Nov 2023 13:25:02 +0100 Subject: [PATCH 455/496] fix: expense overview API --- .../controller/expense/expense_controller.py | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 47ffc179..0f3ccd0b 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -1,5 +1,6 @@ import calendar from datetime import datetime, timezone, timedelta +from time import strftime from dateutil.relativedelta import relativedelta from sqlalchemy.sql.expression import desc from sqlalchemy import or_ @@ -237,10 +238,17 @@ def getExpenseOverview(args, household_id): .group_by(Expense.category_id, ExpenseCategory.id) .join(Expense.category, isouter=True) ) - by_day_query = Expense.query.filter( + + groupByStr = "%Y-%m" + if frame < 3: + groupByStr += "-%d" + if frame < 1: + groupByStr += " %H" + + by_subframe_query = Expense.query.filter( Expense.household_id == household_id, Expense.exclude_from_statistics == False, - ).group_by(func.strftime("%Y-%m-%d", Expense.date)) + ).group_by(func.strftime(groupByStr, Expense.date)) if "view" in args and args["view"] == 1: filterQuery = ( @@ -272,23 +280,25 @@ def getExpenseOverview(args, household_id): by_category_query = by_category_query.filter(Expense.id.in_(filterQuery)).join( s2 ) - by_day_query = by_day_query.filter(Expense.id.in_(filterQuery)).join(s2) + by_subframe_query = by_subframe_query.filter(Expense.id.in_(filterQuery)).join( + s2 + ) def getFilterForStepAgo(stepAgo: int): start = None end = None - if frame == 0: + if frame == 0: # daily start = datetime.utcnow().date() - timedelta(days=stepAgo) end = start + timedelta(hours=24) - elif frame == 1: + elif frame == 1: # weekly start = datetime.utcnow().date() - relativedelta( days=7, weekday=calendar.MONDAY, weeks=stepAgo ) end = start + timedelta(days=7) - elif frame == 2: + elif frame == 2: # monthly start = thisMonthStart - relativedelta(months=stepAgo) end = start + relativedelta(months=1) - elif frame == 3: + elif frame == 3: # yearly start = datetime.utcnow().date().replace(day=1, month=1) - relativedelta( years=stepAgo ) @@ -307,10 +317,10 @@ def getOverviewForStepAgo(stepAgo: int): .filter(*getFilterForStepAgo(stepAgo)) .all() }, - "by_day": { + "by_subframe": { e.day: (float(e.balance) or 0) - for e in by_day_query.with_entities( - func.strftime("%Y-%m-%d", Expense.date).label("day"), + for e in by_subframe_query.with_entities( + func.strftime(groupByStr, Expense.date).label("day"), func.sum(Expense.amount * factor).label("balance"), ) .filter(*getFilterForStepAgo(stepAgo)) From 58640f162c29c06d67810ff88f2a59de3e00d881 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 23 Nov 2023 15:25:38 +0100 Subject: [PATCH 456/496] Translated using Weblate (Dutch) (TomBursch/kitchenowl-backend#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 93.1% (461 of 495 strings) Translated using Weblate (German) Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (English) Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (English) Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (Greek) Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (Finnish) Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (Danish) Currently translated at 96.7% (479 of 495 strings) Translated using Weblate (French) Currently translated at 97.5% (483 of 495 strings) Translated using Weblate (German) Currently translated at 97.9% (485 of 495 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (495 of 495 strings) Translated using Weblate (English) Currently translated at 100.0% (495 of 495 strings) Added translation using Weblate (English (Australia)) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/da/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/de/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/el/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/en/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fi/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fr/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nl/ Translation: KitchenOwl/Default Items Co-authored-by: BeardedWatermelon Co-authored-by: Matthias Liffers Co-authored-by: Petri Hämäläinen Co-authored-by: Tom Bursch Co-authored-by: gallegonovato Co-authored-by: nani8ot --- backend/templates/l10n/da.json | 1 + backend/templates/l10n/de.json | 4 ++-- backend/templates/l10n/el.json | 8 ++++---- backend/templates/l10n/en.json | 28 ++++++++++++++-------------- backend/templates/l10n/en_AU.json | 1 + backend/templates/l10n/es.json | 12 ++++++------ backend/templates/l10n/fi.json | 16 ++++++++-------- backend/templates/l10n/fr.json | 2 +- backend/templates/l10n/nl.json | 1 + 9 files changed, 38 insertions(+), 35 deletions(-) create mode 100644 backend/templates/l10n/en_AU.json diff --git a/backend/templates/l10n/da.json b/backend/templates/l10n/da.json index 05d3198b..91c73053 100644 --- a/backend/templates/l10n/da.json +++ b/backend/templates/l10n/da.json @@ -50,6 +50,7 @@ "beetroot": "Rødbeder", "birthday_card": "Fødselsdagskort", "black_beans": "Sorte bønner", + "blister_plaster": "Plaster", "bockwurst": "Bockwurst", "bodywash": "Bodywash", "bread": "Brød", diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index bb913c17..119ef5ef 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -22,7 +22,7 @@ "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Asiatische Eiernudeln", - "asian_noodles": "Asiatische Nudeln", + "asian_noodles": "Nudeln", "asparagus": "Spargel", "aspirin": "Aspirin", "avocado": "Avocado", @@ -65,7 +65,7 @@ "buns": "Buns", "burger_buns": "Burgerbrötchen", "burger_patties": "Burgerpatties", - "burger_sauces": "Burgersaucen", + "burger_sauces": "Burgersauce", "butter": "Butter", "butter_cookies": "Butterkekse", "butternut_squash": "Butternut-Kürbis", diff --git a/backend/templates/l10n/el.json b/backend/templates/l10n/el.json index 4e6efcd7..68652843 100644 --- a/backend/templates/l10n/el.json +++ b/backend/templates/l10n/el.json @@ -6,7 +6,7 @@ "drinks": "🍹 Ποτά", "freezer": "❄️ Κατεψυγμένα", "fruits_vegetables": "🥬 Φρούτα και Λαχανικά", - "grain": "🥟 Είδη Σίτου", + "grain": "🥟 Πάστα και Νούντλς", "hygiene": "🚽 Είδη Υγιεινής", "refrigerated": "💧 Είδη Ψυγείου", "snacks": "🥜 Σνάκ" @@ -22,7 +22,7 @@ "apérol": "Άπερολ", "arugula": "Ρόκα", "asian_egg_noodles": "Ασιάτικα νούντλς αυγού", - "asian_noodles": "Ασιατικά νουντλς", + "asian_noodles": "Νούντλς", "asparagus": "Σπαράγγια", "aspirin": "Ασπιρίνη", "avocado": "Αβοκάντο", @@ -61,7 +61,7 @@ "brown_sugar": "Καστανή ζάχαρη", "brussels_sprouts": "Λαχανάκια Βρυξελλών", "buffalo_mozzarella": "Μοτσαρέλα Buffalo", - "buko": "Μπούκο", + "buko": "Baby καρύδα", "buns": "Ψωμάκια", "burger_buns": "Ψωμάκια Μπέργκερ", "burger_patties": "Μπιφτέκια Μπέργκερ", @@ -83,7 +83,7 @@ "cauliflower": "Κουνουπίδι", "celeriac": "Σελινόριζα", "celery": "Σέλινο", - "cereal_bar": "Μπάρα δημητριακών", + "cereal_bar": "Μπάρα Μουέσλι", "cheddar": "Τσένταρ", "cheese": "Τυρί", "cherry_tomatoes": "Ντοματίνια", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index 9c91f74d..561a5ac9 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -1,12 +1,12 @@ { "categories": { - "bread": "🍞 Bread Goods", - "canned": "🥫 Canned Food", + "bread": "🍞 Baked goods", + "canned": "🥫 Preserved goods", "dairy": "🥛 Dairy", "drinks": "🍹 Drinks", "freezer": "❄️ Freezer", "fruits_vegetables": "🥬 Fruits and vegetables", - "grain": "🥟 Grain Products", + "grain": "🥟 Pasta and noodles", "hygiene": "🚽 Hygiene", "refrigerated": "💧 Refrigerated", "snacks": "🥜 Snacks" @@ -16,21 +16,21 @@ "aioli": "Aioli", "amaretto": "Amaretto", "apple": "Apple", - "apple_pulp": "Apple pulp", - "applesauce": "Applesauce", + "apple_pulp": "Apple puree", + "applesauce": "Apple sauce", "apricots": "Apricots", "apérol": "Apérol", "arugula": "Arugula", "asian_egg_noodles": "Asian egg noodles", - "asian_noodles": "Asian noodles", + "asian_noodles": "Noodles", "asparagus": "Asparagus", "aspirin": "Aspirin", "avocado": "Avocado", - "baby_potatoes": "Triplets", + "baby_potatoes": "Baby potatoes", "baby_spinach": "Baby spinach", "bacon": "Bacon", "baguette": "Baguette", - "bakefish": "Bakefish", + "bakefish": "Baked fish", "baking_cocoa": "Baking cocoa", "baking_mix": "Baking mix", "baking_paper": "Baking paper", @@ -54,18 +54,18 @@ "black_beans": "Black beans", "blister_plaster": "Blister plaster", "bockwurst": "Bockwurst", - "bodywash": "Bodywash", + "bodywash": "Body wash", "bread": "Bread", "breadcrumbs": "Breadcrumbs", "broccoli": "Broccoli", "brown_sugar": "Brown sugar", "brussels_sprouts": "Brussels sprouts", "buffalo_mozzarella": "Buffalo mozzarella", - "buko": "Buko", + "buko": "Young coconut", "buns": "Buns", - "burger_buns": "Burger Buns", - "burger_patties": "Burger Patties", - "burger_sauces": "Burger sauces", + "burger_buns": "Burger buns", + "burger_patties": "Hamburger patties", + "burger_sauces": "Hamburger sauce", "butter": "Butter", "butter_cookies": "Butter cookies", "butternut_squash": "Butternut squash", @@ -83,7 +83,7 @@ "cauliflower": "Cauliflower", "celeriac": "Celeriac", "celery": "Celery", - "cereal_bar": "Cereal bar", + "cereal_bar": "Muesli bar", "cheddar": "Cheddar", "cheese": "Cheese", "cherry_tomatoes": "Cherry tomatoes", diff --git a/backend/templates/l10n/en_AU.json b/backend/templates/l10n/en_AU.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/backend/templates/l10n/en_AU.json @@ -0,0 +1 @@ +{} diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index b1696848..2981be00 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -1,12 +1,12 @@ { "categories": { - "bread": "🍞 Productos de panadería", + "bread": "🍞 Productos horneados", "canned": "🥫 Conservas", "dairy": "🥛 Lácteos", "drinks": "🍹 Bebidas", "freezer": "❄️ Congelados", "fruits_vegetables": "🥬 Frutas y verduras", - "grain": "🥟 Productos de cereales", + "grain": "🥟 Pasta y fideos", "hygiene": "🚽 Higiene", "refrigerated": "💧 Refrigerados", "snacks": "🥜 Aperitivos" @@ -22,11 +22,11 @@ "apérol": "Aperol", "arugula": "Rúcula", "asian_egg_noodles": "Fideos asiáticos al huevo", - "asian_noodles": "Fideos asiáticos", + "asian_noodles": "Tallarines", "asparagus": "Espárragos", "aspirin": "Aspirina", "avocado": "Aguacate", - "baby_potatoes": "Trillizos", + "baby_potatoes": "Chats", "baby_spinach": "Espinacas tiernas", "bacon": "Beicon", "baguette": "Baguette", @@ -61,7 +61,7 @@ "brown_sugar": "Azúcar moreno", "brussels_sprouts": "Coles de Bruselas", "buffalo_mozzarella": "Queso Mozzarella de búfala campana", - "buko": "Coco", + "buko": "Coco joven", "buns": "Bollos", "burger_buns": "Pan de hamburguesa", "burger_patties": "Hamburguesas", @@ -83,7 +83,7 @@ "cauliflower": "Coliflor", "celeriac": "Apionabo", "celery": "Apio", - "cereal_bar": "Barra de cereales", + "cereal_bar": "Barra de muesli", "cheddar": "Queso cheddar", "cheese": "Queso", "cherry_tomatoes": "Tomates cherry", diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json index bee5f0d3..d0e42486 100644 --- a/backend/templates/l10n/fi.json +++ b/backend/templates/l10n/fi.json @@ -1,12 +1,12 @@ { "categories": { - "bread": "🍞 Leipätuotteet", + "bread": "🍞 Paistotuotteet", "canned": "🥫 Säilykkeet", "dairy": "🥛 Maitotuotteet", "drinks": "🍹 Juomat", "freezer": "❄️ Pakasteet", "fruits_vegetables": "🥬 Hedelmät ja vihannekset", - "grain": "🥟 Viljatuotteet", + "grain": "Pastat ja nuudelit", "hygiene": "🚽 Hygienia", "refrigerated": "💧 Jääkaappi", "snacks": "🥜 Herkut" @@ -22,7 +22,7 @@ "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Munanuudelit", - "asian_noodles": "Aasialaiset nuudelit", + "asian_noodles": "Nuudelit", "asparagus": "Parsa", "aspirin": "Aspiriini", "avocado": "Avocado", @@ -30,7 +30,7 @@ "baby_spinach": "Babypinaatti", "bacon": "Pekoni", "baguette": "Patonki", - "bakefish": "Paneroitu kala", + "bakefish": "Paistettu kala", "baking_cocoa": "Leivontakaakao", "baking_mix": "Jauhoseos", "baking_paper": "Leivinpaperi", @@ -54,18 +54,18 @@ "black_beans": "Mustapavut", "blister_plaster": "Rakkolaastari", "bockwurst": "Bockwurst", - "bodywash": "Vartalosaippua", + "bodywash": "Suihkusaippua", "bread": "Leipä", "breadcrumbs": "Korppujauhot", "broccoli": "Parsakaali", "brown_sugar": "Fariinisokeri", "brussels_sprouts": "Ruusukaali", "buffalo_mozzarella": "Buffalomozzarella", - "buko": "Buko", + "buko": "Nuori kookospähkinä", "buns": "Sämpylät", "burger_buns": "Hampurilaissämpylät", "burger_patties": "Burgerpihvit", - "burger_sauces": "Hampurilaiskastikkeet", + "burger_sauces": "Hampurilaiskastikke", "butter": "Voi", "butter_cookies": "Voikeksit", "butternut_squash": "Pähkinäkurpitsa", @@ -83,7 +83,7 @@ "cauliflower": "Kukkakaali", "celeriac": "Mukulaselleri", "celery": "Selleri", - "cereal_bar": "Viljapatukka", + "cereal_bar": "Myslipatukka", "cheddar": "Cheddar-juusto", "cheese": "Juusto", "cherry_tomatoes": "Kirsikkatomaatit", diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json index a1c5507f..cf84f394 100644 --- a/backend/templates/l10n/fr.json +++ b/backend/templates/l10n/fr.json @@ -276,7 +276,7 @@ "neutral_oil": "Huile neutre", "nori_sheets": "Feuilles de nori", "nutmeg": "Noix de muscade", - "oat_milk": "Boisson à l'avoine", + "oat_milk": "Lait d'avoine", "oatmeal": "Flocons d'avoine", "oatmeal_cookies": "Biscuits à la farine d'avoine", "oatsome": "Avoine", diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json index 4fe6c9be..8a798ef9 100644 --- a/backend/templates/l10n/nl.json +++ b/backend/templates/l10n/nl.json @@ -12,6 +12,7 @@ "snacks": "🥜 Snacks" }, "items": { + "agave_syrup": "Agave siroop", "aioli": "Aioli", "amaretto": "Amaretto", "apple": "Appel", From 2a87fb3a26d89ba13b56135fe849bd96f124b19d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 23 Nov 2023 15:26:59 +0100 Subject: [PATCH 457/496] fix: Remove buko --- backend/templates/attributes.json | 3 --- backend/templates/l10n/da.json | 1 - backend/templates/l10n/de.json | 1 - backend/templates/l10n/el.json | 1 - backend/templates/l10n/en.json | 1 - backend/templates/l10n/es.json | 1 - backend/templates/l10n/fi.json | 1 - backend/templates/l10n/fr.json | 1 - backend/templates/l10n/hu.json | 1 - backend/templates/l10n/id.json | 1 - backend/templates/l10n/it.json | 1 - backend/templates/l10n/nb_NO.json | 1 - backend/templates/l10n/nl.json | 1 - backend/templates/l10n/pl.json | 1 - backend/templates/l10n/pt.json | 1 - backend/templates/l10n/pt_BR.json | 1 - backend/templates/l10n/ru.json | 1 - backend/templates/l10n/sv.json | 1 - backend/templates/l10n/tr.json | 1 - backend/templates/l10n/zh_Hans.json | 1 - 20 files changed, 22 deletions(-) diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index 125f2b06..4a650ece 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -143,9 +143,6 @@ "buffalo_mozzarella": { "category": "dairy" }, - "buko": { - "category": "dairy" - }, "buns": { "category": "bread" }, diff --git a/backend/templates/l10n/da.json b/backend/templates/l10n/da.json index 91c73053..7fb244a8 100644 --- a/backend/templates/l10n/da.json +++ b/backend/templates/l10n/da.json @@ -59,7 +59,6 @@ "brown_sugar": "Brunt sukker", "brussels_sprouts": "rosenkål", "buffalo_mozzarella": "Buffalo-mozzarella", - "buko": "Buko", "buns": "Boller", "burger_buns": "Burgerboller", "burger_patties": "Burgerpatties", diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index 119ef5ef..290e11a0 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -61,7 +61,6 @@ "brown_sugar": "Brauner Zucker", "brussels_sprouts": "Rosenkohl", "buffalo_mozzarella": "Büffelmozzarella", - "buko": "Buko", "buns": "Buns", "burger_buns": "Burgerbrötchen", "burger_patties": "Burgerpatties", diff --git a/backend/templates/l10n/el.json b/backend/templates/l10n/el.json index 68652843..69ce5c38 100644 --- a/backend/templates/l10n/el.json +++ b/backend/templates/l10n/el.json @@ -61,7 +61,6 @@ "brown_sugar": "Καστανή ζάχαρη", "brussels_sprouts": "Λαχανάκια Βρυξελλών", "buffalo_mozzarella": "Μοτσαρέλα Buffalo", - "buko": "Baby καρύδα", "buns": "Ψωμάκια", "burger_buns": "Ψωμάκια Μπέργκερ", "burger_patties": "Μπιφτέκια Μπέργκερ", diff --git a/backend/templates/l10n/en.json b/backend/templates/l10n/en.json index 561a5ac9..7cb07b38 100644 --- a/backend/templates/l10n/en.json +++ b/backend/templates/l10n/en.json @@ -61,7 +61,6 @@ "brown_sugar": "Brown sugar", "brussels_sprouts": "Brussels sprouts", "buffalo_mozzarella": "Buffalo mozzarella", - "buko": "Young coconut", "buns": "Buns", "burger_buns": "Burger buns", "burger_patties": "Hamburger patties", diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index 2981be00..787ce900 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -61,7 +61,6 @@ "brown_sugar": "Azúcar moreno", "brussels_sprouts": "Coles de Bruselas", "buffalo_mozzarella": "Queso Mozzarella de búfala campana", - "buko": "Coco joven", "buns": "Bollos", "burger_buns": "Pan de hamburguesa", "burger_patties": "Hamburguesas", diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json index d0e42486..629ed4fc 100644 --- a/backend/templates/l10n/fi.json +++ b/backend/templates/l10n/fi.json @@ -61,7 +61,6 @@ "brown_sugar": "Fariinisokeri", "brussels_sprouts": "Ruusukaali", "buffalo_mozzarella": "Buffalomozzarella", - "buko": "Nuori kookospähkinä", "buns": "Sämpylät", "burger_buns": "Hampurilaissämpylät", "burger_patties": "Burgerpihvit", diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json index cf84f394..3e0b285a 100644 --- a/backend/templates/l10n/fr.json +++ b/backend/templates/l10n/fr.json @@ -61,7 +61,6 @@ "brown_sugar": "Sucre brun", "brussels_sprouts": "Choux de Bruxelles", "buffalo_mozzarella": "Mozzarella de buffle", - "buko": "Buko", "buns": "Brioches", "burger_buns": "Pains à burger", "burger_patties": "Galettes pour hamburgers", diff --git a/backend/templates/l10n/hu.json b/backend/templates/l10n/hu.json index 4527c94f..62643ea3 100644 --- a/backend/templates/l10n/hu.json +++ b/backend/templates/l10n/hu.json @@ -57,7 +57,6 @@ "brown_sugar": "Barna cukor", "brussels_sprouts": "Kelbimbó", "buffalo_mozzarella": "Bivaly mozzarella", - "buko": "Buko", "buns": "Zsemle", "burger_buns": "Hamburger zsemle", "burger_patties": "Hamburger hús", diff --git a/backend/templates/l10n/id.json b/backend/templates/l10n/id.json index 8ac1235a..325f3be3 100644 --- a/backend/templates/l10n/id.json +++ b/backend/templates/l10n/id.json @@ -57,7 +57,6 @@ "brown_sugar": "Gula merah", "brussels_sprouts": "Kubis Brussel", "buffalo_mozzarella": "Mozzarella kerbau", - "buko": "Buko", "buns": "Roti", "burger_buns": "Roti Burger", "burger_patties": "Roti Burger", diff --git a/backend/templates/l10n/it.json b/backend/templates/l10n/it.json index 02d28e0a..fd9fdf0e 100644 --- a/backend/templates/l10n/it.json +++ b/backend/templates/l10n/it.json @@ -59,7 +59,6 @@ "brown_sugar": "Zucchero di canna", "brussels_sprouts": "Cavoletti di Bruxelles", "buffalo_mozzarella": "Mozzarella di bufala", - "buko": "Buko", "buns": "Pagnotte", "burger_buns": "Pagnotte per hamburger", "burger_patties": "Hamburger", diff --git a/backend/templates/l10n/nb_NO.json b/backend/templates/l10n/nb_NO.json index def61e4b..eb3c6650 100644 --- a/backend/templates/l10n/nb_NO.json +++ b/backend/templates/l10n/nb_NO.json @@ -57,7 +57,6 @@ "brown_sugar": "Brunt sukker", "brussels_sprouts": "Rosenkål", "buffalo_mozzarella": "Bøffelmozzarella", - "buko": "Buko", "buns": "Boller", "burger_buns": "Burgerboller", "burger_patties": "Burger Patties", diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json index 8a798ef9..934a5320 100644 --- a/backend/templates/l10n/nl.json +++ b/backend/templates/l10n/nl.json @@ -58,7 +58,6 @@ "brown_sugar": "Bruine suiker", "brussels_sprouts": "Spruiten", "buffalo_mozzarella": "Buffel mozzarella", - "buko": "Buko", "buns": "Broodjes", "burger_buns": "Hamburger broodjes", "burger_patties": "Hamburgers", diff --git a/backend/templates/l10n/pl.json b/backend/templates/l10n/pl.json index 71da14d3..b632ebc6 100644 --- a/backend/templates/l10n/pl.json +++ b/backend/templates/l10n/pl.json @@ -57,7 +57,6 @@ "brown_sugar": "Cukier brązowy", "brussels_sprouts": "Brukselka", "buffalo_mozzarella": "Mozzarella wołowa", - "buko": "Buko", "buns": "Bułeczki", "burger_buns": "Bułki do hamburgerów", "burger_patties": "Kotlety do hamburgerów", diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json index fee86d35..180e2ac6 100644 --- a/backend/templates/l10n/pt.json +++ b/backend/templates/l10n/pt.json @@ -57,7 +57,6 @@ "brown_sugar": "Açúcar Amarelo", "brussels_sprouts": "Couve de Bruxelas", "buffalo_mozzarella": "Mozzarella Bufalina", - "buko": "Coco", "buns": "Pãezinhos", "burger_buns": "Pão de Hambúrguer", "burger_patties": "Carne de Hambúrguer", diff --git a/backend/templates/l10n/pt_BR.json b/backend/templates/l10n/pt_BR.json index 05a0e88c..97670bbd 100644 --- a/backend/templates/l10n/pt_BR.json +++ b/backend/templates/l10n/pt_BR.json @@ -57,7 +57,6 @@ "brown_sugar": "Açúcar mascavo", "brussels_sprouts": "Couve-de-bruxelas", "buffalo_mozzarella": "Mozzarella de búfalo", - "buko": "Buko", "buns": "Pãezinhos", "burger_buns": "Pães para hambúrguer", "burger_patties": "Hambúrgueres Patties", diff --git a/backend/templates/l10n/ru.json b/backend/templates/l10n/ru.json index 973afbc0..7b57ed96 100644 --- a/backend/templates/l10n/ru.json +++ b/backend/templates/l10n/ru.json @@ -57,7 +57,6 @@ "brown_sugar": "Коричневый сахар", "brussels_sprouts": "Брюссельская капуста", "buffalo_mozzarella": "Моцарелла Буффало", - "buko": "Буко", "buns": "Булочки", "burger_buns": "Булочки для бургеров", "burger_patties": "Котлеты для бургеров", diff --git a/backend/templates/l10n/sv.json b/backend/templates/l10n/sv.json index e1ca14f9..66668587 100644 --- a/backend/templates/l10n/sv.json +++ b/backend/templates/l10n/sv.json @@ -57,7 +57,6 @@ "brown_sugar": "Brunt socker", "brussels_sprouts": "Brysselkål", "buffalo_mozzarella": "Buffalo mozzarella", - "buko": "Buko", "buns": "Bullar", "burger_buns": "Hamburgare bröd", "burger_patties": "Hamburgare biffar", diff --git a/backend/templates/l10n/tr.json b/backend/templates/l10n/tr.json index be79d3e4..dbda99e8 100644 --- a/backend/templates/l10n/tr.json +++ b/backend/templates/l10n/tr.json @@ -57,7 +57,6 @@ "brown_sugar": "Esmer Şeker", "brussels_sprouts": "Brüksel Lahanası", "buffalo_mozzarella": "Buffalo Mozarella", - "buko": "Buko", "buns": "Poğaça", "burger_buns": "Burger Ekmeği", "burger_patties": "Burger Köftesi", diff --git a/backend/templates/l10n/zh_Hans.json b/backend/templates/l10n/zh_Hans.json index 1d5ca8a9..f02587a4 100644 --- a/backend/templates/l10n/zh_Hans.json +++ b/backend/templates/l10n/zh_Hans.json @@ -57,7 +57,6 @@ "brown_sugar": "红糖", "brussels_sprouts": "抱子甘蓝", "buffalo_mozzarella": "马苏里拉奶酪", - "buko": "Buko", "buns": "馒头", "burger_buns": "汉堡包", "burger_patties": "汉堡馅饼", From 3617ed1b86d06ccef7826cc339186bd29332861e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 23 Nov 2023 15:27:26 +0100 Subject: [PATCH 458/496] chore: upgrade requirements --- backend/requirements.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9370ce29..a6bf6304 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,7 +10,7 @@ bidict==0.22.1 black==23.1a1 blinker==1.7.0 blurhash-python==1.2.1 -certifi==2023.7.22 +certifi==2023.11.17 cffi==1.16.0 charset-normalizer==3.3.2 click==8.1.7 @@ -29,7 +29,7 @@ Flask-JWT-Extended==4.5.3 Flask-Migrate==4.0.5 Flask-SocketIO==5.3.6 Flask-SQLAlchemy==3.1.1 -fonttools==4.44.0 +fonttools==4.45.1 future==0.18.3 gevent==23.9.1 greenlet==3.0.0rc3 @@ -50,7 +50,7 @@ lxml==4.9.3 Mako==1.3.0 MarkupSafe==2.1.3 marshmallow==3.20.1 -matplotlib==3.8.1 +matplotlib==3.8.2 mccabe==0.7.0 mf2py==1.1.3 mlxtend==0.23.0 @@ -64,16 +64,16 @@ pathspec==0.11.2 Pillow==10.1.0 platformdirs==4.0.0 pluggy==1.3.0 -prometheus-client==0.18.0 +prometheus-client==0.19.0 prometheus-flask-exporter==0.23.0 psycopg2-binary==2.9.9 py==1.11.0 pycodestyle==2.11.1 pycparser==2.21 pycryptodomex==3.19.0 -pydantic==2.4.2 -pydantic-settings==2.0.3 -pydantic_core==2.10.1 +pydantic==2.5.2 +pydantic-settings==2.1.0 +pydantic_core==2.14.5 pyflakes==3.1.0 pyjwkest==1.4.2 PyJWT==2.8.0 @@ -94,7 +94,7 @@ recipe-scrapers==14.52.0 regex==2023.10.3 requests==2.31.0 scikit-learn==1.3.2 -scipy==1.11.3 +scipy==1.11.4 setuptools-scm==8.0.4 simple-websocket==1.0.0 six==1.16.0 @@ -112,7 +112,7 @@ types-urllib3==1.26.25.14 typing_extensions==4.8.0 tzdata==2023.3 tzlocal==5.2 -urllib3==2.0.7 +urllib3==2.1.0 uWSGI==2.0.23 uwsgi-tools==1.1.1 w3lib==2.1.2 From b0c1a585ae5f12286ede5813111dd713d388c19d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 23 Nov 2023 15:38:10 +0100 Subject: [PATCH 459/496] Prepare release 88 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index cbddebc5..46b37aa1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -28,7 +28,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 87 +BACKEND_VERSION = 88 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From d192d761ee0596b216ac81b3c9c8210fd1919012 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 27 Nov 2023 13:16:45 +0100 Subject: [PATCH 460/496] feat: Basic OpenAPI documentation endpoint --- backend/app/config.py | 38 +++++++++++++++++++++++++++++++++++++- backend/requirements.txt | 1 + 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 46b37aa1..171bdf36 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -3,6 +3,8 @@ from flask_socketio import SocketIO from sqlalchemy import MetaData from sqlalchemy.engine import URL +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin from prometheus_client import multiprocess from prometheus_client.core import CollectorRegistry from prometheus_flask_exporter import PrometheusMetrics @@ -17,7 +19,7 @@ from oic.oic import Client from oic.oic.message import RegistrationResponse from oic.utils.authn.client import CLIENT_AUTHN_METHOD -from flask import Flask, request +from flask import Flask, jsonify, request from flask_basicauth import BasicAuth from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy @@ -125,6 +127,35 @@ socketio = SocketIO( app, json=app.json, logger=app.logger, cors_allowed_origins=FRONT_URL ) +api_spec = APISpec( + title="KitchenOwl", + version="v" + str(BACKEND_VERSION), + openapi_version="3.0.2", + info={ + "description": "WIP KitchenOwl API documentation", + "termsOfService": "https://kitchenowl.org/privacy/", + "contact": { + "name": "API Support", + "url": "https://kitchenowl.org/imprint/", + "email": "support@kitchenowl.org", + }, + "license": { + "name": "AGPL 3.0", + "url": "https://github.com/TomBursch/kitchenowl/blob/main/LICENSE", + }, + }, + servers=[ + { + "url": "https://app.kitchenowl.org/api", + "description": "Official KitchenOwl server instance", + } + ], + externalDocs={ + "description": "Find more info at the official documentation", + "url": "https://docs.kitchenowl.org", + }, + plugins=[MarshmallowPlugin()], +) oidc_clients = {} if FRONT_URL: if OIDC_CLIENT_ID and OIDC_CLIENT_SECRET and OIDC_ISSUER: @@ -225,3 +256,8 @@ def not_found(error): @socketio.on_error_default def default_socket_error_handler(e): app.logger.error(e) + + +@app.route("/api/openapi", methods=["GET"]) +def swagger(): + return jsonify(api_spec.to_dict()) diff --git a/backend/requirements.txt b/backend/requirements.txt index a6bf6304..67e2ce39 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,6 @@ alembic==1.12.1 annotated-types==0.6.0 +apispec==6.3.0 appdirs==1.4.4 APScheduler==3.10.4 attrs==23.1.0 From b19b4ae3d5f7f8c3267ca240ddaf0ece070c1593 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 27 Nov 2023 15:02:28 +0100 Subject: [PATCH 461/496] fix: update analytics endpoint --- .../analytics/analytics_controller.py | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/backend/app/controller/analytics/analytics_controller.py b/backend/app/controller/analytics/analytics_controller.py index 530a67f7..658ccc73 100644 --- a/backend/app/controller/analytics/analytics_controller.py +++ b/backend/app/controller/analytics/analytics_controller.py @@ -1,7 +1,8 @@ +from datetime import datetime import os from app.helpers import server_admin_required -from app.models import User, Token, Household -from app.config import UPLOAD_FOLDER +from app.models import User, Token, Household, OIDCLink +from app.config import JWT_REFRESH_TOKEN_EXPIRES, UPLOAD_FOLDER from app import db from flask import jsonify, Blueprint from flask_jwt_extended import jwt_required @@ -17,18 +18,47 @@ def getBaseAnalytics(): statvfs = os.statvfs(UPLOAD_FOLDER) return jsonify( { - "total_users": User.count(), - "verified_users": User.query.filter(User.email_verified == True).count(), - "active_users": db.session.query(Token.user_id) - .filter(Token.type == "refresh") - .group_by(Token.user_id) - .count(), - "total_households": Household.count(), + "users": { + "total": User.count(), + "verified": User.query.filter(User.email_verified == True).count(), + "active": db.session.query(Token.user_id) + .filter(Token.type == "refresh") + .group_by(Token.user_id) + .count(), + "online": db.session.query(Token.user_id) + .filter(Token.type == "access") + .group_by(Token.user_id) + .count(), + "old": User.query.filter( + User.created_at <= datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES + ).count(), + "old_active": User.query.filter( + User.created_at <= datetime.utcnow() - JWT_REFRESH_TOKEN_EXPIRES + ) + .filter( + User.id.in_( + db.session.query(Token.user_id) + .filter(Token.type == "refresh") + .group_by(Token.user_id) + .subquery() + .select() + ) + ) + .count(), + "linked_account": db.session.query(OIDCLink) + .group_by(OIDCLink.user_id) + .count(), + }, "free_storage": statvfs.f_frsize * statvfs.f_bavail, "available_storage": statvfs.f_frsize * statvfs.f_blocks, "households": { - "expense_feature": Household.query.filter(Household.expenses_feature == True).count(), - "planner_feature": Household.query.filter(Household.planner_feature == True).count(), + "total": Household.count(), + "expense_feature": Household.query.filter( + Household.expenses_feature == True + ).count(), + "planner_feature": Household.query.filter( + Household.planner_feature == True + ).count(), }, } ) From ddc82f1b47c31df6c089a4dedb551e23b4771978 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 29 Nov 2023 19:19:35 +0100 Subject: [PATCH 462/496] feat: support Message broker --- backend/app/__init__.py | 2 +- backend/app/config.py | 35 +++++++++-- backend/app/jobs/jobs.py | 98 ++++++++++++++++++++--------- backend/docker-compose-rabbitmq.yml | 27 ++++++++ backend/requirements.txt | 10 +++ backend/wsgi.ini | 6 +- backend/wsgi.py | 2 +- 7 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 backend/docker-compose-rabbitmq.yml diff --git a/backend/app/__init__.py b/backend/app/__init__.py index ee343e34..86516729 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,4 +1,4 @@ -from app.config import app, jwt, socketio +from app.config import app, jwt, socketio, celery_app from app.config import db from app.config import scheduler from app.controller import * diff --git a/backend/app/config.py b/backend/app/config.py index 171bdf36..e5919e25 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,5 +1,6 @@ from datetime import timedelta from http import client +from celery import Celery, Task from flask_socketio import SocketIO from sqlalchemy import MetaData from sqlalchemy.engine import URL @@ -54,6 +55,7 @@ host=os.getenv("DB_HOST"), database=os.getenv("DB_NAME", STORAGE_PATH + "/database.db"), ) +MESSAGE_BROKER = os.getenv("MESSAGE_BROKER") JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) @@ -125,7 +127,11 @@ bcrypt = Bcrypt(app) jwt = JWTManager(app) socketio = SocketIO( - app, json=app.json, logger=app.logger, cors_allowed_origins=FRONT_URL + app, + json=app.json, + logger=app.logger, + cors_allowed_origins=FRONT_URL, + message_queue=MESSAGE_BROKER, ) api_spec = APISpec( title="KitchenOwl", @@ -202,11 +208,28 @@ ) metrics.info("app_info", "Application info", version=BACKEND_VERSION) -scheduler = APScheduler() -# enable for debugging jobs: ../scheduler/jobs to see scheduled jobs -scheduler.api_enabled = False -scheduler.init_app(app) -scheduler.start() +scheduler = None +celery_app = None +if not MESSAGE_BROKER: + scheduler = APScheduler() + scheduler.api_enabled = False + scheduler.init_app(app) + scheduler.start() +else: + + class FlaskTask(Task): + def __call__(self, *args: object, **kwargs: object) -> object: + with app.app_context(): + return self.run(*args, **kwargs) + + celery_app = Celery( + app.name + "_tasks", + broker=MESSAGE_BROKER, + task_cls=FlaskTask, + task_ignore_result=True, + ) + celery_app.set_default() + app.extensions["celery"] = celery_app @app.after_request diff --git a/backend/app/jobs/jobs.py b/backend/app/jobs/jobs.py index f5c17616..73bf6dc6 100644 --- a/backend/app/jobs/jobs.py +++ b/backend/app/jobs/jobs.py @@ -1,41 +1,77 @@ +from datetime import timedelta from app.jobs.recipe_suggestions import computeRecipeSuggestions -from app import app, scheduler -from app.models import Token, Household, Shoppinglist, Recipe, ChallengePasswordReset, OIDCRequest +from app.config import app, scheduler, celery_app, MESSAGE_BROKER +from celery.schedules import crontab +from app.models import ( + Token, + Household, + Shoppinglist, + Recipe, + ChallengePasswordReset, + OIDCRequest, +) from .item_ordering import findItemOrdering from .item_suggestions import findItemSuggestions from .cluster_shoppings import clusterShoppings -# # for debugging run: FLASK_DEBUG=True python manage.py +if not MESSAGE_BROKER: + + @scheduler.task("cron", id="everyDay", day_of_week="*", hour="3", minute="0") + def setup_daily(): + with app.app_context(): + daily() + + @scheduler.task("interval", id="every30min", minutes=30) + def setup_halfHourly(): + with app.app_context(): + halfHourly() + +else: + @celery_app.task + def dailyTask(): + daily() + + @celery_app.task + def halfHourlyTask(): + halfHourly() + + @celery_app.on_after_configure.connect + def setup_periodic_tasks(sender, **kwargs): + sender.add_periodic_task( + timedelta(minutes=30), halfHourlyTask, name="every30min" + ) + + sender.add_periodic_task( + crontab(day_of_week="*", hour=3, minute=0), + dailyTask, + name="everyDay", + ) -@scheduler.task("cron", id="everyDay", day_of_week="*", hour="3") def daily(): - with app.app_context(): - app.logger.info("--- daily analysis is starting ---") - # task for all households - for household in Household.all(): - # shopping tasks - shopping_instances = clusterShoppings( - Shoppinglist.query.filter(Shoppinglist.household_id == household.id) - .first() - .id - ) - if shopping_instances: - findItemOrdering(shopping_instances) - findItemSuggestions(shopping_instances) - # recipe planner tasks - computeRecipeSuggestions(household.id) - Recipe.compute_suggestion_ranking(household.id) - - app.logger.info("--- daily analysis is completed ---") - - -@scheduler.task("interval", id="every30min", minutes=30) + app.logger.info("--- daily analysis is starting ---") + # task for all households + for household in Household.all(): + # shopping tasks + shopping_instances = clusterShoppings( + Shoppinglist.query.filter(Shoppinglist.household_id == household.id) + .first() + .id + ) + if shopping_instances: + findItemOrdering(shopping_instances) + findItemSuggestions(shopping_instances) + # recipe planner tasks + computeRecipeSuggestions(household.id) + Recipe.compute_suggestion_ranking(household.id) + + app.logger.info("--- daily analysis is completed ---") + + def halfHourly(): - with app.app_context(): - # Remove expired Tokens - Token.delete_expired_access() - Token.delete_expired_refresh() - ChallengePasswordReset.delete_expired() - OIDCRequest.delete_expired() + # Remove expired Tokens + Token.delete_expired_access() + Token.delete_expired_refresh() + ChallengePasswordReset.delete_expired() + OIDCRequest.delete_expired() diff --git a/backend/docker-compose-rabbitmq.yml b/backend/docker-compose-rabbitmq.yml new file mode 100644 index 00000000..d679fb80 --- /dev/null +++ b/backend/docker-compose-rabbitmq.yml @@ -0,0 +1,27 @@ +version: "3" +services: + front: + image: tombursch/kitchenowl-web:latest + restart: unless-stopped + # environment: + # - BACK_URL=back:5000 # Change this if you rename the containers + ports: + - "80:80" + depends_on: + - back + back: + image: tombursch/kitchenowl:latest + restart: unless-stopped + command: --ini wsgi.ini:celery --gevent 100 + environment: + - JWT_SECRET_KEY=PLEASE_CHANGE_ME + - MESSAGE_BROKER="amqp://rabbitmq" + volumes: + - kitchenowl_data:/data + rabbitmq: + image: rabbitmq:3 + volumes: + - ~/.docker-conf/rabbitmq/data/:/var/lib/rabbitmq/ + +volumes: + kitchenowl_data: diff --git a/backend/requirements.txt b/backend/requirements.txt index 67e2ce39..3256d8d9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,5 @@ alembic==1.12.1 +amqp==5.2.0 annotated-types==0.6.0 apispec==6.3.0 appdirs==1.4.4 @@ -8,13 +9,18 @@ autopep8==2.0.4 bcrypt==4.0.1 beautifulsoup4==4.12.2 bidict==0.22.1 +billiard==4.2.0 black==23.1a1 blinker==1.7.0 blurhash-python==1.2.1 +celery==5.3.6 certifi==2023.11.17 cffi==1.16.0 charset-normalizer==3.3.2 click==8.1.7 +click-didyoumean==0.3.0 +click-plugins==1.1.1 +click-repl==0.3.0 contourpy==1.2.0 cryptography==41.0.5 cycler==0.12.1 @@ -46,6 +52,7 @@ Jinja2==3.1.2 joblib==1.3.2 jstyleson==0.0.2 kiwisolver==1.4.5 +kombu==5.3.4 lark==1.1.8 lxml==4.9.3 Mako==1.3.0 @@ -67,6 +74,7 @@ platformdirs==4.0.0 pluggy==1.3.0 prometheus-client==0.19.0 prometheus-flask-exporter==0.23.0 +prompt-toolkit==3.0.41 psycopg2-binary==2.9.9 py==1.11.0 pycodestyle==2.11.1 @@ -116,7 +124,9 @@ tzlocal==5.2 urllib3==2.1.0 uWSGI==2.0.23 uwsgi-tools==1.1.1 +vine==5.1.0 w3lib==2.1.2 +wcwidth==0.2.12 webencodings==0.5.1 Werkzeug==3.0.1 wsproto==1.2.0 diff --git a/backend/wsgi.ini b/backend/wsgi.ini index a01bb792..9294c3ee 100644 --- a/backend/wsgi.ini +++ b/backend/wsgi.ini @@ -13,4 +13,8 @@ chmod-socket = 664 wsgi-file = wsgi.py callable = app socket = [::]:5000 -procname-prefix-spaced = kitchenowl \ No newline at end of file +procname-prefix-spaced = kitchenowl + +[celery] +ini = :uwsgi +smart-attach-daemon = /tmp/celery.pid celery -A app.celery_app worker -B --pidfile=/tmp/celery.pid \ No newline at end of file diff --git a/backend/wsgi.py b/backend/wsgi.py index 9a31af33..7b8eb729 100644 --- a/backend/wsgi.py +++ b/backend/wsgi.py @@ -1,7 +1,7 @@ import gevent.monkey gevent.monkey.patch_all() -from app import app, socketio +from app import app, socketio, celery_app import os from app.config import UPLOAD_FOLDER From e5169f13f8aec351a2278c7cd186d5ce14109bb1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 29 Nov 2023 19:19:52 +0100 Subject: [PATCH 463/496] feat: Add background tasks --- .../app/controller/auth/auth_controller.py | 5 +- .../household/household_controller.py | 5 +- .../app/controller/user/user_controller.py | 7 +- backend/app/service/import_language.py | 129 +++++++++--------- backend/upgrade_default_items.py | 11 +- 5 files changed, 82 insertions(+), 75 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index f85d1b58..97c7bd59 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -1,6 +1,7 @@ from datetime import datetime import re import uuid +import gevent from oic import rndstr from oic.oic.message import AuthorizationResponse @@ -90,8 +91,8 @@ def signup(args): password=args["password"], email=args["email"] if "email" in args else None, ) - if mail.mailConfigured(): - mail.sendVerificationMail(user, ChallengeMailVerify.create_challenge(user)) + if "email" in args and mail.mailConfigured(): + gevent.spawn(mail.sendVerificationMail, user, ChallengeMailVerify.create_challenge(user)) device = "Unkown" if "device" in args: diff --git a/backend/app/controller/household/household_controller.py b/backend/app/controller/household/household_controller.py index 56c51d75..f1f08530 100644 --- a/backend/app/controller/household/household_controller.py +++ b/backend/app/controller/household/household_controller.py @@ -1,3 +1,4 @@ +import gevent from app.config import SUPPORTED_LANGUAGES from app.helpers import validate_args, authorize_household, RequiredRights from flask import jsonify, Blueprint @@ -71,7 +72,7 @@ def addHousehold(args): Shoppinglist(name="Default", household_id=household.id).save() if household.language: - importLanguage(household.id, household.language, True) + gevent.spawn(importLanguage, household.id, household.language, True) return jsonify(household.obj_to_dict()) @@ -95,7 +96,7 @@ def updateHousehold(args, household_id): and args["language"] in SUPPORTED_LANGUAGES ): household.language = args["language"] - importLanguage(household.id, household.language) + gevent.spawn(importLanguage, household.id, household.language) if "planner_feature" in args: household.planner_feature = args["planner_feature"] if "expenses_feature" in args: diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index fa4889a6..479d10b7 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -1,3 +1,4 @@ +import gevent from app.errors import NotFoundRequest, UnauthorizedRequest from app.helpers.server_admin_required import server_admin_required from app.helpers import validate_args @@ -74,11 +75,13 @@ def updateUser(args): 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()): + return "Request invalid: email", 400 user.email = args["email"].strip() user.email_verified = False ChallengeMailVerify.delete_by_user(user) if mail.mailConfigured(): - mail.sendVerificationMail(user, ChallengeMailVerify.create_challenge(user)) + gevent.spawn(mail.sendVerificationMail, user, ChallengeMailVerify.create_challenge(user)) if "photo" in args and user.photo != args["photo"]: user.photo = file_has_access_or_download(args["photo"], user.photo) user.save() @@ -98,6 +101,8 @@ def updateUserById(args, id): 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()): + return "Request invalid: email", 400 user.email = args["email"].strip() user.email_verified = True ChallengeMailVerify.delete_by_user(user) diff --git a/backend/app/service/import_language.py b/backend/app/service/import_language.py index 06a06ee2..45e8a395 100644 --- a/backend/app/service/import_language.py +++ b/backend/app/service/import_language.py @@ -8,73 +8,74 @@ def importLanguage(household_id, lang, bulkSave=False): - file_path = f"{APP_DIR}/../templates/l10n/{lang}.json" - if lang not in SUPPORTED_LANGUAGES or not exists(file_path): - raise NotFoundRequest("Language code not supported") - with open(file_path, "r") as f: - data = json.load(f) - with open(f"{APP_DIR}/../templates/attributes.json", "r") as f: - attributes = json.load(f) + with app.app_context(): + file_path = f"{APP_DIR}/../templates/l10n/{lang}.json" + if lang not in SUPPORTED_LANGUAGES or not exists(file_path): + raise NotFoundRequest("Language code not supported") + with open(file_path, "r") as f: + data = json.load(f) + with open(f"{APP_DIR}/../templates/attributes.json", "r") as f: + attributes = json.load(f) - t0 = time.time() - models: list[Item] = [] - for key, name in data["items"].items(): - item = Item.find_by_default_key(household_id, key) or Item.find_by_name( - household_id, name - ) - if not item: - # needed to filter out duplicate names - if bulkSave and any(i.name == name for i in models): - continue - item = Item() - item.name = name.strip() - item.household_id = household_id - item.default = True - item.default_key = key + t0 = time.time() + models: list[Item] = [] + for key, name in data["items"].items(): + item = Item.find_by_default_key(household_id, key) or Item.find_by_name( + household_id, name + ) + if not item: + # needed to filter out duplicate names + if bulkSave and any(i.name == name for i in models): + continue + item = Item() + item.name = name.strip() + item.household_id = household_id + item.default = True + item.default_key = key - if not item.default_key: # migrate to new system - item.default_key = key + if not item.default_key: # migrate to new system + item.default_key = key - if item.default: - if ( - item.name != name.strip() - and not Item.find_by_name(household_id, name) - and not any(i.name == name for i in models) - ): - item.name = name.strip() + if item.default: + if ( + item.name != name.strip() + and not Item.find_by_name(household_id, name) + and not any(i.name == name for i in models) + ): + item.name = name.strip() - if key in attributes["items"] and "icon" in attributes["items"][key]: - item.icon = attributes["items"][key]["icon"] + if key in attributes["items"] and "icon" in attributes["items"][key]: + item.icon = attributes["items"][key]["icon"] - # Category not already set for existing item and category set for template and category translation exist for language - if ( - key in attributes["items"] - and "category" in attributes["items"][key] - and attributes["items"][key]["category"] in data["categories"] - ): - category_key = attributes["items"][key]["category"] - category_name = data["categories"][category_key] - category = Category.find_by_default_key( - household_id, category_key - ) or Category.find_by_name(household_id, category_name) - if not category: - category = Category.create_by_name( - household_id, category_name, True, category_key - ) - if not category.default_key: # migrate to new system - category.default_key = category_key - category.save() - item.category = category - if not bulkSave: - item.save(keepDefault=True) - else: - models.append(item) + # Category not already set for existing item and category set for template and category translation exist for language + if ( + key in attributes["items"] + and "category" in attributes["items"][key] + and attributes["items"][key]["category"] in data["categories"] + ): + category_key = attributes["items"][key]["category"] + category_name = data["categories"][category_key] + category = Category.find_by_default_key( + household_id, category_key + ) or Category.find_by_name(household_id, category_name) + if not category: + category = Category.create_by_name( + household_id, category_name, True, category_key + ) + if not category.default_key: # migrate to new system + category.default_key = category_key + category.save() + item.category = category + if not bulkSave: + item.save(keepDefault=True) + else: + models.append(item) - if bulkSave: - try: - db.session.add_all(models) - db.session.commit() - except Exception as e: - db.session.rollback() - raise e - app.logger.info(f"Import took: {(time.time() - t0):.3f}s") + if bulkSave: + try: + db.session.add_all(models) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + app.logger.info(f"Import took: {(time.time() - t0):.3f}s") diff --git a/backend/upgrade_default_items.py b/backend/upgrade_default_items.py index 4919619e..abe99964 100644 --- a/backend/upgrade_default_items.py +++ b/backend/upgrade_default_items.py @@ -6,9 +6,8 @@ if __name__ == "__main__": - with app.app_context(): - for household in tqdm(Household.query.filter(Household.language != None).all(), desc="Upgrading households..."): - try: - importLanguage(household.id, household.language, bulkSave=True) - except NotFoundRequest: - pass + for household in tqdm(Household.query.filter(Household.language != None).all(), desc="Upgrading households..."): + try: + importLanguage(household.id, household.language, bulkSave=True) + except NotFoundRequest: + pass From 9cbeca2fba26e1c5873b7bfaf55e1840d6268803 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 29 Nov 2023 19:42:54 +0100 Subject: [PATCH 464/496] fix: Expense overview for PostgreSQL --- .../controller/expense/expense_controller.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/app/controller/expense/expense_controller.py b/backend/app/controller/expense/expense_controller.py index 0f3ccd0b..740237cd 100644 --- a/backend/app/controller/expense/expense_controller.py +++ b/backend/app/controller/expense/expense_controller.py @@ -1,6 +1,5 @@ import calendar from datetime import datetime, timezone, timedelta -from time import strftime from dateutil.relativedelta import relativedelta from sqlalchemy.sql.expression import desc from sqlalchemy import or_ @@ -239,16 +238,20 @@ def getExpenseOverview(args, household_id): .join(Expense.category, isouter=True) ) - groupByStr = "%Y-%m" + groupByStr = "YYYY-MM" if "postgresql" in db.engine.name else "%Y-%m" if frame < 3: - groupByStr += "-%d" + groupByStr += "-DD" if "postgresql" in db.engine.name else "-%d" if frame < 1: - groupByStr += " %H" + groupByStr += " HH24" if "postgresql" in db.engine.name else" %H" by_subframe_query = Expense.query.filter( Expense.household_id == household_id, Expense.exclude_from_statistics == False, - ).group_by(func.strftime(groupByStr, Expense.date)) + ).group_by( + func.to_char(Expense.date, groupByStr).label("day") + if "postgresql" in db.engine.name + else func.strftime(groupByStr, Expense.date) + ) if "view" in args and args["view"] == 1: filterQuery = ( @@ -320,7 +323,9 @@ def getOverviewForStepAgo(stepAgo: int): "by_subframe": { e.day: (float(e.balance) or 0) for e in by_subframe_query.with_entities( - func.strftime(groupByStr, Expense.date).label("day"), + func.to_char(Expense.date, groupByStr).label("day") + if "postgresql" in db.engine.name + else func.strftime(groupByStr, Expense.date).label("day"), func.sum(Expense.amount * factor).label("balance"), ) .filter(*getFilterForStepAgo(stepAgo)) From e4930fd0d728efbddf4636db3ba631b9a560aa65 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 29 Nov 2023 20:57:02 +0100 Subject: [PATCH 465/496] Prepare release 89 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index e5919e25..ef9e2678 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -31,7 +31,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 88 +BACKEND_VERSION = 89 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 81d8f06f7d96236a882065012258001d4a9bc1d7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 30 Nov 2023 10:54:25 +0100 Subject: [PATCH 466/496] fix: send mail in background --- backend/app/controller/auth/auth_controller.py | 2 +- backend/app/controller/user/user_controller.py | 4 ++-- backend/app/service/mail.py | 3 ++- backend/manage.py | 11 +++++++---- backend/upgrade_default_items.py | 1 - 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/backend/app/controller/auth/auth_controller.py b/backend/app/controller/auth/auth_controller.py index 97c7bd59..e6bb7031 100644 --- a/backend/app/controller/auth/auth_controller.py +++ b/backend/app/controller/auth/auth_controller.py @@ -92,7 +92,7 @@ def signup(args): email=args["email"] if "email" in args else None, ) if "email" in args and mail.mailConfigured(): - gevent.spawn(mail.sendVerificationMail, user, ChallengeMailVerify.create_challenge(user)) + gevent.spawn(mail.sendVerificationMail, user.id, ChallengeMailVerify.create_challenge(user)) device = "Unkown" if "device" in args: diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 479d10b7..56b0d962 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -81,7 +81,7 @@ def updateUser(args): user.email_verified = False ChallengeMailVerify.delete_by_user(user) if mail.mailConfigured(): - gevent.spawn(mail.sendVerificationMail, user, ChallengeMailVerify.create_challenge(user)) + gevent.spawn(mail.sendVerificationMail, 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) user.save() @@ -146,7 +146,7 @@ def resendVerificationMail(): raise Exception("Mail service not configured") if not user.email_verified: - mail.sendVerificationMail(user, ChallengeMailVerify.create_challenge(user)) + mail.sendVerificationMail(user.id, ChallengeMailVerify.create_challenge(user)) return jsonify({"msg": "DONE"}) diff --git a/backend/app/service/mail.py b/backend/app/service/mail.py index 7f4fbc70..939a60ac 100644 --- a/backend/app/service/mail.py +++ b/backend/app/service/mail.py @@ -48,7 +48,8 @@ def sendMail(to: str, message: MIMEMultipart): server.sendmail(SMTP_FROM, to, message.as_string()) -def sendVerificationMail(user: User, token: str): +def sendVerificationMail(userId: int, token: str): + user = User.find_by_id(userId) if not user.email or not token: return diff --git a/backend/manage.py b/backend/manage.py index 8f75325e..3c8c384b 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -1,7 +1,9 @@ from os import listdir from os.path import isfile, join +import time import blurhash from PIL import Image +from tqdm import tqdm from app import app, db from app.config import UPLOAD_FOLDER from app.jobs import jobs @@ -87,11 +89,12 @@ def manageUsers(): if not mail.mailConfigured(): print("Mail service not configured") continue - print("Sending mails...") - users = User.query.filter(User.email_verified == False).all() - for user in users: + delay = float(input("Delay between mails in seconds (0):") or "0") + for user in tqdm(User.query.filter(User.email_verified == False).all(), desc="Sending mails..."): if len(user.verify_mail_challenge) == 0: - mail.sendVerificationMail(user, ChallengeMailVerify.create_challenge(user)) + mail.sendVerificationMail(user.id, ChallengeMailVerify.create_challenge(user)) + if delay > 0: + time.sleep(delay) else: return diff --git a/backend/upgrade_default_items.py b/backend/upgrade_default_items.py index abe99964..5e01af08 100644 --- a/backend/upgrade_default_items.py +++ b/backend/upgrade_default_items.py @@ -1,5 +1,4 @@ from tqdm import tqdm -from app import app from app.errors import NotFoundRequest from app.models import Household from app.service.import_language import importLanguage From de7c0a8f4c1c094d35a592b7ac18e0dd6ec63719 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 30 Nov 2023 11:08:32 +0100 Subject: [PATCH 467/496] fix: error logging --- backend/app/config.py | 2 +- backend/app/controller/analytics/analytics_controller.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index ef9e2678..4ac9c734 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -267,7 +267,7 @@ def unhandled_exception(e: Exception): if type(e) is MethodNotAllowed: app.logger.warning(e) return "The method is not allowed for the requested URL", 405 - app.logger.error(e) + app.logger.error(e, exc_info=e) return "Something went wrong", 500 diff --git a/backend/app/controller/analytics/analytics_controller.py b/backend/app/controller/analytics/analytics_controller.py index 658ccc73..aee41ec7 100644 --- a/backend/app/controller/analytics/analytics_controller.py +++ b/backend/app/controller/analytics/analytics_controller.py @@ -45,7 +45,7 @@ def getBaseAnalytics(): ) ) .count(), - "linked_account": db.session.query(OIDCLink) + "linked_account": db.session.query(OIDCLink.user_id) .group_by(OIDCLink.user_id) .count(), }, From 94282bd2aacf27a01daf65e5de4900e7a426bf99 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 30 Nov 2023 11:15:48 +0100 Subject: [PATCH 468/496] Translated using Weblate (Spanish) (TomBursch/kitchenowl-backend#65) Currently translated at 100.0% (494 of 494 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/es/ Translation: KitchenOwl/Default Items Co-authored-by: gallegonovato --- backend/templates/l10n/es.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/templates/l10n/es.json b/backend/templates/l10n/es.json index 787ce900..ae4710d3 100644 --- a/backend/templates/l10n/es.json +++ b/backend/templates/l10n/es.json @@ -16,8 +16,8 @@ "aioli": "Alioli", "amaretto": "Amaretto", "apple": "Manzana", - "apple_pulp": "Pulpa de la manzana", - "applesauce": "Compota de manzana", + "apple_pulp": "Puré de manzana", + "applesauce": "Puré de manzana", "apricots": "Albaricoques", "apérol": "Aperol", "arugula": "Rúcula", @@ -26,7 +26,7 @@ "asparagus": "Espárragos", "aspirin": "Aspirina", "avocado": "Aguacate", - "baby_potatoes": "Chats", + "baby_potatoes": "Patatas pequeñas", "baby_spinach": "Espinacas tiernas", "bacon": "Beicon", "baguette": "Baguette", From e2c7dfb054eb8ac580ff760ea780eecf2d72856d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 30 Nov 2023 11:25:49 +0100 Subject: [PATCH 469/496] chore: upgrade requirements --- backend/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 3256d8d9..01a2207f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,7 +6,7 @@ appdirs==1.4.4 APScheduler==3.10.4 attrs==23.1.0 autopep8==2.0.4 -bcrypt==4.0.1 +bcrypt==4.1.1 beautifulsoup4==4.12.2 bidict==0.22.1 billiard==4.2.0 @@ -22,7 +22,7 @@ click-didyoumean==0.3.0 click-plugins==1.1.1 click-repl==0.3.0 contourpy==1.2.0 -cryptography==41.0.5 +cryptography==41.0.7 cycler==0.12.1 dbscan1d==0.2.2 defusedxml==0.7.1 @@ -43,8 +43,8 @@ greenlet==3.0.0rc3 h11==0.14.0 html-text==0.5.2 html5lib==1.1 -idna==3.4 -ingredient-parser-nlp==0.1.0b6 +idna==3.6 +ingredient-parser-nlp==0.1.0b7 iniconfig==2.0.0 isodate==0.6.1 itsdangerous==2.1.2 From 80cee6f2d70142e0e592c99c16cd81c4dc886efa Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 30 Nov 2023 11:26:17 +0100 Subject: [PATCH 470/496] Prepare release 90 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 4ac9c734..4ddc1a74 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -31,7 +31,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 89 +BACKEND_VERSION = 90 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 263ddecca25517e81d61e2c3526b8a3d86a435aa Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 30 Nov 2023 12:33:17 +0100 Subject: [PATCH 471/496] fix: manage send mails --- backend/manage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/manage.py b/backend/manage.py index 3c8c384b..9b82edc3 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -90,7 +90,7 @@ def manageUsers(): print("Mail service not configured") continue delay = float(input("Delay between mails in seconds (0):") or "0") - for user in tqdm(User.query.filter(User.email_verified == False).all(), desc="Sending mails..."): + for user in tqdm(User.query.filter(User.email_verified != True).all(), desc="Sending mails"): if len(user.verify_mail_challenge) == 0: mail.sendVerificationMail(user.id, ChallengeMailVerify.create_challenge(user)) if delay > 0: From 8533de626138ba948d6df53bfe2cc5fa4582d2f5 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 30 Nov 2023 12:37:31 +0100 Subject: [PATCH 472/496] fix: Upgrade default items --- backend/upgrade_default_items.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/upgrade_default_items.py b/backend/upgrade_default_items.py index 5e01af08..ce859ddd 100644 --- a/backend/upgrade_default_items.py +++ b/backend/upgrade_default_items.py @@ -1,12 +1,14 @@ from tqdm import tqdm +from app import app from app.errors import NotFoundRequest from app.models import Household from app.service.import_language import importLanguage if __name__ == "__main__": - for household in tqdm(Household.query.filter(Household.language != None).all(), desc="Upgrading households..."): - try: - importLanguage(household.id, household.language, bulkSave=True) - except NotFoundRequest: - pass + with app.app_context(): + for household in tqdm(Household.query.filter(Household.language != None).all(), desc="Upgrading households"): + try: + importLanguage(household.id, household.language, bulkSave=True) + except NotFoundRequest: + pass From b0bc2ace971d05e315fa3e35aa675261829ddc6d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 30 Nov 2023 12:47:10 +0100 Subject: [PATCH 473/496] Prepare release 91 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 4ddc1a74..7b165d84 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -31,7 +31,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 90 +BACKEND_VERSION = 91 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 17877457c51a9b802a3007912e7f78062233f7da Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 1 Dec 2023 17:42:02 +0100 Subject: [PATCH 474/496] fix: send async verification mail --- backend/app/service/mail.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/backend/app/service/mail.py b/backend/app/service/mail.py index 939a60ac..39163e0e 100644 --- a/backend/app/service/mail.py +++ b/backend/app/service/mail.py @@ -1,7 +1,7 @@ import smtplib, ssl, os from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -from app.config import FRONT_URL +from app.config import app, FRONT_URL from app.models import User SMTP_HOST = os.getenv("SMTP_HOST") @@ -49,24 +49,25 @@ def sendMail(to: str, message: MIMEMultipart): def sendVerificationMail(userId: int, token: str): - user = User.find_by_id(userId) - if not user.email or not token: - return + with app.app_context(): + user = User.find_by_id(userId) + if not user.email or not token: + return - verifyLink = FRONT_URL + "/confirm-email?t=" + token + verifyLink = FRONT_URL + "/confirm-email?t=" + token - message = MIMEMultipart("alternative") - message["Subject"] = "Verify Email" - text = """\ + message = MIMEMultipart("alternative") + message["Subject"] = "Verify Email" + text = """\ Hi {name} (@{username}), Verify your email so we know it's really you and you don't loose access to your account. Verify email address: {link} Have any questions? Check out https://kitchenowl.org/privacy/""".format( - name=user.name, username=user.username, link=verifyLink - ) - html = """\ + name=user.name, username=user.username, link=verifyLink + ) + html = """\

Hi {name} (@{username}),

@@ -78,13 +79,13 @@ def sendVerificationMail(userId: int, token: str):

- """.format( - name=user.name, username=user.username, link=verifyLink - ) - # The email client will try to render the last part first - message.attach(MIMEText(text, "plain")) - message.attach(MIMEText(html, "html")) - sendMail(user.email, message) + """.format( + name=user.name, username=user.username, link=verifyLink + ) + # The email client will try to render the last part first + message.attach(MIMEText(text, "plain")) + message.attach(MIMEText(html, "html")) + sendMail(user.email, message) def sendPasswordResetMail(user: User, token: str): From 88432e7cce6acdb6b7213906275e1fcc2dcab947 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 1 Dec 2023 18:07:55 +0100 Subject: [PATCH 475/496] fix: python manage script --- backend/manage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/manage.py b/backend/manage.py index 9b82edc3..b190c8d4 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -90,7 +90,7 @@ def manageUsers(): print("Mail service not configured") continue delay = float(input("Delay between mails in seconds (0):") or "0") - for user in tqdm(User.query.filter(User.email_verified != True).all(), desc="Sending mails"): + for user in tqdm(User.query.filter((User.email_verified == False) | (User.email_verified == None)).all(), desc="Sending mails"): if len(user.verify_mail_challenge) == 0: mail.sendVerificationMail(user.id, ChallengeMailVerify.create_challenge(user)) if delay > 0: From 36a468a0e0daad4aee07b47c64d79f21c9399476 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 1 Dec 2023 19:14:25 +0100 Subject: [PATCH 476/496] fix: email spelling --- backend/app/service/mail.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/service/mail.py b/backend/app/service/mail.py index 39163e0e..819ff2f7 100644 --- a/backend/app/service/mail.py +++ b/backend/app/service/mail.py @@ -61,7 +61,7 @@ def sendVerificationMail(userId: int, token: str): text = """\ Hi {name} (@{username}), -Verify your email so we know it's really you and you don't loose access to your account. +Verify your email so we know it's really you, and you don't lose access to your account. Verify email address: {link} Have any questions? Check out https://kitchenowl.org/privacy/""".format( @@ -72,7 +72,7 @@ def sendVerificationMail(userId: int, token: str):

Hi {name} (@{username}),

- Verify your email so we know it's really you and you don't loose access to your account.
+ Verify your email so we know it's really you, and you don't lose access to your account.
Verify email address

Have any questions? Check out our Privacy Policy From eca267a5308d85fcb1f29ea0d2171d1d0c267f7d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Mon, 4 Dec 2023 16:29:08 +0100 Subject: [PATCH 477/496] fix: update all user sorting --- backend/app/controller/user/user_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index 56b0d962..f01512de 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -24,7 +24,7 @@ @jwt_required() @server_admin_required() def getAllUsers(): - return jsonify([e.obj_to_dict(include_email=True) for e in User.all_by_name()]) + return jsonify([e.obj_to_dict(include_email=True) for e in User.query.order_by(User.admin, User.username).all()]) @user.route("", methods=["GET"]) From 5d2640abd13c38002c3ff0383e5888d866218d53 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 6 Dec 2023 21:13:29 +0100 Subject: [PATCH 478/496] fix: smaller bugs --- .../shoppinglist/shoppinglist_controller.py | 13 +++++++++++-- backend/app/controller/user/user_controller.py | 3 ++- backend/app/models/item.py | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/app/controller/shoppinglist/shoppinglist_controller.py b/backend/app/controller/shoppinglist/shoppinglist_controller.py index 0abfa531..056b1732 100644 --- a/backend/app/controller/shoppinglist/shoppinglist_controller.py +++ b/backend/app/controller/shoppinglist/shoppinglist_controller.py @@ -79,13 +79,22 @@ def deleteShoppinglist(id): return jsonify({"msg": "DONE"}) -@shoppinglist.route("//item/", methods=["POST"]) +@shoppinglist.route("//item/", methods=["POST", "PUT"]) @jwt_required() @validate_args(UpdateDescription) def updateItemDescription(args, id, item_id): con = ShoppinglistItems.find_by_ids(id, item_id) if not con: - raise NotFoundRequest() + shoppinglist = Shoppinglist.find_by_id(id) + item = Item.find_by_id(item_id) + if not item or not shoppinglist: + raise NotFoundRequest() + if shoppinglist.household_id != item.household_id: + raise InvalidUsage() + con = ShoppinglistItems() + con.shoppinglist = shoppinglist + con.item = item + con.created_by = current_user.id con.shoppinglist.checkAuthorized() con.description = args["description"] or "" diff --git a/backend/app/controller/user/user_controller.py b/backend/app/controller/user/user_controller.py index f01512de..7e476e47 100644 --- a/backend/app/controller/user/user_controller.py +++ b/backend/app/controller/user/user_controller.py @@ -1,4 +1,5 @@ import gevent +from sqlalchemy import desc from app.errors import NotFoundRequest, UnauthorizedRequest from app.helpers.server_admin_required import server_admin_required from app.helpers import validate_args @@ -24,7 +25,7 @@ @jwt_required() @server_admin_required() def getAllUsers(): - return jsonify([e.obj_to_dict(include_email=True) for e in User.query.order_by(User.admin, User.username).all()]) + return jsonify([e.obj_to_dict(include_email=True) for e in User.query.order_by(desc(User.admin), User.username).all()]) @user.route("", methods=["GET"]) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 3a6245d3..963bbaa7 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -145,7 +145,7 @@ def create_by_name( def find_by_name(cls, household_id: int, name: str) -> Self: name = name.strip() return cls.query.filter( - cls.household_id == household_id, func.lower(cls.name) == name.lower() + cls.household_id == household_id, func.lower(cls.name) == func.lower(name) ).first() @classmethod From 2f38464f98438b20c8925e9547ecf117663bf8bf Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 7 Dec 2023 10:25:07 +0100 Subject: [PATCH 479/496] feat: improve SQLite language support --- backend/app/config.py | 14 ++++++++++++++ backend/requirements.txt | 1 + 2 files changed, 15 insertions(+) diff --git a/backend/app/config.py b/backend/app/config.py index 7b165d84..b545a116 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,6 +4,7 @@ from flask_socketio import SocketIO from sqlalchemy import MetaData from sqlalchemy.engine import URL +from sqlalchemy.event import listen from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin from prometheus_client import multiprocess @@ -27,6 +28,7 @@ from flask_bcrypt import Bcrypt from flask_jwt_extended import JWTManager from flask_apscheduler import APScheduler +import sqlite_icu import os @@ -232,6 +234,18 @@ def __call__(self, *args: object, **kwargs: object) -> object: app.extensions["celery"] = celery_app +# Load ICU extension for sqlite +if DB_URL.drivername == "sqlite": + + def load_extension(conn, unused): + conn.enable_load_extension(True) + conn.load_extension(sqlite_icu.extension_path().replace(".so", "")) + conn.enable_load_extension(False) + + with app.app_context(): + listen(db.engine, "connect", load_extension) + + @app.after_request def add_cors_headers(response): if not request.referrer: diff --git a/backend/requirements.txt b/backend/requirements.txt index 01a2207f..7225eaae 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -109,6 +109,7 @@ simple-websocket==1.0.0 six==1.16.0 soupsieve==2.5 SQLAlchemy==2.0.23 +sqlite-icu==1.0 threadpoolctl==3.2.0 toml==0.10.2 tomli==2.0.1 From 99b839c5e6409965ae765e44a03d1a8666041780 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 7 Dec 2023 10:27:28 +0100 Subject: [PATCH 480/496] chore: upgrade requirements --- backend/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7225eaae..99f3847a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -alembic==1.12.1 +alembic==1.13.0 amqp==5.2.0 annotated-types==0.6.0 apispec==6.3.0 @@ -36,7 +36,7 @@ Flask-JWT-Extended==4.5.3 Flask-Migrate==4.0.5 Flask-SocketIO==5.3.6 Flask-SQLAlchemy==3.1.1 -fonttools==4.45.1 +fonttools==4.46.0 future==0.18.3 gevent==23.9.1 greenlet==3.0.0rc3 @@ -70,7 +70,7 @@ packaging==23.2 pandas==2.1.3 pathspec==0.11.2 Pillow==10.1.0 -platformdirs==4.0.0 +platformdirs==4.1.0 pluggy==1.3.0 prometheus-client==0.19.0 prometheus-flask-exporter==0.23.0 @@ -99,7 +99,7 @@ pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 rdflib==7.0.0 rdflib-jsonld==0.6.2 -recipe-scrapers==14.52.0 +recipe-scrapers==14.53.0 regex==2023.10.3 requests==2.31.0 scikit-learn==1.3.2 From 6f5363b04cccf722788b8bc3bcc47cff69ce9a34 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 7 Dec 2023 10:37:10 +0100 Subject: [PATCH 481/496] fix: docker build --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 78a2037c..f291269f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,7 @@ RUN apt-get update \ && apt-get install --yes --no-install-recommends \ gcc g++ libffi-dev libpcre3-dev build-essential cargo \ libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build \ - autoconf automake zlib1g-dev libjpeg62-turbo-dev libssl-dev + autoconf automake zlib1g-dev libjpeg62-turbo-dev libssl-dev libsqlite3-dev # Create virtual enviroment RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel From 00dcf5685e50b3d61e83435dce65b391342cdb5c Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 7 Dec 2023 11:22:32 +0100 Subject: [PATCH 482/496] Translated using Weblate (Chinese (Simplified)) (TomBursch/kitchenowl-backend#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 93.9% (464 of 494 strings) Translated using Weblate (Portuguese) Currently translated at 100.0% (494 of 494 strings) Translated using Weblate (Portuguese) Currently translated at 94.5% (467 of 494 strings) Translated using Weblate (Finnish) Currently translated at 100.0% (494 of 494 strings) Translated using Weblate (Greek) Currently translated at 100.0% (494 of 494 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/el/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fi/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/pt/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/zh_Hans/ Translation: KitchenOwl/Default Items Co-authored-by: BeardedWatermelon Co-authored-by: MiguelNdeCarvalho Co-authored-by: Petri Hämäläinen Co-authored-by: 一叶知秋 <407763781@qq.com> --- backend/templates/l10n/el.json | 4 +- backend/templates/l10n/fi.json | 2 +- backend/templates/l10n/pt.json | 57 +++++++++++++++++++---------- backend/templates/l10n/zh_Hans.json | 4 ++ 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/backend/templates/l10n/el.json b/backend/templates/l10n/el.json index 69ce5c38..a5ab07c0 100644 --- a/backend/templates/l10n/el.json +++ b/backend/templates/l10n/el.json @@ -16,7 +16,7 @@ "aioli": "Αιόλι", "amaretto": "Αμαρέττο", "apple": "Μήλο", - "apple_pulp": "Πολτός μήλου", + "apple_pulp": "Πουρές μήλου", "applesauce": "Σάλτσα μήλου", "apricots": "Βερίκοκα", "apérol": "Άπερολ", @@ -26,7 +26,7 @@ "asparagus": "Σπαράγγια", "aspirin": "Ασπιρίνη", "avocado": "Αβοκάντο", - "baby_potatoes": "Τρίδυμα", + "baby_potatoes": "Μπέιμπι πατάτες", "baby_spinach": "Baby σπανάκι", "bacon": "Μπέικον", "baguette": "Μπαγκέτα", diff --git a/backend/templates/l10n/fi.json b/backend/templates/l10n/fi.json index 629ed4fc..7a8b01a6 100644 --- a/backend/templates/l10n/fi.json +++ b/backend/templates/l10n/fi.json @@ -26,7 +26,7 @@ "asparagus": "Parsa", "aspirin": "Aspiriini", "avocado": "Avocado", - "baby_potatoes": "Tripletit", + "baby_potatoes": "Varhaisperunat", "baby_spinach": "Babypinaatti", "bacon": "Pekoni", "baguette": "Patonki", diff --git a/backend/templates/l10n/pt.json b/backend/templates/l10n/pt.json index 180e2ac6..9e4b4f5b 100644 --- a/backend/templates/l10n/pt.json +++ b/backend/templates/l10n/pt.json @@ -1,33 +1,34 @@ { "categories": { - "bread": "🍞 Produtos de Pão", - "canned": "🥫 Alimentos Enlatados", - "dairy": "🥛 Lácteos", + "bread": "🍞 Produtos de pastelaria", + "canned": "🥫 Bens de conserva", + "dairy": "🥛 Lacticínios", "drinks": "🍹 Bebidas", "freezer": "❄️ Congelados", "fruits_vegetables": "🥬 Frutas e legumes", - "grain": "🥟 Grãos", + "grain": "🥟 Massas e noodles", "hygiene": "🚽 Higiene", "refrigerated": "💧 Refrigerados", "snacks": "🥜 Lanches" }, "items": { + "agave_syrup": "Xarope de agave", "aioli": "Aioli", "amaretto": "Amaretto", "apple": "Maçã", - "apple_pulp": "Polpa de maçã", - "applesauce": "Sumo de maçã", + "apple_pulp": "Puré de maçã", + "applesauce": "Molho de maçã", "apricots": "Damascos", "apérol": "Apérol", "arugula": "Rúcula", - "asian_egg_noodles": "Macarrão asiático", - "asian_noodles": "Massa asiática", + "asian_egg_noodles": "Noodles com ovos asiáticos", + "asian_noodles": "Noodles", "asparagus": "Espargos", "aspirin": "Aspirina", "avocado": "Abacate", - "baby_potatoes": "Trigémeos", - "baby_spinach": "Baby espinafre", - "bacon": "Toucinho", + "baby_potatoes": "Batatas pequenas", + "baby_spinach": "Espinafres bebé", + "bacon": "Bacon", "baguette": "Baguete", "bakefish": "Peixe assado", "baking_cocoa": "Cacau em pó", @@ -44,13 +45,16 @@ "batteries": "Pilhas", "bay_leaf": "Louro", "beans": "Feijão", + "beef": "Bife", + "beef_broth": "Caldo de carne", "beer": "Cerveja", "beet": "Beterraba", "beetroot": "Beterraba Vermelha", "birthday_card": "Cartão de Aniversário", "black_beans": "Feijão Preto", + "blister_plaster": "Penso para bolhas", "bockwurst": "Salsichão", - "bodywash": "Gel de Banho", + "bodywash": "Gel de banho", "bread": "Pão", "breadcrumbs": "Pão ralado", "broccoli": "Brócolos", @@ -58,11 +62,12 @@ "brussels_sprouts": "Couve de Bruxelas", "buffalo_mozzarella": "Mozzarella Bufalina", "buns": "Pãezinhos", - "burger_buns": "Pão de Hambúrguer", - "burger_patties": "Carne de Hambúrguer", - "burger_sauces": "Molhos de Hambúrguer", + "burger_buns": "Pães de hambúrguer", + "burger_patties": "Carne de hambúrguer", + "burger_sauces": "Molho de Hambúrguer", "butter": "Manteiga", "butter_cookies": "Bolachas de Manteiga", + "butternut_squash": "Abóbora-butternut", "button_cells": "Pilhas de relógio", "börek_cheese": "Queijo Börek", "cake": "Bolo", @@ -77,7 +82,7 @@ "cauliflower": "Couve-flor", "celeriac": "Aipo-rábano", "celery": "Aipo", - "cereal_bar": "Barra Cereais", + "cereal_bar": "Barra de cereais", "cheddar": "Chedar", "cheese": "Queijo", "cherry_tomatoes": "Tomates cherry", @@ -101,6 +106,7 @@ "coconut_flakes": "Flocos de coco", "coconut_milk": "Leite de coco", "coconut_oil": "Óleo de coco", + "coffee_powder": "Café em pó", "colorful_sprinkles": "Pepitas Multicores", "concealer": "Corretor de olheiras", "cookies": "Bolachas", @@ -110,6 +116,7 @@ "cornstarch": "Amido de milho", "cornys": "Cornys", "corriander": "Coentros", + "cotton_rounds": "Discos de algodão", "cough_drops": "Gotas para a tosse", "couscous": "Couz-couz", "covid_rapid_test": "Teste rápido COVID", @@ -132,12 +139,13 @@ "deodorant": "Desodorizante", "detergent": "Detergente", "detergent_sheets": "Folhas de detergente", - "diarrhea_remedy": "Remédio para a diarreia", + "diarrhea_remedy": "Medicamento para a diarreia", "dill": "Endro", "dishwasher_salt": "Sal para máquina da loiça", "dishwasher_tabs": "Pastilhas para máquina da loiça", "disinfection_spray": "Spray desinfetante", "dried_tomatoes": "Tomates secos", + "dry_yeast": "Levedura seca", "edamame": "Edamame", "egg_salad": "Salada de ovo", "egg_yolk": "Gema de ovo", @@ -155,6 +163,7 @@ "flushing": "Lavagem", "fresh_chili_pepper": "Pimenta fresca", "frozen_berries": "Bagas congeladas", + "frozen_broccoli": "Brócolos congelados", "frozen_fruit": "Frutas congeladas", "frozen_pizza": "Pizza congelada", "frozen_spinach": "Espinafres congelados", @@ -166,6 +175,7 @@ "garlic_granules": "Grânulos de alho", "gherkins": "Pepinos", "ginger": "Gengibre", + "ginger_ale": "Ginger ale", "glass_noodles": "Massa de vidro", "gluten": "Glúten", "gnocchi": "Gnocchi", @@ -182,6 +192,8 @@ "hair_gel": "Gel para cabelo", "hair_ties": "Laços para o cabelo", "hair_wax": "Cera para pelos", + "ham": "Fiambre", + "ham_cubes": "Cubos de fiambre", "hand_soap": "Sabonete para as mãos", "handkerchief_box": "Caixa de lenços", "handkerchiefs": "Lenços de bolso", @@ -191,6 +203,7 @@ "hazelnuts": "Avelãs", "head_of_lettuce": "Cabeça de alface", "herb_baguettes": "Baguetes de ervas", + "herb_butter": "Manteiga de ervas", "herb_cream_cheese": "Queijo creme de ervas", "honey": "Mel", "honey_wafers": "Bolachas de mel", @@ -207,6 +220,7 @@ "kidney_beans": "Feijão vermelho", "kitchen_roll": "Rolo de cozinha", "kitchen_towels": "Pano de cozinha", + "kiwi": "Kiwi", "kohlrabi": "Couve-rábano", "lasagna": "Lasanha", "lasagna_noodles": "Massa de lasanha", @@ -226,6 +240,7 @@ "lime": "Lima", "linguine": "Linguine", "lip_care": "Cuidados com os lábios", + "liqueur": "Licor", "low-fat_curd_cheese": "Requeijão magro", "maggi": "Maggi", "magnesium": "Magnésio", @@ -256,10 +271,11 @@ "mushrooms": "Cogumelos", "mustard": "Mostarda", "nail_file": "Lima de unhas", + "nail_polish_remover": "Removedor de verniz para unhas", "neutral_oil": "Óleo neutro", "nori_sheets": "Folhas Nori", "nutmeg": "Noz-moscada", - "oat_milk": "Bebida de aveia", + "oat_milk": "Leite de aveia", "oatmeal": "Farinha de aveia", "oatmeal_cookies": "Bolachas de aveia", "oatsome": "Aveia", @@ -276,6 +292,7 @@ "organic_waste_bags": "Sacos para resíduos orgânicos", "pak_choi": "Pak Choi", "pantyhose": "Meias-calças", + "papaya": "Papaia", "paprika": "Paprica", "paprika_seasoning": "Tempero de paprica", "pardina_lentils_dried": "Lentilhas Pardina secas", @@ -376,6 +393,7 @@ "shower_gel": "Gel de duche", "shredded_cheese": "Queijo ralado", "sieved_tomatoes": "Tomate peneirado", + "skyr": "Skyr", "sliced_cheese": "Queijo fatiado", "smoked_paprika": "Paprika fumada", "smoked_tofu": "Tofu fumado", @@ -387,7 +405,7 @@ "sour_cream": "Creme de leite", "sour_cucumbers": "Pepinos azedos", "soy_cream": "Creme de soja", - "soy_hack": "Hack de soja", + "soy_hack": "Carne picada de soja", "soy_sauce": "Molho de soja", "soy_shred": "Triturador de soja", "spaetzle": "Spaetzle", @@ -452,6 +470,7 @@ "vinegar": "Vinagre", "vitamin_tablets": "Comprimidos de vitaminas", "vodka": "Vodka", + "walnuts": "Nozes", "washing_gel": "Gel de lavagem", "washing_powder": "Pó de lavagem", "water": "Água", diff --git a/backend/templates/l10n/zh_Hans.json b/backend/templates/l10n/zh_Hans.json index f02587a4..6b77a680 100644 --- a/backend/templates/l10n/zh_Hans.json +++ b/backend/templates/l10n/zh_Hans.json @@ -12,6 +12,7 @@ "snacks": "🥜 零食" }, "items": { + "agave_syrup": "龙舌兰糖浆", "aioli": "大蒜蛋黄酱", "amaretto": "杏仁酒", "apple": "苹果", @@ -44,6 +45,8 @@ "batteries": "电池", "bay_leaf": "香叶", "beans": "豆", + "beef": "牛肉", + "beef_broth": "牛肉汤", "beer": "啤酒", "beet": "甜菜", "beetroot": "甜根菜", @@ -101,6 +104,7 @@ "coconut_flakes": "椰子片", "coconut_milk": "椰子汁", "coconut_oil": "椰子油", + "coffee_powder": "咖啡粉", "colorful_sprinkles": "五颜六色的喷洒物", "concealer": "遮瑕膏", "cookies": "饼干", From f8c03187e46c43dc6971ef18238d8614a11f55f1 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 13 Dec 2023 15:51:43 +0100 Subject: [PATCH 483/496] chore: Upgrade requirements --- backend/requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 99f3847a..ea5fb293 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,7 +10,7 @@ bcrypt==4.1.1 beautifulsoup4==4.12.2 bidict==0.22.1 billiard==4.2.0 -black==23.1a1 +black==24.1a1 blinker==1.7.0 blurhash-python==1.2.1 celery==5.3.6 @@ -32,7 +32,7 @@ Flask==3.0.0 Flask-APScheduler==1.13.1 Flask-BasicAuth==0.2.0 Flask-Bcrypt==1.0.1 -Flask-JWT-Extended==4.5.3 +Flask-JWT-Extended==4.6.0 Flask-Migrate==4.0.5 Flask-SocketIO==5.3.6 Flask-SQLAlchemy==3.1.1 @@ -60,21 +60,21 @@ MarkupSafe==2.1.3 marshmallow==3.20.1 matplotlib==3.8.2 mccabe==0.7.0 -mf2py==1.1.3 +mf2py==2.0.1 mlxtend==0.23.0 mypy-extensions==1.0.0 nltk==3.8.1 numpy==1.26.2 oic==1.6.1 packaging==23.2 -pandas==2.1.3 -pathspec==0.11.2 +pandas==2.1.4 +pathspec==0.12.1 Pillow==10.1.0 platformdirs==4.1.0 pluggy==1.3.0 prometheus-client==0.19.0 prometheus-flask-exporter==0.23.0 -prompt-toolkit==3.0.41 +prompt-toolkit==3.0.43 psycopg2-binary==2.9.9 py==1.11.0 pycodestyle==2.11.1 @@ -119,7 +119,7 @@ types-beautifulsoup4==4.12.0.7 types-html5lib==1.1.11.15 types-requests==2.31.0.10 types-urllib3==1.26.25.14 -typing_extensions==4.8.0 +typing_extensions==4.9.0 tzdata==2023.3 tzlocal==5.2 urllib3==2.1.0 From 365d0383b7aa4f85df991b8699b0f532e53e355f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Wed, 13 Dec 2023 15:52:04 +0100 Subject: [PATCH 484/496] Prepare release 92 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index b545a116..795cf4be 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,7 +33,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 91 +BACKEND_VERSION = 92 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From 41c7a26b2a078139d1be26abe7e2b1e1e20821c0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 21 Dec 2023 10:17:25 +0100 Subject: [PATCH 485/496] chore: upgrade requirements --- backend/requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index ea5fb293..5cd3a0d8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -alembic==1.13.0 +alembic==1.13.1 amqp==5.2.0 annotated-types==0.6.0 apispec==6.3.0 @@ -6,7 +6,7 @@ appdirs==1.4.4 APScheduler==3.10.4 attrs==23.1.0 autopep8==2.0.4 -bcrypt==4.1.1 +bcrypt==4.1.2 beautifulsoup4==4.12.2 bidict==0.22.1 billiard==4.2.0 @@ -36,7 +36,7 @@ Flask-JWT-Extended==4.6.0 Flask-Migrate==4.0.5 Flask-SocketIO==5.3.6 Flask-SQLAlchemy==3.1.1 -fonttools==4.46.0 +fonttools==4.47.0 future==0.18.3 gevent==23.9.1 greenlet==3.0.0rc3 @@ -54,7 +54,7 @@ jstyleson==0.0.2 kiwisolver==1.4.5 kombu==5.3.4 lark==1.1.8 -lxml==4.9.3 +lxml==4.9.4 Mako==1.3.0 MarkupSafe==2.1.3 marshmallow==3.20.1 @@ -89,7 +89,7 @@ PyJWT==2.8.0 pyparsing==3.1.1 pyRdfa3==3.5.3 pytest==7.4.3 -python-crfsuite==0.9.9 +python-crfsuite==0.9.10 python-dateutil==2.8.2 python-dotenv==1.0.0 python-editor==1.0.4 From aac85821455db5b62e7954e30bd4971a98554e6c Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 2 Jan 2024 15:01:26 +0100 Subject: [PATCH 486/496] chore: upgrade requirements --- backend/requirements.txt | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5cd3a0d8..61096838 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,10 +1,10 @@ alembic==1.13.1 amqp==5.2.0 annotated-types==0.6.0 -apispec==6.3.0 +apispec==6.3.1 appdirs==1.4.4 APScheduler==3.10.4 -attrs==23.1.0 +attrs==23.2.0 autopep8==2.0.4 bcrypt==4.1.2 beautifulsoup4==4.12.2 @@ -54,7 +54,7 @@ jstyleson==0.0.2 kiwisolver==1.4.5 kombu==5.3.4 lark==1.1.8 -lxml==4.9.4 +lxml==5.0.0 Mako==1.3.0 MarkupSafe==2.1.3 marshmallow==3.20.1 @@ -69,7 +69,7 @@ oic==1.6.1 packaging==23.2 pandas==2.1.4 pathspec==0.12.1 -Pillow==10.1.0 +Pillow==10.2.0 platformdirs==4.1.0 pluggy==1.3.0 prometheus-client==0.19.0 @@ -79,21 +79,21 @@ psycopg2-binary==2.9.9 py==1.11.0 pycodestyle==2.11.1 pycparser==2.21 -pycryptodomex==3.19.0 -pydantic==2.5.2 +pycryptodomex==3.19.1 +pydantic==2.5.3 pydantic-settings==2.1.0 -pydantic_core==2.14.5 +pydantic_core==2.14.6 pyflakes==3.1.0 pyjwkest==1.4.2 PyJWT==2.8.0 pyparsing==3.1.1 pyRdfa3==3.5.3 -pytest==7.4.3 +pytest==7.4.4 python-crfsuite==0.9.10 python-dateutil==2.8.2 python-dotenv==1.0.0 python-editor==1.0.4 -python-engineio==4.8.0 +python-engineio==4.8.1 python-socketio==5.10.0 pytz==2023.3 pytz-deprecation-shim==0.1.0.post0 @@ -108,7 +108,7 @@ setuptools-scm==8.0.4 simple-websocket==1.0.0 six==1.16.0 soupsieve==2.5 -SQLAlchemy==2.0.23 +SQLAlchemy==2.0.24 sqlite-icu==1.0 threadpoolctl==3.2.0 toml==0.10.2 @@ -117,10 +117,10 @@ tqdm==4.66.1 typed-ast==1.5.5 types-beautifulsoup4==4.12.0.7 types-html5lib==1.1.11.15 -types-requests==2.31.0.10 +types-requests==2.31.0.20231231 types-urllib3==1.26.25.14 typing_extensions==4.9.0 -tzdata==2023.3 +tzdata==2023.4 tzlocal==5.2 urllib3==2.1.0 uWSGI==2.0.23 From 1b45e14c2879f1ea5da2ef01e614b8f9588384f0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Tue, 2 Jan 2024 18:45:45 +0100 Subject: [PATCH 487/496] fix: IPv6 not starting (TomBursch/kitchenowl-backend#68) --- backend/entrypoint.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 17e5d5e6..09ac9600 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -1,4 +1,13 @@ #!/bin/sh +set -e + +# if ipv6 is unavailable, remove it from the wsgi config +ME=$(basename $0) +if [ ! -f "/proc/net/if_inet6" ]; then + echo "$ME: info: ipv6 not available" + sed -i 's/\[::\]//g' /usr/src/kitchenowl/wsgi.ini +fi + mkdir -p $STORAGE_PATH/upload flask db upgrade if [ "${SKIP_UPGRADE_DEFAULT_ITEMS}" != "true" ] && [ "${SKIP_UPGRADE_DEFAULT_ITEMS}" != "True" ]; then From 27a2418ca3facf44255b38488ffd2b1e0ebd7f47 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 6 Jan 2024 17:50:28 +0100 Subject: [PATCH 488/496] Translated using Weblate (Dutch) (TomBursch/kitchenowl-backend#67) Currently translated at 95.5% (472 of 494 strings) Translated using Weblate (French) Currently translated at 99.3% (491 of 494 strings) Translated using Weblate (Swedish) Currently translated at 80.1% (396 of 494 strings) Translated using Weblate (French) Currently translated at 99.3% (491 of 494 strings) Translated using Weblate (German) Currently translated at 100.0% (494 of 494 strings) Translated using Weblate (Swedish) Currently translated at 55.6% (275 of 494 strings) Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/de/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/fr/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/nl/ Translate-URL: https://hosted.weblate.org/projects/kitchenowl/default-items/sv/ Translation: KitchenOwl/Default Items Co-authored-by: JBLOD Co-authored-by: Willem F Co-authored-by: Yannou90 Co-authored-by: belanglos --- backend/templates/l10n/de.json | 4 +- backend/templates/l10n/fr.json | 18 +-- backend/templates/l10n/nl.json | 11 +- backend/templates/l10n/sv.json | 243 +++++++++++++++++++++++++-------- 4 files changed, 201 insertions(+), 75 deletions(-) diff --git a/backend/templates/l10n/de.json b/backend/templates/l10n/de.json index 290e11a0..66cfb0d8 100644 --- a/backend/templates/l10n/de.json +++ b/backend/templates/l10n/de.json @@ -128,7 +128,7 @@ "crepe_tape": "Crepesband", "crispbread": "Knäckebrot", "cucumber": "Gurke", - "cumin": "Cumin", + "cumin": "Kreuzkümmel", "curd": "Quark", "curry_paste": "Currypaste", "curry_powder": "Currypulver", @@ -378,7 +378,7 @@ "sausage": "Wurst", "sausages": "Würstchen", "savoy_cabbage": "Wirsing", - "scallion": "Jakobsmuschel", + "scallion": "Frühlingszwiebel", "scattered_cheese": "Streukäse", "schlemmerfilet": "Schlemmerfilet", "schupfnudeln": "Schupfnudeln", diff --git a/backend/templates/l10n/fr.json b/backend/templates/l10n/fr.json index 3e0b285a..8f2edc2e 100644 --- a/backend/templates/l10n/fr.json +++ b/backend/templates/l10n/fr.json @@ -6,7 +6,7 @@ "drinks": "🍹 Boissons", "freezer": "❄️ Surgelé", "fruits_vegetables": "🥬 Fruits et légumes", - "grain": "🥟 Produits céréaliers", + "grain": "🥟 Pâtes et nouilles", "hygiene": "🚽 Hygiène", "refrigerated": "💧 Réfrigéré", "snacks": "🥜 Collations" @@ -22,15 +22,15 @@ "apérol": "Apérol", "arugula": "Roquette", "asian_egg_noodles": "Nouilles asiatiques aux œufs", - "asian_noodles": "Nouilles asiatiques", + "asian_noodles": "Nouilles", "asparagus": "Asperges", "aspirin": "Aspirine", "avocado": "Avocat", - "baby_potatoes": "Triplés", + "baby_potatoes": "Petites pommes de terre", "baby_spinach": "Jeunes épinards", "bacon": "Bacon", "baguette": "Baguette", - "bakefish": "Bakefish", + "bakefish": "Poisson cuit", "baking_cocoa": "Cacao à cuire", "baking_mix": "Mélange à pâtisserie", "baking_paper": "Papier sulfurisé", @@ -54,21 +54,21 @@ "black_beans": "Haricots noirs", "blister_plaster": "Pansement pour ampoules", "bockwurst": "Bockwurst", - "bodywash": "Bain de corps", + "bodywash": "Soin du corps", "bread": "Pain", "breadcrumbs": "Chapelure", "broccoli": "Brocoli", - "brown_sugar": "Sucre brun", + "brown_sugar": "Sucre roux", "brussels_sprouts": "Choux de Bruxelles", "buffalo_mozzarella": "Mozzarella de buffle", "buns": "Brioches", "burger_buns": "Pains à burger", "burger_patties": "Galettes pour hamburgers", - "burger_sauces": "Sauces pour hamburgers", + "burger_sauces": "Sauce hamburger", "butter": "Beurre", "butter_cookies": "Biscuits au beurre", - "butternut_squash": "Purée de butternut", - "button_cells": "Cellules boutons", + "butternut_squash": "Purée de butternuts", + "button_cells": "Piles boutons", "börek_cheese": "Fromage Börek", "cake": "Gâteau", "cake_icing": "Glaçage de gâteau", diff --git a/backend/templates/l10n/nl.json b/backend/templates/l10n/nl.json index 934a5320..e1736ee9 100644 --- a/backend/templates/l10n/nl.json +++ b/backend/templates/l10n/nl.json @@ -1,12 +1,12 @@ { "categories": { - "bread": "🍞 Brood artikelen", + "bread": "🍞 Gebakken artikelen", "canned": "🥫 Ingeblikt eten", "dairy": "🥛 Zuivel", "drinks": "🍹 Drinken", "freezer": "❄️ Vriezer", "fruits_vegetables": "🥬 Fruit en Groenten", - "grain": "🥟 Graanproducten", + "grain": "🥟 Pasta en noedels", "hygiene": "🚽 Hygiëne", "refrigerated": "💧 Gekoeld", "snacks": "🥜 Snacks" @@ -22,11 +22,11 @@ "apérol": "Apérol", "arugula": "Rucola", "asian_egg_noodles": "Aziatische eiernoedels", - "asian_noodles": "Aziatische noedels", + "asian_noodles": "Noedels", "asparagus": "Asperges", "aspirin": "Aspirine", "avocado": "Avocado", - "baby_potatoes": "Drieling", + "baby_potatoes": "Krielaardappel", "baby_spinach": "Babyspinazie", "bacon": "Spek", "baguette": "Stokbrood", @@ -45,6 +45,7 @@ "batteries": "Batterijen", "bay_leaf": "Laurierblad", "beans": "Bonen", + "beef": "Biefstuk", "beer": "Bier", "beet": "Biet", "beetroot": "Biet", @@ -59,7 +60,7 @@ "brussels_sprouts": "Spruiten", "buffalo_mozzarella": "Buffel mozzarella", "buns": "Broodjes", - "burger_buns": "Hamburger broodjes", + "burger_buns": "Hamburgerbroodjes", "burger_patties": "Hamburgers", "burger_sauces": "Burger sauzen", "butter": "Boter", diff --git a/backend/templates/l10n/sv.json b/backend/templates/l10n/sv.json index 66668587..1d3200a6 100644 --- a/backend/templates/l10n/sv.json +++ b/backend/templates/l10n/sv.json @@ -1,17 +1,18 @@ { "categories": { - "bread": "🍞 Brödvaror", + "bread": "🍞 Bröd & Bageri", "canned": "🥫 Konserverad Mat", - "dairy": "🥛 Mjölkprodukt", + "dairy": "🥛 Mejeri", "drinks": "🍹 Drinkar", "freezer": "❄️ Frys", - "fruits_vegetables": "🥬 Frukt och görnsaker", - "grain": "🥟 Spannmåls Produkter", + "fruits_vegetables": "🥬 Frukt & grönt", + "grain": "🥟 Pasta & nudlar", "hygiene": "🚽 Hygien", "refrigerated": "💧 Kylvaror", "snacks": "🥜 Snacks" }, "items": { + "agave_syrup": "Agavesirap", "aioli": "Aioli", "amaretto": "Amaretto", "apple": "Äpple", @@ -21,55 +22,59 @@ "apérol": "Apérol", "arugula": "Ruccola", "asian_egg_noodles": "Äggnudlar från Asien", - "asian_noodles": "Nudlar från Asien", + "asian_noodles": "Nudlar", "asparagus": "Sparris", "aspirin": "Aspirin", "avocado": "Avokado", "baby_potatoes": "Trillingar", - "baby_spinach": "Baby spenat", + "baby_spinach": "Babyspenat", "bacon": "Bacon", "baguette": "Baguette", "bakefish": "Ugnsbakad fisk", - "baking_cocoa": "Bak kakao", + "baking_cocoa": "Kakao", "baking_mix": "Bakmix", "baking_paper": "Bakplåtspapper", "baking_powder": "Bakpulver", - "baking_soda": "Bakpulver", - "baking_yeast": "Bakjäst", - "balsamic_vinegar": "Balsam vinäger", + "baking_soda": "Bikarbonat", + "baking_yeast": "Jäst", + "balsamic_vinegar": "Balsamvinäger", "bananas": "Bananer", "basil": "Basilika", - "basmati_rice": "Basmati ris", + "basmati_rice": "Basmatiris", "bathroom_cleaner": "Badrumsrengöring", "batteries": "Batterier", "bay_leaf": "Lagerblad", "beans": "Bönor", + "beef": "Nötkött", + "beef_broth": "Köttbuljong", "beer": "Öl", "beet": "Beta", "beetroot": "Rödbeta", "birthday_card": "Födelsedagskort", "black_beans": "Svarta bönor", + "blister_plaster": "Skavsårsplåster", "bockwurst": "Bockwurst", "bodywash": "Bodywash", "bread": "Bröd", - "breadcrumbs": "Brödsmulor", + "breadcrumbs": "Ströbröd", "broccoli": "Broccoli", "brown_sugar": "Brunt socker", "brussels_sprouts": "Brysselkål", - "buffalo_mozzarella": "Buffalo mozzarella", + "buffalo_mozzarella": "Buffelmozzarella", "buns": "Bullar", - "burger_buns": "Hamburgare bröd", - "burger_patties": "Hamburgare biffar", - "burger_sauces": "Hamburgarsås", + "burger_buns": "Hamburgarbröd", + "burger_patties": "Hamburgare", + "burger_sauces": "Hamburgerdressing", "butter": "Smör", "butter_cookies": "Smörkakor", - "button_cells": "Knappcell", + "butternut_squash": "Butternutpumpa", + "button_cells": "Knappcellsbatterier", "börek_cheese": "Börek ost", "cake": "Tårta", "cake_icing": "Tårtglasyr", "cane_sugar": "Rörsocker", "cannelloni": "Cannelloni", - "canola_oil": "Canolaolja", + "canola_oil": "Rapsolja", "cardamom": "Kardemumma", "carrots": "Morötter", "cashews": "Cashewnötter", @@ -88,9 +93,9 @@ "chips": "Chips", "chives": "Gräslök", "chocolate": "Choklad", - "chocolate_chips": "Chokladbitar", + "chocolate_chips": "Chokladknappar", "chopped_tomatoes": "Hackade tomater", - "chunky_tomatoes": "Klumpiga tomater", + "chunky_tomatoes": "Krossade tomater", "ciabatta": "Cibatta", "cider_vinegar": "Cidervinäger", "cilantro": "Koriander", @@ -101,7 +106,7 @@ "coconut_flakes": "Kokosflingor", "coconut_milk": "Kokosmjölk", "coconut_oil": "Kokosolja", - "colorful_sprinkles": "Färgrikt strössel", + "colorful_sprinkles": "Färgglatt strössel", "concealer": "Concealer", "cookies": "Kakor", "coriander": "Koriander", @@ -110,38 +115,40 @@ "cornstarch": "Majsstärkelse", "cornys": "Cornys", "corriander": "Koriander", + "cotton_rounds": "Bomullsrondeller", "cough_drops": "Halstabletter", "couscous": "Couscous", "covid_rapid_test": "COVID snabbtest", - "cow's_milk": "Mjölk från ko", + "cow's_milk": "Komjölk", "cream": "Grädde", "cream_cheese": "Färskost", "creamed_spinach": "Stuvad spenat", "creme_fraiche": "Creme fraiche", - "crepe_tape": "Crepe tejp", - "crispbread": "Hårtbröd", - "cucumber": "Gruka", - "cumin": "Kummin", - "curd": "Ostmassa", + "crepe_tape": "Maskeringstejp", + "crispbread": "Knäckebröd", + "cucumber": "Gurka", + "cumin": "Spiskummin", + "curd": "Kvarg", "curry_paste": "Currypasta", - "curry_powder": "Curry pulver", - "curry_sauce": "Curry sås", + "curry_powder": "Curry", + "curry_sauce": "Currysås", "dates": "Dadlar", "dental_floss": "Tandtråd", "deo": "Deodorant", "deodorant": "Deodorant", - "detergent": "Rengöringsmedel", - "detergent_sheets": "Tvättlappar", + "detergent": "Tvättmedel", + "detergent_sheets": "Tvättdukar", "diarrhea_remedy": "Läkemedel mot diarré", "dill": "Dill", "dishwasher_salt": "Diskmaskinssalt", "dishwasher_tabs": "Diskmaskinstabletter", "disinfection_spray": "Desinfektionsspray", - "dried_tomatoes": "Torkadetomater", + "dried_tomatoes": "Torkade tomater", + "dry_yeast": "Torrjäst", "edamame": "Edamame", "egg_salad": "Äggsallad", - "egg_yolk": "Ägg gula", - "eggplant": "aubergine", + "egg_yolk": "Äggula", + "eggplant": "Aubergine", "eggs": "Ägg", "enoki_mushrooms": "Enokisvampar", "eyebrow_gel": "Ögonbrynsgelé", @@ -155,17 +162,19 @@ "flushing": "Spolning", "fresh_chili_pepper": "Färsk chilipeppar", "frozen_berries": "Frysta bär", + "frozen_broccoli": "Fryst broccoli", "frozen_fruit": "Fryst frukt", "frozen_pizza": "Fryspizza", "frozen_spinach": "Fryst spenat", - "funeral_card": "Begravningskort", + "funeral_card": "Kondoleanskort", "garam_masala": "Garam Masala", "garbage_bag": "Soppåsar", "garlic": "Vitlök", "garlic_dip": "Vitlöksdip", "garlic_granules": "Vitlöksgranulat", - "gherkins": "Gurkor", + "gherkins": "Smörgåsgurkor", "ginger": "Ingefära", + "ginger_ale": "Ginger ale", "glass_noodles": "Glasnudlar", "gluten": "Gluten", "gnocchi": "Gnocchi", @@ -173,27 +182,30 @@ "gorgonzola": "Gorgonzola", "gouda": "Gouda", "granola": "Granola", - "granola_bar": "Granolabar", + "granola_bar": "Müslibar", "grapes": "Vindruvor", "greek_yogurt": "Grekisk Yoghurt", - "green_asparagus": "Grönsparis", + "green_asparagus": "Grön sparris", "green_chili": "Grön chili", "green_pesto": "Grön pesto", "hair_gel": "Hårgelé", "hair_ties": "Hårsnodd", "hair_wax": "Hårvax", + "ham": "Skinka", + "ham_cubes": "Skinkkuber", "hand_soap": "Handtvål", "handkerchief_box": "Näsdukslåda", - "handkerchiefs": "Näsduk", - "hard_cheese": "Hård ost", + "handkerchiefs": "Näsdukar", + "hard_cheese": "Hårdost", "haribo": "Haribo", "harissa": "Harissa", "hazelnuts": "Hasselnötter", "head_of_lettuce": "Salladshuvud", - "herb_baguettes": "Örtbaugetter", + "herb_baguettes": "Örtbaguetter", + "herb_butter": "Örtsmör", "herb_cream_cheese": "Örtfärskost", "honey": "Honung", - "honey_wafers": "Honungsrån", + "honey_wafers": "Honungsvåfflor", "hot_dog_bun": "Korvbröd", "ice_cream": "Glass", "ice_cube": "Isbitar", @@ -201,23 +213,24 @@ "iced_tea": "Iste", "instant_soups": "Instantsoppa", "jam": "Sylt", - "jasmine_rice": "Jasmine ris", + "jasmine_rice": "Jasminris", "katjes": "Katjes", "ketchup": "Ketchup", "kidney_beans": "Kidneybönor", - "kitchen_roll": "Köksrulle", + "kitchen_roll": "Hushållspapper", "kitchen_towels": "Hushållspapper", + "kiwi": "Kiwi", "kohlrabi": "Kålrabbi", "lasagna": "Lasagne", - "lasagna_noodles": "Lasagne nudlar", - "lasagna_plates": "Lasagne plattor", + "lasagna_noodles": "Lasagneplattor", + "lasagna_plates": "Lasagneplattor", "leaf_spinach": "Bladspenat", "leek": "Purjolök", "lemon": "Citron", "lemon_curd": "Lemon Curd", - "lemon_juice": "Citron juice", - "lemonade": "Citronsaft", - "lemongrass": "Citrongrös", + "lemon_juice": "Citronjuice", + "lemonade": "Lemonad", + "lemongrass": "Citrongräs", "lentil_stew": "Linsgryta", "lentils": "Linser", "lentils_red": "Röda linser", @@ -226,7 +239,8 @@ "lime": "Lime", "linguine": "Linguine", "lip_care": "Läppvård", - "low-fat_curd_cheese": "Lågfetts kvarg", + "liqueur": "Likör", + "low-fat_curd_cheese": "Lätt kvarg", "maggi": "Maggi", "magnesium": "Magnesium", "mango": "Mango", @@ -241,26 +255,30 @@ "meat_substitute_product": "Köttersättningsprodukt", "microfiber_cloth": "Mikrofiberduk", "milk": "Mjölk", - "mint": "Mint", + "mint": "Mynta", "mint_candy": "Mintgodis", "miso_paste": "Misopasta", "mixed_vegetables": "Blandade grönsaker", - "mold_remover": "Mögelborttagare", + "mochis": "Mochis", + "mold_remover": "Mögelborttagning", "mountain_cheese": "Bergsost", "mouth_wash": "Munskölj", "mozzarella": "Mozzarella", "muesli": "Müsli", - "muesli_bar": "Müsli bar", + "muesli_bar": "Müslibar", "mulled_wine": "Glögg", "mushrooms": "Svamp", "mustard": "Senap", "nail_file": "Nagelfil", - "neutral_oil": "Naturell olja", - "nori_sheets": "Nori lakan", + "nail_polish_remover": "Nagellacksborttagning", + "neutral_oil": "Neutral olja", + "nori_sheets": "Noriark", "nutmeg": "Muskot", "oat_milk": "Havredryck", - "oatmeal": "Havregröt", + "oatmeal": "Havregryn", "oatmeal_cookies": "Havrekakor", + "oatsome": "Oatsome", + "obatzda": "Obatzda", "oil": "Olja", "olive_oil": "Olivolja", "olives": "Oliver", @@ -273,10 +291,117 @@ "organic_waste_bags": "Nerbrytningsbara avfallspåsar", "pak_choi": "Pak Choi", "pantyhose": "Strumpbyxor", + "papaya": "Papaya", "paprika": "Paprika", - "paprika_seasoning": "Paptrikakrydda", + "paprika_seasoning": "Paprikakrydda", + "pardina_lentils_dried": "Pardinalinser, torkade", + "parmesan": "Parmesan", + "parsley": "Persilja", "pasta": "Pasta", - "peppers": "Peppar", - "persian_rice": "Persisktris" + "peach": "Persika", + "peanut_butter": "Jordnötssmör", + "peanut_oil": "Jordnötsolja", + "peanuts": "Jordnötter", + "pears": "Päron", + "peas": "Ärtor", + "penne": "Penne", + "pepper": "Peppar", + "pepper_mill": "Pepparkvarn", + "peppers": "Paprika", + "persian_rice": "Persiskt ris", + "pesto": "Pesto", + "pilsner": "Pilsner", + "pine_nuts": "Pinjenötter", + "pineapple": "Ananas", + "pita_bag": "Pitabröd", + "pita_bread": "Pitabröd", + "pizza": "Pizza", + "pizza_dough": "Pizzadeg", + "plant_magarine": "Margarin, växtbaserad", + "plaster": "Plåster", + "pointed_peppers": "Spetspaprika", + "porcini_mushrooms": "Stensopp", + "potato_wedges": "Potatisklyftor", + "potatoes": "Potatis", + "potting_soil": "Blomjord", + "powder": "Pulver", + "powdered_sugar": "Florsocker", + "processed_cheese": "Smältost", + "prosecco": "Prosecco", + "puff_pastry": "Smördeg", + "pumpkin": "Pumpa", + "pumpkin_seeds": "Pumpakärnor", + "quark": "Kvarg", + "quinoa": "Quinoa", + "radish": "Rädisa", + "ramen": "Ramen", + "rapeseed_oil": "Rapsolja", + "raspberries": "Hallon", + "raspberry_syrup": "Hallonsirap", + "razor_blades": "Rakblad", + "red_bull": "Red Bull", + "red_chili": "Röd chili", + "red_curry_paste": "Röd currypasta", + "red_lentils": "Röda linser", + "red_onions": "Rödlök", + "red_pesto": "Röd pesto", + "red_wine": "Rödvin", + "red_wine_vinegar": "Rödvinsvinäger", + "rhubarb": "Rabarber", + "rice": "Ris", + "rice_cakes": "Riskakor", + "rice_paper": "Rispapper", + "rice_vinegar": "Risvinsvinäger", + "ricotta": "Ricotta", + "rinse_tabs": "Maskindiskmedel", + "rinsing_agent": "Spolglans", + "risotto_rice": "Risottoris", + "rocket": "Raket", + "roll": "Fralla", + "rosemary": "Rosmarin", + "saffron_threads": "Saffran", + "sage": "Salvia", + "salad_mix": "Salladsmix", + "salt": "Salt", + "salt_mill": "Saltkvarn", + "sambal_oelek": "Sambal oelek", + "sauce": "Sås", + "sausage": "Korv", + "sausages": "Korvar", + "savoy_cabbage": "Savojkål", + "scallion": "Salladslök", + "scattered_cheese": "Riven ost", + "schlemmerfilet": "Fiskgratäng", + "semolina_porridge": "Mannagrynsgröt", + "sesame": "Sesam", + "sesame_oil": "Sesamolja", + "shallot": "Schalottenlök", + "shampoo": "Shampoo", + "shawarma_spice": "Shawarmakrydda", + "shiitake_mushroom": "Shiitake", + "shoe_insoles": "Skoinlägg", + "shower_gel": "Duschgel", + "shredded_cheese": "Riven ost", + "sieved_tomatoes": "Passerade tomater", + "skyr": "Skyr", + "sliced_cheese": "Skivad ost", + "smoked_paprika": "Rökt paprika", + "smoked_tofu": "Rökt tofu", + "snacks": "Snacks", + "soap": "Tvål", + "soba_noodles": "Sobanudlar", + "soft_drinks": "Läsk", + "sour_cream": "Gräddfil", + "sour_cucumbers": "Ättiksgurka", + "soy_cream": "Sojagrädde", + "soy_hack": "Sojafärs", + "soy_sauce": "Sojasås", + "soy_shred": "Sojastrimlor", + "spaetzle": "Spätzle", + "spaghetti": "Spaghetti", + "sparkling_water": "Kolsyrad vatten", + "spelt": "Dinkel", + "spinach": "Spenat", + "sponge_cloth": "Kökssvamp" } } From babf4de1e6ffa7dfa85398f7c0bc1327cc149fa7 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 6 Jan 2024 17:51:01 +0100 Subject: [PATCH 489/496] fix: get all items --- backend/app/controller/item/item_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/item/item_controller.py b/backend/app/controller/item/item_controller.py index 04840707..61927cb0 100644 --- a/backend/app/controller/item/item_controller.py +++ b/backend/app/controller/item/item_controller.py @@ -15,7 +15,7 @@ @authorize_household() def getAllItems(household_id): return jsonify( - [e.obj_to_dict() for e in Item.all_by_name_with_filter(household_id)] + [e.obj_to_dict() for e in Item.all_from_household_by_name(household_id)] ) From 01fdfb3fc3d822686b0716d255012a9b01610a6f Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 6 Jan 2024 17:54:02 +0100 Subject: [PATCH 490/496] fix: Add Australian English --- backend/app/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/config.py b/backend/app/config.py index 795cf4be..8bbb0938 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -74,6 +74,7 @@ SUPPORTED_LANGUAGES = { "en": "English", + "en_AU": "Australian English", "cs": "čeština", "da": "Dansk", "de": "Deutsch", From 1ce703fc8567f1a0f8b17247e3a37fd508e02db2 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 6 Jan 2024 18:00:34 +0100 Subject: [PATCH 491/496] feat: update default items attributes --- backend/templates/attributes.json | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/backend/templates/attributes.json b/backend/templates/attributes.json index 4a650ece..d78b2d31 100644 --- a/backend/templates/attributes.json +++ b/backend/templates/attributes.json @@ -279,7 +279,8 @@ "icon": "cereal" }, "cornstarch": { - "category": "dairy" + "category": "dairy", + "icon": "flour" }, "cornys": {}, "corriander": { @@ -288,7 +289,9 @@ }, "cotton_rounds": {}, "cough_drops": {}, - "couscous": {}, + "couscous": { + "icon": "lentil" + }, "covid_rapid_test": {}, "cow's_milk": { "category": "dairy", @@ -727,7 +730,9 @@ "icon": "paprika" }, "paprika_seasoning": {}, - "pardina_lentils_dried": {}, + "pardina_lentils_dried": { + "icon": "lentil" + }, "parmesan": { "category": "dairy", "icon": "cheese" @@ -893,7 +898,8 @@ "rice_vinegar": {}, "ricotta": {}, "rinse_tabs": { - "category": "hygiene" + "category": "hygiene", + "icon": "soap_bubble" }, "rinsing_agent": { "category": "hygiene" @@ -930,6 +936,7 @@ "icon": "sausage" }, "savoy_cabbage": { + "category": "fruits_vegetables", "icon": "cabbage" }, "scallion": {}, @@ -1032,7 +1039,8 @@ "category": "hygiene" }, "sponges": { - "category": "hygiene" + "category": "hygiene", + "icon": "soap_bubble" }, "spreading_cream": { "category": "dairy" @@ -1211,7 +1219,9 @@ "icon": "nachos" }, "yeast": {}, - "yeast_flakes": {}, + "yeast_flakes": { + "icon": "lentil" + }, "yoghurt": { "category": "dairy", "icon": "yogurt" From a1bd35c0eddf9ce03383e63920c9b6cad064c13d Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 6 Jan 2024 18:00:44 +0100 Subject: [PATCH 492/496] chore: upgrade requirements --- backend/requirements.txt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 61096838..6c1c8aab 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -27,7 +27,7 @@ cycler==0.12.1 dbscan1d==0.2.2 defusedxml==0.7.1 extruct==0.16.0 -flake8==6.1.0 +flake8==7.0.0 Flask==3.0.0 Flask-APScheduler==1.13.1 Flask-BasicAuth==0.2.0 @@ -54,17 +54,17 @@ jstyleson==0.0.2 kiwisolver==1.4.5 kombu==5.3.4 lark==1.1.8 -lxml==5.0.0 +lxml==5.0.1 Mako==1.3.0 MarkupSafe==2.1.3 marshmallow==3.20.1 matplotlib==3.8.2 mccabe==0.7.0 mf2py==2.0.1 -mlxtend==0.23.0 +mlxtend==0.23.1 mypy-extensions==1.0.0 nltk==3.8.1 -numpy==1.26.2 +numpy==1.26.3 oic==1.6.1 packaging==23.2 pandas==2.1.4 @@ -83,7 +83,7 @@ pycryptodomex==3.19.1 pydantic==2.5.3 pydantic-settings==2.1.0 pydantic_core==2.14.6 -pyflakes==3.1.0 +pyflakes==3.2.0 pyjwkest==1.4.2 PyJWT==2.8.0 pyparsing==3.1.1 @@ -108,16 +108,16 @@ setuptools-scm==8.0.4 simple-websocket==1.0.0 six==1.16.0 soupsieve==2.5 -SQLAlchemy==2.0.24 +SQLAlchemy==2.0.25 sqlite-icu==1.0 threadpoolctl==3.2.0 toml==0.10.2 tomli==2.0.1 tqdm==4.66.1 typed-ast==1.5.5 -types-beautifulsoup4==4.12.0.7 -types-html5lib==1.1.11.15 -types-requests==2.31.0.20231231 +types-beautifulsoup4==4.12.0.20240106 +types-html5lib==1.1.11.20240106 +types-requests==2.31.0.20240106 types-urllib3==1.26.25.14 typing_extensions==4.9.0 tzdata==2023.4 @@ -127,7 +127,7 @@ uWSGI==2.0.23 uwsgi-tools==1.1.1 vine==5.1.0 w3lib==2.1.2 -wcwidth==0.2.12 +wcwidth==0.2.13 webencodings==0.5.1 Werkzeug==3.0.1 wsproto==1.2.0 From 92abca00145d5ec6f95b1f90c618cbbfce3d29e0 Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Sat, 6 Jan 2024 18:01:36 +0100 Subject: [PATCH 493/496] Prepare release 93 --- backend/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 8bbb0938..8897d3b3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,7 +33,7 @@ MIN_FRONTEND_VERSION = 71 -BACKEND_VERSION = 92 +BACKEND_VERSION = 93 APP_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(APP_DIR) From f9fd10554acb4863eccb8cd4459ea617885021bb Mon Sep 17 00:00:00 2001 From: Christoph Loy Date: Tue, 9 Jan 2024 19:05:27 +0100 Subject: [PATCH 494/496] Fix pytest action --- .github/workflows/pytest.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7d83582c..fcdd664a 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -14,6 +14,10 @@ jobs: runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: - uses: actions/checkout@v3 - name: Set up Python @@ -21,6 +25,7 @@ jobs: with: python-version: '3.11' cache: 'pip' + cache-dependency-path: backend/requirements.txt - name: Install dependencies run: | python -m pip install --upgrade pip From bbd2e7afa26a5b639f9cee0f75e9c829c64e718e Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 19 Jan 2024 12:07:04 +0100 Subject: [PATCH 495/496] Move app to subfolder --- .dockerignore | 67 +- ...lish.yml => deploy_backend_docker_hub.yml} | 18 +- .github/workflows/deploy_docker_hub.yml | 20 +- .github/workflows/deploy_docs.yml | 1 + .github/workflows/deploy_play_store.yml | 8 +- .github/workflows/deploy_web_docker_hub.yml | 70 + .github/workflows/pytest.yml | 4 + .github/workflows/release.yml | 23 +- .github/workflows/tests.yaml | 12 +- CONTRIBUTING.md | 6 - Dockerfile | 71 +- analysis_options.yaml | 32 - backend/.dockerignore | 1 + backend/CONTRIBUTING.md | 52 - backend/README.md | 20 +- backend/wsgi.ini | 9 +- changelog_configuration.json | 2 +- icons/README.md | 2 +- .../generate-item-icons.py | 8 +- kitchenowl/.dockerignore | 112 ++ kitchenowl/.gitignore | 43 + .metadata => kitchenowl/.metadata | 0 kitchenowl/Dockerfile | 70 + kitchenowl/README.md | 12 + {android => kitchenowl/android}/.gitignore | 0 {android => kitchenowl/android}/Gemfile | 0 .../android}/app/build.gradle | 0 .../app/src/debug/AndroidManifest.xml | 0 .../android}/app/src/main/AndroidManifest.xml | 0 .../app/src/main/ic_launcher-playstore.png | Bin .../app/FlutterMultiDexApplication.java | 0 .../com/tombursch/kitchenowl/MainActivity.kt | 0 .../res/drawable-night-v21/background.png | Bin .../drawable-night-v21/launch_background.xml | 0 .../main/res/drawable-night/background.png | Bin .../res/drawable-night/launch_background.xml | 0 .../src/main/res/drawable-v21/background.png | Bin .../res/drawable-v21/launch_background.xml | 0 .../app/src/main/res/drawable/background.png | Bin .../main/res/drawable/launch_background.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_rounded.xml | 0 .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin .../mipmap-hdpi/ic_launcher_foreground.png | Bin .../mipmap-hdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-hdpi/ic_launcher_round.png | Bin .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin .../mipmap-mdpi/ic_launcher_foreground.png | Bin .../mipmap-mdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-mdpi/ic_launcher_round.png | Bin .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin .../mipmap-xhdpi/ic_launcher_foreground.png | Bin .../mipmap-xhdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-xhdpi/ic_launcher_round.png | Bin .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxhdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxxhdpi/ic_launcher_monochrome.png | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin .../src/main/res/values-night-v31/styles.xml | 0 .../app/src/main/res/values-night/styles.xml | 0 .../app/src/main/res/values-v31/styles.xml | 0 .../app/src/main/res/values/colors.xml | 0 .../app/src/main/res/values/styles.xml | 0 .../app/src/main/res/xml/locales_config.xml | 0 .../main/res/xml/network_security_config.xml | 0 .../app/src/profile/AndroidManifest.xml | 0 {android => kitchenowl/android}/build.gradle | 0 .../android}/fastlane/Appfile | 0 .../android}/fastlane/Fastfile | 0 .../android}/fastlane/README.md | 0 .../android}/gradle.properties | 0 .../gradle/wrapper/gradle-wrapper.properties | 0 .../android}/settings.gradle | 0 .../assets}/icon/icon-foreground.png | Bin .../assets}/icon/icon-padded.png | Bin .../assets}/icon/icon-rounded.png | Bin .../assets}/icon/icon-small.png | Bin {assets => kitchenowl/assets}/icon/icon.png | Bin .../assets}/images/google_logo.png | Bin dart_test.yaml => kitchenowl/dart_test.yaml | 0 {debian => kitchenowl/debian}/build.sh | 0 .../debian}/kitchenowl/DEBIAN/control | 0 .../debian}/kitchenowl/DEBIAN/postinst | 0 .../debian}/kitchenowl/usr/bin/kitchenowl | 0 .../default.conf.template | 0 .../docker-entrypoint-custom.sh | 0 {fedora => kitchenowl/fedora}/build.sh | 0 {fedora => kitchenowl/fedora}/kitchenowl.spec | 0 {fonts => kitchenowl/fonts}/Items.ttf | Bin {fonts => kitchenowl/fonts}/README.md | 0 {fonts => kitchenowl/fonts}/Roboto-Black.ttf | Bin .../fonts}/Roboto-BlackItalic.ttf | Bin {fonts => kitchenowl/fonts}/Roboto-Bold.ttf | Bin .../fonts}/Roboto-BoldItalic.ttf | Bin {fonts => kitchenowl/fonts}/Roboto-Italic.ttf | Bin {fonts => kitchenowl/fonts}/Roboto-Light.ttf | Bin .../fonts}/Roboto-LightItalic.ttf | Bin {fonts => kitchenowl/fonts}/Roboto-Medium.ttf | Bin .../fonts}/Roboto-MediumItalic.ttf | Bin .../fonts}/Roboto-Regular.ttf | Bin {fonts => kitchenowl/fonts}/Roboto-Thin.ttf | Bin .../fonts}/Roboto-ThinItalic.ttf | Bin {ios => kitchenowl/ios}/.gitignore | 0 .../ios}/Flutter/AppFrameworkInfo.plist | 0 .../ios}/Flutter/Debug.xcconfig | 0 .../ios}/Flutter/Release.xcconfig | 0 {ios => kitchenowl/ios}/Gemfile | 0 {ios => kitchenowl/ios}/Podfile | 0 .../ios}/Runner.xcodeproj/project.pbxproj | 0 .../contents.xcworkspacedata | 0 .../xcshareddata/IDEWorkspaceChecks.plist | 0 .../xcshareddata/WorkspaceSettings.xcsettings | 0 .../xcshareddata/xcschemes/Runner.xcscheme | 0 .../contents.xcworkspacedata | 0 .../xcshareddata/IDEWorkspaceChecks.plist | 0 .../xcshareddata/WorkspaceSettings.xcsettings | 0 .../ios}/Runner/AppDelegate.swift | 0 .../AppIcon.appiconset/Contents.json | 0 .../Icon-App-1024x1024@1x.png | Bin .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin .../Icon-App-83.5x83.5@2x.png | Bin .../LaunchBackground.imageset/Contents.json | 0 .../LaunchBackground.imageset/background.png | Bin .../darkbackground.png | Bin .../LaunchImage.imageset/Contents.json | 0 .../LaunchImage.imageset/LaunchImage.png | Bin .../LaunchImage.imageset/LaunchImage@2x.png | Bin .../LaunchImage.imageset/LaunchImage@3x.png | Bin .../LaunchImage.imageset/README.md | 0 .../Runner/Base.lproj/LaunchScreen.storyboard | 0 .../ios}/Runner/Base.lproj/Main.storyboard | 0 {ios => kitchenowl/ios}/Runner/Info.plist | 0 .../ios}/Runner/Runner-Bridging-Header.h | 0 .../ios}/Runner/Runner.entitlements | 0 .../ios}/RunnerTests/RunnerTests.swift | 0 .../Base.lproj/MainInterface.storyboard | 0 .../ios}/ShareExtension/Info.plist | 0 .../ShareExtension.entitlements | 0 .../ShareExtension/ShareViewController.swift | 0 {ios => kitchenowl/ios}/fastlane/Appfile | 0 {ios => kitchenowl/ios}/fastlane/Fastfile | 0 {ios => kitchenowl/ios}/fastlane/README.md | 0 l10n.yaml => kitchenowl/l10n.yaml | 0 {lib => kitchenowl/lib}/app.dart | 0 {lib => kitchenowl/lib}/config.dart | 0 .../lib}/cubits/auth_cubit.dart | 0 .../lib}/cubits/email_confirm_cubit.dart | 0 .../lib}/cubits/expense_add_update_cubit.dart | 0 .../expense_category_add_update_cubit.dart | 0 .../lib}/cubits/expense_cubit.dart | 0 .../lib}/cubits/expense_list_cubit.dart | 0 .../lib}/cubits/expense_month_list_cubit.dart | 0 .../lib}/cubits/expense_overview_cubit.dart | 0 .../household_add_cubit.dart | 0 .../household_add_update_cubit.dart | 0 .../household_update_cubit.dart | 0 .../lib}/cubits/household_cubit.dart | 0 .../lib}/cubits/household_list_cubit.dart | 0 .../lib}/cubits/household_member_cubit.dart | 0 .../lib}/cubits/item_edit_cubit.dart | 0 .../lib}/cubits/item_search_cubit.dart | 0 .../lib}/cubits/item_selection_cubit.dart | 0 .../lib}/cubits/password_reset_cubit.dart | 0 .../lib}/cubits/planner_cubit.dart | 0 .../lib}/cubits/recipe_add_update_cubit.dart | 0 .../lib}/cubits/recipe_cubit.dart | 0 .../lib}/cubits/recipe_list_cubit.dart | 0 .../lib}/cubits/recipe_scraper_cubit.dart | 0 .../lib}/cubits/server_info_cubit.dart | 0 .../lib}/cubits/settings_cubit.dart | 0 .../lib}/cubits/settings_server_cubit.dart | 0 .../lib}/cubits/settings_user_cubit.dart | 0 .../lib}/cubits/shoppinglist_cubit.dart | 0 .../lib}/cubits/user_search_cubit.dart | 0 .../lib}/enums/expenselist_sorting.dart | 0 .../lib}/enums/oidc_provider.dart | 0 .../lib}/enums/shoppinglist_sorting.dart | 0 {lib => kitchenowl/lib}/enums/timeframe.dart | 0 .../lib}/enums/token_type_enum.dart | 0 .../lib}/enums/update_enum.dart | 0 {lib => kitchenowl/lib}/enums/views_enum.dart | 0 .../currency_text_input_formatter.dart | 0 .../lib}/helpers/debouncer.dart | 0 .../helpers/fade_through_transition_page.dart | 0 .../lib}/helpers/named_bytearray.dart | 0 .../recipe_item_markdown_extension.dart | 0 .../helpers/shared_axis_transition_page.dart | 0 .../lib}/helpers/string_scaler.dart | 0 .../lib}/helpers/url_launcher.dart | 0 .../username_text_input_formatter.dart | 0 {lib => kitchenowl/lib}/item_icons.dart | 0 {lib => kitchenowl/lib}/kitchenowl.dart | 0 {lib => kitchenowl/lib}/l10n/app_be.arb | 0 {lib => kitchenowl/lib}/l10n/app_ca.arb | 0 .../lib}/l10n/app_ca_valencia.arb | 0 {lib => kitchenowl/lib}/l10n/app_cs.arb | 0 {lib => kitchenowl/lib}/l10n/app_da.arb | 0 {lib => kitchenowl/lib}/l10n/app_de.arb | 0 {lib => kitchenowl/lib}/l10n/app_el.arb | 0 {lib => kitchenowl/lib}/l10n/app_en.arb | 0 {lib => kitchenowl/lib}/l10n/app_en_AU.arb | 0 {lib => kitchenowl/lib}/l10n/app_es.arb | 0 {lib => kitchenowl/lib}/l10n/app_fi.arb | 0 {lib => kitchenowl/lib}/l10n/app_fr.arb | 0 {lib => kitchenowl/lib}/l10n/app_hu.arb | 0 {lib => kitchenowl/lib}/l10n/app_id.arb | 0 {lib => kitchenowl/lib}/l10n/app_it.arb | 0 {lib => kitchenowl/lib}/l10n/app_nb.arb | 0 {lib => kitchenowl/lib}/l10n/app_nl.arb | 0 {lib => kitchenowl/lib}/l10n/app_pa.arb | 0 {lib => kitchenowl/lib}/l10n/app_pl.arb | 0 {lib => kitchenowl/lib}/l10n/app_pt.arb | 0 {lib => kitchenowl/lib}/l10n/app_pt_BR.arb | 0 {lib => kitchenowl/lib}/l10n/app_ro.arb | 0 {lib => kitchenowl/lib}/l10n/app_ru.arb | 0 {lib => kitchenowl/lib}/l10n/app_sv.arb | 0 {lib => kitchenowl/lib}/l10n/app_tr.arb | 0 {lib => kitchenowl/lib}/l10n/app_vi.arb | 0 {lib => kitchenowl/lib}/l10n/app_zh.arb | 0 {lib => kitchenowl/lib}/main.dart | 0 {lib => kitchenowl/lib}/models/category.dart | 0 {lib => kitchenowl/lib}/models/expense.dart | 0 .../lib}/models/expense_category.dart | 0 .../lib}/models/expense_overview.dart | 0 {lib => kitchenowl/lib}/models/household.dart | 0 .../lib}/models/import_settings.dart | 0 {lib => kitchenowl/lib}/models/item.dart | 0 {lib => kitchenowl/lib}/models/member.dart | 0 {lib => kitchenowl/lib}/models/model.dart | 0 {lib => kitchenowl/lib}/models/nullable.dart | 0 {lib => kitchenowl/lib}/models/planner.dart | 0 {lib => kitchenowl/lib}/models/recipe.dart | 0 .../lib}/models/recipe_scrape.dart | 0 .../lib}/models/shoppinglist.dart | 0 {lib => kitchenowl/lib}/models/tag.dart | 0 {lib => kitchenowl/lib}/models/token.dart | 0 .../lib}/models/update_value.dart | 0 {lib => kitchenowl/lib}/models/user.dart | 0 .../lib}/pages/analytics_page.dart | 0 .../lib}/pages/email_confirm_page.dart | 0 .../lib}/pages/expense_add_update_page.dart | 0 .../lib}/pages/expense_category_add_page.dart | 0 .../lib}/pages/expense_month_list_page.dart | 0 .../lib}/pages/expense_overview_page.dart | 0 .../lib}/pages/expense_page.dart | 0 .../lib}/pages/household_add_page.dart | 0 .../lib}/pages/household_list_page.dart | 0 .../lib}/pages/household_member_page.dart | 0 .../lib}/pages/household_page.dart | 0 .../lib}/pages/household_page/_export.dart | 0 .../pages/household_page/expense_list.dart | 0 .../household_page/household_drawer.dart | 0 .../household_navigation_rail.dart | 0 .../lib}/pages/household_page/planner.dart | 0 .../lib}/pages/household_page/profile.dart | 0 .../pages/household_page/recipe_list.dart | 0 .../pages/household_page/shoppinglist.dart | 0 .../lib}/pages/household_update_page.dart | 0 .../lib}/pages/icon_selection_page.dart | 0 {lib => kitchenowl/lib}/pages/item_page.dart | 0 .../lib}/pages/item_search_page.dart | 0 .../lib}/pages/item_selection_page.dart | 0 {lib => kitchenowl/lib}/pages/login_page.dart | 0 .../lib}/pages/login_redirect_page.dart | 0 .../lib}/pages/onboarding_page.dart | 0 .../lib}/pages/page_not_found.dart | 0 .../lib}/pages/password_forgot_page.dart | 0 .../lib}/pages/password_reset_page.dart | 0 .../lib}/pages/photo_view_page.dart | 0 .../lib}/pages/recipe_add_update_page.dart | 0 .../lib}/pages/recipe_page.dart | 0 .../lib}/pages/recipe_scraper_page.dart | 0 .../lib}/pages/settings/create_user_page.dart | 0 .../lib}/pages/settings_page.dart | 0 .../lib}/pages/settings_server_user_page.dart | 0 .../lib}/pages/settings_user_email_page.dart | 0 .../settings_user_linked_accounts_page.dart | 0 .../lib}/pages/settings_user_page.dart | 0 .../pages/settings_user_password_page.dart | 0 .../pages/settings_user_sessions_page.dart | 0 {lib => kitchenowl/lib}/pages/setup_page.dart | 0 .../lib}/pages/signup_page.dart | 0 .../lib}/pages/splash_page.dart | 0 .../lib}/pages/unreachable_page.dart | 0 .../lib}/pages/unsupported_page.dart | 0 .../lib}/pages/user_search_page.dart | 0 {lib => kitchenowl/lib}/router.dart | 0 .../lib}/services/api/analytics.dart | 0 .../lib}/services/api/api_service.dart | 0 .../lib}/services/api/category.dart | 0 .../lib}/services/api/expense.dart | 0 .../lib}/services/api/household.dart | 0 .../lib}/services/api/import_export.dart | 0 .../lib}/services/api/item.dart | 0 .../lib}/services/api/planner.dart | 0 .../lib}/services/api/recipe.dart | 0 .../lib}/services/api/shoppinglist.dart | 0 {lib => kitchenowl/lib}/services/api/tag.dart | 0 .../lib}/services/api/upload.dart | 0 .../lib}/services/api/user.dart | 0 .../lib}/services/storage/mem_storage.dart | 0 .../lib}/services/storage/storage.dart | 0 .../lib}/services/storage/temp_storage.dart | 0 .../services/storage/transaction_storage.dart | 0 .../lib}/services/transaction.dart | 0 .../lib}/services/transaction_handler.dart | 0 .../lib}/services/transactions/category.dart | 0 .../lib}/services/transactions/expense.dart | 0 .../lib}/services/transactions/household.dart | 0 .../lib}/services/transactions/item.dart | 0 .../lib}/services/transactions/planner.dart | 0 .../lib}/services/transactions/recipe.dart | 0 .../services/transactions/shoppinglist.dart | 0 .../lib}/services/transactions/tag.dart | 0 .../lib}/services/transactions/user.dart | 0 {lib => kitchenowl/lib}/styles/colors.dart | 0 {lib => kitchenowl/lib}/styles/dynamic.dart | 0 {lib => kitchenowl/lib}/styles/themes.dart | 0 {lib => kitchenowl/lib}/widgets/_export.dart | 0 .../chart_bar_member_distribution.dart | 0 .../lib}/widgets/chart_bar_months.dart | 0 .../widgets/chart_line_current_month.dart | 0 .../lib}/widgets/chart_pie_current_month.dart | 0 .../lib}/widgets/checkbox_list_tile.dart | 0 .../lib}/widgets/choice_scroll.dart | 0 .../lib}/widgets/confirmation_dialog.dart | 0 .../lib}/widgets/create_user_form_fields.dart | 0 .../lib}/widgets/dismissible_card.dart | 0 .../lib}/widgets/expandable_fab.dart | 0 .../expense/timeframe_dropdown_button.dart | 0 .../expense_add_update/paid_for_widget.dart | 0 .../lib}/widgets/expense_category_icon.dart | 0 .../lib}/widgets/expense_create_fab.dart | 0 .../lib}/widgets/expense_item.dart | 0 .../widgets/flexible_image_space_bar.dart | 0 .../lib}/widgets/fractionally_sized_box.dart | 0 .../sliver_category_item_grid_list.dart | 0 .../lib}/widgets/household_card.dart | 0 .../lib}/widgets/image_provider.dart | 0 .../lib}/widgets/image_selector.dart | 0 .../kitchenowl_color_picker_dialog.dart | 0 .../lib}/widgets/kitchenowl_fab.dart | 0 .../lib}/widgets/kitchenowl_switch.dart | 0 .../lib}/widgets/language_dialog.dart | 0 .../lib}/widgets/left_right_wrap.dart | 0 .../lib}/widgets/loading_elevated_button.dart | 0 .../widgets/loading_elevated_button_icon.dart | 0 .../lib}/widgets/loading_icon_button.dart | 0 .../lib}/widgets/loading_list_tile.dart | 0 .../lib}/widgets/loading_text_button.dart | 0 .../lib}/widgets/number_selector.dart | 0 .../lib}/widgets/recipe_card.dart | 0 .../lib}/widgets/recipe_create_fab.dart | 0 .../lib}/widgets/recipe_item.dart | 0 .../lib}/widgets/recipe_source_chip.dart | 0 .../lib}/widgets/recipe_time_settings.dart | 0 .../rendering/sliver_with_pinned_footer.dart | 0 .../lib}/widgets/search_text_field.dart | 0 .../lib}/widgets/select_dialog.dart | 0 .../lib}/widgets/select_file.dart | 0 .../lib}/widgets/selectable_button_card.dart | 0 .../widgets/selectable_button_list_tile.dart | 0 .../lib}/widgets/settings/color_button.dart | 0 .../widgets/settings/server_user_card.dart | 0 .../widgets/settings/token_bottom_sheet.dart | 0 .../lib}/widgets/settings/token_card.dart | 0 .../import_settings_dialog.dart | 0 .../sliver_household_category_settings.dart | 0 .../sliver_household_danger_zone.dart | 0 ...r_household_expense_category_settings.dart | 0 .../sliver_household_feature_settings.dart | 0 ...liver_household_shoppinglist_settings.dart | 0 .../sliver_household_tags_settings.dart | 0 .../update_member_bottom_sheet.dart | 0 .../view_settings_list_tile.dart | 0 .../lib}/widgets/shimmer_card.dart | 0 .../lib}/widgets/shimmer_shopping_item.dart | 0 .../lib}/widgets/shopping_item.dart | 0 .../shoppinglist_confirm_remove_fab.dart | 0 .../lib}/widgets/show_snack_bar.dart | 0 .../sliver_implicit_animated_list.dart | 0 .../lib}/widgets/sliver_item_grid_list.dart | 0 .../lib}/widgets/sliver_text.dart | 0 .../widgets/sliver_with_pinned_footer.dart | 0 .../lib}/widgets/string_item_match.dart | 0 .../lib}/widgets/text_dialog.dart | 0 .../lib}/widgets/text_with_icon_button.dart | 0 .../widgets/trailing_icon_text_button.dart | 0 .../lib}/widgets/user_list_tile.dart | 0 {linux => kitchenowl/linux}/.gitignore | 0 {linux => kitchenowl/linux}/CMakeLists.txt | 0 .../linux}/flutter/CMakeLists.txt | 0 .../flutter/generated_plugin_registrant.cc | 0 .../flutter/generated_plugin_registrant.h | 0 .../linux}/flutter/generated_plugins.cmake | 0 {linux => kitchenowl/linux}/icon.png | Bin .../linux}/kitchenowl.desktop | 0 {linux => kitchenowl/linux}/main.cc | 0 {linux => kitchenowl/linux}/my_application.cc | 0 {linux => kitchenowl/linux}/my_application.h | 0 {macos => kitchenowl/macos}/.gitignore | 0 .../macos}/Flutter/Flutter-Debug.xcconfig | 0 .../macos}/Flutter/Flutter-Release.xcconfig | 0 {macos => kitchenowl/macos}/Podfile | 0 .../macos}/Runner.xcodeproj/project.pbxproj | 0 .../xcshareddata/IDEWorkspaceChecks.plist | 0 .../xcshareddata/xcschemes/Runner.xcscheme | 0 .../contents.xcworkspacedata | 0 .../xcshareddata/IDEWorkspaceChecks.plist | 0 .../macos}/Runner/AppDelegate.swift | 0 .../AppIcon.appiconset/Contents.json | 0 .../AppIcon.appiconset/app_icon_1024.png | Bin .../AppIcon.appiconset/app_icon_128.png | Bin .../AppIcon.appiconset/app_icon_16.png | Bin .../AppIcon.appiconset/app_icon_256.png | Bin .../AppIcon.appiconset/app_icon_32.png | Bin .../AppIcon.appiconset/app_icon_512.png | Bin .../AppIcon.appiconset/app_icon_64.png | Bin .../macos}/Runner/Base.lproj/MainMenu.xib | 0 .../macos}/Runner/Configs/AppInfo.xcconfig | 0 .../macos}/Runner/Configs/Debug.xcconfig | 0 .../macos}/Runner/Configs/Release.xcconfig | 0 .../macos}/Runner/Configs/Warnings.xcconfig | 0 .../macos}/Runner/DebugProfile.entitlements | 0 {macos => kitchenowl/macos}/Runner/Info.plist | 0 .../macos}/Runner/MainFlutterWindow.swift | 0 .../macos}/Runner/Release.entitlements | 0 .../macos}/RunnerTests/RunnerTests.swift | 0 pubspec.yaml => kitchenowl/pubspec.yaml | 0 .../test}/helpers/named_bytearray_test.dart | 0 .../test}/models/category_test.dart | 0 .../test}/models/expense_category.dart | 0 .../test}/models/expense_test.dart | 0 .../test}/models/household_test.dart | 0 {test => kitchenowl/test}/models/item.dart | 0 .../.well-known/apple-app-site-association | 0 .../web}/.well-known/assetlinks.json | 0 {web => kitchenowl/web}/favicon.ico | Bin {web => kitchenowl/web}/favicon.png | Bin {web => kitchenowl/web}/icons/Icon-192.png | Bin {web => kitchenowl/web}/icons/Icon-512.png | Bin .../web}/icons/Icon-maskable-192.png | Bin .../web}/icons/Icon-maskable-512.png | Bin {web => kitchenowl/web}/index.html | 0 {web => kitchenowl/web}/manifest.json | 0 {windows => kitchenowl/windows}/.gitignore | 0 .../windows}/CMakeLists.txt | 0 .../windows}/flutter/CMakeLists.txt | 0 .../flutter/generated_plugin_registrant.cc | 0 .../flutter/generated_plugin_registrant.h | 0 .../windows}/flutter/generated_plugins.cmake | 0 .../windows}/runner/CMakeLists.txt | 0 .../windows}/runner/Runner.rc | 0 .../windows}/runner/flutter_window.cpp | 0 .../windows}/runner/flutter_window.h | 0 .../windows}/runner/main.cpp | 0 .../windows}/runner/resource.h | 0 .../windows}/runner/resources/app_icon.ico | Bin .../windows}/runner/runner.exe.manifest | 0 .../windows}/runner/utils.cpp | 0 .../windows}/runner/utils.h | 0 .../windows}/runner/win32_window.cpp | 0 .../windows}/runner/win32_window.h | 0 pubspec.lock | 1270 ----------------- 480 files changed, 455 insertions(+), 1478 deletions(-) rename .github/workflows/{publish.yml => deploy_backend_docker_hub.yml} (86%) create mode 100644 .github/workflows/deploy_web_docker_hub.yml delete mode 100644 analysis_options.yaml delete mode 100644 backend/CONTRIBUTING.md rename generate-item-icons.py => icons/generate-item-icons.py (92%) create mode 100644 kitchenowl/.dockerignore create mode 100644 kitchenowl/.gitignore rename .metadata => kitchenowl/.metadata (100%) create mode 100644 kitchenowl/Dockerfile create mode 100644 kitchenowl/README.md rename {android => kitchenowl/android}/.gitignore (100%) rename {android => kitchenowl/android}/Gemfile (100%) rename {android => kitchenowl/android}/app/build.gradle (100%) rename {android => kitchenowl/android}/app/src/debug/AndroidManifest.xml (100%) rename {android => kitchenowl/android}/app/src/main/AndroidManifest.xml (100%) rename {android => kitchenowl/android}/app/src/main/ic_launcher-playstore.png (100%) rename {android => kitchenowl/android}/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java (100%) rename {android => kitchenowl/android}/app/src/main/kotlin/com/tombursch/kitchenowl/MainActivity.kt (100%) rename {android => kitchenowl/android}/app/src/main/res/drawable-night-v21/background.png (100%) rename {android => kitchenowl/android}/app/src/main/res/drawable-night-v21/launch_background.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/drawable-night/background.png (100%) rename {android => kitchenowl/android}/app/src/main/res/drawable-night/launch_background.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/drawable-v21/background.png (100%) rename {android => kitchenowl/android}/app/src/main/res/drawable-v21/launch_background.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/drawable/background.png (100%) rename {android => kitchenowl/android}/app/src/main/res/drawable/launch_background.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-hdpi/ic_launcher.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-hdpi/ic_launcher_round.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-mdpi/ic_launcher.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-mdpi/ic_launcher_round.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xhdpi/ic_launcher.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xxhdpi/ic_launcher.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png (100%) rename {android => kitchenowl/android}/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename {android => kitchenowl/android}/app/src/main/res/values-night-v31/styles.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/values-night/styles.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/values-v31/styles.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/values/colors.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/values/styles.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/xml/locales_config.xml (100%) rename {android => kitchenowl/android}/app/src/main/res/xml/network_security_config.xml (100%) rename {android => kitchenowl/android}/app/src/profile/AndroidManifest.xml (100%) rename {android => kitchenowl/android}/build.gradle (100%) rename {android => kitchenowl/android}/fastlane/Appfile (100%) rename {android => kitchenowl/android}/fastlane/Fastfile (100%) rename {android => kitchenowl/android}/fastlane/README.md (100%) rename {android => kitchenowl/android}/gradle.properties (100%) rename {android => kitchenowl/android}/gradle/wrapper/gradle-wrapper.properties (100%) rename {android => kitchenowl/android}/settings.gradle (100%) rename {assets => kitchenowl/assets}/icon/icon-foreground.png (100%) rename {assets => kitchenowl/assets}/icon/icon-padded.png (100%) rename {assets => kitchenowl/assets}/icon/icon-rounded.png (100%) rename {assets => kitchenowl/assets}/icon/icon-small.png (100%) rename {assets => kitchenowl/assets}/icon/icon.png (100%) rename {assets => kitchenowl/assets}/images/google_logo.png (100%) rename dart_test.yaml => kitchenowl/dart_test.yaml (100%) rename {debian => kitchenowl/debian}/build.sh (100%) rename {debian => kitchenowl/debian}/kitchenowl/DEBIAN/control (100%) rename {debian => kitchenowl/debian}/kitchenowl/DEBIAN/postinst (100%) rename {debian => kitchenowl/debian}/kitchenowl/usr/bin/kitchenowl (100%) rename default.conf.template => kitchenowl/default.conf.template (100%) rename docker-entrypoint-custom.sh => kitchenowl/docker-entrypoint-custom.sh (100%) rename {fedora => kitchenowl/fedora}/build.sh (100%) rename {fedora => kitchenowl/fedora}/kitchenowl.spec (100%) rename {fonts => kitchenowl/fonts}/Items.ttf (100%) rename {fonts => kitchenowl/fonts}/README.md (100%) rename {fonts => kitchenowl/fonts}/Roboto-Black.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-BlackItalic.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-Bold.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-BoldItalic.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-Italic.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-Light.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-LightItalic.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-Medium.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-MediumItalic.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-Regular.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-Thin.ttf (100%) rename {fonts => kitchenowl/fonts}/Roboto-ThinItalic.ttf (100%) rename {ios => kitchenowl/ios}/.gitignore (100%) rename {ios => kitchenowl/ios}/Flutter/AppFrameworkInfo.plist (100%) rename {ios => kitchenowl/ios}/Flutter/Debug.xcconfig (100%) rename {ios => kitchenowl/ios}/Flutter/Release.xcconfig (100%) rename {ios => kitchenowl/ios}/Gemfile (100%) rename {ios => kitchenowl/ios}/Podfile (100%) rename {ios => kitchenowl/ios}/Runner.xcodeproj/project.pbxproj (100%) rename {ios => kitchenowl/ios}/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata (100%) rename {ios => kitchenowl/ios}/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (100%) rename {ios => kitchenowl/ios}/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings (100%) rename {ios => kitchenowl/ios}/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme (100%) rename {ios => kitchenowl/ios}/Runner.xcworkspace/contents.xcworkspacedata (100%) rename {ios => kitchenowl/ios}/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (100%) rename {ios => kitchenowl/ios}/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings (100%) rename {ios => kitchenowl/ios}/Runner/AppDelegate.swift (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/LaunchBackground.imageset/background.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png (100%) rename {ios => kitchenowl/ios}/Runner/Assets.xcassets/LaunchImage.imageset/README.md (100%) rename {ios => kitchenowl/ios}/Runner/Base.lproj/LaunchScreen.storyboard (100%) rename {ios => kitchenowl/ios}/Runner/Base.lproj/Main.storyboard (100%) rename {ios => kitchenowl/ios}/Runner/Info.plist (100%) rename {ios => kitchenowl/ios}/Runner/Runner-Bridging-Header.h (100%) rename {ios => kitchenowl/ios}/Runner/Runner.entitlements (100%) rename {ios => kitchenowl/ios}/RunnerTests/RunnerTests.swift (100%) rename {ios => kitchenowl/ios}/ShareExtension/Base.lproj/MainInterface.storyboard (100%) rename {ios => kitchenowl/ios}/ShareExtension/Info.plist (100%) rename {ios => kitchenowl/ios}/ShareExtension/ShareExtension.entitlements (100%) rename {ios => kitchenowl/ios}/ShareExtension/ShareViewController.swift (100%) rename {ios => kitchenowl/ios}/fastlane/Appfile (100%) rename {ios => kitchenowl/ios}/fastlane/Fastfile (100%) rename {ios => kitchenowl/ios}/fastlane/README.md (100%) rename l10n.yaml => kitchenowl/l10n.yaml (100%) rename {lib => kitchenowl/lib}/app.dart (100%) rename {lib => kitchenowl/lib}/config.dart (100%) rename {lib => kitchenowl/lib}/cubits/auth_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/email_confirm_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/expense_add_update_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/expense_category_add_update_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/expense_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/expense_list_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/expense_month_list_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/expense_overview_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/household_add_update/household_add_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/household_add_update/household_add_update_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/household_add_update/household_update_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/household_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/household_list_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/household_member_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/item_edit_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/item_search_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/item_selection_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/password_reset_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/planner_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/recipe_add_update_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/recipe_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/recipe_list_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/recipe_scraper_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/server_info_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/settings_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/settings_server_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/settings_user_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/shoppinglist_cubit.dart (100%) rename {lib => kitchenowl/lib}/cubits/user_search_cubit.dart (100%) rename {lib => kitchenowl/lib}/enums/expenselist_sorting.dart (100%) rename {lib => kitchenowl/lib}/enums/oidc_provider.dart (100%) rename {lib => kitchenowl/lib}/enums/shoppinglist_sorting.dart (100%) rename {lib => kitchenowl/lib}/enums/timeframe.dart (100%) rename {lib => kitchenowl/lib}/enums/token_type_enum.dart (100%) rename {lib => kitchenowl/lib}/enums/update_enum.dart (100%) rename {lib => kitchenowl/lib}/enums/views_enum.dart (100%) rename {lib => kitchenowl/lib}/helpers/currency_text_input_formatter.dart (100%) rename {lib => kitchenowl/lib}/helpers/debouncer.dart (100%) rename {lib => kitchenowl/lib}/helpers/fade_through_transition_page.dart (100%) rename {lib => kitchenowl/lib}/helpers/named_bytearray.dart (100%) rename {lib => kitchenowl/lib}/helpers/recipe_item_markdown_extension.dart (100%) rename {lib => kitchenowl/lib}/helpers/shared_axis_transition_page.dart (100%) rename {lib => kitchenowl/lib}/helpers/string_scaler.dart (100%) rename {lib => kitchenowl/lib}/helpers/url_launcher.dart (100%) rename {lib => kitchenowl/lib}/helpers/username_text_input_formatter.dart (100%) rename {lib => kitchenowl/lib}/item_icons.dart (100%) rename {lib => kitchenowl/lib}/kitchenowl.dart (100%) rename {lib => kitchenowl/lib}/l10n/app_be.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_ca.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_ca_valencia.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_cs.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_da.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_de.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_el.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_en.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_en_AU.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_es.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_fi.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_fr.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_hu.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_id.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_it.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_nb.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_nl.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_pa.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_pl.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_pt.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_pt_BR.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_ro.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_ru.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_sv.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_tr.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_vi.arb (100%) rename {lib => kitchenowl/lib}/l10n/app_zh.arb (100%) rename {lib => kitchenowl/lib}/main.dart (100%) rename {lib => kitchenowl/lib}/models/category.dart (100%) rename {lib => kitchenowl/lib}/models/expense.dart (100%) rename {lib => kitchenowl/lib}/models/expense_category.dart (100%) rename {lib => kitchenowl/lib}/models/expense_overview.dart (100%) rename {lib => kitchenowl/lib}/models/household.dart (100%) rename {lib => kitchenowl/lib}/models/import_settings.dart (100%) rename {lib => kitchenowl/lib}/models/item.dart (100%) rename {lib => kitchenowl/lib}/models/member.dart (100%) rename {lib => kitchenowl/lib}/models/model.dart (100%) rename {lib => kitchenowl/lib}/models/nullable.dart (100%) rename {lib => kitchenowl/lib}/models/planner.dart (100%) rename {lib => kitchenowl/lib}/models/recipe.dart (100%) rename {lib => kitchenowl/lib}/models/recipe_scrape.dart (100%) rename {lib => kitchenowl/lib}/models/shoppinglist.dart (100%) rename {lib => kitchenowl/lib}/models/tag.dart (100%) rename {lib => kitchenowl/lib}/models/token.dart (100%) rename {lib => kitchenowl/lib}/models/update_value.dart (100%) rename {lib => kitchenowl/lib}/models/user.dart (100%) rename {lib => kitchenowl/lib}/pages/analytics_page.dart (100%) rename {lib => kitchenowl/lib}/pages/email_confirm_page.dart (100%) rename {lib => kitchenowl/lib}/pages/expense_add_update_page.dart (100%) rename {lib => kitchenowl/lib}/pages/expense_category_add_page.dart (100%) rename {lib => kitchenowl/lib}/pages/expense_month_list_page.dart (100%) rename {lib => kitchenowl/lib}/pages/expense_overview_page.dart (100%) rename {lib => kitchenowl/lib}/pages/expense_page.dart (100%) rename {lib => kitchenowl/lib}/pages/household_add_page.dart (100%) rename {lib => kitchenowl/lib}/pages/household_list_page.dart (100%) rename {lib => kitchenowl/lib}/pages/household_member_page.dart (100%) rename {lib => kitchenowl/lib}/pages/household_page.dart (100%) rename {lib => kitchenowl/lib}/pages/household_page/_export.dart (100%) rename {lib => kitchenowl/lib}/pages/household_page/expense_list.dart (100%) rename {lib => kitchenowl/lib}/pages/household_page/household_drawer.dart (100%) rename {lib => kitchenowl/lib}/pages/household_page/household_navigation_rail.dart (100%) rename {lib => kitchenowl/lib}/pages/household_page/planner.dart (100%) rename {lib => kitchenowl/lib}/pages/household_page/profile.dart (100%) rename {lib => kitchenowl/lib}/pages/household_page/recipe_list.dart (100%) rename {lib => kitchenowl/lib}/pages/household_page/shoppinglist.dart (100%) rename {lib => kitchenowl/lib}/pages/household_update_page.dart (100%) rename {lib => kitchenowl/lib}/pages/icon_selection_page.dart (100%) rename {lib => kitchenowl/lib}/pages/item_page.dart (100%) rename {lib => kitchenowl/lib}/pages/item_search_page.dart (100%) rename {lib => kitchenowl/lib}/pages/item_selection_page.dart (100%) rename {lib => kitchenowl/lib}/pages/login_page.dart (100%) rename {lib => kitchenowl/lib}/pages/login_redirect_page.dart (100%) rename {lib => kitchenowl/lib}/pages/onboarding_page.dart (100%) rename {lib => kitchenowl/lib}/pages/page_not_found.dart (100%) rename {lib => kitchenowl/lib}/pages/password_forgot_page.dart (100%) rename {lib => kitchenowl/lib}/pages/password_reset_page.dart (100%) rename {lib => kitchenowl/lib}/pages/photo_view_page.dart (100%) rename {lib => kitchenowl/lib}/pages/recipe_add_update_page.dart (100%) rename {lib => kitchenowl/lib}/pages/recipe_page.dart (100%) rename {lib => kitchenowl/lib}/pages/recipe_scraper_page.dart (100%) rename {lib => kitchenowl/lib}/pages/settings/create_user_page.dart (100%) rename {lib => kitchenowl/lib}/pages/settings_page.dart (100%) rename {lib => kitchenowl/lib}/pages/settings_server_user_page.dart (100%) rename {lib => kitchenowl/lib}/pages/settings_user_email_page.dart (100%) rename {lib => kitchenowl/lib}/pages/settings_user_linked_accounts_page.dart (100%) rename {lib => kitchenowl/lib}/pages/settings_user_page.dart (100%) rename {lib => kitchenowl/lib}/pages/settings_user_password_page.dart (100%) rename {lib => kitchenowl/lib}/pages/settings_user_sessions_page.dart (100%) rename {lib => kitchenowl/lib}/pages/setup_page.dart (100%) rename {lib => kitchenowl/lib}/pages/signup_page.dart (100%) rename {lib => kitchenowl/lib}/pages/splash_page.dart (100%) rename {lib => kitchenowl/lib}/pages/unreachable_page.dart (100%) rename {lib => kitchenowl/lib}/pages/unsupported_page.dart (100%) rename {lib => kitchenowl/lib}/pages/user_search_page.dart (100%) rename {lib => kitchenowl/lib}/router.dart (100%) rename {lib => kitchenowl/lib}/services/api/analytics.dart (100%) rename {lib => kitchenowl/lib}/services/api/api_service.dart (100%) rename {lib => kitchenowl/lib}/services/api/category.dart (100%) rename {lib => kitchenowl/lib}/services/api/expense.dart (100%) rename {lib => kitchenowl/lib}/services/api/household.dart (100%) rename {lib => kitchenowl/lib}/services/api/import_export.dart (100%) rename {lib => kitchenowl/lib}/services/api/item.dart (100%) rename {lib => kitchenowl/lib}/services/api/planner.dart (100%) rename {lib => kitchenowl/lib}/services/api/recipe.dart (100%) rename {lib => kitchenowl/lib}/services/api/shoppinglist.dart (100%) rename {lib => kitchenowl/lib}/services/api/tag.dart (100%) rename {lib => kitchenowl/lib}/services/api/upload.dart (100%) rename {lib => kitchenowl/lib}/services/api/user.dart (100%) rename {lib => kitchenowl/lib}/services/storage/mem_storage.dart (100%) rename {lib => kitchenowl/lib}/services/storage/storage.dart (100%) rename {lib => kitchenowl/lib}/services/storage/temp_storage.dart (100%) rename {lib => kitchenowl/lib}/services/storage/transaction_storage.dart (100%) rename {lib => kitchenowl/lib}/services/transaction.dart (100%) rename {lib => kitchenowl/lib}/services/transaction_handler.dart (100%) rename {lib => kitchenowl/lib}/services/transactions/category.dart (100%) rename {lib => kitchenowl/lib}/services/transactions/expense.dart (100%) rename {lib => kitchenowl/lib}/services/transactions/household.dart (100%) rename {lib => kitchenowl/lib}/services/transactions/item.dart (100%) rename {lib => kitchenowl/lib}/services/transactions/planner.dart (100%) rename {lib => kitchenowl/lib}/services/transactions/recipe.dart (100%) rename {lib => kitchenowl/lib}/services/transactions/shoppinglist.dart (100%) rename {lib => kitchenowl/lib}/services/transactions/tag.dart (100%) rename {lib => kitchenowl/lib}/services/transactions/user.dart (100%) rename {lib => kitchenowl/lib}/styles/colors.dart (100%) rename {lib => kitchenowl/lib}/styles/dynamic.dart (100%) rename {lib => kitchenowl/lib}/styles/themes.dart (100%) rename {lib => kitchenowl/lib}/widgets/_export.dart (100%) rename {lib => kitchenowl/lib}/widgets/chart_bar_member_distribution.dart (100%) rename {lib => kitchenowl/lib}/widgets/chart_bar_months.dart (100%) rename {lib => kitchenowl/lib}/widgets/chart_line_current_month.dart (100%) rename {lib => kitchenowl/lib}/widgets/chart_pie_current_month.dart (100%) rename {lib => kitchenowl/lib}/widgets/checkbox_list_tile.dart (100%) rename {lib => kitchenowl/lib}/widgets/choice_scroll.dart (100%) rename {lib => kitchenowl/lib}/widgets/confirmation_dialog.dart (100%) rename {lib => kitchenowl/lib}/widgets/create_user_form_fields.dart (100%) rename {lib => kitchenowl/lib}/widgets/dismissible_card.dart (100%) rename {lib => kitchenowl/lib}/widgets/expandable_fab.dart (100%) rename {lib => kitchenowl/lib}/widgets/expense/timeframe_dropdown_button.dart (100%) rename {lib => kitchenowl/lib}/widgets/expense_add_update/paid_for_widget.dart (100%) rename {lib => kitchenowl/lib}/widgets/expense_category_icon.dart (100%) rename {lib => kitchenowl/lib}/widgets/expense_create_fab.dart (100%) rename {lib => kitchenowl/lib}/widgets/expense_item.dart (100%) rename {lib => kitchenowl/lib}/widgets/flexible_image_space_bar.dart (100%) rename {lib => kitchenowl/lib}/widgets/fractionally_sized_box.dart (100%) rename {lib => kitchenowl/lib}/widgets/home_page/sliver_category_item_grid_list.dart (100%) rename {lib => kitchenowl/lib}/widgets/household_card.dart (100%) rename {lib => kitchenowl/lib}/widgets/image_provider.dart (100%) rename {lib => kitchenowl/lib}/widgets/image_selector.dart (100%) rename {lib => kitchenowl/lib}/widgets/kitchenowl_color_picker_dialog.dart (100%) rename {lib => kitchenowl/lib}/widgets/kitchenowl_fab.dart (100%) rename {lib => kitchenowl/lib}/widgets/kitchenowl_switch.dart (100%) rename {lib => kitchenowl/lib}/widgets/language_dialog.dart (100%) rename {lib => kitchenowl/lib}/widgets/left_right_wrap.dart (100%) rename {lib => kitchenowl/lib}/widgets/loading_elevated_button.dart (100%) rename {lib => kitchenowl/lib}/widgets/loading_elevated_button_icon.dart (100%) rename {lib => kitchenowl/lib}/widgets/loading_icon_button.dart (100%) rename {lib => kitchenowl/lib}/widgets/loading_list_tile.dart (100%) rename {lib => kitchenowl/lib}/widgets/loading_text_button.dart (100%) rename {lib => kitchenowl/lib}/widgets/number_selector.dart (100%) rename {lib => kitchenowl/lib}/widgets/recipe_card.dart (100%) rename {lib => kitchenowl/lib}/widgets/recipe_create_fab.dart (100%) rename {lib => kitchenowl/lib}/widgets/recipe_item.dart (100%) rename {lib => kitchenowl/lib}/widgets/recipe_source_chip.dart (100%) rename {lib => kitchenowl/lib}/widgets/recipe_time_settings.dart (100%) rename {lib => kitchenowl/lib}/widgets/rendering/sliver_with_pinned_footer.dart (100%) rename {lib => kitchenowl/lib}/widgets/search_text_field.dart (100%) rename {lib => kitchenowl/lib}/widgets/select_dialog.dart (100%) rename {lib => kitchenowl/lib}/widgets/select_file.dart (100%) rename {lib => kitchenowl/lib}/widgets/selectable_button_card.dart (100%) rename {lib => kitchenowl/lib}/widgets/selectable_button_list_tile.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings/color_button.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings/server_user_card.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings/token_bottom_sheet.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings/token_card.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings_household/import_settings_dialog.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings_household/sliver_household_category_settings.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings_household/sliver_household_danger_zone.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings_household/sliver_household_expense_category_settings.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings_household/sliver_household_feature_settings.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings_household/sliver_household_shoppinglist_settings.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings_household/sliver_household_tags_settings.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings_household/update_member_bottom_sheet.dart (100%) rename {lib => kitchenowl/lib}/widgets/settings_household/view_settings_list_tile.dart (100%) rename {lib => kitchenowl/lib}/widgets/shimmer_card.dart (100%) rename {lib => kitchenowl/lib}/widgets/shimmer_shopping_item.dart (100%) rename {lib => kitchenowl/lib}/widgets/shopping_item.dart (100%) rename {lib => kitchenowl/lib}/widgets/shoppinglist_confirm_remove_fab.dart (100%) rename {lib => kitchenowl/lib}/widgets/show_snack_bar.dart (100%) rename {lib => kitchenowl/lib}/widgets/sliver_implicit_animated_list.dart (100%) rename {lib => kitchenowl/lib}/widgets/sliver_item_grid_list.dart (100%) rename {lib => kitchenowl/lib}/widgets/sliver_text.dart (100%) rename {lib => kitchenowl/lib}/widgets/sliver_with_pinned_footer.dart (100%) rename {lib => kitchenowl/lib}/widgets/string_item_match.dart (100%) rename {lib => kitchenowl/lib}/widgets/text_dialog.dart (100%) rename {lib => kitchenowl/lib}/widgets/text_with_icon_button.dart (100%) rename {lib => kitchenowl/lib}/widgets/trailing_icon_text_button.dart (100%) rename {lib => kitchenowl/lib}/widgets/user_list_tile.dart (100%) rename {linux => kitchenowl/linux}/.gitignore (100%) rename {linux => kitchenowl/linux}/CMakeLists.txt (100%) rename {linux => kitchenowl/linux}/flutter/CMakeLists.txt (100%) rename {linux => kitchenowl/linux}/flutter/generated_plugin_registrant.cc (100%) rename {linux => kitchenowl/linux}/flutter/generated_plugin_registrant.h (100%) rename {linux => kitchenowl/linux}/flutter/generated_plugins.cmake (100%) rename {linux => kitchenowl/linux}/icon.png (100%) rename {linux => kitchenowl/linux}/kitchenowl.desktop (100%) rename {linux => kitchenowl/linux}/main.cc (100%) rename {linux => kitchenowl/linux}/my_application.cc (100%) rename {linux => kitchenowl/linux}/my_application.h (100%) rename {macos => kitchenowl/macos}/.gitignore (100%) rename {macos => kitchenowl/macos}/Flutter/Flutter-Debug.xcconfig (100%) rename {macos => kitchenowl/macos}/Flutter/Flutter-Release.xcconfig (100%) rename {macos => kitchenowl/macos}/Podfile (100%) rename {macos => kitchenowl/macos}/Runner.xcodeproj/project.pbxproj (100%) rename {macos => kitchenowl/macos}/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (100%) rename {macos => kitchenowl/macos}/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme (100%) rename {macos => kitchenowl/macos}/Runner.xcworkspace/contents.xcworkspacedata (100%) rename {macos => kitchenowl/macos}/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (100%) rename {macos => kitchenowl/macos}/Runner/AppDelegate.swift (100%) rename {macos => kitchenowl/macos}/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename {macos => kitchenowl/macos}/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png (100%) rename {macos => kitchenowl/macos}/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png (100%) rename {macos => kitchenowl/macos}/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png (100%) rename {macos => kitchenowl/macos}/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png (100%) rename {macos => kitchenowl/macos}/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png (100%) rename {macos => kitchenowl/macos}/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png (100%) rename {macos => kitchenowl/macos}/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png (100%) rename {macos => kitchenowl/macos}/Runner/Base.lproj/MainMenu.xib (100%) rename {macos => kitchenowl/macos}/Runner/Configs/AppInfo.xcconfig (100%) rename {macos => kitchenowl/macos}/Runner/Configs/Debug.xcconfig (100%) rename {macos => kitchenowl/macos}/Runner/Configs/Release.xcconfig (100%) rename {macos => kitchenowl/macos}/Runner/Configs/Warnings.xcconfig (100%) rename {macos => kitchenowl/macos}/Runner/DebugProfile.entitlements (100%) rename {macos => kitchenowl/macos}/Runner/Info.plist (100%) rename {macos => kitchenowl/macos}/Runner/MainFlutterWindow.swift (100%) rename {macos => kitchenowl/macos}/Runner/Release.entitlements (100%) rename {macos => kitchenowl/macos}/RunnerTests/RunnerTests.swift (100%) rename pubspec.yaml => kitchenowl/pubspec.yaml (100%) rename {test => kitchenowl/test}/helpers/named_bytearray_test.dart (100%) rename {test => kitchenowl/test}/models/category_test.dart (100%) rename {test => kitchenowl/test}/models/expense_category.dart (100%) rename {test => kitchenowl/test}/models/expense_test.dart (100%) rename {test => kitchenowl/test}/models/household_test.dart (100%) rename {test => kitchenowl/test}/models/item.dart (100%) rename {web => kitchenowl/web}/.well-known/apple-app-site-association (100%) rename {web => kitchenowl/web}/.well-known/assetlinks.json (100%) rename {web => kitchenowl/web}/favicon.ico (100%) rename {web => kitchenowl/web}/favicon.png (100%) rename {web => kitchenowl/web}/icons/Icon-192.png (100%) rename {web => kitchenowl/web}/icons/Icon-512.png (100%) rename {web => kitchenowl/web}/icons/Icon-maskable-192.png (100%) rename {web => kitchenowl/web}/icons/Icon-maskable-512.png (100%) rename {web => kitchenowl/web}/index.html (100%) rename {web => kitchenowl/web}/manifest.json (100%) rename {windows => kitchenowl/windows}/.gitignore (100%) rename {windows => kitchenowl/windows}/CMakeLists.txt (100%) rename {windows => kitchenowl/windows}/flutter/CMakeLists.txt (100%) rename {windows => kitchenowl/windows}/flutter/generated_plugin_registrant.cc (100%) rename {windows => kitchenowl/windows}/flutter/generated_plugin_registrant.h (100%) rename {windows => kitchenowl/windows}/flutter/generated_plugins.cmake (100%) rename {windows => kitchenowl/windows}/runner/CMakeLists.txt (100%) rename {windows => kitchenowl/windows}/runner/Runner.rc (100%) rename {windows => kitchenowl/windows}/runner/flutter_window.cpp (100%) rename {windows => kitchenowl/windows}/runner/flutter_window.h (100%) rename {windows => kitchenowl/windows}/runner/main.cpp (100%) rename {windows => kitchenowl/windows}/runner/resource.h (100%) rename {windows => kitchenowl/windows}/runner/resources/app_icon.ico (100%) rename {windows => kitchenowl/windows}/runner/runner.exe.manifest (100%) rename {windows => kitchenowl/windows}/runner/utils.cpp (100%) rename {windows => kitchenowl/windows}/runner/utils.h (100%) rename {windows => kitchenowl/windows}/runner/win32_window.cpp (100%) rename {windows => kitchenowl/windows}/runner/win32_window.h (100%) delete mode 100755 pubspec.lock diff --git a/.dockerignore b/.dockerignore index a89a5353..8e31edd3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,19 @@ -# Docker ignore (include only web files) +# General files +.git +.github + +# KitchenOwl +icons/ docs/ -fedora/ -ios/ -android/ -linux/ -macos/ -windows/ -.github/ +kitchenowl/fedora/ +kitchenowl/ios/ +kitchenowl/android/ +kitchenowl/linux/ +kitchenowl/macos/ +kitchenowl/windows/ +backend/upload/ +backend/database.db # .gitignore here: # Miscellaneous @@ -84,60 +90,15 @@ unlinked_spec.ds **/android/key.properties *.jks -# iOS/XCode related -**/ios/**/*.mode1v3 -**/ios/**/*.mode2v3 -**/ios/**/*.moved-aside -**/ios/**/*.pbxuser -**/ios/**/*.perspectivev3 -**/ios/**/*sync/ -**/ios/**/.sconsign.dblite -**/ios/**/.tags* -**/ios/**/.vagrant/ -**/ios/**/DerivedData/ -**/ios/**/Icon? -**/ios/**/Pods/ -**/ios/**/.symlinks/ -**/ios/**/profile -**/ios/**/xcuserdata -**/ios/.generated/ -**/ios/Flutter/.last_build_id -**/ios/Flutter/App.framework -**/ios/Flutter/Flutter.framework -**/ios/Flutter/Flutter.podspec -**/ios/Flutter/Generated.xcconfig -**/ios/Flutter/ephemeral -**/ios/Flutter/app.flx -**/ios/Flutter/app.zip -**/ios/Flutter/flutter_assets/ -**/ios/Flutter/flutter_export_environment.sh -**/ios/ServiceDefinitions.json -**/ios/Runner/GeneratedPluginRegistrant.* - -# macOS -**/macos/Flutter/GeneratedPluginRegistrant.swift -**/macos/Flutter/ephemeral - # Coverage coverage/ # Symbols app.*.symbols -# Exceptions to above rules. -!**/ios/**/default.mode1v3 -!**/ios/**/default.mode2v3 -!**/ios/**/default.pbxuser -!**/ios/**/default.perspectivev3 -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock - # MkDocs site/ -# KitchenOwl -icons/ - # Development .devcontainer diff --git a/.github/workflows/publish.yml b/.github/workflows/deploy_backend_docker_hub.yml similarity index 86% rename from .github/workflows/publish.yml rename to .github/workflows/deploy_backend_docker_hub.yml index f6ab1a76..308501c2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/deploy_backend_docker_hub.yml @@ -1,10 +1,12 @@ -name: CI to Docker Hub +name: CI deploy backend to Docker Hub # Controls when the workflow will run on: # Triggers the workflow on push events but only for tags push: branches: [ main ] + paths: + - backend/** tags: - "v*" - "beta-v*" @@ -21,7 +23,7 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: decide docker tags id: dockertag @@ -37,27 +39,27 @@ jobs: fi env: REF: ${{ github.ref }} - BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl + BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-backend - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build and push id: docker_build - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: ./ - file: ./Dockerfile + file: backend/Dockerfile platforms: linux/amd64,linux/arm64 #,linux/arm/v7 #,linux/386,linux/arm/v6 push: true tags: ${{ env.tags }} diff --git a/.github/workflows/deploy_docker_hub.yml b/.github/workflows/deploy_docker_hub.yml index 54152be8..d2337670 100644 --- a/.github/workflows/deploy_docker_hub.yml +++ b/.github/workflows/deploy_docker_hub.yml @@ -6,13 +6,8 @@ on: push: branches: [ main ] paths: - - lib/** - - web/** - - assets/** - - Dockerfile - - entrypoint.sh - - pubspec.yaml - - default.conf.template + - kitchenowl/** + - backend/** tags: - "v*" - "beta-v*" @@ -45,10 +40,7 @@ jobs: fi env: REF: ${{ github.ref }} - BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web - - - name: Tags - run: echo ${{ env.tags }} + BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl - name: Login to Docker Hub uses: docker/login-action@v3 @@ -69,11 +61,11 @@ jobs: with: context: ./ file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/arm/v6 + platforms: linux/amd64,linux/arm64 #,linux/arm/v7 #,linux/386,linux/arm/v6 push: true tags: ${{ env.tags }} - cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web:buildcache - cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web:buildcache,mode=max + # cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache + # cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl:buildcache,mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 7aced200..4c3c2d21 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -6,6 +6,7 @@ on: paths: - docs/** - mkdocs.yml + - docs-requirements.txt workflow_dispatch: jobs: diff --git a/.github/workflows/deploy_play_store.yml b/.github/workflows/deploy_play_store.yml index 4834e12f..f8908c1e 100644 --- a/.github/workflows/deploy_play_store.yml +++ b/.github/workflows/deploy_play_store.yml @@ -26,12 +26,12 @@ jobs: with: channel: stable - run: flutter config --no-analytics - - run: flutter doctor -v # Checkout code and get packages. - name: Checkout code uses: actions/checkout@v4 - run: flutter packages get + working-directory: kitchenowl/android # Decide track internal|beta|production (not in use yet) - name: Decide track @@ -55,7 +55,7 @@ jobs: with: ruby-version: "3.2" bundler-cache: true - working-directory: android + working-directory: kitchenowl/android - name: Configure Keystore run: | @@ -69,11 +69,11 @@ jobs: KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} - working-directory: android + working-directory: kitchenowl/android # Build and deploy with Fastlane (by default, to internal track) 🚀. # Naturally, promote_to_production only deploys. - run: bundle exec fastlane ${{ github.event.inputs.lane || 'internal' }} env: PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }} - working-directory: android + working-directory: kitchenowl/android diff --git a/.github/workflows/deploy_web_docker_hub.yml b/.github/workflows/deploy_web_docker_hub.yml new file mode 100644 index 00000000..0030776e --- /dev/null +++ b/.github/workflows/deploy_web_docker_hub.yml @@ -0,0 +1,70 @@ +name: CI deploy web to Docker Hub + +# Controls when the workflow will run +on: + # Triggers the workflow on push events of tags + push: + branches: [ main ] + paths: + - kitchenowl/** + tags: + - "v*" + - "beta-v*" + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + - name: decide docker tags + id: dockertag + run: | + if [[ $REF == "refs/tags/v"* ]] + then + echo "tags=$BASE_TAG:latest, $BASE_TAG:beta, $BASE_TAG:${REF#refs/tags/}" >> $GITHUB_ENV + elif [[ $REF == "refs/tags/beta-v"* ]] + then + echo "tags=$BASE_TAG:beta, $BASE_TAG:${REF#refs/tags/}" >> $GITHUB_ENV + else + echo "tags=$BASE_TAG:dev" >> $GITHUB_ENV + fi + env: + REF: ${{ github.ref }} + BASE_TAG: ${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v5 + with: + context: ./ + file: kitchenowl/Dockerfile + platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7,linux/arm/v6 + push: true + tags: ${{ env.tags }} + cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web:buildcache + cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/kitchenowl-web:buildcache,mode=max + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fcdd664a..44064c6a 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -6,8 +6,12 @@ name: Pytesting on: push: branches: [ main ] + paths: + - backend/** pull_request: branches: [ main ] + paths: + - backend/** jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 725a669b..a2f1af09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,31 +52,31 @@ jobs: - os: macos-latest target: macOS build_target: macos - build_path: build/macos/Build/Products/Release + build_path: kitchenowl/build/macos/Build/Products/Release asset_extension: .zip asset_content_type: application/zip - os: windows-latest target: Windows build_target: windows - build_path: build\windows\runner\Release + build_path: kitchenowl/build\windows\runner\Release asset_extension: .zip asset_content_type: application/zip - os: ubuntu-latest target: Linux build_target: linux - build_path: build/linux/x64/release/bundle + build_path: kitchenowl/build/linux/x64/release/bundle asset_extension: .tar.gz asset_content_type: application/gzip - os: ubuntu-latest target: Android build_target: apk - build_path: build/app/outputs/flutter-apk + build_path: kitchenowl/build/app/outputs/flutter-apk asset_extension: .apk asset_content_type: application/vnd.android.package-archive - os: ubuntu-latest target: Debian build_target: linux - build_path: build/debian/release + build_path: kitchenowl/build/debian/release asset_extension: .deb asset_content_type: application/vnd.debian.binary-package - os: ubuntu-latest @@ -85,13 +85,13 @@ jobs: options: --group-add 135 target: Fedora build_target: linux - build_path: build/fedora/release + build_path: kitchenowl/build/fedora/release asset_extension: .rpm asset_content_type: application/x-rpm - os: ubuntu-latest target: Web build_target: web --dart-define=FLUTTER_WEB_CANVASKIT_URL=/canvaskit/ - build_path: build/web + build_path: kitchenowl/build/web asset_extension: .tar.gz asset_content_type: application/gzip # Disable fail-fast as we want results from all even if one fails. @@ -133,7 +133,6 @@ jobs: git clone https://github.com/flutter/flutter.git -b stable echo "/usr/local/src/flutter/bin" >> $GITHUB_PATH export PATH="$PATH:/usr/local/src/flutter/bin" - flutter doctor working-directory: /usr/local/src @@ -164,10 +163,11 @@ jobs: KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} - working-directory: android + working-directory: kitchenowl/android # Build the application. - run: flutter build -v ${{ matrix.build_target }} --release + working-directory: kitchenowl # Package the build. - name: Copy VC redistributables to release directory for Windows @@ -176,6 +176,7 @@ jobs: Copy-Item (vswhere -latest -find 'VC\Redist\MSVC\*\x64\*\msvcp140.dll') . Copy-Item (vswhere -latest -find 'VC\Redist\MSVC\*\x64\*\vcruntime140.dll') . Copy-Item (vswhere -latest -find 'VC\Redist\MSVC\*\x64\*\vcruntime140_1.dll') . + working-directory: kitchenowl - name: Rename build for Android if: matrix.target == 'Android' run: mv app-release.apk $GITHUB_WORKSPACE/kitchenowl_${{ matrix.target }}.apk @@ -195,7 +196,7 @@ jobs: - name: Package build for debian if: matrix.target == 'Debian' run: ./build.sh - working-directory: debian + working-directory: kitchenowl/debian - name: Rename build for debian if: matrix.target == 'Debian' run: mv kitchenowl.deb $GITHUB_WORKSPACE/kitchenowl_${{ matrix.target }}.deb @@ -203,7 +204,7 @@ jobs: - name: Package build for Fedora if: matrix.target == 'Fedora' run: ./build.sh - working-directory: fedora + working-directory: kitchenowl/fedora - name: Rename build for fedora if: matrix.target == 'Fedora' run: mv KitchenOwl.x86_64.rpm $GITHUB_WORKSPACE/kitchenowl_${{ matrix.target }}.rpm diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 45ca2d3d..3f3f4cdc 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -3,10 +3,14 @@ name: Quality on: push: branches: [main] + paths: + - kitchenowl/** pull_request: types: - opened - synchronize + paths: + - kitchenowl/** jobs: analyze: @@ -19,10 +23,14 @@ jobs: with: channel: stable - run: flutter packages get + working-directory: kitchenowl # Run analyze - run: flutter analyze + working-directory: kitchenowl - uses: leancodepl/dart-problem-matcher@main + with: + working-directory: kitchenowl test: name: Tests runs-on: ubuntu-latest @@ -34,13 +42,15 @@ jobs: with: channel: stable - run: flutter packages get + working-directory: kitchenowl # Run tests - run: flutter test --machine > test-results.json + working-directory: kitchenowl # upload test results - uses: actions/upload-artifact@v3 if: success() || failure() # run this step even if previous step failed with: name: test-results - path: test-results.json + path: kitchenowl/test-results.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index addf4584..c3ad90fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,12 +27,6 @@ The `description` is a descriptive summary of the change the PR will make. - All PRs should be rebased (with main) and commits squashed prior to the final merge process - One PR per fix or feature -### Setup & Install -- [Install flutter](https://flutter.dev/docs/get-started/install) -- Install dependencies: `flutter packages get` -- Create empty environment file: `touch .env` -- Run app: `flutter run` - ### Git Commit Message Style This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format. diff --git a/Dockerfile b/Dockerfile index 7e2b7b9d..44120127 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ------------ -# BUILDER +# WEB BUILDER # ------------ -FROM --platform=$BUILDPLATFORM debian:latest AS builder +FROM --platform=$BUILDPLATFORM debian:latest AS app_builder # Install dependencies RUN apt-get update -y @@ -36,11 +36,11 @@ RUN flutter upgrade RUN flutter doctor -v # Copy the app files to the container -COPY .metadata l10n.yaml pubspec.yaml /usr/local/src/app/ -COPY lib /usr/local/src/app/lib -COPY web /usr/local/src/app/web -COPY assets /usr/local/src/app/assets -COPY fonts /usr/local/src/app/fonts +COPY kitchenowl/.metadata kitchenowl/l10n.yaml kitchenowl/pubspec.yaml /usr/local/src/app/ +COPY kitchenowl/lib /usr/local/src/app/lib +COPY kitchenowl/web /usr/local/src/app/web +COPY kitchenowl/assets /usr/local/src/app/assets +COPY kitchenowl/fonts /usr/local/src/app/fonts # Set the working directory to the app files within the container WORKDIR /usr/local/src/app @@ -51,20 +51,59 @@ RUN flutter packages get # Build the app for the web RUN flutter build web --release --dart-define=FLUTTER_WEB_CANVASKIT_URL=/canvaskit/ +# ------------ +# BACKEND BUILDER +# ------------ +FROM python:3.11-slim as backend_builder + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + gcc g++ libffi-dev libpcre3-dev build-essential cargo \ + libxml2-dev libxslt-dev cmake gfortran libopenblas-dev liblapack-dev pkg-config ninja-build \ + autoconf automake zlib1g-dev libjpeg62-turbo-dev libssl-dev libsqlite3-dev + +# Create virtual enviroment +RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -U pip setuptools wheel +ENV PATH="/opt/venv/bin:$PATH" + +COPY backend/requirements.txt . +RUN pip3 install --no-cache-dir -r requirements.txt && find /opt/venv \( -type d -a -name test -o -name tests \) -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ + +RUN python -c "import nltk; nltk.download('averaged_perceptron_tagger', download_dir='/opt/venv/nltk_data')" + # ------------ # RUNNER # ------------ -FROM nginx:stable-alpine +FROM python:3.11-slim as runner + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + libxml2 libpcre3 curl \ + && rm -rf /var/lib/apt/lists/* + +# Use virtual enviroment +COPY --from=backend_builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +# Setup Frontend RUN mkdir -p /var/www/web/kitchenowl -COPY --from=builder /usr/local/src/app/build/web /var/www/web/kitchenowl -COPY docker-entrypoint-custom.sh /docker-entrypoint.d/01-kitchenowl-customization.sh -COPY default.conf.template /etc/nginx/templates/ +COPY --from=app_builder /usr/local/src/app/build/web /var/www/web/kitchenowl + +# Setup KitchenOwl Backend +COPY backend/wsgi.ini backend/wsgi.py backend/entrypoint.sh backend/manage.py backend/manage_default_items.py backend/upgrade_default_items.py /usr/src/kitchenowl/ +COPY backend/app /usr/src/kitchenowl/app +COPY backend/templates /usr/src/kitchenowl/templates +COPY backend/migrations /usr/src/kitchenowl/migrations +WORKDIR /usr/src/kitchenowl +VOLUME ["/data"] + +HEALTHCHECK --interval=60s --timeout=3s CMD uwsgi_curl localhost:5000 /api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V || exit 1 -HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1 +ENV STORAGE_PATH='/data' +ENV JWT_SECRET_KEY='PLEASE_CHANGE_ME' +ENV DEBUG='False' -# Set ENV -ENV BACK_URL='back:5000' +RUN chmod u+x ./entrypoint.sh -# Expose the web server -EXPOSE 80 \ No newline at end of file +CMD ["--ini", "wsgi.ini:web", "--gevent", "200"] +ENTRYPOINT ["./entrypoint.sh"] diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 6636d350..00000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -analyzer: - exclude: - - flutter/** - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - library_private_types_in_public_api: false - use_build_context_synchronously: false - no_leading_underscores_for_local_identifiers: false - # prefer_single_quotes: true diff --git a/backend/.dockerignore b/backend/.dockerignore index cbe46ec9..e09b94ad 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -2,6 +2,7 @@ .git .github database.db +upload/ docs # Development diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md deleted file mode 100644 index 34f12157..00000000 --- a/backend/CONTRIBUTING.md +++ /dev/null @@ -1,52 +0,0 @@ -## Contributing - -Thanks for wanting to contribute to KitchenOwl! - -### Where do I go from here? - -So you want to contribute to KitchenOwl? Great! - -If you have noticed a bug, please [create an issue](https://github.com/TomBursch/KitchenOwl/issues/new) for it before starting any work on a pull request. - -### Fork & create a branch - -If there is something you want to fix or add, the first step is to fork this repository. - -Next is to create a new branch with an appropriate name. The general format that should be used is - -``` -git checkout -b '/' -``` - -The `type` is the same as the `type` that you will use for [your commit message](https://www.conventionalcommits.org/en/v1.0.0/#summary). - -The `description` is a descriptive summary of the change the PR will make. - -### General Rules - -- All PRs should be rebased (with main) and commits squashed prior to the final merge process -- One PR per fix or feature - -### Requirements -- Python 3.11+ - -### Setup & Install -- Create a python environment `python3 -m venv venv` -- Activate your python environment `source venv/bin/activate` (environment can be deactivated with `deactivate`) -- Install dependencies `pip3 install -r requirements.txt` -- Initialize/Upgrade the SQLite database with `flask db upgrade` -- Initialize/Upgrade requirements for the recipe scraper `python -c "import nltk; nltk.download('averaged_perceptron_tagger')"` -- Run debug server with `python3 wsgi.py` or without debugging `flask run` -- The backend should be reachable at `localhost:5000` - -### Git Commit Message Style - -This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format. - -Example commit messages: - -``` -chore: update gqlgen dependency to v2.6.0 -docs(README): add new contributing section -fix: remove debug log statements -``` diff --git a/backend/README.md b/backend/README.md index 29dc673c..71177f11 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,4 +1,16 @@ -# KitchenOwl Backend -This is the backend repository of KitchenOwl. -Find more information at the main [KitchenOwl](https://github.com/TomBursch/kitchenowl) repository. -Take a look at the [contributing](./CONTRIBUTING.md) file for local setup instructions. \ No newline at end of file +## Contributing + +Take a look at the general contribution rules [here](../CONTRIBUTING.md). + +### Requirements +- Python 3.11+ + +### Setup & Install +- If you haven't already, switch to the backend folder `cd backend` +- Create a python environment `python3 -m venv venv` +- Activate your python environment `source venv/bin/activate` (environment can be deactivated with `deactivate`) +- Install dependencies `pip3 install -r requirements.txt` +- Initialize/Upgrade the SQLite database with `flask db upgrade` +- Initialize/Upgrade requirements for the recipe scraper `python -c "import nltk; nltk.download('averaged_perceptron_tagger')"` +- Run debug server with `python3 wsgi.py` or without debugging `flask run` +- The backend should be reachable at `localhost:5000` diff --git a/backend/wsgi.ini b/backend/wsgi.ini index 9294c3ee..72337782 100644 --- a/backend/wsgi.ini +++ b/backend/wsgi.ini @@ -17,4 +17,11 @@ procname-prefix-spaced = kitchenowl [celery] ini = :uwsgi -smart-attach-daemon = /tmp/celery.pid celery -A app.celery_app worker -B --pidfile=/tmp/celery.pid \ No newline at end of file +smart-attach-daemon = /tmp/celery.pid celery -A app.celery_app worker -B --pidfile=/tmp/celery.pid + +[web] +ini = :uwsgi +http = [::]:8080 +http-to = :5000 +static-map = /=/var/www/web/kitchenowl +route = ^\/(?!api)[^\.]*$ static:/var/www/web/kitchenowl/index.html diff --git a/changelog_configuration.json b/changelog_configuration.json index 5f0b0648..e3f4b828 100644 --- a/changelog_configuration.json +++ b/changelog_configuration.json @@ -26,7 +26,7 @@ "order": "ASC", "on_property": "mergedAt" }, - "template": "${{CHANGELOG}}## Uncategorized\n${{UNCATEGORIZED}}\n\n${{RELEASE_DIFF}}", + "template": "${{CHANGELOG}}\n## Uncategorized\n${{UNCATEGORIZED}}\n\n${{RELEASE_DIFF}}", "pr_template": "- ${{TITLE}} (#${{NUMBER}} by @${{AUTHOR}})", "empty_template": "- no changes", "base_branches": ["main"] diff --git a/icons/README.md b/icons/README.md index e4f4601e..737034e1 100644 --- a/icons/README.md +++ b/icons/README.md @@ -2,7 +2,7 @@ The icons in the `/icons/icons8` folder cannot be extracted, used, or distributed by any third party. See [copyright](./icons8/COPYRIGHT) for more information. -Specials thanks to https://icons8.com/ who are the sole copyright holder. +Special thanks to https://icons8.com/ who are the sole copyright holder. # Contributing diff --git a/generate-item-icons.py b/icons/generate-item-icons.py similarity index 92% rename from generate-item-icons.py rename to icons/generate-item-icons.py index 8c9fdad7..7c2350a7 100644 --- a/generate-item-icons.py +++ b/icons/generate-item-icons.py @@ -76,8 +76,7 @@ def validate_source_directories(self, source_directories): if os.path.exists(directory): ret_directories.append(directory) else: - sys.stderr("path \"%s\" for source svg files does not exist." % \ - directory) + print(f"path \"{directory}\" for source svg files does not exist.\n") if len(ret_directories) == 0: raise NoSourceSvgDirectoriesException("No valid paths for source \ svg files provided") @@ -105,10 +104,11 @@ def generate(self): font.save_to_file(self.target_ttf_file) if __name__ == "__main__": - fontGenerator = SvgToFontGenerator(['./icons/icons8', './icons'], './fonts/Items.ttf') + folderPath = os.path.dirname(os.path.abspath(__file__)) + fontGenerator = SvgToFontGenerator([folderPath + '/icons8', folderPath + '/'], folderPath + '/../kitchenowl/fonts/Items.ttf') fontGenerator.generate() names = [svg.name.lower().replace("-", "_").replace("icons8_","") for svg in fontGenerator.source_svg_files] - with open('./lib/item_icons.dart', 'w') as f: + with open(folderPath + '/../kitchenowl/lib/item_icons.dart', 'w') as f: f.write(""" /* generated code, do not edit */ // ignore_for_file: constant_identifier_names diff --git a/kitchenowl/.dockerignore b/kitchenowl/.dockerignore new file mode 100644 index 00000000..c246b596 --- /dev/null +++ b/kitchenowl/.dockerignore @@ -0,0 +1,112 @@ +# General files +.git +.github + +# Docker ignore (include only web files) +fedora/ +ios/ +android/ +linux/ +macos/ +windows/ + +# .gitignore here: +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +.env* + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ +*.code-workspace + +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-cache/ +.pub/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# MkDocs +site/ + +# KitchenOwl +icons/ + +# Development +.devcontainer + +# Test related files +.tox +tests + +# Other virtualization methods +venv +.vagrant + +# Temporary files +**/__pycache__ \ No newline at end of file diff --git a/kitchenowl/.gitignore b/kitchenowl/.gitignore new file mode 100644 index 00000000..29a3a501 --- /dev/null +++ b/kitchenowl/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/kitchenowl/.metadata similarity index 100% rename from .metadata rename to kitchenowl/.metadata diff --git a/kitchenowl/Dockerfile b/kitchenowl/Dockerfile new file mode 100644 index 00000000..7e2b7b9d --- /dev/null +++ b/kitchenowl/Dockerfile @@ -0,0 +1,70 @@ +# ------------ +# BUILDER +# ------------ +FROM --platform=$BUILDPLATFORM debian:latest AS builder + +# Install dependencies +RUN apt-get update -y +RUN apt-get upgrade -y +# Install basics +RUN apt-get install -y --no-install-recommends \ + git \ + wget \ + curl \ + zip \ + unzip \ + apt-transport-https \ + ca-certificates \ + gnupg \ + python3 \ + libstdc++6 \ + libglu1-mesa +RUN apt-get clean + +# Clone the flutter repo +RUN git clone https://github.com/flutter/flutter.git -b stable /usr/local/src/flutter + +# Set flutter path +ENV PATH="${PATH}:/usr/local/src/flutter/bin" + +# Enable flutter web +RUN flutter config --enable-web +RUN flutter config --no-analytics +RUN flutter upgrade + +# Run flutter doctor +RUN flutter doctor -v + +# Copy the app files to the container +COPY .metadata l10n.yaml pubspec.yaml /usr/local/src/app/ +COPY lib /usr/local/src/app/lib +COPY web /usr/local/src/app/web +COPY assets /usr/local/src/app/assets +COPY fonts /usr/local/src/app/fonts + +# Set the working directory to the app files within the container +WORKDIR /usr/local/src/app + +# Get App Dependencies +RUN flutter packages get + +# Build the app for the web +RUN flutter build web --release --dart-define=FLUTTER_WEB_CANVASKIT_URL=/canvaskit/ + +# ------------ +# RUNNER +# ------------ +FROM nginx:stable-alpine + +RUN mkdir -p /var/www/web/kitchenowl +COPY --from=builder /usr/local/src/app/build/web /var/www/web/kitchenowl +COPY docker-entrypoint-custom.sh /docker-entrypoint.d/01-kitchenowl-customization.sh +COPY default.conf.template /etc/nginx/templates/ + +HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost/ || exit 1 + +# Set ENV +ENV BACK_URL='back:5000' + +# Expose the web server +EXPOSE 80 \ No newline at end of file diff --git a/kitchenowl/README.md b/kitchenowl/README.md new file mode 100644 index 00000000..bf5b0d26 --- /dev/null +++ b/kitchenowl/README.md @@ -0,0 +1,12 @@ +## Contributing + +Take a look at the general contribution rules [here](../CONTRIBUTING.md). + +### Requirements +- [flutter](https://flutter.dev/docs/get-started/install) + +### Setup & Install +- If you haven't already, switch to the frontend folder `cd kitchenowl` +- Install dependencies: `flutter packages get` +- Run app: `flutter run` +``` \ No newline at end of file diff --git a/android/.gitignore b/kitchenowl/android/.gitignore similarity index 100% rename from android/.gitignore rename to kitchenowl/android/.gitignore diff --git a/android/Gemfile b/kitchenowl/android/Gemfile similarity index 100% rename from android/Gemfile rename to kitchenowl/android/Gemfile diff --git a/android/app/build.gradle b/kitchenowl/android/app/build.gradle similarity index 100% rename from android/app/build.gradle rename to kitchenowl/android/app/build.gradle diff --git a/android/app/src/debug/AndroidManifest.xml b/kitchenowl/android/app/src/debug/AndroidManifest.xml similarity index 100% rename from android/app/src/debug/AndroidManifest.xml rename to kitchenowl/android/app/src/debug/AndroidManifest.xml diff --git a/android/app/src/main/AndroidManifest.xml b/kitchenowl/android/app/src/main/AndroidManifest.xml similarity index 100% rename from android/app/src/main/AndroidManifest.xml rename to kitchenowl/android/app/src/main/AndroidManifest.xml diff --git a/android/app/src/main/ic_launcher-playstore.png b/kitchenowl/android/app/src/main/ic_launcher-playstore.png similarity index 100% rename from android/app/src/main/ic_launcher-playstore.png rename to kitchenowl/android/app/src/main/ic_launcher-playstore.png diff --git a/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java b/kitchenowl/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java similarity index 100% rename from android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java rename to kitchenowl/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java diff --git a/android/app/src/main/kotlin/com/tombursch/kitchenowl/MainActivity.kt b/kitchenowl/android/app/src/main/kotlin/com/tombursch/kitchenowl/MainActivity.kt similarity index 100% rename from android/app/src/main/kotlin/com/tombursch/kitchenowl/MainActivity.kt rename to kitchenowl/android/app/src/main/kotlin/com/tombursch/kitchenowl/MainActivity.kt diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/kitchenowl/android/app/src/main/res/drawable-night-v21/background.png similarity index 100% rename from android/app/src/main/res/drawable-night-v21/background.png rename to kitchenowl/android/app/src/main/res/drawable-night-v21/background.png diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/kitchenowl/android/app/src/main/res/drawable-night-v21/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable-night-v21/launch_background.xml rename to kitchenowl/android/app/src/main/res/drawable-night-v21/launch_background.xml diff --git a/android/app/src/main/res/drawable-night/background.png b/kitchenowl/android/app/src/main/res/drawable-night/background.png similarity index 100% rename from android/app/src/main/res/drawable-night/background.png rename to kitchenowl/android/app/src/main/res/drawable-night/background.png diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/kitchenowl/android/app/src/main/res/drawable-night/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable-night/launch_background.xml rename to kitchenowl/android/app/src/main/res/drawable-night/launch_background.xml diff --git a/android/app/src/main/res/drawable-v21/background.png b/kitchenowl/android/app/src/main/res/drawable-v21/background.png similarity index 100% rename from android/app/src/main/res/drawable-v21/background.png rename to kitchenowl/android/app/src/main/res/drawable-v21/background.png diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/kitchenowl/android/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable-v21/launch_background.xml rename to kitchenowl/android/app/src/main/res/drawable-v21/launch_background.xml diff --git a/android/app/src/main/res/drawable/background.png b/kitchenowl/android/app/src/main/res/drawable/background.png similarity index 100% rename from android/app/src/main/res/drawable/background.png rename to kitchenowl/android/app/src/main/res/drawable/background.png diff --git a/android/app/src/main/res/drawable/launch_background.xml b/kitchenowl/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable/launch_background.xml rename to kitchenowl/android/app/src/main/res/drawable/launch_background.xml diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/kitchenowl/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to kitchenowl/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml b/kitchenowl/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml similarity index 100% rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml rename to kitchenowl/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png rename to kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png rename to kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png rename to kitchenowl/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/kitchenowl/android/app/src/main/res/values-night-v31/styles.xml similarity index 100% rename from android/app/src/main/res/values-night-v31/styles.xml rename to kitchenowl/android/app/src/main/res/values-night-v31/styles.xml diff --git a/android/app/src/main/res/values-night/styles.xml b/kitchenowl/android/app/src/main/res/values-night/styles.xml similarity index 100% rename from android/app/src/main/res/values-night/styles.xml rename to kitchenowl/android/app/src/main/res/values-night/styles.xml diff --git a/android/app/src/main/res/values-v31/styles.xml b/kitchenowl/android/app/src/main/res/values-v31/styles.xml similarity index 100% rename from android/app/src/main/res/values-v31/styles.xml rename to kitchenowl/android/app/src/main/res/values-v31/styles.xml diff --git a/android/app/src/main/res/values/colors.xml b/kitchenowl/android/app/src/main/res/values/colors.xml similarity index 100% rename from android/app/src/main/res/values/colors.xml rename to kitchenowl/android/app/src/main/res/values/colors.xml diff --git a/android/app/src/main/res/values/styles.xml b/kitchenowl/android/app/src/main/res/values/styles.xml similarity index 100% rename from android/app/src/main/res/values/styles.xml rename to kitchenowl/android/app/src/main/res/values/styles.xml diff --git a/android/app/src/main/res/xml/locales_config.xml b/kitchenowl/android/app/src/main/res/xml/locales_config.xml similarity index 100% rename from android/app/src/main/res/xml/locales_config.xml rename to kitchenowl/android/app/src/main/res/xml/locales_config.xml diff --git a/android/app/src/main/res/xml/network_security_config.xml b/kitchenowl/android/app/src/main/res/xml/network_security_config.xml similarity index 100% rename from android/app/src/main/res/xml/network_security_config.xml rename to kitchenowl/android/app/src/main/res/xml/network_security_config.xml diff --git a/android/app/src/profile/AndroidManifest.xml b/kitchenowl/android/app/src/profile/AndroidManifest.xml similarity index 100% rename from android/app/src/profile/AndroidManifest.xml rename to kitchenowl/android/app/src/profile/AndroidManifest.xml diff --git a/android/build.gradle b/kitchenowl/android/build.gradle similarity index 100% rename from android/build.gradle rename to kitchenowl/android/build.gradle diff --git a/android/fastlane/Appfile b/kitchenowl/android/fastlane/Appfile similarity index 100% rename from android/fastlane/Appfile rename to kitchenowl/android/fastlane/Appfile diff --git a/android/fastlane/Fastfile b/kitchenowl/android/fastlane/Fastfile similarity index 100% rename from android/fastlane/Fastfile rename to kitchenowl/android/fastlane/Fastfile diff --git a/android/fastlane/README.md b/kitchenowl/android/fastlane/README.md similarity index 100% rename from android/fastlane/README.md rename to kitchenowl/android/fastlane/README.md diff --git a/android/gradle.properties b/kitchenowl/android/gradle.properties similarity index 100% rename from android/gradle.properties rename to kitchenowl/android/gradle.properties diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/kitchenowl/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from android/gradle/wrapper/gradle-wrapper.properties rename to kitchenowl/android/gradle/wrapper/gradle-wrapper.properties diff --git a/android/settings.gradle b/kitchenowl/android/settings.gradle similarity index 100% rename from android/settings.gradle rename to kitchenowl/android/settings.gradle diff --git a/assets/icon/icon-foreground.png b/kitchenowl/assets/icon/icon-foreground.png similarity index 100% rename from assets/icon/icon-foreground.png rename to kitchenowl/assets/icon/icon-foreground.png diff --git a/assets/icon/icon-padded.png b/kitchenowl/assets/icon/icon-padded.png similarity index 100% rename from assets/icon/icon-padded.png rename to kitchenowl/assets/icon/icon-padded.png diff --git a/assets/icon/icon-rounded.png b/kitchenowl/assets/icon/icon-rounded.png similarity index 100% rename from assets/icon/icon-rounded.png rename to kitchenowl/assets/icon/icon-rounded.png diff --git a/assets/icon/icon-small.png b/kitchenowl/assets/icon/icon-small.png similarity index 100% rename from assets/icon/icon-small.png rename to kitchenowl/assets/icon/icon-small.png diff --git a/assets/icon/icon.png b/kitchenowl/assets/icon/icon.png similarity index 100% rename from assets/icon/icon.png rename to kitchenowl/assets/icon/icon.png diff --git a/assets/images/google_logo.png b/kitchenowl/assets/images/google_logo.png similarity index 100% rename from assets/images/google_logo.png rename to kitchenowl/assets/images/google_logo.png diff --git a/dart_test.yaml b/kitchenowl/dart_test.yaml similarity index 100% rename from dart_test.yaml rename to kitchenowl/dart_test.yaml diff --git a/debian/build.sh b/kitchenowl/debian/build.sh similarity index 100% rename from debian/build.sh rename to kitchenowl/debian/build.sh diff --git a/debian/kitchenowl/DEBIAN/control b/kitchenowl/debian/kitchenowl/DEBIAN/control similarity index 100% rename from debian/kitchenowl/DEBIAN/control rename to kitchenowl/debian/kitchenowl/DEBIAN/control diff --git a/debian/kitchenowl/DEBIAN/postinst b/kitchenowl/debian/kitchenowl/DEBIAN/postinst similarity index 100% rename from debian/kitchenowl/DEBIAN/postinst rename to kitchenowl/debian/kitchenowl/DEBIAN/postinst diff --git a/debian/kitchenowl/usr/bin/kitchenowl b/kitchenowl/debian/kitchenowl/usr/bin/kitchenowl similarity index 100% rename from debian/kitchenowl/usr/bin/kitchenowl rename to kitchenowl/debian/kitchenowl/usr/bin/kitchenowl diff --git a/default.conf.template b/kitchenowl/default.conf.template similarity index 100% rename from default.conf.template rename to kitchenowl/default.conf.template diff --git a/docker-entrypoint-custom.sh b/kitchenowl/docker-entrypoint-custom.sh similarity index 100% rename from docker-entrypoint-custom.sh rename to kitchenowl/docker-entrypoint-custom.sh diff --git a/fedora/build.sh b/kitchenowl/fedora/build.sh similarity index 100% rename from fedora/build.sh rename to kitchenowl/fedora/build.sh diff --git a/fedora/kitchenowl.spec b/kitchenowl/fedora/kitchenowl.spec similarity index 100% rename from fedora/kitchenowl.spec rename to kitchenowl/fedora/kitchenowl.spec diff --git a/fonts/Items.ttf b/kitchenowl/fonts/Items.ttf similarity index 100% rename from fonts/Items.ttf rename to kitchenowl/fonts/Items.ttf diff --git a/fonts/README.md b/kitchenowl/fonts/README.md similarity index 100% rename from fonts/README.md rename to kitchenowl/fonts/README.md diff --git a/fonts/Roboto-Black.ttf b/kitchenowl/fonts/Roboto-Black.ttf similarity index 100% rename from fonts/Roboto-Black.ttf rename to kitchenowl/fonts/Roboto-Black.ttf diff --git a/fonts/Roboto-BlackItalic.ttf b/kitchenowl/fonts/Roboto-BlackItalic.ttf similarity index 100% rename from fonts/Roboto-BlackItalic.ttf rename to kitchenowl/fonts/Roboto-BlackItalic.ttf diff --git a/fonts/Roboto-Bold.ttf b/kitchenowl/fonts/Roboto-Bold.ttf similarity index 100% rename from fonts/Roboto-Bold.ttf rename to kitchenowl/fonts/Roboto-Bold.ttf diff --git a/fonts/Roboto-BoldItalic.ttf b/kitchenowl/fonts/Roboto-BoldItalic.ttf similarity index 100% rename from fonts/Roboto-BoldItalic.ttf rename to kitchenowl/fonts/Roboto-BoldItalic.ttf diff --git a/fonts/Roboto-Italic.ttf b/kitchenowl/fonts/Roboto-Italic.ttf similarity index 100% rename from fonts/Roboto-Italic.ttf rename to kitchenowl/fonts/Roboto-Italic.ttf diff --git a/fonts/Roboto-Light.ttf b/kitchenowl/fonts/Roboto-Light.ttf similarity index 100% rename from fonts/Roboto-Light.ttf rename to kitchenowl/fonts/Roboto-Light.ttf diff --git a/fonts/Roboto-LightItalic.ttf b/kitchenowl/fonts/Roboto-LightItalic.ttf similarity index 100% rename from fonts/Roboto-LightItalic.ttf rename to kitchenowl/fonts/Roboto-LightItalic.ttf diff --git a/fonts/Roboto-Medium.ttf b/kitchenowl/fonts/Roboto-Medium.ttf similarity index 100% rename from fonts/Roboto-Medium.ttf rename to kitchenowl/fonts/Roboto-Medium.ttf diff --git a/fonts/Roboto-MediumItalic.ttf b/kitchenowl/fonts/Roboto-MediumItalic.ttf similarity index 100% rename from fonts/Roboto-MediumItalic.ttf rename to kitchenowl/fonts/Roboto-MediumItalic.ttf diff --git a/fonts/Roboto-Regular.ttf b/kitchenowl/fonts/Roboto-Regular.ttf similarity index 100% rename from fonts/Roboto-Regular.ttf rename to kitchenowl/fonts/Roboto-Regular.ttf diff --git a/fonts/Roboto-Thin.ttf b/kitchenowl/fonts/Roboto-Thin.ttf similarity index 100% rename from fonts/Roboto-Thin.ttf rename to kitchenowl/fonts/Roboto-Thin.ttf diff --git a/fonts/Roboto-ThinItalic.ttf b/kitchenowl/fonts/Roboto-ThinItalic.ttf similarity index 100% rename from fonts/Roboto-ThinItalic.ttf rename to kitchenowl/fonts/Roboto-ThinItalic.ttf diff --git a/ios/.gitignore b/kitchenowl/ios/.gitignore similarity index 100% rename from ios/.gitignore rename to kitchenowl/ios/.gitignore diff --git a/ios/Flutter/AppFrameworkInfo.plist b/kitchenowl/ios/Flutter/AppFrameworkInfo.plist similarity index 100% rename from ios/Flutter/AppFrameworkInfo.plist rename to kitchenowl/ios/Flutter/AppFrameworkInfo.plist diff --git a/ios/Flutter/Debug.xcconfig b/kitchenowl/ios/Flutter/Debug.xcconfig similarity index 100% rename from ios/Flutter/Debug.xcconfig rename to kitchenowl/ios/Flutter/Debug.xcconfig diff --git a/ios/Flutter/Release.xcconfig b/kitchenowl/ios/Flutter/Release.xcconfig similarity index 100% rename from ios/Flutter/Release.xcconfig rename to kitchenowl/ios/Flutter/Release.xcconfig diff --git a/ios/Gemfile b/kitchenowl/ios/Gemfile similarity index 100% rename from ios/Gemfile rename to kitchenowl/ios/Gemfile diff --git a/ios/Podfile b/kitchenowl/ios/Podfile similarity index 100% rename from ios/Podfile rename to kitchenowl/ios/Podfile diff --git a/ios/Runner.xcodeproj/project.pbxproj b/kitchenowl/ios/Runner.xcodeproj/project.pbxproj similarity index 100% rename from ios/Runner.xcodeproj/project.pbxproj rename to kitchenowl/ios/Runner.xcodeproj/project.pbxproj diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to kitchenowl/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/kitchenowl/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to kitchenowl/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/kitchenowl/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from ios/Runner.xcworkspace/contents.xcworkspacedata rename to kitchenowl/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kitchenowl/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to kitchenowl/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/kitchenowl/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to kitchenowl/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/ios/Runner/AppDelegate.swift b/kitchenowl/ios/Runner/AppDelegate.swift similarity index 100% rename from ios/Runner/AppDelegate.swift rename to kitchenowl/ios/Runner/AppDelegate.swift diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to kitchenowl/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/kitchenowl/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from ios/Runner/Base.lproj/LaunchScreen.storyboard rename to kitchenowl/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/ios/Runner/Base.lproj/Main.storyboard b/kitchenowl/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from ios/Runner/Base.lproj/Main.storyboard rename to kitchenowl/ios/Runner/Base.lproj/Main.storyboard diff --git a/ios/Runner/Info.plist b/kitchenowl/ios/Runner/Info.plist similarity index 100% rename from ios/Runner/Info.plist rename to kitchenowl/ios/Runner/Info.plist diff --git a/ios/Runner/Runner-Bridging-Header.h b/kitchenowl/ios/Runner/Runner-Bridging-Header.h similarity index 100% rename from ios/Runner/Runner-Bridging-Header.h rename to kitchenowl/ios/Runner/Runner-Bridging-Header.h diff --git a/ios/Runner/Runner.entitlements b/kitchenowl/ios/Runner/Runner.entitlements similarity index 100% rename from ios/Runner/Runner.entitlements rename to kitchenowl/ios/Runner/Runner.entitlements diff --git a/ios/RunnerTests/RunnerTests.swift b/kitchenowl/ios/RunnerTests/RunnerTests.swift similarity index 100% rename from ios/RunnerTests/RunnerTests.swift rename to kitchenowl/ios/RunnerTests/RunnerTests.swift diff --git a/ios/ShareExtension/Base.lproj/MainInterface.storyboard b/kitchenowl/ios/ShareExtension/Base.lproj/MainInterface.storyboard similarity index 100% rename from ios/ShareExtension/Base.lproj/MainInterface.storyboard rename to kitchenowl/ios/ShareExtension/Base.lproj/MainInterface.storyboard diff --git a/ios/ShareExtension/Info.plist b/kitchenowl/ios/ShareExtension/Info.plist similarity index 100% rename from ios/ShareExtension/Info.plist rename to kitchenowl/ios/ShareExtension/Info.plist diff --git a/ios/ShareExtension/ShareExtension.entitlements b/kitchenowl/ios/ShareExtension/ShareExtension.entitlements similarity index 100% rename from ios/ShareExtension/ShareExtension.entitlements rename to kitchenowl/ios/ShareExtension/ShareExtension.entitlements diff --git a/ios/ShareExtension/ShareViewController.swift b/kitchenowl/ios/ShareExtension/ShareViewController.swift similarity index 100% rename from ios/ShareExtension/ShareViewController.swift rename to kitchenowl/ios/ShareExtension/ShareViewController.swift diff --git a/ios/fastlane/Appfile b/kitchenowl/ios/fastlane/Appfile similarity index 100% rename from ios/fastlane/Appfile rename to kitchenowl/ios/fastlane/Appfile diff --git a/ios/fastlane/Fastfile b/kitchenowl/ios/fastlane/Fastfile similarity index 100% rename from ios/fastlane/Fastfile rename to kitchenowl/ios/fastlane/Fastfile diff --git a/ios/fastlane/README.md b/kitchenowl/ios/fastlane/README.md similarity index 100% rename from ios/fastlane/README.md rename to kitchenowl/ios/fastlane/README.md diff --git a/l10n.yaml b/kitchenowl/l10n.yaml similarity index 100% rename from l10n.yaml rename to kitchenowl/l10n.yaml diff --git a/lib/app.dart b/kitchenowl/lib/app.dart similarity index 100% rename from lib/app.dart rename to kitchenowl/lib/app.dart diff --git a/lib/config.dart b/kitchenowl/lib/config.dart similarity index 100% rename from lib/config.dart rename to kitchenowl/lib/config.dart diff --git a/lib/cubits/auth_cubit.dart b/kitchenowl/lib/cubits/auth_cubit.dart similarity index 100% rename from lib/cubits/auth_cubit.dart rename to kitchenowl/lib/cubits/auth_cubit.dart diff --git a/lib/cubits/email_confirm_cubit.dart b/kitchenowl/lib/cubits/email_confirm_cubit.dart similarity index 100% rename from lib/cubits/email_confirm_cubit.dart rename to kitchenowl/lib/cubits/email_confirm_cubit.dart diff --git a/lib/cubits/expense_add_update_cubit.dart b/kitchenowl/lib/cubits/expense_add_update_cubit.dart similarity index 100% rename from lib/cubits/expense_add_update_cubit.dart rename to kitchenowl/lib/cubits/expense_add_update_cubit.dart diff --git a/lib/cubits/expense_category_add_update_cubit.dart b/kitchenowl/lib/cubits/expense_category_add_update_cubit.dart similarity index 100% rename from lib/cubits/expense_category_add_update_cubit.dart rename to kitchenowl/lib/cubits/expense_category_add_update_cubit.dart diff --git a/lib/cubits/expense_cubit.dart b/kitchenowl/lib/cubits/expense_cubit.dart similarity index 100% rename from lib/cubits/expense_cubit.dart rename to kitchenowl/lib/cubits/expense_cubit.dart diff --git a/lib/cubits/expense_list_cubit.dart b/kitchenowl/lib/cubits/expense_list_cubit.dart similarity index 100% rename from lib/cubits/expense_list_cubit.dart rename to kitchenowl/lib/cubits/expense_list_cubit.dart diff --git a/lib/cubits/expense_month_list_cubit.dart b/kitchenowl/lib/cubits/expense_month_list_cubit.dart similarity index 100% rename from lib/cubits/expense_month_list_cubit.dart rename to kitchenowl/lib/cubits/expense_month_list_cubit.dart diff --git a/lib/cubits/expense_overview_cubit.dart b/kitchenowl/lib/cubits/expense_overview_cubit.dart similarity index 100% rename from lib/cubits/expense_overview_cubit.dart rename to kitchenowl/lib/cubits/expense_overview_cubit.dart diff --git a/lib/cubits/household_add_update/household_add_cubit.dart b/kitchenowl/lib/cubits/household_add_update/household_add_cubit.dart similarity index 100% rename from lib/cubits/household_add_update/household_add_cubit.dart rename to kitchenowl/lib/cubits/household_add_update/household_add_cubit.dart diff --git a/lib/cubits/household_add_update/household_add_update_cubit.dart b/kitchenowl/lib/cubits/household_add_update/household_add_update_cubit.dart similarity index 100% rename from lib/cubits/household_add_update/household_add_update_cubit.dart rename to kitchenowl/lib/cubits/household_add_update/household_add_update_cubit.dart diff --git a/lib/cubits/household_add_update/household_update_cubit.dart b/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart similarity index 100% rename from lib/cubits/household_add_update/household_update_cubit.dart rename to kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart diff --git a/lib/cubits/household_cubit.dart b/kitchenowl/lib/cubits/household_cubit.dart similarity index 100% rename from lib/cubits/household_cubit.dart rename to kitchenowl/lib/cubits/household_cubit.dart diff --git a/lib/cubits/household_list_cubit.dart b/kitchenowl/lib/cubits/household_list_cubit.dart similarity index 100% rename from lib/cubits/household_list_cubit.dart rename to kitchenowl/lib/cubits/household_list_cubit.dart diff --git a/lib/cubits/household_member_cubit.dart b/kitchenowl/lib/cubits/household_member_cubit.dart similarity index 100% rename from lib/cubits/household_member_cubit.dart rename to kitchenowl/lib/cubits/household_member_cubit.dart diff --git a/lib/cubits/item_edit_cubit.dart b/kitchenowl/lib/cubits/item_edit_cubit.dart similarity index 100% rename from lib/cubits/item_edit_cubit.dart rename to kitchenowl/lib/cubits/item_edit_cubit.dart diff --git a/lib/cubits/item_search_cubit.dart b/kitchenowl/lib/cubits/item_search_cubit.dart similarity index 100% rename from lib/cubits/item_search_cubit.dart rename to kitchenowl/lib/cubits/item_search_cubit.dart diff --git a/lib/cubits/item_selection_cubit.dart b/kitchenowl/lib/cubits/item_selection_cubit.dart similarity index 100% rename from lib/cubits/item_selection_cubit.dart rename to kitchenowl/lib/cubits/item_selection_cubit.dart diff --git a/lib/cubits/password_reset_cubit.dart b/kitchenowl/lib/cubits/password_reset_cubit.dart similarity index 100% rename from lib/cubits/password_reset_cubit.dart rename to kitchenowl/lib/cubits/password_reset_cubit.dart diff --git a/lib/cubits/planner_cubit.dart b/kitchenowl/lib/cubits/planner_cubit.dart similarity index 100% rename from lib/cubits/planner_cubit.dart rename to kitchenowl/lib/cubits/planner_cubit.dart diff --git a/lib/cubits/recipe_add_update_cubit.dart b/kitchenowl/lib/cubits/recipe_add_update_cubit.dart similarity index 100% rename from lib/cubits/recipe_add_update_cubit.dart rename to kitchenowl/lib/cubits/recipe_add_update_cubit.dart diff --git a/lib/cubits/recipe_cubit.dart b/kitchenowl/lib/cubits/recipe_cubit.dart similarity index 100% rename from lib/cubits/recipe_cubit.dart rename to kitchenowl/lib/cubits/recipe_cubit.dart diff --git a/lib/cubits/recipe_list_cubit.dart b/kitchenowl/lib/cubits/recipe_list_cubit.dart similarity index 100% rename from lib/cubits/recipe_list_cubit.dart rename to kitchenowl/lib/cubits/recipe_list_cubit.dart diff --git a/lib/cubits/recipe_scraper_cubit.dart b/kitchenowl/lib/cubits/recipe_scraper_cubit.dart similarity index 100% rename from lib/cubits/recipe_scraper_cubit.dart rename to kitchenowl/lib/cubits/recipe_scraper_cubit.dart diff --git a/lib/cubits/server_info_cubit.dart b/kitchenowl/lib/cubits/server_info_cubit.dart similarity index 100% rename from lib/cubits/server_info_cubit.dart rename to kitchenowl/lib/cubits/server_info_cubit.dart diff --git a/lib/cubits/settings_cubit.dart b/kitchenowl/lib/cubits/settings_cubit.dart similarity index 100% rename from lib/cubits/settings_cubit.dart rename to kitchenowl/lib/cubits/settings_cubit.dart diff --git a/lib/cubits/settings_server_cubit.dart b/kitchenowl/lib/cubits/settings_server_cubit.dart similarity index 100% rename from lib/cubits/settings_server_cubit.dart rename to kitchenowl/lib/cubits/settings_server_cubit.dart diff --git a/lib/cubits/settings_user_cubit.dart b/kitchenowl/lib/cubits/settings_user_cubit.dart similarity index 100% rename from lib/cubits/settings_user_cubit.dart rename to kitchenowl/lib/cubits/settings_user_cubit.dart diff --git a/lib/cubits/shoppinglist_cubit.dart b/kitchenowl/lib/cubits/shoppinglist_cubit.dart similarity index 100% rename from lib/cubits/shoppinglist_cubit.dart rename to kitchenowl/lib/cubits/shoppinglist_cubit.dart diff --git a/lib/cubits/user_search_cubit.dart b/kitchenowl/lib/cubits/user_search_cubit.dart similarity index 100% rename from lib/cubits/user_search_cubit.dart rename to kitchenowl/lib/cubits/user_search_cubit.dart diff --git a/lib/enums/expenselist_sorting.dart b/kitchenowl/lib/enums/expenselist_sorting.dart similarity index 100% rename from lib/enums/expenselist_sorting.dart rename to kitchenowl/lib/enums/expenselist_sorting.dart diff --git a/lib/enums/oidc_provider.dart b/kitchenowl/lib/enums/oidc_provider.dart similarity index 100% rename from lib/enums/oidc_provider.dart rename to kitchenowl/lib/enums/oidc_provider.dart diff --git a/lib/enums/shoppinglist_sorting.dart b/kitchenowl/lib/enums/shoppinglist_sorting.dart similarity index 100% rename from lib/enums/shoppinglist_sorting.dart rename to kitchenowl/lib/enums/shoppinglist_sorting.dart diff --git a/lib/enums/timeframe.dart b/kitchenowl/lib/enums/timeframe.dart similarity index 100% rename from lib/enums/timeframe.dart rename to kitchenowl/lib/enums/timeframe.dart diff --git a/lib/enums/token_type_enum.dart b/kitchenowl/lib/enums/token_type_enum.dart similarity index 100% rename from lib/enums/token_type_enum.dart rename to kitchenowl/lib/enums/token_type_enum.dart diff --git a/lib/enums/update_enum.dart b/kitchenowl/lib/enums/update_enum.dart similarity index 100% rename from lib/enums/update_enum.dart rename to kitchenowl/lib/enums/update_enum.dart diff --git a/lib/enums/views_enum.dart b/kitchenowl/lib/enums/views_enum.dart similarity index 100% rename from lib/enums/views_enum.dart rename to kitchenowl/lib/enums/views_enum.dart diff --git a/lib/helpers/currency_text_input_formatter.dart b/kitchenowl/lib/helpers/currency_text_input_formatter.dart similarity index 100% rename from lib/helpers/currency_text_input_formatter.dart rename to kitchenowl/lib/helpers/currency_text_input_formatter.dart diff --git a/lib/helpers/debouncer.dart b/kitchenowl/lib/helpers/debouncer.dart similarity index 100% rename from lib/helpers/debouncer.dart rename to kitchenowl/lib/helpers/debouncer.dart diff --git a/lib/helpers/fade_through_transition_page.dart b/kitchenowl/lib/helpers/fade_through_transition_page.dart similarity index 100% rename from lib/helpers/fade_through_transition_page.dart rename to kitchenowl/lib/helpers/fade_through_transition_page.dart diff --git a/lib/helpers/named_bytearray.dart b/kitchenowl/lib/helpers/named_bytearray.dart similarity index 100% rename from lib/helpers/named_bytearray.dart rename to kitchenowl/lib/helpers/named_bytearray.dart diff --git a/lib/helpers/recipe_item_markdown_extension.dart b/kitchenowl/lib/helpers/recipe_item_markdown_extension.dart similarity index 100% rename from lib/helpers/recipe_item_markdown_extension.dart rename to kitchenowl/lib/helpers/recipe_item_markdown_extension.dart diff --git a/lib/helpers/shared_axis_transition_page.dart b/kitchenowl/lib/helpers/shared_axis_transition_page.dart similarity index 100% rename from lib/helpers/shared_axis_transition_page.dart rename to kitchenowl/lib/helpers/shared_axis_transition_page.dart diff --git a/lib/helpers/string_scaler.dart b/kitchenowl/lib/helpers/string_scaler.dart similarity index 100% rename from lib/helpers/string_scaler.dart rename to kitchenowl/lib/helpers/string_scaler.dart diff --git a/lib/helpers/url_launcher.dart b/kitchenowl/lib/helpers/url_launcher.dart similarity index 100% rename from lib/helpers/url_launcher.dart rename to kitchenowl/lib/helpers/url_launcher.dart diff --git a/lib/helpers/username_text_input_formatter.dart b/kitchenowl/lib/helpers/username_text_input_formatter.dart similarity index 100% rename from lib/helpers/username_text_input_formatter.dart rename to kitchenowl/lib/helpers/username_text_input_formatter.dart diff --git a/lib/item_icons.dart b/kitchenowl/lib/item_icons.dart similarity index 100% rename from lib/item_icons.dart rename to kitchenowl/lib/item_icons.dart diff --git a/lib/kitchenowl.dart b/kitchenowl/lib/kitchenowl.dart similarity index 100% rename from lib/kitchenowl.dart rename to kitchenowl/lib/kitchenowl.dart diff --git a/lib/l10n/app_be.arb b/kitchenowl/lib/l10n/app_be.arb similarity index 100% rename from lib/l10n/app_be.arb rename to kitchenowl/lib/l10n/app_be.arb diff --git a/lib/l10n/app_ca.arb b/kitchenowl/lib/l10n/app_ca.arb similarity index 100% rename from lib/l10n/app_ca.arb rename to kitchenowl/lib/l10n/app_ca.arb diff --git a/lib/l10n/app_ca_valencia.arb b/kitchenowl/lib/l10n/app_ca_valencia.arb similarity index 100% rename from lib/l10n/app_ca_valencia.arb rename to kitchenowl/lib/l10n/app_ca_valencia.arb diff --git a/lib/l10n/app_cs.arb b/kitchenowl/lib/l10n/app_cs.arb similarity index 100% rename from lib/l10n/app_cs.arb rename to kitchenowl/lib/l10n/app_cs.arb diff --git a/lib/l10n/app_da.arb b/kitchenowl/lib/l10n/app_da.arb similarity index 100% rename from lib/l10n/app_da.arb rename to kitchenowl/lib/l10n/app_da.arb diff --git a/lib/l10n/app_de.arb b/kitchenowl/lib/l10n/app_de.arb similarity index 100% rename from lib/l10n/app_de.arb rename to kitchenowl/lib/l10n/app_de.arb diff --git a/lib/l10n/app_el.arb b/kitchenowl/lib/l10n/app_el.arb similarity index 100% rename from lib/l10n/app_el.arb rename to kitchenowl/lib/l10n/app_el.arb diff --git a/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb similarity index 100% rename from lib/l10n/app_en.arb rename to kitchenowl/lib/l10n/app_en.arb diff --git a/lib/l10n/app_en_AU.arb b/kitchenowl/lib/l10n/app_en_AU.arb similarity index 100% rename from lib/l10n/app_en_AU.arb rename to kitchenowl/lib/l10n/app_en_AU.arb diff --git a/lib/l10n/app_es.arb b/kitchenowl/lib/l10n/app_es.arb similarity index 100% rename from lib/l10n/app_es.arb rename to kitchenowl/lib/l10n/app_es.arb diff --git a/lib/l10n/app_fi.arb b/kitchenowl/lib/l10n/app_fi.arb similarity index 100% rename from lib/l10n/app_fi.arb rename to kitchenowl/lib/l10n/app_fi.arb diff --git a/lib/l10n/app_fr.arb b/kitchenowl/lib/l10n/app_fr.arb similarity index 100% rename from lib/l10n/app_fr.arb rename to kitchenowl/lib/l10n/app_fr.arb diff --git a/lib/l10n/app_hu.arb b/kitchenowl/lib/l10n/app_hu.arb similarity index 100% rename from lib/l10n/app_hu.arb rename to kitchenowl/lib/l10n/app_hu.arb diff --git a/lib/l10n/app_id.arb b/kitchenowl/lib/l10n/app_id.arb similarity index 100% rename from lib/l10n/app_id.arb rename to kitchenowl/lib/l10n/app_id.arb diff --git a/lib/l10n/app_it.arb b/kitchenowl/lib/l10n/app_it.arb similarity index 100% rename from lib/l10n/app_it.arb rename to kitchenowl/lib/l10n/app_it.arb diff --git a/lib/l10n/app_nb.arb b/kitchenowl/lib/l10n/app_nb.arb similarity index 100% rename from lib/l10n/app_nb.arb rename to kitchenowl/lib/l10n/app_nb.arb diff --git a/lib/l10n/app_nl.arb b/kitchenowl/lib/l10n/app_nl.arb similarity index 100% rename from lib/l10n/app_nl.arb rename to kitchenowl/lib/l10n/app_nl.arb diff --git a/lib/l10n/app_pa.arb b/kitchenowl/lib/l10n/app_pa.arb similarity index 100% rename from lib/l10n/app_pa.arb rename to kitchenowl/lib/l10n/app_pa.arb diff --git a/lib/l10n/app_pl.arb b/kitchenowl/lib/l10n/app_pl.arb similarity index 100% rename from lib/l10n/app_pl.arb rename to kitchenowl/lib/l10n/app_pl.arb diff --git a/lib/l10n/app_pt.arb b/kitchenowl/lib/l10n/app_pt.arb similarity index 100% rename from lib/l10n/app_pt.arb rename to kitchenowl/lib/l10n/app_pt.arb diff --git a/lib/l10n/app_pt_BR.arb b/kitchenowl/lib/l10n/app_pt_BR.arb similarity index 100% rename from lib/l10n/app_pt_BR.arb rename to kitchenowl/lib/l10n/app_pt_BR.arb diff --git a/lib/l10n/app_ro.arb b/kitchenowl/lib/l10n/app_ro.arb similarity index 100% rename from lib/l10n/app_ro.arb rename to kitchenowl/lib/l10n/app_ro.arb diff --git a/lib/l10n/app_ru.arb b/kitchenowl/lib/l10n/app_ru.arb similarity index 100% rename from lib/l10n/app_ru.arb rename to kitchenowl/lib/l10n/app_ru.arb diff --git a/lib/l10n/app_sv.arb b/kitchenowl/lib/l10n/app_sv.arb similarity index 100% rename from lib/l10n/app_sv.arb rename to kitchenowl/lib/l10n/app_sv.arb diff --git a/lib/l10n/app_tr.arb b/kitchenowl/lib/l10n/app_tr.arb similarity index 100% rename from lib/l10n/app_tr.arb rename to kitchenowl/lib/l10n/app_tr.arb diff --git a/lib/l10n/app_vi.arb b/kitchenowl/lib/l10n/app_vi.arb similarity index 100% rename from lib/l10n/app_vi.arb rename to kitchenowl/lib/l10n/app_vi.arb diff --git a/lib/l10n/app_zh.arb b/kitchenowl/lib/l10n/app_zh.arb similarity index 100% rename from lib/l10n/app_zh.arb rename to kitchenowl/lib/l10n/app_zh.arb diff --git a/lib/main.dart b/kitchenowl/lib/main.dart similarity index 100% rename from lib/main.dart rename to kitchenowl/lib/main.dart diff --git a/lib/models/category.dart b/kitchenowl/lib/models/category.dart similarity index 100% rename from lib/models/category.dart rename to kitchenowl/lib/models/category.dart diff --git a/lib/models/expense.dart b/kitchenowl/lib/models/expense.dart similarity index 100% rename from lib/models/expense.dart rename to kitchenowl/lib/models/expense.dart diff --git a/lib/models/expense_category.dart b/kitchenowl/lib/models/expense_category.dart similarity index 100% rename from lib/models/expense_category.dart rename to kitchenowl/lib/models/expense_category.dart diff --git a/lib/models/expense_overview.dart b/kitchenowl/lib/models/expense_overview.dart similarity index 100% rename from lib/models/expense_overview.dart rename to kitchenowl/lib/models/expense_overview.dart diff --git a/lib/models/household.dart b/kitchenowl/lib/models/household.dart similarity index 100% rename from lib/models/household.dart rename to kitchenowl/lib/models/household.dart diff --git a/lib/models/import_settings.dart b/kitchenowl/lib/models/import_settings.dart similarity index 100% rename from lib/models/import_settings.dart rename to kitchenowl/lib/models/import_settings.dart diff --git a/lib/models/item.dart b/kitchenowl/lib/models/item.dart similarity index 100% rename from lib/models/item.dart rename to kitchenowl/lib/models/item.dart diff --git a/lib/models/member.dart b/kitchenowl/lib/models/member.dart similarity index 100% rename from lib/models/member.dart rename to kitchenowl/lib/models/member.dart diff --git a/lib/models/model.dart b/kitchenowl/lib/models/model.dart similarity index 100% rename from lib/models/model.dart rename to kitchenowl/lib/models/model.dart diff --git a/lib/models/nullable.dart b/kitchenowl/lib/models/nullable.dart similarity index 100% rename from lib/models/nullable.dart rename to kitchenowl/lib/models/nullable.dart diff --git a/lib/models/planner.dart b/kitchenowl/lib/models/planner.dart similarity index 100% rename from lib/models/planner.dart rename to kitchenowl/lib/models/planner.dart diff --git a/lib/models/recipe.dart b/kitchenowl/lib/models/recipe.dart similarity index 100% rename from lib/models/recipe.dart rename to kitchenowl/lib/models/recipe.dart diff --git a/lib/models/recipe_scrape.dart b/kitchenowl/lib/models/recipe_scrape.dart similarity index 100% rename from lib/models/recipe_scrape.dart rename to kitchenowl/lib/models/recipe_scrape.dart diff --git a/lib/models/shoppinglist.dart b/kitchenowl/lib/models/shoppinglist.dart similarity index 100% rename from lib/models/shoppinglist.dart rename to kitchenowl/lib/models/shoppinglist.dart diff --git a/lib/models/tag.dart b/kitchenowl/lib/models/tag.dart similarity index 100% rename from lib/models/tag.dart rename to kitchenowl/lib/models/tag.dart diff --git a/lib/models/token.dart b/kitchenowl/lib/models/token.dart similarity index 100% rename from lib/models/token.dart rename to kitchenowl/lib/models/token.dart diff --git a/lib/models/update_value.dart b/kitchenowl/lib/models/update_value.dart similarity index 100% rename from lib/models/update_value.dart rename to kitchenowl/lib/models/update_value.dart diff --git a/lib/models/user.dart b/kitchenowl/lib/models/user.dart similarity index 100% rename from lib/models/user.dart rename to kitchenowl/lib/models/user.dart diff --git a/lib/pages/analytics_page.dart b/kitchenowl/lib/pages/analytics_page.dart similarity index 100% rename from lib/pages/analytics_page.dart rename to kitchenowl/lib/pages/analytics_page.dart diff --git a/lib/pages/email_confirm_page.dart b/kitchenowl/lib/pages/email_confirm_page.dart similarity index 100% rename from lib/pages/email_confirm_page.dart rename to kitchenowl/lib/pages/email_confirm_page.dart diff --git a/lib/pages/expense_add_update_page.dart b/kitchenowl/lib/pages/expense_add_update_page.dart similarity index 100% rename from lib/pages/expense_add_update_page.dart rename to kitchenowl/lib/pages/expense_add_update_page.dart diff --git a/lib/pages/expense_category_add_page.dart b/kitchenowl/lib/pages/expense_category_add_page.dart similarity index 100% rename from lib/pages/expense_category_add_page.dart rename to kitchenowl/lib/pages/expense_category_add_page.dart diff --git a/lib/pages/expense_month_list_page.dart b/kitchenowl/lib/pages/expense_month_list_page.dart similarity index 100% rename from lib/pages/expense_month_list_page.dart rename to kitchenowl/lib/pages/expense_month_list_page.dart diff --git a/lib/pages/expense_overview_page.dart b/kitchenowl/lib/pages/expense_overview_page.dart similarity index 100% rename from lib/pages/expense_overview_page.dart rename to kitchenowl/lib/pages/expense_overview_page.dart diff --git a/lib/pages/expense_page.dart b/kitchenowl/lib/pages/expense_page.dart similarity index 100% rename from lib/pages/expense_page.dart rename to kitchenowl/lib/pages/expense_page.dart diff --git a/lib/pages/household_add_page.dart b/kitchenowl/lib/pages/household_add_page.dart similarity index 100% rename from lib/pages/household_add_page.dart rename to kitchenowl/lib/pages/household_add_page.dart diff --git a/lib/pages/household_list_page.dart b/kitchenowl/lib/pages/household_list_page.dart similarity index 100% rename from lib/pages/household_list_page.dart rename to kitchenowl/lib/pages/household_list_page.dart diff --git a/lib/pages/household_member_page.dart b/kitchenowl/lib/pages/household_member_page.dart similarity index 100% rename from lib/pages/household_member_page.dart rename to kitchenowl/lib/pages/household_member_page.dart diff --git a/lib/pages/household_page.dart b/kitchenowl/lib/pages/household_page.dart similarity index 100% rename from lib/pages/household_page.dart rename to kitchenowl/lib/pages/household_page.dart diff --git a/lib/pages/household_page/_export.dart b/kitchenowl/lib/pages/household_page/_export.dart similarity index 100% rename from lib/pages/household_page/_export.dart rename to kitchenowl/lib/pages/household_page/_export.dart diff --git a/lib/pages/household_page/expense_list.dart b/kitchenowl/lib/pages/household_page/expense_list.dart similarity index 100% rename from lib/pages/household_page/expense_list.dart rename to kitchenowl/lib/pages/household_page/expense_list.dart diff --git a/lib/pages/household_page/household_drawer.dart b/kitchenowl/lib/pages/household_page/household_drawer.dart similarity index 100% rename from lib/pages/household_page/household_drawer.dart rename to kitchenowl/lib/pages/household_page/household_drawer.dart diff --git a/lib/pages/household_page/household_navigation_rail.dart b/kitchenowl/lib/pages/household_page/household_navigation_rail.dart similarity index 100% rename from lib/pages/household_page/household_navigation_rail.dart rename to kitchenowl/lib/pages/household_page/household_navigation_rail.dart diff --git a/lib/pages/household_page/planner.dart b/kitchenowl/lib/pages/household_page/planner.dart similarity index 100% rename from lib/pages/household_page/planner.dart rename to kitchenowl/lib/pages/household_page/planner.dart diff --git a/lib/pages/household_page/profile.dart b/kitchenowl/lib/pages/household_page/profile.dart similarity index 100% rename from lib/pages/household_page/profile.dart rename to kitchenowl/lib/pages/household_page/profile.dart diff --git a/lib/pages/household_page/recipe_list.dart b/kitchenowl/lib/pages/household_page/recipe_list.dart similarity index 100% rename from lib/pages/household_page/recipe_list.dart rename to kitchenowl/lib/pages/household_page/recipe_list.dart diff --git a/lib/pages/household_page/shoppinglist.dart b/kitchenowl/lib/pages/household_page/shoppinglist.dart similarity index 100% rename from lib/pages/household_page/shoppinglist.dart rename to kitchenowl/lib/pages/household_page/shoppinglist.dart diff --git a/lib/pages/household_update_page.dart b/kitchenowl/lib/pages/household_update_page.dart similarity index 100% rename from lib/pages/household_update_page.dart rename to kitchenowl/lib/pages/household_update_page.dart diff --git a/lib/pages/icon_selection_page.dart b/kitchenowl/lib/pages/icon_selection_page.dart similarity index 100% rename from lib/pages/icon_selection_page.dart rename to kitchenowl/lib/pages/icon_selection_page.dart diff --git a/lib/pages/item_page.dart b/kitchenowl/lib/pages/item_page.dart similarity index 100% rename from lib/pages/item_page.dart rename to kitchenowl/lib/pages/item_page.dart diff --git a/lib/pages/item_search_page.dart b/kitchenowl/lib/pages/item_search_page.dart similarity index 100% rename from lib/pages/item_search_page.dart rename to kitchenowl/lib/pages/item_search_page.dart diff --git a/lib/pages/item_selection_page.dart b/kitchenowl/lib/pages/item_selection_page.dart similarity index 100% rename from lib/pages/item_selection_page.dart rename to kitchenowl/lib/pages/item_selection_page.dart diff --git a/lib/pages/login_page.dart b/kitchenowl/lib/pages/login_page.dart similarity index 100% rename from lib/pages/login_page.dart rename to kitchenowl/lib/pages/login_page.dart diff --git a/lib/pages/login_redirect_page.dart b/kitchenowl/lib/pages/login_redirect_page.dart similarity index 100% rename from lib/pages/login_redirect_page.dart rename to kitchenowl/lib/pages/login_redirect_page.dart diff --git a/lib/pages/onboarding_page.dart b/kitchenowl/lib/pages/onboarding_page.dart similarity index 100% rename from lib/pages/onboarding_page.dart rename to kitchenowl/lib/pages/onboarding_page.dart diff --git a/lib/pages/page_not_found.dart b/kitchenowl/lib/pages/page_not_found.dart similarity index 100% rename from lib/pages/page_not_found.dart rename to kitchenowl/lib/pages/page_not_found.dart diff --git a/lib/pages/password_forgot_page.dart b/kitchenowl/lib/pages/password_forgot_page.dart similarity index 100% rename from lib/pages/password_forgot_page.dart rename to kitchenowl/lib/pages/password_forgot_page.dart diff --git a/lib/pages/password_reset_page.dart b/kitchenowl/lib/pages/password_reset_page.dart similarity index 100% rename from lib/pages/password_reset_page.dart rename to kitchenowl/lib/pages/password_reset_page.dart diff --git a/lib/pages/photo_view_page.dart b/kitchenowl/lib/pages/photo_view_page.dart similarity index 100% rename from lib/pages/photo_view_page.dart rename to kitchenowl/lib/pages/photo_view_page.dart diff --git a/lib/pages/recipe_add_update_page.dart b/kitchenowl/lib/pages/recipe_add_update_page.dart similarity index 100% rename from lib/pages/recipe_add_update_page.dart rename to kitchenowl/lib/pages/recipe_add_update_page.dart diff --git a/lib/pages/recipe_page.dart b/kitchenowl/lib/pages/recipe_page.dart similarity index 100% rename from lib/pages/recipe_page.dart rename to kitchenowl/lib/pages/recipe_page.dart diff --git a/lib/pages/recipe_scraper_page.dart b/kitchenowl/lib/pages/recipe_scraper_page.dart similarity index 100% rename from lib/pages/recipe_scraper_page.dart rename to kitchenowl/lib/pages/recipe_scraper_page.dart diff --git a/lib/pages/settings/create_user_page.dart b/kitchenowl/lib/pages/settings/create_user_page.dart similarity index 100% rename from lib/pages/settings/create_user_page.dart rename to kitchenowl/lib/pages/settings/create_user_page.dart diff --git a/lib/pages/settings_page.dart b/kitchenowl/lib/pages/settings_page.dart similarity index 100% rename from lib/pages/settings_page.dart rename to kitchenowl/lib/pages/settings_page.dart diff --git a/lib/pages/settings_server_user_page.dart b/kitchenowl/lib/pages/settings_server_user_page.dart similarity index 100% rename from lib/pages/settings_server_user_page.dart rename to kitchenowl/lib/pages/settings_server_user_page.dart diff --git a/lib/pages/settings_user_email_page.dart b/kitchenowl/lib/pages/settings_user_email_page.dart similarity index 100% rename from lib/pages/settings_user_email_page.dart rename to kitchenowl/lib/pages/settings_user_email_page.dart diff --git a/lib/pages/settings_user_linked_accounts_page.dart b/kitchenowl/lib/pages/settings_user_linked_accounts_page.dart similarity index 100% rename from lib/pages/settings_user_linked_accounts_page.dart rename to kitchenowl/lib/pages/settings_user_linked_accounts_page.dart diff --git a/lib/pages/settings_user_page.dart b/kitchenowl/lib/pages/settings_user_page.dart similarity index 100% rename from lib/pages/settings_user_page.dart rename to kitchenowl/lib/pages/settings_user_page.dart diff --git a/lib/pages/settings_user_password_page.dart b/kitchenowl/lib/pages/settings_user_password_page.dart similarity index 100% rename from lib/pages/settings_user_password_page.dart rename to kitchenowl/lib/pages/settings_user_password_page.dart diff --git a/lib/pages/settings_user_sessions_page.dart b/kitchenowl/lib/pages/settings_user_sessions_page.dart similarity index 100% rename from lib/pages/settings_user_sessions_page.dart rename to kitchenowl/lib/pages/settings_user_sessions_page.dart diff --git a/lib/pages/setup_page.dart b/kitchenowl/lib/pages/setup_page.dart similarity index 100% rename from lib/pages/setup_page.dart rename to kitchenowl/lib/pages/setup_page.dart diff --git a/lib/pages/signup_page.dart b/kitchenowl/lib/pages/signup_page.dart similarity index 100% rename from lib/pages/signup_page.dart rename to kitchenowl/lib/pages/signup_page.dart diff --git a/lib/pages/splash_page.dart b/kitchenowl/lib/pages/splash_page.dart similarity index 100% rename from lib/pages/splash_page.dart rename to kitchenowl/lib/pages/splash_page.dart diff --git a/lib/pages/unreachable_page.dart b/kitchenowl/lib/pages/unreachable_page.dart similarity index 100% rename from lib/pages/unreachable_page.dart rename to kitchenowl/lib/pages/unreachable_page.dart diff --git a/lib/pages/unsupported_page.dart b/kitchenowl/lib/pages/unsupported_page.dart similarity index 100% rename from lib/pages/unsupported_page.dart rename to kitchenowl/lib/pages/unsupported_page.dart diff --git a/lib/pages/user_search_page.dart b/kitchenowl/lib/pages/user_search_page.dart similarity index 100% rename from lib/pages/user_search_page.dart rename to kitchenowl/lib/pages/user_search_page.dart diff --git a/lib/router.dart b/kitchenowl/lib/router.dart similarity index 100% rename from lib/router.dart rename to kitchenowl/lib/router.dart diff --git a/lib/services/api/analytics.dart b/kitchenowl/lib/services/api/analytics.dart similarity index 100% rename from lib/services/api/analytics.dart rename to kitchenowl/lib/services/api/analytics.dart diff --git a/lib/services/api/api_service.dart b/kitchenowl/lib/services/api/api_service.dart similarity index 100% rename from lib/services/api/api_service.dart rename to kitchenowl/lib/services/api/api_service.dart diff --git a/lib/services/api/category.dart b/kitchenowl/lib/services/api/category.dart similarity index 100% rename from lib/services/api/category.dart rename to kitchenowl/lib/services/api/category.dart diff --git a/lib/services/api/expense.dart b/kitchenowl/lib/services/api/expense.dart similarity index 100% rename from lib/services/api/expense.dart rename to kitchenowl/lib/services/api/expense.dart diff --git a/lib/services/api/household.dart b/kitchenowl/lib/services/api/household.dart similarity index 100% rename from lib/services/api/household.dart rename to kitchenowl/lib/services/api/household.dart diff --git a/lib/services/api/import_export.dart b/kitchenowl/lib/services/api/import_export.dart similarity index 100% rename from lib/services/api/import_export.dart rename to kitchenowl/lib/services/api/import_export.dart diff --git a/lib/services/api/item.dart b/kitchenowl/lib/services/api/item.dart similarity index 100% rename from lib/services/api/item.dart rename to kitchenowl/lib/services/api/item.dart diff --git a/lib/services/api/planner.dart b/kitchenowl/lib/services/api/planner.dart similarity index 100% rename from lib/services/api/planner.dart rename to kitchenowl/lib/services/api/planner.dart diff --git a/lib/services/api/recipe.dart b/kitchenowl/lib/services/api/recipe.dart similarity index 100% rename from lib/services/api/recipe.dart rename to kitchenowl/lib/services/api/recipe.dart diff --git a/lib/services/api/shoppinglist.dart b/kitchenowl/lib/services/api/shoppinglist.dart similarity index 100% rename from lib/services/api/shoppinglist.dart rename to kitchenowl/lib/services/api/shoppinglist.dart diff --git a/lib/services/api/tag.dart b/kitchenowl/lib/services/api/tag.dart similarity index 100% rename from lib/services/api/tag.dart rename to kitchenowl/lib/services/api/tag.dart diff --git a/lib/services/api/upload.dart b/kitchenowl/lib/services/api/upload.dart similarity index 100% rename from lib/services/api/upload.dart rename to kitchenowl/lib/services/api/upload.dart diff --git a/lib/services/api/user.dart b/kitchenowl/lib/services/api/user.dart similarity index 100% rename from lib/services/api/user.dart rename to kitchenowl/lib/services/api/user.dart diff --git a/lib/services/storage/mem_storage.dart b/kitchenowl/lib/services/storage/mem_storage.dart similarity index 100% rename from lib/services/storage/mem_storage.dart rename to kitchenowl/lib/services/storage/mem_storage.dart diff --git a/lib/services/storage/storage.dart b/kitchenowl/lib/services/storage/storage.dart similarity index 100% rename from lib/services/storage/storage.dart rename to kitchenowl/lib/services/storage/storage.dart diff --git a/lib/services/storage/temp_storage.dart b/kitchenowl/lib/services/storage/temp_storage.dart similarity index 100% rename from lib/services/storage/temp_storage.dart rename to kitchenowl/lib/services/storage/temp_storage.dart diff --git a/lib/services/storage/transaction_storage.dart b/kitchenowl/lib/services/storage/transaction_storage.dart similarity index 100% rename from lib/services/storage/transaction_storage.dart rename to kitchenowl/lib/services/storage/transaction_storage.dart diff --git a/lib/services/transaction.dart b/kitchenowl/lib/services/transaction.dart similarity index 100% rename from lib/services/transaction.dart rename to kitchenowl/lib/services/transaction.dart diff --git a/lib/services/transaction_handler.dart b/kitchenowl/lib/services/transaction_handler.dart similarity index 100% rename from lib/services/transaction_handler.dart rename to kitchenowl/lib/services/transaction_handler.dart diff --git a/lib/services/transactions/category.dart b/kitchenowl/lib/services/transactions/category.dart similarity index 100% rename from lib/services/transactions/category.dart rename to kitchenowl/lib/services/transactions/category.dart diff --git a/lib/services/transactions/expense.dart b/kitchenowl/lib/services/transactions/expense.dart similarity index 100% rename from lib/services/transactions/expense.dart rename to kitchenowl/lib/services/transactions/expense.dart diff --git a/lib/services/transactions/household.dart b/kitchenowl/lib/services/transactions/household.dart similarity index 100% rename from lib/services/transactions/household.dart rename to kitchenowl/lib/services/transactions/household.dart diff --git a/lib/services/transactions/item.dart b/kitchenowl/lib/services/transactions/item.dart similarity index 100% rename from lib/services/transactions/item.dart rename to kitchenowl/lib/services/transactions/item.dart diff --git a/lib/services/transactions/planner.dart b/kitchenowl/lib/services/transactions/planner.dart similarity index 100% rename from lib/services/transactions/planner.dart rename to kitchenowl/lib/services/transactions/planner.dart diff --git a/lib/services/transactions/recipe.dart b/kitchenowl/lib/services/transactions/recipe.dart similarity index 100% rename from lib/services/transactions/recipe.dart rename to kitchenowl/lib/services/transactions/recipe.dart diff --git a/lib/services/transactions/shoppinglist.dart b/kitchenowl/lib/services/transactions/shoppinglist.dart similarity index 100% rename from lib/services/transactions/shoppinglist.dart rename to kitchenowl/lib/services/transactions/shoppinglist.dart diff --git a/lib/services/transactions/tag.dart b/kitchenowl/lib/services/transactions/tag.dart similarity index 100% rename from lib/services/transactions/tag.dart rename to kitchenowl/lib/services/transactions/tag.dart diff --git a/lib/services/transactions/user.dart b/kitchenowl/lib/services/transactions/user.dart similarity index 100% rename from lib/services/transactions/user.dart rename to kitchenowl/lib/services/transactions/user.dart diff --git a/lib/styles/colors.dart b/kitchenowl/lib/styles/colors.dart similarity index 100% rename from lib/styles/colors.dart rename to kitchenowl/lib/styles/colors.dart diff --git a/lib/styles/dynamic.dart b/kitchenowl/lib/styles/dynamic.dart similarity index 100% rename from lib/styles/dynamic.dart rename to kitchenowl/lib/styles/dynamic.dart diff --git a/lib/styles/themes.dart b/kitchenowl/lib/styles/themes.dart similarity index 100% rename from lib/styles/themes.dart rename to kitchenowl/lib/styles/themes.dart diff --git a/lib/widgets/_export.dart b/kitchenowl/lib/widgets/_export.dart similarity index 100% rename from lib/widgets/_export.dart rename to kitchenowl/lib/widgets/_export.dart diff --git a/lib/widgets/chart_bar_member_distribution.dart b/kitchenowl/lib/widgets/chart_bar_member_distribution.dart similarity index 100% rename from lib/widgets/chart_bar_member_distribution.dart rename to kitchenowl/lib/widgets/chart_bar_member_distribution.dart diff --git a/lib/widgets/chart_bar_months.dart b/kitchenowl/lib/widgets/chart_bar_months.dart similarity index 100% rename from lib/widgets/chart_bar_months.dart rename to kitchenowl/lib/widgets/chart_bar_months.dart diff --git a/lib/widgets/chart_line_current_month.dart b/kitchenowl/lib/widgets/chart_line_current_month.dart similarity index 100% rename from lib/widgets/chart_line_current_month.dart rename to kitchenowl/lib/widgets/chart_line_current_month.dart diff --git a/lib/widgets/chart_pie_current_month.dart b/kitchenowl/lib/widgets/chart_pie_current_month.dart similarity index 100% rename from lib/widgets/chart_pie_current_month.dart rename to kitchenowl/lib/widgets/chart_pie_current_month.dart diff --git a/lib/widgets/checkbox_list_tile.dart b/kitchenowl/lib/widgets/checkbox_list_tile.dart similarity index 100% rename from lib/widgets/checkbox_list_tile.dart rename to kitchenowl/lib/widgets/checkbox_list_tile.dart diff --git a/lib/widgets/choice_scroll.dart b/kitchenowl/lib/widgets/choice_scroll.dart similarity index 100% rename from lib/widgets/choice_scroll.dart rename to kitchenowl/lib/widgets/choice_scroll.dart diff --git a/lib/widgets/confirmation_dialog.dart b/kitchenowl/lib/widgets/confirmation_dialog.dart similarity index 100% rename from lib/widgets/confirmation_dialog.dart rename to kitchenowl/lib/widgets/confirmation_dialog.dart diff --git a/lib/widgets/create_user_form_fields.dart b/kitchenowl/lib/widgets/create_user_form_fields.dart similarity index 100% rename from lib/widgets/create_user_form_fields.dart rename to kitchenowl/lib/widgets/create_user_form_fields.dart diff --git a/lib/widgets/dismissible_card.dart b/kitchenowl/lib/widgets/dismissible_card.dart similarity index 100% rename from lib/widgets/dismissible_card.dart rename to kitchenowl/lib/widgets/dismissible_card.dart diff --git a/lib/widgets/expandable_fab.dart b/kitchenowl/lib/widgets/expandable_fab.dart similarity index 100% rename from lib/widgets/expandable_fab.dart rename to kitchenowl/lib/widgets/expandable_fab.dart diff --git a/lib/widgets/expense/timeframe_dropdown_button.dart b/kitchenowl/lib/widgets/expense/timeframe_dropdown_button.dart similarity index 100% rename from lib/widgets/expense/timeframe_dropdown_button.dart rename to kitchenowl/lib/widgets/expense/timeframe_dropdown_button.dart diff --git a/lib/widgets/expense_add_update/paid_for_widget.dart b/kitchenowl/lib/widgets/expense_add_update/paid_for_widget.dart similarity index 100% rename from lib/widgets/expense_add_update/paid_for_widget.dart rename to kitchenowl/lib/widgets/expense_add_update/paid_for_widget.dart diff --git a/lib/widgets/expense_category_icon.dart b/kitchenowl/lib/widgets/expense_category_icon.dart similarity index 100% rename from lib/widgets/expense_category_icon.dart rename to kitchenowl/lib/widgets/expense_category_icon.dart diff --git a/lib/widgets/expense_create_fab.dart b/kitchenowl/lib/widgets/expense_create_fab.dart similarity index 100% rename from lib/widgets/expense_create_fab.dart rename to kitchenowl/lib/widgets/expense_create_fab.dart diff --git a/lib/widgets/expense_item.dart b/kitchenowl/lib/widgets/expense_item.dart similarity index 100% rename from lib/widgets/expense_item.dart rename to kitchenowl/lib/widgets/expense_item.dart diff --git a/lib/widgets/flexible_image_space_bar.dart b/kitchenowl/lib/widgets/flexible_image_space_bar.dart similarity index 100% rename from lib/widgets/flexible_image_space_bar.dart rename to kitchenowl/lib/widgets/flexible_image_space_bar.dart diff --git a/lib/widgets/fractionally_sized_box.dart b/kitchenowl/lib/widgets/fractionally_sized_box.dart similarity index 100% rename from lib/widgets/fractionally_sized_box.dart rename to kitchenowl/lib/widgets/fractionally_sized_box.dart diff --git a/lib/widgets/home_page/sliver_category_item_grid_list.dart b/kitchenowl/lib/widgets/home_page/sliver_category_item_grid_list.dart similarity index 100% rename from lib/widgets/home_page/sliver_category_item_grid_list.dart rename to kitchenowl/lib/widgets/home_page/sliver_category_item_grid_list.dart diff --git a/lib/widgets/household_card.dart b/kitchenowl/lib/widgets/household_card.dart similarity index 100% rename from lib/widgets/household_card.dart rename to kitchenowl/lib/widgets/household_card.dart diff --git a/lib/widgets/image_provider.dart b/kitchenowl/lib/widgets/image_provider.dart similarity index 100% rename from lib/widgets/image_provider.dart rename to kitchenowl/lib/widgets/image_provider.dart diff --git a/lib/widgets/image_selector.dart b/kitchenowl/lib/widgets/image_selector.dart similarity index 100% rename from lib/widgets/image_selector.dart rename to kitchenowl/lib/widgets/image_selector.dart diff --git a/lib/widgets/kitchenowl_color_picker_dialog.dart b/kitchenowl/lib/widgets/kitchenowl_color_picker_dialog.dart similarity index 100% rename from lib/widgets/kitchenowl_color_picker_dialog.dart rename to kitchenowl/lib/widgets/kitchenowl_color_picker_dialog.dart diff --git a/lib/widgets/kitchenowl_fab.dart b/kitchenowl/lib/widgets/kitchenowl_fab.dart similarity index 100% rename from lib/widgets/kitchenowl_fab.dart rename to kitchenowl/lib/widgets/kitchenowl_fab.dart diff --git a/lib/widgets/kitchenowl_switch.dart b/kitchenowl/lib/widgets/kitchenowl_switch.dart similarity index 100% rename from lib/widgets/kitchenowl_switch.dart rename to kitchenowl/lib/widgets/kitchenowl_switch.dart diff --git a/lib/widgets/language_dialog.dart b/kitchenowl/lib/widgets/language_dialog.dart similarity index 100% rename from lib/widgets/language_dialog.dart rename to kitchenowl/lib/widgets/language_dialog.dart diff --git a/lib/widgets/left_right_wrap.dart b/kitchenowl/lib/widgets/left_right_wrap.dart similarity index 100% rename from lib/widgets/left_right_wrap.dart rename to kitchenowl/lib/widgets/left_right_wrap.dart diff --git a/lib/widgets/loading_elevated_button.dart b/kitchenowl/lib/widgets/loading_elevated_button.dart similarity index 100% rename from lib/widgets/loading_elevated_button.dart rename to kitchenowl/lib/widgets/loading_elevated_button.dart diff --git a/lib/widgets/loading_elevated_button_icon.dart b/kitchenowl/lib/widgets/loading_elevated_button_icon.dart similarity index 100% rename from lib/widgets/loading_elevated_button_icon.dart rename to kitchenowl/lib/widgets/loading_elevated_button_icon.dart diff --git a/lib/widgets/loading_icon_button.dart b/kitchenowl/lib/widgets/loading_icon_button.dart similarity index 100% rename from lib/widgets/loading_icon_button.dart rename to kitchenowl/lib/widgets/loading_icon_button.dart diff --git a/lib/widgets/loading_list_tile.dart b/kitchenowl/lib/widgets/loading_list_tile.dart similarity index 100% rename from lib/widgets/loading_list_tile.dart rename to kitchenowl/lib/widgets/loading_list_tile.dart diff --git a/lib/widgets/loading_text_button.dart b/kitchenowl/lib/widgets/loading_text_button.dart similarity index 100% rename from lib/widgets/loading_text_button.dart rename to kitchenowl/lib/widgets/loading_text_button.dart diff --git a/lib/widgets/number_selector.dart b/kitchenowl/lib/widgets/number_selector.dart similarity index 100% rename from lib/widgets/number_selector.dart rename to kitchenowl/lib/widgets/number_selector.dart diff --git a/lib/widgets/recipe_card.dart b/kitchenowl/lib/widgets/recipe_card.dart similarity index 100% rename from lib/widgets/recipe_card.dart rename to kitchenowl/lib/widgets/recipe_card.dart diff --git a/lib/widgets/recipe_create_fab.dart b/kitchenowl/lib/widgets/recipe_create_fab.dart similarity index 100% rename from lib/widgets/recipe_create_fab.dart rename to kitchenowl/lib/widgets/recipe_create_fab.dart diff --git a/lib/widgets/recipe_item.dart b/kitchenowl/lib/widgets/recipe_item.dart similarity index 100% rename from lib/widgets/recipe_item.dart rename to kitchenowl/lib/widgets/recipe_item.dart diff --git a/lib/widgets/recipe_source_chip.dart b/kitchenowl/lib/widgets/recipe_source_chip.dart similarity index 100% rename from lib/widgets/recipe_source_chip.dart rename to kitchenowl/lib/widgets/recipe_source_chip.dart diff --git a/lib/widgets/recipe_time_settings.dart b/kitchenowl/lib/widgets/recipe_time_settings.dart similarity index 100% rename from lib/widgets/recipe_time_settings.dart rename to kitchenowl/lib/widgets/recipe_time_settings.dart diff --git a/lib/widgets/rendering/sliver_with_pinned_footer.dart b/kitchenowl/lib/widgets/rendering/sliver_with_pinned_footer.dart similarity index 100% rename from lib/widgets/rendering/sliver_with_pinned_footer.dart rename to kitchenowl/lib/widgets/rendering/sliver_with_pinned_footer.dart diff --git a/lib/widgets/search_text_field.dart b/kitchenowl/lib/widgets/search_text_field.dart similarity index 100% rename from lib/widgets/search_text_field.dart rename to kitchenowl/lib/widgets/search_text_field.dart diff --git a/lib/widgets/select_dialog.dart b/kitchenowl/lib/widgets/select_dialog.dart similarity index 100% rename from lib/widgets/select_dialog.dart rename to kitchenowl/lib/widgets/select_dialog.dart diff --git a/lib/widgets/select_file.dart b/kitchenowl/lib/widgets/select_file.dart similarity index 100% rename from lib/widgets/select_file.dart rename to kitchenowl/lib/widgets/select_file.dart diff --git a/lib/widgets/selectable_button_card.dart b/kitchenowl/lib/widgets/selectable_button_card.dart similarity index 100% rename from lib/widgets/selectable_button_card.dart rename to kitchenowl/lib/widgets/selectable_button_card.dart diff --git a/lib/widgets/selectable_button_list_tile.dart b/kitchenowl/lib/widgets/selectable_button_list_tile.dart similarity index 100% rename from lib/widgets/selectable_button_list_tile.dart rename to kitchenowl/lib/widgets/selectable_button_list_tile.dart diff --git a/lib/widgets/settings/color_button.dart b/kitchenowl/lib/widgets/settings/color_button.dart similarity index 100% rename from lib/widgets/settings/color_button.dart rename to kitchenowl/lib/widgets/settings/color_button.dart diff --git a/lib/widgets/settings/server_user_card.dart b/kitchenowl/lib/widgets/settings/server_user_card.dart similarity index 100% rename from lib/widgets/settings/server_user_card.dart rename to kitchenowl/lib/widgets/settings/server_user_card.dart diff --git a/lib/widgets/settings/token_bottom_sheet.dart b/kitchenowl/lib/widgets/settings/token_bottom_sheet.dart similarity index 100% rename from lib/widgets/settings/token_bottom_sheet.dart rename to kitchenowl/lib/widgets/settings/token_bottom_sheet.dart diff --git a/lib/widgets/settings/token_card.dart b/kitchenowl/lib/widgets/settings/token_card.dart similarity index 100% rename from lib/widgets/settings/token_card.dart rename to kitchenowl/lib/widgets/settings/token_card.dart diff --git a/lib/widgets/settings_household/import_settings_dialog.dart b/kitchenowl/lib/widgets/settings_household/import_settings_dialog.dart similarity index 100% rename from lib/widgets/settings_household/import_settings_dialog.dart rename to kitchenowl/lib/widgets/settings_household/import_settings_dialog.dart diff --git a/lib/widgets/settings_household/sliver_household_category_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_category_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_category_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_category_settings.dart diff --git a/lib/widgets/settings_household/sliver_household_danger_zone.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_danger_zone.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart diff --git a/lib/widgets/settings_household/sliver_household_expense_category_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_expense_category_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_expense_category_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_expense_category_settings.dart diff --git a/lib/widgets/settings_household/sliver_household_feature_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_feature_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_feature_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_feature_settings.dart diff --git a/lib/widgets/settings_household/sliver_household_shoppinglist_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_shoppinglist_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_shoppinglist_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_shoppinglist_settings.dart diff --git a/lib/widgets/settings_household/sliver_household_tags_settings.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_tags_settings.dart similarity index 100% rename from lib/widgets/settings_household/sliver_household_tags_settings.dart rename to kitchenowl/lib/widgets/settings_household/sliver_household_tags_settings.dart diff --git a/lib/widgets/settings_household/update_member_bottom_sheet.dart b/kitchenowl/lib/widgets/settings_household/update_member_bottom_sheet.dart similarity index 100% rename from lib/widgets/settings_household/update_member_bottom_sheet.dart rename to kitchenowl/lib/widgets/settings_household/update_member_bottom_sheet.dart diff --git a/lib/widgets/settings_household/view_settings_list_tile.dart b/kitchenowl/lib/widgets/settings_household/view_settings_list_tile.dart similarity index 100% rename from lib/widgets/settings_household/view_settings_list_tile.dart rename to kitchenowl/lib/widgets/settings_household/view_settings_list_tile.dart diff --git a/lib/widgets/shimmer_card.dart b/kitchenowl/lib/widgets/shimmer_card.dart similarity index 100% rename from lib/widgets/shimmer_card.dart rename to kitchenowl/lib/widgets/shimmer_card.dart diff --git a/lib/widgets/shimmer_shopping_item.dart b/kitchenowl/lib/widgets/shimmer_shopping_item.dart similarity index 100% rename from lib/widgets/shimmer_shopping_item.dart rename to kitchenowl/lib/widgets/shimmer_shopping_item.dart diff --git a/lib/widgets/shopping_item.dart b/kitchenowl/lib/widgets/shopping_item.dart similarity index 100% rename from lib/widgets/shopping_item.dart rename to kitchenowl/lib/widgets/shopping_item.dart diff --git a/lib/widgets/shoppinglist_confirm_remove_fab.dart b/kitchenowl/lib/widgets/shoppinglist_confirm_remove_fab.dart similarity index 100% rename from lib/widgets/shoppinglist_confirm_remove_fab.dart rename to kitchenowl/lib/widgets/shoppinglist_confirm_remove_fab.dart diff --git a/lib/widgets/show_snack_bar.dart b/kitchenowl/lib/widgets/show_snack_bar.dart similarity index 100% rename from lib/widgets/show_snack_bar.dart rename to kitchenowl/lib/widgets/show_snack_bar.dart diff --git a/lib/widgets/sliver_implicit_animated_list.dart b/kitchenowl/lib/widgets/sliver_implicit_animated_list.dart similarity index 100% rename from lib/widgets/sliver_implicit_animated_list.dart rename to kitchenowl/lib/widgets/sliver_implicit_animated_list.dart diff --git a/lib/widgets/sliver_item_grid_list.dart b/kitchenowl/lib/widgets/sliver_item_grid_list.dart similarity index 100% rename from lib/widgets/sliver_item_grid_list.dart rename to kitchenowl/lib/widgets/sliver_item_grid_list.dart diff --git a/lib/widgets/sliver_text.dart b/kitchenowl/lib/widgets/sliver_text.dart similarity index 100% rename from lib/widgets/sliver_text.dart rename to kitchenowl/lib/widgets/sliver_text.dart diff --git a/lib/widgets/sliver_with_pinned_footer.dart b/kitchenowl/lib/widgets/sliver_with_pinned_footer.dart similarity index 100% rename from lib/widgets/sliver_with_pinned_footer.dart rename to kitchenowl/lib/widgets/sliver_with_pinned_footer.dart diff --git a/lib/widgets/string_item_match.dart b/kitchenowl/lib/widgets/string_item_match.dart similarity index 100% rename from lib/widgets/string_item_match.dart rename to kitchenowl/lib/widgets/string_item_match.dart diff --git a/lib/widgets/text_dialog.dart b/kitchenowl/lib/widgets/text_dialog.dart similarity index 100% rename from lib/widgets/text_dialog.dart rename to kitchenowl/lib/widgets/text_dialog.dart diff --git a/lib/widgets/text_with_icon_button.dart b/kitchenowl/lib/widgets/text_with_icon_button.dart similarity index 100% rename from lib/widgets/text_with_icon_button.dart rename to kitchenowl/lib/widgets/text_with_icon_button.dart diff --git a/lib/widgets/trailing_icon_text_button.dart b/kitchenowl/lib/widgets/trailing_icon_text_button.dart similarity index 100% rename from lib/widgets/trailing_icon_text_button.dart rename to kitchenowl/lib/widgets/trailing_icon_text_button.dart diff --git a/lib/widgets/user_list_tile.dart b/kitchenowl/lib/widgets/user_list_tile.dart similarity index 100% rename from lib/widgets/user_list_tile.dart rename to kitchenowl/lib/widgets/user_list_tile.dart diff --git a/linux/.gitignore b/kitchenowl/linux/.gitignore similarity index 100% rename from linux/.gitignore rename to kitchenowl/linux/.gitignore diff --git a/linux/CMakeLists.txt b/kitchenowl/linux/CMakeLists.txt similarity index 100% rename from linux/CMakeLists.txt rename to kitchenowl/linux/CMakeLists.txt diff --git a/linux/flutter/CMakeLists.txt b/kitchenowl/linux/flutter/CMakeLists.txt similarity index 100% rename from linux/flutter/CMakeLists.txt rename to kitchenowl/linux/flutter/CMakeLists.txt diff --git a/linux/flutter/generated_plugin_registrant.cc b/kitchenowl/linux/flutter/generated_plugin_registrant.cc similarity index 100% rename from linux/flutter/generated_plugin_registrant.cc rename to kitchenowl/linux/flutter/generated_plugin_registrant.cc diff --git a/linux/flutter/generated_plugin_registrant.h b/kitchenowl/linux/flutter/generated_plugin_registrant.h similarity index 100% rename from linux/flutter/generated_plugin_registrant.h rename to kitchenowl/linux/flutter/generated_plugin_registrant.h diff --git a/linux/flutter/generated_plugins.cmake b/kitchenowl/linux/flutter/generated_plugins.cmake similarity index 100% rename from linux/flutter/generated_plugins.cmake rename to kitchenowl/linux/flutter/generated_plugins.cmake diff --git a/linux/icon.png b/kitchenowl/linux/icon.png similarity index 100% rename from linux/icon.png rename to kitchenowl/linux/icon.png diff --git a/linux/kitchenowl.desktop b/kitchenowl/linux/kitchenowl.desktop similarity index 100% rename from linux/kitchenowl.desktop rename to kitchenowl/linux/kitchenowl.desktop diff --git a/linux/main.cc b/kitchenowl/linux/main.cc similarity index 100% rename from linux/main.cc rename to kitchenowl/linux/main.cc diff --git a/linux/my_application.cc b/kitchenowl/linux/my_application.cc similarity index 100% rename from linux/my_application.cc rename to kitchenowl/linux/my_application.cc diff --git a/linux/my_application.h b/kitchenowl/linux/my_application.h similarity index 100% rename from linux/my_application.h rename to kitchenowl/linux/my_application.h diff --git a/macos/.gitignore b/kitchenowl/macos/.gitignore similarity index 100% rename from macos/.gitignore rename to kitchenowl/macos/.gitignore diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/kitchenowl/macos/Flutter/Flutter-Debug.xcconfig similarity index 100% rename from macos/Flutter/Flutter-Debug.xcconfig rename to kitchenowl/macos/Flutter/Flutter-Debug.xcconfig diff --git a/macos/Flutter/Flutter-Release.xcconfig b/kitchenowl/macos/Flutter/Flutter-Release.xcconfig similarity index 100% rename from macos/Flutter/Flutter-Release.xcconfig rename to kitchenowl/macos/Flutter/Flutter-Release.xcconfig diff --git a/macos/Podfile b/kitchenowl/macos/Podfile similarity index 100% rename from macos/Podfile rename to kitchenowl/macos/Podfile diff --git a/macos/Runner.xcodeproj/project.pbxproj b/kitchenowl/macos/Runner.xcodeproj/project.pbxproj similarity index 100% rename from macos/Runner.xcodeproj/project.pbxproj rename to kitchenowl/macos/Runner.xcodeproj/project.pbxproj diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kitchenowl/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to kitchenowl/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/kitchenowl/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to kitchenowl/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/kitchenowl/macos/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from macos/Runner.xcworkspace/contents.xcworkspacedata rename to kitchenowl/macos/Runner.xcworkspace/contents.xcworkspacedata diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/kitchenowl/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to kitchenowl/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/macos/Runner/AppDelegate.swift b/kitchenowl/macos/Runner/AppDelegate.swift similarity index 100% rename from macos/Runner/AppDelegate.swift rename to kitchenowl/macos/Runner/AppDelegate.swift diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png similarity index 100% rename from macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png rename to kitchenowl/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/kitchenowl/macos/Runner/Base.lproj/MainMenu.xib similarity index 100% rename from macos/Runner/Base.lproj/MainMenu.xib rename to kitchenowl/macos/Runner/Base.lproj/MainMenu.xib diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/kitchenowl/macos/Runner/Configs/AppInfo.xcconfig similarity index 100% rename from macos/Runner/Configs/AppInfo.xcconfig rename to kitchenowl/macos/Runner/Configs/AppInfo.xcconfig diff --git a/macos/Runner/Configs/Debug.xcconfig b/kitchenowl/macos/Runner/Configs/Debug.xcconfig similarity index 100% rename from macos/Runner/Configs/Debug.xcconfig rename to kitchenowl/macos/Runner/Configs/Debug.xcconfig diff --git a/macos/Runner/Configs/Release.xcconfig b/kitchenowl/macos/Runner/Configs/Release.xcconfig similarity index 100% rename from macos/Runner/Configs/Release.xcconfig rename to kitchenowl/macos/Runner/Configs/Release.xcconfig diff --git a/macos/Runner/Configs/Warnings.xcconfig b/kitchenowl/macos/Runner/Configs/Warnings.xcconfig similarity index 100% rename from macos/Runner/Configs/Warnings.xcconfig rename to kitchenowl/macos/Runner/Configs/Warnings.xcconfig diff --git a/macos/Runner/DebugProfile.entitlements b/kitchenowl/macos/Runner/DebugProfile.entitlements similarity index 100% rename from macos/Runner/DebugProfile.entitlements rename to kitchenowl/macos/Runner/DebugProfile.entitlements diff --git a/macos/Runner/Info.plist b/kitchenowl/macos/Runner/Info.plist similarity index 100% rename from macos/Runner/Info.plist rename to kitchenowl/macos/Runner/Info.plist diff --git a/macos/Runner/MainFlutterWindow.swift b/kitchenowl/macos/Runner/MainFlutterWindow.swift similarity index 100% rename from macos/Runner/MainFlutterWindow.swift rename to kitchenowl/macos/Runner/MainFlutterWindow.swift diff --git a/macos/Runner/Release.entitlements b/kitchenowl/macos/Runner/Release.entitlements similarity index 100% rename from macos/Runner/Release.entitlements rename to kitchenowl/macos/Runner/Release.entitlements diff --git a/macos/RunnerTests/RunnerTests.swift b/kitchenowl/macos/RunnerTests/RunnerTests.swift similarity index 100% rename from macos/RunnerTests/RunnerTests.swift rename to kitchenowl/macos/RunnerTests/RunnerTests.swift diff --git a/pubspec.yaml b/kitchenowl/pubspec.yaml similarity index 100% rename from pubspec.yaml rename to kitchenowl/pubspec.yaml diff --git a/test/helpers/named_bytearray_test.dart b/kitchenowl/test/helpers/named_bytearray_test.dart similarity index 100% rename from test/helpers/named_bytearray_test.dart rename to kitchenowl/test/helpers/named_bytearray_test.dart diff --git a/test/models/category_test.dart b/kitchenowl/test/models/category_test.dart similarity index 100% rename from test/models/category_test.dart rename to kitchenowl/test/models/category_test.dart diff --git a/test/models/expense_category.dart b/kitchenowl/test/models/expense_category.dart similarity index 100% rename from test/models/expense_category.dart rename to kitchenowl/test/models/expense_category.dart diff --git a/test/models/expense_test.dart b/kitchenowl/test/models/expense_test.dart similarity index 100% rename from test/models/expense_test.dart rename to kitchenowl/test/models/expense_test.dart diff --git a/test/models/household_test.dart b/kitchenowl/test/models/household_test.dart similarity index 100% rename from test/models/household_test.dart rename to kitchenowl/test/models/household_test.dart diff --git a/test/models/item.dart b/kitchenowl/test/models/item.dart similarity index 100% rename from test/models/item.dart rename to kitchenowl/test/models/item.dart diff --git a/web/.well-known/apple-app-site-association b/kitchenowl/web/.well-known/apple-app-site-association similarity index 100% rename from web/.well-known/apple-app-site-association rename to kitchenowl/web/.well-known/apple-app-site-association diff --git a/web/.well-known/assetlinks.json b/kitchenowl/web/.well-known/assetlinks.json similarity index 100% rename from web/.well-known/assetlinks.json rename to kitchenowl/web/.well-known/assetlinks.json diff --git a/web/favicon.ico b/kitchenowl/web/favicon.ico similarity index 100% rename from web/favicon.ico rename to kitchenowl/web/favicon.ico diff --git a/web/favicon.png b/kitchenowl/web/favicon.png similarity index 100% rename from web/favicon.png rename to kitchenowl/web/favicon.png diff --git a/web/icons/Icon-192.png b/kitchenowl/web/icons/Icon-192.png similarity index 100% rename from web/icons/Icon-192.png rename to kitchenowl/web/icons/Icon-192.png diff --git a/web/icons/Icon-512.png b/kitchenowl/web/icons/Icon-512.png similarity index 100% rename from web/icons/Icon-512.png rename to kitchenowl/web/icons/Icon-512.png diff --git a/web/icons/Icon-maskable-192.png b/kitchenowl/web/icons/Icon-maskable-192.png similarity index 100% rename from web/icons/Icon-maskable-192.png rename to kitchenowl/web/icons/Icon-maskable-192.png diff --git a/web/icons/Icon-maskable-512.png b/kitchenowl/web/icons/Icon-maskable-512.png similarity index 100% rename from web/icons/Icon-maskable-512.png rename to kitchenowl/web/icons/Icon-maskable-512.png diff --git a/web/index.html b/kitchenowl/web/index.html similarity index 100% rename from web/index.html rename to kitchenowl/web/index.html diff --git a/web/manifest.json b/kitchenowl/web/manifest.json similarity index 100% rename from web/manifest.json rename to kitchenowl/web/manifest.json diff --git a/windows/.gitignore b/kitchenowl/windows/.gitignore similarity index 100% rename from windows/.gitignore rename to kitchenowl/windows/.gitignore diff --git a/windows/CMakeLists.txt b/kitchenowl/windows/CMakeLists.txt similarity index 100% rename from windows/CMakeLists.txt rename to kitchenowl/windows/CMakeLists.txt diff --git a/windows/flutter/CMakeLists.txt b/kitchenowl/windows/flutter/CMakeLists.txt similarity index 100% rename from windows/flutter/CMakeLists.txt rename to kitchenowl/windows/flutter/CMakeLists.txt diff --git a/windows/flutter/generated_plugin_registrant.cc b/kitchenowl/windows/flutter/generated_plugin_registrant.cc similarity index 100% rename from windows/flutter/generated_plugin_registrant.cc rename to kitchenowl/windows/flutter/generated_plugin_registrant.cc diff --git a/windows/flutter/generated_plugin_registrant.h b/kitchenowl/windows/flutter/generated_plugin_registrant.h similarity index 100% rename from windows/flutter/generated_plugin_registrant.h rename to kitchenowl/windows/flutter/generated_plugin_registrant.h diff --git a/windows/flutter/generated_plugins.cmake b/kitchenowl/windows/flutter/generated_plugins.cmake similarity index 100% rename from windows/flutter/generated_plugins.cmake rename to kitchenowl/windows/flutter/generated_plugins.cmake diff --git a/windows/runner/CMakeLists.txt b/kitchenowl/windows/runner/CMakeLists.txt similarity index 100% rename from windows/runner/CMakeLists.txt rename to kitchenowl/windows/runner/CMakeLists.txt diff --git a/windows/runner/Runner.rc b/kitchenowl/windows/runner/Runner.rc similarity index 100% rename from windows/runner/Runner.rc rename to kitchenowl/windows/runner/Runner.rc diff --git a/windows/runner/flutter_window.cpp b/kitchenowl/windows/runner/flutter_window.cpp similarity index 100% rename from windows/runner/flutter_window.cpp rename to kitchenowl/windows/runner/flutter_window.cpp diff --git a/windows/runner/flutter_window.h b/kitchenowl/windows/runner/flutter_window.h similarity index 100% rename from windows/runner/flutter_window.h rename to kitchenowl/windows/runner/flutter_window.h diff --git a/windows/runner/main.cpp b/kitchenowl/windows/runner/main.cpp similarity index 100% rename from windows/runner/main.cpp rename to kitchenowl/windows/runner/main.cpp diff --git a/windows/runner/resource.h b/kitchenowl/windows/runner/resource.h similarity index 100% rename from windows/runner/resource.h rename to kitchenowl/windows/runner/resource.h diff --git a/windows/runner/resources/app_icon.ico b/kitchenowl/windows/runner/resources/app_icon.ico similarity index 100% rename from windows/runner/resources/app_icon.ico rename to kitchenowl/windows/runner/resources/app_icon.ico diff --git a/windows/runner/runner.exe.manifest b/kitchenowl/windows/runner/runner.exe.manifest similarity index 100% rename from windows/runner/runner.exe.manifest rename to kitchenowl/windows/runner/runner.exe.manifest diff --git a/windows/runner/utils.cpp b/kitchenowl/windows/runner/utils.cpp similarity index 100% rename from windows/runner/utils.cpp rename to kitchenowl/windows/runner/utils.cpp diff --git a/windows/runner/utils.h b/kitchenowl/windows/runner/utils.h similarity index 100% rename from windows/runner/utils.h rename to kitchenowl/windows/runner/utils.h diff --git a/windows/runner/win32_window.cpp b/kitchenowl/windows/runner/win32_window.cpp similarity index 100% rename from windows/runner/win32_window.cpp rename to kitchenowl/windows/runner/win32_window.cpp diff --git a/windows/runner/win32_window.h b/kitchenowl/windows/runner/win32_window.h similarity index 100% rename from windows/runner/win32_window.h rename to kitchenowl/windows/runner/win32_window.h diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100755 index d9191701..00000000 --- a/pubspec.lock +++ /dev/null @@ -1,1270 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - animations: - dependency: "direct main" - description: - name: animations - sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb - url: "https://pub.dev" - source: hosted - version: "2.0.11" - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - archive: - dependency: transitive - description: - name: archive - sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" - url: "https://pub.dev" - source: hosted - version: "3.4.10" - args: - dependency: transitive - description: - name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - azlistview_plus: - dependency: "direct main" - description: - name: azlistview_plus - sha256: bbf08532db8ba2b9054100f68829e1841b23f3244e54712934f0f192651d8319 - url: "https://pub.dev" - source: hosted - version: "3.0.0" - bloc: - dependency: transitive - description: - name: bloc - sha256: "3820f15f502372d979121de1f6b97bfcf1630ebff8fe1d52fb2b0bfa49be5b49" - url: "https://pub.dev" - source: hosted - version: "8.1.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - cached_network_image: - dependency: "direct main" - description: - name: cached_network_image - sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" - url: "https://pub.dev" - source: hosted - version: "3.3.1" - cached_network_image_platform_interface: - dependency: transitive - description: - name: cached_network_image_platform_interface - sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - cached_network_image_web: - dependency: transitive - description: - name: cached_network_image_web - sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - characters: - dependency: transitive - description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 - url: "https://pub.dev" - source: hosted - version: "1.3.1" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.dev" - source: hosted - version: "1.1.1" - collection: - dependency: "direct main" - description: - name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a - url: "https://pub.dev" - source: hosted - version: "1.18.0" - community_charts_common: - dependency: transitive - description: - name: community_charts_common - sha256: "20697244c826df0545237ebe01d61caa96a2a2e4d23c6f88890441636a4d5220" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - community_charts_flutter: - dependency: "direct main" - description: - name: community_charts_flutter - sha256: ca5bd07337e162daee13c19679f602cd8b3f704520d242beeebbc2e312f84f89 - url: "https://pub.dev" - source: hosted - version: "1.0.2" - convert: - dependency: transitive - description: - name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e - url: "https://pub.dev" - source: hosted - version: "0.3.3+8" - crypto: - dependency: transitive - description: - name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab - url: "https://pub.dev" - source: hosted - version: "3.0.3" - csslib: - dependency: transitive - description: - name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d - url: "https://pub.dev" - source: hosted - version: "1.0.6" - device_info_plus: - dependency: "direct main" - description: - name: device_info_plus - sha256: "0042cb3b2a76413ea5f8a2b40cec2a33e01d0c937e91f0f7c211fde4f7739ba6" - url: "https://pub.dev" - source: hosted - version: "9.1.1" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 - url: "https://pub.dev" - source: hosted - version: "7.0.0" - diffutil_dart: - dependency: "direct main" - description: - name: diffutil_dart - sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" - url: "https://pub.dev" - source: hosted - version: "4.0.1" - dynamic_color: - dependency: "direct main" - description: - name: dynamic_color - sha256: a866f1f8947bfdaf674d7928e769eac7230388a2e7a2542824fad4bb5b87be3b - url: "https://pub.dev" - source: hosted - version: "1.6.9" - equatable: - dependency: "direct main" - description: - name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 - url: "https://pub.dev" - source: hosted - version: "2.0.5" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - ffi: - dependency: transitive - description: - name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - file: - dependency: transitive - description: - name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" - url: "https://pub.dev" - source: hosted - version: "6.1.4" - file_picker: - dependency: "direct main" - description: - name: file_picker - sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" - url: "https://pub.dev" - source: hosted - version: "6.1.1" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" - url: "https://pub.dev" - source: hosted - version: "0.9.2+1" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 - url: "https://pub.dev" - source: hosted - version: "0.9.3+3" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262" - url: "https://pub.dev" - source: hosted - version: "2.6.1" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 - url: "https://pub.dev" - source: hosted - version: "0.9.3+1" - fl_chart: - dependency: "direct main" - description: - name: fl_chart - sha256: fe6fec7d85975a99c73b9515a69a6e291364accfa0e4a5b3ce6de814d74b9a1c - url: "https://pub.dev" - source: hosted - version: "0.66.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_bloc: - dependency: "direct main" - description: - name: flutter_bloc - sha256: e74efb89ee6945bcbce74a5b3a5a3376b088e5f21f55c263fc38cbdc6237faae - url: "https://pub.dev" - source: hosted - version: "8.1.3" - flutter_blurhash: - dependency: "direct main" - description: - name: flutter_blurhash - sha256: "5e67678e479ac639069d7af1e133f4a4702311491188ff3e0227486430db0c06" - url: "https://pub.dev" - source: hosted - version: "0.8.2" - flutter_cache_manager: - dependency: transitive - description: - name: flutter_cache_manager - sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" - url: "https://pub.dev" - source: hosted - version: "3.3.1" - flutter_colorpicker: - dependency: "direct main" - description: - name: flutter_colorpicker - sha256: "458a6ed8ea480eb16ff892aedb4b7092b2804affd7e046591fb03127e8d8ef8b" - url: "https://pub.dev" - source: hosted - version: "1.0.3" - flutter_custom_tabs: - dependency: "direct main" - description: - name: flutter_custom_tabs - sha256: e90e5b7cad5648aeb0e1ed04aa3c0cada62d86f3b5d4aaef488ab7de61ec2a9f - url: "https://pub.dev" - source: hosted - version: "1.2.1" - flutter_custom_tabs_platform_interface: - dependency: transitive - description: - name: flutter_custom_tabs_platform_interface - sha256: "1d6b9eb6c5671b21511fdb47babf18aa65982784373986c003aaf67ca78798ad" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - flutter_custom_tabs_web: - dependency: transitive - description: - name: flutter_custom_tabs_web - sha256: dbb5689a97c2398aa5dbcfc9cd59cffea5518ec815e9d23def448dc143cb02be - url: "https://pub.dev" - source: hosted - version: "1.1.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 - url: "https://pub.dev" - source: hosted - version: "3.0.1" - flutter_localizations: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_markdown: - dependency: "direct main" - description: - name: flutter_markdown - sha256: "30088ce826b5b9cfbf9e8bece34c716c8a59fa54461dcae1e4ac01a94639e762" - url: "https://pub.dev" - source: hosted - version: "0.6.18+3" - flutter_native_splash: - dependency: "direct dev" - description: - name: flutter_native_splash - sha256: "9cdb5d9665dab5d098dc50feab74301c2c228cd02ca25c9b546ab572cebcd6af" - url: "https://pub.dev" - source: hosted - version: "2.3.9" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da - url: "https://pub.dev" - source: hosted - version: "2.0.17" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: ffdbb60130e4665d2af814a0267c481bcf522c41ae2e43caf69fa0146876d685 - url: "https://pub.dev" - source: hosted - version: "9.0.0" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: "3d5032e314774ee0e1a7d0a9f5e2793486f0dff2dd9ef5a23f4e3fb2a0ae6a9e" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: bd33935b4b628abd0b86c8ca20655c5b36275c3a3f5194769a7b3f37c905369c - url: "https://pub.dev" - source: hosted - version: "3.0.1" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: "0d4d3a5dd4db28c96ae414d7ba3b8422fd735a8255642774803b2532c9a61d7e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: "30f84f102df9dcdaa2241866a958c2ec976902ebdaa8883fbfe525f1f2f3cf20" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: "5809c66f9dd3b4b93b0a6e2e8561539405322ee767ac2f64d084e2ab5429d108" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - fraction: - dependency: "direct main" - description: - name: fraction - sha256: "09e9504c9177bbd77df56e5d147abfbb3b43360e64bf61510059c14d6a82d524" - url: "https://pub.dev" - source: hosted - version: "5.0.2" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - go_router: - dependency: "direct main" - description: - name: go_router - sha256: "3b40e751eaaa855179b416974d59d29669e750d2e50fcdb2b37f1cb0ca8c803a" - url: "https://pub.dev" - source: hosted - version: "13.0.1" - html: - dependency: transitive - description: - name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" - url: "https://pub.dev" - source: hosted - version: "0.15.4" - http: - dependency: "direct main" - description: - name: http - sha256: d4872660c46d929f6b8a9ef4e7a7eff7e49bbf0c4ec3f385ee32df5119175139 - url: "https://pub.dev" - source: hosted - version: "1.1.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - icons_launcher: - dependency: "direct dev" - description: - name: icons_launcher - sha256: "3ed4560181f238e69ca5d55589d6946ef31e6a321c934251a26ce1d9e9867305" - url: "https://pub.dev" - source: hosted - version: "2.1.6" - image: - dependency: transitive - description: - name: image - sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" - url: "https://pub.dev" - source: hosted - version: "4.1.3" - image_picker: - dependency: "direct main" - description: - name: image_picker - sha256: "340efe08645537d6b088a30620ee5752298b1630f23a829181172610b868262b" - url: "https://pub.dev" - source: hosted - version: "1.0.6" - image_picker_android: - dependency: transitive - description: - name: image_picker_android - sha256: "1a27bf4cc0330389cebe465bab08fe6dec97e44015b4899637344bb7297759ec" - url: "https://pub.dev" - source: hosted - version: "0.8.9+2" - image_picker_for_web: - dependency: transitive - description: - name: image_picker_for_web - sha256: e2423c53a68b579a7c37a1eda967b8ae536c3d98518e5db95ca1fe5719a730a3 - url: "https://pub.dev" - source: hosted - version: "3.0.2" - image_picker_ios: - dependency: transitive - description: - name: image_picker_ios - sha256: eac0a62104fa12feed213596df0321f57ce5a572562f72a68c4ff81e9e4caacf - url: "https://pub.dev" - source: hosted - version: "0.8.9" - image_picker_linux: - dependency: transitive - description: - name: image_picker_linux - sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" - url: "https://pub.dev" - source: hosted - version: "0.2.1+1" - image_picker_macos: - dependency: transitive - description: - name: image_picker_macos - sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" - url: "https://pub.dev" - source: hosted - version: "0.2.1+1" - image_picker_platform_interface: - dependency: transitive - description: - name: image_picker_platform_interface - sha256: "0e827c156e3a90edd3bbe7f6de048b39247b16e58173b08a835b7eb00aba239e" - url: "https://pub.dev" - source: hosted - version: "2.9.2" - image_picker_windows: - dependency: transitive - description: - name: image_picker_windows - sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" - url: "https://pub.dev" - source: hosted - version: "0.2.1+1" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - intl: - dependency: "direct main" - description: - name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" - url: "https://pub.dev" - source: hosted - version: "0.18.1" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - lints: - dependency: transitive - description: - name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 - url: "https://pub.dev" - source: hosted - version: "3.0.0" - logging: - dependency: transitive - description: - name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - markdown: - dependency: "direct main" - description: - name: markdown - sha256: acf35edccc0463a9d7384e437c015a3535772e09714cf60e07eeef3a15870dcd - url: "https://pub.dev" - source: hosted - version: "7.1.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" - url: "https://pub.dev" - source: hosted - version: "0.12.16" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" - url: "https://pub.dev" - source: hosted - version: "0.5.0" - meta: - dependency: transitive - description: - name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e - url: "https://pub.dev" - source: hosted - version: "1.10.0" - mime: - dependency: transitive - description: - name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e - url: "https://pub.dev" - source: hosted - version: "1.0.4" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - octo_image: - dependency: transitive - description: - name: octo_image - sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79" - url: "https://pub.dev" - source: hosted - version: "5.0.1" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - path: - dependency: transitive - description: - name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" - url: "https://pub.dev" - source: hosted - version: "1.8.3" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa - url: "https://pub.dev" - source: hosted - version: "2.1.1" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" - url: "https://pub.dev" - source: hosted - version: "2.2.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 - url: "https://pub.dev" - source: hosted - version: "6.0.2" - photo_view: - dependency: "direct main" - description: - name: photo_view - sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" - url: "https://pub.dev" - source: hosted - version: "0.14.0" - platform: - dependency: transitive - description: - name: platform - sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" - url: "https://pub.dev" - source: hosted - version: "3.7.3" - process: - dependency: transitive - description: - name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" - url: "https://pub.dev" - source: hosted - version: "4.2.4" - provider: - dependency: transitive - description: - name: provider - sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" - url: "https://pub.dev" - source: hosted - version: "6.1.1" - reorderables: - dependency: "direct main" - description: - name: reorderables - sha256: "004a886e4878df1ee27321831c838bc1c976311f4ca6a74ce7d561e506540a77" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - responsive_builder: - dependency: "direct main" - description: - name: responsive_builder - sha256: a38ba9ba86c9daf08904674553034b651377b1d685d10ee450d8350ae51f76ec - url: "https://pub.dev" - source: hosted - version: "0.7.0" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" - url: "https://pub.dev" - source: hosted - version: "0.27.7" - scrollable_positioned_list: - dependency: transitive - description: - name: scrollable_positioned_list - sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" - url: "https://pub.dev" - source: hosted - version: "0.3.8" - share_handler: - dependency: "direct main" - description: - name: share_handler - sha256: "798041ad129b4c3bf27008cd7acb676ccd750a9391570f7ea40f08b3d27bbb94" - url: "https://pub.dev" - source: hosted - version: "0.0.20" - share_handler_android: - dependency: transitive - description: - name: share_handler_android - sha256: "6e752f2c4f67a9f7bef5503f6e1b0dd6075e127cafe7763d92649559c3692bc6" - url: "https://pub.dev" - source: hosted - version: "0.0.7" - share_handler_ios: - dependency: transitive - description: - name: share_handler_ios - sha256: "9daf6924d906dda55460b811b4ea0ee76f360210a098c7d5314e796842f6e63e" - url: "https://pub.dev" - source: hosted - version: "0.0.13" - share_handler_platform_interface: - dependency: transitive - description: - name: share_handler_platform_interface - sha256: "7a4df95a87b326b2f07458d937f2281874567c364b7b7ebe4e7d50efaae5f106" - url: "https://pub.dev" - source: hosted - version: "0.0.6" - share_plus: - dependency: "direct main" - description: - name: share_plus - sha256: f74fc3f1cbd99f39760182e176802f693fa0ec9625c045561cfad54681ea93dd - url: "https://pub.dev" - source: hosted - version: "7.2.1" - share_plus_platform_interface: - dependency: transitive - description: - name: share_plus_platform_interface - sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956 - url: "https://pub.dev" - source: hosted - version: "3.3.1" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" - url: "https://pub.dev" - source: hosted - version: "2.2.1" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" - url: "https://pub.dev" - source: hosted - version: "2.3.4" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a - url: "https://pub.dev" - source: hosted - version: "2.3.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - shimmer: - dependency: "direct main" - description: - name: shimmer - sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sign_in_with_apple: - dependency: "direct main" - description: - name: sign_in_with_apple - sha256: "0975c23b9f8b30a80e27d5659a75993a093d4cb5f4eb7d23a9ccc586fea634e0" - url: "https://pub.dev" - source: hosted - version: "5.0.0" - sign_in_with_apple_platform_interface: - dependency: transitive - description: - name: sign_in_with_apple_platform_interface - sha256: a5883edee09ed6be19de19e7d9f618a617fe41a6fa03f76d082dfb787e9ea18d - url: "https://pub.dev" - source: hosted - version: "1.0.0" - sign_in_with_apple_web: - dependency: transitive - description: - name: sign_in_with_apple_web - sha256: "44b66528f576e77847c14999d5e881e17e7223b7b0625a185417829e5306f47a" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - sliver_tools: - dependency: "direct main" - description: - name: sliver_tools - sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 - url: "https://pub.dev" - source: hosted - version: "0.2.12" - socket_io_client: - dependency: "direct main" - description: - name: socket_io_client - sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b - url: "https://pub.dev" - source: hosted - version: "2.0.3+1" - socket_io_common: - dependency: transitive - description: - name: socket_io_common - sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb" - url: "https://pub.dev" - source: hosted - version: "2.0.3" - source_span: - dependency: transitive - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: bb4738f15b23352822f4c42a531677e5c6f522e079461fd240ead29d8d8a54a6 - url: "https://pub.dev" - source: hosted - version: "2.5.0+2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" - url: "https://pub.dev" - source: hosted - version: "1.11.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" - url: "https://pub.dev" - source: hosted - version: "3.1.0+1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" - url: "https://pub.dev" - source: hosted - version: "0.6.1" - transparent_image: - dependency: "direct main" - description: - name: transparent_image - sha256: e8991d955a2094e197ca24c645efec2faf4285772a4746126ca12875e54ca02f - url: "https://pub.dev" - source: hosted - version: "2.0.1" - tuple: - dependency: "direct main" - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c - url: "https://pub.dev" - source: hosted - version: "1.3.2" - universal_html: - dependency: "direct main" - description: - name: universal_html - sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" - url: "https://pub.dev" - source: hosted - version: "2.2.4" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: e9aa5ea75c84cf46b3db4eea212523591211c3cf2e13099ee4ec147f54201c86 - url: "https://pub.dev" - source: hosted - version: "6.2.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: c0766a55ab42cefaa728cabc951e82919ab41a3a4fee0aaa96176ca82da8cc51 - url: "https://pub.dev" - source: hosted - version: "6.2.1" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "46b81e3109cbb2d6b81702ad3077540789a3e74e22795eb9f0b7d494dbaa72ea" - url: "https://pub.dev" - source: hosted - version: "6.2.2" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 - url: "https://pub.dev" - source: hosted - version: "3.1.1" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 - url: "https://pub.dev" - source: hosted - version: "3.1.0" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "4aca1e060978e19b2998ee28503f40b5ba6226819c2b5e3e4d1821e8ccd92198" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: fff0932192afeedf63cdd50ecbb1bc825d31aed259f02bb8dba0f3b729a5e88b - url: "https://pub.dev" - source: hosted - version: "2.2.3" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 - url: "https://pub.dev" - source: hosted - version: "3.1.1" - uuid: - dependency: transitive - description: - name: uuid - sha256: "22c94e5ad1e75f9934b766b53c742572ee2677c56bc871d850a57dad0f82127f" - url: "https://pub.dev" - source: hosted - version: "4.2.2" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 - url: "https://pub.dev" - source: hosted - version: "11.10.0" - web: - dependency: transitive - description: - name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 - url: "https://pub.dev" - source: hosted - version: "0.3.0" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - win32: - dependency: transitive - description: - name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" - url: "https://pub.dev" - source: hosted - version: "5.2.0" - win32_registry: - dependency: transitive - description: - name: win32_registry - sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d - url: "https://pub.dev" - source: hosted - version: "1.0.4" - xml: - dependency: transitive - description: - name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 - url: "https://pub.dev" - source: hosted - version: "6.5.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" - url: "https://pub.dev" - source: hosted - version: "3.1.2" -sdks: - dart: ">=3.2.0 <4.0.0" - flutter: ">=3.16.0" From c9e709be7eb8322debd9e3b542f566512ce0848b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Fri, 19 Jan 2024 12:15:03 +0100 Subject: [PATCH 496/496] Add back pubspec.lock --- kitchenowl/pubspec.lock | 1270 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1270 insertions(+) create mode 100755 kitchenowl/pubspec.lock diff --git a/kitchenowl/pubspec.lock b/kitchenowl/pubspec.lock new file mode 100755 index 00000000..d9191701 --- /dev/null +++ b/kitchenowl/pubspec.lock @@ -0,0 +1,1270 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + animations: + dependency: "direct main" + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + archive: + dependency: transitive + description: + name: archive + sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" + url: "https://pub.dev" + source: hosted + version: "3.4.10" + args: + dependency: transitive + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + azlistview_plus: + dependency: "direct main" + description: + name: azlistview_plus + sha256: bbf08532db8ba2b9054100f68829e1841b23f3244e54712934f0f192651d8319 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + bloc: + dependency: transitive + description: + name: bloc + sha256: "3820f15f502372d979121de1f6b97bfcf1630ebff8fe1d52fb2b0bfa49be5b49" + url: "https://pub.dev" + source: hosted + version: "8.1.2" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + community_charts_common: + dependency: transitive + description: + name: community_charts_common + sha256: "20697244c826df0545237ebe01d61caa96a2a2e4d23c6f88890441636a4d5220" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + community_charts_flutter: + dependency: "direct main" + description: + name: community_charts_flutter + sha256: ca5bd07337e162daee13c19679f602cd8b3f704520d242beeebbc2e312f84f89 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e + url: "https://pub.dev" + source: hosted + version: "0.3.3+8" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + csslib: + dependency: transitive + description: + name: csslib + sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + url: "https://pub.dev" + source: hosted + version: "1.0.6" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "0042cb3b2a76413ea5f8a2b40cec2a33e01d0c937e91f0f7c211fde4f7739ba6" + url: "https://pub.dev" + source: hosted + version: "9.1.1" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + url: "https://pub.dev" + source: hosted + version: "7.0.0" + diffutil_dart: + dependency: "direct main" + description: + name: diffutil_dart + sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: a866f1f8947bfdaf674d7928e769eac7230388a2e7a2542824fad4bb5b87be3b + url: "https://pub.dev" + source: hosted + version: "1.6.9" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" + url: "https://pub.dev" + source: hosted + version: "6.1.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 + url: "https://pub.dev" + source: hosted + version: "0.9.3+3" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + url: "https://pub.dev" + source: hosted + version: "0.9.3+1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: fe6fec7d85975a99c73b9515a69a6e291364accfa0e4a5b3ce6de814d74b9a1c + url: "https://pub.dev" + source: hosted + version: "0.66.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: e74efb89ee6945bcbce74a5b3a5a3376b088e5f21f55c263fc38cbdc6237faae + url: "https://pub.dev" + source: hosted + version: "8.1.3" + flutter_blurhash: + dependency: "direct main" + description: + name: flutter_blurhash + sha256: "5e67678e479ac639069d7af1e133f4a4702311491188ff3e0227486430db0c06" + url: "https://pub.dev" + source: hosted + version: "0.8.2" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "458a6ed8ea480eb16ff892aedb4b7092b2804affd7e046591fb03127e8d8ef8b" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_custom_tabs: + dependency: "direct main" + description: + name: flutter_custom_tabs + sha256: e90e5b7cad5648aeb0e1ed04aa3c0cada62d86f3b5d4aaef488ab7de61ec2a9f + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_custom_tabs_platform_interface: + dependency: transitive + description: + name: flutter_custom_tabs_platform_interface + sha256: "1d6b9eb6c5671b21511fdb47babf18aa65982784373986c003aaf67ca78798ad" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter_custom_tabs_web: + dependency: transitive + description: + name: flutter_custom_tabs_web + sha256: dbb5689a97c2398aa5dbcfc9cd59cffea5518ec815e9d23def448dc143cb02be + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "30088ce826b5b9cfbf9e8bece34c716c8a59fa54461dcae1e4ac01a94639e762" + url: "https://pub.dev" + source: hosted + version: "0.6.18+3" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "9cdb5d9665dab5d098dc50feab74301c2c228cd02ca25c9b546ab572cebcd6af" + url: "https://pub.dev" + source: hosted + version: "2.3.9" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da + url: "https://pub.dev" + source: hosted + version: "2.0.17" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: ffdbb60130e4665d2af814a0267c481bcf522c41ae2e43caf69fa0146876d685 + url: "https://pub.dev" + source: hosted + version: "9.0.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "3d5032e314774ee0e1a7d0a9f5e2793486f0dff2dd9ef5a23f4e3fb2a0ae6a9e" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: bd33935b4b628abd0b86c8ca20655c5b36275c3a3f5194769a7b3f37c905369c + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "0d4d3a5dd4db28c96ae414d7ba3b8422fd735a8255642774803b2532c9a61d7e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "30f84f102df9dcdaa2241866a958c2ec976902ebdaa8883fbfe525f1f2f3cf20" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "5809c66f9dd3b4b93b0a6e2e8561539405322ee767ac2f64d084e2ab5429d108" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fraction: + dependency: "direct main" + description: + name: fraction + sha256: "09e9504c9177bbd77df56e5d147abfbb3b43360e64bf61510059c14d6a82d524" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "3b40e751eaaa855179b416974d59d29669e750d2e50fcdb2b37f1cb0ca8c803a" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + html: + dependency: transitive + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" + http: + dependency: "direct main" + description: + name: http + sha256: d4872660c46d929f6b8a9ef4e7a7eff7e49bbf0c4ec3f385ee32df5119175139 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + icons_launcher: + dependency: "direct dev" + description: + name: icons_launcher + sha256: "3ed4560181f238e69ca5d55589d6946ef31e6a321c934251a26ce1d9e9867305" + url: "https://pub.dev" + source: hosted + version: "2.1.6" + image: + dependency: transitive + description: + name: image + sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + url: "https://pub.dev" + source: hosted + version: "4.1.3" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "340efe08645537d6b088a30620ee5752298b1630f23a829181172610b868262b" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "1a27bf4cc0330389cebe465bab08fe6dec97e44015b4899637344bb7297759ec" + url: "https://pub.dev" + source: hosted + version: "0.8.9+2" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: e2423c53a68b579a7c37a1eda967b8ae536c3d98518e5db95ca1fe5719a730a3 + url: "https://pub.dev" + source: hosted + version: "3.0.2" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eac0a62104fa12feed213596df0321f57ce5a572562f72a68c4ff81e9e4caacf + url: "https://pub.dev" + source: hosted + version: "0.8.9" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "0e827c156e3a90edd3bbe7f6de048b39247b16e58173b08a835b7eb00aba239e" + url: "https://pub.dev" + source: hosted + version: "2.9.2" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + markdown: + dependency: "direct main" + description: + name: markdown + sha256: acf35edccc0463a9d7384e437c015a3535772e09714cf60e07eeef3a15870dcd + url: "https://pub.dev" + source: hosted + version: "7.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + meta: + dependency: transitive + description: + name: meta + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + url: "https://pub.dev" + source: hosted + version: "1.10.0" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79" + url: "https://pub.dev" + source: hosted + version: "5.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + path: + dependency: transitive + description: + name: path + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.dev" + source: hosted + version: "1.8.3" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" + url: "https://pub.dev" + source: hosted + version: "0.14.0" + platform: + dependency: transitive + description: + name: platform + sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + url: "https://pub.dev" + source: hosted + version: "3.7.3" + process: + dependency: transitive + description: + name: process + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + url: "https://pub.dev" + source: hosted + version: "4.2.4" + provider: + dependency: transitive + description: + name: provider + sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" + url: "https://pub.dev" + source: hosted + version: "6.1.1" + reorderables: + dependency: "direct main" + description: + name: reorderables + sha256: "004a886e4878df1ee27321831c838bc1c976311f4ca6a74ce7d561e506540a77" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + responsive_builder: + dependency: "direct main" + description: + name: responsive_builder + sha256: a38ba9ba86c9daf08904674553034b651377b1d685d10ee450d8350ae51f76ec + url: "https://pub.dev" + source: hosted + version: "0.7.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" + scrollable_positioned_list: + dependency: transitive + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" + share_handler: + dependency: "direct main" + description: + name: share_handler + sha256: "798041ad129b4c3bf27008cd7acb676ccd750a9391570f7ea40f08b3d27bbb94" + url: "https://pub.dev" + source: hosted + version: "0.0.20" + share_handler_android: + dependency: transitive + description: + name: share_handler_android + sha256: "6e752f2c4f67a9f7bef5503f6e1b0dd6075e127cafe7763d92649559c3692bc6" + url: "https://pub.dev" + source: hosted + version: "0.0.7" + share_handler_ios: + dependency: transitive + description: + name: share_handler_ios + sha256: "9daf6924d906dda55460b811b4ea0ee76f360210a098c7d5314e796842f6e63e" + url: "https://pub.dev" + source: hosted + version: "0.0.13" + share_handler_platform_interface: + dependency: transitive + description: + name: share_handler_platform_interface + sha256: "7a4df95a87b326b2f07458d937f2281874567c364b7b7ebe4e7d50efaae5f106" + url: "https://pub.dev" + source: hosted + version: "0.0.6" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: f74fc3f1cbd99f39760182e176802f693fa0ec9625c045561cfad54681ea93dd + url: "https://pub.dev" + source: hosted + version: "7.2.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956 + url: "https://pub.dev" + source: hosted + version: "3.3.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + url: "https://pub.dev" + source: hosted + version: "2.3.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + url: "https://pub.dev" + source: hosted + version: "2.3.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sign_in_with_apple: + dependency: "direct main" + description: + name: sign_in_with_apple + sha256: "0975c23b9f8b30a80e27d5659a75993a093d4cb5f4eb7d23a9ccc586fea634e0" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + sign_in_with_apple_platform_interface: + dependency: transitive + description: + name: sign_in_with_apple_platform_interface + sha256: a5883edee09ed6be19de19e7d9f618a617fe41a6fa03f76d082dfb787e9ea18d + url: "https://pub.dev" + source: hosted + version: "1.0.0" + sign_in_with_apple_web: + dependency: transitive + description: + name: sign_in_with_apple_web + sha256: "44b66528f576e77847c14999d5e881e17e7223b7b0625a185417829e5306f47a" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + sliver_tools: + dependency: "direct main" + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b + url: "https://pub.dev" + source: hosted + version: "2.0.3+1" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: bb4738f15b23352822f4c42a531677e5c6f522e079461fd240ead29d8d8a54a6 + url: "https://pub.dev" + source: hosted + version: "2.5.0+2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + transparent_image: + dependency: "direct main" + description: + name: transparent_image + sha256: e8991d955a2094e197ca24c645efec2faf4285772a4746126ca12875e54ca02f + url: "https://pub.dev" + source: hosted + version: "2.0.1" + tuple: + dependency: "direct main" + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + universal_html: + dependency: "direct main" + description: + name: universal_html + sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e9aa5ea75c84cf46b3db4eea212523591211c3cf2e13099ee4ec147f54201c86 + url: "https://pub.dev" + source: hosted + version: "6.2.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: c0766a55ab42cefaa728cabc951e82919ab41a3a4fee0aaa96176ca82da8cc51 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "46b81e3109cbb2d6b81702ad3077540789a3e74e22795eb9f0b7d494dbaa72ea" + url: "https://pub.dev" + source: hosted + version: "6.2.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "4aca1e060978e19b2998ee28503f40b5ba6226819c2b5e3e4d1821e8ccd92198" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: fff0932192afeedf63cdd50ecbb1bc825d31aed259f02bb8dba0f3b729a5e88b + url: "https://pub.dev" + source: hosted + version: "2.2.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + uuid: + dependency: transitive + description: + name: uuid + sha256: "22c94e5ad1e75f9934b766b53c742572ee2677c56bc871d850a57dad0f82127f" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 + url: "https://pub.dev" + source: hosted + version: "11.10.0" + web: + dependency: transitive + description: + name: web + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + win32: + dependency: transitive + description: + name: win32 + sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + url: "https://pub.dev" + source: hosted + version: "5.2.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.2.0 <4.0.0" + flutter: ">=3.16.0"