From c14b81c3b5d9265d30eeefc9c807f417b6874c9b Mon Sep 17 00:00:00 2001 From: Tom Bursch Date: Thu, 17 Aug 2023 16:49:45 +0200 Subject: [PATCH] feat: Add socket.io --- Dockerfile | 5 +- app/__init__.py | 3 +- app/config.py | 7 +++ .../shoppinglist/shoppinglist_controller.py | 26 +++++++-- app/helpers/__init__.py | 2 + app/helpers/socket_jwt_required.py | 25 +++++++++ app/helpers/validate_socket_args.py | 23 ++++++++ app/models/history.py | 2 +- app/sockets/__init__.py | 2 + app/sockets/connection_socket.py | 18 ++++++ app/sockets/schemas.py | 13 +++++ app/sockets/shoppinglist_socket.py | 56 +++++++++++++++++++ requirements.txt | 10 ++++ wsgi.ini | 3 +- wsgi.py | 7 ++- 15 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 app/helpers/socket_jwt_required.py create mode 100644 app/helpers/validate_socket_args.py create mode 100644 app/sockets/__init__.py create mode 100644 app/sockets/connection_socket.py create mode 100644 app/sockets/schemas.py create mode 100644 app/sockets/shoppinglist_socket.py diff --git a/Dockerfile b/Dockerfile index e1f4f5b..35a5f83 100644 --- a/Dockerfile +++ b/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/app/__init__.py b/app/__init__.py index 072894c..ee343e3 100644 --- a/app/__init__.py +++ b/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/app/config.py b/app/config.py index cc6ae37..a837c9e 100644 --- a/app/config.py +++ b/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/app/controller/shoppinglist/shoppinglist_controller.py b/app/controller/shoppinglist/shoppinglist_controller.py index bbe96c5..2ebd2bf 100644 --- a/app/controller/shoppinglist/shoppinglist_controller.py +++ b/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/app/helpers/__init__.py b/app/helpers/__init__.py index 354060f..94a29af 100644 --- a/app/helpers/__init__.py +++ b/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/app/helpers/socket_jwt_required.py b/app/helpers/socket_jwt_required.py new file mode 100644 index 0000000..971fd71 --- /dev/null +++ b/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/app/helpers/validate_socket_args.py b/app/helpers/validate_socket_args.py new file mode 100644 index 0000000..ab59749 --- /dev/null +++ b/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/app/models/history.py b/app/models/history.py index 52e006f..1733bf4 100644 --- a/app/models/history.py +++ b/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/app/sockets/__init__.py b/app/sockets/__init__.py new file mode 100644 index 0000000..45d1885 --- /dev/null +++ b/app/sockets/__init__.py @@ -0,0 +1,2 @@ +from .shoppinglist_socket import * +from .connection_socket import * diff --git a/app/sockets/connection_socket.py b/app/sockets/connection_socket.py new file mode 100644 index 0000000..8eb903c --- /dev/null +++ b/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/app/sockets/schemas.py b/app/sockets/schemas.py new file mode 100644 index 0000000..2b54fd8 --- /dev/null +++ b/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/app/sockets/shoppinglist_socket.py b/app/sockets/shoppinglist_socket.py new file mode 100644 index 0000000..c24af4c --- /dev/null +++ b/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/requirements.txt b/requirements.txt index 884f743..8dbd14c 100644 --- a/requirements.txt +++ b/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/wsgi.ini b/wsgi.ini index 41871f2..e56d395 100644 --- a/wsgi.ini +++ b/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/wsgi.py b/wsgi.py index f78dae0..9a31af3 100644 --- a/wsgi.py +++ b/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)