From c1c34376824500fbfbf42d2bbee6f3b28c3b02cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 20 Aug 2020 08:37:24 +0200 Subject: [PATCH 001/446] =?UTF-8?q?FreeDrinkList=20f=C3=BCr=20Bardienste?= =?UTF-8?q?=20und=20AG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Es wurde die komplette backendverwaltung für Freigetränke Band und AG hinzugefügt. Es gibt auch schon ansätze für das Interface um Freigetränke zu bearbeiten. --- .../controller/databaseController/__init__.py | 3 +- .../dbFreeDrinkListConfigController.py | 136 ++++++++++++++++++ .../dbPricelistController.py | 8 +- .../databaseController/dbWorkerController.py | 2 +- .../controller/mainController/__init__.py | 3 +- .../mainFreeDrinkListConfigController.py | 25 ++++ geruecht/routes.py | 43 ++++++ 7 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 geruecht/controller/databaseController/dbFreeDrinkListConfigController.py create mode 100644 geruecht/controller/mainController/mainFreeDrinkListConfigController.py diff --git a/geruecht/controller/databaseController/__init__.py b/geruecht/controller/databaseController/__init__.py index 8f783fd..e53e75b 100644 --- a/geruecht/controller/databaseController/__init__.py +++ b/geruecht/controller/databaseController/__init__.py @@ -1,6 +1,6 @@ from ..mainController import Singleton from geruecht import db -from ..databaseController import dbUserController, dbCreditListController, dbJobKindController, dbPricelistController, dbWorkerController, dbWorkgroupController, dbJobInviteController, dbJobRequesController, dbAccessTokenController, dbRegistrationController +from ..databaseController import dbUserController, dbCreditListController, dbJobKindController, dbPricelistController, dbWorkerController, dbWorkgroupController, dbJobInviteController, dbJobRequesController, dbAccessTokenController, dbRegistrationController, dbFreeDrinkListConfigController from geruecht.exceptions import DatabaseExecption import traceback from MySQLdb._exceptions import IntegrityError @@ -15,6 +15,7 @@ class DatabaseController(dbUserController.Base, dbJobRequesController.Base, dbAccessTokenController.Base, dbRegistrationController.Base, + dbFreeDrinkListConfigController.Base, metaclass=Singleton): ''' DatabaesController diff --git a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py new file mode 100644 index 0000000..dd7a7ab --- /dev/null +++ b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py @@ -0,0 +1,136 @@ +import traceback +from datetime import datetime +from datetime import timedelta +from geruecht.exceptions import DatabaseExecption + +class Base: + def get_free_drink_list_config(self, id): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'select * from free_drink_list_config where id={id}') + data = cursor.fetchone() + if data['drink_id'] != None: + data['drink'] = self.getDrinkPrice(data['drink_id']) + data['free_drink_types'] = self.get_free_drink_list_types_for_drink(data['id']) + return data + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def get_free_drink_list_configs(self): + try: + cursor = self.db.connection.cursor() + cursor.execute("select * from free_drink_list_config") + retVal = cursor.fetchall() + for data in retVal: + if data['drink_id'] != None: + data['drink'] = self.getDrinkPrice(data['drink_id']) + data['free_drink_types'] = self.get_free_drink_list_types_for_drink(data['id']) + + return retVal + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def set_free_drink_list_config(self, free_drink_list_config): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'insert into free_drink_list_config (drink_id, label, price) values ({free_drink_list_config["drink"]["id"]}, "{free_drink_list_config["label"]}", {free_drink_list_config["price"]})') + self.db.connection.commit() + return self.get_free_drink_list_configs() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def get_free_drink_list_types(self): + try: + cursor = self.db.connection.cursor() + cursor.execute('select * from free_drink_list_type') + return cursor.fetchall() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def get_free_drink_list_types_for_drink(self, id): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'select a.* from free_drink_list_type a, free_drink_list_type_config b where free_drink_list_config_id={id} and b.free_drink_list_type_id=a.id') + return cursor.fetchall() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def get_free_drink_list_type(self, name): + try: + cursor = self.db.connection.cursor() + if type(name) == str: + sql = f'select * from free_drink_list_type where name={name}' + elif type(name) == int: + sql = f'select * from free_drink_list_type where id={name}' + else: + raise DatabaseExecption("name as no type int or str. name={}, type={}".format(name, type(name))) + cursor.execute(sql) + return cursor.fetchone() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def set_free_drink_list_history(self, user, free_drink_list_config): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'insert into free_drink_list_history (timestamp, free_drink_config_id, user_id, free_drink_type_id) values ("{datetime.now()}", {free_drink_list_config["id"]}, {user.id}, {free_drink_list_config["free_drink_type_id"]})') + self.db.connection.commit() + return self.get_free_drink_list_history_by_user(user) + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def get_free_drink_list_history_by_user(self, user): + try: + cursor = self.db.connection.cursor() + now = datetime.now() + worker = self.getWorker(user, now) + cursor.execute(f'select * from free_drink_list_history where timestamp>="{worker["startdatetime"]}" and user_id={user.id}') + retVal = cursor.fetchall() + for data in retVal: + data['timestamp'] = {'year': data['timestamp'].year, + 'month': data['timestamp'].month, + 'day': data['timestamp'].day, + 'hour': data['timestamp'].hour, + 'minute': data['timestamp'].minute, + 'second': data['timestamp'].second} + data['free_drink_config'] = self.get_free_drink_list_config(data['free_drink_config_id']) + data['free_drink_type'] = self.get_free_drink_list_type(data['free_drink_type_id']) + return retVal + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def update_free_drink_list_history(self, free_drink_list_history): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'update free_drink_list_history set canceled={free_drink_list_history["canceled"]} where id={free_drink_list_history["id"]}') + self.db.connection.commit() + return True + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + def delete_free_drink_list_history(self, free_drink_list_history): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'delete from free_drink_list_history where id={free_drink_list_history["id"]}') + self.db.connection.commit() + return True + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) \ No newline at end of file diff --git a/geruecht/controller/databaseController/dbPricelistController.py b/geruecht/controller/databaseController/dbPricelistController.py index 2b0c749..7d00117 100644 --- a/geruecht/controller/databaseController/dbPricelistController.py +++ b/geruecht/controller/databaseController/dbPricelistController.py @@ -8,6 +8,9 @@ class Base: try: cursor = self.db.connection.cursor() cursor.execute("select * from pricelist") + retVal = cursor.fetchall() + for data in retVal: + data['drink_type'] = self.getDrinkType(data['type']) return cursor.fetchall() except Exception as err: traceback.print_exc() @@ -24,7 +27,10 @@ class Base: else: raise DatabaseExecption("name as no type int or str. name={}, type={}".format(name, type(name))) cursor.execute(sql) - return cursor.fetchone() + retVal = cursor.fetchone() + if retVal: + retVal['drink_type'] = self.getDrinkType(retVal['type']) + return retVal except Exception as err: traceback.print_exc() self.db.connection.rollback() diff --git a/geruecht/controller/databaseController/dbWorkerController.py b/geruecht/controller/databaseController/dbWorkerController.py index 3d7652d..c54f16d 100644 --- a/geruecht/controller/databaseController/dbWorkerController.py +++ b/geruecht/controller/databaseController/dbWorkerController.py @@ -8,7 +8,7 @@ class Base: def getWorker(self, user, date): try: cursor = self.db.connection.cursor() - cursor.execute("select * from bardienste where user_id={} and startdatetime='{}'".format(user.id, date)) + cursor.execute("select * from bardienste where user_id={} and startdatetime<='{}' and enddatetime>='{}'".format(user.id, date, date)) data = cursor.fetchone() return {"user": user.toJSON(), "startdatetime": data['startdatetime'], "enddatetime": data['enddatetime'], "start": { "year": data['startdatetime'].year, "month": data['startdatetime'].month, "day": data['startdatetime'].day}, "job_kind": self.getJobKind(data['job_kind']) if data['job_kind'] != None else None} if data else None except Exception as err: diff --git a/geruecht/controller/mainController/__init__.py b/geruecht/controller/mainController/__init__.py index ae3f73a..aabd653 100644 --- a/geruecht/controller/mainController/__init__.py +++ b/geruecht/controller/mainController/__init__.py @@ -5,7 +5,7 @@ import geruecht.controller.emailController as ec from geruecht.model.user import User from datetime import datetime, timedelta from geruecht.logger import getDebugLogger -from ..mainController import mainJobKindController, mainCreditListController, mainPricelistController, mainUserController, mainWorkerController, mainWorkgroupController, mainJobInviteController, mainJobRequestController, mainRegistrationController, mainPasswordReset +from ..mainController import mainJobKindController, mainCreditListController, mainPricelistController, mainUserController, mainWorkerController, mainWorkgroupController, mainJobInviteController, mainJobRequestController, mainRegistrationController, mainPasswordReset, mainFreeDrinkListConfigController db = dc.DatabaseController() ldap = lc.LDAPController() @@ -24,6 +24,7 @@ class MainController(mainJobKindController.Base, mainJobRequestController.Base, mainRegistrationController.Base, mainPasswordReset.Base, + mainFreeDrinkListConfigController.Base, metaclass=Singleton): def __init__(self): diff --git a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py new file mode 100644 index 0000000..52d5bac --- /dev/null +++ b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py @@ -0,0 +1,25 @@ +import geruecht.controller.databaseController as dc +from geruecht.logger import getDebugLogger + +db = dc.DatabaseController() +debug = getDebugLogger() + +class Base: + def get_free_drink_list_configs(self): + return db.get_free_drink_list_configs() + + def set_free_drink_list_config(self, data): + return db.set_free_drink_list_config(data) + + def set_free_drink_list_history(self, user, data): + return db.set_free_drink_list_history(user, data) + + def get_free_drink_list_history(self, user): + return db.get_free_drink_list_history_by_user(user) + + def delete_free_drink_list_history(self, data): + return db.delete_free_drink_list_history(data) + + def update_free_drink_list_history(self, user, data): + db.update_free_drink_list_history(data) + return db.get_free_drink_list_history_by_user(user) \ No newline at end of file diff --git a/geruecht/routes.py b/geruecht/routes.py index 07b78ce..f6fa5da 100644 --- a/geruecht/routes.py +++ b/geruecht/routes.py @@ -26,6 +26,49 @@ def _valid(**kwargs): debug.warning("exception in valide.", exc_info=True) return jsonify({"error": str(err)}), 500 +@app.route("/freeDrinkListConfig", methods=['GET']) +@login_required() +def _free_drink_list_config(**kwargs): + try: + debug.info("get free_drink_list_config") + retVal = mainController.get_free_drink_list_configs() + debug.info("return free_drink_list_config {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.warning("exception in get free_dirnk_list_config.", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@app.route("/freeDrinkListHistory", methods=['GET', 'POST', 'PUT']) +@login_required() +def _free_drink_list_history(**kwargs): + try: + debug.info("set free_drink_list_history") + user = kwargs['accToken'].user + if request.method == 'GET': + retVal = mainController.get_free_drink_list_history(user) + if request.method == 'POST' or request.method == 'PUT': + data = request.get_json() + if request.method == 'POST': + retVal = mainController.set_free_drink_list_history(user, data) + else: + retVal = mainController.update_free_drink_list_history(user, data) + debug.debug(f'return free_drink_list_history {{{retVal}}}') + return jsonify(retVal) + except Exception as err: + debug.warning("exception in get free_dirnk_list_config.", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@app.route("/deleteDrinkListHistory", methods=['POST']) +@login_required() +def _delete_free_drink_list_history(**kwargs): + try: + debug.info("delete free_drink_list_history") + data = request.get_json() + retVal = mainController.delete_free_drink_list_history(data) + return jsonify({"ok": retVal}) + except Exception as err: + debug.warning("exception in delete free_dirnk_list_config.", exc_info=True) + return jsonify({"error": str(err)}), 500 @app.route("/pricelist", methods=['GET']) def _getPricelist(): try: From e5d2de4d35f914ed0bf6bede0e1938f31bb25602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 20 Aug 2020 11:35:42 +0200 Subject: [PATCH 002/446] =?UTF-8?q?Canceld=20FreeDrinks=20hinzugef=C3=BCgt?= =?UTF-8?q?=20und=20Bugfix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FreeDrinksListHistory haben jetzt auch canceld in dem steht ob das getränk storniert wurde oder nicht. Außerdem werden die Bandfreigetränke nicht per User sondern für alle Dienste rausgesucht --- .../databaseController/dbFreeDrinkListConfigController.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py index dd7a7ab..269179c 100644 --- a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py +++ b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py @@ -97,7 +97,7 @@ class Base: cursor = self.db.connection.cursor() now = datetime.now() worker = self.getWorker(user, now) - cursor.execute(f'select * from free_drink_list_history where timestamp>="{worker["startdatetime"]}" and user_id={user.id}') + cursor.execute(f'select * from free_drink_list_history where timestamp>="{worker["startdatetime"]}" and (user_id={user.id} or free_drink_type_id=3)') retVal = cursor.fetchall() for data in retVal: data['timestamp'] = {'year': data['timestamp'].year, From 246bd90ebd4953fb8e239d0e407788b85a7b24c3 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 13 Aug 2020 19:48:26 +0200 Subject: [PATCH 003/446] Restructure code for pluginify --- flaschengeist/.app.py.swp | Bin 0 -> 12288 bytes flaschengeist/__init__.py | 5 ++ geruecht/__init__.py => flaschengeist/app.py | 43 +++++++++------ .../config.example.yml | 0 {geruecht => flaschengeist}/logging.yml | 0 flaschengeist/modules/baruser/.routes.py.swp | Bin 0 -> 16384 bytes .../modules}/baruser/__init__.py | 0 .../modules}/baruser/routes.py | 0 .../modules}/creditList.py | 0 .../modules}/finanzer/__init__.py | 0 .../modules}/finanzer/routes.py | 0 .../modules}/gastro/__init__.py | 0 .../modules}/gastro/routes.py | 0 .../modules}/registration_route.py | 0 {geruecht => flaschengeist/modules}/routes.py | 0 .../model => flaschengeist/modules}/user.py | 0 .../modules}/user/__init__.py | 0 .../modules}/user/routes.py | 0 .../modules}/vorstand/__init__.py | 0 .../modules}/vorstand/routes.py | 0 flaschengeist/system/__init__.py | 0 .../system}/configparser.py | 0 .../system/controller/.ldapController.py.swp | Bin 0 -> 16384 bytes .../system}/controller/__init__.py | 9 ++-- .../controller/accesTokenController.py | 0 .../controller/databaseController/__init__.py | 0 .../dbAccessTokenController.py | 0 .../dbCreditListController.py | 0 .../dbJobInviteController.py | 0 .../databaseController/dbJobKindController.py | 0 .../dbJobRequesController.py | 0 .../dbPricelistController.py | 0 .../dbRegistrationController.py | 0 .../databaseController/dbUserController.py | 0 .../databaseController/dbWorkerController.py | 0 .../dbWorkgroupController.py | 0 .../system}/controller/emailController.py | 0 .../system}/controller/ldapController.py | 2 +- .../controller/mainController/__init__.py | 0 .../mainCreditListController.py | 0 .../mainController/mainJobInviteController.py | 0 .../mainController/mainJobKindController.py | 0 .../mainJobRequestController.py | 0 .../mainController/mainPasswordReset.py | 0 .../mainController/mainPricelistController.py | 0 .../mainRegistrationController.py | 0 .../mainController/mainUserController.py | 0 .../mainController/mainWorkerController.py | 0 .../mainController/mainWorkgroupController.py | 0 .../system}/decorator.py | 0 .../system}/exceptions/__init__.py | 0 {geruecht => flaschengeist/system}/logger.py | 5 +- flaschengeist/system/model/__init__.py | 0 .../system}/model/accessToken.py | 2 +- flaschengeist/system/model/user.py | 49 ++++++++++++++++++ geruecht/model/__init__.py | 6 --- packages.txt | 20 ------- required.txt | 14 ----- run.py => run_flaschengeist | 3 +- setup.py | 19 +++++++ 60 files changed, 113 insertions(+), 64 deletions(-) create mode 100644 flaschengeist/.app.py.swp create mode 100644 flaschengeist/__init__.py rename geruecht/__init__.py => flaschengeist/app.py (61%) rename geruecht/config.yml.example => flaschengeist/config.example.yml (100%) rename {geruecht => flaschengeist}/logging.yml (100%) create mode 100644 flaschengeist/modules/baruser/.routes.py.swp rename {geruecht => flaschengeist/modules}/baruser/__init__.py (100%) rename {geruecht => flaschengeist/modules}/baruser/routes.py (100%) rename {geruecht/model => flaschengeist/modules}/creditList.py (100%) rename {geruecht => flaschengeist/modules}/finanzer/__init__.py (100%) rename {geruecht => flaschengeist/modules}/finanzer/routes.py (100%) rename {geruecht => flaschengeist/modules}/gastro/__init__.py (100%) rename {geruecht => flaschengeist/modules}/gastro/routes.py (100%) rename {geruecht => flaschengeist/modules}/registration_route.py (100%) rename {geruecht => flaschengeist/modules}/routes.py (100%) rename {geruecht/model => flaschengeist/modules}/user.py (100%) rename {geruecht => flaschengeist/modules}/user/__init__.py (100%) rename {geruecht => flaschengeist/modules}/user/routes.py (100%) rename {geruecht => flaschengeist/modules}/vorstand/__init__.py (100%) rename {geruecht => flaschengeist/modules}/vorstand/routes.py (100%) create mode 100644 flaschengeist/system/__init__.py rename {geruecht => flaschengeist/system}/configparser.py (100%) create mode 100644 flaschengeist/system/controller/.ldapController.py.swp rename {geruecht => flaschengeist/system}/controller/__init__.py (72%) rename {geruecht => flaschengeist/system}/controller/accesTokenController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/__init__.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbAccessTokenController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbCreditListController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbJobInviteController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbJobKindController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbJobRequesController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbPricelistController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbRegistrationController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbUserController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbWorkerController.py (100%) rename {geruecht => flaschengeist/system}/controller/databaseController/dbWorkgroupController.py (100%) rename {geruecht => flaschengeist/system}/controller/emailController.py (100%) rename {geruecht => flaschengeist/system}/controller/ldapController.py (99%) rename {geruecht => flaschengeist/system}/controller/mainController/__init__.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainCreditListController.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainJobInviteController.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainJobKindController.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainJobRequestController.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainPasswordReset.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainPricelistController.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainRegistrationController.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainUserController.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainWorkerController.py (100%) rename {geruecht => flaschengeist/system}/controller/mainController/mainWorkgroupController.py (100%) rename {geruecht => flaschengeist/system}/decorator.py (100%) rename {geruecht => flaschengeist/system}/exceptions/__init__.py (100%) rename {geruecht => flaschengeist/system}/logger.py (86%) create mode 100644 flaschengeist/system/model/__init__.py rename {geruecht => flaschengeist/system}/model/accessToken.py (98%) create mode 100644 flaschengeist/system/model/user.py delete mode 100644 geruecht/model/__init__.py delete mode 100644 packages.txt delete mode 100644 required.txt rename run.py => run_flaschengeist (67%) create mode 100644 setup.py diff --git a/flaschengeist/.app.py.swp b/flaschengeist/.app.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..cfc7fadd141df886a10b934aec5c3081972d59fe GIT binary patch literal 12288 zcmeI2O^n+_6vw9^O8IC(Ahp*aRh0y7lkK+Mc2@#5o47P;x=HP%rQ*Yy*ptM>vCY`o zDy?WGK;lpdf#A-S3m0x&xbY!zX# z*t$t8pcQzO0td;-wG(QqAI-f)UwonSCmKr5gX&MLhgd! zz^~v7kbn!|Fu40HA>V*I;B8O?hrj{w%QNT$x51m>IQa8vLVf_BgU`S%5P%VQ9b5(H zz!C5``0*)1z6aldFTqXF0LQ=|2M`N<3O)foXn-#Q+)4W-x`zr{m zq;!CUyr(7xvPZ+dEqEM`g~Q{LCzm8nS&E4rg;L-`{=Tr&S;?n!DyMG7P=%ZMTngBT zZ_Zlas3&s^MY1^0^c8v&-v`r4+LaF>ZWq0mCf4Of0jsP?e3wONY1AsMY8V7*BasWx z34@;3eCGf^!?kLwC8zXtrrw?$k}gt{P@) z7RP=`ez3Qj+s2}OyJ6W^4AZJNw^dYW-ZGo*X0_R{(YbTNUR(6)ScYv`4do%nf}O z=BCn7zROaP9h-{^w3^fDm`xyUTr{=}6H~GvZpVb+yR0u=!-}%29n->UX;iFK;e5?= zlImE*p~x>WuWhNwZa14WmnE#rV!kwZ3sJ8&&Dxw##Nzn6yVL_~BhCH69b}bQYI~tM zUCa+`7e|>+y?xQRrjp37vh~wvx*KPB@ARp4mu;+e*WB*LiL>i#%ss`|Hu8j2o2Hd+ zDl9VW6?wRNSg>umoo&0gt0HfWJ>R93R!df4RfK!e+IH~x$?A=aH`A9jtFZX(CLavb z`d$yb#ACj9gVRbBA*AX=3Voi`cz4`ygniT^RFJGxV8_jbw&emr5eTw-WHn;eW;BW4 z^-)u%#YPpDw7(Qj@H`&LGJ@x;?{kqEWeTs%Y@gjezRE1K-<>bX<#I&ru9*R1Cx?{L z*u$Z9KM4aWt7$U8$Q@MSb~xk#)|ku#Y2ZOl@i@l=Oj@)Df-|?Y5HX5Ys1$yMW~)*; zb{#Q^5?YWqP)jcq?tsMu+_|U>nCo(vdI%n>_?ULzbJUlR5|PPMiwiuKk4MZIvOXt& E1Ck0(?*IS* literal 0 HcmV?d00001 diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py new file mode 100644 index 0000000..2cbd85f --- /dev/null +++ b/flaschengeist/__init__.py @@ -0,0 +1,5 @@ +""" Server-package +""" + +from pathlib import Path +_modpath = Path(__file__).parent diff --git a/geruecht/__init__.py b/flaschengeist/app.py similarity index 61% rename from geruecht/__init__.py rename to flaschengeist/app.py index 2687a9a..3fc4b3c 100644 --- a/geruecht/__init__.py +++ b/flaschengeist/app.py @@ -4,8 +4,8 @@ Initialize also a singelton for the AccesTokenControler and start the Thread. """ -from .logger import getDebugLogger -from geruecht.controller import dbConfig, ldapConfig +from .system.logger import getDebugLogger +from .system.controller import dbConfig, ldapConfig from flask_mysqldb import MySQL from flask_ldapconn import LDAPConn import ssl @@ -19,6 +19,7 @@ from flask_cors import CORS DEBUG.info("Build APP") app = Flask(__name__) CORS(app) + app.config['SECRET_KEY'] = '0a657b97ef546da90b2db91862ad4e29' app.config['MYSQL_HOST'] = dbConfig['URL'] app.config['MYSQL_USER'] = dbConfig['user'] @@ -42,18 +43,30 @@ app.config['FORCE_ATTRIBUTE_VALUE_AS_LIST'] = True ldap = LDAPConn(app) db = MySQL(app) -from geruecht import routes -from geruecht.baruser.routes import baruser -from geruecht.finanzer.routes import finanzer -from geruecht.user.routes import user -from geruecht.vorstand.routes import vorstand -from geruecht.gastro.routes import gastrouser -from geruecht.registration_route import registration +import pkg_resources + +discovered_plugins = { + entry_point.name: entry_point.load() + for entry_point + in pkg_resources.iter_entry_points('geruecht.plugins') +} + +#from geruecht import routes +#from geruecht.baruser.routes import baruser +#from geruecht.finanzer.routes import finanzer +#from geruecht.user.routes import user +#from geruecht.vorstand.routes import vorstand +#from geruecht.gastro.routes import gastrouser +#from geruecht.registration_route import registration DEBUG.info("Registrate bluebrints") -app.register_blueprint(baruser) -app.register_blueprint(finanzer) -app.register_blueprint(user) -app.register_blueprint(vorstand) -app.register_blueprint(gastrouser) -app.register_blueprint(registration) +for k, v in discovered_plugins: + DEBUG("Register %s" % k) + app.register_blueprint(v()) + +#app.register_blueprint(baruser) +#app.register_blueprint(finanzer) +#app.register_blueprint(user) +#app.register_blueprint(vorstand) +#app.register_blueprint(gastrouser) +#app.register_blueprint(registration) diff --git a/geruecht/config.yml.example b/flaschengeist/config.example.yml similarity index 100% rename from geruecht/config.yml.example rename to flaschengeist/config.example.yml diff --git a/geruecht/logging.yml b/flaschengeist/logging.yml similarity index 100% rename from geruecht/logging.yml rename to flaschengeist/logging.yml diff --git a/flaschengeist/modules/baruser/.routes.py.swp b/flaschengeist/modules/baruser/.routes.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..c213588c08c951d559f910f86882f27ec41e8e14 GIT binary patch literal 16384 zcmeHOO^h5z6)uusAYdor#{xy5a_2DP(C%FmgDi_7UT4>d(f(<7ZDB2&p|`tcr!zg> zLwAoitJT5@0+Bc&!WD#s03o=*_o}-3Z)UAQvJ?rb zrEj~dUcJ{}T#qvL*M}~2PbAMi+Ig6GHFkl!k3>XFs1BL;^fMMW&n1O8cAp0`f_dwpp=kohI8ux$am&o(G4f%t4`lI>% zrww_YG&jS5VZbn87%&VN1`Gp+0mFb{z%XDKFbo(5-UbF-Z0>&q@BI!f__6=5*Z*%k z!r03|0(=_y81Va3jC~Kd1&o2u1DAoPfYZQ-fPbB2>`maCz&C)Gf!lxwtO4f$8~DTf z7<&u&74Q-ufF0l}&;e}VpYLVtcfe18uK-^Fo&-(~E^rfg z3iuT8LEvu>p&alUa2L1*H~<5FhQP>OAOfxd7l01~4+DQj(B%!_HQ?(2^})Aw;APra z;Q8eT-gXP;B(iQQMAX{h9e!vX2sg1VaXJ~{6f%c$ZEg+YDCKlGz}_5sXPym5&0c@Z!jyETHm_c+hK9n2RtKd^L^)tF<;e7zx=>dZ>%sVLXm9 zDRjfIDg!T)jx7G{=GtoeY#}4lWC#UXxq2a^3S25u)skK!{2*%;GE^QfGj8e3yaAcuU zN533uviYdPGx;H!HjM|RL{Fs-6-0SDx$`v4Cvg~J96|Fs1Z|RDTP#ZxD=N?Csx3CJ zTeekOD)qBD&h2!zaG0mG(|Dch`wOx&+XcteKGchDSuhk%6z@;)NQA3e6lw**L6o(j z>~8CRu5hbbpsb^SO!}>z!U*X>nusrs1r9fXjPCPktot!b4pguEVt2gf1W_+;TXSe5 zT4AXsT9#E9>I`uL*9yNZGK6Hg5G-DEF4dx>2RuyU1B~$Ls?@Q^Ick#WYtm_J3Ej1YrUS2*nd;1Sfn0c|=IOO? zCXY{rVWn`*_&i5(CMP=O$Ak(MC8jvbEwYc%npS@-yJ~|dd~9V!g@`_az_mDm2fOZ3C*IcJQI$x+od0G zw~NZDpVTr+V#GWxC&yWwApM_`EYcjQ5HK22#Yo4gaZ=#Ha1n_+zC>h-xsqx3NLMW{<+`eE~xwbiAYHov~Pgv5=tjm@ov)kT{xeQs-MV^wME zU!B&|X;xHN9Z+|$)X@{?sa-~c*1MNhqu)?|c+c|Gg=NRl+$8i=oqPDk4|Ii#KD6~* z*4EP{M(h7Ytfzki(E4BR-~Slv{a1mPfENKDcoujJ_y^YcZvo!|?f@}BF@RIRN#M6w z-+vdl3w#wg1a1Nsfz!aBu(p2%SO!i2Z|3j+HPyjfh5^HXVZbn87%&VN1`Gp+0mHz5 z#{eyY8-dBg)-4xHxfTLNo+sCm^v}8jiq;;f7(z8#*(#%zmefOlNro+3I&3*%r4q!{ zGA#Y3t%Dp`3}_LxK&w}^ux)D{C!=+)&9~KxOs&HzF?(I^INRgVahA(SiMCNN0AtX? z7J%Yt)*S%EbM)ST3Q+S-hg%e(wp4sYzX|(I zBv1mG_bZqnjbr&i_@% literal 0 HcmV?d00001 diff --git a/geruecht/baruser/__init__.py b/flaschengeist/modules/baruser/__init__.py similarity index 100% rename from geruecht/baruser/__init__.py rename to flaschengeist/modules/baruser/__init__.py diff --git a/geruecht/baruser/routes.py b/flaschengeist/modules/baruser/routes.py similarity index 100% rename from geruecht/baruser/routes.py rename to flaschengeist/modules/baruser/routes.py diff --git a/geruecht/model/creditList.py b/flaschengeist/modules/creditList.py similarity index 100% rename from geruecht/model/creditList.py rename to flaschengeist/modules/creditList.py diff --git a/geruecht/finanzer/__init__.py b/flaschengeist/modules/finanzer/__init__.py similarity index 100% rename from geruecht/finanzer/__init__.py rename to flaschengeist/modules/finanzer/__init__.py diff --git a/geruecht/finanzer/routes.py b/flaschengeist/modules/finanzer/routes.py similarity index 100% rename from geruecht/finanzer/routes.py rename to flaschengeist/modules/finanzer/routes.py diff --git a/geruecht/gastro/__init__.py b/flaschengeist/modules/gastro/__init__.py similarity index 100% rename from geruecht/gastro/__init__.py rename to flaschengeist/modules/gastro/__init__.py diff --git a/geruecht/gastro/routes.py b/flaschengeist/modules/gastro/routes.py similarity index 100% rename from geruecht/gastro/routes.py rename to flaschengeist/modules/gastro/routes.py diff --git a/geruecht/registration_route.py b/flaschengeist/modules/registration_route.py similarity index 100% rename from geruecht/registration_route.py rename to flaschengeist/modules/registration_route.py diff --git a/geruecht/routes.py b/flaschengeist/modules/routes.py similarity index 100% rename from geruecht/routes.py rename to flaschengeist/modules/routes.py diff --git a/geruecht/model/user.py b/flaschengeist/modules/user.py similarity index 100% rename from geruecht/model/user.py rename to flaschengeist/modules/user.py diff --git a/geruecht/user/__init__.py b/flaschengeist/modules/user/__init__.py similarity index 100% rename from geruecht/user/__init__.py rename to flaschengeist/modules/user/__init__.py diff --git a/geruecht/user/routes.py b/flaschengeist/modules/user/routes.py similarity index 100% rename from geruecht/user/routes.py rename to flaschengeist/modules/user/routes.py diff --git a/geruecht/vorstand/__init__.py b/flaschengeist/modules/vorstand/__init__.py similarity index 100% rename from geruecht/vorstand/__init__.py rename to flaschengeist/modules/vorstand/__init__.py diff --git a/geruecht/vorstand/routes.py b/flaschengeist/modules/vorstand/routes.py similarity index 100% rename from geruecht/vorstand/routes.py rename to flaschengeist/modules/vorstand/routes.py diff --git a/flaschengeist/system/__init__.py b/flaschengeist/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/geruecht/configparser.py b/flaschengeist/system/configparser.py similarity index 100% rename from geruecht/configparser.py rename to flaschengeist/system/configparser.py diff --git a/flaschengeist/system/controller/.ldapController.py.swp b/flaschengeist/system/controller/.ldapController.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..673b1b5c5fdeed0ca0996ea6fafe1af1db199463 GIT binary patch literal 16384 zcmeHO&5z_p6)%#IKuGvlkU$$Eie3r3rC!gFfCLlzFxl?St~4_{nvYGQ&d~C-tGk`J z?Z$TRY-<<-8;KK$NCfx?kPw$#B1K$)Ln6ch1QJL@2&9CF+&G}XArb<9ugc}G_Uuen zNF3Ide&e>Q>eYL{SFc?4?Ac$wdU>6H!CGcGKFrwLcYUt^z&Eab;>H=qh9Y!a-}W6U zyUF1N1KS%1QEbUyPB5`uzoP!vQh`gI;F;M(__0{p41=e|GxTE~j{|?{qGv~gvG7O2 zjp8L=9E?Ob6N7QQl>Uh3Q7k4)gTRl&!1F}7{ALV9UU=M}xzw`vA%cLwff4?px- z8S3X6pXMJuvwOP}QrllMP%}_7P%}_7P%}_7P%}_7a9c4DPw!;U!s0s;tGAQ$drIg3 zB*$iQf2UOb@1(q$-2Y*zJh`k-H3KyRH3KyRH3KyRH3KyRH3KyRH3KyRH3KyR{|5%_ zl0$fhO?B51Uf(06qnr0p5HcW0!!tfnVRl*vr6mpbMM@-oBf$zXHDmegHfLJOq3UxC40oy^Q@9 zcoujBSOLBWd>r`QU5xz#co8sxU)>4afyaS!z*|V7JO$hX{0w|P4@h1?Ew4C25nr`E z%bre!@0h#W-L2+AJ$J}QVKAHaeS0E!yUh*y*5Ujt5}~oOP}>pvvymnLnhn{G%XV(W z4-fg_5kER=Si>Nk*s+<3Z7z1374kNZgg3OPTMM6D_+k*dfp3K(ng)I(ddqt~!;a(7 z-JiuGGH^BU@ppM|LAy#hk+8$T*ffG!TXxzyJTkP3vVr3_Tih^5uCpmUhXAhsP6di;53ss9X3T~si z%IDmdCXv(y?=46yIdP%<7W|k}ZWMiak{@Ys9_PlH>l7SFb!mvgZ`-?%?re3tB?sn| zDEd)F56tRi+Qs2KV^*>1`ojRRAmW76VHiv}^}5Jus*CE#5r{nzKStqalFcpIPHMYS z3hB^xBf&RCIB}y0;n5MkD;%@25%68=OI*jWi1>jUk9jo^nJ+MRH^+#qy zTpx%jg&L>v0R5%Xjvd=v+CTx>XhHIzPd2u9@R?L5P_@p;pb<}pQnagOn^_Ag6<#DN z+K6}-`dkstsGdGBwAJ070fUS^nxHg`MLC5bE-k4V)k;`tC{t&;7Yu8Ux{Hq;wf^E~zNKbs%OVR$}d{Ne(R z!Cy5viUYjBic(2Bo?=V}R95|_bBQFW7>hhm!|t^6$}U*wAet+t7CkcIS_(W;tWS^L zl;wgMX>sL)A}!6iIlWi)P%z0h%kf)$YDdvQ5IPwrs$Vsw?z_IDxYG|O9tU|6Y5t^2 zIRkStF@tI|>3rM-dfC5bd#=Mb6J2-^${Cdc(ZY%*UCxNGJdl;L74zxA*!2YWf)SkV z`daA|rc$2HncR*zdMi#Vw`xP_89-tR&b4PTK8AIN?!Z>hf(R-Pf-uA~tkf3Mo*%@? zR9V!nLS&rDOcmBTMNxHq4ZNWT2%3 ztQe&@W%uB@~)TmD;jmDp( zYH}MNFAv>O&**IA)>^7t6>YBX#!>*sM38*;ocNz;=wl7w#{Irzf~=-)48xG;RXK)o z4iBzjNwU^i-L&|HP{3n+bub8Ke#`^^JjUDkp*K$@4Dj5GWNj#!^lVN`U`G+CrTsmRo9} zgJlu|Ly4n_m4-o=)?8#|OKMN?8&6SD39xka)PNHOmzQ^?29v-Mo|d|PWuyCKi_@mA z#UESU-r2g+;*YN4;_8*H?VZ()PK$S+*y(Oy zdHqV~^2I0nTiwmI)eBfZUs~P1)a~@QSJ!rMTJLI(AH=@+&j9WJ;VOB& ziv9i#;1S?o*w;S~di0@?rv?gie!KK}cF2b=}| zjy?R3fE&OH@Dbpr*t<`GPXhnM-u?H$*MVhV39x|kz!~73MBhJ=&h=3Sc*EF8S-p3P$TARMQM{{9Cm`}3y3@y{x zD;kZVkd=`Wv($QVe@UGyOVR{gg#}q_oti5fK^;m7r|XOoa4_FK)6 z@=T}bIZFdpcAXWUuJ0tpdXl{~m}viFRCbz{-mFjZC5SyNCQHu^)MgQ-h(!@Ha(nqu zlfrI!4`w#>EpB@d1Nli#JeeCRVuM7hxJ#2bTKkfaj4ca;dBP)KDR=c{9rC(1=CVUSIiBri=*s+SR& z>hEeI({JcVVqzzyAyl`!xS31Iv?TNS8Am55h>|47sZvVC=Zp4TQZ4tc^f04FCF51v z$H*dTg{QxfdrE_Rh3lV3i{8=w=_-f-vakGlGfDe1UA=rxJ3gj;ZVD;CxzM8Ed`jiX j91&7G5ixq%Uqe2_R74eCMd?=gGbv7_9HWDN&B6W)UW#}7 literal 0 HcmV?d00001 diff --git a/geruecht/controller/__init__.py b/flaschengeist/system/controller/__init__.py similarity index 72% rename from geruecht/controller/__init__.py rename to flaschengeist/system/controller/__init__.py index b7f1734..7b2f4c6 100644 --- a/geruecht/controller/__init__.py +++ b/flaschengeist/system/controller/__init__.py @@ -1,10 +1,9 @@ -from geruecht.logger import getDebugLogger -from geruecht.configparser import ConifgParser +from ..logger import getDebugLogger +from ..configparser import ConifgParser +from flaschengeist import _modpath import os -print(os.getcwd()) - -config = ConifgParser('geruecht/config.yml') +config = ConifgParser(_modpath/'config.yml') LOGGER = getDebugLogger() diff --git a/geruecht/controller/accesTokenController.py b/flaschengeist/system/controller/accesTokenController.py similarity index 100% rename from geruecht/controller/accesTokenController.py rename to flaschengeist/system/controller/accesTokenController.py diff --git a/geruecht/controller/databaseController/__init__.py b/flaschengeist/system/controller/databaseController/__init__.py similarity index 100% rename from geruecht/controller/databaseController/__init__.py rename to flaschengeist/system/controller/databaseController/__init__.py diff --git a/geruecht/controller/databaseController/dbAccessTokenController.py b/flaschengeist/system/controller/databaseController/dbAccessTokenController.py similarity index 100% rename from geruecht/controller/databaseController/dbAccessTokenController.py rename to flaschengeist/system/controller/databaseController/dbAccessTokenController.py diff --git a/geruecht/controller/databaseController/dbCreditListController.py b/flaschengeist/system/controller/databaseController/dbCreditListController.py similarity index 100% rename from geruecht/controller/databaseController/dbCreditListController.py rename to flaschengeist/system/controller/databaseController/dbCreditListController.py diff --git a/geruecht/controller/databaseController/dbJobInviteController.py b/flaschengeist/system/controller/databaseController/dbJobInviteController.py similarity index 100% rename from geruecht/controller/databaseController/dbJobInviteController.py rename to flaschengeist/system/controller/databaseController/dbJobInviteController.py diff --git a/geruecht/controller/databaseController/dbJobKindController.py b/flaschengeist/system/controller/databaseController/dbJobKindController.py similarity index 100% rename from geruecht/controller/databaseController/dbJobKindController.py rename to flaschengeist/system/controller/databaseController/dbJobKindController.py diff --git a/geruecht/controller/databaseController/dbJobRequesController.py b/flaschengeist/system/controller/databaseController/dbJobRequesController.py similarity index 100% rename from geruecht/controller/databaseController/dbJobRequesController.py rename to flaschengeist/system/controller/databaseController/dbJobRequesController.py diff --git a/geruecht/controller/databaseController/dbPricelistController.py b/flaschengeist/system/controller/databaseController/dbPricelistController.py similarity index 100% rename from geruecht/controller/databaseController/dbPricelistController.py rename to flaschengeist/system/controller/databaseController/dbPricelistController.py diff --git a/geruecht/controller/databaseController/dbRegistrationController.py b/flaschengeist/system/controller/databaseController/dbRegistrationController.py similarity index 100% rename from geruecht/controller/databaseController/dbRegistrationController.py rename to flaschengeist/system/controller/databaseController/dbRegistrationController.py diff --git a/geruecht/controller/databaseController/dbUserController.py b/flaschengeist/system/controller/databaseController/dbUserController.py similarity index 100% rename from geruecht/controller/databaseController/dbUserController.py rename to flaschengeist/system/controller/databaseController/dbUserController.py diff --git a/geruecht/controller/databaseController/dbWorkerController.py b/flaschengeist/system/controller/databaseController/dbWorkerController.py similarity index 100% rename from geruecht/controller/databaseController/dbWorkerController.py rename to flaschengeist/system/controller/databaseController/dbWorkerController.py diff --git a/geruecht/controller/databaseController/dbWorkgroupController.py b/flaschengeist/system/controller/databaseController/dbWorkgroupController.py similarity index 100% rename from geruecht/controller/databaseController/dbWorkgroupController.py rename to flaschengeist/system/controller/databaseController/dbWorkgroupController.py diff --git a/geruecht/controller/emailController.py b/flaschengeist/system/controller/emailController.py similarity index 100% rename from geruecht/controller/emailController.py rename to flaschengeist/system/controller/emailController.py diff --git a/geruecht/controller/ldapController.py b/flaschengeist/system/controller/ldapController.py similarity index 99% rename from geruecht/controller/ldapController.py rename to flaschengeist/system/controller/ldapController.py index da3044e..e17d3e7 100644 --- a/geruecht/controller/ldapController.py +++ b/flaschengeist/system/controller/ldapController.py @@ -5,7 +5,7 @@ from geruecht.model import MONEY, USER, GASTRO, BAR, VORSTAND, EXTERN from geruecht.exceptions import PermissionDenied from . import Singleton from geruecht.exceptions import UsernameExistLDAP, LDAPExcetpion -from geruecht import ldapConfig +from . import ldapConfig from geruecht.logger import getDebugLogger debug = getDebugLogger() diff --git a/geruecht/controller/mainController/__init__.py b/flaschengeist/system/controller/mainController/__init__.py similarity index 100% rename from geruecht/controller/mainController/__init__.py rename to flaschengeist/system/controller/mainController/__init__.py diff --git a/geruecht/controller/mainController/mainCreditListController.py b/flaschengeist/system/controller/mainController/mainCreditListController.py similarity index 100% rename from geruecht/controller/mainController/mainCreditListController.py rename to flaschengeist/system/controller/mainController/mainCreditListController.py diff --git a/geruecht/controller/mainController/mainJobInviteController.py b/flaschengeist/system/controller/mainController/mainJobInviteController.py similarity index 100% rename from geruecht/controller/mainController/mainJobInviteController.py rename to flaschengeist/system/controller/mainController/mainJobInviteController.py diff --git a/geruecht/controller/mainController/mainJobKindController.py b/flaschengeist/system/controller/mainController/mainJobKindController.py similarity index 100% rename from geruecht/controller/mainController/mainJobKindController.py rename to flaschengeist/system/controller/mainController/mainJobKindController.py diff --git a/geruecht/controller/mainController/mainJobRequestController.py b/flaschengeist/system/controller/mainController/mainJobRequestController.py similarity index 100% rename from geruecht/controller/mainController/mainJobRequestController.py rename to flaschengeist/system/controller/mainController/mainJobRequestController.py diff --git a/geruecht/controller/mainController/mainPasswordReset.py b/flaschengeist/system/controller/mainController/mainPasswordReset.py similarity index 100% rename from geruecht/controller/mainController/mainPasswordReset.py rename to flaschengeist/system/controller/mainController/mainPasswordReset.py diff --git a/geruecht/controller/mainController/mainPricelistController.py b/flaschengeist/system/controller/mainController/mainPricelistController.py similarity index 100% rename from geruecht/controller/mainController/mainPricelistController.py rename to flaschengeist/system/controller/mainController/mainPricelistController.py diff --git a/geruecht/controller/mainController/mainRegistrationController.py b/flaschengeist/system/controller/mainController/mainRegistrationController.py similarity index 100% rename from geruecht/controller/mainController/mainRegistrationController.py rename to flaschengeist/system/controller/mainController/mainRegistrationController.py diff --git a/geruecht/controller/mainController/mainUserController.py b/flaschengeist/system/controller/mainController/mainUserController.py similarity index 100% rename from geruecht/controller/mainController/mainUserController.py rename to flaschengeist/system/controller/mainController/mainUserController.py diff --git a/geruecht/controller/mainController/mainWorkerController.py b/flaschengeist/system/controller/mainController/mainWorkerController.py similarity index 100% rename from geruecht/controller/mainController/mainWorkerController.py rename to flaschengeist/system/controller/mainController/mainWorkerController.py diff --git a/geruecht/controller/mainController/mainWorkgroupController.py b/flaschengeist/system/controller/mainController/mainWorkgroupController.py similarity index 100% rename from geruecht/controller/mainController/mainWorkgroupController.py rename to flaschengeist/system/controller/mainController/mainWorkgroupController.py diff --git a/geruecht/decorator.py b/flaschengeist/system/decorator.py similarity index 100% rename from geruecht/decorator.py rename to flaschengeist/system/decorator.py diff --git a/geruecht/exceptions/__init__.py b/flaschengeist/system/exceptions/__init__.py similarity index 100% rename from geruecht/exceptions/__init__.py rename to flaschengeist/system/exceptions/__init__.py diff --git a/geruecht/logger.py b/flaschengeist/system/logger.py similarity index 86% rename from geruecht/logger.py rename to flaschengeist/system/logger.py index 0348262..67c626f 100644 --- a/geruecht/logger.py +++ b/flaschengeist/system/logger.py @@ -2,6 +2,9 @@ import logging import logging.config import yaml from os import path, makedirs, getcwd +from .. import _modpath + +fname = _modpath/'logging.yml' if not path.exists("geruecht/log/debug"): a = path.join(path.curdir, "geruecht", "log", "debug") @@ -12,7 +15,7 @@ if not path.exists("geruecht/log/info"): makedirs(b) -with open("geruecht/logging.yml", 'rb') as file: +with fname.open(mode='rb') as file: config = yaml.safe_load(file.read()) logging.config.dictConfig(config) diff --git a/flaschengeist/system/model/__init__.py b/flaschengeist/system/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/geruecht/model/accessToken.py b/flaschengeist/system/model/accessToken.py similarity index 98% rename from geruecht/model/accessToken.py rename to flaschengeist/system/model/accessToken.py index 6e777f7..6072378 100644 --- a/geruecht/model/accessToken.py +++ b/flaschengeist/system/model/accessToken.py @@ -1,5 +1,5 @@ from datetime import datetime -from geruecht.logger import getDebugLogger +from ..logger import getDebugLogger debug = getDebugLogger() diff --git a/flaschengeist/system/model/user.py b/flaschengeist/system/model/user.py new file mode 100644 index 0000000..157a965 --- /dev/null +++ b/flaschengeist/system/model/user.py @@ -0,0 +1,49 @@ +from ..logger import getDebugLogger +from datetime import datetime +debug = getDebugLogger() + + +class User(): + """ Database Object for User + + Table for all safed User + + Attributes: + id: Id in Database as Primary Key. + username: Username of the User to Login + firstname: Firstname of the User + lastname: Lastname of the User + mail: mail address of the User + """ + def __init__(self, data): + debug.info("init user") + if 'id' in data: + self.id = int(data['id']) + self.firstname = data['firstname'] + self.lastname = data['lastname'] + if 'mail' in data: + self.mail = data['mail'] + else: + self.mail = '' + if 'username' in data: + self.username = data['username'] + else: + self.username = None + debug.debug("user is {{ {} }}".format(self)) + + def updateData(self, data): + debug.info("update data of user") + if 'firstname' in data: + self.firstname = data['firstname'] + if 'lastname' in data: + self.lastname = data['lastname'] + if 'mail' in data: + self.mail = data['mail'] + if 'username' in data: + self.username = data['username'] + + def __repr__(self): + return "User({}, {})".format(self.uid, self.username) + + +# TODO: user attributes (uid|key|value), getter, setter, has diff --git a/geruecht/model/__init__.py b/geruecht/model/__init__.py deleted file mode 100644 index 065f441..0000000 --- a/geruecht/model/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -MONEY = "moneymaster" -VORSTAND = "vorstand" -EXTERN = "extern" -GASTRO = "gastro" -USER = "user" -BAR = "bar" \ No newline at end of file diff --git a/packages.txt b/packages.txt deleted file mode 100644 index c852dc5..0000000 --- a/packages.txt +++ /dev/null @@ -1,20 +0,0 @@ -bcrypt==3.1.6 -cffi==1.12.3 -Click==7.0 -entrypoints==0.3 -flake8==3.7.7 -Flask==1.0.2 -Flask-Bcrypt==0.7.1 -Flask-Cors==3.0.7 -Flask-SQLAlchemy==2.4.0 -itsdangerous==1.1.0 -Jinja2==2.10.1 -MarkupSafe==1.1.1 -mccabe==0.6.1 -pkg-resources==0.0.0 -pycodestyle==2.5.0 -pycparser==2.19 -pyflakes==2.1.1 -six==1.12.0 -SQLAlchemy==1.3.3 -Werkzeug==0.15.2 diff --git a/required.txt b/required.txt deleted file mode 100644 index 1a818f2..0000000 --- a/required.txt +++ /dev/null @@ -1,14 +0,0 @@ -click==7.1.2 -Flask==1.1.2 -Flask-Cors==3.0.8 -Flask-LDAPConn==0.10.1 -Flask-MySQLdb==0.2.0 -itsdangerous==1.1.0 -Jinja2==2.11.2 -ldap3==2.7 -MarkupSafe==1.1.1 -mysqlclient==1.4.6 -pyasn1==0.4.8 -PyYAML==5.3.1 -six==1.15.0 -Werkzeug==1.0.1 diff --git a/run.py b/run_flaschengeist similarity index 67% rename from run.py rename to run_flaschengeist index b6d40ab..ab4b3b2 100644 --- a/run.py +++ b/run_flaschengeist @@ -1,4 +1,5 @@ -from geruecht import app +#!/usr/bin/python3 +from flaschengeist.app import app """ Main diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b142102 --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +from setuptools import setup, find_packages + +setup( + name='flaschengeist', + version='0.0.1', + url='https://wu5.de/redmine/projects/geruecht', + author='WU5 + Friends', + author_email='tim@groeger-clan.de', + description='Does things', + packages=find_packages(), + package_data={'': ['*.yml']}, + scripts=['run_flaschengeist'], + install_requires=['Flask >= 1.0.2', 'PyYAML>=5.3.1', "flask_mysqldb", "flask_ldapconn", "flask_cors"], + entry_points = { + 'flaschengeist.plugins': [ + 'users = flaschengeist.system.user:register' + ] + } +) From 1bac2e857f628c868bd926a90cb4bd0c70226dce Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 20 Aug 2020 17:19:16 +0200 Subject: [PATCH 004/446] Fixed plugin detection. Added dummy user plugin. Nothing works atm --- flaschengeist/.app.py.swo | Bin 0 -> 12288 bytes flaschengeist/app.py | 8 +- flaschengeist/modules/__init__.py | 0 flaschengeist/modules/routes.py | 216 -------- flaschengeist/modules/schedule/__init__.py | 413 +++++++++++++++ flaschengeist/modules/schedule/routes.py | 413 +++++++++++++++ flaschengeist/modules/user/__init__.py | 56 ++ flaschengeist/modules/user/routes.py | 583 +++++++-------------- flaschengeist/modules/{ => user}/user.py | 0 setup.py | 2 +- 10 files changed, 1080 insertions(+), 611 deletions(-) create mode 100644 flaschengeist/.app.py.swo create mode 100644 flaschengeist/modules/__init__.py delete mode 100644 flaschengeist/modules/routes.py create mode 100644 flaschengeist/modules/schedule/__init__.py create mode 100644 flaschengeist/modules/schedule/routes.py rename flaschengeist/modules/{ => user}/user.py (100%) diff --git a/flaschengeist/.app.py.swo b/flaschengeist/.app.py.swo new file mode 100644 index 0000000000000000000000000000000000000000..37fe53c75a232de5f8059fc7e0cb72af1ce8965d GIT binary patch literal 12288 zcmeHNON`q@7@krA<?XC7mZ}tM5>Ffx$2PXJ zRf=dOI3QGnkl@aN19xuRcpMN{E}TH(R`HTJaYKAFj;Zt5l{glSrB8`xzW?_>-#_zD ztW109N_B^>mzD^wrwQ5o@Fg?%hjrw++k|v^XgMD9EYWws<@z1w_WUR=$#0fFV2(G3 zfBRpDixs~=9B?n@i;d8Ko%h9;QQY&r#q%zUIz8^$+==2vkKeR;IOLsPyx5)SVL?y| zMsuE`nv?=cff)scad%<;C^@sTESLJp+za&CXIe8f)u0qm3Md7X0!jg;fKosypcGIF z{C^dQgG1yFhWF5Th!@A7ho(Nqx9USFpcGIFCr>Y;5cyaNkYB=?gFm?72p_f1o+_z^Z|E(SAhlKug3}b7Wf$W z2)GS+zyNp!xC*=soB$33-#kXhSHPFRr@$U?0ieL2M+o^Ecptb6*uVuK2mA^>egVD% zJ_FtZY@iGXtqud~QVJ*qlmbeD2U>xX;wV|jZJambq1kqaJO~{xE`)sl9zhOS?D$@h z?8mYhIQ}r=;r^UGKa66Wu-l(;uzgpY&%1Td6WT0HTo8(DpP^b#)baOt$SpH)hqmKI z*F84iHwwj~G^=RatqUc`>-vS9K30mJigNT66}%$p`l0+r9gogB+a$9NpdP0}?6wd) z$`uJY*$w>xP0#y{Q5!5E67rTz5M&Sfwi)usABG(sl^ijbalkShnSn0^ zF61Z9PnO1c&L;^L`OC1S&&+*EWO*~oz)??T6^djw+4M#FCVmelnVJZS5UY*eosn_5 zR=`Xw64zn@T56T8M%nkgq>+e8+VQ=vV_(mouj^&a+-f%U>UOKCnOC-IE!-Pst!gy$ zH(=bv@z4nq;my>w%dM)enPp8+Xi?;r#ErRI-_>UM&6;6e(R8C)-<4hky`k5e^>V#t zqH}NAT$#0M8JcMrHEAKnfmXRo&uQ;8!JPad0%+H}$wxwsQohHpAAk&vo8_*Q<5CGG!C6 zD7tCQwZPm+V!!_cQ7YzgFV@GC`961clu7E%OWHLVM1F~_t)6Rdoa5cqvuhUHSZc3W z?TzL2wH0QaUqFarID8;f8i-OTN zJJ_Pqq9goe`qF0U7VmDEU83qavBO;F7N=W50GF~8A=o^w@b=KI`8Fy&Do9!?u;MbV zO))`G1ibVaX^qHo2BYW=7d2&EY-C|c`b*IWyZJzr5$wUP%fpnEDttP#d?syN8ClAo zjF;qcIlMgL$_x-I*{6&~4i2rmvF}k)P2(O$-a#pD`hD(Uj)^!B1~z+&Eg!p>U^IIn zXI5#(V-TFCLip1(ot4tC-3doQObg-xa_ae^)nic)PcBLVvn*~=2hM%zA8B`;j{Kog V!i#M#M-3igqhAPEr_XFo{sETdZwLSY literal 0 HcmV?d00001 diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 3fc4b3c..a2ec07c 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -48,7 +48,7 @@ import pkg_resources discovered_plugins = { entry_point.name: entry_point.load() for entry_point - in pkg_resources.iter_entry_points('geruecht.plugins') + in pkg_resources.iter_entry_points('flaschengeist.plugins') } #from geruecht import routes @@ -60,9 +60,9 @@ discovered_plugins = { #from geruecht.registration_route import registration DEBUG.info("Registrate bluebrints") -for k, v in discovered_plugins: - DEBUG("Register %s" % k) - app.register_blueprint(v()) +for name in discovered_plugins: + DEBUG.info("Register %s" % name) + app.register_blueprint(discovered_plugins[name]()) #app.register_blueprint(baruser) #app.register_blueprint(finanzer) diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flaschengeist/modules/routes.py b/flaschengeist/modules/routes.py deleted file mode 100644 index 07b78ce..0000000 --- a/flaschengeist/modules/routes.py +++ /dev/null @@ -1,216 +0,0 @@ -from geruecht import app -from geruecht.logger import getDebugLogger -from geruecht.decorator import login_required -from geruecht.exceptions import PermissionDenied -import geruecht.controller.accesTokenController as ac -import geruecht.controller.mainController as mc -from geruecht.model import MONEY, BAR, USER, GASTRO, VORSTAND, EXTERN -from flask import request, jsonify - -accesTokenController = ac.AccesTokenController() -mainController = mc.MainController() - -debug = getDebugLogger() - -@app.route("/valid", methods=['POST']) -@login_required(bar=True) -def _valid(**kwargs): - debug.info('/valid') - try: - accToken = kwargs['accToken'] - data = request.get_json() - mainController.validateUser(accToken.user.uid, data['password']) - debug.debug('return {{ "ok": "ok" }}') - return jsonify({"ok": "ok"}) - except Exception as err: - debug.warning("exception in valide.", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@app.route("/pricelist", methods=['GET']) -def _getPricelist(): - try: - debug.info("get pricelist") - retVal = mainController.getPricelist() - debug.info("return pricelist {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get pricelist.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route('/drinkTypes', methods=['GET']) -def getTypes(): - try: - debug.info("get drinktypes") - retVal = mainController.getAllDrinkTypes() - debug.info("return drinktypes {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get drinktypes.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route('/getAllStatus', methods=['GET']) -@login_required(groups=[USER, MONEY, GASTRO, BAR, VORSTAND], bar=True) -def _getAllStatus(**kwargs): - try: - debug.info("get all status for users") - retVal = mainController.getAllStatus() - debug.info("return all status for users {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get all status for users.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route('/getStatus', methods=['POST']) -@login_required(groups=[USER, MONEY, GASTRO, BAR, VORSTAND], bar=True) -def _getStatus(**kwargs): - try: - debug.info("get status from user") - data = request.get_json() - name = data['name'] - debug.info("get status from user {{ {} }}".format(name)) - retVal = mainController.getStatus(name) - debug.info( - "return status from user {{ {} }} : {{ {} }}".format(name, retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get status from user.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route('/getUsers', methods=['GET']) -@login_required(groups=[USER], bar=True) -def _getUsers(**kwargs): - try: - extern = True - if 'extern' in request.args: - extern = not bool(int(request.args['extern'])) - debug.info("get all users from database") - users = mainController.getAllUsersfromDB(extern=extern) - debug.debug("users are {{ {} }}".format(users)) - retVal = [user.toJSON() for user in users] - debug.info("return all users from database {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning( - "exception in get all users from database.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route("/getLifeTime", methods=['GET']) -@login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) -def _getLifeTime(**kwargs): - try: - debug.info("get lifetime of accesstoken") - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - debug.debug("accessToken is {{ {} }}".format(accToken)) - retVal = {"value": accToken.lifetime, - "group": accToken.user.toJSON()['group'], - "lock_bar": accToken.lock_bar} - debug.info( - "return get lifetime from accesstoken {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.info("exception in get lifetime of accesstoken.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route("/saveLifeTime", methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) -def _saveLifeTime(**kwargs): - try: - debug.info("save lifetime for accessToken") - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - debug.debug("accessToken is {{ {} }}".format(accToken)) - data = request.get_json() - lifetime = data['value'] - debug.debug("lifetime is {{ {} }}".format(lifetime)) - debug.info("set lifetime {{ {} }} to accesstoken {{ {} }}".format( - lifetime, accToken)) - accToken.lifetime = lifetime - debug.info("update accesstoken timestamp") - accToken = accesTokenController.updateAccessToken(accToken) - accToken = accesTokenController.validateAccessToken(accToken.token, [USER, EXTERN]) - retVal = {"value": accToken.lifetime, - "group": accToken.user.toJSON()['group']} - debug.info( - "return save lifetime for accessToken {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning( - "exception in save lifetime for accesstoken.", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@app.route("/passwordReset", methods=['POST']) -def _passwordReset(): - try: - debug.info('password reset') - data = request.get_json() - mail = mainController.resetPassword(data) - index = mail.find('@') - for i in range(index): - if i == 0: - continue - mail = mail.replace(mail[i], "*", 1) - return jsonify({"ok": "ok", "mail": mail}) - except Exception as err: - debug.warning("excetpion in password reset", exc_info=True) - return jsonify({"error": str(err)}), 409 - -@app.route("/logout", methods=['GET']) -@login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) -def _logout(**kwargs): - try: - debug.info("logout user") - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - debug.debug("accesstoken is {{ {} }}".format(accToken)) - debug.info("delete accesstoken") - accesTokenController.deleteAccessToken(accToken) - debug.info("return ok logout user") - return jsonify({"ok": "ok"}) - except Exception as err: - debug.warning("exception in logout user.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route("/login", methods=['POST']) -def _login(): - """ Login User - - Nothing to say. - Login in User and create an AccessToken for the User. - - Returns: - A JSON-File with createt Token or Errors - """ - debug.info("Start log in.") - data = request.get_json() - username = data['username'] - password = data['password'] - debug.debug("username is {{ {} }}".format(username)) - try: - user_agent = request.user_agent - debug.info("search {{ {} }} in database".format(username)) - user = mainController.loginUser(username, password) - debug.debug("user is {{ {} }}".format(user)) - token = accesTokenController.createAccesToken(user, user_agent=user_agent) - debug.debug("accesstoken is {{ {} }}".format(token)) - debug.info("validate accesstoken") - dic = accesTokenController.validateAccessToken( - token, [USER, EXTERN]).user.toJSON() - dic["token"] = token - dic["accessToken"] = token - debug.info("User {{ {} }} success login.".format(username)) - debug.info("return login {{ {} }}".format(dic)) - return jsonify(dic) - except PermissionDenied as err: - debug.warning("permission denied exception in logout", exc_info=True) - return jsonify({"error": str(err)}), 401 - except Exception as err: - debug.warning("exception in logout.", exc_info=True) - return jsonify({"error": "permission denied"}), 401 diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py new file mode 100644 index 0000000..ff2fc7d --- /dev/null +++ b/flaschengeist/modules/schedule/__init__.py @@ -0,0 +1,413 @@ +from flask import Blueprint, request, jsonify +#from geruecht.decorator import login_required +#import geruecht.controller.mainController as mc +#import geruecht.controller.accesTokenController as ac +#from geruecht.model import USER +#from datetime import datetime, time, date +#from geruecht.exceptions import DayLocked +#from geruecht.logger import getDebugLogger, getCreditLogger, getJobsLogger +#from geruecht.model.accessToken import AccessToken + +user = Blueprint("user", __name__) + +#mainController = mc.MainController() +#accesTokenController = ac.AccesTokenController() + +debug = getDebugLogger() +#creditL = getCreditLogger() +#jobL = getJobsLogger() + + +#@user.route("/user/main") +#@login_required(groups=[USER]) +#def _main(**kwargs): +# debug.info("/user/main") +# try: +# if 'accToken' in kwargs: +# accToken = kwargs['accToken'] +# accToken.user = mainController.getUser(accToken.user.uid) +# retVal = accToken.user.toJSON() +# retVal['creditList'] = {credit.year: credit.toJSON() +# for credit in accToken.user.geruechte} +# debug.debug("return {{ {} }}".format(retVal)) +# return jsonify(retVal) +# except Exception: +# debug.debug("exception", exc_info=True) +# return jsonify("error", "something went wrong"), 500 + + +#@user.route("/user/addAmount", methods=['POST']) +#@login_required(groups=[USER]) +#def _addAmount(**kwargs): +# debug.info("/user/addAmount") +# try: +# if 'accToken' in kwargs: +# accToken = kwargs['accToken'] +# data = request.get_json() +# amount = int(data['amount']) +# date = datetime.now() +# mainController.addAmount( +# accToken.user.uid, amount, year=date.year, month=date.month) +# accToken.user = mainController.getUser(accToken.user.uid) +# retVal = accToken.user.toJSON() +# retVal['creditList'] = {credit.year: credit.toJSON() +# for credit in accToken.user.geruechte} +# debug.debug("return {{ {} }}".format(retVal)) +# creditL.info("{} {} {} fügt sich selbst {} € Schulden hinzu".format( +# date, accToken.user.firstname, accToken.user.lastname, amount/100)) +# return jsonify(retVal) +# except Exception: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": "something went wrong"}), 500 + + +#@user.route("/user/saveConfig", methods=['POST']) +#@login_required(groups=[USER]) +#def _saveConfig(**kwargs): +# debug.info("/user/saveConfig") +# try: +# if 'accToken' in kwargs: +# accToken = kwargs['accToken'] +# data = request.get_json() +# password = data['acceptedPassword'] +# data.pop('acceptedPassword') +# accToken.user = mainController.modifyUser( +# accToken.user, data, password) +# retVal = accToken.user.toJSON() +# retVal['creditList'] = {credit.year: credit.toJSON() +# for credit in accToken.user.geruechte} +# debug.debug("return {{ {} }}".format(retVal)) +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 409 + + +#@user.route("/user/jobs", methods=['POST']) +#@login_required(groups=[USER]) +#def _getUsers(**kwrags): +# debug.info("/user/jobs") +# try: +# data = request.get_json() + #from_date = data['from_date'] + #to_date = data['to_date'] + #from_date = datetime( + #from_date['year'], from_date['month'], from_date['day']) + #to_date = datetime(to_date['year'], to_date['month'], to_date['day']) + #lockedDays = mainController.getLockedDays(from_date, to_date) + #retVal = [] + #for lockedDay in lockedDays: + #day = datetime.combine(lockedDay['daydate'], time(12)) + #retDay = { + #"worker": mainController.getWorker(day), + #"day": { + #"date": { + #"year": day.year, + #"month": day.month, + #"day": day.day + #}, + #"locked": lockedDay['locked'] + #}, + #"jobkinddate": mainController.getJobKindDates(day.date()) + #} + #retVal.append(retDay) + + #debug.debug("return {{ {} }}".format(retVal)) + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + +#@user.route("/user/jobsOnDates", methods=['POST']) +#@login_required(groups=[USER]) +#def _getJobsOnDates(**kwargs): + #debug.info("/user/jobsOnDates") + #try: + #data = request.get_json() + #lockedDays = mainController.getLockedDaysFromList(data) + #retVal = [] + #for lockedDay in lockedDays: + #day = datetime.combine(lockedDay['daydate'], time(12)) + #retDay = { + #"worker": mainController.getWorker(day), + #"day": { + #"date": { + #"year": day.year, + #"month": day.month, + #"day": day.day + #}, + #"locked": lockedDay['locked'] + #}, + #"jobkinddate": mainController.getJobKindDates(day.date()) + #} + #retVal.append(retDay) + + #debug.debug("return {{ {} }}".format(retVal)) + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + +#@user.route("/user/job", methods=['POST']) +#@login_required(groups=[USER]) +#def _getUser(**kwargs): + #debug.info("/user/job") + #try: + #data = request.get_json() + #day = data['day'] + #month = data['month'] + #year = data['year'] + #date = datetime(year, month, day, 12) + #lockedDay = mainController.getLockedDay(date) + #if not lockedDay: + #lockedDay = { + #'date': { + #'year': year, + #'month': month, + #'day': day + #}, + #'locked': False + #} + #else: + #lockedDay = { + #'date': { + #'year': year, + #'month': month, + #'day': day + #}, + #'locked': lockedDay['locked'] + #} + #retVal = { + #'worker': mainController.getWorker(date), + #'day': lockedDay + #} + #debug.debug("retrun {{ {} }}".format(retVal)) + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + + +#@user.route("/user/addJob", methods=['POST']) +#@login_required(groups=[USER]) +#def _addUser(**kwargs): + #debug.info("/user/addJob") + #try: + #if 'accToken' in kwargs: + #accToken = kwargs['accToken'] + #user = accToken.user + #data = request.get_json() + #day = data['day'] + #month = data['month'] + #year = data['year'] + #date = datetime(year, month, day, 12) + #job_kind = None + #if 'job_kind' in data: + #job_kind = data['job_kind'] + #mainController.addWorker(user.uid, date, job_kind=job_kind, userExc=True) + #retVal = mainController.getWorker(date) + #debug.debug("return {{ {} }}".format(retVal)) + #jobL.info("Mitglied {} {} schreib sich am {} zum Dienst ein.".format( + #user.firstname, user.lastname, date.date())) + #return jsonify(retVal) + #except DayLocked as err: + #debug.debug("exception", exc_info=True) + #return jsonify({'error': str(err)}), 403 + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({'error': str(err)}), 409 + + +#@user.route("/user/deleteJob", methods=['POST']) +#@login_required(groups=[USER]) +#def _deletJob(**kwargs): + #debug.info("/user/deleteJob") + #try: + #if 'accToken' in kwargs: + #accToken = kwargs['accToken'] + #user = accToken.user + #data = request.get_json() + #day = data['day'] + #month = data['month'] + #year = data['year'] + #date = datetime(year, month, day, 12) + #mainController.deleteWorker(user.uid, date, True) + #retVal = mainController.getWorker(date) + #debug.debug("return ok") + #jobL.info("Mitglied {} {} entfernt sich am {} aus dem Dienst".format( + #user.firstname, user.lastname, date.date())) + #return jsonify(retVal) + #except DayLocked as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 403 + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 409 + +#@user.route("/user/storno", methods=['POST']) +#@login_required(groups=[USER]) +#def _storno(**kwargs): + #""" Function for Baruser to storno amount + + #This function added to the user with the posted userID the posted amount. + + #Returns: + #JSON-File with userID and the amount + #or ERROR 401 Permission Denied + #""" + + #debug.info("/user/storno") + #try: + #if 'accToken' in kwargs: + #accToken = kwargs['accToken'] + #user = accToken.user + #data = request.get_json() + #amount = int(data['amount']) + + #date = datetime.now() + #mainController.addCredit( + #user.uid, amount, year=date.year, month=date.month) + #accToken.user = mainController.getUser(accToken.user.uid) + #retVal = accToken.user.toJSON() + #retVal['creditList'] = {credit.year: credit.toJSON() + #for credit in accToken.user.geruechte} + #debug.debug("return {{ {} }}".format(retVal)) + #creditL.info("{} {} {} storniert {} €".format( + #date, user.firstname, user.lastname, amount/100)) + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + + +#@user.route("/user/getJobInvites", methods=['POST']) +#@login_required(groups=[USER]) +#def _getJobInvites(**kwargs): + #try: + #debug.info("/user/getJobInvites") + #from_user = None + #to_user = None + #on_date = None + + #data = request.get_json() + + #if 'from_user' in data: + #from_user = data['from_user'] + #if 'to_user' in data: + #to_user = data['to_user'] + #on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) + #retVal = mainController.getJobInvites(from_user, to_user, on_date) + #debug.debug("return {{ {} }}".format(retVal)) + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + +#@user.route("/user/JobInvites", methods=['PUT', 'POST']) +#@login_required(groups=[USER]) +#def _JobInvites(**kwargs): + #try: + #debug.info("/user/JobInvites") + #data = request.get_json() + #if request.method == 'PUT': + #mainController.setJobInvites(data) + #retVal = mainController.getJobInvites(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) + #debug.debug("return {{ {} }}".format(retVal)) + #if request.method == 'POST': + #retVal = mainController.updateJobInvites(data) + + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + +#@user.route("/user/deleteJobInvite", methods=['POST']) +#@login_required(groups=[USER]) +#def _deleteJobInvite(**kwargs): + #try: + #debug.info("/user/deleteJobInvite") + #data = request.get_json() + #mainController.deleteJobInvite(data) + #retVal = mainController.getJobInvites(data['from_user'], None, datetime.now().date()) + #debug.debug("return {{ {} }}".format(retVal)) + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + + +#@user.route("/user/getJobRequests", methods=['POST']) +#@login_required(groups=[USER]) +#def _getJobRequests(**kwargs): + #try: + #debug.info("/user/getJobRequests") + #from_user = None + #to_user = None + #on_date = None + + #data = request.get_json() + + #if 'from_user' in data: + #from_user = data['from_user'] + #if 'to_user' in data: + #to_user = data['to_user'] + #on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) + #retVal = mainController.getJobRequests(from_user, to_user, on_date) + #debug.debug("return {{ {} }}".format(retVal)) + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + +#@user.route("/user/JobRequests", methods=['PUT', 'POST']) +#@login_required(groups=[USER]) +#def _JobRequests(**kwargs): + #try: + #debug.info("/user/JobRequests") + #data = request.get_json() + #if request.method == 'PUT': + #mainController.setJobRequests(data) + #retVal = mainController.getJobRequests(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) + #debug.debug("return {{ {} }}".format(retVal)) + #if request.method == 'POST': + #data['on_date'] = date(data['on_date']['year'], data['on_date']['month'], data['on_date']['day']) + #retVal = mainController.updateJobRequests(data) + + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + +#@user.route("/user/deleteJobRequest", methods=['POST']) +#@login_required(groups=[USER]) +#def _deleteJobRequest(**kwargs): + #try: + #debug.info("/user/deleteJobRequest") + #data = request.get_json() + #mainController.deleteJobRequest(data) + #retVal = mainController.getJobRequests(data['from_user'], None, datetime.now().date()) + #debug.debug("return {{ {} }}".format(retVal)) + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 + + +#@user.route("/user/getAccessTokens", methods=['GET', 'POST']) +#@login_required(groups=[USER]) +#def _getAccessTokens(**kwargs): + #try: + #debug.info("/user/getAccessTokens") + #if request.method == 'POST': + #data = request.get_json() + #delAccToken = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) + #accesTokenController.deleteAccessToken(delAccToken) + #tokens = accesTokenController.getAccessTokensFromUser(kwargs['accToken'].user) + #retVal = [] + #for token in tokens: + #retVal.append(token.toJSON()) + #debug.debug("return {{ {} }}".format(retVal)) + #return jsonify(retVal) + #except Exception as err: + #debug.debug("exception", exc_info=True) + #return jsonify({"error": str(err)}), 500 diff --git a/flaschengeist/modules/schedule/routes.py b/flaschengeist/modules/schedule/routes.py new file mode 100644 index 0000000..6b5e3c2 --- /dev/null +++ b/flaschengeist/modules/schedule/routes.py @@ -0,0 +1,413 @@ +from flask import Blueprint, request, jsonify +from geruecht.decorator import login_required +import geruecht.controller.mainController as mc +import geruecht.controller.accesTokenController as ac +from geruecht.model import USER +from datetime import datetime, time, date +from geruecht.exceptions import DayLocked +from geruecht.logger import getDebugLogger, getCreditLogger, getJobsLogger +from geruecht.model.accessToken import AccessToken + +user = Blueprint("user", __name__) + +mainController = mc.MainController() +accesTokenController = ac.AccesTokenController() + +debug = getDebugLogger() +creditL = getCreditLogger() +jobL = getJobsLogger() + + +@user.route("/user/main") +@login_required(groups=[USER]) +def _main(**kwargs): + debug.info("/user/main") + try: + if 'accToken' in kwargs: + accToken = kwargs['accToken'] + accToken.user = mainController.getUser(accToken.user.uid) + retVal = accToken.user.toJSON() + retVal['creditList'] = {credit.year: credit.toJSON() + for credit in accToken.user.geruechte} + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception: + debug.debug("exception", exc_info=True) + return jsonify("error", "something went wrong"), 500 + + +@user.route("/user/addAmount", methods=['POST']) +@login_required(groups=[USER]) +def _addAmount(**kwargs): + debug.info("/user/addAmount") + try: + if 'accToken' in kwargs: + accToken = kwargs['accToken'] + data = request.get_json() + amount = int(data['amount']) + date = datetime.now() + mainController.addAmount( + accToken.user.uid, amount, year=date.year, month=date.month) + accToken.user = mainController.getUser(accToken.user.uid) + retVal = accToken.user.toJSON() + retVal['creditList'] = {credit.year: credit.toJSON() + for credit in accToken.user.geruechte} + debug.debug("return {{ {} }}".format(retVal)) + creditL.info("{} {} {} fügt sich selbst {} € Schulden hinzu".format( + date, accToken.user.firstname, accToken.user.lastname, amount/100)) + return jsonify(retVal) + except Exception: + debug.debug("exception", exc_info=True) + return jsonify({"error": "something went wrong"}), 500 + + +@user.route("/user/saveConfig", methods=['POST']) +@login_required(groups=[USER]) +def _saveConfig(**kwargs): + debug.info("/user/saveConfig") + try: + if 'accToken' in kwargs: + accToken = kwargs['accToken'] + data = request.get_json() + password = data['acceptedPassword'] + data.pop('acceptedPassword') + accToken.user = mainController.modifyUser( + accToken.user, data, password) + retVal = accToken.user.toJSON() + retVal['creditList'] = {credit.year: credit.toJSON() + for credit in accToken.user.geruechte} + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 409 + + +@user.route("/user/jobs", methods=['POST']) +@login_required(groups=[USER]) +def _getUsers(**kwrags): + debug.info("/user/jobs") + try: + data = request.get_json() + from_date = data['from_date'] + to_date = data['to_date'] + from_date = datetime( + from_date['year'], from_date['month'], from_date['day']) + to_date = datetime(to_date['year'], to_date['month'], to_date['day']) + lockedDays = mainController.getLockedDays(from_date, to_date) + retVal = [] + for lockedDay in lockedDays: + day = datetime.combine(lockedDay['daydate'], time(12)) + retDay = { + "worker": mainController.getWorker(day), + "day": { + "date": { + "year": day.year, + "month": day.month, + "day": day.day + }, + "locked": lockedDay['locked'] + }, + "jobkinddate": mainController.getJobKindDates(day.date()) + } + retVal.append(retDay) + + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@user.route("/user/jobsOnDates", methods=['POST']) +@login_required(groups=[USER]) +def _getJobsOnDates(**kwargs): + debug.info("/user/jobsOnDates") + try: + data = request.get_json() + lockedDays = mainController.getLockedDaysFromList(data) + retVal = [] + for lockedDay in lockedDays: + day = datetime.combine(lockedDay['daydate'], time(12)) + retDay = { + "worker": mainController.getWorker(day), + "day": { + "date": { + "year": day.year, + "month": day.month, + "day": day.day + }, + "locked": lockedDay['locked'] + }, + "jobkinddate": mainController.getJobKindDates(day.date()) + } + retVal.append(retDay) + + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@user.route("/user/job", methods=['POST']) +@login_required(groups=[USER]) +def _getUser(**kwargs): + debug.info("/user/job") + try: + data = request.get_json() + day = data['day'] + month = data['month'] + year = data['year'] + date = datetime(year, month, day, 12) + lockedDay = mainController.getLockedDay(date) + if not lockedDay: + lockedDay = { + 'date': { + 'year': year, + 'month': month, + 'day': day + }, + 'locked': False + } + else: + lockedDay = { + 'date': { + 'year': year, + 'month': month, + 'day': day + }, + 'locked': lockedDay['locked'] + } + retVal = { + 'worker': mainController.getWorker(date), + 'day': lockedDay + } + debug.debug("retrun {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@user.route("/user/addJob", methods=['POST']) +@login_required(groups=[USER]) +def _addUser(**kwargs): + debug.info("/user/addJob") + try: + if 'accToken' in kwargs: + accToken = kwargs['accToken'] + user = accToken.user + data = request.get_json() + day = data['day'] + month = data['month'] + year = data['year'] + date = datetime(year, month, day, 12) + job_kind = None + if 'job_kind' in data: + job_kind = data['job_kind'] + mainController.addWorker(user.uid, date, job_kind=job_kind, userExc=True) + retVal = mainController.getWorker(date) + debug.debug("return {{ {} }}".format(retVal)) + jobL.info("Mitglied {} {} schreib sich am {} zum Dienst ein.".format( + user.firstname, user.lastname, date.date())) + return jsonify(retVal) + except DayLocked as err: + debug.debug("exception", exc_info=True) + return jsonify({'error': str(err)}), 403 + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({'error': str(err)}), 409 + + +@user.route("/user/deleteJob", methods=['POST']) +@login_required(groups=[USER]) +def _deletJob(**kwargs): + debug.info("/user/deleteJob") + try: + if 'accToken' in kwargs: + accToken = kwargs['accToken'] + user = accToken.user + data = request.get_json() + day = data['day'] + month = data['month'] + year = data['year'] + date = datetime(year, month, day, 12) + mainController.deleteWorker(user.uid, date, True) + retVal = mainController.getWorker(date) + debug.debug("return ok") + jobL.info("Mitglied {} {} entfernt sich am {} aus dem Dienst".format( + user.firstname, user.lastname, date.date())) + return jsonify(retVal) + except DayLocked as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 403 + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 409 + +@user.route("/user/storno", methods=['POST']) +@login_required(groups=[USER]) +def _storno(**kwargs): + """ Function for Baruser to storno amount + + This function added to the user with the posted userID the posted amount. + + Returns: + JSON-File with userID and the amount + or ERROR 401 Permission Denied + """ + + debug.info("/user/storno") + try: + if 'accToken' in kwargs: + accToken = kwargs['accToken'] + user = accToken.user + data = request.get_json() + amount = int(data['amount']) + + date = datetime.now() + mainController.addCredit( + user.uid, amount, year=date.year, month=date.month) + accToken.user = mainController.getUser(accToken.user.uid) + retVal = accToken.user.toJSON() + retVal['creditList'] = {credit.year: credit.toJSON() + for credit in accToken.user.geruechte} + debug.debug("return {{ {} }}".format(retVal)) + creditL.info("{} {} {} storniert {} €".format( + date, user.firstname, user.lastname, amount/100)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@user.route("/user/getJobInvites", methods=['POST']) +@login_required(groups=[USER]) +def _getJobInvites(**kwargs): + try: + debug.info("/user/getJobInvites") + from_user = None + to_user = None + on_date = None + + data = request.get_json() + + if 'from_user' in data: + from_user = data['from_user'] + if 'to_user' in data: + to_user = data['to_user'] + on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) + retVal = mainController.getJobInvites(from_user, to_user, on_date) + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@user.route("/user/JobInvites", methods=['PUT', 'POST']) +@login_required(groups=[USER]) +def _JobInvites(**kwargs): + try: + debug.info("/user/JobInvites") + data = request.get_json() + if request.method == 'PUT': + mainController.setJobInvites(data) + retVal = mainController.getJobInvites(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) + debug.debug("return {{ {} }}".format(retVal)) + if request.method == 'POST': + retVal = mainController.updateJobInvites(data) + + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@user.route("/user/deleteJobInvite", methods=['POST']) +@login_required(groups=[USER]) +def _deleteJobInvite(**kwargs): + try: + debug.info("/user/deleteJobInvite") + data = request.get_json() + mainController.deleteJobInvite(data) + retVal = mainController.getJobInvites(data['from_user'], None, datetime.now().date()) + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@user.route("/user/getJobRequests", methods=['POST']) +@login_required(groups=[USER]) +def _getJobRequests(**kwargs): + try: + debug.info("/user/getJobRequests") + from_user = None + to_user = None + on_date = None + + data = request.get_json() + + if 'from_user' in data: + from_user = data['from_user'] + if 'to_user' in data: + to_user = data['to_user'] + on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) + retVal = mainController.getJobRequests(from_user, to_user, on_date) + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@user.route("/user/JobRequests", methods=['PUT', 'POST']) +@login_required(groups=[USER]) +def _JobRequests(**kwargs): + try: + debug.info("/user/JobRequests") + data = request.get_json() + if request.method == 'PUT': + mainController.setJobRequests(data) + retVal = mainController.getJobRequests(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) + debug.debug("return {{ {} }}".format(retVal)) + if request.method == 'POST': + data['on_date'] = date(data['on_date']['year'], data['on_date']['month'], data['on_date']['day']) + retVal = mainController.updateJobRequests(data) + + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@user.route("/user/deleteJobRequest", methods=['POST']) +@login_required(groups=[USER]) +def _deleteJobRequest(**kwargs): + try: + debug.info("/user/deleteJobRequest") + data = request.get_json() + mainController.deleteJobRequest(data) + retVal = mainController.getJobRequests(data['from_user'], None, datetime.now().date()) + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@user.route("/user/getAccessTokens", methods=['GET', 'POST']) +@login_required(groups=[USER]) +def _getAccessTokens(**kwargs): + try: + debug.info("/user/getAccessTokens") + if request.method == 'POST': + data = request.get_json() + delAccToken = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) + accesTokenController.deleteAccessToken(delAccToken) + tokens = accesTokenController.getAccessTokensFromUser(kwargs['accToken'].user) + retVal = [] + for token in tokens: + retVal.append(token.toJSON()) + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 diff --git a/flaschengeist/modules/user/__init__.py b/flaschengeist/modules/user/__init__.py index e69de29..ec9f6f3 100644 --- a/flaschengeist/modules/user/__init__.py +++ b/flaschengeist/modules/user/__init__.py @@ -0,0 +1,56 @@ +####################################### +# Plugin: Users # +# Functionality: Allow management # +# of users, login, logout, etc # +####################################### + +from flask import Blueprint +from flaschengeist.app import app + +def register(): + return Blueprint('user', __name__) + +####################################### +## Routes ## +####################################### +#dummy +@app.route("/") +def _dummy(): + return 'Noch funktioniert hier mal überhaupt nichts!' + +@app.route("/login", methods=['POST']) +def _login(): + """ Login User + + Nothing to say. + Login in User and create an AccessToken for the User. + + Returns: + A JSON-File with createt Token or Errors + """ + debug.info("Start log in.") + data = request.get_json() + username = data['username'] + password = data['password'] + debug.debug("username is {{ {} }}".format(username)) + try: + user_agent = request.user_agent + debug.info("search {{ {} }} in database".format(username)) + user = mainController.loginUser(username, password) + debug.debug("user is {{ {} }}".format(user)) + token = accesTokenController.createAccesToken(user, user_agent=user_agent) + debug.debug("accesstoken is {{ {} }}".format(token)) + debug.info("validate accesstoken") + dic = accesTokenController.validateAccessToken( + token, [USER, EXTERN]).user.toJSON() + dic["token"] = token + dic["accessToken"] = token + debug.info("User {{ {} }} success login.".format(username)) + debug.info("return login {{ {} }}".format(dic)) + return jsonify(dic) + except PermissionDenied as err: + debug.warning("permission denied exception in logout", exc_info=True) + return jsonify({"error": str(err)}), 401 + except Exception as err: + debug.warning("exception in logout.", exc_info=True) + return jsonify({"error": "permission denied"}), 401 diff --git a/flaschengeist/modules/user/routes.py b/flaschengeist/modules/user/routes.py index 6b5e3c2..07b78ce 100644 --- a/flaschengeist/modules/user/routes.py +++ b/flaschengeist/modules/user/routes.py @@ -1,413 +1,216 @@ -from flask import Blueprint, request, jsonify +from geruecht import app +from geruecht.logger import getDebugLogger from geruecht.decorator import login_required -import geruecht.controller.mainController as mc +from geruecht.exceptions import PermissionDenied import geruecht.controller.accesTokenController as ac -from geruecht.model import USER -from datetime import datetime, time, date -from geruecht.exceptions import DayLocked -from geruecht.logger import getDebugLogger, getCreditLogger, getJobsLogger -from geruecht.model.accessToken import AccessToken +import geruecht.controller.mainController as mc +from geruecht.model import MONEY, BAR, USER, GASTRO, VORSTAND, EXTERN +from flask import request, jsonify -user = Blueprint("user", __name__) - -mainController = mc.MainController() accesTokenController = ac.AccesTokenController() +mainController = mc.MainController() debug = getDebugLogger() -creditL = getCreditLogger() -jobL = getJobsLogger() - -@user.route("/user/main") -@login_required(groups=[USER]) -def _main(**kwargs): - debug.info("/user/main") +@app.route("/valid", methods=['POST']) +@login_required(bar=True) +def _valid(**kwargs): + debug.info('/valid') try: + accToken = kwargs['accToken'] + data = request.get_json() + mainController.validateUser(accToken.user.uid, data['password']) + debug.debug('return {{ "ok": "ok" }}') + return jsonify({"ok": "ok"}) + except Exception as err: + debug.warning("exception in valide.", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@app.route("/pricelist", methods=['GET']) +def _getPricelist(): + try: + debug.info("get pricelist") + retVal = mainController.getPricelist() + debug.info("return pricelist {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.warning("exception in get pricelist.", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@app.route('/drinkTypes', methods=['GET']) +def getTypes(): + try: + debug.info("get drinktypes") + retVal = mainController.getAllDrinkTypes() + debug.info("return drinktypes {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.warning("exception in get drinktypes.", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@app.route('/getAllStatus', methods=['GET']) +@login_required(groups=[USER, MONEY, GASTRO, BAR, VORSTAND], bar=True) +def _getAllStatus(**kwargs): + try: + debug.info("get all status for users") + retVal = mainController.getAllStatus() + debug.info("return all status for users {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.warning("exception in get all status for users.", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@app.route('/getStatus', methods=['POST']) +@login_required(groups=[USER, MONEY, GASTRO, BAR, VORSTAND], bar=True) +def _getStatus(**kwargs): + try: + debug.info("get status from user") + data = request.get_json() + name = data['name'] + debug.info("get status from user {{ {} }}".format(name)) + retVal = mainController.getStatus(name) + debug.info( + "return status from user {{ {} }} : {{ {} }}".format(name, retVal)) + return jsonify(retVal) + except Exception as err: + debug.warning("exception in get status from user.", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@app.route('/getUsers', methods=['GET']) +@login_required(groups=[USER], bar=True) +def _getUsers(**kwargs): + try: + extern = True + if 'extern' in request.args: + extern = not bool(int(request.args['extern'])) + debug.info("get all users from database") + users = mainController.getAllUsersfromDB(extern=extern) + debug.debug("users are {{ {} }}".format(users)) + retVal = [user.toJSON() for user in users] + debug.info("return all users from database {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.warning( + "exception in get all users from database.", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@app.route("/getLifeTime", methods=['GET']) +@login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) +def _getLifeTime(**kwargs): + try: + debug.info("get lifetime of accesstoken") if 'accToken' in kwargs: accToken = kwargs['accToken'] - accToken.user = mainController.getUser(accToken.user.uid) - retVal = accToken.user.toJSON() - retVal['creditList'] = {credit.year: credit.toJSON() - for credit in accToken.user.geruechte} - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception: - debug.debug("exception", exc_info=True) - return jsonify("error", "something went wrong"), 500 - - -@user.route("/user/addAmount", methods=['POST']) -@login_required(groups=[USER]) -def _addAmount(**kwargs): - debug.info("/user/addAmount") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - data = request.get_json() - amount = int(data['amount']) - date = datetime.now() - mainController.addAmount( - accToken.user.uid, amount, year=date.year, month=date.month) - accToken.user = mainController.getUser(accToken.user.uid) - retVal = accToken.user.toJSON() - retVal['creditList'] = {credit.year: credit.toJSON() - for credit in accToken.user.geruechte} - debug.debug("return {{ {} }}".format(retVal)) - creditL.info("{} {} {} fügt sich selbst {} € Schulden hinzu".format( - date, accToken.user.firstname, accToken.user.lastname, amount/100)) - return jsonify(retVal) - except Exception: - debug.debug("exception", exc_info=True) - return jsonify({"error": "something went wrong"}), 500 - - -@user.route("/user/saveConfig", methods=['POST']) -@login_required(groups=[USER]) -def _saveConfig(**kwargs): - debug.info("/user/saveConfig") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - data = request.get_json() - password = data['acceptedPassword'] - data.pop('acceptedPassword') - accToken.user = mainController.modifyUser( - accToken.user, data, password) - retVal = accToken.user.toJSON() - retVal['creditList'] = {credit.year: credit.toJSON() - for credit in accToken.user.geruechte} - debug.debug("return {{ {} }}".format(retVal)) + debug.debug("accessToken is {{ {} }}".format(accToken)) + retVal = {"value": accToken.lifetime, + "group": accToken.user.toJSON()['group'], + "lock_bar": accToken.lock_bar} + debug.info( + "return get lifetime from accesstoken {{ {} }}".format(retVal)) return jsonify(retVal) except Exception as err: - debug.debug("exception", exc_info=True) + debug.info("exception in get lifetime of accesstoken.", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@app.route("/saveLifeTime", methods=['POST']) +@login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) +def _saveLifeTime(**kwargs): + try: + debug.info("save lifetime for accessToken") + if 'accToken' in kwargs: + accToken = kwargs['accToken'] + debug.debug("accessToken is {{ {} }}".format(accToken)) + data = request.get_json() + lifetime = data['value'] + debug.debug("lifetime is {{ {} }}".format(lifetime)) + debug.info("set lifetime {{ {} }} to accesstoken {{ {} }}".format( + lifetime, accToken)) + accToken.lifetime = lifetime + debug.info("update accesstoken timestamp") + accToken = accesTokenController.updateAccessToken(accToken) + accToken = accesTokenController.validateAccessToken(accToken.token, [USER, EXTERN]) + retVal = {"value": accToken.lifetime, + "group": accToken.user.toJSON()['group']} + debug.info( + "return save lifetime for accessToken {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.warning( + "exception in save lifetime for accesstoken.", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@app.route("/passwordReset", methods=['POST']) +def _passwordReset(): + try: + debug.info('password reset') + data = request.get_json() + mail = mainController.resetPassword(data) + index = mail.find('@') + for i in range(index): + if i == 0: + continue + mail = mail.replace(mail[i], "*", 1) + return jsonify({"ok": "ok", "mail": mail}) + except Exception as err: + debug.warning("excetpion in password reset", exc_info=True) return jsonify({"error": str(err)}), 409 - -@user.route("/user/jobs", methods=['POST']) -@login_required(groups=[USER]) -def _getUsers(**kwrags): - debug.info("/user/jobs") - try: - data = request.get_json() - from_date = data['from_date'] - to_date = data['to_date'] - from_date = datetime( - from_date['year'], from_date['month'], from_date['day']) - to_date = datetime(to_date['year'], to_date['month'], to_date['day']) - lockedDays = mainController.getLockedDays(from_date, to_date) - retVal = [] - for lockedDay in lockedDays: - day = datetime.combine(lockedDay['daydate'], time(12)) - retDay = { - "worker": mainController.getWorker(day), - "day": { - "date": { - "year": day.year, - "month": day.month, - "day": day.day - }, - "locked": lockedDay['locked'] - }, - "jobkinddate": mainController.getJobKindDates(day.date()) - } - retVal.append(retDay) - - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/jobsOnDates", methods=['POST']) -@login_required(groups=[USER]) -def _getJobsOnDates(**kwargs): - debug.info("/user/jobsOnDates") - try: - data = request.get_json() - lockedDays = mainController.getLockedDaysFromList(data) - retVal = [] - for lockedDay in lockedDays: - day = datetime.combine(lockedDay['daydate'], time(12)) - retDay = { - "worker": mainController.getWorker(day), - "day": { - "date": { - "year": day.year, - "month": day.month, - "day": day.day - }, - "locked": lockedDay['locked'] - }, - "jobkinddate": mainController.getJobKindDates(day.date()) - } - retVal.append(retDay) - - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/job", methods=['POST']) -@login_required(groups=[USER]) -def _getUser(**kwargs): - debug.info("/user/job") - try: - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - lockedDay = mainController.getLockedDay(date) - if not lockedDay: - lockedDay = { - 'date': { - 'year': year, - 'month': month, - 'day': day - }, - 'locked': False - } - else: - lockedDay = { - 'date': { - 'year': year, - 'month': month, - 'day': day - }, - 'locked': lockedDay['locked'] - } - retVal = { - 'worker': mainController.getWorker(date), - 'day': lockedDay - } - debug.debug("retrun {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@user.route("/user/addJob", methods=['POST']) -@login_required(groups=[USER]) -def _addUser(**kwargs): - debug.info("/user/addJob") +@app.route("/logout", methods=['GET']) +@login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) +def _logout(**kwargs): try: + debug.info("logout user") if 'accToken' in kwargs: accToken = kwargs['accToken'] - user = accToken.user - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - job_kind = None - if 'job_kind' in data: - job_kind = data['job_kind'] - mainController.addWorker(user.uid, date, job_kind=job_kind, userExc=True) - retVal = mainController.getWorker(date) - debug.debug("return {{ {} }}".format(retVal)) - jobL.info("Mitglied {} {} schreib sich am {} zum Dienst ein.".format( - user.firstname, user.lastname, date.date())) - return jsonify(retVal) - except DayLocked as err: - debug.debug("exception", exc_info=True) - return jsonify({'error': str(err)}), 403 + debug.debug("accesstoken is {{ {} }}".format(accToken)) + debug.info("delete accesstoken") + accesTokenController.deleteAccessToken(accToken) + debug.info("return ok logout user") + return jsonify({"ok": "ok"}) except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({'error': str(err)}), 409 - - -@user.route("/user/deleteJob", methods=['POST']) -@login_required(groups=[USER]) -def _deletJob(**kwargs): - debug.info("/user/deleteJob") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - user = accToken.user - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - mainController.deleteWorker(user.uid, date, True) - retVal = mainController.getWorker(date) - debug.debug("return ok") - jobL.info("Mitglied {} {} entfernt sich am {} aus dem Dienst".format( - user.firstname, user.lastname, date.date())) - return jsonify(retVal) - except DayLocked as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 403 - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 409 - -@user.route("/user/storno", methods=['POST']) -@login_required(groups=[USER]) -def _storno(**kwargs): - """ Function for Baruser to storno amount - - This function added to the user with the posted userID the posted amount. - - Returns: - JSON-File with userID and the amount - or ERROR 401 Permission Denied - """ - - debug.info("/user/storno") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - user = accToken.user - data = request.get_json() - amount = int(data['amount']) - - date = datetime.now() - mainController.addCredit( - user.uid, amount, year=date.year, month=date.month) - accToken.user = mainController.getUser(accToken.user.uid) - retVal = accToken.user.toJSON() - retVal['creditList'] = {credit.year: credit.toJSON() - for credit in accToken.user.geruechte} - debug.debug("return {{ {} }}".format(retVal)) - creditL.info("{} {} {} storniert {} €".format( - date, user.firstname, user.lastname, amount/100)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) + debug.warning("exception in logout user.", exc_info=True) return jsonify({"error": str(err)}), 500 -@user.route("/user/getJobInvites", methods=['POST']) -@login_required(groups=[USER]) -def _getJobInvites(**kwargs): +@app.route("/login", methods=['POST']) +def _login(): + """ Login User + + Nothing to say. + Login in User and create an AccessToken for the User. + + Returns: + A JSON-File with createt Token or Errors + """ + debug.info("Start log in.") + data = request.get_json() + username = data['username'] + password = data['password'] + debug.debug("username is {{ {} }}".format(username)) try: - debug.info("/user/getJobInvites") - from_user = None - to_user = None - on_date = None - - data = request.get_json() - - if 'from_user' in data: - from_user = data['from_user'] - if 'to_user' in data: - to_user = data['to_user'] - on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) - retVal = mainController.getJobInvites(from_user, to_user, on_date) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) + user_agent = request.user_agent + debug.info("search {{ {} }} in database".format(username)) + user = mainController.loginUser(username, password) + debug.debug("user is {{ {} }}".format(user)) + token = accesTokenController.createAccesToken(user, user_agent=user_agent) + debug.debug("accesstoken is {{ {} }}".format(token)) + debug.info("validate accesstoken") + dic = accesTokenController.validateAccessToken( + token, [USER, EXTERN]).user.toJSON() + dic["token"] = token + dic["accessToken"] = token + debug.info("User {{ {} }} success login.".format(username)) + debug.info("return login {{ {} }}".format(dic)) + return jsonify(dic) + except PermissionDenied as err: + debug.warning("permission denied exception in logout", exc_info=True) + return jsonify({"error": str(err)}), 401 except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/JobInvites", methods=['PUT', 'POST']) -@login_required(groups=[USER]) -def _JobInvites(**kwargs): - try: - debug.info("/user/JobInvites") - data = request.get_json() - if request.method == 'PUT': - mainController.setJobInvites(data) - retVal = mainController.getJobInvites(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - if request.method == 'POST': - retVal = mainController.updateJobInvites(data) - - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/deleteJobInvite", methods=['POST']) -@login_required(groups=[USER]) -def _deleteJobInvite(**kwargs): - try: - debug.info("/user/deleteJobInvite") - data = request.get_json() - mainController.deleteJobInvite(data) - retVal = mainController.getJobInvites(data['from_user'], None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@user.route("/user/getJobRequests", methods=['POST']) -@login_required(groups=[USER]) -def _getJobRequests(**kwargs): - try: - debug.info("/user/getJobRequests") - from_user = None - to_user = None - on_date = None - - data = request.get_json() - - if 'from_user' in data: - from_user = data['from_user'] - if 'to_user' in data: - to_user = data['to_user'] - on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) - retVal = mainController.getJobRequests(from_user, to_user, on_date) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/JobRequests", methods=['PUT', 'POST']) -@login_required(groups=[USER]) -def _JobRequests(**kwargs): - try: - debug.info("/user/JobRequests") - data = request.get_json() - if request.method == 'PUT': - mainController.setJobRequests(data) - retVal = mainController.getJobRequests(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - if request.method == 'POST': - data['on_date'] = date(data['on_date']['year'], data['on_date']['month'], data['on_date']['day']) - retVal = mainController.updateJobRequests(data) - - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/deleteJobRequest", methods=['POST']) -@login_required(groups=[USER]) -def _deleteJobRequest(**kwargs): - try: - debug.info("/user/deleteJobRequest") - data = request.get_json() - mainController.deleteJobRequest(data) - retVal = mainController.getJobRequests(data['from_user'], None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@user.route("/user/getAccessTokens", methods=['GET', 'POST']) -@login_required(groups=[USER]) -def _getAccessTokens(**kwargs): - try: - debug.info("/user/getAccessTokens") - if request.method == 'POST': - data = request.get_json() - delAccToken = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) - accesTokenController.deleteAccessToken(delAccToken) - tokens = accesTokenController.getAccessTokensFromUser(kwargs['accToken'].user) - retVal = [] - for token in tokens: - retVal.append(token.toJSON()) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 + debug.warning("exception in logout.", exc_info=True) + return jsonify({"error": "permission denied"}), 401 diff --git a/flaschengeist/modules/user.py b/flaschengeist/modules/user/user.py similarity index 100% rename from flaschengeist/modules/user.py rename to flaschengeist/modules/user/user.py diff --git a/setup.py b/setup.py index b142102..1575c38 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup( install_requires=['Flask >= 1.0.2', 'PyYAML>=5.3.1', "flask_mysqldb", "flask_ldapconn", "flask_cors"], entry_points = { 'flaschengeist.plugins': [ - 'users = flaschengeist.system.user:register' + 'users = flaschengeist.modules.user:register' ] } ) From 7ac3813782cb6a8fb4876d920947866c6097e829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 20 Aug 2020 20:17:35 +0200 Subject: [PATCH 005/446] bugfix, free_drink_list_history wenn kein dienst Sollte der user, der diese liste abruft keinen dienst haben, wird nur die history der letzten halben stunde abgerufen --- .../databaseController/dbFreeDrinkListConfigController.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py index 269179c..e17723f 100644 --- a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py +++ b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py @@ -97,7 +97,11 @@ class Base: cursor = self.db.connection.cursor() now = datetime.now() worker = self.getWorker(user, now) - cursor.execute(f'select * from free_drink_list_history where timestamp>="{worker["startdatetime"]}" and (user_id={user.id} or free_drink_type_id=3)') + if worker: + timestamp = worker["startdatetime"] + else: + timestamp = datetime.now() - timedelta(minutes=30) + cursor.execute(f'select * from free_drink_list_history where timestamp>="{timestamp}" and (user_id={user.id} or free_drink_type_id=3)') retVal = cursor.fetchall() for data in retVal: data['timestamp'] = {'year': data['timestamp'].year, From 4a69d546605ff823bdf12b684f515a6bc7efc850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 20 Aug 2020 22:03:43 +0200 Subject: [PATCH 006/446] =?UTF-8?q?Logik=20f=C3=BCr=20FreeDrinkListHistory?= =?UTF-8?q?Workgroup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Es können nun auch Freigetränke mit Grund und Beschreibung des Grundes erstellt werden. Count wird erstmal vernachlässigt. --- .../dbFreeDrinkListConfigController.py | 27 ++++++++++++++++++- .../mainFreeDrinkListConfigController.py | 5 +++- geruecht/routes.py | 13 ++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py index e17723f..e7a7e3e 100644 --- a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py +++ b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py @@ -84,7 +84,11 @@ class Base: def set_free_drink_list_history(self, user, free_drink_list_config): try: cursor = self.db.connection.cursor() - cursor.execute(f'insert into free_drink_list_history (timestamp, free_drink_config_id, user_id, free_drink_type_id) values ("{datetime.now()}", {free_drink_list_config["id"]}, {user.id}, {free_drink_list_config["free_drink_type_id"]})') + if 'free_drink_list_reason_id' in free_drink_list_config and 'description' in free_drink_list_config: + sql = f'insert into free_drink_list_history (timestamp, free_drink_config_id, user_id, free_drink_type_id, free_drink_list_reason_id, description) values ("{datetime.now()}", {free_drink_list_config["id"]}, {user.id}, {free_drink_list_config["free_drink_type_id"]}, {free_drink_list_config["free_drink_list_reason_id"]}, "{free_drink_list_config["description"]}")' + else: + sql = f'insert into free_drink_list_history (timestamp, free_drink_config_id, user_id, free_drink_type_id) values ("{datetime.now()}", {free_drink_list_config["id"]}, {user.id}, {free_drink_list_config["free_drink_type_id"]})' + cursor.execute(sql) self.db.connection.commit() return self.get_free_drink_list_history_by_user(user) except Exception as err: @@ -112,6 +116,7 @@ class Base: 'second': data['timestamp'].second} data['free_drink_config'] = self.get_free_drink_list_config(data['free_drink_config_id']) data['free_drink_type'] = self.get_free_drink_list_type(data['free_drink_type_id']) + data['free_drink_list_reason'] = self.get_free_drink_list_reason(data['free_drink_list_reason_id']) if data['free_drink_list_reason_id'] else None return retVal except Exception as err: traceback.print_exc() @@ -134,6 +139,26 @@ class Base: cursor.execute(f'delete from free_drink_list_history where id={free_drink_list_history["id"]}') self.db.connection.commit() return True + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def get_free_drink_list_reason(self, id): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'select * from free_drink_list_reason where id={id}') + return cursor.fetchone() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def get_free_drink_list_reasons(self): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'select * from free_drink_list_reason') + return cursor.fetchall() except Exception as err: traceback.print_exc() self.db.connection.rollback() diff --git a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py index 52d5bac..3c999ad 100644 --- a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py +++ b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py @@ -22,4 +22,7 @@ class Base: def update_free_drink_list_history(self, user, data): db.update_free_drink_list_history(data) - return db.get_free_drink_list_history_by_user(user) \ No newline at end of file + return db.get_free_drink_list_history_by_user(user) + + def get_free_drink_list_reasons(self): + return db.get_free_drink_list_reasons() \ No newline at end of file diff --git a/geruecht/routes.py b/geruecht/routes.py index f6fa5da..3135f70 100644 --- a/geruecht/routes.py +++ b/geruecht/routes.py @@ -69,6 +69,18 @@ def _delete_free_drink_list_history(**kwargs): except Exception as err: debug.warning("exception in delete free_dirnk_list_config.", exc_info=True) return jsonify({"error": str(err)}), 500 + +@app.route("/freeDrinkListReasons", methods=['GET']) +@login_required() +def _free_drink_list_reasons(**kwargs): + try: + debug.info("get free_drink_list_reasons") + retVal = mainController.get_free_drink_list_reasons() + return jsonify(retVal) + except Exception as err: + debug.warning("exception in delete free_dirnk_list_reasons.", exc_info=True) + return jsonify({"error": str(err)}), 500 + @app.route("/pricelist", methods=['GET']) def _getPricelist(): try: @@ -80,7 +92,6 @@ def _getPricelist(): debug.warning("exception in get pricelist.", exc_info=True) return jsonify({"error": str(err)}), 500 - @app.route('/drinkTypes', methods=['GET']) def getTypes(): try: From ec0bd12caac4c0eba8cc09f52642e345c7c5a1dd Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 21 Aug 2020 13:55:42 +0200 Subject: [PATCH 007/446] Remove vim files from repository --- .gitignore | 3 ++- flaschengeist/.app.py.swo | Bin 12288 -> 0 bytes flaschengeist/.app.py.swp | Bin 12288 -> 0 bytes flaschengeist/modules/baruser/.routes.py.swp | Bin 16384 -> 0 bytes .../system/controller/.ldapController.py.swp | Bin 16384 -> 0 bytes setup.py | 2 +- 6 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 flaschengeist/.app.py.swo delete mode 100644 flaschengeist/.app.py.swp delete mode 100644 flaschengeist/modules/baruser/.routes.py.swp delete mode 100644 flaschengeist/system/controller/.ldapController.py.swp diff --git a/.gitignore b/.gitignore index ed93d09..293d4a7 100644 --- a/.gitignore +++ b/.gitignore @@ -117,7 +117,8 @@ dmypy.json #ide .idea - +.swp +.swo .vscode/ *.log diff --git a/flaschengeist/.app.py.swo b/flaschengeist/.app.py.swo deleted file mode 100644 index 37fe53c75a232de5f8059fc7e0cb72af1ce8965d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeHNON`q@7@krA<?XC7mZ}tM5>Ffx$2PXJ zRf=dOI3QGnkl@aN19xuRcpMN{E}TH(R`HTJaYKAFj;Zt5l{glSrB8`xzW?_>-#_zD ztW109N_B^>mzD^wrwQ5o@Fg?%hjrw++k|v^XgMD9EYWws<@z1w_WUR=$#0fFV2(G3 zfBRpDixs~=9B?n@i;d8Ko%h9;QQY&r#q%zUIz8^$+==2vkKeR;IOLsPyx5)SVL?y| zMsuE`nv?=cff)scad%<;C^@sTESLJp+za&CXIe8f)u0qm3Md7X0!jg;fKosypcGIF z{C^dQgG1yFhWF5Th!@A7ho(Nqx9USFpcGIFCr>Y;5cyaNkYB=?gFm?72p_f1o+_z^Z|E(SAhlKug3}b7Wf$W z2)GS+zyNp!xC*=soB$33-#kXhSHPFRr@$U?0ieL2M+o^Ecptb6*uVuK2mA^>egVD% zJ_FtZY@iGXtqud~QVJ*qlmbeD2U>xX;wV|jZJambq1kqaJO~{xE`)sl9zhOS?D$@h z?8mYhIQ}r=;r^UGKa66Wu-l(;uzgpY&%1Td6WT0HTo8(DpP^b#)baOt$SpH)hqmKI z*F84iHwwj~G^=RatqUc`>-vS9K30mJigNT66}%$p`l0+r9gogB+a$9NpdP0}?6wd) z$`uJY*$w>xP0#y{Q5!5E67rTz5M&Sfwi)usABG(sl^ijbalkShnSn0^ zF61Z9PnO1c&L;^L`OC1S&&+*EWO*~oz)??T6^djw+4M#FCVmelnVJZS5UY*eosn_5 zR=`Xw64zn@T56T8M%nkgq>+e8+VQ=vV_(mouj^&a+-f%U>UOKCnOC-IE!-Pst!gy$ zH(=bv@z4nq;my>w%dM)enPp8+Xi?;r#ErRI-_>UM&6;6e(R8C)-<4hky`k5e^>V#t zqH}NAT$#0M8JcMrHEAKnfmXRo&uQ;8!JPad0%+H}$wxwsQohHpAAk&vo8_*Q<5CGG!C6 zD7tCQwZPm+V!!_cQ7YzgFV@GC`961clu7E%OWHLVM1F~_t)6Rdoa5cqvuhUHSZc3W z?TzL2wH0QaUqFarID8;f8i-OTN zJJ_Pqq9goe`qF0U7VmDEU83qavBO;F7N=W50GF~8A=o^w@b=KI`8Fy&Do9!?u;MbV zO))`G1ibVaX^qHo2BYW=7d2&EY-C|c`b*IWyZJzr5$wUP%fpnEDttP#d?syN8ClAo zjF;qcIlMgL$_x-I*{6&~4i2rmvF}k)P2(O$-a#pD`hD(Uj)^!B1~z+&Eg!p>U^IIn zXI5#(V-TFCLip1(ot4tC-3doQObg-xa_ae^)nic)PcBLVvn*~=2hM%zA8B`;j{Kog V!i#M#M-3igqhAPEr_XFo{sETdZwLSY diff --git a/flaschengeist/.app.py.swp b/flaschengeist/.app.py.swp deleted file mode 100644 index cfc7fadd141df886a10b934aec5c3081972d59fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI2O^n+_6vw9^O8IC(Ahp*aRh0y7lkK+Mc2@#5o47P;x=HP%rQ*Yy*ptM>vCY`o zDy?WGK;lpdf#A-S3m0x&xbY!zX# z*t$t8pcQzO0td;-wG(QqAI-f)UwonSCmKr5gX&MLhgd! zz^~v7kbn!|Fu40HA>V*I;B8O?hrj{w%QNT$x51m>IQa8vLVf_BgU`S%5P%VQ9b5(H zz!C5``0*)1z6aldFTqXF0LQ=|2M`N<3O)foXn-#Q+)4W-x`zr{m zq;!CUyr(7xvPZ+dEqEM`g~Q{LCzm8nS&E4rg;L-`{=Tr&S;?n!DyMG7P=%ZMTngBT zZ_Zlas3&s^MY1^0^c8v&-v`r4+LaF>ZWq0mCf4Of0jsP?e3wONY1AsMY8V7*BasWx z34@;3eCGf^!?kLwC8zXtrrw?$k}gt{P@) z7RP=`ez3Qj+s2}OyJ6W^4AZJNw^dYW-ZGo*X0_R{(YbTNUR(6)ScYv`4do%nf}O z=BCn7zROaP9h-{^w3^fDm`xyUTr{=}6H~GvZpVb+yR0u=!-}%29n->UX;iFK;e5?= zlImE*p~x>WuWhNwZa14WmnE#rV!kwZ3sJ8&&Dxw##Nzn6yVL_~BhCH69b}bQYI~tM zUCa+`7e|>+y?xQRrjp37vh~wvx*KPB@ARp4mu;+e*WB*LiL>i#%ss`|Hu8j2o2Hd+ zDl9VW6?wRNSg>umoo&0gt0HfWJ>R93R!df4RfK!e+IH~x$?A=aH`A9jtFZX(CLavb z`d$yb#ACj9gVRbBA*AX=3Voi`cz4`ygniT^RFJGxV8_jbw&emr5eTw-WHn;eW;BW4 z^-)u%#YPpDw7(Qj@H`&LGJ@x;?{kqEWeTs%Y@gjezRE1K-<>bX<#I&ru9*R1Cx?{L z*u$Z9KM4aWt7$U8$Q@MSb~xk#)|ku#Y2ZOl@i@l=Oj@)Df-|?Y5HX5Ys1$yMW~)*; zb{#Q^5?YWqP)jcq?tsMu+_|U>nCo(vdI%n>_?ULzbJUlR5|PPMiwiuKk4MZIvOXt& E1Ck0(?*IS* diff --git a/flaschengeist/modules/baruser/.routes.py.swp b/flaschengeist/modules/baruser/.routes.py.swp deleted file mode 100644 index c213588c08c951d559f910f86882f27ec41e8e14..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeHOO^h5z6)uusAYdor#{xy5a_2DP(C%FmgDi_7UT4>d(f(<7ZDB2&p|`tcr!zg> zLwAoitJT5@0+Bc&!WD#s03o=*_o}-3Z)UAQvJ?rb zrEj~dUcJ{}T#qvL*M}~2PbAMi+Ig6GHFkl!k3>XFs1BL;^fMMW&n1O8cAp0`f_dwpp=kohI8ux$am&o(G4f%t4`lI>% zrww_YG&jS5VZbn87%&VN1`Gp+0mFb{z%XDKFbo(5-UbF-Z0>&q@BI!f__6=5*Z*%k z!r03|0(=_y81Va3jC~Kd1&o2u1DAoPfYZQ-fPbB2>`maCz&C)Gf!lxwtO4f$8~DTf z7<&u&74Q-ufF0l}&;e}VpYLVtcfe18uK-^Fo&-(~E^rfg z3iuT8LEvu>p&alUa2L1*H~<5FhQP>OAOfxd7l01~4+DQj(B%!_HQ?(2^})Aw;APra z;Q8eT-gXP;B(iQQMAX{h9e!vX2sg1VaXJ~{6f%c$ZEg+YDCKlGz}_5sXPym5&0c@Z!jyETHm_c+hK9n2RtKd^L^)tF<;e7zx=>dZ>%sVLXm9 zDRjfIDg!T)jx7G{=GtoeY#}4lWC#UXxq2a^3S25u)skK!{2*%;GE^QfGj8e3yaAcuU zN533uviYdPGx;H!HjM|RL{Fs-6-0SDx$`v4Cvg~J96|Fs1Z|RDTP#ZxD=N?Csx3CJ zTeekOD)qBD&h2!zaG0mG(|Dch`wOx&+XcteKGchDSuhk%6z@;)NQA3e6lw**L6o(j z>~8CRu5hbbpsb^SO!}>z!U*X>nusrs1r9fXjPCPktot!b4pguEVt2gf1W_+;TXSe5 zT4AXsT9#E9>I`uL*9yNZGK6Hg5G-DEF4dx>2RuyU1B~$Ls?@Q^Ick#WYtm_J3Ej1YrUS2*nd;1Sfn0c|=IOO? zCXY{rVWn`*_&i5(CMP=O$Ak(MC8jvbEwYc%npS@-yJ~|dd~9V!g@`_az_mDm2fOZ3C*IcJQI$x+od0G zw~NZDpVTr+V#GWxC&yWwApM_`EYcjQ5HK22#Yo4gaZ=#Ha1n_+zC>h-xsqx3NLMW{<+`eE~xwbiAYHov~Pgv5=tjm@ov)kT{xeQs-MV^wME zU!B&|X;xHN9Z+|$)X@{?sa-~c*1MNhqu)?|c+c|Gg=NRl+$8i=oqPDk4|Ii#KD6~* z*4EP{M(h7Ytfzki(E4BR-~Slv{a1mPfENKDcoujJ_y^YcZvo!|?f@}BF@RIRN#M6w z-+vdl3w#wg1a1Nsfz!aBu(p2%SO!i2Z|3j+HPyjfh5^HXVZbn87%&VN1`Gp+0mHz5 z#{eyY8-dBg)-4xHxfTLNo+sCm^v}8jiq;;f7(z8#*(#%zmefOlNro+3I&3*%r4q!{ zGA#Y3t%Dp`3}_LxK&w}^ux)D{C!=+)&9~KxOs&HzF?(I^INRgVahA(SiMCNN0AtX? z7J%Yt)*S%EbM)ST3Q+S-hg%e(wp4sYzX|(I zBv1mG_bZqnjbr&i_@% diff --git a/flaschengeist/system/controller/.ldapController.py.swp b/flaschengeist/system/controller/.ldapController.py.swp deleted file mode 100644 index 673b1b5c5fdeed0ca0996ea6fafe1af1db199463..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeHO&5z_p6)%#IKuGvlkU$$Eie3r3rC!gFfCLlzFxl?St~4_{nvYGQ&d~C-tGk`J z?Z$TRY-<<-8;KK$NCfx?kPw$#B1K$)Ln6ch1QJL@2&9CF+&G}XArb<9ugc}G_Uuen zNF3Ide&e>Q>eYL{SFc?4?Ac$wdU>6H!CGcGKFrwLcYUt^z&Eab;>H=qh9Y!a-}W6U zyUF1N1KS%1QEbUyPB5`uzoP!vQh`gI;F;M(__0{p41=e|GxTE~j{|?{qGv~gvG7O2 zjp8L=9E?Ob6N7QQl>Uh3Q7k4)gTRl&!1F}7{ALV9UU=M}xzw`vA%cLwff4?px- z8S3X6pXMJuvwOP}QrllMP%}_7P%}_7P%}_7P%}_7a9c4DPw!;U!s0s;tGAQ$drIg3 zB*$iQf2UOb@1(q$-2Y*zJh`k-H3KyRH3KyRH3KyRH3KyRH3KyRH3KyRH3KyR{|5%_ zl0$fhO?B51Uf(06qnr0p5HcW0!!tfnVRl*vr6mpbMM@-oBf$zXHDmegHfLJOq3UxC40oy^Q@9 zcoujBSOLBWd>r`QU5xz#co8sxU)>4afyaS!z*|V7JO$hX{0w|P4@h1?Ew4C25nr`E z%bre!@0h#W-L2+AJ$J}QVKAHaeS0E!yUh*y*5Ujt5}~oOP}>pvvymnLnhn{G%XV(W z4-fg_5kER=Si>Nk*s+<3Z7z1374kNZgg3OPTMM6D_+k*dfp3K(ng)I(ddqt~!;a(7 z-JiuGGH^BU@ppM|LAy#hk+8$T*ffG!TXxzyJTkP3vVr3_Tih^5uCpmUhXAhsP6di;53ss9X3T~si z%IDmdCXv(y?=46yIdP%<7W|k}ZWMiak{@Ys9_PlH>l7SFb!mvgZ`-?%?re3tB?sn| zDEd)F56tRi+Qs2KV^*>1`ojRRAmW76VHiv}^}5Jus*CE#5r{nzKStqalFcpIPHMYS z3hB^xBf&RCIB}y0;n5MkD;%@25%68=OI*jWi1>jUk9jo^nJ+MRH^+#qy zTpx%jg&L>v0R5%Xjvd=v+CTx>XhHIzPd2u9@R?L5P_@p;pb<}pQnagOn^_Ag6<#DN z+K6}-`dkstsGdGBwAJ070fUS^nxHg`MLC5bE-k4V)k;`tC{t&;7Yu8Ux{Hq;wf^E~zNKbs%OVR$}d{Ne(R z!Cy5viUYjBic(2Bo?=V}R95|_bBQFW7>hhm!|t^6$}U*wAet+t7CkcIS_(W;tWS^L zl;wgMX>sL)A}!6iIlWi)P%z0h%kf)$YDdvQ5IPwrs$Vsw?z_IDxYG|O9tU|6Y5t^2 zIRkStF@tI|>3rM-dfC5bd#=Mb6J2-^${Cdc(ZY%*UCxNGJdl;L74zxA*!2YWf)SkV z`daA|rc$2HncR*zdMi#Vw`xP_89-tR&b4PTK8AIN?!Z>hf(R-Pf-uA~tkf3Mo*%@? zR9V!nLS&rDOcmBTMNxHq4ZNWT2%3 ztQe&@W%uB@~)TmD;jmDp( zYH}MNFAv>O&**IA)>^7t6>YBX#!>*sM38*;ocNz;=wl7w#{Irzf~=-)48xG;RXK)o z4iBzjNwU^i-L&|HP{3n+bub8Ke#`^^JjUDkp*K$@4Dj5GWNj#!^lVN`U`G+CrTsmRo9} zgJlu|Ly4n_m4-o=)?8#|OKMN?8&6SD39xka)PNHOmzQ^?29v-Mo|d|PWuyCKi_@mA z#UESU-r2g+;*YN4;_8*H?VZ()PK$S+*y(Oy zdHqV~^2I0nTiwmI)eBfZUs~P1)a~@QSJ!rMTJLI(AH=@+&j9WJ;VOB& ziv9i#;1S?o*w;S~di0@?rv?gie!KK}cF2b=}| zjy?R3fE&OH@Dbpr*t<`GPXhnM-u?H$*MVhV39x|kz!~73MBhJ=&h=3Sc*EF8S-p3P$TARMQM{{9Cm`}3y3@y{x zD;kZVkd=`Wv($QVe@UGyOVR{gg#}q_oti5fK^;m7r|XOoa4_FK)6 z@=T}bIZFdpcAXWUuJ0tpdXl{~m}viFRCbz{-mFjZC5SyNCQHu^)MgQ-h(!@Ha(nqu zlfrI!4`w#>EpB@d1Nli#JeeCRVuM7hxJ#2bTKkfaj4ca;dBP)KDR=c{9rC(1=CVUSIiBri=*s+SR& z>hEeI({JcVVqzzyAyl`!xS31Iv?TNS8Am55h>|47sZvVC=Zp4TQZ4tc^f04FCF51v z$H*dTg{QxfdrE_Rh3lV3i{8=w=_-f-vakGlGfDe1UA=rxJ3gj;ZVD;CxzM8Ed`jiX j91&7G5ixq%Uqe2_R74eCMd?=gGbv7_9HWDN&B6W)UW#}7 diff --git a/setup.py b/setup.py index 1575c38..09cdb58 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( packages=find_packages(), package_data={'': ['*.yml']}, scripts=['run_flaschengeist'], - install_requires=['Flask >= 1.0.2', 'PyYAML>=5.3.1', "flask_mysqldb", "flask_ldapconn", "flask_cors"], + install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', "flask_mysqldb", "flask_ldapconn", "flask_cors"], entry_points = { 'flaschengeist.plugins': [ 'users = flaschengeist.modules.user:register' From 2f2fdacca225ce4692dc656aae3c241d65f06633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 21 Aug 2020 21:59:39 +0200 Subject: [PATCH 008/446] Get FreeDrinkTypes und Bugfix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle Freigetränktypen können nun geladen werden. Es wurde ein Bug gefixed, sodass die Preisliste wieder geladen werden kann. --- .../databaseController/dbPricelistController.py | 2 +- .../mainFreeDrinkListConfigController.py | 5 ++++- geruecht/routes.py | 11 +++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/geruecht/controller/databaseController/dbPricelistController.py b/geruecht/controller/databaseController/dbPricelistController.py index 7d00117..11f23d1 100644 --- a/geruecht/controller/databaseController/dbPricelistController.py +++ b/geruecht/controller/databaseController/dbPricelistController.py @@ -11,7 +11,7 @@ class Base: retVal = cursor.fetchall() for data in retVal: data['drink_type'] = self.getDrinkType(data['type']) - return cursor.fetchall() + return retVal except Exception as err: traceback.print_exc() self.db.connection.rollback() diff --git a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py index 3c999ad..ac271e5 100644 --- a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py +++ b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py @@ -25,4 +25,7 @@ class Base: return db.get_free_drink_list_history_by_user(user) def get_free_drink_list_reasons(self): - return db.get_free_drink_list_reasons() \ No newline at end of file + return db.get_free_drink_list_reasons() + + def get_free_drink_types(self): + return db.get_free_drink_list_types() \ No newline at end of file diff --git a/geruecht/routes.py b/geruecht/routes.py index 3135f70..2208f94 100644 --- a/geruecht/routes.py +++ b/geruecht/routes.py @@ -81,6 +81,17 @@ def _free_drink_list_reasons(**kwargs): debug.warning("exception in delete free_dirnk_list_reasons.", exc_info=True) return jsonify({"error": str(err)}), 500 +@app.route("/freeDrinkTypes", methods=['GET', 'POST']) +@login_required() +def _free_drink_types(**kwargs): + try: + debug.info("get free_drnik_types") + retVal = mainController.get_free_drink_types() + return jsonify(retVal) + except Exception as err: + debug.warning("exception in free_dirnk_types.", exc_info=True) + return jsonify({"error": str(err)}), 500 + @app.route("/pricelist", methods=['GET']) def _getPricelist(): try: From 187dc407303c5ce2277f08181b1835aaa1ac5b02 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 22 Aug 2020 14:02:39 +0200 Subject: [PATCH 009/446] Use flask logger, fixing app creation, split geruecht and user --- .gitignore | 4 +- flaschengeist/__init__.py | 81 ++++++++++++++++++- flaschengeist/app.py | 72 ----------------- flaschengeist/system/configparser.py | 47 +++++------ flaschengeist/system/database.py | 3 + flaschengeist/system/decorator.py | 15 ++-- flaschengeist/system/logger.py | 32 -------- flaschengeist/system/model/user.py | 49 ----------- .../system/{model => models}/__init__.py | 0 .../system/{model => models}/accessToken.py | 47 ++++------- flaschengeist/system/models/user.py | 63 +++++++++++++++ run_flaschengeist | 4 +- setup.py | 9 ++- 13 files changed, 204 insertions(+), 222 deletions(-) delete mode 100644 flaschengeist/app.py create mode 100644 flaschengeist/system/database.py delete mode 100644 flaschengeist/system/logger.py delete mode 100644 flaschengeist/system/model/user.py rename flaschengeist/system/{model => models}/__init__.py (100%) rename flaschengeist/system/{model => models}/accessToken.py (60%) create mode 100644 flaschengeist/system/models/user.py diff --git a/.gitignore b/.gitignore index 293d4a7..71796df 100644 --- a/.gitignore +++ b/.gitignore @@ -117,8 +117,8 @@ dmypy.json #ide .idea -.swp -.swo +*.swp +*.swo .vscode/ *.log diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 2cbd85f..1a29c59 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -1,5 +1,84 @@ """ Server-package -""" + Initialize app, cors, database and bcrypt (for passwordhashing) and added it to the application. + Initialize also a singelton for the AccesTokenControler and start the Thread. + +""" from pathlib import Path _modpath = Path(__file__).parent + +from flask import Flask +from flask_cors import CORS +from flask_ldapconn import LDAPConn +import ssl +import pkg_resources, yaml + +from logging.config import dictConfig + +with (_modpath/'logging.yml').open(mode='rb') as file: + config = yaml.safe_load(file.read()) + dictConfig(config) + +def create_app(): + app = Flask(__name__) + CORS(app) + + with app.app_context(): + from .system.controller import dbConfig, ldapConfig + from .system.database import db + app.config['SECRET_KEY'] = '0a657b97ef546da90b2db91862ad4e29' + + app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://{user}:{passwd}@{host}/{database}'.format( + user=dbConfig['user'], + passwd=dbConfig['passwd'], + host=dbConfig['URL'], + database=dbConfig['database']) + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +# app.config['MYSQL_CURSORCLASS'] = 'DictCursor' + app.config['LDAP_SERVER'] = ldapConfig['URL'] + app.config['LDAP_PORT'] = ldapConfig['PORT'] + if ldapConfig['BIND_DN']: + app.config['LDAP_BINDDN'] = ldapConfig['BIND_DN'] + else: + app.config['LDAP_BINDDN'] = ldapConfig['DN'] + if ldapConfig['BIND_SECRET']: + app.config['LDAP_SECRET'] = ldapConfig['BIND_SECRET'] + app.config['LDAP_USE_TLS'] = False + app.config['LDAP_USE_SSL'] = ldapConfig['SSL'] + app.config['LDAP_TLS_VERSION'] = ssl.PROTOCOL_TLSv1_2 + app.config['LDAP_REQUIRE_CERT'] = ssl.CERT_NONE + app.config['FORCE_ATTRIBUTE_VALUE_AS_LIST'] = True + + app.config['FG_AUTH_BACKENDS'] = [ + entry_point.load() for entry_point in pkg_resources.iter_entry_points('flaschengeist.auth') + ] + + ldap = LDAPConn(app) + db.init_app(app) + + discovered_plugins = { + entry_point.name: entry_point.load() + for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin') + } + +#from geruecht import routes +#from geruecht.baruser.routes import baruser +#from geruecht.finanzer.routes import finanzer +#from geruecht.user.routes import user +#from geruecht.vorstand.routes import vorstand +#from geruecht.gastro.routes import gastrouser +#from geruecht.registration_route import registration + + app.logger.info("Registrate bluebrints") + for name in discovered_plugins: + app.logger.info("Register plugin: %s" % name) + app.register_blueprint(discovered_plugins[name]()) + + return app +#app.register_blueprint(baruser) +#app.register_blueprint(finanzer) +#app.register_blueprint(user) +#app.register_blueprint(vorstand) +#app.register_blueprint(gastrouser) +#app.register_blueprint(registration) diff --git a/flaschengeist/app.py b/flaschengeist/app.py deleted file mode 100644 index a2ec07c..0000000 --- a/flaschengeist/app.py +++ /dev/null @@ -1,72 +0,0 @@ -""" Server-package - - Initialize app, cors, database and bcrypt (for passwordhashing) and added it to the application. - Initialize also a singelton for the AccesTokenControler and start the Thread. - -""" -from .system.logger import getDebugLogger -from .system.controller import dbConfig, ldapConfig -from flask_mysqldb import MySQL -from flask_ldapconn import LDAPConn -import ssl - -DEBUG = getDebugLogger() -DEBUG.info("Initialize App") - -from flask import Flask -from flask_cors import CORS - -DEBUG.info("Build APP") -app = Flask(__name__) -CORS(app) - -app.config['SECRET_KEY'] = '0a657b97ef546da90b2db91862ad4e29' -app.config['MYSQL_HOST'] = dbConfig['URL'] -app.config['MYSQL_USER'] = dbConfig['user'] -app.config['MYSQL_PASSWORD'] = dbConfig['passwd'] -app.config['MYSQL_DB'] = dbConfig['database'] -app.config['MYSQL_CURSORCLASS'] = 'DictCursor' -app.config['LDAP_SERVER'] = ldapConfig['URL'] -app.config['LDAP_PORT'] = ldapConfig['PORT'] -if ldapConfig['BIND_DN']: - app.config['LDAP_BINDDN'] = ldapConfig['BIND_DN'] -else: - app.config['LDAP_BINDDN'] = ldapConfig['DN'] -if ldapConfig['BIND_SECRET']: - app.config['LDAP_SECRET'] = ldapConfig['BIND_SECRET'] -app.config['LDAP_USE_TLS'] = False -app.config['LDAP_USE_SSL'] = ldapConfig['SSL'] -app.config['LDAP_TLS_VERSION'] = ssl.PROTOCOL_TLSv1_2 -app.config['LDAP_REQUIRE_CERT'] = ssl.CERT_NONE -app.config['FORCE_ATTRIBUTE_VALUE_AS_LIST'] = True - -ldap = LDAPConn(app) -db = MySQL(app) - -import pkg_resources - -discovered_plugins = { - entry_point.name: entry_point.load() - for entry_point - in pkg_resources.iter_entry_points('flaschengeist.plugins') -} - -#from geruecht import routes -#from geruecht.baruser.routes import baruser -#from geruecht.finanzer.routes import finanzer -#from geruecht.user.routes import user -#from geruecht.vorstand.routes import vorstand -#from geruecht.gastro.routes import gastrouser -#from geruecht.registration_route import registration - -DEBUG.info("Registrate bluebrints") -for name in discovered_plugins: - DEBUG.info("Register %s" % name) - app.register_blueprint(discovered_plugins[name]()) - -#app.register_blueprint(baruser) -#app.register_blueprint(finanzer) -#app.register_blueprint(user) -#app.register_blueprint(vorstand) -#app.register_blueprint(gastrouser) -#app.register_blueprint(registration) diff --git a/flaschengeist/system/configparser.py b/flaschengeist/system/configparser.py index 1fbe90c..94297e4 100644 --- a/flaschengeist/system/configparser.py +++ b/flaschengeist/system/configparser.py @@ -1,7 +1,8 @@ import yaml import sys -from .logger import getDebugLogger -DEBUG = getDebugLogger() +from flask import current_app +from werkzeug.local import LocalProxy +logger = LocalProxy(lambda: current_app.logger) default = { 'AccessTokenLifeTime': 1800, @@ -30,7 +31,7 @@ class ConifgParser(): 'Wrong Configuration for Database. You should configure databaseconfig with "URL", "user", "passwd", "database"') self.db = self.config['Database'] - DEBUG.debug("Set Databaseconfig: {}".format(self.db)) + logger.debug("Set Databaseconfig: {}".format(self.db)) if 'LDAP' not in self.config: self.__error__( @@ -39,79 +40,79 @@ class ConifgParser(): self.__error__( 'Wrong Configuration for LDAP. You should configure ldapconfig with "URL" and "BIND_DN"') if 'PORT' not in self.config['LDAP']: - DEBUG.info( + logger.info( 'No Config for port in LDAP found. Set it to default: {}'.format(389)) self.config['LDAP']['PORT'] = 389 if 'ADMIN_DN' not in self.config['LDAP']: - DEBUG.info( + logger.info( 'No Config for ADMIN_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) ) self.config['LDAP']['ADMIN_DN'] = None if 'ADMIN_SECRET' not in self.config['LDAP']: - DEBUG.info( + logger.info( 'No Config for ADMIN_SECRET in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) ) self.config['LDAP']['ADMIN_SECRET'] = None if 'USER_DN' not in self.config['LDAP']: - DEBUG.info( + logger.info( 'No Config for USER_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) ) self.config['LDAP']['USER_DN'] = None if 'BIND_DN' not in self.config['LDAP']: - DEBUG.info( + logger.info( 'No Config for BIND_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) ) self.config['LDAP']['BIND_DN'] = None if 'BIND_SECRET' not in self.config['LDAP']: - DEBUG.info( + logger.info( 'No Config for BIND_SECRET in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) ) self.config['LDAP']['BIND_SECRET'] = None if 'SSL' not in self.config['LDAP']: - DEBUG.info( + logger.info( 'No Config for SSL in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(False) ) self.config['LDAP']['SSL'] = False else: self.config['LDAP']['SSL'] = bool(self.config['LDAP']['SSL']) self.ldap = self.config['LDAP'] - DEBUG.info("Set LDAPconfig: {}".format(self.ldap)) + logger.debug("Set LDAPconfig: {}".format(self.ldap)) if 'AccessTokenLifeTime' in self.config: self.accessTokenLifeTime = int(self.config['AccessTokenLifeTime']) - DEBUG.info("Set AccessTokenLifeTime: {}".format( + logger.info("Set AccessTokenLifeTime: {}".format( self.accessTokenLifeTime)) else: self.accessTokenLifeTime = default['AccessTokenLifeTime'] - DEBUG.info("No Config for AccessTokenLifetime found. Set it to default: {}".format( + logger.info("No Config for AccessTokenLifetime found. Set it to default: {}".format( self.accessTokenLifeTime)) if 'Mail' not in self.config: self.config['Mail'] = default['Mail'] - DEBUG.info('No Conifg for Mail found. Set it to defaul: {}'.format( + logger.info('No Conifg for Mail found. Set it to defaul: {}'.format( self.config['Mail'])) if 'URL' not in self.config['Mail']: self.config['Mail']['URL'] = default['Mail']['URL'] - DEBUG.info("No Config for URL in Mail found. Set it to default") + logger.info("No Config for URL in Mail found. Set it to default") if 'port' not in self.config['Mail']: self.config['Mail']['port'] = default['Mail']['port'] - DEBUG.info("No Config for port in Mail found. Set it to default") + logger.info("No Config for port in Mail found. Set it to default") else: self.config['Mail']['port'] = int(self.config['Mail']['port']) - DEBUG.info("No Conifg for port in Mail found. Set it to default") + logger.info("No Conifg for port in Mail found. Set it to default") if 'user' not in self.config['Mail']: self.config['Mail']['user'] = default['Mail']['user'] - DEBUG.info("No Config for user in Mail found. Set it to default") + logger.info("No Config for user in Mail found. Set it to default") if 'passwd' not in self.config['Mail']: self.config['Mail']['passwd'] = default['Mail']['passwd'] - DEBUG.info("No Config for passwd in Mail found. Set it to default") + logger.info("No Config for passwd in Mail found. Set it to default") if 'email' not in self.config['Mail']: self.config['Mail']['email'] = default['Mail']['email'] - DEBUG.info("No Config for email in Mail found. Set it to default") + logger.info("No Config for email in Mail found. Set it to default") if 'crypt' not in self.config['Mail']: self.config['Mail']['crypt'] = default['Mail']['crypt'] - DEBUG.info("No Config for crypt in Mail found. Set it to default") + logger.info("No Config for crypt in Mail found. Set it to default") self.mail = self.config['Mail'] - DEBUG.info('Set Mailconfig: {}'.format(self.mail)) + logger.debug('Set Mailconfig: {}'.format(self.mail)) def getLDAP(self): return self.ldap @@ -126,7 +127,7 @@ class ConifgParser(): return self.mail def __error__(self, msg): - DEBUG.error(msg, exc_info=True) + logger.error(msg, exc_info=True) sys.exit(-1) diff --git a/flaschengeist/system/database.py b/flaschengeist/system/database.py new file mode 100644 index 0000000..f0b13d6 --- /dev/null +++ b/flaschengeist/system/database.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index fe9fb58..d948a50 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -1,6 +1,7 @@ from functools import wraps -from .logger import getDebugLogger -DEBUG = getDebugLogger() +from flask import current_app +from werkzeug.local import LocalProxy +logger = LocalProxy(lambda: current_app.logger) def login_required(**kwargs): @@ -14,24 +15,24 @@ def login_required(**kwargs): groups = kwargs["groups"] if "bar" in kwargs: bar = kwargs["bar"] - DEBUG.debug("groups are {{ {} }}".format(groups)) + logger.debug("groups are {{ {} }}".format(groups)) def real_decorator(func): @wraps(func) def wrapper(*args, **kwargs): token = request.headers.get('Token') - DEBUG.debug("token is {{ {} }}".format(token)) + logger.debug("token is {{ {} }}".format(token)) accToken = accessController.validateAccessToken(token, groups) - DEBUG.debug("accToken is {{ {} }}".format(accToken)) + logger.debug("accToken is {{ {} }}".format(accToken)) kwargs['accToken'] = accToken if accToken: - DEBUG.debug("token {{ {} }} is valid".format(token)) + logger.debug("token {{ {} }} is valid".format(token)) if accToken.lock_bar and not bar: return jsonify({"error": "error", "message": "permission forbidden"}), 403 return func(*args, **kwargs) else: - DEBUG.warning("token {{ {} }} is not valid".format(token)) + logger.warning("token {{ {} }} is not valid".format(token)) return jsonify({"error": "error", "message": "permission denied"}), 401 return wrapper diff --git a/flaschengeist/system/logger.py b/flaschengeist/system/logger.py deleted file mode 100644 index 67c626f..0000000 --- a/flaschengeist/system/logger.py +++ /dev/null @@ -1,32 +0,0 @@ -import logging -import logging.config -import yaml -from os import path, makedirs, getcwd -from .. import _modpath - -fname = _modpath/'logging.yml' - -if not path.exists("geruecht/log/debug"): - a = path.join(path.curdir, "geruecht", "log", "debug") - makedirs(a) - -if not path.exists("geruecht/log/info"): - b = path.join(path.curdir, "geruecht", "log", "info") - makedirs(b) - - -with fname.open(mode='rb') as file: - config = yaml.safe_load(file.read()) -logging.config.dictConfig(config) - - -def getDebugLogger(): - return logging.getLogger("debug_logger") - - -def getCreditLogger(): - return logging.getLogger("credit_logger") - - -def getJobsLogger(): - return logging.getLogger("jobs_logger") diff --git a/flaschengeist/system/model/user.py b/flaschengeist/system/model/user.py deleted file mode 100644 index 157a965..0000000 --- a/flaschengeist/system/model/user.py +++ /dev/null @@ -1,49 +0,0 @@ -from ..logger import getDebugLogger -from datetime import datetime -debug = getDebugLogger() - - -class User(): - """ Database Object for User - - Table for all safed User - - Attributes: - id: Id in Database as Primary Key. - username: Username of the User to Login - firstname: Firstname of the User - lastname: Lastname of the User - mail: mail address of the User - """ - def __init__(self, data): - debug.info("init user") - if 'id' in data: - self.id = int(data['id']) - self.firstname = data['firstname'] - self.lastname = data['lastname'] - if 'mail' in data: - self.mail = data['mail'] - else: - self.mail = '' - if 'username' in data: - self.username = data['username'] - else: - self.username = None - debug.debug("user is {{ {} }}".format(self)) - - def updateData(self, data): - debug.info("update data of user") - if 'firstname' in data: - self.firstname = data['firstname'] - if 'lastname' in data: - self.lastname = data['lastname'] - if 'mail' in data: - self.mail = data['mail'] - if 'username' in data: - self.username = data['username'] - - def __repr__(self): - return "User({}, {})".format(self.uid, self.username) - - -# TODO: user attributes (uid|key|value), getter, setter, has diff --git a/flaschengeist/system/model/__init__.py b/flaschengeist/system/models/__init__.py similarity index 100% rename from flaschengeist/system/model/__init__.py rename to flaschengeist/system/models/__init__.py diff --git a/flaschengeist/system/model/accessToken.py b/flaschengeist/system/models/accessToken.py similarity index 60% rename from flaschengeist/system/model/accessToken.py rename to flaschengeist/system/models/accessToken.py index 6072378..fc19e96 100644 --- a/flaschengeist/system/model/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -1,9 +1,10 @@ from datetime import datetime -from ..logger import getDebugLogger +from ..database import db +from flask import current_app +from werkzeug.local import LocalProxy +logger = LocalProxy(lambda: current_app.logger) -debug = getDebugLogger() - -class AccessToken(): +class AccessToken(db.Model): """ Model for an AccessToken Attributes: @@ -11,39 +12,23 @@ class AccessToken(): user: Is an User. token: String to verify access later. """ - - timestamp = None - user = None - token = None - - def __init__(self, id, user, token, lifetime, lock_bar=False, timestamp=datetime.now(), browser=None, platform=None): - """ Initialize Class AccessToken - - No more to say. - - Args: - User: Is an User to set. - token: Is a String to verify later - timestamp: Default current time, but can set to an other datetime-Object. - """ - debug.debug("init accesstoken") - self.id = id - self.user = user - self.timestamp = timestamp - self.lifetime = lifetime - self.token = token - self.lock_bar = lock_bar - self.browser = browser - self.platform = platform - debug.debug("accesstoken is {{ {} }}".format(self)) + __tablename__ = 'session' + id = db.Column(db.Integer, primary_key=True) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + user = db.relationship("User", back_populates="sessions") + token = db.Column(db.String(30)) + lifetime = db.Column(db.Integer) + browser = db.Column(db.String(30)) + platform = db.Column(db.String(30)) def updateTimestamp(self): """ Update the Timestamp Update the Timestamp to the current Time. """ - debug.debug("update timestamp from accesstoken {{ {} }}".format(self)) - self.timestamp = datetime.now() + logger.debug("update timestamp from accesstoken {{ {} }}".format(self)) + self.timestamp = datetime.utcnow() def toJSON(self): """ Create Dic to dump in JSON diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py new file mode 100644 index 0000000..8677f5b --- /dev/null +++ b/flaschengeist/system/models/user.py @@ -0,0 +1,63 @@ +from datetime import datetime +from ..database import db +from .accessToken import AccessToken +from sqlalchemy.orm.collections import attribute_mapped_collection +from flask import current_app +from werkzeug.local import LocalProxy +logger = LocalProxy(lambda: current_app.logger) + +association_table = db.Table('user_group', + db.Column('user_id', db.Integer, db.ForeignKey('user.id')), + db.Column('group_id', db.Integer, db.ForeignKey('group.id')) +) + +class User(db.Model): + """ Database Object for User + + Table for all safed User + + Attributes: + id: Id in Database as Primary Key. + uid: User ID used by authentification provider + displayname: Name to show + firstname: Firstname of the User + lastname: Lastname of the User + mail: mail address of the User + """ + __tablename__ = 'user' + id = db.Column(db.Integer, primary_key=True) + uid = db.Column(db.String(30)) + displayname = db.Column(db.String(20)) + firstname = db.Column(db.String(20)) + lastname = db.Column(db.String(20)) + mail = db.Column(db.String(20)) + groups = db.relationship("UserGroup", secondary=association_table) + sessions = db.relationship("AccessToken", back_populates="user") + attributes = db.relationship("UserAttribute", collection_class=attribute_mapped_collection('name'), cascade="all, delete") + + def updateData(self, data): + logger.debug("update data of user") + if 'uid' in data: + self.uid = data['uid'] + if 'firstname' in data: + self.firstname = data['firstname'] + if 'lastname' in data: + self.lastname = data['lastname'] + if 'mail' in data: + self.mail = data['mail'] + if 'displayname' in data: + self.displayname = data['displayname'] + + +class UserAttribute(db.Model): + __tablename__ = 'userAttribute' + id = db.Column(db.Integer, primary_key=True) + user = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + name = db.Column(db.String(30)) + value = db.Column(db.String(192)) + +class UserGroup(db.Model): + __tablename__ = 'group' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(30)) + diff --git a/run_flaschengeist b/run_flaschengeist index ab4b3b2..41f9494 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,5 +1,5 @@ #!/usr/bin/python3 -from flaschengeist.app import app +from flaschengeist import create_app """ Main @@ -7,4 +7,4 @@ from flaschengeist.app import app """ if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0') + create_app().run(debug=True, host='0.0.0.0') diff --git a/setup.py b/setup.py index 09cdb58..50a8de7 100644 --- a/setup.py +++ b/setup.py @@ -10,10 +10,13 @@ setup( packages=find_packages(), package_data={'': ['*.yml']}, scripts=['run_flaschengeist'], - install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', "flask_mysqldb", "flask_ldapconn", "flask_cors"], + install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', 'sqlalchemy>=1.3', "flask_sqlalchemy", "flask_ldapconn", "flask_cors"], entry_points = { - 'flaschengeist.plugins': [ - 'users = flaschengeist.modules.user:register' + 'flaschengeist.plugin': [ + 'user = flaschengeist.modules.user:register' + ], + 'flaschengeist.auth': [ + 'plain_auth = flaschengeist.modules.auth_plain:AuthPlain' ] } ) From a000ccfb1cf6c590af9970bd84a77eb5cb278a7e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 22 Aug 2020 16:47:56 +0200 Subject: [PATCH 010/446] Added modules for authentification. * Added base class for auth plugins * Provide plain_auth (using password authentification) * Provide module for login and logout handling --- flaschengeist/__init__.py | 5 + flaschengeist/logging.yml | 12 +- flaschengeist/modules/__init__.py | 29 +++ flaschengeist/modules/auth/__init__.py | 77 ++++++ flaschengeist/modules/auth_plain/__init__.py | 24 ++ flaschengeist/modules/user/__init__.py | 56 ---- flaschengeist/modules/user/routes.py | 216 ---------------- flaschengeist/modules/user/user.py | 244 ------------------ flaschengeist/system/controller/__init__.py | 4 - .../system/controller/accesTokenController.py | 87 +++---- .../controller/mainController/__init__.py | 83 +++--- .../mainController/mainUserController.py | 37 ++- flaschengeist/system/decorator.py | 32 +-- flaschengeist/system/models/accessToken.py | 2 +- flaschengeist/system/models/user.py | 10 + setup.py | 2 +- 16 files changed, 256 insertions(+), 664 deletions(-) create mode 100644 flaschengeist/modules/auth/__init__.py create mode 100644 flaschengeist/modules/auth_plain/__init__.py delete mode 100644 flaschengeist/modules/user/__init__.py delete mode 100644 flaschengeist/modules/user/routes.py delete mode 100644 flaschengeist/modules/user/user.py diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 1a29c59..9d97fd0 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -19,6 +19,11 @@ with (_modpath/'logging.yml').open(mode='rb') as file: config = yaml.safe_load(file.read()) dictConfig(config) +import logging +from werkzeug.local import LocalProxy +logger = LocalProxy(lambda: logging.getLogger(__name__)) + + def create_app(): app = Flask(__name__) CORS(app) diff --git a/flaschengeist/logging.yml b/flaschengeist/logging.yml index 6ca9241..715b92d 100644 --- a/flaschengeist/logging.yml +++ b/flaschengeist/logging.yml @@ -37,11 +37,6 @@ handlers: encoding: utf8 loggers: - debug_logger: - level: DEBUG - handlers: [console, debug] - propagate: no - credit_logger: level: INFO handlers: [credit] @@ -52,6 +47,9 @@ loggers: handlers: [jobs] propagate: no + werkzeug: + level: WARNING + root: - level: INFO - handlers: [console, debug] \ No newline at end of file + level: DEBUG + handlers: [console, debug] diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index e69de29..a0c81bc 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -0,0 +1,29 @@ +class Auth(): + def login(self, user, pw): + """ + user User class containing at least the uid + pw given password + + HAS TO BE IMPLEMENTED! + + should return False if not found or invalid credentials + should return True if success + """ + return False + + def updateUser(self, user): + """ + user User class + + If backend is using external data, then update this user instance with external data + """ + pass + + def modifyUser(self, user): + """ + user User class + + If backend is using (writeable) external data, then update the external database with the user provided. + """ + pass + diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py new file mode 100644 index 0000000..1559465 --- /dev/null +++ b/flaschengeist/modules/auth/__init__.py @@ -0,0 +1,77 @@ +############################################# +# Plugin: Auth # +# Functionality: Allow management of # +# authentification, login, logout, etc # +############################################# + +from flask import Blueprint, current_app, request, jsonify +from werkzeug.local import LocalProxy + +from flaschengeist.system.decorator import login_required +from flaschengeist.system.exceptions import PermissionDenied +from flaschengeist.system.controller import mainController as mc +import flaschengeist.system.controller.accesTokenController as ac + +logger = LocalProxy(lambda: current_app.logger) +accesTokenController = LocalProxy(lambda: ac.AccesTokenController()) + +auth_bp = Blueprint('auth', __name__) + +def register(): + return auth_bp + +############################################ +## Routes ## +############################################ + +@auth_bp.route("/logout", methods=['GET']) +@login_required() +def _logout(**kwargs): + try: + logger.debug("logout user") + accToken = kwargs['accToken'] + logger.debug("accesstoken is {{ {} }}".format(accToken)) + logger.debug("delete accesstoken") + accesTokenController.deleteAccessToken(accToken) + logger.info("return ok logout user") + return jsonify({"ok": "ok"}) + except Exception as err: + logger.warning("exception in logout user.", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@auth_bp.route("/login", methods=['POST']) +def _login(): + """ Login User + + Nothing to say. + Login in User and create an AccessToken for the User. + + Returns: + A JSON-File with createt Token or Errors + """ + logger.debug("Start log in.") + data = request.get_json() + logger.info(request) + username = data['username'] + password = data['password'] + logger.debug("username is {{ {} }}".format(username)) + try: + logger.debug("search {{ {} }} in database".format(username)) + mainController = mc.MainController() + user = mainController.loginUser(username, password) + logger.debug("user is {{ {} }}".format(user)) + token = accesTokenController.createAccesToken(user, user_agent=request.user_agent) + logger.debug("accesstoken is {{ {} }}".format(token)) + logger.debug("validate accesstoken") + dic = user.toJSON() + dic["token"] = token + logger.info("User {{ {} }} success login.".format(username)) + logger.debug("return login {{ {} }}".format(dic)) + return jsonify(dic) + except PermissionDenied as err: + logger.debug("permission denied exception in login", exc_info=True) + return jsonify({"error": str(err)}), 401 + except Exception as err: + logger.error("exception in login.", exc_info=True) + return jsonify({"error": "permission denied"}), 401 diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/modules/auth_plain/__init__.py new file mode 100644 index 0000000..037533c --- /dev/null +++ b/flaschengeist/modules/auth_plain/__init__.py @@ -0,0 +1,24 @@ +import hashlib, binascii, os +import flaschengeist.modules as modules + +class AuthPlain(modules.Auth): + def login(self, user, password): + if not user: + return False + if 'password' in user.attributes: + return self.__verify_password(user.attributes['password'].value, password) + return False + + def __hash_password(self, password): + salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') + pwdhash = hashlib.pbkdf2_hmac('sha3-512', password.encode('utf-8'), salt, 100000) + pwdhash = binascii.hexlify(pwdhash) + return (salt + pwdhash).decode('ascii') + + def __verify_password(self, stored_password, provided_password): + salt = stored_password[:64] + stored_password = stored_password[64:] + pwdhash = hashlib.pbkdf2_hmac('sha3-512', provided_password.encode('utf-8'), + salt.encode('ascii'), 100000) + pwdhash = binascii.hexlify(pwdhash).decode('ascii') + return pwdhash == stored_password diff --git a/flaschengeist/modules/user/__init__.py b/flaschengeist/modules/user/__init__.py deleted file mode 100644 index ec9f6f3..0000000 --- a/flaschengeist/modules/user/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -####################################### -# Plugin: Users # -# Functionality: Allow management # -# of users, login, logout, etc # -####################################### - -from flask import Blueprint -from flaschengeist.app import app - -def register(): - return Blueprint('user', __name__) - -####################################### -## Routes ## -####################################### -#dummy -@app.route("/") -def _dummy(): - return 'Noch funktioniert hier mal überhaupt nichts!' - -@app.route("/login", methods=['POST']) -def _login(): - """ Login User - - Nothing to say. - Login in User and create an AccessToken for the User. - - Returns: - A JSON-File with createt Token or Errors - """ - debug.info("Start log in.") - data = request.get_json() - username = data['username'] - password = data['password'] - debug.debug("username is {{ {} }}".format(username)) - try: - user_agent = request.user_agent - debug.info("search {{ {} }} in database".format(username)) - user = mainController.loginUser(username, password) - debug.debug("user is {{ {} }}".format(user)) - token = accesTokenController.createAccesToken(user, user_agent=user_agent) - debug.debug("accesstoken is {{ {} }}".format(token)) - debug.info("validate accesstoken") - dic = accesTokenController.validateAccessToken( - token, [USER, EXTERN]).user.toJSON() - dic["token"] = token - dic["accessToken"] = token - debug.info("User {{ {} }} success login.".format(username)) - debug.info("return login {{ {} }}".format(dic)) - return jsonify(dic) - except PermissionDenied as err: - debug.warning("permission denied exception in logout", exc_info=True) - return jsonify({"error": str(err)}), 401 - except Exception as err: - debug.warning("exception in logout.", exc_info=True) - return jsonify({"error": "permission denied"}), 401 diff --git a/flaschengeist/modules/user/routes.py b/flaschengeist/modules/user/routes.py deleted file mode 100644 index 07b78ce..0000000 --- a/flaschengeist/modules/user/routes.py +++ /dev/null @@ -1,216 +0,0 @@ -from geruecht import app -from geruecht.logger import getDebugLogger -from geruecht.decorator import login_required -from geruecht.exceptions import PermissionDenied -import geruecht.controller.accesTokenController as ac -import geruecht.controller.mainController as mc -from geruecht.model import MONEY, BAR, USER, GASTRO, VORSTAND, EXTERN -from flask import request, jsonify - -accesTokenController = ac.AccesTokenController() -mainController = mc.MainController() - -debug = getDebugLogger() - -@app.route("/valid", methods=['POST']) -@login_required(bar=True) -def _valid(**kwargs): - debug.info('/valid') - try: - accToken = kwargs['accToken'] - data = request.get_json() - mainController.validateUser(accToken.user.uid, data['password']) - debug.debug('return {{ "ok": "ok" }}') - return jsonify({"ok": "ok"}) - except Exception as err: - debug.warning("exception in valide.", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@app.route("/pricelist", methods=['GET']) -def _getPricelist(): - try: - debug.info("get pricelist") - retVal = mainController.getPricelist() - debug.info("return pricelist {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get pricelist.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route('/drinkTypes', methods=['GET']) -def getTypes(): - try: - debug.info("get drinktypes") - retVal = mainController.getAllDrinkTypes() - debug.info("return drinktypes {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get drinktypes.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route('/getAllStatus', methods=['GET']) -@login_required(groups=[USER, MONEY, GASTRO, BAR, VORSTAND], bar=True) -def _getAllStatus(**kwargs): - try: - debug.info("get all status for users") - retVal = mainController.getAllStatus() - debug.info("return all status for users {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get all status for users.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route('/getStatus', methods=['POST']) -@login_required(groups=[USER, MONEY, GASTRO, BAR, VORSTAND], bar=True) -def _getStatus(**kwargs): - try: - debug.info("get status from user") - data = request.get_json() - name = data['name'] - debug.info("get status from user {{ {} }}".format(name)) - retVal = mainController.getStatus(name) - debug.info( - "return status from user {{ {} }} : {{ {} }}".format(name, retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get status from user.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route('/getUsers', methods=['GET']) -@login_required(groups=[USER], bar=True) -def _getUsers(**kwargs): - try: - extern = True - if 'extern' in request.args: - extern = not bool(int(request.args['extern'])) - debug.info("get all users from database") - users = mainController.getAllUsersfromDB(extern=extern) - debug.debug("users are {{ {} }}".format(users)) - retVal = [user.toJSON() for user in users] - debug.info("return all users from database {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning( - "exception in get all users from database.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route("/getLifeTime", methods=['GET']) -@login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) -def _getLifeTime(**kwargs): - try: - debug.info("get lifetime of accesstoken") - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - debug.debug("accessToken is {{ {} }}".format(accToken)) - retVal = {"value": accToken.lifetime, - "group": accToken.user.toJSON()['group'], - "lock_bar": accToken.lock_bar} - debug.info( - "return get lifetime from accesstoken {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.info("exception in get lifetime of accesstoken.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route("/saveLifeTime", methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) -def _saveLifeTime(**kwargs): - try: - debug.info("save lifetime for accessToken") - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - debug.debug("accessToken is {{ {} }}".format(accToken)) - data = request.get_json() - lifetime = data['value'] - debug.debug("lifetime is {{ {} }}".format(lifetime)) - debug.info("set lifetime {{ {} }} to accesstoken {{ {} }}".format( - lifetime, accToken)) - accToken.lifetime = lifetime - debug.info("update accesstoken timestamp") - accToken = accesTokenController.updateAccessToken(accToken) - accToken = accesTokenController.validateAccessToken(accToken.token, [USER, EXTERN]) - retVal = {"value": accToken.lifetime, - "group": accToken.user.toJSON()['group']} - debug.info( - "return save lifetime for accessToken {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning( - "exception in save lifetime for accesstoken.", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@app.route("/passwordReset", methods=['POST']) -def _passwordReset(): - try: - debug.info('password reset') - data = request.get_json() - mail = mainController.resetPassword(data) - index = mail.find('@') - for i in range(index): - if i == 0: - continue - mail = mail.replace(mail[i], "*", 1) - return jsonify({"ok": "ok", "mail": mail}) - except Exception as err: - debug.warning("excetpion in password reset", exc_info=True) - return jsonify({"error": str(err)}), 409 - -@app.route("/logout", methods=['GET']) -@login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) -def _logout(**kwargs): - try: - debug.info("logout user") - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - debug.debug("accesstoken is {{ {} }}".format(accToken)) - debug.info("delete accesstoken") - accesTokenController.deleteAccessToken(accToken) - debug.info("return ok logout user") - return jsonify({"ok": "ok"}) - except Exception as err: - debug.warning("exception in logout user.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@app.route("/login", methods=['POST']) -def _login(): - """ Login User - - Nothing to say. - Login in User and create an AccessToken for the User. - - Returns: - A JSON-File with createt Token or Errors - """ - debug.info("Start log in.") - data = request.get_json() - username = data['username'] - password = data['password'] - debug.debug("username is {{ {} }}".format(username)) - try: - user_agent = request.user_agent - debug.info("search {{ {} }} in database".format(username)) - user = mainController.loginUser(username, password) - debug.debug("user is {{ {} }}".format(user)) - token = accesTokenController.createAccesToken(user, user_agent=user_agent) - debug.debug("accesstoken is {{ {} }}".format(token)) - debug.info("validate accesstoken") - dic = accesTokenController.validateAccessToken( - token, [USER, EXTERN]).user.toJSON() - dic["token"] = token - dic["accessToken"] = token - debug.info("User {{ {} }} success login.".format(username)) - debug.info("return login {{ {} }}".format(dic)) - return jsonify(dic) - except PermissionDenied as err: - debug.warning("permission denied exception in logout", exc_info=True) - return jsonify({"error": str(err)}), 401 - except Exception as err: - debug.warning("exception in logout.", exc_info=True) - return jsonify({"error": "permission denied"}), 401 diff --git a/flaschengeist/modules/user/user.py b/flaschengeist/modules/user/user.py deleted file mode 100644 index 543859c..0000000 --- a/flaschengeist/modules/user/user.py +++ /dev/null @@ -1,244 +0,0 @@ -from geruecht.logger import getDebugLogger -from geruecht.model.creditList import CreditList, create_empty_data -from datetime import datetime - -debug = getDebugLogger() - - -class User(): - """ Database Object for User - - Table for all safed User - - Attributes: - id: Id in Database as Primary Key. - userID: ID for the User maybe to Link? - username: Username of the User to Login - firstname: Firstname of the User - Lastname: Lastname of the User - group: Which group is the User? moneymaster, gastro, user or bar? - password: salted hashed password for the User. - """ - def __init__(self, data): - debug.info("init user") - if 'id' in data: - self.id = int(data['id']) - self.uid = data['uid'] - self.dn = data['dn'] - self.firstname = data['firstname'] - self.lastname = data['lastname'] - self.group = data['gruppe'] - self.last_seen = None - if 'last_seen' in data: - self.last_seen = data['last_seen'] - if 'statusgroup' in data: - self.statusgroup = data['statusgroup'] - else: - self.statusgroup = None - if 'voting' in data: - self.voting = data['voting'] - else: - self.voting = None - if 'mail' in data: - self.mail = data['mail'] - else: - self.mail = '' - if 'lockLimit' in data: - self.limit = int(data['lockLimit']) - else: - self.limit = 4200 - if 'locked' in data: - self.locked = bool(data['locked']) - else: - self.locked = False - if 'autoLock' in data: - self.autoLock = bool(data['autoLock']) - else: - self.autoLock = True - if type(data['gruppe']) == list: - self.group = data['gruppe'] - elif type(data['gruppe']) == str: - self.group = data['gruppe'].split(',') - if 'creditLists' in data: - self.geruechte = data['creditLists'] - if 'workgroups' in data: - self.workgroups = data['workgroups'] - else: - self.workgroups = None - self.password = '' - debug.debug("user is {{ {} }}".format(self)) - - def updateData(self, data): - debug.info("update data of user") - if 'dn' in data: - self.dn = data['dn'] - if 'firstname' in data: - self.firstname = data['firstname'] - if 'lastname' in data: - self.lastname = data['lastname'] - if 'gruppe' in data: - self.group = data['gruppe'] - if 'lockLimit' in data: - self.limit = int(data['lockLimit']) - if 'locked' in data: - self.locked = bool(data['locked']) - if 'autoLock' in data: - self.autoLock = bool(data['autoLock']) - if 'mail' in data: - self.mail = data['mail'] - if 'statusgorup' in data: - self.statusgroup = data['statusgroup'] - if 'voting' in data: - self.voting = data['voting'] - if 'workgroups' in data: - self.workgroups = data['workgroups'] - else: - self.workgroups = None - - def initGeruechte(self, creditLists): - if type(creditLists) == list: - self.geruechte = creditLists - - def createGeruecht(self, amount=0, year=datetime.now().year): - """ Create Geruecht - - This function create a geruecht for the user for an year. - By default is amount zero and year the actual year. - - Args: - amount: is the last_schulden of the geruecht - year: is the year of the geruecht - - Returns: - the created geruecht - """ - debug.info("create creditlist for user {{ {} }} in year {{ {} }}".format(self, year)) - data = create_empty_data() - data['user_id'] = self.id - data['last_schulden'] = amount - data['year_date'] = year - credit = CreditList(data) - self.geruechte.append(credit) - debug.debug("creditlist is {{ {} }}".format(credit)) - return credit - - def getGeruecht(self, year=datetime.now().year): - """ Get Geruecht - - This function returns the geruecht of an year. - By default is the year the actual year. - - Args: - year: the year of the geruecht - - Returns: - the geruecht of the year - """ - debug.info("get creditlist from user on year {{ {} }}".format(year)) - for geruecht in self.geruechte: - if geruecht.year == year: - debug.debug("creditlist is {{ {} }} for user {{ {} }}".format(geruecht, self)) - return geruecht - debug.debug("no creditlist found for user {{ {} }}".format(self)) - geruecht = self.createGeruecht(year=year) - - return self.getGeruecht(year=year) - - def addAmount(self, amount, year=datetime.now().year, month=datetime.now().month): - """ Add Amount - - This function add an amount to a geruecht with an spezified year and month to the user. - By default the year is the actual year. - By default the month is the actual month. - - Args: - year: year of the geruecht - month: month for the amount - - Returns: - double (credit, amount) - """ - debug.info("add amount to user {{ {} }} in year {{ {} }} and month {{ {} }}".format(self, year, month)) - geruecht = self.getGeruecht(year=year) - retVal = geruecht.addAmount(amount, month=month) - - return retVal - - def addCredit(self, credit, year=datetime.now().year, month=datetime.now().month): - """ Add Credit - - This function add an credit to a geruecht with an spezified year and month to the user. - By default the year is the actual year. - By default the month is the actual month. - - Args: - year: year of the geruecht - month: month for the amount - - Returns: - double (credit, amount) - """ - debug.info("add credit to user {{ {} }} in year {{ {} }} and month {{ {} }}".format(self, year, month)) - geruecht = self.getGeruecht(year=year) - retVal = geruecht.addCredit(credit, month=month) - - return retVal - - def updateGeruecht(self): - """ Update list of geruechte - - This function iterate through the geruechte, which sorted by year and update the last_schulden of the geruecht. - """ - debug.info("update all creditlists ") - self.geruechte.sort(key=self.sortYear) - - for index, geruecht in enumerate(self.geruechte): - if index == 0 or index == len(self.geruechte) - 1: - geruecht.last_schulden = 0 - if index != 0: - geruecht.last_schulden = (self.geruechte[index - 1].getSchulden() * -1) - - return self.geruechte - - def sortYear(self, geruecht): - """ Sort Year - - This function is only an helperfunction to sort the list of geruechte by years. - It only returns the year of the geruecht. - - Args: - geruecht: geruecht which year you want - - Returns: - int year of the geruecht - """ - return geruecht.year - - def toJSON(self): - """ Create Dic to dump in JSON - - Returns: - A Dic with static Attributes. - """ - dic = { - "id": self.id, - "userId": self.uid, - "uid": self.uid, - "dn": self.dn, - "firstname": self.firstname, - "lastname": self.lastname, - "group": self.group, - "username": self.uid, - "locked": self.locked, - "autoLock": self.autoLock, - "limit": self.limit, - "mail": self.mail, - "statusgroup": self.statusgroup, - "voting": self.voting, - "workgroups": self.workgroups - } - return dic - - def __repr__(self): - return "User({}, {}, {})".format(self.uid, self.dn, self.group) - diff --git a/flaschengeist/system/controller/__init__.py b/flaschengeist/system/controller/__init__.py index 7b2f4c6..095c03c 100644 --- a/flaschengeist/system/controller/__init__.py +++ b/flaschengeist/system/controller/__init__.py @@ -1,12 +1,8 @@ -from ..logger import getDebugLogger from ..configparser import ConifgParser from flaschengeist import _modpath -import os config = ConifgParser(_modpath/'config.yml') -LOGGER = getDebugLogger() - class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): diff --git a/flaschengeist/system/controller/accesTokenController.py b/flaschengeist/system/controller/accesTokenController.py index bec7703..c6274be 100644 --- a/flaschengeist/system/controller/accesTokenController.py +++ b/flaschengeist/system/controller/accesTokenController.py @@ -1,17 +1,13 @@ -from geruecht.model.accessToken import AccessToken -import geruecht.controller as gc -import geruecht.controller.mainController as mc -import geruecht.controller.databaseController as dc -from geruecht.model import BAR +from ..models.accessToken import AccessToken +from flaschengeist.system.database import db + from datetime import datetime, timedelta -import hashlib +import secrets from . import Singleton -from geruecht.logger import getDebugLogger +from flask import Blueprint, request, jsonify +import logging -debug = getDebugLogger() - -mainController = mc.MainController() -db = dc.DatabaseController() +logger = logging.getLogger("flaschenpost") class AccesTokenController(metaclass=Singleton): """ Control all createt AccesToken @@ -30,22 +26,8 @@ class AccesTokenController(metaclass=Singleton): Initialize Thread and set tokenList empty. """ - debug.info("init accesstoken controller") - self.lifetime = gc.accConfig - - def checkBar(self, user): - debug.info("check if user {{ {} }} is baruser".format(user)) - if (mainController.checkBarUser(user)): - if BAR not in user.group: - debug.debug("append bar to user {{ {} }}".format(user)) - user.group.append(BAR) - return True - else: - while BAR in user.group: - debug.debug("delete bar from user {{ {} }}".format(user)) - user.group.remove(BAR) - return False - debug.debug("user {{ {} }} groups are {{ {} }}".format(user, user.group)) + logger.debug("init accesstoken controller") + self.lifetime = lifetime def validateAccessToken(self, token, group): """ Verify Accestoken @@ -59,27 +41,27 @@ class AccesTokenController(metaclass=Singleton): Returns: An the AccesToken for this given Token or False. """ - debug.info("check token {{ {} }} is valid") - for accToken in db.getAccessTokens(): - debug.debug("accesstoken is {}".format(accToken)) + logger.debug("check token {{ {} }} is valid".format(token)) + for accToken in AccessToken.query.filter_by(token=token): endTime = accToken.timestamp + timedelta(seconds=accToken.lifetime) - now = datetime.now() - debug.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, endTime)) + now = datetime.utcnow() + logger.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, endTime)) if now <= endTime: - debug.debug("check if token {{ {} }} is same as {{ {} }}".format(token, accToken)) + logger.debug("check if token {{ {} }} is same as {{ {} }}".format(token, accToken)) if accToken == token: - if not self.checkBar(accToken.user): - accToken.lock_bar = False - debug.debug("check if accestoken {{ {} }} has group {{ {} }}".format(accToken, group)) - if self.isSameGroup(accToken, group): - accToken.updateTimestamp() - db.updateAccessToken(accToken) - debug.debug("found accesstoken {{ {} }} with token: {{ {} }} and group: {{ {} }}".format(accToken, token, group)) - return accToken + # if not self.checkBar(accToken.user): + # accToken.lock_bar = False + # logger.debug("check if accestoken {{ {} }} has group {{ {} }}".format(accToken, group)) + # if self.isSameGroup(accToken, group): + accToken.updateTimestamp() + db.session.commit() + # logger.debug("found accesstoken {{ {} }} with token: {{ {} }} and group: {{ {} }}".format(accToken, token, group)) + return accToken else: - debug.debug("accesstoken is {{ {} }} out of date".format(accToken)) - db.deleteAccessToken(accToken) - debug.debug("no valid accesstoken with token: {{ {} }} and group: {{ {} }}".format(token, group)) + logger.debug("accesstoken is {{ {} }} out of date".format(accToken)) + db.session.delete(accToken) + db.session.commit() + logger.debug("no valid accesstoken with token: {{ {} }} and group: {{ {} }}".format(token, group)) return False def createAccesToken(self, user, user_agent=None): @@ -93,12 +75,13 @@ class AccesTokenController(metaclass=Singleton): Returns: A created Token for User """ - debug.info("creat accesstoken") - now = datetime.ctime(datetime.now()) - token = hashlib.md5((now + user.dn).encode('utf-8')).hexdigest() - self.checkBar(user) - accToken = db.createAccessToken(user, token, self.lifetime, datetime.now(), lock_bar=False, user_agent=user_agent) - debug.debug("accesstoken is {{ {} }}".format(accToken)) + logger.debug("creat accesstoken") + token = secrets.token_hex(16) + accToken = AccessToken(token=token, user=user, lifetime=self.lifetime, browser=user_agent.browser, platform=user_agent.platform) + db.session.add(accToken) + db.session.commit() + + logger.debug("accesstoken is {{ {} }}".format(accToken)) return token def isSameGroup(self, accToken, groups): @@ -122,7 +105,9 @@ class AccesTokenController(metaclass=Singleton): return db.getAccessTokensFromUser(user) def deleteAccessToken(self, accToken): - db.deleteAccessToken(accToken) + db.session.delete(accToken) + db.session.commit() + #AccessToken.query.filter_by(token=accToken).delete() def updateAccessToken(self, accToken): accToken.updateTimestamp() diff --git a/flaschengeist/system/controller/mainController/__init__.py b/flaschengeist/system/controller/mainController/__init__.py index ae3f73a..d426514 100644 --- a/flaschengeist/system/controller/mainController/__init__.py +++ b/flaschengeist/system/controller/mainController/__init__.py @@ -1,44 +1,37 @@ from .. import Singleton, mailConfig -import geruecht.controller.databaseController as dc -import geruecht.controller.ldapController as lc -import geruecht.controller.emailController as ec -from geruecht.model.user import User +from ...models.user import User from datetime import datetime, timedelta -from geruecht.logger import getDebugLogger -from ..mainController import mainJobKindController, mainCreditListController, mainPricelistController, mainUserController, mainWorkerController, mainWorkgroupController, mainJobInviteController, mainJobRequestController, mainRegistrationController, mainPasswordReset +from . import mainUserController +from ...database import db +from flask import current_app +from werkzeug.local import LocalProxy +logger = LocalProxy(lambda: current_app.logger) -db = dc.DatabaseController() -ldap = lc.LDAPController() -emailController = ec.EmailController() - -debug = getDebugLogger() - - -class MainController(mainJobKindController.Base, - mainCreditListController.Base, - mainPricelistController.Base, +class MainController(#mainJobKindController.Base, + #mainCreditListController.Base, + #mainPricelistController.Base, mainUserController.Base, - mainWorkerController.Base, - mainWorkgroupController.Base, - mainJobInviteController.Base, - mainJobRequestController.Base, - mainRegistrationController.Base, - mainPasswordReset.Base, + #mainWorkerController.Base, + #mainWorkgroupController.Base, + #mainJobInviteController.Base, + #mainJobRequestController.Base, + #mainRegistrationController.Base, + #mainPasswordReset.Base, metaclass=Singleton): def __init__(self): - debug.debug("init UserController") + logger.debug("init UserController") pass def setLockedDay(self, date, locked, hard=False): - debug.info( + logger.info( "set day locked on {{ {} }} with state {{ {} }}".format(date, locked)) retVal = db.setLockedDay(date.date(), locked, hard) - debug.debug("seted day locked is {{ {} }}".format(retVal)) + logger.debug("seted day locked is {{ {} }}".format(retVal)) return retVal def getLockedDays(self, from_date, to_date): - debug.info("get locked days from {{ {} }} to {{ {} }}".format( + logger.info("get locked days from {{ {} }} to {{ {} }}".format( from_date.date(), to_date.date())) oneDay = timedelta(1) delta = to_date.date() - from_date.date() @@ -48,11 +41,11 @@ class MainController(mainJobKindController.Base, startdate += oneDay lockday = self.getLockedDay(startdate) retVal.append(lockday) - debug.debug("lock days are {{ {} }}".format(retVal)) + logger.debug("lock days are {{ {} }}".format(retVal)) return retVal def getLockedDaysFromList(self, date_list): - debug.info("get locked days from list {{ {} }}".format(date_list)) + logger.info("get locked days from list {{ {} }}".format(date_list)) retVal = [] for on_date in date_list: day = datetime(on_date['on_date']['year'], on_date['on_date']['month'], on_date['on_date']['day'], 12) @@ -60,22 +53,22 @@ class MainController(mainJobKindController.Base, return retVal def getLockedDay(self, date): - debug.info("get locked day on {{ {} }}".format(date)) + logger.info("get locked day on {{ {} }}".format(date)) now = datetime.now() - debug.debug("now is {{ {} }}".format(now)) + logger.debug("now is {{ {} }}".format(now)) oldMonth = False - debug.debug("check if date old month or current month") + logger.debug("check if date old month or current month") for i in range(1, 8): if datetime(now.year, now.month, i).weekday() == 2: if now.day < i: oldMonth = True break - debug.debug("oldMonth is {{ {} }}".format(oldMonth)) + logger.debug("oldMonth is {{ {} }}".format(oldMonth)) lockedYear = now.year lockedMonth = now.month if now.month < now.month else now.month - \ 1 if oldMonth else now.month endDay = 1 - debug.debug("calculate end day of month") + logger.debug("calculate end day of month") lockedYear = lockedYear if lockedMonth != 12 else (lockedYear + 1) lockedMonth = (lockedMonth + 1) if lockedMonth != 12 else 1 for i in range(1, 8): @@ -86,33 +79,33 @@ class MainController(mainJobKindController.Base, monthLockedEndDate = datetime( lockedYear, lockedMonth, endDay) - timedelta(1) - debug.debug("get lock day from database") + logger.debug("get lock day from database") retVal = db.getLockedDay(date.date()) if not retVal: - debug.debug( + logger.debug( "lock day not exists, retVal is {{ {} }}".format(retVal)) if date.date() <= monthLockedEndDate.date(): - debug.debug("lock day {{ {} }}".format(date.date())) + logger.debug("lock day {{ {} }}".format(date.date())) self.setLockedDay(date, True) retVal = db.getLockedDay(date.date()) else: retVal = {"daydate": date.date(), "locked": False} - debug.debug("locked day is {{ {} }}".format(retVal)) + logger.debug("locked day is {{ {} }}".format(retVal)) return retVal def __updateDataFromLDAP(self, user): - debug.info("update data from ldap for user {{ {} }}".format(user)) + logger.info("update data from ldap for user {{ {} }}".format(user)) groups = ldap.getGroup(user.uid) - debug.debug("ldap gorups are {{ {} }}".format(groups)) + logger.debug("ldap gorups are {{ {} }}".format(groups)) user_data = ldap.getUserData(user.uid) - debug.debug("ldap data is {{ {} }}".format(user_data)) + logger.debug("ldap data is {{ {} }}".format(user_data)) user_data['gruppe'] = groups user_data['group'] = groups user.updateData(user_data) db.updateUser(user) def checkBarUser(self, user): - debug.info("check if user {{ {} }} is baruser") + logger.info("check if user {{ {} }} is baruser") date = datetime.now() zero = date.replace(hour=0, minute=0, second=0, microsecond=0) end = zero + timedelta(hours=12) @@ -121,22 +114,22 @@ class MainController(mainJobKindController.Base, if date > zero and end > date: startdatetime = startdatetime - timedelta(days=1) enddatetime = startdatetime + timedelta(days=1) - debug.debug("startdatetime is {{ {} }} and enddatetime is {{ {} }}".format( + logger.debug("startdatetime is {{ {} }} and enddatetime is {{ {} }}".format( startdatetime, end)) result = False if date >= startdatetime and date < enddatetime: result = db.getWorker(user, startdatetime) - debug.debug("worker is {{ {} }}".format(result)) + logger.debug("worker is {{ {} }}".format(result)) return True if result else False def sendMail(self, username): - debug.info("send mail to user {{ {} }}".format(username)) + logger.info("send mail to user {{ {} }}".format(username)) if type(username) == User: user = username if type(username) == str: user = db.getUser(username) retVal = emailController.sendMail(user) - debug.debug("send mail is {{ {} }}".format(retVal)) + logger.debug("send mail is {{ {} }}".format(retVal)) return retVal def sendAllMail(self): diff --git a/flaschengeist/system/controller/mainController/mainUserController.py b/flaschengeist/system/controller/mainController/mainUserController.py index 135663d..1f553fa 100644 --- a/flaschengeist/system/controller/mainController/mainUserController.py +++ b/flaschengeist/system/controller/mainController/mainUserController.py @@ -1,14 +1,10 @@ -from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError +from flaschengeist.system.exceptions import UsernameExistLDAP, LDAPExcetpion, PermissionDenied +from flaschengeist.system.models.user import User +from flaschengeist.system.database import db -from geruecht.exceptions import UsernameExistLDAP, LDAPExcetpion, PermissionDenied -import geruecht.controller.databaseController as dc -import geruecht.controller.ldapController as lc -from geruecht.logger import getDebugLogger -from geruecht.model.user import User - -db = dc.DatabaseController() -ldap = lc.LDAPController() -debug = getDebugLogger() +from flask import Blueprint, current_app +from werkzeug.local import LocalProxy +logger = LocalProxy(lambda: current_app.logger) class Base: def getAllStatus(self): @@ -167,13 +163,14 @@ class Base: ldap.login(username, password) def loginUser(self, username, password): - debug.info("login user {{ {} }}".format(username)) - try: - user = self.getUser(username) - debug.debug("user is {{ {} }}".format(user)) - user.password = password - ldap.login(username, password) - return user - except PermissionDenied as err: - debug.debug("permission is denied", exc_info=True) - raise err \ No newline at end of file + logger.info("login user {{ {} }}".format(username)) + user = User.query.filter_by(uid=username).first() + if user is None: + user = User(uid=username) + for backend in current_app.config['FG_AUTH_BACKENDS']: + b = backend() + if b.login(user, password): + db.session.add(user) + db.session.commit() + return user + raise PermissionDenied() diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index d948a50..4c67c0f 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -1,35 +1,29 @@ from functools import wraps -from flask import current_app -from werkzeug.local import LocalProxy -logger = LocalProxy(lambda: current_app.logger) - +from flask import current_app, request, jsonify +from flaschengeist import logger def login_required(**kwargs): - import geruecht.controller.accesTokenController as ac - from geruecht.model import BAR, USER, MONEY, GASTRO, VORSTAND, EXTERN - from flask import request, jsonify - accessController = ac.AccesTokenController() - groups = [USER, BAR, GASTRO, MONEY, VORSTAND, EXTERN] - bar = False - if "groups" in kwargs: - groups = kwargs["groups"] - if "bar" in kwargs: - bar = kwargs["bar"] - logger.debug("groups are {{ {} }}".format(groups)) + from .controller.accesTokenController import AccesTokenController + accessController = AccesTokenController() + #if "groups" in kwargs: + # groups = kwargs["groups"] + #if "bar" in kwargs: + # bar = kwargs["bar"] + #logger.debug("groups are {{ {} }}".format(groups)) def real_decorator(func): @wraps(func) def wrapper(*args, **kwargs): token = request.headers.get('Token') logger.debug("token is {{ {} }}".format(token)) - accToken = accessController.validateAccessToken(token, groups) + accToken = accessController.validateAccessToken(token, None) logger.debug("accToken is {{ {} }}".format(accToken)) kwargs['accToken'] = accToken if accToken: logger.debug("token {{ {} }} is valid".format(token)) - if accToken.lock_bar and not bar: - return jsonify({"error": "error", - "message": "permission forbidden"}), 403 + # if accToken.lock_bar and not bar: + # return jsonify({"error": "error", + # "message": "permission forbidden"}), 403 return func(*args, **kwargs) else: logger.warning("token {{ {} }} is not valid".format(token)) diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/accessToken.py index fc19e96..e9a3546 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -52,7 +52,7 @@ class AccessToken(db.Model): return dic def __eq__(self, token): - return True if self.token == token else False + return self.token == token def __sub__(self, other): return other - self.timestamp diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index 8677f5b..17d2ad3 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -48,6 +48,16 @@ class User(db.Model): if 'displayname' in data: self.displayname = data['displayname'] + def toJSON(self): + return { + "uid": self.uid, + "displayname": self.displayname, + "firstname": self.firstname, + "lastname": self.lastname, + "mail": self.mail, + "groups": self.groups + } + class UserAttribute(db.Model): __tablename__ = 'userAttribute' diff --git a/setup.py b/setup.py index 50a8de7..b16c5c4 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup( install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', 'sqlalchemy>=1.3', "flask_sqlalchemy", "flask_ldapconn", "flask_cors"], entry_points = { 'flaschengeist.plugin': [ - 'user = flaschengeist.modules.user:register' + 'auth = flaschengeist.modules.auth:register' ], 'flaschengeist.auth': [ 'plain_auth = flaschengeist.modules.auth_plain:AuthPlain' From fa5097da108fecb645d7cecaedd8f3c364a59212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 23 Aug 2020 21:14:43 +0200 Subject: [PATCH 011/446] =?UTF-8?q?L=C3=B6schen=20und=20modifizieren=20von?= =?UTF-8?q?=20Freigetr=C3=A4nken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Vorstand hat nun die Möglichkeit Freigetränke zu löschen oder zu ändern. Beim Löschen wird auch der gesamte Verlauf dieses Freigetränks gelöscht. --- .../dbFreeDrinkListConfigController.py | 33 +++++++++++++++++++ .../mainFreeDrinkListConfigController.py | 6 ++++ geruecht/routes.py | 24 ++++++++++++-- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py index e7a7e3e..dbc4738 100644 --- a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py +++ b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py @@ -39,6 +39,39 @@ class Base: cursor = self.db.connection.cursor() cursor.execute(f'insert into free_drink_list_config (drink_id, label, price) values ({free_drink_list_config["drink"]["id"]}, "{free_drink_list_config["label"]}", {free_drink_list_config["price"]})') self.db.connection.commit() + cursor.execute(f'select id from free_drink_list_config where drink_id={free_drink_list_config["drink"]["id"]} and label="{free_drink_list_config["label"]}" and price={free_drink_list_config["price"]}') + data = cursor.fetchone() + for free_drink_type in free_drink_list_config["free_drink_types"]: + cursor.execute( + f'insert into free_drink_list_type_config (free_drink_list_config_id, free_drink_list_type_id) values ({data["id"]},{free_drink_type["id"]})') + self.db.connection.commit() + return self.get_free_drink_list_configs() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def update_free_drink_list_config(self, free_drink_list_config): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'update free_drink_list_config set drink_id={free_drink_list_config["drink"]["id"]}, label="{free_drink_list_config["label"]}", price={free_drink_list_config["price"]} where id={free_drink_list_config["id"]}') + cursor.execute(f'delete from free_drink_list_type_config where free_drink_list_config_id={free_drink_list_config["id"]}') + for free_drink_type in free_drink_list_config["free_drink_types"]: + cursor.execute(f'insert into free_drink_list_type_config (free_drink_list_config_id, free_drink_list_type_id) values ({free_drink_list_config["id"]},{free_drink_type["id"]})') + self.db.connection.commit() + return self.get_free_drink_list_configs() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def delete_free_drink_list_config(self, free_drink_list_config): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'delete from free_drink_list_type_config where free_drink_list_config_id={free_drink_list_config["id"]}') + cursor.execute(f'delete from free_drink_list_history where free_drink_config_id={free_drink_list_config["id"]}') + cursor.execute(f'delete from free_drink_list_config where id={free_drink_list_config["id"]}') + self.db.connection.commit() return self.get_free_drink_list_configs() except Exception as err: traceback.print_exc() diff --git a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py index ac271e5..ffcf3e6 100644 --- a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py +++ b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py @@ -11,6 +11,12 @@ class Base: def set_free_drink_list_config(self, data): return db.set_free_drink_list_config(data) + def update_free_drink_list_config(self, data): + return db.update_free_drink_list_config(data) + + def delete_free_drink_list_config(self, data): + return db.delete_free_drink_list_config(data) + def set_free_drink_list_history(self, user, data): return db.set_free_drink_list_history(user, data) diff --git a/geruecht/routes.py b/geruecht/routes.py index 2208f94..e58a5bf 100644 --- a/geruecht/routes.py +++ b/geruecht/routes.py @@ -26,18 +26,38 @@ def _valid(**kwargs): debug.warning("exception in valide.", exc_info=True) return jsonify({"error": str(err)}), 500 -@app.route("/freeDrinkListConfig", methods=['GET']) +@app.route("/freeDrinkListConfig", methods=['GET', 'POST', 'PUT']) @login_required() def _free_drink_list_config(**kwargs): try: debug.info("get free_drink_list_config") - retVal = mainController.get_free_drink_list_configs() + if request.method == 'GET': + retVal = mainController.get_free_drink_list_configs() + if request.method == 'POST': + data = request.get_json() + retVal = mainController.set_free_drink_list_config(data) + if request.method == 'PUT': + data = request.get_json() + retVal = mainController.update_free_drink_list_config(data) debug.info("return free_drink_list_config {{ {} }}".format(retVal)) return jsonify(retVal) except Exception as err: debug.warning("exception in get free_dirnk_list_config.", exc_info=True) return jsonify({"error": str(err)}), 500 +@app.route("/deleteFreeDrinkListConfig", methods=['POST']) +@login_required() +def _delete_free_drink_list_config(**kwargs): + try: + debug.info("delete free_drink_list_config") + data = request.get_json() + retVal = mainController.delete_free_drink_list_config(data) + debug.info("return delete_free_drink_list_config {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.warning("exception in delete_free_dirnk_list_config.", exc_info=True) + return jsonify({"error": str(err)}), 500 + @app.route("/freeDrinkListHistory", methods=['GET', 'POST', 'PUT']) @login_required() def _free_drink_list_history(**kwargs): From 1f5eb0be9d5c5f3805bece508505d8a806f9c1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 23 Aug 2020 23:17:12 +0200 Subject: [PATCH 012/446] FreeDrinkHistory in einem Zeitraumen bekommen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Vorstand kann nun in einem bestimmten Zeitraum alle Freigetränke abrufen. --- .../dbFreeDrinkListConfigController.py | 23 +++++++++++++++++++ .../mainFreeDrinkListConfigController.py | 6 +++++ geruecht/routes.py | 13 +++++++++++ 3 files changed, 42 insertions(+) diff --git a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py index dbc4738..dabb926 100644 --- a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py +++ b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py @@ -156,6 +156,28 @@ class Base: self.db.connection.rollback() raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + def get_free_drink_list_history_from_to(self, from_date, to_date): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'select * from free_drink_list_history where timestamp>="{from_date}" and timestamp<="{to_date}"') + retVal = cursor.fetchall() + for data in retVal: + data['timestamp'] = {'year': data['timestamp'].year, + 'month': data['timestamp'].month, + 'day': data['timestamp'].day, + 'hour': data['timestamp'].hour, + 'minute': data['timestamp'].minute, + 'second': data['timestamp'].second} + data['free_drink_config'] = self.get_free_drink_list_config(data['free_drink_config_id']) + data['free_drink_type'] = self.get_free_drink_list_type(data['free_drink_type_id']) + data['free_drink_list_reason'] = self.get_free_drink_list_reason(data['free_drink_list_reason_id']) if \ + data['free_drink_list_reason_id'] else None + return retVal + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + def update_free_drink_list_history(self, free_drink_list_history): try: cursor = self.db.connection.cursor() @@ -166,6 +188,7 @@ class Base: traceback.print_exc() self.db.connection.rollback() raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + def delete_free_drink_list_history(self, free_drink_list_history): try: cursor = self.db.connection.cursor() diff --git a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py index ffcf3e6..b75af0a 100644 --- a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py +++ b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py @@ -1,5 +1,6 @@ import geruecht.controller.databaseController as dc from geruecht.logger import getDebugLogger +from datetime import datetime db = dc.DatabaseController() debug = getDebugLogger() @@ -30,6 +31,11 @@ class Base: db.update_free_drink_list_history(data) return db.get_free_drink_list_history_by_user(user) + def get_free_drink_list_history_from_to(self, data): + from_date = datetime(data["from_date"]["year"], data["from_date"]["month"], data["from_date"]["day"]) + to_date = datetime(data["to_date"]["year"], data["to_date"]["month"], data["to_date"]["day"]) + return db.get_free_drink_list_history_from_to(from_date, to_date) + def get_free_drink_list_reasons(self): return db.get_free_drink_list_reasons() diff --git a/geruecht/routes.py b/geruecht/routes.py index e58a5bf..625cebd 100644 --- a/geruecht/routes.py +++ b/geruecht/routes.py @@ -78,6 +78,19 @@ def _free_drink_list_history(**kwargs): debug.warning("exception in get free_dirnk_list_config.", exc_info=True) return jsonify({"error": str(err)}), 500 +@app.route("/freeDrinkListHistoryFromTo", methods=['POST']) +@login_required(groups=[VORSTAND]) +def _free_drink_list_history_from_to(**kwargs): + try: + debug.info("get free_drink_list_history") + data = request.get_json() + retVal = mainController.get_free_drink_list_history_from_to(data) + debug.debug(f'return free_drink_list_history {{{retVal}}}') + return jsonify(retVal) + except Exception as err: + debug.warning("exception in get free_dirnk_list_history.", exc_info=True) + return jsonify({"error": str(err)}), 500 + @app.route("/deleteDrinkListHistory", methods=['POST']) @login_required() def _delete_free_drink_list_history(**kwargs): From 32066b10052a45146d4ec3a163e892b39e24309c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 23 Aug 2020 23:58:26 +0200 Subject: [PATCH 013/446] Make it possible to configure plugins. * Reworked configuration --- flaschengeist/__init__.py | 58 ++----- flaschengeist/config.example.yml | 22 --- flaschengeist/flaschengeist.example.cfg | 37 ++++ flaschengeist/modules/__init__.py | 6 + flaschengeist/modules/auth_ldap/__init__.py | 20 +++ flaschengeist/system/config.py | 162 ++++++++++++++++++ flaschengeist/system/configparser.py | 135 --------------- flaschengeist/system/controller/__init__.py | 9 - .../controller/mainController/__init__.py | 2 +- .../mainController/mainUserController.py | 10 +- setup.py | 3 +- 11 files changed, 249 insertions(+), 215 deletions(-) delete mode 100644 flaschengeist/config.example.yml create mode 100644 flaschengeist/flaschengeist.example.cfg create mode 100644 flaschengeist/modules/auth_ldap/__init__.py create mode 100644 flaschengeist/system/config.py delete mode 100644 flaschengeist/system/configparser.py diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 9d97fd0..beb4b32 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -29,56 +29,32 @@ def create_app(): CORS(app) with app.app_context(): - from .system.controller import dbConfig, ldapConfig from .system.database import db - app.config['SECRET_KEY'] = '0a657b97ef546da90b2db91862ad4e29' - - app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://{user}:{passwd}@{host}/{database}'.format( - user=dbConfig['user'], - passwd=dbConfig['passwd'], - host=dbConfig['URL'], - database=dbConfig['database']) - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -# app.config['MYSQL_CURSORCLASS'] = 'DictCursor' - app.config['LDAP_SERVER'] = ldapConfig['URL'] - app.config['LDAP_PORT'] = ldapConfig['PORT'] - if ldapConfig['BIND_DN']: - app.config['LDAP_BINDDN'] = ldapConfig['BIND_DN'] - else: - app.config['LDAP_BINDDN'] = ldapConfig['DN'] - if ldapConfig['BIND_SECRET']: - app.config['LDAP_SECRET'] = ldapConfig['BIND_SECRET'] - app.config['LDAP_USE_TLS'] = False - app.config['LDAP_USE_SSL'] = ldapConfig['SSL'] - app.config['LDAP_TLS_VERSION'] = ssl.PROTOCOL_TLSv1_2 - app.config['LDAP_REQUIRE_CERT'] = ssl.CERT_NONE - app.config['FORCE_ATTRIBUTE_VALUE_AS_LIST'] = True - - app.config['FG_AUTH_BACKENDS'] = [ - entry_point.load() for entry_point in pkg_resources.iter_entry_points('flaschengeist.auth') - ] - - ldap = LDAPConn(app) + from .system.config import configure_app, config + configure_app(app) db.init_app(app) + for entry_point in pkg_resources.iter_entry_points('flaschengeist.auth'): + logger.debug('Found authentification plugin: %s', entry_point.name) + if entry_point.name == config['FLASCHENGEIST']['AUTH']: + app.config['FG_AUTH_BACKEND'] = entry_point.load()() + app.config['FG_AUTH_BACKEND'].configure(config[entry_point.name] if config.has_section(entry_point.name) else None) + logger.info('Loaded authentification plugin > %s <', entry_point.name) + break + if not app.config['FG_AUTH_BACKEND']: + logger.error('No authentification plugin configured or authentification plugin not found') + + logger.info('Search for plugins') discovered_plugins = { entry_point.name: entry_point.load() for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin') } -#from geruecht import routes -#from geruecht.baruser.routes import baruser -#from geruecht.finanzer.routes import finanzer -#from geruecht.user.routes import user -#from geruecht.vorstand.routes import vorstand -#from geruecht.gastro.routes import gastrouser -#from geruecht.registration_route import registration - - app.logger.info("Registrate bluebrints") for name in discovered_plugins: - app.logger.info("Register plugin: %s" % name) - app.register_blueprint(discovered_plugins[name]()) + logger.debug("Found plugin: %s", name) + if config.get(name, 'enabled', fallback=False): + logger.info('Loaded plugin > %s <', name) + app.register_blueprint(discovered_plugins[name]()) return app #app.register_blueprint(baruser) diff --git a/flaschengeist/config.example.yml b/flaschengeist/config.example.yml deleted file mode 100644 index 62a16a2..0000000 --- a/flaschengeist/config.example.yml +++ /dev/null @@ -1,22 +0,0 @@ -AccessTokenLifeTime: 1800 -Database: - URL: - user: - passwd: - database: -LDAP: - URL: - DN: - BIND_DN: - BIND_SECRET: - SSL: - USER_DN: - ADMIN_DN: - ADMIN_SECRET: -Mail: - URL: - port: - user: - passwd: - email: - crypt: SSL/STARTLS \ No newline at end of file diff --git a/flaschengeist/flaschengeist.example.cfg b/flaschengeist/flaschengeist.example.cfg new file mode 100644 index 0000000..1631a1c --- /dev/null +++ b/flaschengeist/flaschengeist.example.cfg @@ -0,0 +1,37 @@ +[FLASCHENGEIST] +# Set lifetime of session (idle time until you get logged out) +AccessTokenLifetime = 1800 +# Select authentification provider +AUTH = auth_plain + +[DATABASE] +USER = +HOST = +PASSWD = +DATABASE = + +# [LDAP] +# URL = +# PORT = +# BINDDN = +# SECRET = +# USE_SSL = +## ADMIN_DN: +## ADMIN_SECRET: + +[MAIL] +URL = +PORT = +USER = +PASSWD = +MAIL = +CRYPT = SSL/STARTLS + +############################ +# Configuration of plugins # +############################ +[geruecht] +enable = true + +[schubu] +enable = false diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index a0c81bc..728864c 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -1,4 +1,10 @@ class Auth(): + def defaultConfig(self): + return None + + def configure(self, config): + pass + def login(self, user, pw): """ user User class containing at least the uid diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py new file mode 100644 index 0000000..b69f2c9 --- /dev/null +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -0,0 +1,20 @@ +import flaschengeist.modules as modules +from flask import current_app as app + +class AuthPlain(modules.Auth): + def configure(self, config): + app.config.update( + 'LDAP_SERVER' = config['URL'], + 'LDAP_PORT' = config['PORT'], + 'LDAP_BINDDN' = config['BINDDN'], + 'LDAP_SECRET' = config['SECRET'] + 'LDAP_USE_SSL' = config['USE_SSL'], + 'LDAP_TLS_VERSION' = ssl.PROTOCOL_TLSv1_2, + 'LDAP_REQUIRE_CERT' = ssl.CERT_NONE, + 'FORCE_ATTRIBUTE_VALUE_AS_LIST' = True + ) + + def login(self, user, password): + if not user: + return False + return False diff --git a/flaschengeist/system/config.py b/flaschengeist/system/config.py new file mode 100644 index 0000000..1ba87c5 --- /dev/null +++ b/flaschengeist/system/config.py @@ -0,0 +1,162 @@ +import configparser +import os +from pathlib import Path +from .. import _modpath, logger + +default = { + 'FLASCHENGEIST': { + 'AccessTokenLifeTime': 1800 + }, + 'MAIL': { + 'CRYPT': 'SSL/STARTLS' + } +} + +config = configparser.ConfigParser() +config.read_dict(default) +pathes = [_modpath, Path.home()/".config"] +if 'FLASCHENGEIST_CONF' in os.environ: + pathes.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) +for loc in pathes: + try: + with (loc/"flaschengeist.cfg").open() as source: + config.read_file(source) + except IOError: + pass +# Always enable this buildin plugins! +config.read_dict({ + 'auth': { + 'enabled': True + } +}) + + +def configure_app(app): + if not config.has_option('FLASCHENGEIST', 'SECRET_KEY'): + logger.warn('No secret key was configured, please configure one for production systems!') + app.config['SECRET_KEY'] = config.get('FLASCHENGEIST', 'SECRET_KEY', fallback='0a657b97ef546da90b2db91862ad4e29') + + app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://{user}:{passwd}@{host}/{database}'.format( + user=config['DATABASE']['user'], + passwd=config['DATABASE']['passwd'], + host=config['DATABASE']['host'], + database=config['DATABASE']['database'] + ) + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +#class ConifgParser(): +# def __init__(self, file='config.yml'): +# self.file = file +# with open(file, 'r') as f: +# self.config = yaml.safe_load(f) +# +# if 'Database' not in self.config: +# self.__error__( +# 'Wrong Configuration for Database. You should configure databaseconfig with "URL", "user", "passwd", "database"') +# if 'URL' not in self.config['Database'] or 'user' not in self.config['Database'] or 'passwd' not in self.config['Database'] or 'database' not in self.config['Database']: +# self.__error__( +# 'Wrong Configuration for Database. You should configure databaseconfig with "URL", "user", "passwd", "database"') +# +# self.db = self.config['Database'] +# logger.debug("Set Databaseconfig: {}".format(self.db)) +# +# if 'LDAP' not in self.config: +# self.__error__( +# 'Wrong Configuration for LDAP. You should configure ldapconfig with "URL" and "BIND_DN"') +# if 'URL' not in self.config['LDAP'] or 'DN' not in self.config['LDAP']: +# self.__error__( +# 'Wrong Configuration for LDAP. You should configure ldapconfig with "URL" and "BIND_DN"') +# if 'PORT' not in self.config['LDAP']: +# logger.info( +# 'No Config for port in LDAP found. Set it to default: {}'.format(389)) +# self.config['LDAP']['PORT'] = 389 +# if 'ADMIN_DN' not in self.config['LDAP']: +# logger.info( +# 'No Config for ADMIN_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) +# ) +# self.config['LDAP']['ADMIN_DN'] = None +# if 'ADMIN_SECRET' not in self.config['LDAP']: +# logger.info( +# 'No Config for ADMIN_SECRET in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) +# ) +# self.config['LDAP']['ADMIN_SECRET'] = None +# if 'USER_DN' not in self.config['LDAP']: +# logger.info( +# 'No Config for USER_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) +# ) +# self.config['LDAP']['USER_DN'] = None +# if 'BIND_DN' not in self.config['LDAP']: +# logger.info( +# 'No Config for BIND_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) +# ) +# self.config['LDAP']['BIND_DN'] = None +# if 'BIND_SECRET' not in self.config['LDAP']: +# logger.info( +# 'No Config for BIND_SECRET in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) +# ) +# self.config['LDAP']['BIND_SECRET'] = None +# if 'SSL' not in self.config['LDAP']: +# logger.info( +# 'No Config for SSL in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(False) +# ) +# self.config['LDAP']['SSL'] = False +# else: +# self.config['LDAP']['SSL'] = bool(self.config['LDAP']['SSL']) +# self.ldap = self.config['LDAP'] +# logger.debug("Set LDAPconfig: {}".format(self.ldap)) +# if 'AccessTokenLifeTime' in self.config: +# self.accessTokenLifeTime = int(self.config['AccessTokenLifeTime']) +# logger.info("Set AccessTokenLifeTime: {}".format( +# self.accessTokenLifeTime)) +# else: +# self.accessTokenLifeTime = default['AccessTokenLifeTime'] +# logger.info("No Config for AccessTokenLifetime found. Set it to default: {}".format( +# self.accessTokenLifeTime)) +# +# if 'Mail' not in self.config: +# self.config['Mail'] = default['Mail'] +# logger.info('No Conifg for Mail found. Set it to defaul: {}'.format( +# self.config['Mail'])) +# if 'URL' not in self.config['Mail']: +# self.config['Mail']['URL'] = default['Mail']['URL'] +# logger.info("No Config for URL in Mail found. Set it to default") +# if 'port' not in self.config['Mail']: +# self.config['Mail']['port'] = default['Mail']['port'] +# logger.info("No Config for port in Mail found. Set it to default") +# else: +# self.config['Mail']['port'] = int(self.config['Mail']['port']) +# logger.info("No Conifg for port in Mail found. Set it to default") +# if 'user' not in self.config['Mail']: +# self.config['Mail']['user'] = default['Mail']['user'] +# logger.info("No Config for user in Mail found. Set it to default") +# if 'passwd' not in self.config['Mail']: +# self.config['Mail']['passwd'] = default['Mail']['passwd'] +# logger.info("No Config for passwd in Mail found. Set it to default") +# if 'email' not in self.config['Mail']: +# self.config['Mail']['email'] = default['Mail']['email'] +# logger.info("No Config for email in Mail found. Set it to default") +# if 'crypt' not in self.config['Mail']: +# self.config['Mail']['crypt'] = default['Mail']['crypt'] +# logger.info("No Config for crypt in Mail found. Set it to default") +# self.mail = self.config['Mail'] +# logger.debug('Set Mailconfig: {}'.format(self.mail)) +# +# def getLDAP(self): +# return self.ldap +# +# def getDatabase(self): +# return self.db +# +# def getAccessToken(self): +# return self.accessTokenLifeTime +# +# def getMail(self): +# return self.mail +# +# def __error__(self, msg): +# logger.error(msg, exc_info=True) +# sys.exit(-1) +# +# +#if __name__ == '__main__': +# ConifgParser() diff --git a/flaschengeist/system/configparser.py b/flaschengeist/system/configparser.py deleted file mode 100644 index 94297e4..0000000 --- a/flaschengeist/system/configparser.py +++ /dev/null @@ -1,135 +0,0 @@ -import yaml -import sys -from flask import current_app -from werkzeug.local import LocalProxy -logger = LocalProxy(lambda: current_app.logger) - -default = { - 'AccessTokenLifeTime': 1800, - 'Mail': { - 'URL': '', - 'port': 0, - 'user': '', - 'passwd': '', - 'email': '', - 'crypt': 'STARTTLS' - } -} - - -class ConifgParser(): - def __init__(self, file='config.yml'): - self.file = file - with open(file, 'r') as f: - self.config = yaml.safe_load(f) - - if 'Database' not in self.config: - self.__error__( - 'Wrong Configuration for Database. You should configure databaseconfig with "URL", "user", "passwd", "database"') - if 'URL' not in self.config['Database'] or 'user' not in self.config['Database'] or 'passwd' not in self.config['Database'] or 'database' not in self.config['Database']: - self.__error__( - 'Wrong Configuration for Database. You should configure databaseconfig with "URL", "user", "passwd", "database"') - - self.db = self.config['Database'] - logger.debug("Set Databaseconfig: {}".format(self.db)) - - if 'LDAP' not in self.config: - self.__error__( - 'Wrong Configuration for LDAP. You should configure ldapconfig with "URL" and "BIND_DN"') - if 'URL' not in self.config['LDAP'] or 'DN' not in self.config['LDAP']: - self.__error__( - 'Wrong Configuration for LDAP. You should configure ldapconfig with "URL" and "BIND_DN"') - if 'PORT' not in self.config['LDAP']: - logger.info( - 'No Config for port in LDAP found. Set it to default: {}'.format(389)) - self.config['LDAP']['PORT'] = 389 - if 'ADMIN_DN' not in self.config['LDAP']: - logger.info( - 'No Config for ADMIN_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) - ) - self.config['LDAP']['ADMIN_DN'] = None - if 'ADMIN_SECRET' not in self.config['LDAP']: - logger.info( - 'No Config for ADMIN_SECRET in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) - ) - self.config['LDAP']['ADMIN_SECRET'] = None - if 'USER_DN' not in self.config['LDAP']: - logger.info( - 'No Config for USER_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) - ) - self.config['LDAP']['USER_DN'] = None - if 'BIND_DN' not in self.config['LDAP']: - logger.info( - 'No Config for BIND_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) - ) - self.config['LDAP']['BIND_DN'] = None - if 'BIND_SECRET' not in self.config['LDAP']: - logger.info( - 'No Config for BIND_SECRET in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) - ) - self.config['LDAP']['BIND_SECRET'] = None - if 'SSL' not in self.config['LDAP']: - logger.info( - 'No Config for SSL in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(False) - ) - self.config['LDAP']['SSL'] = False - else: - self.config['LDAP']['SSL'] = bool(self.config['LDAP']['SSL']) - self.ldap = self.config['LDAP'] - logger.debug("Set LDAPconfig: {}".format(self.ldap)) - if 'AccessTokenLifeTime' in self.config: - self.accessTokenLifeTime = int(self.config['AccessTokenLifeTime']) - logger.info("Set AccessTokenLifeTime: {}".format( - self.accessTokenLifeTime)) - else: - self.accessTokenLifeTime = default['AccessTokenLifeTime'] - logger.info("No Config for AccessTokenLifetime found. Set it to default: {}".format( - self.accessTokenLifeTime)) - - if 'Mail' not in self.config: - self.config['Mail'] = default['Mail'] - logger.info('No Conifg for Mail found. Set it to defaul: {}'.format( - self.config['Mail'])) - if 'URL' not in self.config['Mail']: - self.config['Mail']['URL'] = default['Mail']['URL'] - logger.info("No Config for URL in Mail found. Set it to default") - if 'port' not in self.config['Mail']: - self.config['Mail']['port'] = default['Mail']['port'] - logger.info("No Config for port in Mail found. Set it to default") - else: - self.config['Mail']['port'] = int(self.config['Mail']['port']) - logger.info("No Conifg for port in Mail found. Set it to default") - if 'user' not in self.config['Mail']: - self.config['Mail']['user'] = default['Mail']['user'] - logger.info("No Config for user in Mail found. Set it to default") - if 'passwd' not in self.config['Mail']: - self.config['Mail']['passwd'] = default['Mail']['passwd'] - logger.info("No Config for passwd in Mail found. Set it to default") - if 'email' not in self.config['Mail']: - self.config['Mail']['email'] = default['Mail']['email'] - logger.info("No Config for email in Mail found. Set it to default") - if 'crypt' not in self.config['Mail']: - self.config['Mail']['crypt'] = default['Mail']['crypt'] - logger.info("No Config for crypt in Mail found. Set it to default") - self.mail = self.config['Mail'] - logger.debug('Set Mailconfig: {}'.format(self.mail)) - - def getLDAP(self): - return self.ldap - - def getDatabase(self): - return self.db - - def getAccessToken(self): - return self.accessTokenLifeTime - - def getMail(self): - return self.mail - - def __error__(self, msg): - logger.error(msg, exc_info=True) - sys.exit(-1) - - -if __name__ == '__main__': - ConifgParser() diff --git a/flaschengeist/system/controller/__init__.py b/flaschengeist/system/controller/__init__.py index 095c03c..83ca20c 100644 --- a/flaschengeist/system/controller/__init__.py +++ b/flaschengeist/system/controller/__init__.py @@ -1,8 +1,3 @@ -from ..configparser import ConifgParser -from flaschengeist import _modpath - -config = ConifgParser(_modpath/'config.yml') - class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): @@ -10,7 +5,3 @@ class Singleton(type): cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) return cls._instances[cls] -dbConfig = config.getDatabase() -ldapConfig = config.getLDAP() -accConfig = config.getAccessToken() -mailConfig = config.getMail() diff --git a/flaschengeist/system/controller/mainController/__init__.py b/flaschengeist/system/controller/mainController/__init__.py index d426514..4fad656 100644 --- a/flaschengeist/system/controller/mainController/__init__.py +++ b/flaschengeist/system/controller/mainController/__init__.py @@ -1,4 +1,4 @@ -from .. import Singleton, mailConfig +from .. import Singleton from ...models.user import User from datetime import datetime, timedelta from . import mainUserController diff --git a/flaschengeist/system/controller/mainController/mainUserController.py b/flaschengeist/system/controller/mainController/mainUserController.py index 1f553fa..d37cb6f 100644 --- a/flaschengeist/system/controller/mainController/mainUserController.py +++ b/flaschengeist/system/controller/mainController/mainUserController.py @@ -167,10 +167,8 @@ class Base: user = User.query.filter_by(uid=username).first() if user is None: user = User(uid=username) - for backend in current_app.config['FG_AUTH_BACKENDS']: - b = backend() - if b.login(user, password): - db.session.add(user) - db.session.commit() - return user + if current_app.config['FG_AUTH_BACKEND'].login(user, password): + db.session.add(user) + db.session.commit() + return user raise PermissionDenied() diff --git a/setup.py b/setup.py index b16c5c4..83cff6d 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,8 @@ setup( 'auth = flaschengeist.modules.auth:register' ], 'flaschengeist.auth': [ - 'plain_auth = flaschengeist.modules.auth_plain:AuthPlain' + 'auth_plain = flaschengeist.modules.auth_plain:AuthPlain', + 'auth_ldap = flaschengeist.modules.auth_ldap:AuthLDAP' ] } ) From fe7b81a5349b6f5c311d92d24b56de72dfc84f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 24 Aug 2020 14:05:50 +0200 Subject: [PATCH 014/446] =?UTF-8?q?Users=20f=C3=BCr=20FreeDrinkListHistory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der User wird mit gesenden, wenn man die FreeDrinkListHistory abruft. Dabei wurde die Funktion des Users angepasst, dass nicht ständig, das komplette Gerücht neu initialisiert wird. --- .../databaseController/dbFreeDrinkListConfigController.py | 1 + geruecht/controller/databaseController/dbUserController.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py index dabb926..1481260 100644 --- a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py +++ b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py @@ -172,6 +172,7 @@ class Base: data['free_drink_type'] = self.get_free_drink_list_type(data['free_drink_type_id']) data['free_drink_list_reason'] = self.get_free_drink_list_reason(data['free_drink_list_reason_id']) if \ data['free_drink_list_reason_id'] else None + data['user'] = self.getUserById(data['user_id'], workgroups=False, geruecht=False).toJSON() return retVal except Exception as err: traceback.print_exc() diff --git a/geruecht/controller/databaseController/dbUserController.py b/geruecht/controller/databaseController/dbUserController.py index 3e419d4..4c1dd1c 100644 --- a/geruecht/controller/databaseController/dbUserController.py +++ b/geruecht/controller/databaseController/dbUserController.py @@ -48,7 +48,7 @@ class Base: self.db.connection.rollback() raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - def getUserById(self, id, workgroups=True): + def getUserById(self, id, workgroups=True, geruecht=True): try: retVal = None cursor = self.db.connection.cursor() @@ -56,8 +56,9 @@ class Base: data = cursor.fetchone() if data: retVal = User(data) - creditLists = self.getCreditListFromUser(retVal) - retVal.initGeruechte(creditLists) + if geruecht: + creditLists = self.getCreditListFromUser(retVal) + retVal.initGeruechte(creditLists) if workgroups: retVal.workgroups = self.getWorkgroupsOfUser(retVal.id) return retVal From 6249b143f1515cc042a7dfd0301bfe39c2fde08a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 24 Aug 2020 15:19:12 +0200 Subject: [PATCH 015/446] =?UTF-8?q?Gr=C3=BCnde=20f=C3=BCr=20Freigetr=C3=A4?= =?UTF-8?q?nke=20k=C3=B6nnen=20erstell=20und=20gel=C3=B6scht=20werden.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auch modifizieren ist möglich --- .../dbFreeDrinkListConfigController.py | 34 +++++++++++++++++++ .../mainFreeDrinkListConfigController.py | 9 +++++ geruecht/routes.py | 23 +++++++++++-- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py index 1481260..5844e54 100644 --- a/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py +++ b/geruecht/controller/databaseController/dbFreeDrinkListConfigController.py @@ -216,6 +216,40 @@ class Base: cursor = self.db.connection.cursor() cursor.execute(f'select * from free_drink_list_reason') return cursor.fetchall() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def set_free_drink_list_reason(self, free_drink_list_reason): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'insert into free_drink_list_reason (name) values ("{free_drink_list_reason["name"]}")') + self.db.connection.commit() + return self.get_free_drink_list_reasons() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def update_free_drink_list_reason(self, free_drink_list_reason): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'update free_drink_list_reason set name="{free_drink_list_reason["name"]}" where id={free_drink_list_reason["id"]}') + self.db.connection.commit() + return self.get_free_drink_list_reasons() + except Exception as err: + traceback.print_exc() + self.db.connection.rollback() + raise DatabaseExecption("Something went wrong with Database: {}".format(err)) + + def delete_free_drink_list_reason(self, free_drink_list_reason): + try: + cursor = self.db.connection.cursor() + cursor.execute(f'update free_drink_list_history set free_drink_list_reason_id=NULL where free_drink_list_reason_id={free_drink_list_reason["id"]}') + cursor.execute(f'delete from free_drink_list_reason where id={free_drink_list_reason["id"]}') + self.db.connection.commit() + return self.get_free_drink_list_reasons() except Exception as err: traceback.print_exc() self.db.connection.rollback() diff --git a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py index b75af0a..1e8fe9e 100644 --- a/geruecht/controller/mainController/mainFreeDrinkListConfigController.py +++ b/geruecht/controller/mainController/mainFreeDrinkListConfigController.py @@ -39,5 +39,14 @@ class Base: def get_free_drink_list_reasons(self): return db.get_free_drink_list_reasons() + def set_free_drink_list_reason(self, data): + return db.set_free_drink_list_reason(data) + + def update_free_drink_list_reason(self, data): + return db.update_free_drink_list_reason(data) + + def delete_free_drink_list_reason(self, data): + return db.delete_free_drink_list_reason(data) + def get_free_drink_types(self): return db.get_free_drink_list_types() \ No newline at end of file diff --git a/geruecht/routes.py b/geruecht/routes.py index 625cebd..810b141 100644 --- a/geruecht/routes.py +++ b/geruecht/routes.py @@ -103,12 +103,31 @@ def _delete_free_drink_list_history(**kwargs): debug.warning("exception in delete free_dirnk_list_config.", exc_info=True) return jsonify({"error": str(err)}), 500 -@app.route("/freeDrinkListReasons", methods=['GET']) +@app.route("/freeDrinkListReasons", methods=['GET', 'POST', 'PUT']) @login_required() def _free_drink_list_reasons(**kwargs): try: debug.info("get free_drink_list_reasons") - retVal = mainController.get_free_drink_list_reasons() + if request.method == 'GET': + retVal = mainController.get_free_drink_list_reasons() + elif request.method == 'POST' or request.method == 'PUT': + data = request.get_json() + if request.method == 'POST': + retVal = mainController.set_free_drink_list_reason(data) + else: + retVal = mainController.update_free_drink_list_reason(data) + return jsonify(retVal) + except Exception as err: + debug.warning("exception in delete free_dirnk_list_reasons.", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@app.route("/deleteFreeDrinkListReason", methods=['POST']) +@login_required(groups=[VORSTAND]) +def __delete_free_drink_list_reason(**kwargs): + try: + debug.info("delete free_drink_list_reason") + data = request.get_json() + retVal = mainController.delete_free_drink_list_reason(data) return jsonify(retVal) except Exception as err: debug.warning("exception in delete free_dirnk_list_reasons.", exc_info=True) From 5f408bfd3caa4aace8c6f5d3578b1be901b343a8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 25 Aug 2020 04:31:34 +0200 Subject: [PATCH 016/446] Support lifetime methods on accesstokens --- flaschengeist/modules/auth/__init__.py | 53 +++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 1559465..bfd7f21 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -33,13 +33,13 @@ def _logout(**kwargs): logger.debug("accesstoken is {{ {} }}".format(accToken)) logger.debug("delete accesstoken") accesTokenController.deleteAccessToken(accToken) + accesTokenController.clearExpired() logger.info("return ok logout user") return jsonify({"ok": "ok"}) except Exception as err: logger.warning("exception in logout user.", exc_info=True) return jsonify({"error": str(err)}), 500 - @auth_bp.route("/login", methods=['POST']) def _login(): """ Login User @@ -65,7 +65,7 @@ def _login(): logger.debug("accesstoken is {{ {} }}".format(token)) logger.debug("validate accesstoken") dic = user.toJSON() - dic["token"] = token + dic["accessToken"] = token logger.info("User {{ {} }} success login.".format(username)) logger.debug("return login {{ {} }}".format(dic)) return jsonify(dic) @@ -75,3 +75,52 @@ def _login(): except Exception as err: logger.error("exception in login.", exc_info=True) return jsonify({"error": "permission denied"}), 401 + +@auth_bp.route("/user/getAccessTokens", methods=['GET', 'POST']) +@login_required() +def _getAccessTokens(**kwargs): + try: + if request.method == 'POST': + data = request.get_json() + accesTokenController.deleteAccessToken(accToken) + delAccToken = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) + accesTokenController.deleteAccessToken(delAccToken) + tokens = accesTokenController.getAccessTokensFromUser(kwargs['accToken'].user) + r = [t.toJSON() for t in tokens] + logger.debug("return {{ {} }}".format(r)) + return jsonify(r) + except Exception as err: + logger.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@auth_bp.route("/getLifetime", methods=['GET']) +@login_required() +def _getLifeTime(**kwargs): + try: + logger.debug("get lifetime of accesstoken") + accToken = kwargs['accToken'] + logger.debug("accessToken is {{ {} }}".format(accToken)) + return jsonify({"value": accToken.lifetime}) + except Exception as err: + logger.warning("exception in get lifetime of accesstoken.", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@auth_bp.route("/setLifetime", methods=['POST']) +@login_required() +def _saveLifeTime(**kwargs): + try: + accToken = kwargs['accToken'] + logger.debug("save lifetime for accessToken {{ {} }}".format(accToken)) + data = request.get_json() + lifetime = data['value'] + logger.debug("lifetime is {{ {} }}".format(lifetime)) + logger.info("set lifetime {{ {} }} to accesstoken {{ {} }}".format( + lifetime, accToken)) + accToken.lifetime = lifetime + logger.info("update accesstoken timestamp") + accToken = accesTokenController.updateAccessToken(accToken) + return jsonify({"value": accToken.lifetime }) + except Exception as err: + logger.warning( + "exception in save lifetime for accesstoken.", exc_info=True) + return jsonify({"error": str(err)}), 500 From bbee163954a2a5896c0d6361934060c731150f9d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 25 Aug 2020 04:34:14 +0200 Subject: [PATCH 017/446] Fixed plugin detection --- flaschengeist/__init__.py | 2 +- flaschengeist/modules/__init__.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index beb4b32..441e331 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -38,7 +38,7 @@ def create_app(): logger.debug('Found authentification plugin: %s', entry_point.name) if entry_point.name == config['FLASCHENGEIST']['AUTH']: app.config['FG_AUTH_BACKEND'] = entry_point.load()() - app.config['FG_AUTH_BACKEND'].configure(config[entry_point.name] if config.has_section(entry_point.name) else None) + app.config['FG_AUTH_BACKEND'].configure(config[entry_point.name] if config.has_section(entry_point.name) else {}) logger.info('Loaded authentification plugin > %s <', entry_point.name) break if not app.config['FG_AUTH_BACKEND']: diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index 728864c..a985443 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -1,7 +1,4 @@ class Auth(): - def defaultConfig(self): - return None - def configure(self, config): pass From 7d8fa4f63043a3a863ac7cc0b44df6d82dc46740 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 25 Aug 2020 04:34:57 +0200 Subject: [PATCH 018/446] Fixed main- and accessToken Controller to work with pluginify --- .../system/controller/accesTokenController.py | 45 ++- .../mainController/mainUserController.py | 320 +++++++++--------- flaschengeist/system/models/user.py | 30 +- 3 files changed, 205 insertions(+), 190 deletions(-) diff --git a/flaschengeist/system/controller/accesTokenController.py b/flaschengeist/system/controller/accesTokenController.py index c6274be..c89b478 100644 --- a/flaschengeist/system/controller/accesTokenController.py +++ b/flaschengeist/system/controller/accesTokenController.py @@ -84,31 +84,28 @@ class AccesTokenController(metaclass=Singleton): logger.debug("accesstoken is {{ {} }}".format(accToken)) return token - def isSameGroup(self, accToken, groups): - """ Verify group in AccessToken - - Verify if the User in the AccesToken has the right group. - - Args: - accToken: AccessToken to verify. - groups: Group to verify. - - Returns: - A Bool. If the same then True else False - """ - debug.info("check accesstoken {{ {} }} has group {{ {} }}".format(accToken, groups)) - for group in groups: - if group in accToken.user.group: return True - return False - def getAccessTokensFromUser(self, user): - return db.getAccessTokensFromUser(user) + return AccessToken.query.filter(AccessToken.user == user) - def deleteAccessToken(self, accToken): - db.session.delete(accToken) + def deleteAccessToken(self, accessToken): + if accessToken is isinstance(accessToken, AccessToken): + db.session.delete(accessToken) + else: + AccessToken.query.filter_by(token=accessToken).delete() db.session.commit() - #AccessToken.query.filter_by(token=accToken).delete() - def updateAccessToken(self, accToken): - accToken.updateTimestamp() - return db.updateAccessToken(accToken) + def updateAccessToken(self, accessToken): + accessToken.updateTimestamp() + db.session.commit() + return accessToken + + def clearExpired(self): + logger.debug("Clear expired AccessToken") + mightExpired = datetime.utcnow() - timedelta(seconds=self.lifetime) + tokens = AccessToken.query.filter(AccessToken.timestamp < mightExpired) + logger.debug(tokens) + for token in tokens: + if token.timestamp < datetime.utcnow() - timedelta(seconds=token.lifetime): + logger.debug("Delete token %s", token.token) + db.session.delete(token) + db.session.commit() diff --git a/flaschengeist/system/controller/mainController/mainUserController.py b/flaschengeist/system/controller/mainController/mainUserController.py index d37cb6f..761c9ba 100644 --- a/flaschengeist/system/controller/mainController/mainUserController.py +++ b/flaschengeist/system/controller/mainController/mainUserController.py @@ -1,167 +1,11 @@ -from flaschengeist.system.exceptions import UsernameExistLDAP, LDAPExcetpion, PermissionDenied +from flask import current_app + +from flaschengeist.system.exceptions import PermissionDenied from flaschengeist.system.models.user import User from flaschengeist.system.database import db - -from flask import Blueprint, current_app -from werkzeug.local import LocalProxy -logger = LocalProxy(lambda: current_app.logger) +from flaschengeist import logger class Base: - def getAllStatus(self): - debug.info("get all status for user") - retVal = db.getAllStatus() - debug.debug("status are {{ {} }}".format(retVal)) - return retVal - - def getStatus(self, name): - debug.info("get status of user {{ {} }}".format(name)) - retVal = db.getStatus(name) - debug.debug("status of user {{ {} }} is {{ {} }}".format(name, retVal)) - return retVal - - def setStatus(self, name): - debug.info("set status of user {{ {} }}".format(name)) - retVal = db.setStatus(name) - debug.debug( - "settet status of user {{ {} }} is {{ {} }}".format(name, retVal)) - return retVal - - def deleteStatus(self, status): - debug.info("delete status {{ {} }}".format(status)) - db.deleteStatus(status) - - def updateStatus(self, status): - debug.info("update status {{ {} }}".format(status)) - retVal = db.updateStatus(status) - debug.debug("updated status is {{ {} }}".format(retVal)) - return retVal - - def updateStatusOfUser(self, username, status): - debug.info("update status {{ {} }} of user {{ {} }}".format( - status, username)) - retVal = db.updateStatusOfUser(username, status) - debug.debug( - "updatet status of user {{ {} }} is {{ {} }}".format(username, retVal)) - return retVal - - def updateVotingOfUser(self, username, voting): - debug.info("update voting {{ {} }} of user {{ {} }}".format( - voting, username)) - retVal = db.updateVotingOfUser(username, voting) - debug.debug( - "updatet voting of user {{ {} }} is {{ {} }}".format(username, retVal)) - return retVal - - def lockUser(self, username, locked): - debug.info("lock user {{ {} }} for credit with status {{ {} }}".format( - username, locked)) - user = self.getUser(username) - debug.debug("user is {{ {} }}".format(user)) - user.updateData({'locked': locked}) - db.updateUser(user) - retVal = self.getUser(username) - debug.debug("locked user is {{ {} }}".format(retVal)) - return retVal - - def updateConfig(self, username, data): - debug.info( - "update config of user {{ {} }} with config {{ {} }}".format(username, data)) - user = self.getUser(username) - debug.debug("user is {{ {} }}".format(user)) - user.updateData(data) - db.updateUser(user) - retVal = self.getUser(username) - debug.debug("updated config of user is {{ {} }}".format(retVal)) - return retVal - - def syncLdap(self): - debug.info('sync Users from Ldap') - ldap_users = ldap.getAllUser() - for user in ldap_users: - self.getUser(user['username']) - - def getAllUsersfromDB(self, extern=True): - debug.info("get all users from database") - if (len(ldap.getAllUser()) != len(db.getAllUser())): - self.syncLdap() - users = db.getAllUser() - debug.debug("users are {{ {} }}".format(users)) - for user in users: - try: - debug.debug("update data from ldap") - self.__updateDataFromLDAP(user) - except: - pass - debug.debug("update creditlists") - self.__updateGeruechte(user) - retVal = db.getAllUser(extern=extern) - debug.debug("all users are {{ {} }}".format(retVal)) - return retVal - - def getUser(self, username): - debug.info("get user {{ {} }}".format(username)) - user = db.getUser(username) - debug.debug("user is {{ {} }}".format(user)) - groups = ldap.getGroup(username) - debug.debug("groups are {{ {} }}".format(groups)) - user_data = ldap.getUserData(username) - debug.debug("user data from ldap is {{ {} }}".format(user_data)) - user_data['gruppe'] = groups - user_data['group'] = groups - if user is None: - debug.debug("user not exists in database -> insert into database") - user = User(user_data) - db.insertUser(user) - else: - debug.debug("update database with user") - user.updateData(user_data) - db.updateUser(user) - user = db.getUser(username) - self.__updateGeruechte(user) - debug.debug("user is {{ {} }}".format(user)) - return user - - def modifyUser(self, user, attributes, password): - debug.info("modify user {{ {} }} with attributes (can't show because here can be a password)".format( - user)) - - try: - ldap_conn = ldap.bind(user, password) - if attributes: - if 'username' in attributes: - debug.debug("change username, so change first in database") - db.changeUsername(user, attributes['username']) - ldap.modifyUser(user, ldap_conn, attributes) - if 'username' in attributes: - retVal = self.getUser(attributes['username']) - debug.debug("user is {{ {} }}".format(retVal)) - return retVal - else: - retVal = self.getUser(user.uid) - debug.debug("user is {{ {} }}".format(retVal)) - return retVal - return self.getUser(user.uid) - - except UsernameExistLDAP as err: - debug.debug( - "username exists on ldap, rechange username on database", exc_info=True) - db.changeUsername(user, user.uid) - raise Exception(err) - except LDAPExcetpion as err: - if 'username' in attributes: - db.changeUsername(user, user.uid) - raise Exception(err) - except LDAPPasswordIsMandatoryError as err: - raise Exception('Password wurde nicht gesetzt!!') - except LDAPBindError as err: - raise Exception('Password ist falsch') - except Exception as err: - raise Exception(err) - - def validateUser(self, username, password): - debug.info("validate user {{ {} }}".format(username)) - ldap.login(username, password) - def loginUser(self, username, password): logger.info("login user {{ {} }}".format(username)) user = User.query.filter_by(uid=username).first() @@ -169,6 +13,162 @@ class Base: user = User(uid=username) if current_app.config['FG_AUTH_BACKEND'].login(user, password): db.session.add(user) + current_app.config['FG_AUTH_BACKEND'].updateUser(user) db.session.commit() return user raise PermissionDenied() + + #def getAllStatus(self): + #debug.info("get all status for user") + #retVal = db.getAllStatus() + #debug.debug("status are {{ {} }}".format(retVal)) + #return retVal + + #def getStatus(self, name): + #debug.info("get status of user {{ {} }}".format(name)) + #retVal = db.getStatus(name) + #debug.debug("status of user {{ {} }} is {{ {} }}".format(name, retVal)) + #return retVal + + #def setStatus(self, name): + #debug.info("set status of user {{ {} }}".format(name)) + #retVal = db.setStatus(name) + #debug.debug( + #"settet status of user {{ {} }} is {{ {} }}".format(name, retVal)) + #return retVal + + #def deleteStatus(self, status): + #debug.info("delete status {{ {} }}".format(status)) + #db.deleteStatus(status) + + #def updateStatus(self, status): + #debug.info("update status {{ {} }}".format(status)) + #retVal = db.updateStatus(status) + #debug.debug("updated status is {{ {} }}".format(retVal)) + #return retVal + + #def updateStatusOfUser(self, username, status): + #debug.info("update status {{ {} }} of user {{ {} }}".format( + #status, username)) + #retVal = db.updateStatusOfUser(username, status) + #debug.debug( + #"updatet status of user {{ {} }} is {{ {} }}".format(username, retVal)) + #return retVal + + #def updateVotingOfUser(self, username, voting): + #debug.info("update voting {{ {} }} of user {{ {} }}".format( + #voting, username)) + #retVal = db.updateVotingOfUser(username, voting) + #debug.debug( + #"updatet voting of user {{ {} }} is {{ {} }}".format(username, retVal)) + #return retVal + + #def lockUser(self, username, locked): + #debug.info("lock user {{ {} }} for credit with status {{ {} }}".format( + #username, locked)) + #user = self.getUser(username) + #debug.debug("user is {{ {} }}".format(user)) + #user.updateData({'locked': locked}) + #db.updateUser(user) + #retVal = self.getUser(username) + #debug.debug("locked user is {{ {} }}".format(retVal)) + #return retVal + + #def updateConfig(self, username, data): + #debug.info( + #"update config of user {{ {} }} with config {{ {} }}".format(username, data)) + #user = self.getUser(username) + #debug.debug("user is {{ {} }}".format(user)) + #user.updateData(data) + #db.updateUser(user) + #retVal = self.getUser(username) + #debug.debug("updated config of user is {{ {} }}".format(retVal)) + #return retVal + + #def syncLdap(self): + #debug.info('sync Users from Ldap') + #ldap_users = ldap.getAllUser() + #for user in ldap_users: + #self.getUser(user['username']) + + #def getAllUsersfromDB(self, extern=True): + #debug.info("get all users from database") + #if (len(ldap.getAllUser()) != len(db.getAllUser())): + #self.syncLdap() + #users = db.getAllUser() + #debug.debug("users are {{ {} }}".format(users)) + #for user in users: + #try: + #debug.debug("update data from ldap") + #self.__updateDataFromLDAP(user) + #except: + #pass + #debug.debug("update creditlists") + #self.__updateGeruechte(user) + #retVal = db.getAllUser(extern=extern) + #debug.debug("all users are {{ {} }}".format(retVal)) + #return retVal + + #def getUser(self, username): + #debug.info("get user {{ {} }}".format(username)) + #user = db.getUser(username) + #debug.debug("user is {{ {} }}".format(user)) + #groups = ldap.getGroup(username) + #debug.debug("groups are {{ {} }}".format(groups)) + #user_data = ldap.getUserData(username) + #debug.debug("user data from ldap is {{ {} }}".format(user_data)) + #user_data['gruppe'] = groups + #user_data['group'] = groups + #if user is None: + #debug.debug("user not exists in database -> insert into database") + #user = User(user_data) + #db.insertUser(user) + #else: + #debug.debug("update database with user") + #user.updateData(user_data) + #db.updateUser(user) + #user = db.getUser(username) + #self.__updateGeruechte(user) + #debug.debug("user is {{ {} }}".format(user)) + #return user + + #def modifyUser(self, user, attributes, password): + #debug.info("modify user {{ {} }} with attributes (can't show because here can be a password)".format( + #user)) + + #try: + #ldap_conn = ldap.bind(user, password) + #if attributes: + #if 'username' in attributes: + #debug.debug("change username, so change first in database") + #db.changeUsername(user, attributes['username']) + #ldap.modifyUser(user, ldap_conn, attributes) + #if 'username' in attributes: + #retVal = self.getUser(attributes['username']) + #debug.debug("user is {{ {} }}".format(retVal)) + #return retVal + #else: + #retVal = self.getUser(user.uid) + #debug.debug("user is {{ {} }}".format(retVal)) + #return retVal + #return self.getUser(user.uid) + + #except UsernameExistLDAP as err: + #debug.debug( + #"username exists on ldap, rechange username on database", exc_info=True) + #db.changeUsername(user, user.uid) + #raise Exception(err) + #except LDAPExcetpion as err: + #if 'username' in attributes: + #db.changeUsername(user, user.uid) + #raise Exception(err) + #except LDAPPasswordIsMandatoryError as err: + #raise Exception('Password wurde nicht gesetzt!!') + #except LDAPBindError as err: + #raise Exception('Password ist falsch') + #except Exception as err: + #raise Exception(err) + + #def validateUser(self, username, password): + #debug.info("validate user {{ {} }}".format(username)) + #ldap.login(username, password) diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index 17d2ad3..f517ae4 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -27,14 +27,26 @@ class User(db.Model): __tablename__ = 'user' id = db.Column(db.Integer, primary_key=True) uid = db.Column(db.String(30)) - displayname = db.Column(db.String(20)) - firstname = db.Column(db.String(20)) - lastname = db.Column(db.String(20)) - mail = db.Column(db.String(20)) + displayname = db.Column(db.String(30)) + firstname = db.Column(db.String(30)) + lastname = db.Column(db.String(30)) + mail = db.Column(db.String(30)) groups = db.relationship("UserGroup", secondary=association_table) sessions = db.relationship("AccessToken", back_populates="user") attributes = db.relationship("UserAttribute", collection_class=attribute_mapped_collection('name'), cascade="all, delete") + def setAttribute(self, name, value): + if name in self.attributes: + self.attributes[name].value = value + else: + self.attributes[name] = UserAttribute(name=name, value=value) + + def addGroup(self, name): + r = UserGroup.query.filter_by(name=name).first() + if not r: + r = UserGroup(name=name) + self.groups.append(r) + def updateData(self, data): logger.debug("update data of user") if 'uid' in data: @@ -50,12 +62,13 @@ class User(db.Model): def toJSON(self): return { - "uid": self.uid, + # TODO: username should be UID? + "username": self.uid, "displayname": self.displayname, "firstname": self.firstname, "lastname": self.lastname, "mail": self.mail, - "groups": self.groups + "groups": ["user"] + [g.name for g in self.groups] } @@ -71,3 +84,8 @@ class UserGroup(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(30)) + def toJSON(self): + return { + 'name': self.name + } + From 53d502336e34a5828e9054b88f493ac2509ed2f5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 25 Aug 2020 04:36:05 +0200 Subject: [PATCH 019/446] Added LDAP authentification plugin --- flaschengeist/modules/auth_ldap/__init__.py | 191 +++++++++++++++- .../system/controller/ldapController.py | 204 ------------------ setup.py | 10 +- 3 files changed, 190 insertions(+), 215 deletions(-) delete mode 100644 flaschengeist/system/controller/ldapController.py diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index b69f2c9..7da6d10 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -1,20 +1,193 @@ import flaschengeist.modules as modules +from flaschengeist import logger from flask import current_app as app +from flask_ldapconn import LDAPConn +from ldap3 import SUBTREE#, MODIFY_REPLACE, HASHED_SALTED_MD5 +import ssl + +class AuthLDAP(modules.Auth): + _default = { + 'PORT': '389', + 'USE_SSL': 'False' + } -class AuthPlain(modules.Auth): def configure(self, config): + for name in self._default: + if name not in config: + config[name] = self._default[name] + app.config.update( - 'LDAP_SERVER' = config['URL'], - 'LDAP_PORT' = config['PORT'], - 'LDAP_BINDDN' = config['BINDDN'], - 'LDAP_SECRET' = config['SECRET'] - 'LDAP_USE_SSL' = config['USE_SSL'], - 'LDAP_TLS_VERSION' = ssl.PROTOCOL_TLSv1_2, - 'LDAP_REQUIRE_CERT' = ssl.CERT_NONE, - 'FORCE_ATTRIBUTE_VALUE_AS_LIST' = True + LDAP_SERVER = config['URL'], + LDAP_PORT = config.getint('PORT'), + LDAP_BINDDN = config['BINDDN'], + LDAP_USE_TLS = False, + LDAP_USE_SSL = config.getboolean('USE_SSL'), + LDAP_TLS_VERSION = ssl.PROTOCOL_TLSv1_2, + LDAP_REQUIRE_CERT = ssl.CERT_NONE, + FORCE_ATTRIBUTE_VALUE_AS_LIST = True ) + if 'SECRET' in config: + app.config['LDAP_SECRET'] = config['SECRET'], + + self.ldap = LDAPConn(app) + self.dn = config['BASEDN'] def login(self, user, password): if not user: return False + try: + r = self.ldap.authenticate(user.uid, password, 'uid', self.dn) + return r == True + except Exception as err: + logger.warning("Exception while login into ldap", exc_info=True) return False + + def modifyUser(self, user): + try: + ldap_conn = self.ldap.bind(user.uid, password) + if attributes: + if 'username' in attributes: + debug.debug("change username, so change first in database") + db.changeUsername(user, attributes['username']) + ldap.modifyUser(user, ldap_conn, attributes) + if 'username' in attributes: + retVal = self.getUser(attributes['username']) + debug.debug("user is {{ {} }}".format(retVal)) + return retVal + else: + retVal = self.getUser(user.uid) + debug.debug("user is {{ {} }}".format(retVal)) + return retVal + return self.getUser(user.uid) + + except UsernameExistLDAP as err: + debug.debug( + "username exists on ldap, rechange username on database", exc_info=True) + db.changeUsername(user, user.uid) + raise Exception(err) + except LDAPExcetpion as err: + if 'username' in attributes: + db.changeUsername(user, user.uid) + raise Exception(err) + except LDAPPasswordIsMandatoryError as err: + raise Exception('Password wurde nicht gesetzt!!') + except LDAPBindError as err: + raise Exception('Password ist falsch') + except Exception as err: + raise Exception(err) + debug.info("modify ldap data from user {{ {} }} with attributes (can't show because here can be a password)".format(user)) + try: + if 'username' in attributes: + debug.debug("change username") + conn.search('ou=user,{}'.format(self.dn), '(uid={})'.format(attributes['username'])) + if conn.entries: + debug.warning("username already exists", exc_info=True) + raise UsernameExistLDAP("Username already exists in LDAP") + #create modifyer + mody = {} + if 'username' in attributes: + mody['uid'] = [(MODIFY_REPLACE, [attributes['username']])] + if 'firstname' in attributes: + mody['givenName'] = [(MODIFY_REPLACE, [attributes['firstname']])] + if 'lastname' in attributes: + mody['sn'] = [(MODIFY_REPLACE, [attributes['lastname']])] + if 'mail' in attributes: + mody['mail'] = [(MODIFY_REPLACE, [attributes['mail']])] + if 'password' in attributes: + salted_password = hashed(HASHED_SALTED_MD5, attributes['password']) + mody['userPassword'] = [(MODIFY_REPLACE, [salted_password])] + debug.debug("modyfier are (can't show because here can be a password)") + conn.modify(user.dn, mody) + except Exception as err: + debug.warning("exception in modify user data from ldap", exc_info=True) + raise LDAPExcetpion("Something went wrong in LDAP: {}".format(err)) + + def updateUser(self, user): + self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(user.uid), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) + r = self.ldap.connection.response[0]['attributes'] + if r['uid'][0] == user.uid: + user.setAttribute('DN', self.ldap.connection.response[0]['dn']) + user.firstname = r['givenName'][0] + user.lastname = r['sn'][0] + if r['mail']: + user.mail = r['mail'][0] + if 'displayName' in r: + user.displayname = r['displayName'][0] + for group in self._getGroups(user.uid): + user.addGroup(group) + + def _getGroups(self, uid): + try: + groups = [] + + self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(uid), SUBTREE, attributes=['gidNumber']) + main_group_number = self.ldap.connection.response[0]['attributes']['gidNumber'] + if main_group_number: + if type(main_group_number) is list: + main_group_number = main_group_number[0] + self.ldap.connection.search('ou=group,{}'.format(self.dn), '(gidNumber={})'.format(main_group_number), attributes=['cn']) + groups.append(self.ldap.connection.response[0]['attributes']['cn'][0]) + + self.ldap.connection.search('ou=group,{}'.format(self.dn), '(memberUID={})'.format(uid), SUBTREE, attributes=['cn']) + groups_data = self.ldap.connection.response + for data in groups_data: + groups.append(data['attributes']['cn'][0]) + return groups + except Exception as err: + debug.warning("exception in get groups from ldap", exc_info=True) + return [] + +# def getAllUser(self): +# debug.info("get all users from ldap") +# retVal = [] +# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid=*)', SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) +# data = self.ldap.connection.response +# debug.debug("data is {{ {} }}".format(data)) +# for user in data: +# if 'uid' in user['attributes']: +# username = user['attributes']['uid'][0] +# firstname = user['attributes']['givenName'][0] +# lastname = user['attributes']['sn'][0] +# retVal.append({'username': username, 'firstname': firstname, 'lastname': lastname}) +# debug.debug("users are {{ {} }}".format(retVal)) +# return retVal + +# def searchUser(self, searchString): +# +# name = searchString.split(" ") +# +# for i in range(len(name)): +# name[i] = "*"+name[i]+"*" +# +# +# print(name) +# +# name_result = [] +# +# if len(name) == 1: +# if name[0] == "**": +# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid=*)', SUBTREE, +# attributes=['uid', 'givenName', 'sn']) +# name_result.append(self.ldap.connection.response) +# else: +# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(givenName={})'.format(name[0]), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) +# name_result.append(self.ldap.connection.response) +# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(sn={})'.format(name[0]), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) +# name_result.append(self.ldap.connection.response) +# else: +# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(givenName={})'.format(name[1]), SUBTREE, attributes=['uid', 'givenName', 'sn']) +# name_result.append(self.ldap.connection.response) +# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(sn={})'.format(name[1]), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) +# name_result.append(self.ldap.connection.response) +# retVal = [] + +# for names in name_result: +# for user in names: +# if 'uid' in user['attributes']: +# username = user['attributes']['uid'][0] +# if not self.__isUserInList(retVal, username): +# firstname = user['attributes']['givenName'][0] +# lastname = user['attributes']['sn'][0] +# retVal.append({'username': username, 'firstname': firstname, 'lastname': lastname}) +# +# return retVal diff --git a/flaschengeist/system/controller/ldapController.py b/flaschengeist/system/controller/ldapController.py deleted file mode 100644 index e17d3e7..0000000 --- a/flaschengeist/system/controller/ldapController.py +++ /dev/null @@ -1,204 +0,0 @@ -from geruecht import ldap -from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_MD5 -from ldap3.utils.hashed import hashed -from geruecht.model import MONEY, USER, GASTRO, BAR, VORSTAND, EXTERN -from geruecht.exceptions import PermissionDenied -from . import Singleton -from geruecht.exceptions import UsernameExistLDAP, LDAPExcetpion -from . import ldapConfig -from geruecht.logger import getDebugLogger - -debug = getDebugLogger() - -class LDAPController(metaclass=Singleton): - ''' - Authentification over LDAP. Create Account on-the-fly - ''' - - def __init__(self): - debug.info("init ldap controller") - self.dn = ldapConfig['DN'] - self.ldap = ldap - debug.debug("base dn is {{ {} }}".format(self.dn)) - debug.debug("ldap is {{ {} }}".format(self.ldap)) - - - def login(self, username, password): - debug.info("login user {{ {} }} in ldap") - try: - retVal = self.ldap.authenticate(username, password, 'uid', self.dn) - debug.debug("authentification to ldap is {{ {} }}".format(retVal)) - if not retVal: - debug.debug("authenification is incorrect") - raise PermissionDenied("Invalid Password or Username") - except Exception as err: - debug.warning("exception while login into ldap", exc_info=True) - raise PermissionDenied("Invalid Password or Username. {}".format(err)) - - def bind(self, user, password): - debug.info("bind user {{ {} }} to ldap") - ldap_conn = self.ldap.connect(user.dn, password) - debug.debug("ldap_conn is {{ {} }}".format(ldap_conn)) - return ldap_conn - - def getUserData(self, username): - debug.info("get user data from ldap of user {{ {} }}".format(username)) - try: - debug.debug("search user in ldap") - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(username), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) - user = self.ldap.connection.response[0]['attributes'] - debug.debug("user is {{ {} }}".format(user)) - retVal = { - 'dn': self.ldap.connection.response[0]['dn'], - 'firstname': user['givenName'][0], - 'lastname': user['sn'][0], - 'uid': user['uid'][0], - } - if user['mail']: - retVal['mail'] = user['mail'][0] - debug.debug("user is {{ {} }}".format(retVal)) - if retVal['uid'] == username: - return retVal - else: - raise Exception() - except: - debug.warning("exception in get user data from ldap", exc_info=True) - raise PermissionDenied("No User exists with this uid.") - - - def getGroup(self, username): - debug.info("get group from user {{ {} }}".format(username)) - try: - retVal = [] - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(username), SUBTREE, attributes=['gidNumber']) - main_group_number = self.ldap.connection.response[0]['attributes']['gidNumber'] - debug.debug("main group number is {{ {} }}".format(main_group_number)) - if main_group_number: - if type(main_group_number) is list: - main_group_number = main_group_number[0] - self.ldap.connection.search('ou=group,{}'.format(self.dn), '(gidNumber={})'.format(main_group_number), attributes=['cn']) - group_name = self.ldap.connection.response[0]['attributes']['cn'][0] - debug.debug("group name is {{ {} }}".format(group_name)) - if group_name == 'ldap-user': - retVal.append(USER) - if group_name == 'extern': - retVal.append(EXTERN) - - self.ldap.connection.search('ou=group,{}'.format(self.dn), '(memberUID={})'.format(username), SUBTREE, attributes=['cn']) - groups_data = self.ldap.connection.response - debug.debug("groups number is {{ {} }}".format(groups_data)) - for data in groups_data: - group_name = data['attributes']['cn'][0] - debug.debug("group name is {{ {} }}".format(group_name)) - if group_name == 'finanzer': - retVal.append(MONEY) - elif group_name == 'gastro': - retVal.append(GASTRO) - elif group_name == 'bar': - retVal.append(BAR) - elif group_name == 'vorstand': - retVal.append(VORSTAND) - elif group_name == 'ldap-user': - retVal.append(USER) - debug.debug("groups are {{ {} }}".format(retVal)) - return retVal - except Exception as err: - debug.warning("exception in get groups from ldap", exc_info=True) - raise LDAPExcetpion(str(err)) - - def __isUserInList(self, list, username): - help_list = [] - for user in list: - help_list.append(user['username']) - if username in help_list: - return True - return False - - def getAllUser(self): - debug.info("get all users from ldap") - retVal = [] - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid=*)', SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) - data = self.ldap.connection.response - debug.debug("data is {{ {} }}".format(data)) - for user in data: - if 'uid' in user['attributes']: - username = user['attributes']['uid'][0] - firstname = user['attributes']['givenName'][0] - lastname = user['attributes']['sn'][0] - retVal.append({'username': username, 'firstname': firstname, 'lastname': lastname}) - debug.debug("users are {{ {} }}".format(retVal)) - return retVal - - def searchUser(self, searchString): - - name = searchString.split(" ") - - for i in range(len(name)): - name[i] = "*"+name[i]+"*" - - - print(name) - - name_result = [] - - if len(name) == 1: - if name[0] == "**": - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid=*)', SUBTREE, - attributes=['uid', 'givenName', 'sn']) - name_result.append(self.ldap.connection.response) - else: - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(givenName={})'.format(name[0]), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) - name_result.append(self.ldap.connection.response) - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(sn={})'.format(name[0]), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) - name_result.append(self.ldap.connection.response) - else: - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(givenName={})'.format(name[1]), SUBTREE, attributes=['uid', 'givenName', 'sn']) - name_result.append(self.ldap.connection.response) - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(sn={})'.format(name[1]), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) - name_result.append(self.ldap.connection.response) - retVal = [] - - for names in name_result: - for user in names: - if 'uid' in user['attributes']: - username = user['attributes']['uid'][0] - if not self.__isUserInList(retVal, username): - firstname = user['attributes']['givenName'][0] - lastname = user['attributes']['sn'][0] - retVal.append({'username': username, 'firstname': firstname, 'lastname': lastname}) - - return retVal - - def modifyUser(self, user, conn, attributes): - debug.info("modify ldap data from user {{ {} }} with attributes (can't show because here can be a password)".format(user)) - try: - if 'username' in attributes: - debug.debug("change username") - conn.search('ou=user,{}'.format(self.dn), '(uid={})'.format(attributes['username'])) - if conn.entries: - debug.warning("username already exists", exc_info=True) - raise UsernameExistLDAP("Username already exists in LDAP") - #create modifyer - mody = {} - if 'username' in attributes: - mody['uid'] = [(MODIFY_REPLACE, [attributes['username']])] - if 'firstname' in attributes: - mody['givenName'] = [(MODIFY_REPLACE, [attributes['firstname']])] - if 'lastname' in attributes: - mody['sn'] = [(MODIFY_REPLACE, [attributes['lastname']])] - if 'mail' in attributes: - mody['mail'] = [(MODIFY_REPLACE, [attributes['mail']])] - if 'password' in attributes: - salted_password = hashed(HASHED_SALTED_MD5, attributes['password']) - mody['userPassword'] = [(MODIFY_REPLACE, [salted_password])] - debug.debug("modyfier are (can't show because here can be a password)") - conn.modify(user.dn, mody) - except Exception as err: - debug.warning("exception in modify user data from ldap", exc_info=True) - raise LDAPExcetpion("Something went wrong in LDAP: {}".format(err)) - - - -if __name__ == '__main__': - a = LDAPController() - a.getUserData('jhille') diff --git a/setup.py b/setup.py index 83cff6d..9409bc6 100644 --- a/setup.py +++ b/setup.py @@ -10,14 +10,20 @@ setup( packages=find_packages(), package_data={'': ['*.yml']}, scripts=['run_flaschengeist'], - install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', 'sqlalchemy>=1.3', "flask_sqlalchemy", "flask_ldapconn", "flask_cors"], + install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', 'sqlalchemy>=1.3', "flask_sqlalchemy", "flask_cors"], + extras_require={ + 'ldap': [ + 'flask_ldapconn', + 'ldap3' + ] + }, entry_points = { 'flaschengeist.plugin': [ 'auth = flaschengeist.modules.auth:register' ], 'flaschengeist.auth': [ 'auth_plain = flaschengeist.modules.auth_plain:AuthPlain', - 'auth_ldap = flaschengeist.modules.auth_ldap:AuthLDAP' + 'auth_ldap = flaschengeist.modules.auth_ldap:AuthLDAP [ldap]' ] } ) From 07a0d266a67d29d520bbcc9253c302e721d13ed4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 25 Aug 2020 21:17:36 +0200 Subject: [PATCH 020/446] Fixed guessing of accesstoken, using python.secrets library. Fixes #399 --- geruecht/controller/accesTokenController.py | 4 ++-- geruecht/model/accessToken.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/geruecht/controller/accesTokenController.py b/geruecht/controller/accesTokenController.py index bec7703..0988c50 100644 --- a/geruecht/controller/accesTokenController.py +++ b/geruecht/controller/accesTokenController.py @@ -4,7 +4,7 @@ import geruecht.controller.mainController as mc import geruecht.controller.databaseController as dc from geruecht.model import BAR from datetime import datetime, timedelta -import hashlib +import secrets from . import Singleton from geruecht.logger import getDebugLogger @@ -95,7 +95,7 @@ class AccesTokenController(metaclass=Singleton): """ debug.info("creat accesstoken") now = datetime.ctime(datetime.now()) - token = hashlib.md5((now + user.dn).encode('utf-8')).hexdigest() + token = secrets.token_hex(16) self.checkBar(user) accToken = db.createAccessToken(user, token, self.lifetime, datetime.now(), lock_bar=False, user_agent=user_agent) debug.debug("accesstoken is {{ {} }}".format(accToken)) diff --git a/geruecht/model/accessToken.py b/geruecht/model/accessToken.py index 6e777f7..f5c50aa 100644 --- a/geruecht/model/accessToken.py +++ b/geruecht/model/accessToken.py @@ -1,4 +1,5 @@ from datetime import datetime +from secrets import compare_digest from geruecht.logger import getDebugLogger debug = getDebugLogger() @@ -67,7 +68,7 @@ class AccessToken(): return dic def __eq__(self, token): - return True if self.token == token else False + return compare_digest(self.token, token) def __sub__(self, other): return other - self.timestamp From 5cd752a096398661b4d9928c6993ce98f31c377a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 25 Aug 2020 22:33:30 +0200 Subject: [PATCH 021/446] Sidewards compatibility with pluginify. Some cleanup --- geruecht/model/user.py | 2 +- geruecht/routes.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/geruecht/model/user.py b/geruecht/model/user.py index 543859c..6003432 100644 --- a/geruecht/model/user.py +++ b/geruecht/model/user.py @@ -227,7 +227,7 @@ class User(): "dn": self.dn, "firstname": self.firstname, "lastname": self.lastname, - "group": self.group, + "groups": self.group, "username": self.uid, "locked": self.locked, "autoLock": self.autoLock, diff --git a/geruecht/routes.py b/geruecht/routes.py index 810b141..1eb8232 100644 --- a/geruecht/routes.py +++ b/geruecht/routes.py @@ -216,16 +216,16 @@ def _getUsers(**kwargs): return jsonify({"error": str(err)}), 500 -@app.route("/getLifeTime", methods=['GET']) +@app.route("/getLifetime", methods=['GET']) @login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) -def _getLifeTime(**kwargs): +def _getLifetime(**kwargs): try: debug.info("get lifetime of accesstoken") if 'accToken' in kwargs: accToken = kwargs['accToken'] debug.debug("accessToken is {{ {} }}".format(accToken)) retVal = {"value": accToken.lifetime, - "group": accToken.user.toJSON()['group'], + "groups": accToken.user.toJSON()['groups'], "lock_bar": accToken.lock_bar} debug.info( "return get lifetime from accesstoken {{ {} }}".format(retVal)) @@ -235,11 +235,11 @@ def _getLifeTime(**kwargs): return jsonify({"error": str(err)}), 500 -@app.route("/saveLifeTime", methods=['POST']) +@app.route("/setLifetime", methods=['POST']) @login_required(groups=[MONEY, GASTRO, VORSTAND, EXTERN, USER], bar=True) -def _saveLifeTime(**kwargs): +def _setLifetime(**kwargs): try: - debug.info("save lifetime for accessToken") + debug.info("set lifetime for accessToken") if 'accToken' in kwargs: accToken = kwargs['accToken'] debug.debug("accessToken is {{ {} }}".format(accToken)) @@ -253,7 +253,7 @@ def _saveLifeTime(**kwargs): accToken = accesTokenController.updateAccessToken(accToken) accToken = accesTokenController.validateAccessToken(accToken.token, [USER, EXTERN]) retVal = {"value": accToken.lifetime, - "group": accToken.user.toJSON()['group']} + "groups": accToken.user.toJSON()['groups']} debug.info( "return save lifetime for accessToken {{ {} }}".format(retVal)) return jsonify(retVal) @@ -320,7 +320,6 @@ def _login(): debug.info("validate accesstoken") dic = accesTokenController.validateAccessToken( token, [USER, EXTERN]).user.toJSON() - dic["token"] = token dic["accessToken"] = token debug.info("User {{ {} }} success login.".format(username)) debug.info("return login {{ {} }}".format(dic)) From 66dcfa80b10002c3dab97072ad620dfad120e0f2 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 1 Sep 2020 21:36:25 +0200 Subject: [PATCH 022/446] Fixed Typo in accessController, added Roles for access controll --- flaschengeist/__init__.py | 15 +++++-------- ...Controller.py => accessTokenController.py} | 22 ++++++++++--------- flaschengeist/system/decorator.py | 17 +++++--------- flaschengeist/system/models/user.py | 22 ++++++++++++++----- 4 files changed, 40 insertions(+), 36 deletions(-) rename flaschengeist/system/controller/{accesTokenController.py => accessTokenController.py} (84%) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 441e331..2d79f16 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -45,16 +45,11 @@ def create_app(): logger.error('No authentification plugin configured or authentification plugin not found') logger.info('Search for plugins') - discovered_plugins = { - entry_point.name: entry_point.load() - for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin') - } - - for name in discovered_plugins: - logger.debug("Found plugin: %s", name) - if config.get(name, 'enabled', fallback=False): - logger.info('Loaded plugin > %s <', name) - app.register_blueprint(discovered_plugins[name]()) + for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin'): + logger.debug("Found plugin: %s", entry_point.name) + if config.get(entry_point.name, 'enabled', fallback=False): + logger.info('Loaded plugin > %s <', entry_point.name) + app.register_blueprint(entry_point.load()()) return app #app.register_blueprint(baruser) diff --git a/flaschengeist/system/controller/accesTokenController.py b/flaschengeist/system/controller/accessTokenController.py similarity index 84% rename from flaschengeist/system/controller/accesTokenController.py rename to flaschengeist/system/controller/accessTokenController.py index c89b478..15b503d 100644 --- a/flaschengeist/system/controller/accesTokenController.py +++ b/flaschengeist/system/controller/accessTokenController.py @@ -4,12 +4,12 @@ from flaschengeist.system.database import db from datetime import datetime, timedelta import secrets from . import Singleton -from flask import Blueprint, request, jsonify import logging logger = logging.getLogger("flaschenpost") -class AccesTokenController(metaclass=Singleton): + +class AccessTokenController(metaclass=Singleton): """ Control all createt AccesToken This Class create, delete, find and manage AccesToken. @@ -29,7 +29,7 @@ class AccesTokenController(metaclass=Singleton): logger.debug("init accesstoken controller") self.lifetime = lifetime - def validateAccessToken(self, token, group): + def validateAccessToken(self, token, roles): """ Verify Accestoken Verify an Accestoken and Group so if the User has permission or not. @@ -37,7 +37,7 @@ class AccesTokenController(metaclass=Singleton): Args: token: Token to verify. - group: Group like 'moneymaster', 'gastro', 'user' or 'bar' + roles: Roles needed to access restricted routes Returns: An the AccesToken for this given Token or False. """ @@ -48,14 +48,9 @@ class AccesTokenController(metaclass=Singleton): logger.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, endTime)) if now <= endTime: logger.debug("check if token {{ {} }} is same as {{ {} }}".format(token, accToken)) - if accToken == token: - # if not self.checkBar(accToken.user): - # accToken.lock_bar = False - # logger.debug("check if accestoken {{ {} }} has group {{ {} }}".format(accToken, group)) - # if self.isSameGroup(accToken, group): + if not roles or (roles and self.userHasRole(accToken.user, roles)): accToken.updateTimestamp() db.session.commit() - # logger.debug("found accesstoken {{ {} }} with token: {{ {} }} and group: {{ {} }}".format(accToken, token, group)) return accToken else: logger.debug("accesstoken is {{ {} }} out of date".format(accToken)) @@ -64,6 +59,13 @@ class AccesTokenController(metaclass=Singleton): logger.debug("no valid accesstoken with token: {{ {} }} and group: {{ {} }}".format(token, group)) return False + def userHasRole(self, user, roles): + for group in user.groups: + for role in group.roles: + if role.name in roles: + return True + return False + def createAccesToken(self, user, user_agent=None): """ Create an AccessToken diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index 4c67c0f..26a1e96 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -3,27 +3,22 @@ from flask import current_app, request, jsonify from flaschengeist import logger def login_required(**kwargs): - from .controller.accesTokenController import AccesTokenController - accessController = AccesTokenController() - #if "groups" in kwargs: - # groups = kwargs["groups"] - #if "bar" in kwargs: - # bar = kwargs["bar"] - #logger.debug("groups are {{ {} }}".format(groups)) + from .controller.accessTokenController import AccessTokenController + accessController = AccessTokenController() + roles = None + if "roles" in kwargs: + roles = kwargs["roles"] def real_decorator(func): @wraps(func) def wrapper(*args, **kwargs): token = request.headers.get('Token') logger.debug("token is {{ {} }}".format(token)) - accToken = accessController.validateAccessToken(token, None) + accToken = accessController.validateAccessToken(token, roles) logger.debug("accToken is {{ {} }}".format(accToken)) kwargs['accToken'] = accToken if accToken: logger.debug("token {{ {} }} is valid".format(token)) - # if accToken.lock_bar and not bar: - # return jsonify({"error": "error", - # "message": "permission forbidden"}), 403 return func(*args, **kwargs) else: logger.warning("token {{ {} }} is not valid".format(token)) diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index f517ae4..f462595 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -18,7 +18,7 @@ class User(db.Model): Attributes: id: Id in Database as Primary Key. - uid: User ID used by authentification provider + uid: User ID used by authentication provider displayname: Name to show firstname: Firstname of the User lastname: Lastname of the User @@ -31,7 +31,7 @@ class User(db.Model): firstname = db.Column(db.String(30)) lastname = db.Column(db.String(30)) mail = db.Column(db.String(30)) - groups = db.relationship("UserGroup", secondary=association_table) + groups = db.relationship("Group", secondary=association_table) sessions = db.relationship("AccessToken", back_populates="user") attributes = db.relationship("UserAttribute", collection_class=attribute_mapped_collection('name'), cascade="all, delete") @@ -42,9 +42,9 @@ class User(db.Model): self.attributes[name] = UserAttribute(name=name, value=value) def addGroup(self, name): - r = UserGroup.query.filter_by(name=name).first() + r = Group.query.filter_by(name=name).first() if not r: - r = UserGroup(name=name) + r = Group(name=name) self.groups.append(r) def updateData(self, data): @@ -79,13 +79,25 @@ class UserAttribute(db.Model): name = db.Column(db.String(30)) value = db.Column(db.String(192)) -class UserGroup(db.Model): +group_permission_association_table = db.Table('group_permission', + db.Column('group_id', db.Integer, db.ForeignKey('group.id')), + db.Column('permission_id', db.Integer, db.ForeignKey('permission.id')) +) + + +class Group(db.Model): __tablename__ = 'group' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(30)) + permissions = db.relationship("Permission", secondary=group_permission_association_table) def toJSON(self): return { 'name': self.name } + +class Permission(db.Model): + __tablename__ = 'permission' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(30)) From 3256787d642e2dbbbe54b74adb75fa333c626990 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 2 Sep 2020 01:09:24 +0200 Subject: [PATCH 023/446] Fixed AccessTokenController. Fixed typos and styling. --- flaschengeist/__init__.py | 2 - flaschengeist/modules/auth/__init__.py | 82 ++++++++++--------- flaschengeist/modules/auth_ldap/__init__.py | 37 +++++---- flaschengeist/modules/auth_plain/__init__.py | 9 +- flaschengeist/system/config.py | 6 +- flaschengeist/system/controller/__init__.py | 2 +- .../controller/accessTokenController.py | 44 +++++----- flaschengeist/system/decorator.py | 13 +-- flaschengeist/system/exceptions/__init__.py | 12 +-- flaschengeist/system/models/accessToken.py | 1 + flaschengeist/system/models/user.py | 3 +- 11 files changed, 109 insertions(+), 102 deletions(-) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 2d79f16..0ea570e 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -9,8 +9,6 @@ _modpath = Path(__file__).parent from flask import Flask from flask_cors import CORS -from flask_ldapconn import LDAPConn -import ssl import pkg_resources, yaml from logging.config import dictConfig diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index bfd7f21..5e867ae 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -10,13 +10,16 @@ from werkzeug.local import LocalProxy from flaschengeist.system.decorator import login_required from flaschengeist.system.exceptions import PermissionDenied from flaschengeist.system.controller import mainController as mc -import flaschengeist.system.controller.accesTokenController as ac +import flaschengeist.system.controller.accessTokenController as ac + +from flaschengeist.system.models.accessToken import AccessToken logger = LocalProxy(lambda: current_app.logger) -accesTokenController = LocalProxy(lambda: ac.AccesTokenController()) +access_controller = LocalProxy(lambda: ac.AccessTokenController()) auth_bp = Blueprint('auth', __name__) + def register(): return auth_bp @@ -24,31 +27,14 @@ def register(): ## Routes ## ############################################ -@auth_bp.route("/logout", methods=['GET']) -@login_required() -def _logout(**kwargs): - try: - logger.debug("logout user") - accToken = kwargs['accToken'] - logger.debug("accesstoken is {{ {} }}".format(accToken)) - logger.debug("delete accesstoken") - accesTokenController.deleteAccessToken(accToken) - accesTokenController.clearExpired() - logger.info("return ok logout user") - return jsonify({"ok": "ok"}) - except Exception as err: - logger.warning("exception in logout user.", exc_info=True) - return jsonify({"error": str(err)}), 500 @auth_bp.route("/login", methods=['POST']) def _login(): """ Login User - Nothing to say. Login in User and create an AccessToken for the User. - Returns: - A JSON-File with createt Token or Errors + A JSON-File with user information and created token or errors """ logger.debug("Start log in.") data = request.get_json() @@ -58,12 +44,12 @@ def _login(): logger.debug("username is {{ {} }}".format(username)) try: logger.debug("search {{ {} }} in database".format(username)) - mainController = mc.MainController() - user = mainController.loginUser(username, password) + main_controller = mc.MainController() + user = main_controller.loginUser(username, password) logger.debug("user is {{ {} }}".format(user)) - token = accesTokenController.createAccesToken(user, user_agent=request.user_agent) - logger.debug("accesstoken is {{ {} }}".format(token)) - logger.debug("validate accesstoken") + token = access_controller.create(user, user_agent=request.user_agent) + logger.debug("access token is {{ {} }}".format(token)) + logger.debug("validate access token") dic = user.toJSON() dic["accessToken"] = token logger.info("User {{ {} }} success login.".format(username)) @@ -76,16 +62,34 @@ def _login(): logger.error("exception in login.", exc_info=True) return jsonify({"error": "permission denied"}), 401 + +@auth_bp.route("/logout", methods=['GET']) +@login_required() +def _logout(**kwargs): + try: + logger.debug("logout user") + accToken = kwargs['accToken'] + logger.debug("accesstoken is {{ {} }}".format(accToken)) + logger.debug("delete accesstoken") + access_controller.deleteAccessToken(accToken) + access_controller.clearExpired() + logger.info("return ok logout user") + return jsonify({"ok": "ok"}) + except Exception as err: + logger.warning("exception in logout user.", exc_info=True) + return jsonify({"error": str(err)}), 500 + + @auth_bp.route("/user/getAccessTokens", methods=['GET', 'POST']) +#@auth_bp.route("/accessTokens", methods=['GET', 'POST']) @login_required() def _getAccessTokens(**kwargs): try: if request.method == 'POST': data = request.get_json() - accesTokenController.deleteAccessToken(accToken) - delAccToken = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) - accesTokenController.deleteAccessToken(delAccToken) - tokens = accesTokenController.getAccessTokensFromUser(kwargs['accToken'].user) + token = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) + access_controller.delete_token(token) + tokens = access_controller.getAccessTokensFromUser(kwargs['accToken'].user) r = [t.toJSON() for t in tokens] logger.debug("return {{ {} }}".format(r)) return jsonify(r) @@ -93,6 +97,7 @@ def _getAccessTokens(**kwargs): logger.debug("exception", exc_info=True) return jsonify({"error": str(err)}), 500 + @auth_bp.route("/getLifetime", methods=['GET']) @login_required() def _getLifeTime(**kwargs): @@ -105,22 +110,23 @@ def _getLifeTime(**kwargs): logger.warning("exception in get lifetime of accesstoken.", exc_info=True) return jsonify({"error": str(err)}), 500 + @auth_bp.route("/setLifetime", methods=['POST']) @login_required() def _saveLifeTime(**kwargs): try: - accToken = kwargs['accToken'] - logger.debug("save lifetime for accessToken {{ {} }}".format(accToken)) + token = kwargs['accToken'] + logger.debug("save lifetime for access token {{ {} }}".format(token)) data = request.get_json() lifetime = data['value'] logger.debug("lifetime is {{ {} }}".format(lifetime)) - logger.info("set lifetime {{ {} }} to accesstoken {{ {} }}".format( - lifetime, accToken)) - accToken.lifetime = lifetime - logger.info("update accesstoken timestamp") - accToken = accesTokenController.updateAccessToken(accToken) - return jsonify({"value": accToken.lifetime }) + logger.info("set lifetime {{ {} }} to access token {{ {} }}".format( + lifetime, token)) + token.lifetime = lifetime + logger.info("update access token timestamp") + token = access_controller.update(token) + return jsonify({"value": token.lifetime }) except Exception as err: logger.warning( - "exception in save lifetime for accesstoken.", exc_info=True) + "exception in save lifetime for access token.", exc_info=True) return jsonify({"error": str(err)}), 500 diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index 7da6d10..00e5803 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -2,9 +2,10 @@ import flaschengeist.modules as modules from flaschengeist import logger from flask import current_app as app from flask_ldapconn import LDAPConn -from ldap3 import SUBTREE#, MODIFY_REPLACE, HASHED_SALTED_MD5 +from ldap3 import SUBTREE import ssl + class AuthLDAP(modules.Auth): _default = { 'PORT': '389', @@ -17,14 +18,14 @@ class AuthLDAP(modules.Auth): config[name] = self._default[name] app.config.update( - LDAP_SERVER = config['URL'], - LDAP_PORT = config.getint('PORT'), - LDAP_BINDDN = config['BINDDN'], - LDAP_USE_TLS = False, - LDAP_USE_SSL = config.getboolean('USE_SSL'), - LDAP_TLS_VERSION = ssl.PROTOCOL_TLSv1_2, - LDAP_REQUIRE_CERT = ssl.CERT_NONE, - FORCE_ATTRIBUTE_VALUE_AS_LIST = True + LDAP_SERVER=config['URL'], + LDAP_PORT=config.getint('PORT'), + LDAP_BINDDN=config['BINDDN'], + LDAP_USE_TLS=False, + LDAP_USE_SSL=config.getboolean('USE_SSL'), + LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2, + LDAP_REQUIRE_CERT=ssl.CERT_NONE, + FORCE_ATTRIBUTE_VALUE_AS_LIST=True ) if 'SECRET' in config: app.config['LDAP_SECRET'] = config['SECRET'], @@ -75,7 +76,9 @@ class AuthLDAP(modules.Auth): raise Exception('Password ist falsch') except Exception as err: raise Exception(err) - debug.info("modify ldap data from user {{ {} }} with attributes (can't show because here can be a password)".format(user)) + debug.info( + "modify ldap data from user {{ {} }} with attributes (can't show because here can be a password)".format( + user)) try: if 'username' in attributes: debug.debug("change username") @@ -83,7 +86,7 @@ class AuthLDAP(modules.Auth): if conn.entries: debug.warning("username already exists", exc_info=True) raise UsernameExistLDAP("Username already exists in LDAP") - #create modifyer + # create modifyer mody = {} if 'username' in attributes: mody['uid'] = [(MODIFY_REPLACE, [attributes['username']])] @@ -103,7 +106,8 @@ class AuthLDAP(modules.Auth): raise LDAPExcetpion("Something went wrong in LDAP: {}".format(err)) def updateUser(self, user): - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(user.uid), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) + self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(user.uid), SUBTREE, + attributes=['uid', 'givenName', 'sn', 'mail']) r = self.ldap.connection.response[0]['attributes'] if r['uid'][0] == user.uid: user.setAttribute('DN', self.ldap.connection.response[0]['dn']) @@ -120,15 +124,18 @@ class AuthLDAP(modules.Auth): try: groups = [] - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(uid), SUBTREE, attributes=['gidNumber']) + self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(uid), SUBTREE, + attributes=['gidNumber']) main_group_number = self.ldap.connection.response[0]['attributes']['gidNumber'] if main_group_number: if type(main_group_number) is list: main_group_number = main_group_number[0] - self.ldap.connection.search('ou=group,{}'.format(self.dn), '(gidNumber={})'.format(main_group_number), attributes=['cn']) + self.ldap.connection.search('ou=group,{}'.format(self.dn), '(gidNumber={})'.format(main_group_number), + attributes=['cn']) groups.append(self.ldap.connection.response[0]['attributes']['cn'][0]) - self.ldap.connection.search('ou=group,{}'.format(self.dn), '(memberUID={})'.format(uid), SUBTREE, attributes=['cn']) + self.ldap.connection.search('ou=group,{}'.format(self.dn), '(memberUID={})'.format(uid), SUBTREE, + attributes=['cn']) groups_data = self.ldap.connection.response for data in groups_data: groups.append(data['attributes']['cn'][0]) diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/modules/auth_plain/__init__.py index 037533c..908c642 100644 --- a/flaschengeist/modules/auth_plain/__init__.py +++ b/flaschengeist/modules/auth_plain/__init__.py @@ -1,6 +1,10 @@ -import hashlib, binascii, os +import binascii +import hashlib +import os + import flaschengeist.modules as modules + class AuthPlain(modules.Auth): def login(self, user, password): if not user: @@ -18,7 +22,6 @@ class AuthPlain(modules.Auth): def __verify_password(self, stored_password, provided_password): salt = stored_password[:64] stored_password = stored_password[64:] - pwdhash = hashlib.pbkdf2_hmac('sha3-512', provided_password.encode('utf-8'), - salt.encode('ascii'), 100000) + pwdhash = hashlib.pbkdf2_hmac('sha3-512', provided_password.encode('utf-8'), salt.encode('ascii'), 100000) pwdhash = binascii.hexlify(pwdhash).decode('ascii') return pwdhash == stored_password diff --git a/flaschengeist/system/config.py b/flaschengeist/system/config.py index 1ba87c5..a83d6c0 100644 --- a/flaschengeist/system/config.py +++ b/flaschengeist/system/config.py @@ -14,10 +14,10 @@ default = { config = configparser.ConfigParser() config.read_dict(default) -pathes = [_modpath, Path.home()/".config"] +paths = [_modpath, Path.home()/".config"] if 'FLASCHENGEIST_CONF' in os.environ: - pathes.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) -for loc in pathes: + paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) +for loc in paths: try: with (loc/"flaschengeist.cfg").open() as source: config.read_file(source) diff --git a/flaschengeist/system/controller/__init__.py b/flaschengeist/system/controller/__init__.py index 83ca20c..3776cb9 100644 --- a/flaschengeist/system/controller/__init__.py +++ b/flaschengeist/system/controller/__init__.py @@ -1,7 +1,7 @@ class Singleton(type): _instances = {} + def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) return cls._instances[cls] - diff --git a/flaschengeist/system/controller/accessTokenController.py b/flaschengeist/system/controller/accessTokenController.py index 15b503d..033d383 100644 --- a/flaschengeist/system/controller/accessTokenController.py +++ b/flaschengeist/system/controller/accessTokenController.py @@ -15,7 +15,6 @@ class AccessTokenController(metaclass=Singleton): This Class create, delete, find and manage AccesToken. Attributes: - tokenList: List of currents AccessToken lifetime: Variable for the Lifetime of one AccessToken in seconds. """ instance = None @@ -29,34 +28,34 @@ class AccessTokenController(metaclass=Singleton): logger.debug("init accesstoken controller") self.lifetime = lifetime - def validateAccessToken(self, token, roles): - """ Verify Accestoken + def validate(self, token, roles): + """ Verify access token - Verify an Accestoken and Group so if the User has permission or not. - Retrieves the accestoken if valid else retrieves False + Verify an AccessToken and Group so if the User has permission or not. + Retrieves the access token if valid else retrieves False Args: token: Token to verify. roles: Roles needed to access restricted routes Returns: - An the AccesToken for this given Token or False. + An the AccessToken for this given Token or False. """ logger.debug("check token {{ {} }} is valid".format(token)) for accToken in AccessToken.query.filter_by(token=token): - endTime = accToken.timestamp + timedelta(seconds=accToken.lifetime) + time_end = accToken.timestamp + timedelta(seconds=accToken.lifetime) now = datetime.utcnow() - logger.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, endTime)) - if now <= endTime: + logger.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, time_end)) + if now <= time_end: logger.debug("check if token {{ {} }} is same as {{ {} }}".format(token, accToken)) if not roles or (roles and self.userHasRole(accToken.user, roles)): accToken.updateTimestamp() db.session.commit() return accToken else: - logger.debug("accesstoken is {{ {} }} out of date".format(accToken)) + logger.debug("access token is {{ {} }} out of date".format(accToken)) db.session.delete(accToken) db.session.commit() - logger.debug("no valid accesstoken with token: {{ {} }} and group: {{ {} }}".format(token, group)) + logger.debug("no valid access token with token: {{ {} }} and group: {{ {} }}".format(token, roles)) return False def userHasRole(self, user, roles): @@ -66,24 +65,25 @@ class AccessTokenController(metaclass=Singleton): return True return False - def createAccesToken(self, user, user_agent=None): + def create(self, user, user_agent=None): """ Create an AccessToken Create an AccessToken for an User and add it to the tokenList. Args: - user: For wich User is to create an AccessToken + user: For which User is to create an AccessToken + user_agent: User agent to identify session Returns: A created Token for User """ - logger.debug("creat accesstoken") - token = secrets.token_hex(16) - accToken = AccessToken(token=token, user=user, lifetime=self.lifetime, browser=user_agent.browser, platform=user_agent.platform) - db.session.add(accToken) + logger.debug("create access token") + token_str = secrets.token_hex(16) + token = AccessToken(token=token_str, user=user, lifetime=self.lifetime, browser=user_agent.browser, platform=user_agent.platform) + db.session.add(token) db.session.commit() - logger.debug("accesstoken is {{ {} }}".format(accToken)) + logger.debug("access token is {{ {} }}".format(token)) return token def getAccessTokensFromUser(self, user): @@ -96,12 +96,12 @@ class AccessTokenController(metaclass=Singleton): AccessToken.query.filter_by(token=accessToken).delete() db.session.commit() - def updateAccessToken(self, accessToken): - accessToken.updateTimestamp() + @staticmethod + def update_token(self, token): + token.updateTimestamp() db.session.commit() - return accessToken - def clearExpired(self): + def clear_expired(self): logger.debug("Clear expired AccessToken") mightExpired = datetime.utcnow() - timedelta(seconds=self.lifetime) tokens = AccessToken.query.filter(AccessToken.timestamp < mightExpired) diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index 26a1e96..a428e10 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -1,10 +1,11 @@ from functools import wraps -from flask import current_app, request, jsonify +from flask import request, jsonify from flaschengeist import logger + def login_required(**kwargs): from .controller.accessTokenController import AccessTokenController - accessController = AccessTokenController() + ac_controller = AccessTokenController() roles = None if "roles" in kwargs: roles = kwargs["roles"] @@ -14,10 +15,10 @@ def login_required(**kwargs): def wrapper(*args, **kwargs): token = request.headers.get('Token') logger.debug("token is {{ {} }}".format(token)) - accToken = accessController.validateAccessToken(token, roles) - logger.debug("accToken is {{ {} }}".format(accToken)) - kwargs['accToken'] = accToken - if accToken: + access_token = ac_controller.validate(token, roles) + logger.debug("accToken is {{ {} }}".format(access_token)) + kwargs['accToken'] = access_token + if access_token: logger.debug("token {{ {} }} is valid".format(token)) return func(*args, **kwargs) else: diff --git a/flaschengeist/system/exceptions/__init__.py b/flaschengeist/system/exceptions/__init__.py index 307c48e..4d18a6b 100644 --- a/flaschengeist/system/exceptions/__init__.py +++ b/flaschengeist/system/exceptions/__init__.py @@ -1,14 +1,6 @@ class PermissionDenied(Exception): pass + + class UsernameExistDB(Exception): pass -class UsernameExistLDAP(Exception): - pass -class DatabaseExecption(Exception): - pass -class LDAPExcetpion(Exception): - pass -class DayLocked(Exception): - pass -class TansactJobIsAnswerdException(Exception): - pass \ No newline at end of file diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/accessToken.py index 396c5ad..3d9fac8 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -6,6 +6,7 @@ from secrets import compare_digest logger = LocalProxy(lambda: current_app.logger) + class AccessToken(db.Model): """ Model for an AccessToken diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index f462595..4844c70 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -1,6 +1,4 @@ -from datetime import datetime from ..database import db -from .accessToken import AccessToken from sqlalchemy.orm.collections import attribute_mapped_collection from flask import current_app from werkzeug.local import LocalProxy @@ -11,6 +9,7 @@ association_table = db.Table('user_group', db.Column('group_id', db.Integer, db.ForeignKey('group.id')) ) + class User(db.Model): """ Database Object for User From e4b4db3405844f905c80a7d5a2a00b3a5eab9dd2 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 2 Sep 2020 01:10:54 +0200 Subject: [PATCH 024/446] Init of schedule plugin --- flaschengeist/modules/schedule/__init__.py | 660 ++++++++++----------- flaschengeist/modules/schedule/routes.py | 413 ------------- setup.py | 5 +- 3 files changed, 317 insertions(+), 761 deletions(-) delete mode 100644 flaschengeist/modules/schedule/routes.py diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index ff2fc7d..41b70ee 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -1,24 +1,287 @@ from flask import Blueprint, request, jsonify -#from geruecht.decorator import login_required -#import geruecht.controller.mainController as mc -#import geruecht.controller.accesTokenController as ac -#from geruecht.model import USER -#from datetime import datetime, time, date -#from geruecht.exceptions import DayLocked -#from geruecht.logger import getDebugLogger, getCreditLogger, getJobsLogger -#from geruecht.model.accessToken import AccessToken +from flaschengeist.system.decorator import login_required +from flaschengeist import logger +from datetime import datetime -user = Blueprint("user", __name__) +import .roles -#mainController = mc.MainController() -#accesTokenController = ac.AccesTokenController() +schedule = Blueprint("schedule", __name__) -debug = getDebugLogger() +@schedule.route("/schedule/jobs", methods=['POST']) +@login_required(roles=['schedule_read']) +def _getUsers(**kwrags): + logger.debug("/schedule/jobs") + try: + data = request.get_json() + from_date = data['from_date'] + to_date = data['to_date'] + from_date = datetime(from_date['year'], from_date['month'], from_date['day']) + to_date = datetime(to_date['year'], to_date['month'], to_date['day']) + lockedDays = mainController.getLockedDays(from_date, to_date) + retVal = [] + for lockedDay in lockedDays: + day = datetime.combine(lockedDay['daydate'], time(12)) + retDay = { + "worker": mainController.getWorker(day), + "day": { + "date": { + "year": day.year, + "month": day.month, + "day": day.day + }, + "locked": lockedDay['locked'] + }, + "jobkinddate": mainController.getJobKindDates(day.date()) + } + retVal.append(retDay) + + logger.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + logger.warn("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@schedule.route("/user/jobsOnDates", methods=['POST']) +@login_required(groups=[USER]) +def _getJobsOnDates(**kwargs): + debug.info("/user/jobsOnDates") + try: + data = request.get_json() + lockedDays = mainController.getLockedDaysFromList(data) + retVal = [] + for lockedDay in lockedDays: + day = datetime.combine(lockedDay['daydate'], time(12)) + retDay = { + "worker": mainController.getWorker(day), + "day": { + "date": { + "year": day.year, + "month": day.month, + "day": day.day + }, + "locked": lockedDay['locked'] + }, + "jobkinddate": mainController.getJobKindDates(day.date()) + } + retVal.append(retDay) + + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@schedule.route("/user/job", methods=['POST']) +@login_required(groups=[USER]) +def _getUser(**kwargs): + debug.info("/user/job") + try: + data = request.get_json() + day = data['day'] + month = data['month'] + year = data['year'] + date = datetime(year, month, day, 12) + lockedDay = mainController.getLockedDay(date) + if not lockedDay: + lockedDay = { + 'date': { + 'year': year, + 'month': month, + 'day': day + }, + 'locked': False + } + else: + lockedDay = { + 'date': { + 'year': year, + 'month': month, + 'day': day + }, + 'locked': lockedDay['locked'] + } + retVal = { + 'worker': mainController.getWorker(date), + 'day': lockedDay + } + debug.debug("retrun {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@schedule.route("/user/addJob", methods=['POST']) +@login_required(groups=[USER]) +def _addUser(**kwargs): + debug.info("/user/addJob") + try: + if 'accToken' in kwargs: + accToken = kwargs['accToken'] + user = accToken.user + data = request.get_json() + day = data['day'] + month = data['month'] + year = data['year'] + date = datetime(year, month, day, 12) + job_kind = None + if 'job_kind' in data: + job_kind = data['job_kind'] + mainController.addWorker(user.uid, date, job_kind=job_kind, userExc=True) + retVal = mainController.getWorker(date) + debug.debug("return {{ {} }}".format(retVal)) + jobL.info("Mitglied {} {} schreib sich am {} zum Dienst ein.".format( + user.firstname, user.lastname, date.date())) + return jsonify(retVal) + except DayLocked as err: + debug.debug("exception", exc_info=True) + return jsonify({'error': str(err)}), 403 + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({'error': str(err)}), 409 + + +@schedule.route("/user/deleteJob", methods=['POST']) +@login_required(groups=[USER]) +def _deletJob(**kwargs): + debug.info("/user/deleteJob") + try: + if 'accToken' in kwargs: + accToken = kwargs['accToken'] + user = accToken.user + data = request.get_json() + day = data['day'] + month = data['month'] + year = data['year'] + date = datetime(year, month, day, 12) + mainController.deleteWorker(user.uid, date, True) + retVal = mainController.getWorker(date) + debug.debug("return ok") + jobL.info("Mitglied {} {} entfernt sich am {} aus dem Dienst".format( + user.firstname, user.lastname, date.date())) + return jsonify(retVal) + except DayLocked as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 403 + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 409 + +@schedule.route("/user/getJobInvites", methods=['POST']) +@login_required(groups=[USER]) +def _getJobInvites(**kwargs): + try: + debug.info("/user/getJobInvites") + from_user = None + to_user = None + on_date = None + + data = request.get_json() + + if 'from_user' in data: + from_user = data['from_user'] + if 'to_user' in data: + to_user = data['to_user'] + on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) + retVal = mainController.getJobInvites(from_user, to_user, on_date) + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@schedule.route("/user/JobInvites", methods=['PUT', 'POST']) +@login_required(groups=[USER]) +def _JobInvites(**kwargs): + try: + debug.info("/user/JobInvites") + data = request.get_json() + if request.method == 'PUT': + mainController.setJobInvites(data) + retVal = mainController.getJobInvites(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) + debug.debug("return {{ {} }}".format(retVal)) + if request.method == 'POST': + retVal = mainController.updateJobInvites(data) + + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@schedule.route("/user/deleteJobInvite", methods=['POST']) +@login_required(groups=[USER]) +def _deleteJobInvite(**kwargs): + try: + debug.info("/user/deleteJobInvite") + data = request.get_json() + mainController.deleteJobInvite(data) + retVal = mainController.getJobInvites(data['from_user'], None, datetime.now().date()) + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + + +@schedule.route("/user/getJobRequests", methods=['POST']) +@login_required(groups=[USER]) +def _getJobRequests(**kwargs): + try: + debug.info("/user/getJobRequests") + from_user = None + to_user = None + on_date = None + + data = request.get_json() + + if 'from_user' in data: + from_user = data['from_user'] + if 'to_user' in data: + to_user = data['to_user'] + on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) + retVal = mainController.getJobRequests(from_user, to_user, on_date) + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@schedule.route("/user/JobRequests", methods=['PUT', 'POST']) +@login_required(groups=[USER]) +def _JobRequests(**kwargs): + try: + debug.info("/user/JobRequests") + data = request.get_json() + if request.method == 'PUT': + mainController.setJobRequests(data) + retVal = mainController.getJobRequests(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) + debug.debug("return {{ {} }}".format(retVal)) + if request.method == 'POST': + data['on_date'] = date(data['on_date']['year'], data['on_date']['month'], data['on_date']['day']) + retVal = mainController.updateJobRequests(data) + + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +@schedule.route("/user/deleteJobRequest", methods=['POST']) +@login_required(groups=[USER]) +def _deleteJobRequest(**kwargs): + try: + debug.info("/user/deleteJobRequest") + data = request.get_json() + mainController.deleteJobRequest(data) + retVal = mainController.getJobRequests(data['from_user'], None, datetime.now().date()) + debug.debug("return {{ {} }}".format(retVal)) + return jsonify(retVal) + except Exception as err: + debug.debug("exception", exc_info=True) + return jsonify({"error": str(err)}), 500 + +# CREDIT == Gerücht? CreditList? #creditL = getCreditLogger() -#jobL = getJobsLogger() - - -#@user.route("/user/main") +#@schedule.route("/user/main") #@login_required(groups=[USER]) #def _main(**kwargs): # debug.info("/user/main") @@ -36,7 +299,7 @@ debug = getDebugLogger() # return jsonify("error", "something went wrong"), 500 -#@user.route("/user/addAmount", methods=['POST']) +#@schedule.route("/user/addAmount", methods=['POST']) #@login_required(groups=[USER]) #def _addAmount(**kwargs): # debug.info("/user/addAmount") @@ -61,7 +324,7 @@ debug = getDebugLogger() # return jsonify({"error": "something went wrong"}), 500 -#@user.route("/user/saveConfig", methods=['POST']) +#@schedule.route("/user/saveConfig", methods=['POST']) #@login_required(groups=[USER]) #def _saveConfig(**kwargs): # debug.info("/user/saveConfig") @@ -81,333 +344,38 @@ debug = getDebugLogger() # except Exception as err: # debug.debug("exception", exc_info=True) # return jsonify({"error": str(err)}), 409 - - -#@user.route("/user/jobs", methods=['POST']) -#@login_required(groups=[USER]) -#def _getUsers(**kwrags): -# debug.info("/user/jobs") -# try: -# data = request.get_json() - #from_date = data['from_date'] - #to_date = data['to_date'] - #from_date = datetime( - #from_date['year'], from_date['month'], from_date['day']) - #to_date = datetime(to_date['year'], to_date['month'], to_date['day']) - #lockedDays = mainController.getLockedDays(from_date, to_date) - #retVal = [] - #for lockedDay in lockedDays: - #day = datetime.combine(lockedDay['daydate'], time(12)) - #retDay = { - #"worker": mainController.getWorker(day), - #"day": { - #"date": { - #"year": day.year, - #"month": day.month, - #"day": day.day - #}, - #"locked": lockedDay['locked'] - #}, - #"jobkinddate": mainController.getJobKindDates(day.date()) - #} - #retVal.append(retDay) - - #debug.debug("return {{ {} }}".format(retVal)) - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - -#@user.route("/user/jobsOnDates", methods=['POST']) -#@login_required(groups=[USER]) -#def _getJobsOnDates(**kwargs): - #debug.info("/user/jobsOnDates") - #try: - #data = request.get_json() - #lockedDays = mainController.getLockedDaysFromList(data) - #retVal = [] - #for lockedDay in lockedDays: - #day = datetime.combine(lockedDay['daydate'], time(12)) - #retDay = { - #"worker": mainController.getWorker(day), - #"day": { - #"date": { - #"year": day.year, - #"month": day.month, - #"day": day.day - #}, - #"locked": lockedDay['locked'] - #}, - #"jobkinddate": mainController.getJobKindDates(day.date()) - #} - #retVal.append(retDay) - - #debug.debug("return {{ {} }}".format(retVal)) - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - -#@user.route("/user/job", methods=['POST']) -#@login_required(groups=[USER]) -#def _getUser(**kwargs): - #debug.info("/user/job") - #try: - #data = request.get_json() - #day = data['day'] - #month = data['month'] - #year = data['year'] - #date = datetime(year, month, day, 12) - #lockedDay = mainController.getLockedDay(date) - #if not lockedDay: - #lockedDay = { - #'date': { - #'year': year, - #'month': month, - #'day': day - #}, - #'locked': False - #} - #else: - #lockedDay = { - #'date': { - #'year': year, - #'month': month, - #'day': day - #}, - #'locked': lockedDay['locked'] - #} - #retVal = { - #'worker': mainController.getWorker(date), - #'day': lockedDay - #} - #debug.debug("retrun {{ {} }}".format(retVal)) - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - - -#@user.route("/user/addJob", methods=['POST']) -#@login_required(groups=[USER]) -#def _addUser(**kwargs): - #debug.info("/user/addJob") - #try: - #if 'accToken' in kwargs: - #accToken = kwargs['accToken'] - #user = accToken.user - #data = request.get_json() - #day = data['day'] - #month = data['month'] - #year = data['year'] - #date = datetime(year, month, day, 12) - #job_kind = None - #if 'job_kind' in data: - #job_kind = data['job_kind'] - #mainController.addWorker(user.uid, date, job_kind=job_kind, userExc=True) - #retVal = mainController.getWorker(date) - #debug.debug("return {{ {} }}".format(retVal)) - #jobL.info("Mitglied {} {} schreib sich am {} zum Dienst ein.".format( - #user.firstname, user.lastname, date.date())) - #return jsonify(retVal) - #except DayLocked as err: - #debug.debug("exception", exc_info=True) - #return jsonify({'error': str(err)}), 403 - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({'error': str(err)}), 409 - - -#@user.route("/user/deleteJob", methods=['POST']) -#@login_required(groups=[USER]) -#def _deletJob(**kwargs): - #debug.info("/user/deleteJob") - #try: - #if 'accToken' in kwargs: - #accToken = kwargs['accToken'] - #user = accToken.user - #data = request.get_json() - #day = data['day'] - #month = data['month'] - #year = data['year'] - #date = datetime(year, month, day, 12) - #mainController.deleteWorker(user.uid, date, True) - #retVal = mainController.getWorker(date) - #debug.debug("return ok") - #jobL.info("Mitglied {} {} entfernt sich am {} aus dem Dienst".format( - #user.firstname, user.lastname, date.date())) - #return jsonify(retVal) - #except DayLocked as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 403 - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 409 - -#@user.route("/user/storno", methods=['POST']) +# +#@schedule.route("/user/storno", methods=['POST']) #@login_required(groups=[USER]) #def _storno(**kwargs): - #""" Function for Baruser to storno amount - - #This function added to the user with the posted userID the posted amount. - - #Returns: - #JSON-File with userID and the amount - #or ERROR 401 Permission Denied - #""" - - #debug.info("/user/storno") - #try: - #if 'accToken' in kwargs: - #accToken = kwargs['accToken'] - #user = accToken.user - #data = request.get_json() - #amount = int(data['amount']) - - #date = datetime.now() - #mainController.addCredit( - #user.uid, amount, year=date.year, month=date.month) - #accToken.user = mainController.getUser(accToken.user.uid) - #retVal = accToken.user.toJSON() - #retVal['creditList'] = {credit.year: credit.toJSON() - #for credit in accToken.user.geruechte} - #debug.debug("return {{ {} }}".format(retVal)) - #creditL.info("{} {} {} storniert {} €".format( - #date, user.firstname, user.lastname, amount/100)) - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - - -#@user.route("/user/getJobInvites", methods=['POST']) -#@login_required(groups=[USER]) -#def _getJobInvites(**kwargs): - #try: - #debug.info("/user/getJobInvites") - #from_user = None - #to_user = None - #on_date = None - - #data = request.get_json() - - #if 'from_user' in data: - #from_user = data['from_user'] - #if 'to_user' in data: - #to_user = data['to_user'] - #on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) - #retVal = mainController.getJobInvites(from_user, to_user, on_date) - #debug.debug("return {{ {} }}".format(retVal)) - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - -#@user.route("/user/JobInvites", methods=['PUT', 'POST']) -#@login_required(groups=[USER]) -#def _JobInvites(**kwargs): - #try: - #debug.info("/user/JobInvites") - #data = request.get_json() - #if request.method == 'PUT': - #mainController.setJobInvites(data) - #retVal = mainController.getJobInvites(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) - #debug.debug("return {{ {} }}".format(retVal)) - #if request.method == 'POST': - #retVal = mainController.updateJobInvites(data) - - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - -#@user.route("/user/deleteJobInvite", methods=['POST']) -#@login_required(groups=[USER]) -#def _deleteJobInvite(**kwargs): - #try: - #debug.info("/user/deleteJobInvite") - #data = request.get_json() - #mainController.deleteJobInvite(data) - #retVal = mainController.getJobInvites(data['from_user'], None, datetime.now().date()) - #debug.debug("return {{ {} }}".format(retVal)) - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - - -#@user.route("/user/getJobRequests", methods=['POST']) -#@login_required(groups=[USER]) -#def _getJobRequests(**kwargs): - #try: - #debug.info("/user/getJobRequests") - #from_user = None - #to_user = None - #on_date = None - - #data = request.get_json() - - #if 'from_user' in data: - #from_user = data['from_user'] - #if 'to_user' in data: - #to_user = data['to_user'] - #on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) - #retVal = mainController.getJobRequests(from_user, to_user, on_date) - #debug.debug("return {{ {} }}".format(retVal)) - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - -#@user.route("/user/JobRequests", methods=['PUT', 'POST']) -#@login_required(groups=[USER]) -#def _JobRequests(**kwargs): - #try: - #debug.info("/user/JobRequests") - #data = request.get_json() - #if request.method == 'PUT': - #mainController.setJobRequests(data) - #retVal = mainController.getJobRequests(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) - #debug.debug("return {{ {} }}".format(retVal)) - #if request.method == 'POST': - #data['on_date'] = date(data['on_date']['year'], data['on_date']['month'], data['on_date']['day']) - #retVal = mainController.updateJobRequests(data) - - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - -#@user.route("/user/deleteJobRequest", methods=['POST']) -#@login_required(groups=[USER]) -#def _deleteJobRequest(**kwargs): - #try: - #debug.info("/user/deleteJobRequest") - #data = request.get_json() - #mainController.deleteJobRequest(data) - #retVal = mainController.getJobRequests(data['from_user'], None, datetime.now().date()) - #debug.debug("return {{ {} }}".format(retVal)) - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 - - -#@user.route("/user/getAccessTokens", methods=['GET', 'POST']) -#@login_required(groups=[USER]) -#def _getAccessTokens(**kwargs): - #try: - #debug.info("/user/getAccessTokens") - #if request.method == 'POST': - #data = request.get_json() - #delAccToken = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) - #accesTokenController.deleteAccessToken(delAccToken) - #tokens = accesTokenController.getAccessTokensFromUser(kwargs['accToken'].user) - #retVal = [] - #for token in tokens: - #retVal.append(token.toJSON()) - #debug.debug("return {{ {} }}".format(retVal)) - #return jsonify(retVal) - #except Exception as err: - #debug.debug("exception", exc_info=True) - #return jsonify({"error": str(err)}), 500 +# """ Function for Baruser to storno amount +# +# This function added to the user with the posted userID the posted amount. +# +# Returns: +# JSON-File with userID and the amount +# or ERROR 401 Permission Denied +# """ +# +# debug.info("/user/storno") +# try: +# if 'accToken' in kwargs: +# accToken = kwargs['accToken'] +# user = accToken.user +# data = request.get_json() +# amount = int(data['amount']) +# +# date = datetime.now() +# mainController.addCredit( +# user.uid, amount, year=date.year, month=date.month) +# accToken.user = mainController.getUser(accToken.user.uid) +# retVal = accToken.user.toJSON() +# retVal['creditList'] = {credit.year: credit.toJSON() +# for credit in accToken.user.geruechte} +# debug.debug("return {{ {} }}".format(retVal)) +# creditL.info("{} {} {} storniert {} €".format( +# date, user.firstname, user.lastname, amount/100)) +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 diff --git a/flaschengeist/modules/schedule/routes.py b/flaschengeist/modules/schedule/routes.py deleted file mode 100644 index 6b5e3c2..0000000 --- a/flaschengeist/modules/schedule/routes.py +++ /dev/null @@ -1,413 +0,0 @@ -from flask import Blueprint, request, jsonify -from geruecht.decorator import login_required -import geruecht.controller.mainController as mc -import geruecht.controller.accesTokenController as ac -from geruecht.model import USER -from datetime import datetime, time, date -from geruecht.exceptions import DayLocked -from geruecht.logger import getDebugLogger, getCreditLogger, getJobsLogger -from geruecht.model.accessToken import AccessToken - -user = Blueprint("user", __name__) - -mainController = mc.MainController() -accesTokenController = ac.AccesTokenController() - -debug = getDebugLogger() -creditL = getCreditLogger() -jobL = getJobsLogger() - - -@user.route("/user/main") -@login_required(groups=[USER]) -def _main(**kwargs): - debug.info("/user/main") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - accToken.user = mainController.getUser(accToken.user.uid) - retVal = accToken.user.toJSON() - retVal['creditList'] = {credit.year: credit.toJSON() - for credit in accToken.user.geruechte} - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception: - debug.debug("exception", exc_info=True) - return jsonify("error", "something went wrong"), 500 - - -@user.route("/user/addAmount", methods=['POST']) -@login_required(groups=[USER]) -def _addAmount(**kwargs): - debug.info("/user/addAmount") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - data = request.get_json() - amount = int(data['amount']) - date = datetime.now() - mainController.addAmount( - accToken.user.uid, amount, year=date.year, month=date.month) - accToken.user = mainController.getUser(accToken.user.uid) - retVal = accToken.user.toJSON() - retVal['creditList'] = {credit.year: credit.toJSON() - for credit in accToken.user.geruechte} - debug.debug("return {{ {} }}".format(retVal)) - creditL.info("{} {} {} fügt sich selbst {} € Schulden hinzu".format( - date, accToken.user.firstname, accToken.user.lastname, amount/100)) - return jsonify(retVal) - except Exception: - debug.debug("exception", exc_info=True) - return jsonify({"error": "something went wrong"}), 500 - - -@user.route("/user/saveConfig", methods=['POST']) -@login_required(groups=[USER]) -def _saveConfig(**kwargs): - debug.info("/user/saveConfig") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - data = request.get_json() - password = data['acceptedPassword'] - data.pop('acceptedPassword') - accToken.user = mainController.modifyUser( - accToken.user, data, password) - retVal = accToken.user.toJSON() - retVal['creditList'] = {credit.year: credit.toJSON() - for credit in accToken.user.geruechte} - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 409 - - -@user.route("/user/jobs", methods=['POST']) -@login_required(groups=[USER]) -def _getUsers(**kwrags): - debug.info("/user/jobs") - try: - data = request.get_json() - from_date = data['from_date'] - to_date = data['to_date'] - from_date = datetime( - from_date['year'], from_date['month'], from_date['day']) - to_date = datetime(to_date['year'], to_date['month'], to_date['day']) - lockedDays = mainController.getLockedDays(from_date, to_date) - retVal = [] - for lockedDay in lockedDays: - day = datetime.combine(lockedDay['daydate'], time(12)) - retDay = { - "worker": mainController.getWorker(day), - "day": { - "date": { - "year": day.year, - "month": day.month, - "day": day.day - }, - "locked": lockedDay['locked'] - }, - "jobkinddate": mainController.getJobKindDates(day.date()) - } - retVal.append(retDay) - - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/jobsOnDates", methods=['POST']) -@login_required(groups=[USER]) -def _getJobsOnDates(**kwargs): - debug.info("/user/jobsOnDates") - try: - data = request.get_json() - lockedDays = mainController.getLockedDaysFromList(data) - retVal = [] - for lockedDay in lockedDays: - day = datetime.combine(lockedDay['daydate'], time(12)) - retDay = { - "worker": mainController.getWorker(day), - "day": { - "date": { - "year": day.year, - "month": day.month, - "day": day.day - }, - "locked": lockedDay['locked'] - }, - "jobkinddate": mainController.getJobKindDates(day.date()) - } - retVal.append(retDay) - - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/job", methods=['POST']) -@login_required(groups=[USER]) -def _getUser(**kwargs): - debug.info("/user/job") - try: - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - lockedDay = mainController.getLockedDay(date) - if not lockedDay: - lockedDay = { - 'date': { - 'year': year, - 'month': month, - 'day': day - }, - 'locked': False - } - else: - lockedDay = { - 'date': { - 'year': year, - 'month': month, - 'day': day - }, - 'locked': lockedDay['locked'] - } - retVal = { - 'worker': mainController.getWorker(date), - 'day': lockedDay - } - debug.debug("retrun {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@user.route("/user/addJob", methods=['POST']) -@login_required(groups=[USER]) -def _addUser(**kwargs): - debug.info("/user/addJob") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - user = accToken.user - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - job_kind = None - if 'job_kind' in data: - job_kind = data['job_kind'] - mainController.addWorker(user.uid, date, job_kind=job_kind, userExc=True) - retVal = mainController.getWorker(date) - debug.debug("return {{ {} }}".format(retVal)) - jobL.info("Mitglied {} {} schreib sich am {} zum Dienst ein.".format( - user.firstname, user.lastname, date.date())) - return jsonify(retVal) - except DayLocked as err: - debug.debug("exception", exc_info=True) - return jsonify({'error': str(err)}), 403 - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({'error': str(err)}), 409 - - -@user.route("/user/deleteJob", methods=['POST']) -@login_required(groups=[USER]) -def _deletJob(**kwargs): - debug.info("/user/deleteJob") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - user = accToken.user - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - mainController.deleteWorker(user.uid, date, True) - retVal = mainController.getWorker(date) - debug.debug("return ok") - jobL.info("Mitglied {} {} entfernt sich am {} aus dem Dienst".format( - user.firstname, user.lastname, date.date())) - return jsonify(retVal) - except DayLocked as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 403 - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 409 - -@user.route("/user/storno", methods=['POST']) -@login_required(groups=[USER]) -def _storno(**kwargs): - """ Function for Baruser to storno amount - - This function added to the user with the posted userID the posted amount. - - Returns: - JSON-File with userID and the amount - or ERROR 401 Permission Denied - """ - - debug.info("/user/storno") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - user = accToken.user - data = request.get_json() - amount = int(data['amount']) - - date = datetime.now() - mainController.addCredit( - user.uid, amount, year=date.year, month=date.month) - accToken.user = mainController.getUser(accToken.user.uid) - retVal = accToken.user.toJSON() - retVal['creditList'] = {credit.year: credit.toJSON() - for credit in accToken.user.geruechte} - debug.debug("return {{ {} }}".format(retVal)) - creditL.info("{} {} {} storniert {} €".format( - date, user.firstname, user.lastname, amount/100)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@user.route("/user/getJobInvites", methods=['POST']) -@login_required(groups=[USER]) -def _getJobInvites(**kwargs): - try: - debug.info("/user/getJobInvites") - from_user = None - to_user = None - on_date = None - - data = request.get_json() - - if 'from_user' in data: - from_user = data['from_user'] - if 'to_user' in data: - to_user = data['to_user'] - on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) - retVal = mainController.getJobInvites(from_user, to_user, on_date) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/JobInvites", methods=['PUT', 'POST']) -@login_required(groups=[USER]) -def _JobInvites(**kwargs): - try: - debug.info("/user/JobInvites") - data = request.get_json() - if request.method == 'PUT': - mainController.setJobInvites(data) - retVal = mainController.getJobInvites(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - if request.method == 'POST': - retVal = mainController.updateJobInvites(data) - - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/deleteJobInvite", methods=['POST']) -@login_required(groups=[USER]) -def _deleteJobInvite(**kwargs): - try: - debug.info("/user/deleteJobInvite") - data = request.get_json() - mainController.deleteJobInvite(data) - retVal = mainController.getJobInvites(data['from_user'], None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@user.route("/user/getJobRequests", methods=['POST']) -@login_required(groups=[USER]) -def _getJobRequests(**kwargs): - try: - debug.info("/user/getJobRequests") - from_user = None - to_user = None - on_date = None - - data = request.get_json() - - if 'from_user' in data: - from_user = data['from_user'] - if 'to_user' in data: - to_user = data['to_user'] - on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) - retVal = mainController.getJobRequests(from_user, to_user, on_date) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/JobRequests", methods=['PUT', 'POST']) -@login_required(groups=[USER]) -def _JobRequests(**kwargs): - try: - debug.info("/user/JobRequests") - data = request.get_json() - if request.method == 'PUT': - mainController.setJobRequests(data) - retVal = mainController.getJobRequests(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - if request.method == 'POST': - data['on_date'] = date(data['on_date']['year'], data['on_date']['month'], data['on_date']['day']) - retVal = mainController.updateJobRequests(data) - - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@user.route("/user/deleteJobRequest", methods=['POST']) -@login_required(groups=[USER]) -def _deleteJobRequest(**kwargs): - try: - debug.info("/user/deleteJobRequest") - data = request.get_json() - mainController.deleteJobRequest(data) - retVal = mainController.getJobRequests(data['from_user'], None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@user.route("/user/getAccessTokens", methods=['GET', 'POST']) -@login_required(groups=[USER]) -def _getAccessTokens(**kwargs): - try: - debug.info("/user/getAccessTokens") - if request.method == 'POST': - data = request.get_json() - delAccToken = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) - accesTokenController.deleteAccessToken(delAccToken) - tokens = accesTokenController.getAccessTokensFromUser(kwargs['accToken'].user) - retVal = [] - for token in tokens: - retVal.append(token.toJSON()) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 diff --git a/setup.py b/setup.py index 9409bc6..e2fd23a 100644 --- a/setup.py +++ b/setup.py @@ -17,9 +17,10 @@ setup( 'ldap3' ] }, - entry_points = { + entry_points={ 'flaschengeist.plugin': [ - 'auth = flaschengeist.modules.auth:register' + 'auth = flaschengeist.modules.auth:register', + 'schedule = flaschengeist.modules.schedule:register' ], 'flaschengeist.auth': [ 'auth_plain = flaschengeist.modules.auth_plain:AuthPlain', From b4505de25315a3f11b57d4630bb2f13f6d85abf4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 2 Sep 2020 01:32:55 +0200 Subject: [PATCH 025/446] Fixed typos --- flaschengeist/modules/auth/__init__.py | 26 ++++---- .../controller/accessTokenController.py | 44 +++++++------- flaschengeist/system/decorator.py | 2 +- flaschengeist/system/models/accessToken.py | 10 ++-- flaschengeist/system/models/user.py | 59 ++++++++++--------- setup.py | 3 +- 6 files changed, 76 insertions(+), 68 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 5e867ae..e17e143 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -1,7 +1,7 @@ ############################################# # Plugin: Auth # # Functionality: Allow management of # -# authentification, login, logout, etc # +# authentication, login, logout, etc # ############################################# from flask import Blueprint, current_app, request, jsonify @@ -24,7 +24,7 @@ def register(): return auth_bp ############################################ -## Routes ## +# Routes # ############################################ @@ -68,10 +68,10 @@ def _login(): def _logout(**kwargs): try: logger.debug("logout user") - accToken = kwargs['accToken'] - logger.debug("accesstoken is {{ {} }}".format(accToken)) - logger.debug("delete accesstoken") - access_controller.deleteAccessToken(accToken) + token = kwargs['accToken'] + logger.debug("access token is {{ {} }}".format(token)) + logger.debug("delete access token") + access_controller.deleteAccessToken(token) access_controller.clearExpired() logger.info("return ok logout user") return jsonify({"ok": "ok"}) @@ -81,7 +81,7 @@ def _logout(**kwargs): @auth_bp.route("/user/getAccessTokens", methods=['GET', 'POST']) -#@auth_bp.route("/accessTokens", methods=['GET', 'POST']) +# @auth_bp.route("/accessTokens", methods=['GET', 'POST']) @login_required() def _getAccessTokens(**kwargs): try: @@ -102,12 +102,12 @@ def _getAccessTokens(**kwargs): @login_required() def _getLifeTime(**kwargs): try: - logger.debug("get lifetime of accesstoken") - accToken = kwargs['accToken'] - logger.debug("accessToken is {{ {} }}".format(accToken)) - return jsonify({"value": accToken.lifetime}) + logger.debug("get lifetime of access token") + token = kwargs['accToken'] + logger.debug("accessToken is {{ {} }}".format(token)) + return jsonify({"value": token.lifetime}) except Exception as err: - logger.warning("exception in get lifetime of accesstoken.", exc_info=True) + logger.warning("exception in get lifetime of access token.", exc_info=True) return jsonify({"error": str(err)}), 500 @@ -125,7 +125,7 @@ def _saveLifeTime(**kwargs): token.lifetime = lifetime logger.info("update access token timestamp") token = access_controller.update(token) - return jsonify({"value": token.lifetime }) + return jsonify({"value": token.lifetime}) except Exception as err: logger.warning( "exception in save lifetime for access token.", exc_info=True) diff --git a/flaschengeist/system/controller/accessTokenController.py b/flaschengeist/system/controller/accessTokenController.py index 033d383..3b0ed76 100644 --- a/flaschengeist/system/controller/accessTokenController.py +++ b/flaschengeist/system/controller/accessTokenController.py @@ -10,9 +10,9 @@ logger = logging.getLogger("flaschenpost") class AccessTokenController(metaclass=Singleton): - """ Control all createt AccesToken + """ Control all created AccessToken - This Class create, delete, find and manage AccesToken. + This Class create, delete, find and manage AccessToken. Attributes: lifetime: Variable for the Lifetime of one AccessToken in seconds. @@ -25,10 +25,10 @@ class AccessTokenController(metaclass=Singleton): Initialize Thread and set tokenList empty. """ - logger.debug("init accesstoken controller") + logger.debug("init access token controller") self.lifetime = lifetime - def validate(self, token, roles): + def validate_token(self, token, roles): """ Verify access token Verify an AccessToken and Group so if the User has permission or not. @@ -41,19 +41,19 @@ class AccessTokenController(metaclass=Singleton): An the AccessToken for this given Token or False. """ logger.debug("check token {{ {} }} is valid".format(token)) - for accToken in AccessToken.query.filter_by(token=token): - time_end = accToken.timestamp + timedelta(seconds=accToken.lifetime) + for access_token in AccessToken.query.filter_by(token=token): + time_end = access_token.timestamp + timedelta(seconds=access_token.lifetime) now = datetime.utcnow() logger.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, time_end)) if now <= time_end: - logger.debug("check if token {{ {} }} is same as {{ {} }}".format(token, accToken)) - if not roles or (roles and self.userHasRole(accToken.user, roles)): - accToken.updateTimestamp() + logger.debug("check if token {{ {} }} is same as {{ {} }}".format(token, access_token)) + if not roles or (roles and self.userHasRole(access_token.user, roles)): + access_token.updateTimestamp() db.session.commit() - return accToken + return access_token else: - logger.debug("access token is {{ {} }} out of date".format(accToken)) - db.session.delete(accToken) + logger.debug("access token is {{ {} }} out of date".format(access_token)) + db.session.delete(access_token) db.session.commit() logger.debug("no valid access token with token: {{ {} }} and group: {{ {} }}".format(token, roles)) return False @@ -79,7 +79,8 @@ class AccessTokenController(metaclass=Singleton): """ logger.debug("create access token") token_str = secrets.token_hex(16) - token = AccessToken(token=token_str, user=user, lifetime=self.lifetime, browser=user_agent.browser, platform=user_agent.platform) + token = AccessToken(token=token_str, user=user, lifetime=self.lifetime, + browser=user_agent.browser, platform=user_agent.platform) db.session.add(token) db.session.commit() @@ -89,22 +90,23 @@ class AccessTokenController(metaclass=Singleton): def getAccessTokensFromUser(self, user): return AccessToken.query.filter(AccessToken.user == user) - def deleteAccessToken(self, accessToken): - if accessToken is isinstance(accessToken, AccessToken): - db.session.delete(accessToken) + @staticmethod + def delete_token(token): + if token is isinstance(token, AccessToken): + db.session.delete(token) else: - AccessToken.query.filter_by(token=accessToken).delete() + AccessToken.query.filter_by(token=token).delete() db.session.commit() @staticmethod - def update_token(self, token): - token.updateTimestamp() + def update_token(token): + token.update_timestamp() db.session.commit() def clear_expired(self): logger.debug("Clear expired AccessToken") - mightExpired = datetime.utcnow() - timedelta(seconds=self.lifetime) - tokens = AccessToken.query.filter(AccessToken.timestamp < mightExpired) + might_expired = datetime.utcnow() - timedelta(seconds=self.lifetime) + tokens = AccessToken.query.filter(AccessToken.timestamp < might_expired) logger.debug(tokens) for token in tokens: if token.timestamp < datetime.utcnow() - timedelta(seconds=token.lifetime): diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index a428e10..3942cd8 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -15,7 +15,7 @@ def login_required(**kwargs): def wrapper(*args, **kwargs): token = request.headers.get('Token') logger.debug("token is {{ {} }}".format(token)) - access_token = ac_controller.validate(token, roles) + access_token = ac_controller.validate_token(token, roles) logger.debug("accToken is {{ {} }}".format(access_token)) kwargs['accToken'] = access_token if access_token: diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/accessToken.py index 3d9fac8..6003568 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -25,12 +25,12 @@ class AccessToken(db.Model): browser = db.Column(db.String(30)) platform = db.Column(db.String(30)) - def updateTimestamp(self): + def update_timestamp(self): """ Update the Timestamp Update the Timestamp to the current Time. """ - logger.debug("update timestamp from accesstoken {{ {} }}".format(self)) + logger.debug("update timestamp from access token {{ {} }}".format(self)) self.timestamp = datetime.utcnow() def toJSON(self): @@ -61,7 +61,9 @@ class AccessToken(db.Model): return other - self.timestamp def __str__(self): - return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format(self.user, self.token, self.timestamp, self.lifetime) + return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format( + self.user, self.token, self.timestamp, self.lifetime) def __repr__(self): - return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format(self.user, self.token, self.timestamp, self.lifetime) + return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format( + self.user, self.token, self.timestamp, self.lifetime) diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index 4844c70..0dd0321 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -2,51 +2,53 @@ from ..database import db from sqlalchemy.orm.collections import attribute_mapped_collection from flask import current_app from werkzeug.local import LocalProxy + logger = LocalProxy(lambda: current_app.logger) association_table = db.Table('user_group', - db.Column('user_id', db.Integer, db.ForeignKey('user.id')), - db.Column('group_id', db.Integer, db.ForeignKey('group.id')) -) + db.Column('user_id', db.Integer, db.ForeignKey('user.id')), + db.Column('group_id', db.Integer, db.ForeignKey('group.id')) + ) class User(db.Model): """ Database Object for User - Table for all safed User + Table for all saved User Attributes: id: Id in Database as Primary Key. uid: User ID used by authentication provider - displayname: Name to show + display_name: Name to show firstname: Firstname of the User lastname: Lastname of the User mail: mail address of the User """ __tablename__ = 'user' - id = db.Column(db.Integer, primary_key=True) - uid = db.Column(db.String(30)) - displayname = db.Column(db.String(30)) - firstname = db.Column(db.String(30)) - lastname = db.Column(db.String(30)) - mail = db.Column(db.String(30)) - groups = db.relationship("Group", secondary=association_table) - sessions = db.relationship("AccessToken", back_populates="user") - attributes = db.relationship("UserAttribute", collection_class=attribute_mapped_collection('name'), cascade="all, delete") + id = db.Column(db.Integer, primary_key=True) + uid = db.Column(db.String(30)) + display_name = db.Column(db.String(30)) + firstname = db.Column(db.String(30)) + lastname = db.Column(db.String(30)) + mail = db.Column(db.String(30)) + groups = db.relationship("Group", secondary=association_table) + sessions = db.relationship("AccessToken", back_populates="user") + attributes = db.relationship("UserAttribute", collection_class=attribute_mapped_collection('name'), + cascade="all, delete") - def setAttribute(self, name, value): + def set_attribute(self, name, value): if name in self.attributes: self.attributes[name].value = value else: self.attributes[name] = UserAttribute(name=name, value=value) - def addGroup(self, name): + def add_group(self, name): r = Group.query.filter_by(name=name).first() if not r: r = Group(name=name) self.groups.append(r) - - def updateData(self, data): + + def update_data(self, data): logger.debug("update data of user") if 'uid' in data: self.uid = data['uid'] @@ -56,14 +58,14 @@ class User(db.Model): self.lastname = data['lastname'] if 'mail' in data: self.mail = data['mail'] - if 'displayname' in data: - self.displayname = data['displayname'] + if 'display_name' in data: + self.display_name = data['display_name'] def toJSON(self): return { # TODO: username should be UID? "username": self.uid, - "displayname": self.displayname, + "display_name": self.display_name, "firstname": self.firstname, "lastname": self.lastname, "mail": self.mail, @@ -73,20 +75,21 @@ class User(db.Model): class UserAttribute(db.Model): __tablename__ = 'userAttribute' - id = db.Column(db.Integer, primary_key=True) - user = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - name = db.Column(db.String(30)) + id = db.Column(db.Integer, primary_key=True) + user = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + name = db.Column(db.String(30)) value = db.Column(db.String(192)) + group_permission_association_table = db.Table('group_permission', - db.Column('group_id', db.Integer, db.ForeignKey('group.id')), - db.Column('permission_id', db.Integer, db.ForeignKey('permission.id')) -) + db.Column('group_id', db.Integer, db.ForeignKey('group.id')), + db.Column('permission_id', db.Integer, db.ForeignKey('permission.id')) + ) class Group(db.Model): __tablename__ = 'group' - id = db.Column(db.Integer, primary_key=True) + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(30)) permissions = db.relationship("Permission", secondary=group_permission_association_table) diff --git a/setup.py b/setup.py index e2fd23a..4720a1c 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,8 @@ setup( packages=find_packages(), package_data={'': ['*.yml']}, scripts=['run_flaschengeist'], - install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', 'sqlalchemy>=1.3', "flask_sqlalchemy", "flask_cors"], + install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', 'sqlalchemy>=1.3', "flask_sqlalchemy", + "flask_cors", "werkzeug"], extras_require={ 'ldap': [ 'flask_ldapconn', From 5bfa305c414899fbeb52417849e21a5c14d55174 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 2 Sep 2020 13:07:21 +0200 Subject: [PATCH 026/446] Fixed auth. Some cleanup --- flaschengeist/modules/auth/__init__.py | 4 ++-- flaschengeist/modules/auth_ldap/__init__.py | 12 ++++++------ flaschengeist/modules/auth_plain/__init__.py | 18 +++++++++--------- .../system/controller/accessTokenController.py | 4 ++-- .../mainController/mainUserController.py | 1 + flaschengeist/system/models/accessToken.py | 2 +- flaschengeist/system/models/user.py | 2 +- 7 files changed, 22 insertions(+), 21 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index e17e143..1426083 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -50,8 +50,8 @@ def _login(): token = access_controller.create(user, user_agent=request.user_agent) logger.debug("access token is {{ {} }}".format(token)) logger.debug("validate access token") - dic = user.toJSON() - dic["accessToken"] = token + dic = user.default() + dic["accessToken"] = token.token logger.info("User {{ {} }} success login.".format(username)) logger.debug("return login {{ {} }}".format(dic)) return jsonify(dic) diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index 00e5803..1eb1530 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -110,17 +110,17 @@ class AuthLDAP(modules.Auth): attributes=['uid', 'givenName', 'sn', 'mail']) r = self.ldap.connection.response[0]['attributes'] if r['uid'][0] == user.uid: - user.setAttribute('DN', self.ldap.connection.response[0]['dn']) + user.set_attribute('DN', self.ldap.connection.response[0]['dn']) user.firstname = r['givenName'][0] user.lastname = r['sn'][0] if r['mail']: user.mail = r['mail'][0] if 'displayName' in r: - user.displayname = r['displayName'][0] - for group in self._getGroups(user.uid): - user.addGroup(group) + user.display_name = r['displayName'][0] + for group in self._get_groups(user.uid): + user.add_group(group) - def _getGroups(self, uid): + def _get_groups(self, uid): try: groups = [] @@ -141,7 +141,7 @@ class AuthLDAP(modules.Auth): groups.append(data['attributes']['cn'][0]) return groups except Exception as err: - debug.warning("exception in get groups from ldap", exc_info=True) + logger.warning("exception in get groups from ldap", exc_info=True) return [] # def getAllUser(self): diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/modules/auth_plain/__init__.py index 908c642..2677d19 100644 --- a/flaschengeist/modules/auth_plain/__init__.py +++ b/flaschengeist/modules/auth_plain/__init__.py @@ -10,18 +10,18 @@ class AuthPlain(modules.Auth): if not user: return False if 'password' in user.attributes: - return self.__verify_password(user.attributes['password'].value, password) + return self._verify_password(user.attributes['password'].value, password) return False - def __hash_password(self, password): + def _hash_password(self, password): salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') - pwdhash = hashlib.pbkdf2_hmac('sha3-512', password.encode('utf-8'), salt, 100000) - pwdhash = binascii.hexlify(pwdhash) - return (salt + pwdhash).decode('ascii') + pass_hash = hashlib.pbkdf2_hmac('sha3-512', password.encode('utf-8'), salt, 100000) + pass_hash = binascii.hexlify(pass_hash) + return (salt + pass_hash).decode('ascii') - def __verify_password(self, stored_password, provided_password): + def _verify_password(self, stored_password, provided_password): salt = stored_password[:64] stored_password = stored_password[64:] - pwdhash = hashlib.pbkdf2_hmac('sha3-512', provided_password.encode('utf-8'), salt.encode('ascii'), 100000) - pwdhash = binascii.hexlify(pwdhash).decode('ascii') - return pwdhash == stored_password + pass_hash = hashlib.pbkdf2_hmac('sha3-512', provided_password.encode('utf-8'), salt.encode('ascii'), 100000) + pass_hash = binascii.hexlify(pass_hash).decode('ascii') + return pass_hash == stored_password diff --git a/flaschengeist/system/controller/accessTokenController.py b/flaschengeist/system/controller/accessTokenController.py index 3b0ed76..f0cf293 100644 --- a/flaschengeist/system/controller/accessTokenController.py +++ b/flaschengeist/system/controller/accessTokenController.py @@ -65,7 +65,7 @@ class AccessTokenController(metaclass=Singleton): return True return False - def create(self, user, user_agent=None): + def create(self, user, user_agent=None) -> AccessToken: """ Create an AccessToken Create an AccessToken for an User and add it to the tokenList. @@ -75,7 +75,7 @@ class AccessTokenController(metaclass=Singleton): user_agent: User agent to identify session Returns: - A created Token for User + AccessToken: A created Token for User """ logger.debug("create access token") token_str = secrets.token_hex(16) diff --git a/flaschengeist/system/controller/mainController/mainUserController.py b/flaschengeist/system/controller/mainController/mainUserController.py index 761c9ba..4927650 100644 --- a/flaschengeist/system/controller/mainController/mainUserController.py +++ b/flaschengeist/system/controller/mainController/mainUserController.py @@ -5,6 +5,7 @@ from flaschengeist.system.models.user import User from flaschengeist.system.database import db from flaschengeist import logger + class Base: def loginUser(self, username, password): logger.info("login user {{ {} }}".format(username)) diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/accessToken.py index 6003568..1383dd7 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -33,7 +33,7 @@ class AccessToken(db.Model): logger.debug("update timestamp from access token {{ {} }}".format(self)) self.timestamp = datetime.utcnow() - def toJSON(self): + def default(self): """ Create Dic to dump in JSON Returns: diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index 0dd0321..c636be0 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -61,7 +61,7 @@ class User(db.Model): if 'display_name' in data: self.display_name = data['display_name'] - def toJSON(self): + def default(self): return { # TODO: username should be UID? "username": self.uid, From ea107a28dd6a4ff61740140483315493f59fa81c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 3 Sep 2020 17:56:12 +0200 Subject: [PATCH 027/446] cleanup --- flaschengeist/__init__.py | 44 +++--- flaschengeist/flaschengeist.example.cfg | 8 +- flaschengeist/modules/__init__.py | 11 +- flaschengeist/modules/auth/__init__.py | 2 +- flaschengeist/modules/auth_ldap/__init__.py | 10 +- flaschengeist/modules/auth_plain/__init__.py | 30 ++-- flaschengeist/system/config.py | 135 ++---------------- .../mainController/mainUserController.py | 4 +- 8 files changed, 65 insertions(+), 179 deletions(-) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 0ea570e..56a8348 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -1,26 +1,25 @@ """ Server-package - Initialize app, cors, database and bcrypt (for passwordhashing) and added it to the application. - Initialize also a singelton for the AccesTokenControler and start the Thread. + Initialize app, CORS, database and add it to the application. + Initialize also a singleton for the AccessTokenController and start the Thread. """ -from pathlib import Path -_modpath = Path(__file__).parent - -from flask import Flask -from flask_cors import CORS -import pkg_resources, yaml - -from logging.config import dictConfig - -with (_modpath/'logging.yml').open(mode='rb') as file: - config = yaml.safe_load(file.read()) - dictConfig(config) - +import yaml import logging +import pkg_resources +from flask import Flask +from pathlib import Path +from flask_cors import CORS +from logging.config import dictConfig from werkzeug.local import LocalProxy + +_module_path = Path(__file__).parent logger = LocalProxy(lambda: logging.getLogger(__name__)) +with (_module_path / 'logging.yml').open(mode='rb') as file: + config = yaml.safe_load(file.read()) + logging.config.dictConfig(config) + def create_app(): app = Flask(__name__) @@ -33,14 +32,15 @@ def create_app(): db.init_app(app) for entry_point in pkg_resources.iter_entry_points('flaschengeist.auth'): - logger.debug('Found authentification plugin: %s', entry_point.name) + logger.debug('Found authentication plugin: %s', entry_point.name) if entry_point.name == config['FLASCHENGEIST']['AUTH']: app.config['FG_AUTH_BACKEND'] = entry_point.load()() - app.config['FG_AUTH_BACKEND'].configure(config[entry_point.name] if config.has_section(entry_point.name) else {}) - logger.info('Loaded authentification plugin > %s <', entry_point.name) + app.config['FG_AUTH_BACKEND'].configure( + config[entry_point.name] if config.has_section(entry_point.name) else {}) + logger.info('Loaded authentication plugin > %s <', entry_point.name) break if not app.config['FG_AUTH_BACKEND']: - logger.error('No authentification plugin configured or authentification plugin not found') + logger.error('No authentication plugin configured or authentication plugin not found') logger.info('Search for plugins') for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin'): @@ -50,9 +50,3 @@ def create_app(): app.register_blueprint(entry_point.load()()) return app -#app.register_blueprint(baruser) -#app.register_blueprint(finanzer) -#app.register_blueprint(user) -#app.register_blueprint(vorstand) -#app.register_blueprint(gastrouser) -#app.register_blueprint(registration) diff --git a/flaschengeist/flaschengeist.example.cfg b/flaschengeist/flaschengeist.example.cfg index 1631a1c..ce5bfde 100644 --- a/flaschengeist/flaschengeist.example.cfg +++ b/flaschengeist/flaschengeist.example.cfg @@ -1,8 +1,10 @@ [FLASCHENGEIST] -# Set lifetime of session (idle time until you get logged out) -AccessTokenLifetime = 1800 -# Select authentification provider +# Select authentication provider (builtin: auth_plain, auth_ldap) AUTH = auth_plain +# Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn +# PROXY = false +# Set root path, prefixes all routes +# ROOT = / [DATABASE] USER = diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index a985443..c7cd619 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -1,4 +1,4 @@ -class Auth(): +class Auth: def configure(self, config): pass @@ -7,14 +7,14 @@ class Auth(): user User class containing at least the uid pw given password - HAS TO BE IMPLEMENTED! + MUST BE IMPLEMENTED! should return False if not found or invalid credentials should return True if success """ - return False + raise NotImplementedError - def updateUser(self, user): + def update_user(self, user): """ user User class @@ -22,11 +22,10 @@ class Auth(): """ pass - def modifyUser(self, user): + def modify_user(self, user): """ user User class If backend is using (writeable) external data, then update the external database with the user provided. """ pass - diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 1426083..b9c5b34 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -45,7 +45,7 @@ def _login(): try: logger.debug("search {{ {} }} in database".format(username)) main_controller = mc.MainController() - user = main_controller.loginUser(username, password) + user = main_controller.login_user(username, password) logger.debug("user is {{ {} }}".format(user)) token = access_controller.create(user, user_agent=request.user_agent) logger.debug("access token is {{ {} }}".format(token)) diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index 1eb1530..d8b0e70 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -1,3 +1,5 @@ +from ldap3.core.exceptions import LDAPException + import flaschengeist.modules as modules from flaschengeist import logger from flask import current_app as app @@ -39,11 +41,11 @@ class AuthLDAP(modules.Auth): try: r = self.ldap.authenticate(user.uid, password, 'uid', self.dn) return r == True - except Exception as err: + except LDAPException as err: logger.warning("Exception while login into ldap", exc_info=True) return False - def modifyUser(self, user): + def modify_user(self, user): try: ldap_conn = self.ldap.bind(user.uid, password) if attributes: @@ -66,7 +68,7 @@ class AuthLDAP(modules.Auth): "username exists on ldap, rechange username on database", exc_info=True) db.changeUsername(user, user.uid) raise Exception(err) - except LDAPExcetpion as err: + except LDAPException as err: if 'username' in attributes: db.changeUsername(user, user.uid) raise Exception(err) @@ -105,7 +107,7 @@ class AuthLDAP(modules.Auth): debug.warning("exception in modify user data from ldap", exc_info=True) raise LDAPExcetpion("Something went wrong in LDAP: {}".format(err)) - def updateUser(self, user): + def update_user(self, user): self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(user.uid), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) r = self.ldap.connection.response[0]['attributes'] diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/modules/auth_plain/__init__.py index 2677d19..768b127 100644 --- a/flaschengeist/modules/auth_plain/__init__.py +++ b/flaschengeist/modules/auth_plain/__init__.py @@ -5,23 +5,25 @@ import os import flaschengeist.modules as modules +def _hash_password(password): + salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') + pass_hash = hashlib.pbkdf2_hmac('sha3-512', password.encode('utf-8'), salt, 100000) + pass_hash = binascii.hexlify(pass_hash) + return (salt + pass_hash).decode('ascii') + + +def _verify_password(stored_password, provided_password): + salt = stored_password[:64] + stored_password = stored_password[64:] + pass_hash = hashlib.pbkdf2_hmac('sha3-512', provided_password.encode('utf-8'), salt.encode('ascii'), 100000) + pass_hash = binascii.hexlify(pass_hash).decode('ascii') + return pass_hash == stored_password + + class AuthPlain(modules.Auth): def login(self, user, password): if not user: return False if 'password' in user.attributes: - return self._verify_password(user.attributes['password'].value, password) + return _verify_password(user.attributes['password'].value, password) return False - - def _hash_password(self, password): - salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') - pass_hash = hashlib.pbkdf2_hmac('sha3-512', password.encode('utf-8'), salt, 100000) - pass_hash = binascii.hexlify(pass_hash) - return (salt + pass_hash).decode('ascii') - - def _verify_password(self, stored_password, provided_password): - salt = stored_password[:64] - stored_password = stored_password[64:] - pass_hash = hashlib.pbkdf2_hmac('sha3-512', provided_password.encode('utf-8'), salt.encode('ascii'), 100000) - pass_hash = binascii.hexlify(pass_hash).decode('ascii') - return pass_hash == stored_password diff --git a/flaschengeist/system/config.py b/flaschengeist/system/config.py index a83d6c0..b9833ae 100644 --- a/flaschengeist/system/config.py +++ b/flaschengeist/system/config.py @@ -1,12 +1,10 @@ -import configparser import os +import configparser from pathlib import Path -from .. import _modpath, logger +from werkzeug.middleware.proxy_fix import ProxyFix +from .. import _module_path, logger default = { - 'FLASCHENGEIST': { - 'AccessTokenLifeTime': 1800 - }, 'MAIL': { 'CRYPT': 'SSL/STARTLS' } @@ -14,7 +12,7 @@ default = { config = configparser.ConfigParser() config.read_dict(default) -paths = [_modpath, Path.home()/".config"] +paths = [_module_path, Path.home()/".config"] if 'FLASCHENGEIST_CONF' in os.environ: paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) for loc in paths: @@ -23,7 +21,8 @@ for loc in paths: config.read_file(source) except IOError: pass -# Always enable this buildin plugins! + +# Always enable this builtin plugins! config.read_dict({ 'auth': { 'enabled': True @@ -43,120 +42,8 @@ def configure_app(app): database=config['DATABASE']['database'] ) app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -#class ConifgParser(): -# def __init__(self, file='config.yml'): -# self.file = file -# with open(file, 'r') as f: -# self.config = yaml.safe_load(f) -# -# if 'Database' not in self.config: -# self.__error__( -# 'Wrong Configuration for Database. You should configure databaseconfig with "URL", "user", "passwd", "database"') -# if 'URL' not in self.config['Database'] or 'user' not in self.config['Database'] or 'passwd' not in self.config['Database'] or 'database' not in self.config['Database']: -# self.__error__( -# 'Wrong Configuration for Database. You should configure databaseconfig with "URL", "user", "passwd", "database"') -# -# self.db = self.config['Database'] -# logger.debug("Set Databaseconfig: {}".format(self.db)) -# -# if 'LDAP' not in self.config: -# self.__error__( -# 'Wrong Configuration for LDAP. You should configure ldapconfig with "URL" and "BIND_DN"') -# if 'URL' not in self.config['LDAP'] or 'DN' not in self.config['LDAP']: -# self.__error__( -# 'Wrong Configuration for LDAP. You should configure ldapconfig with "URL" and "BIND_DN"') -# if 'PORT' not in self.config['LDAP']: -# logger.info( -# 'No Config for port in LDAP found. Set it to default: {}'.format(389)) -# self.config['LDAP']['PORT'] = 389 -# if 'ADMIN_DN' not in self.config['LDAP']: -# logger.info( -# 'No Config for ADMIN_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) -# ) -# self.config['LDAP']['ADMIN_DN'] = None -# if 'ADMIN_SECRET' not in self.config['LDAP']: -# logger.info( -# 'No Config for ADMIN_SECRET in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) -# ) -# self.config['LDAP']['ADMIN_SECRET'] = None -# if 'USER_DN' not in self.config['LDAP']: -# logger.info( -# 'No Config for USER_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) -# ) -# self.config['LDAP']['USER_DN'] = None -# if 'BIND_DN' not in self.config['LDAP']: -# logger.info( -# 'No Config for BIND_DN in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) -# ) -# self.config['LDAP']['BIND_DN'] = None -# if 'BIND_SECRET' not in self.config['LDAP']: -# logger.info( -# 'No Config for BIND_SECRET in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(None) -# ) -# self.config['LDAP']['BIND_SECRET'] = None -# if 'SSL' not in self.config['LDAP']: -# logger.info( -# 'No Config for SSL in LDAP found. Set it to default {}. (Maybe Password reset not working)'.format(False) -# ) -# self.config['LDAP']['SSL'] = False -# else: -# self.config['LDAP']['SSL'] = bool(self.config['LDAP']['SSL']) -# self.ldap = self.config['LDAP'] -# logger.debug("Set LDAPconfig: {}".format(self.ldap)) -# if 'AccessTokenLifeTime' in self.config: -# self.accessTokenLifeTime = int(self.config['AccessTokenLifeTime']) -# logger.info("Set AccessTokenLifeTime: {}".format( -# self.accessTokenLifeTime)) -# else: -# self.accessTokenLifeTime = default['AccessTokenLifeTime'] -# logger.info("No Config for AccessTokenLifetime found. Set it to default: {}".format( -# self.accessTokenLifeTime)) -# -# if 'Mail' not in self.config: -# self.config['Mail'] = default['Mail'] -# logger.info('No Conifg for Mail found. Set it to defaul: {}'.format( -# self.config['Mail'])) -# if 'URL' not in self.config['Mail']: -# self.config['Mail']['URL'] = default['Mail']['URL'] -# logger.info("No Config for URL in Mail found. Set it to default") -# if 'port' not in self.config['Mail']: -# self.config['Mail']['port'] = default['Mail']['port'] -# logger.info("No Config for port in Mail found. Set it to default") -# else: -# self.config['Mail']['port'] = int(self.config['Mail']['port']) -# logger.info("No Conifg for port in Mail found. Set it to default") -# if 'user' not in self.config['Mail']: -# self.config['Mail']['user'] = default['Mail']['user'] -# logger.info("No Config for user in Mail found. Set it to default") -# if 'passwd' not in self.config['Mail']: -# self.config['Mail']['passwd'] = default['Mail']['passwd'] -# logger.info("No Config for passwd in Mail found. Set it to default") -# if 'email' not in self.config['Mail']: -# self.config['Mail']['email'] = default['Mail']['email'] -# logger.info("No Config for email in Mail found. Set it to default") -# if 'crypt' not in self.config['Mail']: -# self.config['Mail']['crypt'] = default['Mail']['crypt'] -# logger.info("No Config for crypt in Mail found. Set it to default") -# self.mail = self.config['Mail'] -# logger.debug('Set Mailconfig: {}'.format(self.mail)) -# -# def getLDAP(self): -# return self.ldap -# -# def getDatabase(self): -# return self.db -# -# def getAccessToken(self): -# return self.accessTokenLifeTime -# -# def getMail(self): -# return self.mail -# -# def __error__(self, msg): -# logger.error(msg, exc_info=True) -# sys.exit(-1) -# -# -#if __name__ == '__main__': -# ConifgParser() + + if config.has_option("FLASCHENGEIST", "ROOT"): + app.config["APPLICATION_ROOT"] = config["FLASCHENGEIST"]["ROOT"] + if config.getboolean("FLASCHENGEIST", "PROXY", fallback=False): + app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) diff --git a/flaschengeist/system/controller/mainController/mainUserController.py b/flaschengeist/system/controller/mainController/mainUserController.py index 4927650..9ee4246 100644 --- a/flaschengeist/system/controller/mainController/mainUserController.py +++ b/flaschengeist/system/controller/mainController/mainUserController.py @@ -7,14 +7,14 @@ from flaschengeist import logger class Base: - def loginUser(self, username, password): + def login_user(self, username, password): logger.info("login user {{ {} }}".format(username)) user = User.query.filter_by(uid=username).first() if user is None: user = User(uid=username) if current_app.config['FG_AUTH_BACKEND'].login(user, password): db.session.add(user) - current_app.config['FG_AUTH_BACKEND'].updateUser(user) + current_app.config['FG_AUTH_BACKEND'].update_user(user) db.session.commit() return user raise PermissionDenied() From 55dc622e1106e2cb5f22a452323fc153eefe2e71 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 3 Sep 2020 17:56:42 +0200 Subject: [PATCH 028/446] Use bjoern for production. Add commandline arguments. --- run_flaschengeist | 18 +++++++++++++----- setup.py | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/run_flaschengeist b/run_flaschengeist index 41f9494..6a89775 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,10 +1,18 @@ #!/usr/bin/python3 from flaschengeist import create_app +import bjoern +import argparse -""" Main - - Start the backend -""" if __name__ == '__main__': - create_app().run(debug=True, host='0.0.0.0') + parser = argparse.ArgumentParser() + parser.add_argument("--host", help="set hostname to listen on", default="127.0.0.1") + parser.add_argument("--port", help="set port to listen on", type=int, default=5000) + parser.add_argument("--debug", help="run in debug mode", action="store_true") + args = parser.parse_args() + + app = create_app() + if args.debug: + app.run(args.host, args.port, debug=True) + else: + bjoern.run(app, args.host, args.port, reuse_port=True) diff --git a/setup.py b/setup.py index 4720a1c..64094f9 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( package_data={'': ['*.yml']}, scripts=['run_flaschengeist'], install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', 'sqlalchemy>=1.3', "flask_sqlalchemy", - "flask_cors", "werkzeug"], + "flask_cors", "werkzeug", "bjoern"], extras_require={ 'ldap': [ 'flask_ldapconn', From b6157f495396e0007dd4dd0143a769ae792339d2 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 3 Sep 2020 22:04:28 +0200 Subject: [PATCH 029/446] Added ErrorHandler for automatic Exception handling No need for try except for HTTP 500 or 403 error --- flaschengeist/__init__.py | 34 ++++++++++++++++++++++++++++- flaschengeist/system/decorator.py | 12 +++++----- flaschengeist/system/models/user.py | 8 +++---- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 56a8348..3b531c7 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -9,8 +9,12 @@ import logging import pkg_resources from flask import Flask from pathlib import Path + +from flask.json import JSONEncoder, jsonify from flask_cors import CORS from logging.config import dictConfig + +from werkzeug.exceptions import HTTPException from werkzeug.local import LocalProxy _module_path = Path(__file__).parent @@ -21,8 +25,27 @@ with (_module_path / 'logging.yml').open(mode='rb') as file: logging.config.dictConfig(config) +class CustomJSONEncoder(JSONEncoder): + def default(self, o): + # Check if custom model + try: + return o.serialize() + except AttributeError: + pass + + # Check if iterable + try: + iterable = iter(o) + except TypeError: + pass + else: + return list(iterable) + return JSONEncoder.default(self, o) + + def create_app(): app = Flask(__name__) + app.json_encoder = CustomJSONEncoder CORS(app) with app.app_context(): @@ -49,4 +72,13 @@ def create_app(): logger.info('Loaded plugin > %s <', entry_point.name) app.register_blueprint(entry_point.load()()) - return app + @app.errorhandler(Exception) + def handle_exception(e): + if isinstance(e, HTTPException): + logger.debug(e.description, exc_info=True) + return jsonify({"error": e.name}), e.code + + logger.error(str(e), exc_info=True) + return jsonify({"error": "Internal server error occurred"}), 500 + + return app \ No newline at end of file diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index 3942cd8..930cf17 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -1,5 +1,7 @@ from functools import wraps from flask import request, jsonify +from werkzeug.exceptions import Unauthorized + from flaschengeist import logger @@ -15,15 +17,13 @@ def login_required(**kwargs): def wrapper(*args, **kwargs): token = request.headers.get('Token') logger.debug("token is {{ {} }}".format(token)) - access_token = ac_controller.validate_token(token, roles) - logger.debug("accToken is {{ {} }}".format(access_token)) - kwargs['accToken'] = access_token + access_token = ac_controller.validate_token(token, request.user_agent, roles) if access_token: + kwargs['access_token'] = access_token logger.debug("token {{ {} }} is valid".format(token)) return func(*args, **kwargs) else: - logger.warning("token {{ {} }} is not valid".format(token)) - return jsonify({"error": "error", - "message": "permission denied"}), 401 + logger.info("token {{ {} }} is not valid".format(token)) + raise Unauthorized return wrapper return real_decorator diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index c636be0..e028781 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -61,7 +61,7 @@ class User(db.Model): if 'display_name' in data: self.display_name = data['display_name'] - def default(self): + def serialize(self): return { # TODO: username should be UID? "username": self.uid, @@ -93,10 +93,8 @@ class Group(db.Model): name = db.Column(db.String(30)) permissions = db.relationship("Permission", secondary=group_permission_association_table) - def toJSON(self): - return { - 'name': self.name - } + def serialize(self): + return self.name class Permission(db.Model): From 7fbff30214bfc886b91975f2024c27902b3debda Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 3 Sep 2020 22:29:14 +0200 Subject: [PATCH 030/446] [API BREAK] Changed authentication routes Authentication is now on /auth/... and using REST pathes and methods. AccessToken are now having a expires field instead of timestamp, more usefull for automatic removal of expired ones. --- flaschengeist/modules/auth/__init__.py | 169 ++++++++---------- .../controller/accessTokenController.py | 116 ++++++------ flaschengeist/system/exceptions/__init__.py | 5 +- flaschengeist/system/models/accessToken.py | 46 ++--- 4 files changed, 156 insertions(+), 180 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index b9c5b34..1152ffc 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -4,17 +4,15 @@ # authentication, login, logout, etc # ############################################# -from flask import Blueprint, current_app, request, jsonify +from flask import Blueprint, request, jsonify +from werkzeug.exceptions import Forbidden, BadRequest from werkzeug.local import LocalProxy +from flaschengeist import logger from flaschengeist.system.decorator import login_required -from flaschengeist.system.exceptions import PermissionDenied from flaschengeist.system.controller import mainController as mc import flaschengeist.system.controller.accessTokenController as ac -from flaschengeist.system.models.accessToken import AccessToken - -logger = LocalProxy(lambda: current_app.logger) access_controller = LocalProxy(lambda: ac.AccessTokenController()) auth_bp = Blueprint('auth', __name__) @@ -23,13 +21,19 @@ auth_bp = Blueprint('auth', __name__) def register(): return auth_bp -############################################ -# Routes # -############################################ +################################################# +# Routes # +# # +# /auth POST: login (new token) # +# GET: get all tokens for user # +# /auth/ GET: get lifetime of token # +# PUT: set new lifetime # +# DELETE: logout / delete token # +################################################# -@auth_bp.route("/login", methods=['POST']) -def _login(): +@auth_bp.route("/auth", methods=['POST']) +def _create_token(): """ Login User Login in User and create an AccessToken for the User. @@ -38,95 +42,70 @@ def _login(): """ logger.debug("Start log in.") data = request.get_json() - logger.info(request) username = data['username'] password = data['password'] - logger.debug("username is {{ {} }}".format(username)) - try: - logger.debug("search {{ {} }} in database".format(username)) - main_controller = mc.MainController() - user = main_controller.login_user(username, password) - logger.debug("user is {{ {} }}".format(user)) - token = access_controller.create(user, user_agent=request.user_agent) - logger.debug("access token is {{ {} }}".format(token)) - logger.debug("validate access token") - dic = user.default() - dic["accessToken"] = token.token - logger.info("User {{ {} }} success login.".format(username)) - logger.debug("return login {{ {} }}".format(dic)) - return jsonify(dic) - except PermissionDenied as err: - logger.debug("permission denied exception in login", exc_info=True) - return jsonify({"error": str(err)}), 401 - except Exception as err: - logger.error("exception in login.", exc_info=True) - return jsonify({"error": "permission denied"}), 401 + + logger.debug("search user {{ {} }} in database".format(username)) + main_controller = mc.MainController() + user = main_controller.login_user(username, password) + logger.debug("user is {{ {} }}".format(user)) + token = access_controller.create(user, user_agent=request.user_agent) + logger.debug("access token is {{ {} }}".format(token)) + dic = user.serialize() + dic["access_token"] = token.token + logger.info("User {{ {} }} success login.".format(username)) + + # Lets cleanup the DB + access_controller.clear_expired() + return jsonify(dic) -@auth_bp.route("/logout", methods=['GET']) +@auth_bp.route("/auth", methods=['GET']) @login_required() -def _logout(**kwargs): +def _get_tokens(access_token, **kwargs): + tokens = access_controller.get_users_tokens(access_token.user) + return jsonify(tokens) + + +@auth_bp.route("/auth/", methods=['DELETE']) +@login_required() +def _delete_token(token, access_token, **kwargs): + logger.debug("Try to delete access token {{ {} }}".format(token)) + token = access_controller.get_token(token, access_token.user) + if not token: + logger.debug("Token not found in database!") + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + access_controller.delete_token(token) + access_controller.clear_expired() + return jsonify({"ok": "ok"}) + + +@auth_bp.route("/auth/", methods=['GET']) +@login_required() +def _get_token(token, access_token, **kwargs): + logger.debug("get token {{ {} }}".format(token)) + token = access_controller.get_token(token, access_token.user) + if not token: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + return jsonify(token) + + +@auth_bp.route("/auth/", methods=['PUT']) +@login_required() +def _set_lifetime(token, access_token, **kwargs): + token = access_controller.get_token(token, access_token.user) + if not token: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden try: - logger.debug("logout user") - token = kwargs['accToken'] - logger.debug("access token is {{ {} }}".format(token)) - logger.debug("delete access token") - access_controller.deleteAccessToken(token) - access_controller.clearExpired() - logger.info("return ok logout user") + lifetime = request.get_json()['value'] + logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) + access_controller.set_lifetime(token, lifetime) return jsonify({"ok": "ok"}) - except Exception as err: - logger.warning("exception in logout user.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@auth_bp.route("/user/getAccessTokens", methods=['GET', 'POST']) -# @auth_bp.route("/accessTokens", methods=['GET', 'POST']) -@login_required() -def _getAccessTokens(**kwargs): - try: - if request.method == 'POST': - data = request.get_json() - token = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) - access_controller.delete_token(token) - tokens = access_controller.getAccessTokensFromUser(kwargs['accToken'].user) - r = [t.toJSON() for t in tokens] - logger.debug("return {{ {} }}".format(r)) - return jsonify(r) - except Exception as err: - logger.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@auth_bp.route("/getLifetime", methods=['GET']) -@login_required() -def _getLifeTime(**kwargs): - try: - logger.debug("get lifetime of access token") - token = kwargs['accToken'] - logger.debug("accessToken is {{ {} }}".format(token)) - return jsonify({"value": token.lifetime}) - except Exception as err: - logger.warning("exception in get lifetime of access token.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@auth_bp.route("/setLifetime", methods=['POST']) -@login_required() -def _saveLifeTime(**kwargs): - try: - token = kwargs['accToken'] - logger.debug("save lifetime for access token {{ {} }}".format(token)) - data = request.get_json() - lifetime = data['value'] - logger.debug("lifetime is {{ {} }}".format(lifetime)) - logger.info("set lifetime {{ {} }} to access token {{ {} }}".format( - lifetime, token)) - token.lifetime = lifetime - logger.info("update access token timestamp") - token = access_controller.update(token) - return jsonify({"value": token.lifetime}) - except Exception as err: - logger.warning( - "exception in save lifetime for access token.", exc_info=True) - return jsonify({"error": str(err)}), 500 + except (KeyError, TypeError): + raise BadRequest diff --git a/flaschengeist/system/controller/accessTokenController.py b/flaschengeist/system/controller/accessTokenController.py index f0cf293..e3d375e 100644 --- a/flaschengeist/system/controller/accessTokenController.py +++ b/flaschengeist/system/controller/accessTokenController.py @@ -1,12 +1,10 @@ +import secrets from ..models.accessToken import AccessToken from flaschengeist.system.database import db - +from flaschengeist import logger +from werkzeug.exceptions import Forbidden from datetime import datetime, timedelta -import secrets from . import Singleton -import logging - -logger = logging.getLogger("flaschenpost") class AccessTokenController(metaclass=Singleton): @@ -17,99 +15,111 @@ class AccessTokenController(metaclass=Singleton): Attributes: lifetime: Variable for the Lifetime of one AccessToken in seconds. """ - instance = None - tokenList = None def __init__(self, lifetime=1800): - """ Initialize AccessTokenController - - Initialize Thread and set tokenList empty. - """ - logger.debug("init access token controller") self.lifetime = lifetime - def validate_token(self, token, roles): + def validate_token(self, token, user_agent, roles): """ Verify access token - Verify an AccessToken and Group so if the User has permission or not. + Verify an AccessToken and Roles so if the User has permission or not. Retrieves the access token if valid else retrieves False Args: token: Token to verify. + user_agent: User agent of browser to check roles: Roles needed to access restricted routes Returns: An the AccessToken for this given Token or False. """ logger.debug("check token {{ {} }} is valid".format(token)) - for access_token in AccessToken.query.filter_by(token=token): - time_end = access_token.timestamp + timedelta(seconds=access_token.lifetime) - now = datetime.utcnow() - logger.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, time_end)) - if now <= time_end: - logger.debug("check if token {{ {} }} is same as {{ {} }}".format(token, access_token)) - if not roles or (roles and self.userHasRole(access_token.user, roles)): - access_token.updateTimestamp() + access_token = AccessToken.query.filter_by(token=token).one_or_none() + if access_token: + logger.debug("token found, check if expired or invalid user agent differs") + if access_token.expires >= datetime.utcnow() and ( + access_token.browser == user_agent.browser and + access_token.platform == user_agent.platform): + if not roles or (roles and self.user_has_role(access_token.user, roles)): + access_token.refresh() db.session.commit() return access_token else: - logger.debug("access token is {{ {} }} out of date".format(access_token)) - db.session.delete(access_token) - db.session.commit() - logger.debug("no valid access token with token: {{ {} }} and group: {{ {} }}".format(token, roles)) - return False - - def userHasRole(self, user, roles): - for group in user.groups: - for role in group.roles: - if role.name in roles: - return True + logger.debug("access token is out of date or invalid client used") + self.delete_token(access_token) + logger.debug("no valid access token with token: {{ {} }} and roles: {{ {} }}".format(token, roles)) return False def create(self, user, user_agent=None) -> AccessToken: """ Create an AccessToken - Create an AccessToken for an User and add it to the tokenList. + Args: + user: For which User is to create an AccessToken + user_agent: User agent to identify session - Args: - user: For which User is to create an AccessToken - user_agent: User agent to identify session - - Returns: + Returns: AccessToken: A created Token for User """ logger.debug("create access token") token_str = secrets.token_hex(16) token = AccessToken(token=token_str, user=user, lifetime=self.lifetime, browser=user_agent.browser, platform=user_agent.platform) + token.refresh() db.session.add(token) db.session.commit() logger.debug("access token is {{ {} }}".format(token)) return token - def getAccessTokensFromUser(self, user): + def get_token(self, token, owner=None): + """Retrieves AccessToken from token string + + Args: + token (str): Token string + owner (User, optional): User owning the token + + Raises: + Forbidden: Raised if owner is set but does not match + Returns: + AccessToken: Token object identified by given token string + """ + access_token = AccessToken.query.filter(AccessToken.token == token).one_or_none() + if access_token and (owner and owner != access_token.user): + raise Forbidden + return access_token + + def get_users_tokens(self, user): return AccessToken.query.filter(AccessToken.user == user) @staticmethod - def delete_token(token): - if token is isinstance(token, AccessToken): - db.session.delete(token) - else: - AccessToken.query.filter_by(token=token).delete() + def delete_token(token: AccessToken): + """Deletes given AccessToken + + Args: + token (AccessToken): Token to delete + """ + db.session.delete(token) db.session.commit() @staticmethod def update_token(token): - token.update_timestamp() + token.refresh() db.session.commit() + def set_lifetime(self, token, lifetime): + token.lifetime = lifetime + self.update_token(token) + def clear_expired(self): + """Remove expired tokens from database""" logger.debug("Clear expired AccessToken") - might_expired = datetime.utcnow() - timedelta(seconds=self.lifetime) - tokens = AccessToken.query.filter(AccessToken.timestamp < might_expired) - logger.debug(tokens) - for token in tokens: - if token.timestamp < datetime.utcnow() - timedelta(seconds=token.lifetime): - logger.debug("Delete token %s", token.token) - db.session.delete(token) + deleted = AccessToken.query.filter(AccessToken.expires < datetime.utcnow()).delete() + logger.debug("{} tokens have been removed".format(deleted)) db.session.commit() + + # TODO: is this needed? + def user_has_role(self, user, roles): + for group in user.groups: + for role in group.roles: + if role.name in roles: + return True + return False diff --git a/flaschengeist/system/exceptions/__init__.py b/flaschengeist/system/exceptions/__init__.py index 4d18a6b..0972d22 100644 --- a/flaschengeist/system/exceptions/__init__.py +++ b/flaschengeist/system/exceptions/__init__.py @@ -1,5 +1,8 @@ class PermissionDenied(Exception): - pass + def __init__(self, message=None): + if not message: + message = "PermissionDenied" + super().__init__(message) class UsernameExistDB(Exception): diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/accessToken.py index 1383dd7..af80281 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -1,69 +1,53 @@ -from datetime import datetime +from datetime import datetime, timedelta from ..database import db -from flask import current_app -from werkzeug.local import LocalProxy from secrets import compare_digest - -logger = LocalProxy(lambda: current_app.logger) +from flaschengeist import logger class AccessToken(db.Model): """ Model for an AccessToken - Attributes: - timestamp: Is a Datetime from current Time. + Args: + expires: Is a Datetime from current Time. user: Is an User. token: String to verify access later. """ __tablename__ = 'session' id = db.Column(db.Integer, primary_key=True) - timestamp = db.Column(db.DateTime, default=datetime.utcnow) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user = db.relationship("User", back_populates="sessions") - token = db.Column(db.String(30)) + + expires = db.Column(db.DateTime) + token = db.Column(db.String(30), unique=True) lifetime = db.Column(db.Integer) browser = db.Column(db.String(30)) platform = db.Column(db.String(30)) - def update_timestamp(self): + def refresh(self): """ Update the Timestamp Update the Timestamp to the current Time. """ logger.debug("update timestamp from access token {{ {} }}".format(self)) - self.timestamp = datetime.utcnow() + self.expires = datetime.utcnow() + timedelta(seconds=self.lifetime) - def default(self): + def serialize(self): """ Create Dic to dump in JSON Returns: A Dic with static Attributes. """ - dic = { - "id": self.id, - "timestamp": {'year': self.timestamp.year, - 'month': self.timestamp.month, - 'day': self.timestamp.day, - 'hour': self.timestamp.hour, - 'minute': self.timestamp.minute, - 'second': self.timestamp.second - }, + return { + "token": self.token, + "expires": self.expires, "lifetime": self.lifetime, "browser": self.browser, "platform": self.platform } - return dic def __eq__(self, token): return compare_digest(self.token, token) - def __sub__(self, other): - return other - self.timestamp - def __str__(self): - return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format( - self.user, self.token, self.timestamp, self.lifetime) - - def __repr__(self): - return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format( - self.user, self.token, self.timestamp, self.lifetime) + return "AccessToken(user={}, token={}, expires={}, lifetime={})".format( + self.user, self.token, self.expires, self.lifetime) From 365677697d577565c622400ee2c9f3f70fc3230c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 4 Sep 2020 00:55:23 +0200 Subject: [PATCH 031/446] Some more cleanup, added modify_user to LDAP --- flaschengeist/__init__.py | 4 +- flaschengeist/flaschengeist.example.cfg | 2 +- flaschengeist/modules/__init__.py | 35 ++-- flaschengeist/modules/auth/__init__.py | 12 +- flaschengeist/modules/auth_ldap/__init__.py | 182 ++++-------------- flaschengeist/modules/auth_plain/__init__.py | 10 +- .../controller/mainController/__init__.py | 6 +- .../mainController/mainUserController.py | 175 ----------------- .../system/controller/userController.py | 40 ++++ 9 files changed, 115 insertions(+), 351 deletions(-) delete mode 100644 flaschengeist/system/controller/mainController/mainUserController.py create mode 100644 flaschengeist/system/controller/userController.py diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 3b531c7..7e5dd23 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -69,7 +69,7 @@ def create_app(): for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin'): logger.debug("Found plugin: %s", entry_point.name) if config.get(entry_point.name, 'enabled', fallback=False): - logger.info('Loaded plugin > %s <', entry_point.name) + logger.info("Loaded plugin >{}<".format(entry_point.name)) app.register_blueprint(entry_point.load()()) @app.errorhandler(Exception) @@ -81,4 +81,4 @@ def create_app(): logger.error(str(e), exc_info=True) return jsonify({"error": "Internal server error occurred"}), 500 - return app \ No newline at end of file + return app diff --git a/flaschengeist/flaschengeist.example.cfg b/flaschengeist/flaschengeist.example.cfg index ce5bfde..1312331 100644 --- a/flaschengeist/flaschengeist.example.cfg +++ b/flaschengeist/flaschengeist.example.cfg @@ -9,7 +9,7 @@ AUTH = auth_plain [DATABASE] USER = HOST = -PASSWD = +PASSWORD = DATABASE = # [LDAP] diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index c7cd619..0cdd4bb 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -3,29 +3,34 @@ class Auth: pass def login(self, user, pw): - """ - user User class containing at least the uid - pw given password + """ Login routine, MUST BE IMPLEMENTED! - MUST BE IMPLEMENTED! - - should return False if not found or invalid credentials - should return True if success + Args: + user: User class containing at least the uid + pw: given password + Returns: + Must return False if not found or invalid credentials, True if success """ raise NotImplementedError def update_user(self, user): - """ - user User class + """If backend is using external data, then update this user instance with external data - If backend is using external data, then update this user instance with external data + Args: + user: User object """ pass - def modify_user(self, user): - """ - user User class + def modify_user(self, user, password, new_password=None): + """If backend is using (writeable) external data, then update the external database with the user provided. - If backend is using (writeable) external data, then update the external database with the user provided. + Args: + user: User object + password: Password (some backends need the current password for changes) + new_password: If set a password change is requested + Raises: + NotImplemented: If backend does not support this feature (or no password change) + BadRequest: Logic error, e.g. password is wrong. + Error: Other errors if backend went mad (are not handled and will result in a 500 error) """ - pass + raise NotImplemented diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 1152ffc..db1ae9a 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -5,15 +5,14 @@ ############################################# from flask import Blueprint, request, jsonify -from werkzeug.exceptions import Forbidden, BadRequest +from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from werkzeug.local import LocalProxy from flaschengeist import logger from flaschengeist.system.decorator import login_required -from flaschengeist.system.controller import mainController as mc -import flaschengeist.system.controller.accessTokenController as ac +from flaschengeist.system.controller import accessTokenController, userController -access_controller = LocalProxy(lambda: ac.AccessTokenController()) +access_controller = LocalProxy(lambda: accessTokenController.AccessTokenController()) auth_bp = Blueprint('auth', __name__) @@ -46,8 +45,9 @@ def _create_token(): password = data['password'] logger.debug("search user {{ {} }} in database".format(username)) - main_controller = mc.MainController() - user = main_controller.login_user(username, password) + user = userController.login_user(username, password) + if not user: + raise Unauthorized logger.debug("user is {{ {} }}".format(user)) token = access_controller.create(user, user_agent=request.user_agent) logger.debug("access token is {{ {} }}".format(token)) diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index d8b0e70..cc3aa4a 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -1,18 +1,23 @@ -from ldap3.core.exceptions import LDAPException +from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError +from ldap3.utils.hashed import hashed +from werkzeug.exceptions import BadRequest import flaschengeist.modules as modules -from flaschengeist import logger from flask import current_app as app from flask_ldapconn import LDAPConn -from ldap3 import SUBTREE +from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_SHA512 import ssl +from flaschengeist.system.models.user import User + class AuthLDAP(modules.Auth): _default = { 'PORT': '389', 'USE_SSL': 'False' } + ldap = None + dn = None def configure(self, config): for name in self._default: @@ -31,81 +36,13 @@ class AuthLDAP(modules.Auth): ) if 'SECRET' in config: app.config['LDAP_SECRET'] = config['SECRET'], - self.ldap = LDAPConn(app) self.dn = config['BASEDN'] def login(self, user, password): if not user: return False - try: - r = self.ldap.authenticate(user.uid, password, 'uid', self.dn) - return r == True - except LDAPException as err: - logger.warning("Exception while login into ldap", exc_info=True) - return False - - def modify_user(self, user): - try: - ldap_conn = self.ldap.bind(user.uid, password) - if attributes: - if 'username' in attributes: - debug.debug("change username, so change first in database") - db.changeUsername(user, attributes['username']) - ldap.modifyUser(user, ldap_conn, attributes) - if 'username' in attributes: - retVal = self.getUser(attributes['username']) - debug.debug("user is {{ {} }}".format(retVal)) - return retVal - else: - retVal = self.getUser(user.uid) - debug.debug("user is {{ {} }}".format(retVal)) - return retVal - return self.getUser(user.uid) - - except UsernameExistLDAP as err: - debug.debug( - "username exists on ldap, rechange username on database", exc_info=True) - db.changeUsername(user, user.uid) - raise Exception(err) - except LDAPException as err: - if 'username' in attributes: - db.changeUsername(user, user.uid) - raise Exception(err) - except LDAPPasswordIsMandatoryError as err: - raise Exception('Password wurde nicht gesetzt!!') - except LDAPBindError as err: - raise Exception('Password ist falsch') - except Exception as err: - raise Exception(err) - debug.info( - "modify ldap data from user {{ {} }} with attributes (can't show because here can be a password)".format( - user)) - try: - if 'username' in attributes: - debug.debug("change username") - conn.search('ou=user,{}'.format(self.dn), '(uid={})'.format(attributes['username'])) - if conn.entries: - debug.warning("username already exists", exc_info=True) - raise UsernameExistLDAP("Username already exists in LDAP") - # create modifyer - mody = {} - if 'username' in attributes: - mody['uid'] = [(MODIFY_REPLACE, [attributes['username']])] - if 'firstname' in attributes: - mody['givenName'] = [(MODIFY_REPLACE, [attributes['firstname']])] - if 'lastname' in attributes: - mody['sn'] = [(MODIFY_REPLACE, [attributes['lastname']])] - if 'mail' in attributes: - mody['mail'] = [(MODIFY_REPLACE, [attributes['mail']])] - if 'password' in attributes: - salted_password = hashed(HASHED_SALTED_MD5, attributes['password']) - mody['userPassword'] = [(MODIFY_REPLACE, [salted_password])] - debug.debug("modyfier are (can't show because here can be a password)") - conn.modify(user.dn, mody) - except Exception as err: - debug.warning("exception in modify user data from ldap", exc_info=True) - raise LDAPExcetpion("Something went wrong in LDAP: {}".format(err)) + return self.ldap.authenticate(user.uid, password, 'uid', self.dn) def update_user(self, user): self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(user.uid), SUBTREE, @@ -123,80 +60,37 @@ class AuthLDAP(modules.Auth): user.add_group(group) def _get_groups(self, uid): - try: - groups = [] + groups = [] - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(uid), SUBTREE, - attributes=['gidNumber']) - main_group_number = self.ldap.connection.response[0]['attributes']['gidNumber'] - if main_group_number: - if type(main_group_number) is list: - main_group_number = main_group_number[0] - self.ldap.connection.search('ou=group,{}'.format(self.dn), '(gidNumber={})'.format(main_group_number), - attributes=['cn']) - groups.append(self.ldap.connection.response[0]['attributes']['cn'][0]) - - self.ldap.connection.search('ou=group,{}'.format(self.dn), '(memberUID={})'.format(uid), SUBTREE, + self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(uid), SUBTREE, + attributes=['gidNumber']) + main_group_number = self.ldap.connection.response[0]['attributes']['gidNumber'] + if main_group_number: + if type(main_group_number) is list: + main_group_number = main_group_number[0] + self.ldap.connection.search('ou=group,{}'.format(self.dn), '(gidNumber={})'.format(main_group_number), attributes=['cn']) - groups_data = self.ldap.connection.response - for data in groups_data: - groups.append(data['attributes']['cn'][0]) - return groups - except Exception as err: - logger.warning("exception in get groups from ldap", exc_info=True) - return [] + groups.append(self.ldap.connection.response[0]['attributes']['cn'][0]) -# def getAllUser(self): -# debug.info("get all users from ldap") -# retVal = [] -# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid=*)', SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) -# data = self.ldap.connection.response -# debug.debug("data is {{ {} }}".format(data)) -# for user in data: -# if 'uid' in user['attributes']: -# username = user['attributes']['uid'][0] -# firstname = user['attributes']['givenName'][0] -# lastname = user['attributes']['sn'][0] -# retVal.append({'username': username, 'firstname': firstname, 'lastname': lastname}) -# debug.debug("users are {{ {} }}".format(retVal)) -# return retVal + self.ldap.connection.search('ou=group,{}'.format(self.dn), '(memberUID={})'.format(uid), SUBTREE, + attributes=['cn']) + groups_data = self.ldap.connection.response + for data in groups_data: + groups.append(data['attributes']['cn'][0]) + return groups -# def searchUser(self, searchString): -# -# name = searchString.split(" ") -# -# for i in range(len(name)): -# name[i] = "*"+name[i]+"*" -# -# -# print(name) -# -# name_result = [] -# -# if len(name) == 1: -# if name[0] == "**": -# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid=*)', SUBTREE, -# attributes=['uid', 'givenName', 'sn']) -# name_result.append(self.ldap.connection.response) -# else: -# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(givenName={})'.format(name[0]), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) -# name_result.append(self.ldap.connection.response) -# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(sn={})'.format(name[0]), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) -# name_result.append(self.ldap.connection.response) -# else: -# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(givenName={})'.format(name[1]), SUBTREE, attributes=['uid', 'givenName', 'sn']) -# name_result.append(self.ldap.connection.response) -# self.ldap.connection.search('ou=user,{}'.format(self.dn), '(sn={})'.format(name[1]), SUBTREE, attributes=['uid', 'givenName', 'sn', 'mail']) -# name_result.append(self.ldap.connection.response) -# retVal = [] + def modify_user(self, user: User, password, new_password=None): + try: + ldap_conn = self.ldap.connect(user.uid, password) + modifier = {'givenName': [(MODIFY_REPLACE, [user.firstname])], + 'sn': [(MODIFY_REPLACE, [user.lastname])], + 'mail': [(MODIFY_REPLACE, [user.mail])], + 'displayName': [(MODIFY_REPLACE, [user.display_name])], + } + if new_password: + salted_password = hashed(HASHED_SALTED_SHA512, new_password) + modifier['userPassword'] = [(MODIFY_REPLACE, [salted_password])] + ldap_conn.modify(user.dn, modifier) -# for names in name_result: -# for user in names: -# if 'uid' in user['attributes']: -# username = user['attributes']['uid'][0] -# if not self.__isUserInList(retVal, username): -# firstname = user['attributes']['givenName'][0] -# lastname = user['attributes']['sn'][0] -# retVal.append({'username': username, 'firstname': firstname, 'lastname': lastname}) -# -# return retVal + except (LDAPPasswordIsMandatoryError, LDAPBindError): + raise BadRequest diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/modules/auth_plain/__init__.py index 768b127..12ee1da 100644 --- a/flaschengeist/modules/auth_plain/__init__.py +++ b/flaschengeist/modules/auth_plain/__init__.py @@ -3,6 +3,7 @@ import hashlib import os import flaschengeist.modules as modules +from flaschengeist.system.models.user import User def _hash_password(password): @@ -21,9 +22,10 @@ def _verify_password(stored_password, provided_password): class AuthPlain(modules.Auth): - def login(self, user, password): - if not user: - return False - if 'password' in user.attributes: + def login(self, user: User, password: str): + if user and 'password' in user.attributes: return _verify_password(user.attributes['password'].value, password) return False + + def modify_user(self, user, password, new_password=None): + pass diff --git a/flaschengeist/system/controller/mainController/__init__.py b/flaschengeist/system/controller/mainController/__init__.py index 8c098e9..72c0076 100644 --- a/flaschengeist/system/controller/mainController/__init__.py +++ b/flaschengeist/system/controller/mainController/__init__.py @@ -1,7 +1,6 @@ -from .. import Singleton +from .. import Singleton, userController from ...models.user import User from datetime import datetime, timedelta -from . import mainUserController from ...database import db from flask import current_app from werkzeug.local import LocalProxy @@ -10,7 +9,6 @@ logger = LocalProxy(lambda: current_app.logger) class MainController(#mainJobKindController.Base, #mainCreditListController.Base, #mainPricelistController.Base, - mainUserController.Base, #mainWorkerController.Base, #mainWorkgroupController.Base, #mainJobInviteController.Base, @@ -134,7 +132,7 @@ class MainController(#mainJobKindController.Base, return retVal def sendAllMail(self): - debug.info("send mail to all user") + debug.info("send mail to all users") retVal = [] users = db.getAllUser() debug.debug("users are {{ {} }}".format(users)) diff --git a/flaschengeist/system/controller/mainController/mainUserController.py b/flaschengeist/system/controller/mainController/mainUserController.py deleted file mode 100644 index 9ee4246..0000000 --- a/flaschengeist/system/controller/mainController/mainUserController.py +++ /dev/null @@ -1,175 +0,0 @@ -from flask import current_app - -from flaschengeist.system.exceptions import PermissionDenied -from flaschengeist.system.models.user import User -from flaschengeist.system.database import db -from flaschengeist import logger - - -class Base: - def login_user(self, username, password): - logger.info("login user {{ {} }}".format(username)) - user = User.query.filter_by(uid=username).first() - if user is None: - user = User(uid=username) - if current_app.config['FG_AUTH_BACKEND'].login(user, password): - db.session.add(user) - current_app.config['FG_AUTH_BACKEND'].update_user(user) - db.session.commit() - return user - raise PermissionDenied() - - #def getAllStatus(self): - #debug.info("get all status for user") - #retVal = db.getAllStatus() - #debug.debug("status are {{ {} }}".format(retVal)) - #return retVal - - #def getStatus(self, name): - #debug.info("get status of user {{ {} }}".format(name)) - #retVal = db.getStatus(name) - #debug.debug("status of user {{ {} }} is {{ {} }}".format(name, retVal)) - #return retVal - - #def setStatus(self, name): - #debug.info("set status of user {{ {} }}".format(name)) - #retVal = db.setStatus(name) - #debug.debug( - #"settet status of user {{ {} }} is {{ {} }}".format(name, retVal)) - #return retVal - - #def deleteStatus(self, status): - #debug.info("delete status {{ {} }}".format(status)) - #db.deleteStatus(status) - - #def updateStatus(self, status): - #debug.info("update status {{ {} }}".format(status)) - #retVal = db.updateStatus(status) - #debug.debug("updated status is {{ {} }}".format(retVal)) - #return retVal - - #def updateStatusOfUser(self, username, status): - #debug.info("update status {{ {} }} of user {{ {} }}".format( - #status, username)) - #retVal = db.updateStatusOfUser(username, status) - #debug.debug( - #"updatet status of user {{ {} }} is {{ {} }}".format(username, retVal)) - #return retVal - - #def updateVotingOfUser(self, username, voting): - #debug.info("update voting {{ {} }} of user {{ {} }}".format( - #voting, username)) - #retVal = db.updateVotingOfUser(username, voting) - #debug.debug( - #"updatet voting of user {{ {} }} is {{ {} }}".format(username, retVal)) - #return retVal - - #def lockUser(self, username, locked): - #debug.info("lock user {{ {} }} for credit with status {{ {} }}".format( - #username, locked)) - #user = self.getUser(username) - #debug.debug("user is {{ {} }}".format(user)) - #user.updateData({'locked': locked}) - #db.updateUser(user) - #retVal = self.getUser(username) - #debug.debug("locked user is {{ {} }}".format(retVal)) - #return retVal - - #def updateConfig(self, username, data): - #debug.info( - #"update config of user {{ {} }} with config {{ {} }}".format(username, data)) - #user = self.getUser(username) - #debug.debug("user is {{ {} }}".format(user)) - #user.updateData(data) - #db.updateUser(user) - #retVal = self.getUser(username) - #debug.debug("updated config of user is {{ {} }}".format(retVal)) - #return retVal - - #def syncLdap(self): - #debug.info('sync Users from Ldap') - #ldap_users = ldap.getAllUser() - #for user in ldap_users: - #self.getUser(user['username']) - - #def getAllUsersfromDB(self, extern=True): - #debug.info("get all users from database") - #if (len(ldap.getAllUser()) != len(db.getAllUser())): - #self.syncLdap() - #users = db.getAllUser() - #debug.debug("users are {{ {} }}".format(users)) - #for user in users: - #try: - #debug.debug("update data from ldap") - #self.__updateDataFromLDAP(user) - #except: - #pass - #debug.debug("update creditlists") - #self.__updateGeruechte(user) - #retVal = db.getAllUser(extern=extern) - #debug.debug("all users are {{ {} }}".format(retVal)) - #return retVal - - #def getUser(self, username): - #debug.info("get user {{ {} }}".format(username)) - #user = db.getUser(username) - #debug.debug("user is {{ {} }}".format(user)) - #groups = ldap.getGroup(username) - #debug.debug("groups are {{ {} }}".format(groups)) - #user_data = ldap.getUserData(username) - #debug.debug("user data from ldap is {{ {} }}".format(user_data)) - #user_data['gruppe'] = groups - #user_data['group'] = groups - #if user is None: - #debug.debug("user not exists in database -> insert into database") - #user = User(user_data) - #db.insertUser(user) - #else: - #debug.debug("update database with user") - #user.updateData(user_data) - #db.updateUser(user) - #user = db.getUser(username) - #self.__updateGeruechte(user) - #debug.debug("user is {{ {} }}".format(user)) - #return user - - #def modifyUser(self, user, attributes, password): - #debug.info("modify user {{ {} }} with attributes (can't show because here can be a password)".format( - #user)) - - #try: - #ldap_conn = ldap.bind(user, password) - #if attributes: - #if 'username' in attributes: - #debug.debug("change username, so change first in database") - #db.changeUsername(user, attributes['username']) - #ldap.modifyUser(user, ldap_conn, attributes) - #if 'username' in attributes: - #retVal = self.getUser(attributes['username']) - #debug.debug("user is {{ {} }}".format(retVal)) - #return retVal - #else: - #retVal = self.getUser(user.uid) - #debug.debug("user is {{ {} }}".format(retVal)) - #return retVal - #return self.getUser(user.uid) - - #except UsernameExistLDAP as err: - #debug.debug( - #"username exists on ldap, rechange username on database", exc_info=True) - #db.changeUsername(user, user.uid) - #raise Exception(err) - #except LDAPExcetpion as err: - #if 'username' in attributes: - #db.changeUsername(user, user.uid) - #raise Exception(err) - #except LDAPPasswordIsMandatoryError as err: - #raise Exception('Password wurde nicht gesetzt!!') - #except LDAPBindError as err: - #raise Exception('Password ist falsch') - #except Exception as err: - #raise Exception(err) - - #def validateUser(self, username, password): - #debug.info("validate user {{ {} }}".format(username)) - #ldap.login(username, password) diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py new file mode 100644 index 0000000..79a2e47 --- /dev/null +++ b/flaschengeist/system/controller/userController.py @@ -0,0 +1,40 @@ +from flask import current_app + +from flaschengeist.system.models.user import User +from flaschengeist.system.database import db +from flaschengeist import logger + + +def login_user(username, password): + logger.info("login user {{ {} }}".format(username)) + user = User.query.filter_by(uid=username).one_or_none() + if user is None: + user = User(uid=username) + db.session.add(user) + if current_app.config['FG_AUTH_BACKEND'].login(user, password): + current_app.config['FG_AUTH_BACKEND'].update_user(user) + db.session.commit() + return user + + +def modify_user(user, password, new_password=None): + """Modify given user on the backend + + Args: + user: User object to sync with backend + password: Current password (most backends are needing this) + new_password (optional): New password, if password should be changed + + Raises: + NotImplemented: If backend is not capable of this operation + BadRequest: Password is wrong or other logic issues + """ + current_app.config['FG_AUTH_BACKEND'].modify_user(user, password, new_password) + + +def get_users(): + return User.query.all() + + +def get_user(uid): + return User.query.filter(User.uid == uid).one_or_none() From 7f6ff3f001451f71c95bb85e15743144b236156c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 4 Sep 2020 01:01:00 +0200 Subject: [PATCH 032/446] Added first version of 'users' module, fixed LDAP --- flaschengeist/modules/auth_ldap/__init__.py | 19 +++--- flaschengeist/modules/registration_route.py | 15 ----- flaschengeist/modules/users/__init__.py | 66 +++++++++++++++++++ flaschengeist/system/config.py | 11 ++-- .../system/controller/userController.py | 9 ++- setup.py | 1 + 6 files changed, 92 insertions(+), 29 deletions(-) delete mode 100644 flaschengeist/modules/registration_route.py create mode 100644 flaschengeist/modules/users/__init__.py diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index cc3aa4a..8668975 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -9,6 +9,7 @@ from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_SHA512 import ssl from flaschengeist.system.models.user import User +from flaschengeist import logger class AuthLDAP(modules.Auth): @@ -81,16 +82,18 @@ class AuthLDAP(modules.Auth): def modify_user(self, user: User, password, new_password=None): try: - ldap_conn = self.ldap.connect(user.uid, password) - modifier = {'givenName': [(MODIFY_REPLACE, [user.firstname])], - 'sn': [(MODIFY_REPLACE, [user.lastname])], - 'mail': [(MODIFY_REPLACE, [user.mail])], - 'displayName': [(MODIFY_REPLACE, [user.display_name])], - } + dn = user.attributes['DN'].value + ldap_conn = self.ldap.connect(dn, password) + modifier = {} + for name, ldap_name in [("firstname", "givenName"), + ("lastname", "sn"), + ("mail", "mail"), + ("display_name", "displayName")]: + if getattr(user, name): + modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] if new_password: salted_password = hashed(HASHED_SALTED_SHA512, new_password) modifier['userPassword'] = [(MODIFY_REPLACE, [salted_password])] - ldap_conn.modify(user.dn, modifier) - + ldap_conn.modify(dn, modifier) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest diff --git a/flaschengeist/modules/registration_route.py b/flaschengeist/modules/registration_route.py deleted file mode 100644 index 8a4bed1..0000000 --- a/flaschengeist/modules/registration_route.py +++ /dev/null @@ -1,15 +0,0 @@ -from flask import Blueprint, request, jsonify -import geruecht.controller.mainController as mc -from geruecht.logger import getDebugLogger - -registration = Blueprint("registration", __name__) - -mainController = mc.MainController() - -debug = getDebugLogger() - -@registration.route("/registration", methods=['PUT']) -def __registration(): - data = request.get_json() - mainController.setNewRegistration(data) - return jsonify({"ok":"ok"}) \ No newline at end of file diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py new file mode 100644 index 0000000..9cf2863 --- /dev/null +++ b/flaschengeist/modules/users/__init__.py @@ -0,0 +1,66 @@ +from flask import Blueprint, request, jsonify +from werkzeug.exceptions import NotFound, BadRequest + +from flaschengeist import logger +from flaschengeist.system.decorator import login_required +from flaschengeist.system.controller import userController + +users_bp = Blueprint("users", __name__) + + +def register(): + return users_bp + +################################################# +# Routes # +# # +# /users POST: register new # +# GET: get all users # +# /users/ GET: get user with uid # +# PUT: modify user # +# DELETE: remove user # +################################################# + + +@users_bp.route("/users", methods=['POST']) +def __registration(): + logger.debug("Register new User...") + return jsonify({"ok": "ok... well not implemented"}) + + +@users_bp.route("/users", methods=['GET']) +@login_required() +def __list_users(**kwargs): + logger.debug("Retrieve list of all users") + users = userController.get_users() + return jsonify(users) + + +@users_bp.route("/users/", methods=['GET']) +@login_required() +def __get_user(uid, **kwargs): + logger.debug("Get information of user {{ {} }}".format(uid)) + user = userController.get_user(uid) + if user: + return jsonify(user) + raise NotFound + + +@users_bp.route("/users/", methods=['PUT']) +@login_required()#roles=['edit_users']) +def __edit_user(uid, **kwargs): + logger.debug("Modify information of user {{ {} }}".format(uid)) + user = userController.get_user(uid) + if not user: + raise NotFound + + data = request.get_json() + if 'password' not in data: + raise BadRequest("Password is missing") + for key in ["firstname", "lastname", "display_name", "mail"]: + if key in data: + setattr(user, key, data[key]) + new_password = data['new_password'] if 'new_password' in data else None + userController.modify_user(user, data['password'], new_password) + userController.update_user(user) + return jsonify({"ok": "ok"}) diff --git a/flaschengeist/system/config.py b/flaschengeist/system/config.py index b9833ae..658e051 100644 --- a/flaschengeist/system/config.py +++ b/flaschengeist/system/config.py @@ -26,6 +26,9 @@ for loc in paths: config.read_dict({ 'auth': { 'enabled': True + }, + 'users': { + 'enabled': True } }) @@ -36,10 +39,10 @@ def configure_app(app): app.config['SECRET_KEY'] = config.get('FLASCHENGEIST', 'SECRET_KEY', fallback='0a657b97ef546da90b2db91862ad4e29') app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://{user}:{passwd}@{host}/{database}'.format( - user=config['DATABASE']['user'], - passwd=config['DATABASE']['passwd'], - host=config['DATABASE']['host'], - database=config['DATABASE']['database'] + user=config['DATABASE']['USER'], + passwd=config['DATABASE']['PASSWORD'], + host=config['DATABASE']['HOST'], + database=config['DATABASE']['DATABASE'] ) app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index 79a2e47..d0433bd 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -12,9 +12,14 @@ def login_user(username, password): user = User(uid=username) db.session.add(user) if current_app.config['FG_AUTH_BACKEND'].login(user, password): - current_app.config['FG_AUTH_BACKEND'].update_user(user) - db.session.commit() + update_user(user) return user + return None + + +def update_user(user): + current_app.config['FG_AUTH_BACKEND'].update_user(user) + db.session.commit() def modify_user(user, password, new_password=None): diff --git a/setup.py b/setup.py index 64094f9..7f2e559 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ setup( entry_points={ 'flaschengeist.plugin': [ 'auth = flaschengeist.modules.auth:register', + 'users = flaschengeist.modules.users:register', 'schedule = flaschengeist.modules.schedule:register' ], 'flaschengeist.auth': [ From bd657d11b60307fc4e594da61696b0a2e21a27e2 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 5 Sep 2020 22:26:00 +0200 Subject: [PATCH 033/446] First version of schedule plugin --- .../{ => geruecht}/baruser/__init__.py | 0 .../modules/{ => geruecht}/baruser/routes.py | 0 flaschengeist/modules/schedule/__init__.py | 629 ++++++++++-------- .../system/controller/eventController.py | 32 + flaschengeist/system/models/event.py | 82 +++ 5 files changed, 466 insertions(+), 277 deletions(-) rename flaschengeist/modules/{ => geruecht}/baruser/__init__.py (100%) rename flaschengeist/modules/{ => geruecht}/baruser/routes.py (100%) create mode 100644 flaschengeist/system/controller/eventController.py create mode 100644 flaschengeist/system/models/event.py diff --git a/flaschengeist/modules/baruser/__init__.py b/flaschengeist/modules/geruecht/baruser/__init__.py similarity index 100% rename from flaschengeist/modules/baruser/__init__.py rename to flaschengeist/modules/geruecht/baruser/__init__.py diff --git a/flaschengeist/modules/baruser/routes.py b/flaschengeist/modules/geruecht/baruser/routes.py similarity index 100% rename from flaschengeist/modules/baruser/routes.py rename to flaschengeist/modules/geruecht/baruser/routes.py diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index 41b70ee..ec02035 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -1,289 +1,364 @@ +from dateutil import parser from flask import Blueprint, request, jsonify +from werkzeug.exceptions import BadRequest, NotFound +from datetime import datetime, timedelta + +from flaschengeist.system.controller import eventController +from flaschengeist.system.database import db from flaschengeist.system.decorator import login_required -from flaschengeist import logger -from datetime import datetime +from flaschengeist.system.models.event import Event, EventKind -import .roles +schedule_bp = Blueprint("schedule", __name__) -schedule = Blueprint("schedule", __name__) -@schedule.route("/schedule/jobs", methods=['POST']) -@login_required(roles=['schedule_read']) -def _getUsers(**kwrags): - logger.debug("/schedule/jobs") +def register(): + return schedule_bp + + +@schedule_bp.route("/schedule/events", methods=['GET']) +@schedule_bp.route("/schedule/events/", methods=['GET']) +@schedule_bp.route("/schedule/events//", methods=['GET']) +@schedule_bp.route("/schedule/events///", methods=['GET']) +@login_required() # roles=['schedule_read']) +def _get_events(year=datetime.now().year, month=None, day=None, **kwrags): + """Get Event objects for specified date (or month or year), + if nothing set then events for current year are returned + + Args: + year (int, optional): year to query, defaults to current year + month (int, optional): month to query (if set) + day (int, optional): day to query events for (if set) + **kwrags: contains at least access_token (see flaschengeist.decorator) + Returns: + JSON list containing events found + Raises: + BadRequest: If date is invalid + """ try: - data = request.get_json() - from_date = data['from_date'] - to_date = data['to_date'] - from_date = datetime(from_date['year'], from_date['month'], from_date['day']) - to_date = datetime(to_date['year'], to_date['month'], to_date['day']) - lockedDays = mainController.getLockedDays(from_date, to_date) - retVal = [] - for lockedDay in lockedDays: - day = datetime.combine(lockedDay['daydate'], time(12)) - retDay = { - "worker": mainController.getWorker(day), - "day": { - "date": { - "year": day.year, - "month": day.month, - "day": day.day - }, - "locked": lockedDay['locked'] - }, - "jobkinddate": mainController.getJobKindDates(day.date()) - } - retVal.append(retDay) - - logger.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - logger.warn("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@schedule.route("/user/jobsOnDates", methods=['POST']) -@login_required(groups=[USER]) -def _getJobsOnDates(**kwargs): - debug.info("/user/jobsOnDates") - try: - data = request.get_json() - lockedDays = mainController.getLockedDaysFromList(data) - retVal = [] - for lockedDay in lockedDays: - day = datetime.combine(lockedDay['daydate'], time(12)) - retDay = { - "worker": mainController.getWorker(day), - "day": { - "date": { - "year": day.year, - "month": day.month, - "day": day.day - }, - "locked": lockedDay['locked'] - }, - "jobkinddate": mainController.getJobKindDates(day.date()) - } - retVal.append(retDay) - - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@schedule.route("/user/job", methods=['POST']) -@login_required(groups=[USER]) -def _getUser(**kwargs): - debug.info("/user/job") - try: - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - lockedDay = mainController.getLockedDay(date) - if not lockedDay: - lockedDay = { - 'date': { - 'year': year, - 'month': month, - 'day': day - }, - 'locked': False - } + begin = datetime(year=year, month=1, day=1) + end = None + if month: + begin = datetime(year=year, month=month, day=1) + if day: + begin += timedelta(days=day - 1) + end = begin + timedelta(days=1) + else: + end = datetime(year=year, month=month + 1, day=1) else: - lockedDay = { - 'date': { - 'year': year, - 'month': month, - 'day': day - }, - 'locked': lockedDay['locked'] - } - retVal = { - 'worker': mainController.getWorker(date), - 'day': lockedDay - } - debug.debug("retrun {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 + end = datetime(year=year + 1, month=1, day=1) + + events = Event.query.filter((begin <= Event.begin), (Event.begin < end)) + return jsonify(events) + except ValueError: + raise BadRequest("Invalid date given") -@schedule.route("/user/addJob", methods=['POST']) -@login_required(groups=[USER]) -def _addUser(**kwargs): - debug.info("/user/addJob") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - user = accToken.user - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - job_kind = None - if 'job_kind' in data: - job_kind = data['job_kind'] - mainController.addWorker(user.uid, date, job_kind=job_kind, userExc=True) - retVal = mainController.getWorker(date) - debug.debug("return {{ {} }}".format(retVal)) - jobL.info("Mitglied {} {} schreib sich am {} zum Dienst ein.".format( - user.firstname, user.lastname, date.date())) - return jsonify(retVal) - except DayLocked as err: - debug.debug("exception", exc_info=True) - return jsonify({'error': str(err)}), 403 - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({'error': str(err)}), 409 +@schedule_bp.route("/schedule/eventKinds", methods=['POST']) +@login_required() +def __new_event_kind(**kwargs): + data = request.get_json() + if "name" not in data: + raise BadRequest + event = eventController.create_event_kind(data["name"]) + return jsonify({"ok": "ok", "id": event.id}) -@schedule.route("/user/deleteJob", methods=['POST']) -@login_required(groups=[USER]) -def _deletJob(**kwargs): - debug.info("/user/deleteJob") - try: - if 'accToken' in kwargs: - accToken = kwargs['accToken'] - user = accToken.user - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - mainController.deleteWorker(user.uid, date, True) - retVal = mainController.getWorker(date) - debug.debug("return ok") - jobL.info("Mitglied {} {} entfernt sich am {} aus dem Dienst".format( - user.firstname, user.lastname, date.date())) - return jsonify(retVal) - except DayLocked as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 403 - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 409 - -@schedule.route("/user/getJobInvites", methods=['POST']) -@login_required(groups=[USER]) -def _getJobInvites(**kwargs): - try: - debug.info("/user/getJobInvites") - from_user = None - to_user = None - on_date = None - - data = request.get_json() - - if 'from_user' in data: - from_user = data['from_user'] - if 'to_user' in data: - to_user = data['to_user'] - on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) - retVal = mainController.getJobInvites(from_user, to_user, on_date) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@schedule.route("/user/JobInvites", methods=['PUT', 'POST']) -@login_required(groups=[USER]) -def _JobInvites(**kwargs): - try: - debug.info("/user/JobInvites") - data = request.get_json() - if request.method == 'PUT': - mainController.setJobInvites(data) - retVal = mainController.getJobInvites(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - if request.method == 'POST': - retVal = mainController.updateJobInvites(data) - - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@schedule.route("/user/deleteJobInvite", methods=['POST']) -@login_required(groups=[USER]) -def _deleteJobInvite(**kwargs): - try: - debug.info("/user/deleteJobInvite") - data = request.get_json() - mainController.deleteJobInvite(data) - retVal = mainController.getJobInvites(data['from_user'], None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 +@schedule_bp.route("/schedule/events", methods=['POST']) +@login_required() +def __new_event(**kwargs): + data = request.get_json() + event = eventController.create_event(begin=parser.isoparse(data["begin"]), + end=parser.isoparse(data["end"]), + description=data["description"], + kind=EventKind.query.get(data["kind"])) + return jsonify({"ok": "ok", "id": event.id}) -@schedule.route("/user/getJobRequests", methods=['POST']) -@login_required(groups=[USER]) -def _getJobRequests(**kwargs): - try: - debug.info("/user/getJobRequests") - from_user = None - to_user = None - on_date = None +@schedule_bp.route("/schedule/events/", methods=["DELETE"]) +@login_required() +def __delete_event(id, **kwargs): + if Event.query.filter(Event.id == id).delete() != 1: + raise NotFound + db.session.commit() + return jsonify({'ok': 'ok'}) - data = request.get_json() - if 'from_user' in data: - from_user = data['from_user'] - if 'to_user' in data: - to_user = data['to_user'] - on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) - retVal = mainController.getJobRequests(from_user, to_user, on_date) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 +def __edit_event(): + ... -@schedule.route("/user/JobRequests", methods=['PUT', 'POST']) -@login_required(groups=[USER]) -def _JobRequests(**kwargs): - try: - debug.info("/user/JobRequests") - data = request.get_json() - if request.method == 'PUT': - mainController.setJobRequests(data) - retVal = mainController.getJobRequests(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - if request.method == 'POST': - data['on_date'] = date(data['on_date']['year'], data['on_date']['month'], data['on_date']['day']) - retVal = mainController.updateJobRequests(data) - - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@schedule.route("/user/deleteJobRequest", methods=['POST']) -@login_required(groups=[USER]) -def _deleteJobRequest(**kwargs): - try: - debug.info("/user/deleteJobRequest") - data = request.get_json() - mainController.deleteJobRequest(data) - retVal = mainController.getJobRequests(data['from_user'], None, datetime.now().date()) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 +# try: +# data = request.get_json() +# from_date = data['from_date'] +# to_date = data['to_date'] +# from_date = datetime(from_date['year'], from_date['month'], from_date['day']) +# to_date = datetime(to_date['year'], to_date['month'], to_date['day']) +# lockedDays = mainController.getLockedDays(from_date, to_date) +# retVal = [] +# for lockedDay in lockedDays: +# day = datetime.combine(lockedDay['daydate'], time(12)) +# retDay = { +# "worker": mainController.getWorker(day), +# "day": { +# "date": { +# "year": day.year, +# "month": day.month, +# "day": day.day +# }, +# "locked": lockedDay['locked'] +# }, +# "jobkinddate": mainController.getJobKindDates(day.date()) +# } +# retVal.append(retDay) +# +# logger.debug("return {{ {} }}".format(retVal)) +# return jsonify(retVal) +# except Exception as err: +# logger.warn("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 +# +# @schedule.route("/user/jobsOnDates", methods=['POST']) +# @login_required(groups=[USER]) +# def _getJobsOnDates(**kwargs): +# debug.info("/user/jobsOnDates") +# try: +# data = request.get_json() +# lockedDays = mainController.getLockedDaysFromList(data) +# retVal = [] +# for lockedDay in lockedDays: +# day = datetime.combine(lockedDay['daydate'], time(12)) +# retDay = { +# "worker": mainController.getWorker(day), +# "day": { +# "date": { +# "year": day.year, +# "month": day.month, +# "day": day.day +# }, +# "locked": lockedDay['locked'] +# }, +# "jobkinddate": mainController.getJobKindDates(day.date()) +# } +# retVal.append(retDay) +# +# debug.debug("return {{ {} }}".format(retVal)) +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 +# +# @schedule.route("/user/job", methods=['POST']) +# @login_required(groups=[USER]) +# def _getUser(**kwargs): +# debug.info("/user/job") +# try: +# data = request.get_json() +# day = data['day'] +# month = data['month'] +# year = data['year'] +# date = datetime(year, month, day, 12) +# lockedDay = mainController.getLockedDay(date) +# if not lockedDay: +# lockedDay = { +# 'date': { +# 'year': year, +# 'month': month, +# 'day': day +# }, +# 'locked': False +# } +# else: +# lockedDay = { +# 'date': { +# 'year': year, +# 'month': month, +# 'day': day +# }, +# 'locked': lockedDay['locked'] +# } +# retVal = { +# 'worker': mainController.getWorker(date), +# 'day': lockedDay +# } +# debug.debug("retrun {{ {} }}".format(retVal)) +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 +# +# +# @schedule.route("/user/addJob", methods=['POST']) +# @login_required(groups=[USER]) +# def _addUser(**kwargs): +# debug.info("/user/addJob") +# try: +# if 'accToken' in kwargs: +# accToken = kwargs['accToken'] +# user = accToken.user +# data = request.get_json() +# day = data['day'] +# month = data['month'] +# year = data['year'] +# date = datetime(year, month, day, 12) +# job_kind = None +# if 'job_kind' in data: +# job_kind = data['job_kind'] +# mainController.addWorker(user.uid, date, job_kind=job_kind, userExc=True) +# retVal = mainController.getWorker(date) +# debug.debug("return {{ {} }}".format(retVal)) +# jobL.info("Mitglied {} {} schreib sich am {} zum Dienst ein.".format( +# user.firstname, user.lastname, date.date())) +# return jsonify(retVal) +# except DayLocked as err: +# debug.debug("exception", exc_info=True) +# return jsonify({'error': str(err)}), 403 +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({'error': str(err)}), 409 +# +# +# @schedule.route("/user/deleteJob", methods=['POST']) +# @login_required(groups=[USER]) +# def _deletJob(**kwargs): +# debug.info("/user/deleteJob") +# try: +# if 'accToken' in kwargs: +# accToken = kwargs['accToken'] +# user = accToken.user +# data = request.get_json() +# day = data['day'] +# month = data['month'] +# year = data['year'] +# date = datetime(year, month, day, 12) +# mainController.deleteWorker(user.uid, date, True) +# retVal = mainController.getWorker(date) +# debug.debug("return ok") +# jobL.info("Mitglied {} {} entfernt sich am {} aus dem Dienst".format( +# user.firstname, user.lastname, date.date())) +# return jsonify(retVal) +# except DayLocked as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 403 +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 409 +# +# @schedule.route("/user/getJobInvites", methods=['POST']) +# @login_required(groups=[USER]) +# def _getJobInvites(**kwargs): +# try: +# debug.info("/user/getJobInvites") +# from_user = None +# to_user = None +# on_date = None +# +# data = request.get_json() +# +# if 'from_user' in data: +# from_user = data['from_user'] +# if 'to_user' in data: +# to_user = data['to_user'] +# on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) +# retVal = mainController.getJobInvites(from_user, to_user, on_date) +# debug.debug("return {{ {} }}".format(retVal)) +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 +# +# @schedule.route("/user/JobInvites", methods=['PUT', 'POST']) +# @login_required(groups=[USER]) +# def _JobInvites(**kwargs): +# try: +# debug.info("/user/JobInvites") +# data = request.get_json() +# if request.method == 'PUT': +# mainController.setJobInvites(data) +# retVal = mainController.getJobInvites(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) +# debug.debug("return {{ {} }}".format(retVal)) +# if request.method == 'POST': +# retVal = mainController.updateJobInvites(data) +# +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 +# +# @schedule.route("/user/deleteJobInvite", methods=['POST']) +# @login_required(groups=[USER]) +# def _deleteJobInvite(**kwargs): +# try: +# debug.info("/user/deleteJobInvite") +# data = request.get_json() +# mainController.deleteJobInvite(data) +# retVal = mainController.getJobInvites(data['from_user'], None, datetime.now().date()) +# debug.debug("return {{ {} }}".format(retVal)) +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 +# +# +# @schedule.route("/user/getJobRequests", methods=['POST']) +# @login_required(groups=[USER]) +# def _getJobRequests(**kwargs): +# try: +# debug.info("/user/getJobRequests") +# from_user = None +# to_user = None +# on_date = None +# +# data = request.get_json() +# +# if 'from_user' in data: +# from_user = data['from_user'] +# if 'to_user' in data: +# to_user = data['to_user'] +# on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) +# retVal = mainController.getJobRequests(from_user, to_user, on_date) +# debug.debug("return {{ {} }}".format(retVal)) +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 +# +# @schedule.route("/user/JobRequests", methods=['PUT', 'POST']) +# @login_required(groups=[USER]) +# def _JobRequests(**kwargs): +# try: +# debug.info("/user/JobRequests") +# data = request.get_json() +# if request.method == 'PUT': +# mainController.setJobRequests(data) +# retVal = mainController.getJobRequests(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) +# debug.debug("return {{ {} }}".format(retVal)) +# if request.method == 'POST': +# data['on_date'] = date(data['on_date']['year'], data['on_date']['month'], data['on_date']['day']) +# retVal = mainController.updateJobRequests(data) +# +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 +# +# @schedule.route("/user/deleteJobRequest", methods=['POST']) +# @login_required(groups=[USER]) +# def _deleteJobRequest(**kwargs): +# try: +# debug.info("/user/deleteJobRequest") +# data = request.get_json() +# mainController.deleteJobRequest(data) +# retVal = mainController.getJobRequests(data['from_user'], None, datetime.now().date()) +# debug.debug("return {{ {} }}".format(retVal)) +# return jsonify(retVal) +# except Exception as err: +# debug.debug("exception", exc_info=True) +# return jsonify({"error": str(err)}), 500 # CREDIT == Gerücht? CreditList? -#creditL = getCreditLogger() -#@schedule.route("/user/main") -#@login_required(groups=[USER]) -#def _main(**kwargs): +# creditL = getCreditLogger() +# @schedule.route("/user/main") +# @login_required(groups=[USER]) +# def _main(**kwargs): # debug.info("/user/main") # try: # if 'accToken' in kwargs: @@ -299,9 +374,9 @@ def _deleteJobRequest(**kwargs): # return jsonify("error", "something went wrong"), 500 -#@schedule.route("/user/addAmount", methods=['POST']) -#@login_required(groups=[USER]) -#def _addAmount(**kwargs): +# @schedule.route("/user/addAmount", methods=['POST']) +# @login_required(groups=[USER]) +# def _addAmount(**kwargs): # debug.info("/user/addAmount") # try: # if 'accToken' in kwargs: @@ -324,9 +399,9 @@ def _deleteJobRequest(**kwargs): # return jsonify({"error": "something went wrong"}), 500 -#@schedule.route("/user/saveConfig", methods=['POST']) -#@login_required(groups=[USER]) -#def _saveConfig(**kwargs): +# @schedule.route("/user/saveConfig", methods=['POST']) +# @login_required(groups=[USER]) +# def _saveConfig(**kwargs): # debug.info("/user/saveConfig") # try: # if 'accToken' in kwargs: @@ -345,9 +420,9 @@ def _deleteJobRequest(**kwargs): # debug.debug("exception", exc_info=True) # return jsonify({"error": str(err)}), 409 # -#@schedule.route("/user/storno", methods=['POST']) -#@login_required(groups=[USER]) -#def _storno(**kwargs): +# @schedule.route("/user/storno", methods=['POST']) +# @login_required(groups=[USER]) +# def _storno(**kwargs): # """ Function for Baruser to storno amount # # This function added to the user with the posted userID the posted amount. diff --git a/flaschengeist/system/controller/eventController.py b/flaschengeist/system/controller/eventController.py new file mode 100644 index 0000000..135164c --- /dev/null +++ b/flaschengeist/system/controller/eventController.py @@ -0,0 +1,32 @@ +from werkzeug.exceptions import BadRequest + +from flaschengeist.system.models.event import EventKind, Event +from sqlalchemy.exc import IntegrityError + +from flaschengeist import logger +from flaschengeist.system.database import db + + +def create_event(begin, kind, end=None, description=None): + try: + event = Event(begin=begin, + end=end, + description=description, + kind=kind) + db.session.add(event) + db.session.commit() + return event + except IntegrityError: + logger.debug("Database error when creating new event", exc_info=True) + raise BadRequest + + +def create_event_kind(name): + try: + event = EventKind(name=name) + db.session.add(event) + db.session.commit() + return event + except IntegrityError: + logger.debug("IntegrityError: Looks like there is a name collision", exc_info=True) + raise BadRequest("Name already exists") diff --git a/flaschengeist/system/models/event.py b/flaschengeist/system/models/event.py new file mode 100644 index 0000000..a81f38f --- /dev/null +++ b/flaschengeist/system/models/event.py @@ -0,0 +1,82 @@ +from ..database import db + + +class Event(db.Model): + """Model for an Event""" + __tablename__ = 'event' + id = db.Column(db.Integer, primary_key=True) + begin = db.Column(db.DateTime, nullable=False) + end = db.Column(db.DateTime) + description = db.Column(db.String(240)) + kind_id = db.Column(db.Integer, db.ForeignKey('event_kind.id', ondelete="CASCADE"), nullable=False) + kind = db.relationship("EventKind") + slots = db.relationship("EventSlot", back_populates="event", cascade="all, delete") + #notices = db.relationship("EventNotice", back_populates="event") + + def serialize(self): + return { + "id": self.id, + "begin": self.begin, + "end": self.end, + "description": self.description, + "kind": self.kind + } + + +class EventKind(db.Model): + """Model for an EventKind""" + __tablename__ = "event_kind" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(30), nullable=False, unique=True) + + def serialize(self): + return { + "id": self.id, + "name": self.name + } + + +class EventSlot(db.Model): + """Model for an EventSlot""" + __tablename__ = "event_slot" + id = db.Column(db.Integer, primary_key=True) + start = db.Column(db.DateTime) + end = db.Column(db.DateTime) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + event = db.relationship("Event", back_populates="slots") + slots = db.relationship("JobSlot", back_populates="event_slot") + + def serialize(self): + return { + "id": self.id, + "start": self.start, + "end": self.end, + "event": self.event_id, + } + + +class JobSlot(db.Model): + __tablename__ = "job_slot" + id = db.Column(db.Integer, primary_key=True) + needed_persons = db.Column(db.Numeric(precision=4, scale=2)) + event_slot_id = db.Column(db.Integer, db.ForeignKey('event_slot.id')) + event_slot = db.relationship("EventSlot", back_populates="slots") + kind_id = db.Column(db.Integer, db.ForeignKey('job_kind.id')) + kind = db.relationship("JobKind") + jobs = db.relationship("Job", back_populates="slot") + + +class Job(db.Model): + __tablename__ = "job" + id = db.Column(db.Integer, primary_key=True) + value = db.Column(db.Numeric(precision=3, scale=2)) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + user = db.relationship("User") + slot_id = db.Column(db.Integer, db.ForeignKey('job_slot.id')) + slot = db.relationship("JobSlot") + + +class JobKind(db.Model): + __tablename__ = "job_kind" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(30)) From 0edd55b64e5c13dff67ae72ad3b9e424b6362e02 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 5 Sep 2020 22:26:36 +0200 Subject: [PATCH 034/446] Encode datetime in JSON as ISO string --- flaschengeist/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 7e5dd23..b18a446 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -4,6 +4,8 @@ Initialize also a singleton for the AccessTokenController and start the Thread. """ +from datetime import datetime + import yaml import logging import pkg_resources @@ -33,6 +35,8 @@ class CustomJSONEncoder(JSONEncoder): except AttributeError: pass + if isinstance(o, datetime): + return o.isoformat() # Check if iterable try: iterable = iter(o) @@ -76,7 +80,7 @@ def create_app(): def handle_exception(e): if isinstance(e, HTTPException): logger.debug(e.description, exc_info=True) - return jsonify({"error": e.name}), e.code + return jsonify({"error": e.description}), e.code logger.error(str(e), exc_info=True) return jsonify({"error": "Internal server error occurred"}), 500 From 4a92b057e8a43a571fcfa1b4b75c0ec9b546e393 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 6 Sep 2020 22:33:27 +0200 Subject: [PATCH 035/446] More meaningful authentication JSON --- flaschengeist/modules/auth/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index db1ae9a..0ec7358 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -51,13 +51,11 @@ def _create_token(): logger.debug("user is {{ {} }}".format(user)) token = access_controller.create(user, user_agent=request.user_agent) logger.debug("access token is {{ {} }}".format(token)) - dic = user.serialize() - dic["access_token"] = token.token logger.info("User {{ {} }} success login.".format(username)) # Lets cleanup the DB access_controller.clear_expired() - return jsonify(dic) + return jsonify({"user": user, "token": token.token}) @auth_bp.route("/auth", methods=['GET']) From ad3e2a34b82e6d6d4c8ac4964a50166d8f565b64 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 7 Sep 2020 16:07:35 +0200 Subject: [PATCH 036/446] Added route for getting API version and installed plugins + some cleanup --- flaschengeist/__init__.py | 67 +------------------------------ flaschengeist/app.py | 84 +++++++++++++++++++++++++++++++++++++++ run_flaschengeist | 2 +- setup.py | 52 ++++++++++++------------ 4 files changed, 114 insertions(+), 91 deletions(-) create mode 100644 flaschengeist/app.py diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 7e5dd23..7882802 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -7,78 +7,15 @@ import yaml import logging import pkg_resources -from flask import Flask from pathlib import Path - -from flask.json import JSONEncoder, jsonify -from flask_cors import CORS from logging.config import dictConfig - -from werkzeug.exceptions import HTTPException from werkzeug.local import LocalProxy _module_path = Path(__file__).parent logger = LocalProxy(lambda: logging.getLogger(__name__)) +__version__ = pkg_resources.get_distribution('flaschengeist').version + with (_module_path / 'logging.yml').open(mode='rb') as file: config = yaml.safe_load(file.read()) logging.config.dictConfig(config) - - -class CustomJSONEncoder(JSONEncoder): - def default(self, o): - # Check if custom model - try: - return o.serialize() - except AttributeError: - pass - - # Check if iterable - try: - iterable = iter(o) - except TypeError: - pass - else: - return list(iterable) - return JSONEncoder.default(self, o) - - -def create_app(): - app = Flask(__name__) - app.json_encoder = CustomJSONEncoder - CORS(app) - - with app.app_context(): - from .system.database import db - from .system.config import configure_app, config - configure_app(app) - db.init_app(app) - - for entry_point in pkg_resources.iter_entry_points('flaschengeist.auth'): - logger.debug('Found authentication plugin: %s', entry_point.name) - if entry_point.name == config['FLASCHENGEIST']['AUTH']: - app.config['FG_AUTH_BACKEND'] = entry_point.load()() - app.config['FG_AUTH_BACKEND'].configure( - config[entry_point.name] if config.has_section(entry_point.name) else {}) - logger.info('Loaded authentication plugin > %s <', entry_point.name) - break - if not app.config['FG_AUTH_BACKEND']: - logger.error('No authentication plugin configured or authentication plugin not found') - - logger.info('Search for plugins') - for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin'): - logger.debug("Found plugin: %s", entry_point.name) - if config.get(entry_point.name, 'enabled', fallback=False): - logger.info("Loaded plugin >{}<".format(entry_point.name)) - app.register_blueprint(entry_point.load()()) - - @app.errorhandler(Exception) - def handle_exception(e): - if isinstance(e, HTTPException): - logger.debug(e.description, exc_info=True) - return jsonify({"error": e.name}), e.code - - logger.error(str(e), exc_info=True) - return jsonify({"error": "Internal server error occurred"}), 500 - - return app diff --git a/flaschengeist/app.py b/flaschengeist/app.py new file mode 100644 index 0000000..a4103ae --- /dev/null +++ b/flaschengeist/app.py @@ -0,0 +1,84 @@ +from datetime import datetime + +import pkg_resources +from flask import Flask +from flask.json import JSONEncoder, jsonify +from flask_cors import CORS +from werkzeug.exceptions import HTTPException + +from . import logger +from .system.config import config, configure_app + + +class CustomJSONEncoder(JSONEncoder): + def default(self, o): + # Check if custom model + try: + return o.serialize() + except AttributeError: + pass + + if isinstance(o, datetime): + return o.isoformat() + # Check if iterable + try: + iterable = iter(o) + except TypeError: + pass + else: + return list(iterable) + return JSONEncoder.default(self, o) + + +def __load_auth(app): + for entry_point in pkg_resources.iter_entry_points('flaschengeist.auth'): + logger.debug('Found authentication plugin: %s', entry_point.name) + if entry_point.name == config['FLASCHENGEIST']['AUTH']: + app.config['FG_AUTH_BACKEND'] = entry_point.load()() + app.config['FG_AUTH_BACKEND'].configure( + config[entry_point.name] if config.has_section(entry_point.name) else {}) + logger.info('Loaded authentication plugin > %s <', entry_point.name) + break + if not app.config['FG_AUTH_BACKEND']: + logger.error('No authentication plugin configured or authentication plugin not found') + + +def __load_plugins(app): + logger.info('Search for plugins') + app.config['FG_PLUGINS'] = {} + for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin'): + logger.debug("Found plugin: >{}<".format(entry_point.name)) + if config.get(entry_point.name, 'enabled', fallback=False): + logger.info("Loaded plugin >{}<".format(entry_point.name)) + app.config["FG_PLUGINS"][entry_point.name] = True + app.register_blueprint(entry_point.load()()) + else: + app.config["FG_PLUGINS"][entry_point.name] = False + + +def create_app(): + app = Flask(__name__) + app.json_encoder = CustomJSONEncoder + CORS(app) + + with app.app_context(): + from .system.database import db + configure_app(app) + db.init_app(app) + __load_auth(app) + __load_plugins(app) + + @app.route("/", methods=["GET"]) + def __get_state(): + from . import __version__ as version + return jsonify({"plugins": app.config["FG_PLUGINS"], "version": version}) + + @app.errorhandler(Exception) + def handle_exception(e): + if isinstance(e, HTTPException): + logger.debug(e.description, exc_info=True) + return jsonify({"error": e.description}), e.code + logger.error(str(e), exc_info=True) + return jsonify({"error": "Internal server error occurred"}), 500 + + return app diff --git a/run_flaschengeist b/run_flaschengeist index 6a89775..1636efd 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,5 +1,5 @@ #!/usr/bin/python3 -from flaschengeist import create_app +from flaschengeist.app import create_app import bjoern import argparse diff --git a/setup.py b/setup.py index 7f2e559..23b76be 100644 --- a/setup.py +++ b/setup.py @@ -1,32 +1,34 @@ from setuptools import setup, find_packages setup( - name='flaschengeist', - version='0.0.1', - url='https://wu5.de/redmine/projects/geruecht', - author='WU5 + Friends', - author_email='tim@groeger-clan.de', - description='Does things', + name="flaschengeist", + version="2.0.0-dev", + url="https://wu5.de/redmine/projects/geruecht", + author="WU5 + Friends", + author_email="tim@groeger-clan.de", + description="Does things", packages=find_packages(), - package_data={'': ['*.yml']}, - scripts=['run_flaschengeist'], - install_requires=['Flask >= 1.1', 'PyYAML>=5.3.1', 'sqlalchemy>=1.3', "flask_sqlalchemy", - "flask_cors", "werkzeug", "bjoern"], - extras_require={ - 'ldap': [ - 'flask_ldapconn', - 'ldap3' - ] - }, + package_data={"": ["*.yml"]}, + scripts=["run_flaschengeist"], + install_requires=[ + "Flask >= 1.1", + "PyYAML>=5.3.1", + "sqlalchemy>=1.3", + "flask_sqlalchemy", + "flask_cors", + "werkzeug", + "bjoern", + ], + extras_require={"ldap": ["flask_ldapconn", "ldap3"]}, entry_points={ - 'flaschengeist.plugin': [ - 'auth = flaschengeist.modules.auth:register', - 'users = flaschengeist.modules.users:register', - 'schedule = flaschengeist.modules.schedule:register' + "flaschengeist.plugin": [ + "auth = flaschengeist.modules.auth:register", + "users = flaschengeist.modules.users:register", + "schedule = flaschengeist.modules.schedule:register", ], - 'flaschengeist.auth': [ - 'auth_plain = flaschengeist.modules.auth_plain:AuthPlain', - 'auth_ldap = flaschengeist.modules.auth_ldap:AuthLDAP [ldap]' - ] - } + "flaschengeist.auth": [ + "auth_plain = flaschengeist.modules.auth_plain:AuthPlain", + "auth_ldap = flaschengeist.modules.auth_ldap:AuthLDAP [ldap]", + ], + }, ) From 7caaea71a72bdddd71cd163461b99fe7d211fabb Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 7 Sep 2020 16:13:18 +0200 Subject: [PATCH 037/446] Some new routes for schedule plugin --- flaschengeist/__init__.py | 2 - flaschengeist/modules/auth/__init__.py | 2 +- flaschengeist/modules/schedule/__init__.py | 527 +++++------------- .../system/controller/eventController.py | 61 +- .../system/controller/userController.py | 5 +- 5 files changed, 196 insertions(+), 401 deletions(-) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index b116846..7882802 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -4,8 +4,6 @@ Initialize also a singleton for the AccessTokenController and start the Thread. """ -from datetime import datetime - import yaml import logging import pkg_resources diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 0ec7358..41fcf7f 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -1,7 +1,7 @@ ############################################# # Plugin: Auth # # Functionality: Allow management of # -# authentication, login, logout, etc # +# authentication, login, logout, etc # ############################################# from flask import Blueprint, request, jsonify diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index ec02035..be354ac 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -6,27 +6,57 @@ from datetime import datetime, timedelta from flaschengeist.system.controller import eventController from flaschengeist.system.database import db from flaschengeist.system.decorator import login_required -from flaschengeist.system.models.event import Event, EventKind +from flaschengeist.system.models.event import EventKind -schedule_bp = Blueprint("schedule", __name__) +schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") def register(): return schedule_bp -@schedule_bp.route("/schedule/events", methods=['GET']) -@schedule_bp.route("/schedule/events/", methods=['GET']) -@schedule_bp.route("/schedule/events//", methods=['GET']) -@schedule_bp.route("/schedule/events///", methods=['GET']) +#################################################################################### +# Routes # +# # +# /schedule/events POST: create new # +# GET: get all events this month # +# /schedule/events// GET: get events by month / date # +# /// # +# /schedule/events/ GET: get event by ID # +# DELETE: delete specified event # +# PUT: modify specified event # +# /schedule/events//slots GET: get EventSlots of Event # +# POST: add new EventSlot to Event # +# /schedule/events//slots/ PUT: modify EventSlot # +# GET: get specified EventSlot # +# /schedule/events//slots//jobs POST: add User # +# /schedule/eventKinds +# /schedule/eventKinds/ +# PUT: modify user # +# DELETE: remove user # +#################################################################################### + + +@schedule_bp.route("/events/", methods=['GET']) @login_required() # roles=['schedule_read']) -def _get_events(year=datetime.now().year, month=None, day=None, **kwrags): +def __get_event(id, **kwargs): + event = eventController.get_event(id) + if not event: + raise NotFound + return jsonify(event) + + +@schedule_bp.route("/events", methods=['GET']) +@schedule_bp.route("/events//", methods=['GET']) +@schedule_bp.route("/events///", methods=['GET']) +@login_required() # roles=['schedule_read']) +def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, **kwrags): """Get Event objects for specified date (or month or year), - if nothing set then events for current year are returned + if nothing set then events for current month are returned Args: year (int, optional): year to query, defaults to current year - month (int, optional): month to query (if set) + month (int, optional): month to query (if set), defaults to current month day (int, optional): day to query events for (if set) **kwrags: contains at least access_token (see flaschengeist.decorator) Returns: @@ -35,35 +65,40 @@ def _get_events(year=datetime.now().year, month=None, day=None, **kwrags): BadRequest: If date is invalid """ try: - begin = datetime(year=year, month=1, day=1) - end = None - if month: - begin = datetime(year=year, month=month, day=1) - if day: - begin += timedelta(days=day - 1) - end = begin + timedelta(days=1) - else: - end = datetime(year=year, month=month + 1, day=1) + begin = datetime(year=year, month=month, day=1) + if day: + begin += timedelta(days=day - 1) + end = begin + timedelta(days=1) else: - end = datetime(year=year + 1, month=1, day=1) + end = datetime(year=year, month=month + 1, day=1) - events = Event.query.filter((begin <= Event.begin), (Event.begin < end)) + events = eventController.get_events(begin, end) return jsonify(events) except ValueError: raise BadRequest("Invalid date given") -@schedule_bp.route("/schedule/eventKinds", methods=['POST']) +@schedule_bp.route("/eventKinds", methods=['POST']) @login_required() def __new_event_kind(**kwargs): data = request.get_json() if "name" not in data: raise BadRequest - event = eventController.create_event_kind(data["name"]) - return jsonify({"ok": "ok", "id": event.id}) + kind = eventController.create_event_kind(data["name"]) + return jsonify({"ok": "ok", "id": kind.id}) -@schedule_bp.route("/schedule/events", methods=['POST']) +@schedule_bp.route("/slotKinds", methods=["POST"]) +@login_required() +def __new_slot_kind(**kwargs): + data = request.get_json() + if not data or "name" not in data: + raise BadRequest + kind = eventController.create_job_kind(data["name"]) + return jsonify({"ok": "ok", "id": kind.id}) + + +@schedule_bp.route("/events", methods=['POST']) @login_required() def __new_event(**kwargs): data = request.get_json() @@ -74,383 +109,85 @@ def __new_event(**kwargs): return jsonify({"ok": "ok", "id": event.id}) -@schedule_bp.route("/schedule/events/", methods=["DELETE"]) +@schedule_bp.route("/events/", methods=["DELETE"]) @login_required() def __delete_event(id, **kwargs): - if Event.query.filter(Event.id == id).delete() != 1: + if not eventController.delete_event(id): raise NotFound db.session.commit() return jsonify({'ok': 'ok'}) +@schedule_bp.route("/eventKinds/", methods=["PUT"]) +@login_required() +def __edit_event_kind(id, **kwargs): + data = request.get_json() + if not data or "name" not in data: + raise BadRequest + eventController.rename_event_kind(id, data["name"]) + return jsonify({"ok": "ok"}) + + +@schedule_bp.route("/events//slots", methods=["GET"]) +@login_required() +def __get_slots(event_id, **kwargs): + event = eventController.get_event(event_id) + if not event: + raise NotFound + return jsonify({event.slots}) + + +@schedule_bp.route("/events//slots/", methods=["GET"]) +@login_required() +def __get_slot(event_id, slot_id, **kwargs): + slot = eventController.get_event_slot(slot_id, event_id) + if slot: + return jsonify(slot) + raise NotFound + + +@schedule_bp.route("/events//slots/", methods=["DELETE"]) +@login_required() +def __delete_slot(event_id, slot_id, **kwargs): + if eventController.delete_event_slot(slot_id, event_id): + return jsonify({"ok": "ok"}) + raise NotFound + + +@schedule_bp.route("/events//slots/", methods=["PUT"]) +@login_required() +def __update_slot(event_id, slot_id, **kwargs): + data = request.get_json() + if not data: + raise BadRequest + + for job in data['jobs']: + eventController.add_job(job.kind, job.user) + if eventController.delete_event_slot(slot_id, event_id): + return jsonify({"ok": "ok"}) + raise NotFound + + +@schedule_bp.route("/events//slots", methods=["POST"]) +@login_required() +def __add_slot(event_id, **kwargs): + event = eventController.get_event(event_id) + if not event: + raise NotFound + data = request.get_json() + attr = {"job_slots": []} + try: + if "start" in data: + attr["start"] = parser.isoparse(data["start"]) + if "end" in data: + attr["end"] = parser.isoparse(data["end"]) + for job in data["jobs"]: + attr["job_slots"].append({"needed_persons": job["needed_persons"], "kind": job["kind"]}) + except KeyError: + raise BadRequest("Missing data in request") + eventController.add_slot(event, **attr) + return jsonify({"ok": "ok"}) + + def __edit_event(): ... - -# try: -# data = request.get_json() -# from_date = data['from_date'] -# to_date = data['to_date'] -# from_date = datetime(from_date['year'], from_date['month'], from_date['day']) -# to_date = datetime(to_date['year'], to_date['month'], to_date['day']) -# lockedDays = mainController.getLockedDays(from_date, to_date) -# retVal = [] -# for lockedDay in lockedDays: -# day = datetime.combine(lockedDay['daydate'], time(12)) -# retDay = { -# "worker": mainController.getWorker(day), -# "day": { -# "date": { -# "year": day.year, -# "month": day.month, -# "day": day.day -# }, -# "locked": lockedDay['locked'] -# }, -# "jobkinddate": mainController.getJobKindDates(day.date()) -# } -# retVal.append(retDay) -# -# logger.debug("return {{ {} }}".format(retVal)) -# return jsonify(retVal) -# except Exception as err: -# logger.warn("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 -# -# @schedule.route("/user/jobsOnDates", methods=['POST']) -# @login_required(groups=[USER]) -# def _getJobsOnDates(**kwargs): -# debug.info("/user/jobsOnDates") -# try: -# data = request.get_json() -# lockedDays = mainController.getLockedDaysFromList(data) -# retVal = [] -# for lockedDay in lockedDays: -# day = datetime.combine(lockedDay['daydate'], time(12)) -# retDay = { -# "worker": mainController.getWorker(day), -# "day": { -# "date": { -# "year": day.year, -# "month": day.month, -# "day": day.day -# }, -# "locked": lockedDay['locked'] -# }, -# "jobkinddate": mainController.getJobKindDates(day.date()) -# } -# retVal.append(retDay) -# -# debug.debug("return {{ {} }}".format(retVal)) -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 -# -# @schedule.route("/user/job", methods=['POST']) -# @login_required(groups=[USER]) -# def _getUser(**kwargs): -# debug.info("/user/job") -# try: -# data = request.get_json() -# day = data['day'] -# month = data['month'] -# year = data['year'] -# date = datetime(year, month, day, 12) -# lockedDay = mainController.getLockedDay(date) -# if not lockedDay: -# lockedDay = { -# 'date': { -# 'year': year, -# 'month': month, -# 'day': day -# }, -# 'locked': False -# } -# else: -# lockedDay = { -# 'date': { -# 'year': year, -# 'month': month, -# 'day': day -# }, -# 'locked': lockedDay['locked'] -# } -# retVal = { -# 'worker': mainController.getWorker(date), -# 'day': lockedDay -# } -# debug.debug("retrun {{ {} }}".format(retVal)) -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 -# -# -# @schedule.route("/user/addJob", methods=['POST']) -# @login_required(groups=[USER]) -# def _addUser(**kwargs): -# debug.info("/user/addJob") -# try: -# if 'accToken' in kwargs: -# accToken = kwargs['accToken'] -# user = accToken.user -# data = request.get_json() -# day = data['day'] -# month = data['month'] -# year = data['year'] -# date = datetime(year, month, day, 12) -# job_kind = None -# if 'job_kind' in data: -# job_kind = data['job_kind'] -# mainController.addWorker(user.uid, date, job_kind=job_kind, userExc=True) -# retVal = mainController.getWorker(date) -# debug.debug("return {{ {} }}".format(retVal)) -# jobL.info("Mitglied {} {} schreib sich am {} zum Dienst ein.".format( -# user.firstname, user.lastname, date.date())) -# return jsonify(retVal) -# except DayLocked as err: -# debug.debug("exception", exc_info=True) -# return jsonify({'error': str(err)}), 403 -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({'error': str(err)}), 409 -# -# -# @schedule.route("/user/deleteJob", methods=['POST']) -# @login_required(groups=[USER]) -# def _deletJob(**kwargs): -# debug.info("/user/deleteJob") -# try: -# if 'accToken' in kwargs: -# accToken = kwargs['accToken'] -# user = accToken.user -# data = request.get_json() -# day = data['day'] -# month = data['month'] -# year = data['year'] -# date = datetime(year, month, day, 12) -# mainController.deleteWorker(user.uid, date, True) -# retVal = mainController.getWorker(date) -# debug.debug("return ok") -# jobL.info("Mitglied {} {} entfernt sich am {} aus dem Dienst".format( -# user.firstname, user.lastname, date.date())) -# return jsonify(retVal) -# except DayLocked as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 403 -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 409 -# -# @schedule.route("/user/getJobInvites", methods=['POST']) -# @login_required(groups=[USER]) -# def _getJobInvites(**kwargs): -# try: -# debug.info("/user/getJobInvites") -# from_user = None -# to_user = None -# on_date = None -# -# data = request.get_json() -# -# if 'from_user' in data: -# from_user = data['from_user'] -# if 'to_user' in data: -# to_user = data['to_user'] -# on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) -# retVal = mainController.getJobInvites(from_user, to_user, on_date) -# debug.debug("return {{ {} }}".format(retVal)) -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 -# -# @schedule.route("/user/JobInvites", methods=['PUT', 'POST']) -# @login_required(groups=[USER]) -# def _JobInvites(**kwargs): -# try: -# debug.info("/user/JobInvites") -# data = request.get_json() -# if request.method == 'PUT': -# mainController.setJobInvites(data) -# retVal = mainController.getJobInvites(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) -# debug.debug("return {{ {} }}".format(retVal)) -# if request.method == 'POST': -# retVal = mainController.updateJobInvites(data) -# -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 -# -# @schedule.route("/user/deleteJobInvite", methods=['POST']) -# @login_required(groups=[USER]) -# def _deleteJobInvite(**kwargs): -# try: -# debug.info("/user/deleteJobInvite") -# data = request.get_json() -# mainController.deleteJobInvite(data) -# retVal = mainController.getJobInvites(data['from_user'], None, datetime.now().date()) -# debug.debug("return {{ {} }}".format(retVal)) -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 -# -# -# @schedule.route("/user/getJobRequests", methods=['POST']) -# @login_required(groups=[USER]) -# def _getJobRequests(**kwargs): -# try: -# debug.info("/user/getJobRequests") -# from_user = None -# to_user = None -# on_date = None -# -# data = request.get_json() -# -# if 'from_user' in data: -# from_user = data['from_user'] -# if 'to_user' in data: -# to_user = data['to_user'] -# on_date = date(data['date']['year'], data['date']['month'], data['date']['day']) -# retVal = mainController.getJobRequests(from_user, to_user, on_date) -# debug.debug("return {{ {} }}".format(retVal)) -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 -# -# @schedule.route("/user/JobRequests", methods=['PUT', 'POST']) -# @login_required(groups=[USER]) -# def _JobRequests(**kwargs): -# try: -# debug.info("/user/JobRequests") -# data = request.get_json() -# if request.method == 'PUT': -# mainController.setJobRequests(data) -# retVal = mainController.getJobRequests(kwargs['accToken'].user.toJSON(), None, datetime.now().date()) -# debug.debug("return {{ {} }}".format(retVal)) -# if request.method == 'POST': -# data['on_date'] = date(data['on_date']['year'], data['on_date']['month'], data['on_date']['day']) -# retVal = mainController.updateJobRequests(data) -# -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 -# -# @schedule.route("/user/deleteJobRequest", methods=['POST']) -# @login_required(groups=[USER]) -# def _deleteJobRequest(**kwargs): -# try: -# debug.info("/user/deleteJobRequest") -# data = request.get_json() -# mainController.deleteJobRequest(data) -# retVal = mainController.getJobRequests(data['from_user'], None, datetime.now().date()) -# debug.debug("return {{ {} }}".format(retVal)) -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 - -# CREDIT == Gerücht? CreditList? -# creditL = getCreditLogger() -# @schedule.route("/user/main") -# @login_required(groups=[USER]) -# def _main(**kwargs): -# debug.info("/user/main") -# try: -# if 'accToken' in kwargs: -# accToken = kwargs['accToken'] -# accToken.user = mainController.getUser(accToken.user.uid) -# retVal = accToken.user.toJSON() -# retVal['creditList'] = {credit.year: credit.toJSON() -# for credit in accToken.user.geruechte} -# debug.debug("return {{ {} }}".format(retVal)) -# return jsonify(retVal) -# except Exception: -# debug.debug("exception", exc_info=True) -# return jsonify("error", "something went wrong"), 500 - - -# @schedule.route("/user/addAmount", methods=['POST']) -# @login_required(groups=[USER]) -# def _addAmount(**kwargs): -# debug.info("/user/addAmount") -# try: -# if 'accToken' in kwargs: -# accToken = kwargs['accToken'] -# data = request.get_json() -# amount = int(data['amount']) -# date = datetime.now() -# mainController.addAmount( -# accToken.user.uid, amount, year=date.year, month=date.month) -# accToken.user = mainController.getUser(accToken.user.uid) -# retVal = accToken.user.toJSON() -# retVal['creditList'] = {credit.year: credit.toJSON() -# for credit in accToken.user.geruechte} -# debug.debug("return {{ {} }}".format(retVal)) -# creditL.info("{} {} {} fügt sich selbst {} € Schulden hinzu".format( -# date, accToken.user.firstname, accToken.user.lastname, amount/100)) -# return jsonify(retVal) -# except Exception: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": "something went wrong"}), 500 - - -# @schedule.route("/user/saveConfig", methods=['POST']) -# @login_required(groups=[USER]) -# def _saveConfig(**kwargs): -# debug.info("/user/saveConfig") -# try: -# if 'accToken' in kwargs: -# accToken = kwargs['accToken'] -# data = request.get_json() -# password = data['acceptedPassword'] -# data.pop('acceptedPassword') -# accToken.user = mainController.modifyUser( -# accToken.user, data, password) -# retVal = accToken.user.toJSON() -# retVal['creditList'] = {credit.year: credit.toJSON() -# for credit in accToken.user.geruechte} -# debug.debug("return {{ {} }}".format(retVal)) -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 409 -# -# @schedule.route("/user/storno", methods=['POST']) -# @login_required(groups=[USER]) -# def _storno(**kwargs): -# """ Function for Baruser to storno amount -# -# This function added to the user with the posted userID the posted amount. -# -# Returns: -# JSON-File with userID and the amount -# or ERROR 401 Permission Denied -# """ -# -# debug.info("/user/storno") -# try: -# if 'accToken' in kwargs: -# accToken = kwargs['accToken'] -# user = accToken.user -# data = request.get_json() -# amount = int(data['amount']) -# -# date = datetime.now() -# mainController.addCredit( -# user.uid, amount, year=date.year, month=date.month) -# accToken.user = mainController.getUser(accToken.user.uid) -# retVal = accToken.user.toJSON() -# retVal['creditList'] = {credit.year: credit.toJSON() -# for credit in accToken.user.geruechte} -# debug.debug("return {{ {} }}".format(retVal)) -# creditL.info("{} {} {} storniert {} €".format( -# date, user.firstname, user.lastname, amount/100)) -# return jsonify(retVal) -# except Exception as err: -# debug.debug("exception", exc_info=True) -# return jsonify({"error": str(err)}), 500 diff --git a/flaschengeist/system/controller/eventController.py b/flaschengeist/system/controller/eventController.py index 135164c..85c1a52 100644 --- a/flaschengeist/system/controller/eventController.py +++ b/flaschengeist/system/controller/eventController.py @@ -1,12 +1,37 @@ -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, NotFound -from flaschengeist.system.models.event import EventKind, Event +from flaschengeist.system.models.event import EventKind, Event, EventSlot, JobSlot, JobKind from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.system.database import db +def get_event(id): + return Event.query.get(id) + + +def get_events(begin, end): + """Query events which start from begin until end + Args: + begin (datetime): Earliest start + end (datetime): Latest start + + Returns: collection of Event objects + """ + return Event.query.filter((begin <= Event.begin), (Event.begin < end)) + + +def delete_event(id): + """Delete event with given ID + Args: + id: id of Event to delete + + Returns: True if successful, False if Event is not found + """ + return Event.query.filter(Event.id == id).delete() == 1 + + def create_event(begin, kind, end=None, description=None): try: event = Event(begin=begin, @@ -21,6 +46,17 @@ def create_event(begin, kind, end=None, description=None): raise BadRequest +def create_job_kind(name): + try: + kind = JobKind(name=name) + db.session.add(kind) + db.session.commit() + return kind + except IntegrityError: + logger.debug("IntegrityError: Looks like there is a name collision", exc_info=True) + raise BadRequest("Name already exists") + + def create_event_kind(name): try: event = EventKind(name=name) @@ -30,3 +66,24 @@ def create_event_kind(name): except IntegrityError: logger.debug("IntegrityError: Looks like there is a name collision", exc_info=True) raise BadRequest("Name already exists") + + +def rename_event_kind(id, name): + ek = EventKind.query.get(id) + if not ek: + raise NotFound + ek.name = name + try: + db.session.commit() + except IntegrityError: + raise BadRequest("Name already exists") + + +def add_slot(event, job_slots, needed_persons, start=None, end=None): + event_slot = EventSlot(start=start, end=end) + for slot in job_slots: + kind = JobKind.query.get(slot.id) + job_slot = JobSlot(kind=kind, needed_persons=slot.needed_persons) + event_slot.add_slot(job_slot) + event.add_slot(event_slot) + db.session.commit() diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index d0433bd..049e2f4 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -27,7 +27,10 @@ def modify_user(user, password, new_password=None): Args: user: User object to sync with backend - password: Current password (most backends are needing this) + password: Cu db.session.commit() + + # TODO: is this needed? + def user_has_rorrent password (most backends are needing this) new_password (optional): New password, if password should be changed Raises: From bf33529bf12d96a98c78363c45cc9836f880afaf Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 7 Sep 2020 18:11:38 +0200 Subject: [PATCH 038/446] Cleanup. Move old controller, removed unused code. --- flaschengeist/__init__.py | 2 +- flaschengeist/app.py | 5 ++-- flaschengeist/flaschengeist.example.cfg | 4 +-- flaschengeist/logging.yml | 26 +------------------ .../geruecht}/databaseController/__init__.py | 2 +- .../dbAccessTokenController.py | 0 .../dbCreditListController.py | 0 .../dbFreeDrinkListConfigController.py | 0 .../dbJobInviteController.py | 0 .../databaseController/dbJobKindController.py | 0 .../dbJobRequesController.py | 0 .../dbPricelistController.py | 0 .../dbRegistrationController.py | 0 .../databaseController/dbUserController.py | 0 .../databaseController/dbWorkerController.py | 0 .../dbWorkgroupController.py | 0 .../geruecht}/mainController/__init__.py | 6 ++--- .../mainCreditListController.py | 0 .../mainFreeDrinkListConfigController.py | 0 .../mainController/mainJobInviteController.py | 0 .../mainController/mainJobKindController.py | 0 .../mainJobRequestController.py | 0 .../mainController/mainPasswordReset.py | 0 .../mainController/mainPricelistController.py | 0 .../mainRegistrationController.py | 0 .../mainController/mainWorkerController.py | 0 .../mainController/mainWorkgroupController.py | 0 flaschengeist/system/config.py | 3 +++ flaschengeist/system/decorator.py | 3 +-- flaschengeist/system/exceptions/__init__.py | 9 ------- 30 files changed, 14 insertions(+), 46 deletions(-) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/__init__.py (97%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbAccessTokenController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbCreditListController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbFreeDrinkListConfigController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbJobInviteController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbJobKindController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbJobRequesController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbPricelistController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbRegistrationController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbUserController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbWorkerController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/databaseController/dbWorkgroupController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/__init__.py (97%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainCreditListController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainFreeDrinkListConfigController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainJobInviteController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainJobKindController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainJobRequestController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainPasswordReset.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainPricelistController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainRegistrationController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainWorkerController.py (100%) rename flaschengeist/{system/controller => modules/geruecht}/mainController/mainWorkgroupController.py (100%) delete mode 100644 flaschengeist/system/exceptions/__init__.py diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 7882802..958aa0f 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -11,10 +11,10 @@ from pathlib import Path from logging.config import dictConfig from werkzeug.local import LocalProxy +__version__ = pkg_resources.get_distribution('flaschengeist').version _module_path = Path(__file__).parent logger = LocalProxy(lambda: logging.getLogger(__name__)) -__version__ = pkg_resources.get_distribution('flaschengeist').version with (_module_path / 'logging.yml').open(mode='rb') as file: config = yaml.safe_load(file.read()) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index a4103ae..266fa3d 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,9 +1,8 @@ -from datetime import datetime - import pkg_resources from flask import Flask -from flask.json import JSONEncoder, jsonify from flask_cors import CORS +from datetime import datetime +from flask.json import JSONEncoder, jsonify from werkzeug.exceptions import HTTPException from . import logger diff --git a/flaschengeist/flaschengeist.example.cfg b/flaschengeist/flaschengeist.example.cfg index 1312331..f9db244 100644 --- a/flaschengeist/flaschengeist.example.cfg +++ b/flaschengeist/flaschengeist.example.cfg @@ -33,7 +33,7 @@ CRYPT = SSL/STARTLS # Configuration of plugins # ############################ [geruecht] -enable = true +enabled = true [schubu] -enable = false +enabled = false diff --git a/flaschengeist/logging.yml b/flaschengeist/logging.yml index 715b92d..96b71e8 100644 --- a/flaschengeist/logging.yml +++ b/flaschengeist/logging.yml @@ -19,34 +19,10 @@ handlers: class: logging.handlers.WatchedFileHandler level: DEBUG formatter: debug - filename: geruecht/log/debug/debug.log - encoding: utf8 - - credit: - class: logging.handlers.WatchedFileHandler - level: INFO - formatter: simple - filename: geruecht/log/info/geruecht.log - encoding: utf8 - - jobs: - class: logging.handlers.WatchedFileHandler - level: INFO - formatter: simple - filename: geruecht/log/info/jobs.log + filename: flaschengeist/log/debug/debug.log encoding: utf8 loggers: - credit_logger: - level: INFO - handlers: [credit] - propagate: no - - jobs_logger: - level: INFO - handlers: [jobs] - propagate: no - werkzeug: level: WARNING diff --git a/flaschengeist/system/controller/databaseController/__init__.py b/flaschengeist/modules/geruecht/databaseController/__init__.py similarity index 97% rename from flaschengeist/system/controller/databaseController/__init__.py rename to flaschengeist/modules/geruecht/databaseController/__init__.py index e53e75b..de5ff2d 100644 --- a/flaschengeist/system/controller/databaseController/__init__.py +++ b/flaschengeist/modules/geruecht/databaseController/__init__.py @@ -1,4 +1,4 @@ -from ..mainController import Singleton +from flaschengeist.modules.geruecht.mainController import Singleton from geruecht import db from ..databaseController import dbUserController, dbCreditListController, dbJobKindController, dbPricelistController, dbWorkerController, dbWorkgroupController, dbJobInviteController, dbJobRequesController, dbAccessTokenController, dbRegistrationController, dbFreeDrinkListConfigController from geruecht.exceptions import DatabaseExecption diff --git a/flaschengeist/system/controller/databaseController/dbAccessTokenController.py b/flaschengeist/modules/geruecht/databaseController/dbAccessTokenController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbAccessTokenController.py rename to flaschengeist/modules/geruecht/databaseController/dbAccessTokenController.py diff --git a/flaschengeist/system/controller/databaseController/dbCreditListController.py b/flaschengeist/modules/geruecht/databaseController/dbCreditListController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbCreditListController.py rename to flaschengeist/modules/geruecht/databaseController/dbCreditListController.py diff --git a/flaschengeist/system/controller/databaseController/dbFreeDrinkListConfigController.py b/flaschengeist/modules/geruecht/databaseController/dbFreeDrinkListConfigController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbFreeDrinkListConfigController.py rename to flaschengeist/modules/geruecht/databaseController/dbFreeDrinkListConfigController.py diff --git a/flaschengeist/system/controller/databaseController/dbJobInviteController.py b/flaschengeist/modules/geruecht/databaseController/dbJobInviteController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbJobInviteController.py rename to flaschengeist/modules/geruecht/databaseController/dbJobInviteController.py diff --git a/flaschengeist/system/controller/databaseController/dbJobKindController.py b/flaschengeist/modules/geruecht/databaseController/dbJobKindController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbJobKindController.py rename to flaschengeist/modules/geruecht/databaseController/dbJobKindController.py diff --git a/flaschengeist/system/controller/databaseController/dbJobRequesController.py b/flaschengeist/modules/geruecht/databaseController/dbJobRequesController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbJobRequesController.py rename to flaschengeist/modules/geruecht/databaseController/dbJobRequesController.py diff --git a/flaschengeist/system/controller/databaseController/dbPricelistController.py b/flaschengeist/modules/geruecht/databaseController/dbPricelistController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbPricelistController.py rename to flaschengeist/modules/geruecht/databaseController/dbPricelistController.py diff --git a/flaschengeist/system/controller/databaseController/dbRegistrationController.py b/flaschengeist/modules/geruecht/databaseController/dbRegistrationController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbRegistrationController.py rename to flaschengeist/modules/geruecht/databaseController/dbRegistrationController.py diff --git a/flaschengeist/system/controller/databaseController/dbUserController.py b/flaschengeist/modules/geruecht/databaseController/dbUserController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbUserController.py rename to flaschengeist/modules/geruecht/databaseController/dbUserController.py diff --git a/flaschengeist/system/controller/databaseController/dbWorkerController.py b/flaschengeist/modules/geruecht/databaseController/dbWorkerController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbWorkerController.py rename to flaschengeist/modules/geruecht/databaseController/dbWorkerController.py diff --git a/flaschengeist/system/controller/databaseController/dbWorkgroupController.py b/flaschengeist/modules/geruecht/databaseController/dbWorkgroupController.py similarity index 100% rename from flaschengeist/system/controller/databaseController/dbWorkgroupController.py rename to flaschengeist/modules/geruecht/databaseController/dbWorkgroupController.py diff --git a/flaschengeist/system/controller/mainController/__init__.py b/flaschengeist/modules/geruecht/mainController/__init__.py similarity index 97% rename from flaschengeist/system/controller/mainController/__init__.py rename to flaschengeist/modules/geruecht/mainController/__init__.py index 72c0076..61ce1b5 100644 --- a/flaschengeist/system/controller/mainController/__init__.py +++ b/flaschengeist/modules/geruecht/mainController/__init__.py @@ -1,7 +1,7 @@ -from .. import Singleton, userController -from ...models.user import User +from flaschengeist.system.controller import Singleton, userController +from flaschengeist.system.models.user import User from datetime import datetime, timedelta -from ...database import db +from flaschengeist.system.database import db from flask import current_app from werkzeug.local import LocalProxy logger = LocalProxy(lambda: current_app.logger) diff --git a/flaschengeist/system/controller/mainController/mainCreditListController.py b/flaschengeist/modules/geruecht/mainController/mainCreditListController.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainCreditListController.py rename to flaschengeist/modules/geruecht/mainController/mainCreditListController.py diff --git a/flaschengeist/system/controller/mainController/mainFreeDrinkListConfigController.py b/flaschengeist/modules/geruecht/mainController/mainFreeDrinkListConfigController.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainFreeDrinkListConfigController.py rename to flaschengeist/modules/geruecht/mainController/mainFreeDrinkListConfigController.py diff --git a/flaschengeist/system/controller/mainController/mainJobInviteController.py b/flaschengeist/modules/geruecht/mainController/mainJobInviteController.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainJobInviteController.py rename to flaschengeist/modules/geruecht/mainController/mainJobInviteController.py diff --git a/flaschengeist/system/controller/mainController/mainJobKindController.py b/flaschengeist/modules/geruecht/mainController/mainJobKindController.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainJobKindController.py rename to flaschengeist/modules/geruecht/mainController/mainJobKindController.py diff --git a/flaschengeist/system/controller/mainController/mainJobRequestController.py b/flaschengeist/modules/geruecht/mainController/mainJobRequestController.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainJobRequestController.py rename to flaschengeist/modules/geruecht/mainController/mainJobRequestController.py diff --git a/flaschengeist/system/controller/mainController/mainPasswordReset.py b/flaschengeist/modules/geruecht/mainController/mainPasswordReset.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainPasswordReset.py rename to flaschengeist/modules/geruecht/mainController/mainPasswordReset.py diff --git a/flaschengeist/system/controller/mainController/mainPricelistController.py b/flaschengeist/modules/geruecht/mainController/mainPricelistController.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainPricelistController.py rename to flaschengeist/modules/geruecht/mainController/mainPricelistController.py diff --git a/flaschengeist/system/controller/mainController/mainRegistrationController.py b/flaschengeist/modules/geruecht/mainController/mainRegistrationController.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainRegistrationController.py rename to flaschengeist/modules/geruecht/mainController/mainRegistrationController.py diff --git a/flaschengeist/system/controller/mainController/mainWorkerController.py b/flaschengeist/modules/geruecht/mainController/mainWorkerController.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainWorkerController.py rename to flaschengeist/modules/geruecht/mainController/mainWorkerController.py diff --git a/flaschengeist/system/controller/mainController/mainWorkgroupController.py b/flaschengeist/modules/geruecht/mainController/mainWorkgroupController.py similarity index 100% rename from flaschengeist/system/controller/mainController/mainWorkgroupController.py rename to flaschengeist/modules/geruecht/mainController/mainWorkgroupController.py diff --git a/flaschengeist/system/config.py b/flaschengeist/system/config.py index 658e051..e8181a2 100644 --- a/flaschengeist/system/config.py +++ b/flaschengeist/system/config.py @@ -18,6 +18,7 @@ if 'FLASCHENGEIST_CONF' in os.environ: for loc in paths: try: with (loc/"flaschengeist.cfg").open() as source: + logger.info("Reading config file from >{}<".format(loc)) config.read_file(source) except IOError: pass @@ -47,6 +48,8 @@ def configure_app(app): app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False if config.has_option("FLASCHENGEIST", "ROOT"): + logger.debug("Setting application root to >{}<".format(config["FLASCHENGEIST"]["ROOT"])) app.config["APPLICATION_ROOT"] = config["FLASCHENGEIST"]["ROOT"] if config.getboolean("FLASCHENGEIST", "PROXY", fallback=False): + logger.debug("Fixing wsgi_app for using behind a proxy server") app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index 930cf17..de3b2df 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -1,5 +1,5 @@ from functools import wraps -from flask import request, jsonify +from flask import request from werkzeug.exceptions import Unauthorized from flaschengeist import logger @@ -16,7 +16,6 @@ def login_required(**kwargs): @wraps(func) def wrapper(*args, **kwargs): token = request.headers.get('Token') - logger.debug("token is {{ {} }}".format(token)) access_token = ac_controller.validate_token(token, request.user_agent, roles) if access_token: kwargs['access_token'] = access_token diff --git a/flaschengeist/system/exceptions/__init__.py b/flaschengeist/system/exceptions/__init__.py deleted file mode 100644 index 0972d22..0000000 --- a/flaschengeist/system/exceptions/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -class PermissionDenied(Exception): - def __init__(self, message=None): - if not message: - message = "PermissionDenied" - super().__init__(message) - - -class UsernameExistDB(Exception): - pass From 4cd68d7e81c31a1f0fea0fd0d867c62efac32fc1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 4 Oct 2020 01:25:50 +0200 Subject: [PATCH 039/446] Added Role controller --- flaschengeist/modules/auth_ldap/__init__.py | 2 +- .../modules/{ => geruecht}/creditList.py | 0 .../{ => geruecht}/finanzer/__init__.py | 0 .../modules/{ => geruecht}/finanzer/routes.py | 0 .../modules/{ => geruecht}/gastro/__init__.py | 0 .../modules/{ => geruecht}/gastro/routes.py | 0 .../{ => geruecht}/vorstand/__init__.py | 0 .../modules/{ => geruecht}/vorstand/routes.py | 0 flaschengeist/modules/roles/__init__.py | 87 +++++++++++++++++++ flaschengeist/system/config.py | 3 + .../system/controller/roleController.py | 58 +++++++++++++ flaschengeist/system/models/user.py | 42 +++++---- setup.py | 2 + 13 files changed, 177 insertions(+), 17 deletions(-) rename flaschengeist/modules/{ => geruecht}/creditList.py (100%) rename flaschengeist/modules/{ => geruecht}/finanzer/__init__.py (100%) rename flaschengeist/modules/{ => geruecht}/finanzer/routes.py (100%) rename flaschengeist/modules/{ => geruecht}/gastro/__init__.py (100%) rename flaschengeist/modules/{ => geruecht}/gastro/routes.py (100%) rename flaschengeist/modules/{ => geruecht}/vorstand/__init__.py (100%) rename flaschengeist/modules/{ => geruecht}/vorstand/routes.py (100%) create mode 100644 flaschengeist/modules/roles/__init__.py create mode 100644 flaschengeist/system/controller/roleController.py diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index 8668975..3f3c5c4 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -58,7 +58,7 @@ class AuthLDAP(modules.Auth): if 'displayName' in r: user.display_name = r['displayName'][0] for group in self._get_groups(user.uid): - user.add_group(group) + user.add_role(group) def _get_groups(self, uid): groups = [] diff --git a/flaschengeist/modules/creditList.py b/flaschengeist/modules/geruecht/creditList.py similarity index 100% rename from flaschengeist/modules/creditList.py rename to flaschengeist/modules/geruecht/creditList.py diff --git a/flaschengeist/modules/finanzer/__init__.py b/flaschengeist/modules/geruecht/finanzer/__init__.py similarity index 100% rename from flaschengeist/modules/finanzer/__init__.py rename to flaschengeist/modules/geruecht/finanzer/__init__.py diff --git a/flaschengeist/modules/finanzer/routes.py b/flaschengeist/modules/geruecht/finanzer/routes.py similarity index 100% rename from flaschengeist/modules/finanzer/routes.py rename to flaschengeist/modules/geruecht/finanzer/routes.py diff --git a/flaschengeist/modules/gastro/__init__.py b/flaschengeist/modules/geruecht/gastro/__init__.py similarity index 100% rename from flaschengeist/modules/gastro/__init__.py rename to flaschengeist/modules/geruecht/gastro/__init__.py diff --git a/flaschengeist/modules/gastro/routes.py b/flaschengeist/modules/geruecht/gastro/routes.py similarity index 100% rename from flaschengeist/modules/gastro/routes.py rename to flaschengeist/modules/geruecht/gastro/routes.py diff --git a/flaschengeist/modules/vorstand/__init__.py b/flaschengeist/modules/geruecht/vorstand/__init__.py similarity index 100% rename from flaschengeist/modules/vorstand/__init__.py rename to flaschengeist/modules/geruecht/vorstand/__init__.py diff --git a/flaschengeist/modules/vorstand/routes.py b/flaschengeist/modules/geruecht/vorstand/routes.py similarity index 100% rename from flaschengeist/modules/vorstand/routes.py rename to flaschengeist/modules/geruecht/vorstand/routes.py diff --git a/flaschengeist/modules/roles/__init__.py b/flaschengeist/modules/roles/__init__.py new file mode 100644 index 0000000..a011f7e --- /dev/null +++ b/flaschengeist/modules/roles/__init__.py @@ -0,0 +1,87 @@ +from flask import Blueprint, request, jsonify +from werkzeug.exceptions import NotFound, BadRequest, Forbidden + +from flaschengeist.system.decorator import login_required +from flaschengeist.system.controller import roleController + +roles_bp = Blueprint("roles", __name__) +permissions = {} + + +def register(): + return roles_bp, permissions + +###################################################### +# Routes # +# # +# /roles POST: register new # +# GET: get all roles # +# /roles/permissions GET: get all permissions # +# /roles/ GET: get role with rid # +# PUT: modify role / permission # +# DELETE: remove role # +###################################################### + + +@roles_bp.route("/roles", methods=['POST']) +@login_required() +def __add_role(): + data = request.get_json() + if not data or "name" not in data: + raise BadRequest + if "permissions" in data: + permissions = data["permissions"] + role = roleController.create_role(data["name"], permissions) + return jsonify({"ok": "ok", "id": role.id}) + + +@roles_bp.route("/roles", methods=['GET']) +@login_required() +def __list_roles(**kwargs): + roles = roleController.get_roles() + return jsonify(roles) + + +@roles_bp.route("/roles/permissions", methods=['GET']) +@login_required() +def __list_permissions(**kwargs): + permissions = roleController.get_permissions() + return jsonify(permissions) + + +@roles_bp.route("/roles/", methods=['GET']) +@login_required() +def __get_role(rid, **kwargs): + role = roleController.get_role(rid) + if role: + return jsonify({ + "id": role.id, + "name": role, + "permissions": role.permissions + }) + raise NotFound + + +@roles_bp.route("/roles/", methods=['PUT']) +@login_required() +def __edit_role(rid, **kwargs): + role = roleController.get_role(rid) + if not role: + raise NotFound + + data = request.get_json() + if 'name' in data: + role.name = data["name"] + if "permissions" in data: + roleController.set_permissions(role, data["permissions"]) + roleController.update_role(role) + return jsonify({"ok": "ok"}) + + +@roles_bp.route("/roles/", methods=['DELETE']) +@login_required() +def __delete_role(rid, **kwargs): + if not roleController.delete_role(rid): + raise NotFound + + return jsonify({"ok": "ok"}) diff --git a/flaschengeist/system/config.py b/flaschengeist/system/config.py index e8181a2..479b908 100644 --- a/flaschengeist/system/config.py +++ b/flaschengeist/system/config.py @@ -28,6 +28,9 @@ config.read_dict({ 'auth': { 'enabled': True }, + 'roles': { + 'enabled': True + }, 'users': { 'enabled': True } diff --git a/flaschengeist/system/controller/roleController.py b/flaschengeist/system/controller/roleController.py new file mode 100644 index 0000000..cdea0bc --- /dev/null +++ b/flaschengeist/system/controller/roleController.py @@ -0,0 +1,58 @@ +from flask import current_app +from sqlalchemy.exc import IntegrityError +from werkzeug.exceptions import BadRequest + +from flaschengeist.system.models.user import Role, Permission +from flaschengeist.system.database import db +from flaschengeist import logger + + +def get_roles(): + return Role.query.all() + + +def get_role(rid): + return Role.query.get(rid) + + +def get_permissions(): + return Permission.query.all() + + +def update_role(role): + db.session.commit() + + +def set_permissions(role, permissions): + for name in permissions: + p = Permission.query.filter(Permission.name == name).one_or_none() + if not p: + raise BadRequest("Invalid permission name >{}<".format(name)) + role.permissions.append(p) + db.session.commit() + + +def create_permissions(permissions): + for permission in permissions: + if Permission.query.filter(Permission.name == permission).count() > 0: + continue + p = Permission(name=permission) + db.session.add(p) + db.session.commit() + + +def create_role(name, permissions=[]): + role = Role(name=name) + db.session.add(role) + set_permissions(role, permissions) + return role.id + + +def delete_role(id): + try: + num = Role.query.filter(Role.id == id).delete() + except IntegrityError: + logger.debug("IntegrityError: Role might still be in use", exc_info=True) + raise BadRequest("Role still in use") + db.session.commit() + return num == 1 diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index e028781..351f6f3 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -5,9 +5,9 @@ from werkzeug.local import LocalProxy logger = LocalProxy(lambda: current_app.logger) -association_table = db.Table('user_group', +association_table = db.Table('user_x_role', db.Column('user_id', db.Integer, db.ForeignKey('user.id')), - db.Column('group_id', db.Integer, db.ForeignKey('group.id')) + db.Column('role_id', db.Integer, db.ForeignKey('role.id')) ) @@ -31,7 +31,7 @@ class User(db.Model): firstname = db.Column(db.String(30)) lastname = db.Column(db.String(30)) mail = db.Column(db.String(30)) - groups = db.relationship("Group", secondary=association_table) + roles = db.relationship("Role", secondary=association_table) sessions = db.relationship("AccessToken", back_populates="user") attributes = db.relationship("UserAttribute", collection_class=attribute_mapped_collection('name'), cascade="all, delete") @@ -42,11 +42,11 @@ class User(db.Model): else: self.attributes[name] = UserAttribute(name=name, value=value) - def add_group(self, name): - r = Group.query.filter_by(name=name).first() + def add_role(self, name): + r = Role.query.filter_by(name=name).first() if not r: - r = Group(name=name) - self.groups.append(r) + r = Role(name=name) + self.roles.append(r) def update_data(self, data): logger.debug("update data of user") @@ -61,6 +61,13 @@ class User(db.Model): if 'display_name' in data: self.display_name = data['display_name'] + def has_permissions(self, permissions): + for role in self.roles: + for permission in role.permissions: + if permission.name in permissions: + return True + return False + def serialize(self): return { # TODO: username should be UID? @@ -69,29 +76,29 @@ class User(db.Model): "firstname": self.firstname, "lastname": self.lastname, "mail": self.mail, - "groups": ["user"] + [g.name for g in self.groups] + "roles": ["user"] + [r.name for r in self.roles] } class UserAttribute(db.Model): - __tablename__ = 'userAttribute' + __tablename__ = 'user_attribute' id = db.Column(db.Integer, primary_key=True) user = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) name = db.Column(db.String(30)) value = db.Column(db.String(192)) -group_permission_association_table = db.Table('group_permission', - db.Column('group_id', db.Integer, db.ForeignKey('group.id')), +role_permission_association_table = db.Table('role_x_permission', + db.Column('role_id', db.Integer, db.ForeignKey('role.id')), db.Column('permission_id', db.Integer, db.ForeignKey('permission.id')) ) -class Group(db.Model): - __tablename__ = 'group' +class Role(db.Model): + __tablename__ = 'role' id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(30)) - permissions = db.relationship("Permission", secondary=group_permission_association_table) + name = db.Column(db.String(30), unique=True) + permissions = db.relationship("Permission", secondary=role_permission_association_table, cascade="all, delete") def serialize(self): return self.name @@ -100,4 +107,7 @@ class Group(db.Model): class Permission(db.Model): __tablename__ = 'permission' id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(30)) + name = db.Column(db.String(30), unique=True) + + def serialize(self): + return self.name diff --git a/setup.py b/setup.py index 23b76be..706a52b 100644 --- a/setup.py +++ b/setup.py @@ -18,12 +18,14 @@ setup( "flask_cors", "werkzeug", "bjoern", + "python-dateutil" ], extras_require={"ldap": ["flask_ldapconn", "ldap3"]}, entry_points={ "flaschengeist.plugin": [ "auth = flaschengeist.modules.auth:register", "users = flaschengeist.modules.users:register", + "roles = flaschengeist.modules.roles:register", "schedule = flaschengeist.modules.schedule:register", ], "flaschengeist.auth": [ From f495829fc723a47024242fffbef6c8eaf10031b6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 4 Oct 2020 01:27:05 +0200 Subject: [PATCH 040/446] Add permissions when plugins are loaded --- flaschengeist/app.py | 7 +++++-- flaschengeist/modules/auth/__init__.py | 2 +- flaschengeist/modules/schedule/__init__.py | 8 ++++++-- flaschengeist/modules/users/__init__.py | 10 +++++++--- .../system/controller/accessTokenController.py | 16 ++++------------ flaschengeist/system/decorator.py | 8 ++++---- flaschengeist/system/models/accessToken.py | 2 +- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 266fa3d..d1cb5d7 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -7,6 +7,7 @@ from werkzeug.exceptions import HTTPException from . import logger from .system.config import config, configure_app +from .system.controller import roleController class CustomJSONEncoder(JSONEncoder): @@ -48,9 +49,11 @@ def __load_plugins(app): for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin'): logger.debug("Found plugin: >{}<".format(entry_point.name)) if config.get(entry_point.name, 'enabled', fallback=False): - logger.info("Loaded plugin >{}<".format(entry_point.name)) + blueprint, permissions = entry_point.load()() app.config["FG_PLUGINS"][entry_point.name] = True - app.register_blueprint(entry_point.load()()) + app.register_blueprint(blueprint) + roleController.create_permissions(permissions.values()) + logger.info("Loaded plugin >{}<".format(entry_point.name)) else: app.config["FG_PLUGINS"][entry_point.name] = False diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 41fcf7f..3fb8447 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -18,7 +18,7 @@ auth_bp = Blueprint('auth', __name__) def register(): - return auth_bp + return auth_bp, {} ################################################# # Routes # diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index be354ac..4c8a72b 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -9,10 +9,11 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.models.event import EventKind schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") +permissions = {} def register(): - return schedule_bp + return schedule_bp, permissions #################################################################################### @@ -70,7 +71,10 @@ def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, begin += timedelta(days=day - 1) end = begin + timedelta(days=1) else: - end = datetime(year=year, month=month + 1, day=1) + if month == 12: + end = datetime(year=year + 1, month=1, day=1) + else: + end = datetime(year=year, month=month+1, day=1) events = eventController.get_events(begin, end) return jsonify(events) diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index 9cf2863..c1ac277 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -1,5 +1,5 @@ from flask import Blueprint, request, jsonify -from werkzeug.exceptions import NotFound, BadRequest +from werkzeug.exceptions import NotFound, BadRequest, Forbidden from flaschengeist import logger from flaschengeist.system.decorator import login_required @@ -7,9 +7,10 @@ from flaschengeist.system.controller import userController users_bp = Blueprint("users", __name__) +permissions = {'EDIT_USER': 'edit_user'} def register(): - return users_bp + return users_bp, permissions ################################################# # Routes # @@ -47,13 +48,16 @@ def __get_user(uid, **kwargs): @users_bp.route("/users/", methods=['PUT']) -@login_required()#roles=['edit_users']) +@login_required() def __edit_user(uid, **kwargs): logger.debug("Modify information of user {{ {} }}".format(uid)) user = userController.get_user(uid) if not user: raise NotFound + if uid != kwargs['access_token'].user.uid and user.has_permissions(permissions['EDIT_USER']): + return Forbidden + data = request.get_json() if 'password' not in data: raise BadRequest("Password is missing") diff --git a/flaschengeist/system/controller/accessTokenController.py b/flaschengeist/system/controller/accessTokenController.py index e3d375e..c4c2cfd 100644 --- a/flaschengeist/system/controller/accessTokenController.py +++ b/flaschengeist/system/controller/accessTokenController.py @@ -19,7 +19,7 @@ class AccessTokenController(metaclass=Singleton): def __init__(self, lifetime=1800): self.lifetime = lifetime - def validate_token(self, token, user_agent, roles): + def validate_token(self, token, user_agent, permissions): """ Verify access token Verify an AccessToken and Roles so if the User has permission or not. @@ -28,7 +28,7 @@ class AccessTokenController(metaclass=Singleton): Args: token: Token to verify. user_agent: User agent of browser to check - roles: Roles needed to access restricted routes + permissions: Permissions needed to access restricted routes Returns: An the AccessToken for this given Token or False. """ @@ -39,14 +39,14 @@ class AccessTokenController(metaclass=Singleton): if access_token.expires >= datetime.utcnow() and ( access_token.browser == user_agent.browser and access_token.platform == user_agent.platform): - if not roles or (roles and self.user_has_role(access_token.user, roles)): + if not permissions or access_token.user.has_permissions(permissions): access_token.refresh() db.session.commit() return access_token else: logger.debug("access token is out of date or invalid client used") self.delete_token(access_token) - logger.debug("no valid access token with token: {{ {} }} and roles: {{ {} }}".format(token, roles)) + logger.debug("no valid access token with token: {{ {} }} and permissions: {{ {} }}".format(token, permissions)) return False def create(self, user, user_agent=None) -> AccessToken: @@ -115,11 +115,3 @@ class AccessTokenController(metaclass=Singleton): deleted = AccessToken.query.filter(AccessToken.expires < datetime.utcnow()).delete() logger.debug("{} tokens have been removed".format(deleted)) db.session.commit() - - # TODO: is this needed? - def user_has_role(self, user, roles): - for group in user.groups: - for role in group.roles: - if role.name in roles: - return True - return False diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index de3b2df..cb71ce1 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -8,15 +8,15 @@ from flaschengeist import logger def login_required(**kwargs): from .controller.accessTokenController import AccessTokenController ac_controller = AccessTokenController() - roles = None - if "roles" in kwargs: - roles = kwargs["roles"] + permissions = None + if "permissions" in kwargs: + permissions = kwargs["roles"] def real_decorator(func): @wraps(func) def wrapper(*args, **kwargs): token = request.headers.get('Token') - access_token = ac_controller.validate_token(token, request.user_agent, roles) + access_token = ac_controller.validate_token(token, request.user_agent, permissions) if access_token: kwargs['access_token'] = access_token logger.debug("token {{ {} }} is valid".format(token)) diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/accessToken.py index af80281..f19b2b0 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -18,7 +18,7 @@ class AccessToken(db.Model): user = db.relationship("User", back_populates="sessions") expires = db.Column(db.DateTime) - token = db.Column(db.String(30), unique=True) + token = db.Column(db.String(32), unique=True) lifetime = db.Column(db.Integer) browser = db.Column(db.String(30)) platform = db.Column(db.String(30)) From 790e65791d2da96f7bcf2273cdae8087c3a6be34 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 4 Oct 2020 01:29:49 +0200 Subject: [PATCH 041/446] Added installation mode to run script --- run_flaschengeist | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/run_flaschengeist b/run_flaschengeist index 1636efd..b48c5c8 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -9,10 +9,18 @@ if __name__ == '__main__': parser.add_argument("--host", help="set hostname to listen on", default="127.0.0.1") parser.add_argument("--port", help="set port to listen on", type=int, default=5000) parser.add_argument("--debug", help="run in debug mode", action="store_true") + parser.add_argument("--install", help="installer", action="store_true") args = parser.parse_args() app = create_app() - if args.debug: - app.run(args.host, args.port, debug=True) + if args.install: + with app.app_context(): + from flaschengeist.system.models import * + from flaschengeist.system.database import db + db.create_all() + db.session.commit() else: - bjoern.run(app, args.host, args.port, reuse_port=True) + if args.debug: + app.run(args.host, args.port, debug=True) + else: + bjoern.run(app, args.host, args.port, reuse_port=True) From 3f9fdc773c4a22cfbf58c37ae59a1d764cf8980f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 15 Oct 2020 02:19:51 +0200 Subject: [PATCH 042/446] Fixed typos and timezone --- flaschengeist/modules/auth/__init__.py | 11 ++++++----- .../system/controller/accessTokenController.py | 2 +- flaschengeist/system/models/accessToken.py | 4 ++-- flaschengeist/system/models/user.py | 8 +++++--- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 3fb8447..1943e58 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -36,26 +36,27 @@ def _create_token(): """ Login User Login in User and create an AccessToken for the User. + Requires POST data {'userid': string, 'password': string} Returns: A JSON-File with user information and created token or errors """ logger.debug("Start log in.") data = request.get_json() - username = data['username'] + userid = data['userid'] password = data['password'] - logger.debug("search user {{ {} }} in database".format(username)) - user = userController.login_user(username, password) + logger.debug("search user {{ {} }} in database".format(userid)) + user = userController.login_user(userid, password) if not user: raise Unauthorized logger.debug("user is {{ {} }}".format(user)) token = access_controller.create(user, user_agent=request.user_agent) logger.debug("access token is {{ {} }}".format(token)) - logger.info("User {{ {} }} success login.".format(username)) + logger.info("User {{ {} }} success login.".format(userid)) # Lets cleanup the DB access_controller.clear_expired() - return jsonify({"user": user, "token": token.token}) + return jsonify({"user": user, "token": token, "permissions": user.get_permissions()}) @auth_bp.route("/auth", methods=['GET']) diff --git a/flaschengeist/system/controller/accessTokenController.py b/flaschengeist/system/controller/accessTokenController.py index c4c2cfd..b82a2a3 100644 --- a/flaschengeist/system/controller/accessTokenController.py +++ b/flaschengeist/system/controller/accessTokenController.py @@ -3,7 +3,7 @@ from ..models.accessToken import AccessToken from flaschengeist.system.database import db from flaschengeist import logger from werkzeug.exceptions import Forbidden -from datetime import datetime, timedelta +from datetime import datetime, timezone from . import Singleton diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/accessToken.py index f19b2b0..62149f8 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from ..database import db from secrets import compare_digest from flaschengeist import logger @@ -39,7 +39,7 @@ class AccessToken(db.Model): """ return { "token": self.token, - "expires": self.expires, + "expires": self.expires.replace(tzinfo=timezone.utc), "lifetime": self.lifetime, "browser": self.browser, "platform": self.platform diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index 351f6f3..989baf1 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -61,6 +61,9 @@ class User(db.Model): if 'display_name' in data: self.display_name = data['display_name'] + def get_permissions(self): + return [permission.name for role in self.roles for permission in role.permissions] + def has_permissions(self, permissions): for role in self.roles: for permission in role.permissions: @@ -70,13 +73,12 @@ class User(db.Model): def serialize(self): return { - # TODO: username should be UID? - "username": self.uid, + "userid": self.uid, "display_name": self.display_name, "firstname": self.firstname, "lastname": self.lastname, "mail": self.mail, - "roles": ["user"] + [r.name for r in self.roles] + "roles": [r.name for r in self.roles] } From e4f42006a7b27d974398a5be2a059b44687f8daf Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 15 Oct 2020 12:05:16 +0200 Subject: [PATCH 043/446] Fixed uncaught exception in auth and wrong example config --- flaschengeist/flaschengeist.example.cfg | 18 +++++++++--------- flaschengeist/modules/auth/__init__.py | 7 +++++-- flaschengeist/system/models/user.py | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/flaschengeist/flaschengeist.example.cfg b/flaschengeist/flaschengeist.example.cfg index f9db244..4136f39 100644 --- a/flaschengeist/flaschengeist.example.cfg +++ b/flaschengeist/flaschengeist.example.cfg @@ -12,15 +12,6 @@ HOST = PASSWORD = DATABASE = -# [LDAP] -# URL = -# PORT = -# BINDDN = -# SECRET = -# USE_SSL = -## ADMIN_DN: -## ADMIN_SECRET: - [MAIL] URL = PORT = @@ -29,6 +20,15 @@ PASSWD = MAIL = CRYPT = SSL/STARTLS +#[auth_ldap] +# URL = +# PORT = +# BINDDN = +# SECRET = +# USE_SSL = +## ADMIN_DN: +## ADMIN_SECRET: + ############################ # Configuration of plugins # ############################ diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 1943e58..be0b688 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -42,8 +42,11 @@ def _create_token(): """ logger.debug("Start log in.") data = request.get_json() - userid = data['userid'] - password = data['password'] + try: + userid = data['userid'] + password = data['password'] + except KeyError: + raise BadRequest("Missing parameter(s)") logger.debug("search user {{ {} }} in database".format(userid)) user = userController.login_user(userid, password) diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index 989baf1..bc2fc7c 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -62,7 +62,7 @@ class User(db.Model): self.display_name = data['display_name'] def get_permissions(self): - return [permission.name for role in self.roles for permission in role.permissions] + return ["user"] + [permission.name for role in self.roles for permission in role.permissions] def has_permissions(self, permissions): for role in self.roles: From 21ea9b3cdf5ce4330b8a21bbadc7a95595c466a7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 15 Oct 2020 12:40:53 +0200 Subject: [PATCH 044/446] Added readme --- flaschengeist/readme.md | 29 +++++++++++++++++++++++++++++ server.crt | 21 --------------------- server.key | 28 ---------------------------- 3 files changed, 29 insertions(+), 49 deletions(-) create mode 100644 flaschengeist/readme.md delete mode 100644 server.crt delete mode 100644 server.key diff --git a/flaschengeist/readme.md b/flaschengeist/readme.md new file mode 100644 index 0000000..901d488 --- /dev/null +++ b/flaschengeist/readme.md @@ -0,0 +1,29 @@ +# Flaschengeist + + +## Installation +### Requirements +- mysql or mariadb +- python 3.4+ +### Install python files + pip3 install --user . +or with ldap support + + pip3 install --user ".[ldap]" + +### Configuration +1. Rename `flaschengeist.example.cfg` to `flaschengeist.cfg` +2. Move it to either + 1. the module path (where flaschegeist is installed) + 2. `~/.config/` + 3. A custom path and set environment variable `FLASCHENGEIST_CONF` +3. Change at least the database parameters + +### Database installation + run_flaschengeist --install + +### Run + run_flaschengeist +or with debug messages: + + run_flaschengeist --debug diff --git a/server.crt b/server.crt deleted file mode 100644 index e804409..0000000 --- a/server.crt +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDazCCAlOgAwIBAgIJAJGH2ozWvd1RMA0GCSqGSIb3DQEBCwUAMFMxCzAJBgNV -BAYTAkRFMQ8wDQYDVQQIDAZTYXhvbnkxEDAOBgNVBAcMB0RyZXNkZW4xITAfBgNV -BAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMDAxMTcwOTA0MDFaFw0z -MDAxMDQwOTA0MDFaMEQxCzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZTYXhvbnkxEDAO -BgNVBAcMB0RyZXNkZW4xEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcN -AQEBBQADggEPADCCAQoCggEBALlkr1UOQypLKicESRnse52d5mAX9MjZQpH0/Y5u -V5WxpPSasmOpt4MRj5MWTfTK2ukj/jLtPAMsggUh7wMXb1uytHj7T5mtiahXBM0H -1sUi2nScXR6doQZlmqKWDGrVS7WHULM01WhirsnxI8S8e6Evpk4F5/RafKA8FgYI -Ongg6S1B16+7T0e/FnILoMjKr1jpgzXnVkPFIneu/qVevSNco5/aw+bc6sjeS/ZA -65dXFGpDlw0lPRHLT5/CgNyMyiLYov7KwMycZw7uxa1ynO+73tqe5tvO/DiMpAPJ -EkrSz/StYBsGJxDhwq5RT31tHVtHhTf0rk1BmaoQJ0Aq7iECAwEAAaNRME8wHwYD -VR0jBBgwFoAUt8P5gBfN9hCUAiWhtPH5fTWnctAwCQYDVR0TBAIwADALBgNVHQ8E -BAMCBPAwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4IBAQCD -fBByVq8AbV1DMrY+MElb/nZA5/cuGnUpBpjSlk5OnYHWtywuQk6veiiJ0S2fNfqf -RzwOFuZDHKmIcH0574VssLfUynMKP3w3xb2ZNic3AxAdhzZ6LXLx6+qF5tYcL7oC -UWmj5Mo9SkX5HZLEGamQlVyGOGKNatxep4liyoSeKXr0AOHYfB4AkDhVZn7yQc/v -But42fLBg4mE+rk4UBYOHA4XdoFwqgTCNZq2RxKzvG9LIcok6lOc6gDnfTsH8GqE -byGpfIIQAXF8aftCm4dGXxtzMh8C5d0t2Ell9g+Rr8i/enebT2nJ9B9ptldDjhcZ -7I0ywGsXwrh0EwFsX74/ ------END CERTIFICATE----- diff --git a/server.key b/server.key deleted file mode 100644 index 153fd6b..0000000 --- a/server.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC5ZK9VDkMqSyon -BEkZ7HudneZgF/TI2UKR9P2ObleVsaT0mrJjqbeDEY+TFk30ytrpI/4y7TwDLIIF -Ie8DF29bsrR4+0+ZrYmoVwTNB9bFItp0nF0enaEGZZqilgxq1Uu1h1CzNNVoYq7J -8SPEvHuhL6ZOBef0WnygPBYGCDp4IOktQdevu09HvxZyC6DIyq9Y6YM151ZDxSJ3 -rv6lXr0jXKOf2sPm3OrI3kv2QOuXVxRqQ5cNJT0Ry0+fwoDcjMoi2KL+ysDMnGcO -7sWtcpzvu97anubbzvw4jKQDyRJK0s/0rWAbBicQ4cKuUU99bR1bR4U39K5NQZmq -ECdAKu4hAgMBAAECggEABoMQ3Y34sf2d52zxHGYAGZM4SlvND1kCS5otZdleXjW1 -M5pTdci6V3JAdswrxNNzSQkonqVSnFHt5zw/5v3lvXTTfgRl0WIVGcKkuobx9k65 -Gat8YdzrkQv0mI1otj/zvtaX8ROEA3yj4xgDR5/PP+QqlUcD1MNw6TfzFhcn5pxB -/RDPmvarMhzMdDW60Uub6Z7e/kVPuXWrW4bDyULd1d1NoSibnFZi+vGY0Lc1ctDW -2Vl7A8RFTcQi6Cjx/FwgPGJTBE4UMjIBO3wnoPQBMrsSxeGhcarerqIlEafgT4XN -p9BMtRyaXE7TTb1BXc35ZYNJLDLJKQxABhrEHtFreQKBgQDpiGwuKAFK8BLPlbAx -zkShhKd9fhlwm2bfRv3cojPQZsxn0BjefmtrISbKCD79Ivyn7TnOyYAoKAxdp2q9 -wtz94aAXV2lfhUw2lhcb/aw4sXuY/s1XnVyoglOO8pYRCUN0o80pKuWFsaDyy/uL -LhINff1oMNCa7vmMdu8Ccz0o/wKBgQDLOqdTQhSFs4f1yhlDDH3pqT6eKvtFNeRJ -usxYDnAyRXHRqwhQ86z1nBZIgwXqq7PfO9V5Y/l6/2HmmA2ufjS8aBTNpCUMuvJk -y98Z4hTjKRdnVlMUjHq9ahCixJVQ8pcCnWRFdeAwSKhHQiJEFLYeYOIrUeCIYJI4 -FiCshSPI3wKBgGU0ErWZ7p18FprRIs8itYlNhIwUxo+POPCPwloIDO5GblSa0Pwy -yvhdIIMzOaDXtahMXN3pYtmEKX+4msBrnvuC+K7E2cxkZtfNCWy+7RCQkaCG45QR -hOMdv3pWVIRDgHEevz0U8uySQs6VaYgySe6A5/1sEiriX1DpBcEJEbsfAoGAKUCb -rGvSbJ1XsM24OQL1IBQJsON6o77fuxOe3RT5M0sjYnL8OipsZmKrp0ZpUgxOc7ba -i0x+3LewMLWWuV/G5qOd7WwvVRkxkMJNZByfLskthf1g2d/2HjLEc7XBtW+4tYAr -VWoq+sIU3noPKJCnsxzpa++vyx8HLzlWoo5YCDMCgYBJvGH2zMgInlQNO/2XY5nl -E53EZMex+RDq8Wzr4tRM3IrCGc2t8WKEQ/9teKNH0tg9xib0vhqqmiGl1xNfqJVo -ePJyfgFabeUx9goG3mgTdV9woSRlBJso62dM0DAC/jsJoHnVzgokysR4/BfW9Da+ -AYTxRZSNbfmsTHawXqG8Fw== ------END PRIVATE KEY----- From f03314efac0e45cb3f59c6a2d9a687347f8e3ab5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 15 Oct 2020 14:44:58 +0200 Subject: [PATCH 045/446] Split installation and app creation. Added Readme --- flaschengeist/app.py | 24 +++++--- flaschengeist/modules/__init__.py | 12 ++++ flaschengeist/modules/auth/__init__.py | 3 +- flaschengeist/modules/roles/__init__.py | 3 +- flaschengeist/modules/schedule/__init__.py | 3 +- flaschengeist/modules/users/__init__.py | 5 +- flaschengeist/readme.md | 68 ++++++++++++++++++++++ run_flaschengeist | 7 +-- 8 files changed, 108 insertions(+), 17 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index d1cb5d7..c96c817 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,5 +1,5 @@ import pkg_resources -from flask import Flask +from flask import Flask, current_app from flask_cors import CORS from datetime import datetime from flask.json import JSONEncoder, jsonify @@ -48,14 +48,24 @@ def __load_plugins(app): app.config['FG_PLUGINS'] = {} for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin'): logger.debug("Found plugin: >{}<".format(entry_point.name)) + plugin = None if config.get(entry_point.name, 'enabled', fallback=False): - blueprint, permissions = entry_point.load()() - app.config["FG_PLUGINS"][entry_point.name] = True - app.register_blueprint(blueprint) - roleController.create_permissions(permissions.values()) + plugin = entry_point.load()() + app.register_blueprint(plugin.blueprint) logger.info("Loaded plugin >{}<".format(entry_point.name)) - else: - app.config["FG_PLUGINS"][entry_point.name] = False + app.config["FG_PLUGINS"][entry_point.name] = plugin + + +def install_all(): + from flaschengeist.system.database import db + from flaschengeist.system.models import user, event, accessToken + db.create_all() + db.session.commit() + for name, plugin in current_app.config["FG_PLUGINS"].items(): + logger.info("Install plugin {}".format(name)) + plugin.install() + if plugin.permissions: + roleController.create_permissions(plugin.permissions.values()) def create_app(): diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index 0cdd4bb..d33f1cd 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -1,3 +1,15 @@ +class Plugin: + def __init__(self, blueprint, permissions = {}): + self.blueprint = blueprint + self.permissions = permissions + + def install(self): + """ Installation routine + Is always called with Flask application context + """ + pass + + class Auth: def configure(self, config): pass diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index be0b688..ef93e97 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -9,6 +9,7 @@ from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from werkzeug.local import LocalProxy from flaschengeist import logger +from flaschengeist.modules import Plugin from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import accessTokenController, userController @@ -18,7 +19,7 @@ auth_bp = Blueprint('auth', __name__) def register(): - return auth_bp, {} + return Plugin(auth_bp) ################################################# # Routes # diff --git a/flaschengeist/modules/roles/__init__.py b/flaschengeist/modules/roles/__init__.py index a011f7e..b29363f 100644 --- a/flaschengeist/modules/roles/__init__.py +++ b/flaschengeist/modules/roles/__init__.py @@ -1,6 +1,7 @@ from flask import Blueprint, request, jsonify from werkzeug.exceptions import NotFound, BadRequest, Forbidden +from flaschengeist.modules import Plugin from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import roleController @@ -9,7 +10,7 @@ permissions = {} def register(): - return roles_bp, permissions + return Plugin(roles_bp, permissions) ###################################################### # Routes # diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index 4c8a72b..62bf9cd 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify from werkzeug.exceptions import BadRequest, NotFound from datetime import datetime, timedelta +from flaschengeist.modules import Plugin from flaschengeist.system.controller import eventController from flaschengeist.system.database import db from flaschengeist.system.decorator import login_required @@ -13,7 +14,7 @@ permissions = {} def register(): - return schedule_bp, permissions + return Plugin(schedule_bp, permissions) #################################################################################### diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index c1ac277..d440750 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -2,15 +2,16 @@ from flask import Blueprint, request, jsonify from werkzeug.exceptions import NotFound, BadRequest, Forbidden from flaschengeist import logger +from flaschengeist.modules import Plugin from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import userController users_bp = Blueprint("users", __name__) - permissions = {'EDIT_USER': 'edit_user'} + def register(): - return users_bp, permissions + return Plugin(users_bp, permissions) ################################################# # Routes # diff --git a/flaschengeist/readme.md b/flaschengeist/readme.md index 901d488..395a49b 100644 --- a/flaschengeist/readme.md +++ b/flaschengeist/readme.md @@ -27,3 +27,71 @@ or with ldap support or with debug messages: run_flaschengeist --debug + +## Plugin Development +### File Structure + flaschengeist-example-plugin + |> __init__.py + |> model.py + |> setup.py + +### Files +#### \_\_init\_\_.py + from flask import Blueprint + from flaschengeist.modules import Plugin + + + example_bp = Blueprint("example", __name__, url_prefix="/example") + permissions = {"EXAMPLE_HELLO": "example_hello"} + + + def register(): + # If no model is needed: + # return Plugin(schedule_bp, permissions) + # else if model is used: + class ExamplePlugin(Plugin): + def install(self): + from flaschengeist.system.database import db + import .model + db.create_all() + db.session.commit() + + return ExamplePlugin(schedule_bp, permissions) + + + + @schedule_bp.route("/hello", methods=['GET']) + @login_required(roles=['example_hello']) + def __hello(id, **kwargs): + return "Hello" + +#### model.py +Optional, only needed if you need your own models (database) + + from flaschengeist.system.database import db + + model_name = __name__ + + + class ExampleModel(db.Model): + """Example Model""" + __tablename__ = 'example' + id = db.Column(db.Integer, primary_key=True) + description = db.Column(db.String(240)) + +#### setup.py + from setuptools import setup, find_packages + + setup( + name="flaschengeist-example-plugin", + version="0.0.0-dev", + packages=find_packages(), + install_requires=[ + "flaschengeist >= 2", + ], + entry_points={ + "flaschengeist.plugin": [ + "example = flaschengeist-example-plugin:register" "roles = flaschengeist.modules.roles:register", + ] + }, + ) diff --git a/run_flaschengeist b/run_flaschengeist index b48c5c8..d69c3f5 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,5 +1,5 @@ #!/usr/bin/python3 -from flaschengeist.app import create_app +from flaschengeist.app import create_app, install_all import bjoern import argparse @@ -15,10 +15,7 @@ if __name__ == '__main__': app = create_app() if args.install: with app.app_context(): - from flaschengeist.system.models import * - from flaschengeist.system.database import db - db.create_all() - db.session.commit() + install_all() else: if args.debug: app.run(args.host, args.port, debug=True) From 60c26377845c050212d1e7d836c682bf1bc6eb09 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 15 Oct 2020 18:11:27 +0200 Subject: [PATCH 046/446] Enhanced run_flaschengeist --- run_flaschengeist | 47 +++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/run_flaschengeist b/run_flaschengeist index d69c3f5..4a89b5d 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,23 +1,38 @@ #!/usr/bin/python3 -from flaschengeist.app import create_app, install_all -import bjoern +import sys import argparse +import bjoern + + +def install(arguments): + from flaschengeist.app import create_app, install_all + app = create_app() + with app.app_context(): + install_all() + + +def run(arguments): + from flaschengeist.app import create_app + app = create_app() + with app.app_context(): + if arguments.debug: + app.run(arguments.host, arguments.port, debug=True) + else: + bjoern.run(app, arguments.host, arguments.port, reuse_port=True) if __name__ == '__main__': + # create the top-level parser parser = argparse.ArgumentParser() - parser.add_argument("--host", help="set hostname to listen on", default="127.0.0.1") - parser.add_argument("--port", help="set port to listen on", type=int, default=5000) - parser.add_argument("--debug", help="run in debug mode", action="store_true") - parser.add_argument("--install", help="installer", action="store_true") - args = parser.parse_args() + subparsers = parser.add_subparsers(help='sub-command help', dest="sub_command") + subparsers.required = True + parser_run = subparsers.add_parser('run', help='run flaschengeist') + parser_run.set_defaults(func=run) + parser_run.add_argument("--host", help="set hostname to listen on", default="127.0.0.1") + parser_run.add_argument("--port", help="set port to listen on", type=int, default=5000) + parser_run.add_argument("--debug", help="run in debug mode", action="store_true") + parser_install = subparsers.add_parser('install', help='run database setup for flaschengeist and all installed plugins') + parser_install.set_defaults(func=install) - app = create_app() - if args.install: - with app.app_context(): - install_all() - else: - if args.debug: - app.run(args.host, args.port, debug=True) - else: - bjoern.run(app, args.host, args.port, reuse_port=True) + args = parser.parse_args() + args.func(args) From 2c55edf6a8f1fd7022f4f6eab64bf94d902d7469 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 15 Oct 2020 21:58:56 +0200 Subject: [PATCH 047/446] Only use one plugin system, load auth and "normal" plugins at once. * Added Plugin class, where to inheritate from --- flaschengeist/app.py | 33 +- flaschengeist/flaschengeist.example.cfg | 20 +- flaschengeist/modules/__init__.py | 12 +- flaschengeist/modules/auth/__init__.py | 151 +++++----- flaschengeist/modules/auth_ldap/__init__.py | 31 +- flaschengeist/modules/message_mail.py | 43 +++ flaschengeist/modules/roles/__init__.py | 112 ++++--- flaschengeist/modules/schedule/__init__.py | 283 +++++++++--------- flaschengeist/modules/users/__init__.py | 83 +++-- .../system/controller/emailController.py | 119 -------- .../system/controller/eventController.py | 3 +- .../system/controller/messageController.py | 14 + .../system/controller/userController.py | 6 +- setup.py | 15 +- 14 files changed, 428 insertions(+), 497 deletions(-) create mode 100644 flaschengeist/modules/message_mail.py delete mode 100644 flaschengeist/system/controller/emailController.py create mode 100644 flaschengeist/system/controller/messageController.py diff --git a/flaschengeist/app.py b/flaschengeist/app.py index c96c817..968e46e 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -6,6 +6,7 @@ from flask.json import JSONEncoder, jsonify from werkzeug.exceptions import HTTPException from . import logger +from .modules import AuthPlugin from .system.config import config, configure_app from .system.controller import roleController @@ -30,19 +31,6 @@ class CustomJSONEncoder(JSONEncoder): return JSONEncoder.default(self, o) -def __load_auth(app): - for entry_point in pkg_resources.iter_entry_points('flaschengeist.auth'): - logger.debug('Found authentication plugin: %s', entry_point.name) - if entry_point.name == config['FLASCHENGEIST']['AUTH']: - app.config['FG_AUTH_BACKEND'] = entry_point.load()() - app.config['FG_AUTH_BACKEND'].configure( - config[entry_point.name] if config.has_section(entry_point.name) else {}) - logger.info('Loaded authentication plugin > %s <', entry_point.name) - break - if not app.config['FG_AUTH_BACKEND']: - logger.error('No authentication plugin configured or authentication plugin not found') - - def __load_plugins(app): logger.info('Search for plugins') app.config['FG_PLUGINS'] = {} @@ -50,10 +38,20 @@ def __load_plugins(app): logger.debug("Found plugin: >{}<".format(entry_point.name)) plugin = None if config.get(entry_point.name, 'enabled', fallback=False): - plugin = entry_point.load()() - app.register_blueprint(plugin.blueprint) - logger.info("Loaded plugin >{}<".format(entry_point.name)) - app.config["FG_PLUGINS"][entry_point.name] = plugin + plugin = entry_point.load()(config[entry_point.name] if config.has_section(entry_point.name) else {}) + if plugin.blueprint: + app.register_blueprint(plugin.blueprint) + logger.info("Load plugin >{}<".format(entry_point.name)) + if isinstance(plugin, AuthPlugin): + logger.debug('Found authentication plugin: %s', entry_point.name) + if entry_point.name == config['FLASCHENGEIST']['AUTH']: + app.config['FG_AUTH_BACKEND'] = plugin + else: + del plugin + else: + app.config["FG_PLUGINS"][entry_point.name] = plugin + if 'FG_AUTH_BACKEND' not in app.config: + logger.error('No authentication plugin configured or authentication plugin not found') def install_all(): @@ -77,7 +75,6 @@ def create_app(): from .system.database import db configure_app(app) db.init_app(app) - __load_auth(app) __load_plugins(app) @app.route("/", methods=["GET"]) diff --git a/flaschengeist/flaschengeist.example.cfg b/flaschengeist/flaschengeist.example.cfg index 4136f39..938602c 100644 --- a/flaschengeist/flaschengeist.example.cfg +++ b/flaschengeist/flaschengeist.example.cfg @@ -12,15 +12,21 @@ HOST = PASSWORD = DATABASE = -[MAIL] -URL = -PORT = -USER = -PASSWD = -MAIL = -CRYPT = SSL/STARTLS +[auth_plain] +enabled = true + +#[mail] +# enabled = true +# SERVER = +# PORT = +# USER = +# PASSWORD = +# MAIL = +# SSL or STARTLS +# CRYPT = SSL #[auth_ldap] +# enabled = true # URL = # PORT = # BINDDN = diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index d33f1cd..c4191be 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -1,5 +1,10 @@ +from pyhooks import precall_register + +send_message_hook = precall_register("send_message") + + class Plugin: - def __init__(self, blueprint, permissions = {}): + def __init__(self, config=None, blueprint=None, permissions={}): self.blueprint = blueprint self.permissions = permissions @@ -10,10 +15,7 @@ class Plugin: pass -class Auth: - def configure(self, config): - pass - +class AuthPlugin(Plugin): def login(self, user, pw): """ Login routine, MUST BE IMPLEMENTED! diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index ef93e97..c6e4238 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -14,12 +14,12 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import accessTokenController, userController access_controller = LocalProxy(lambda: accessTokenController.AccessTokenController()) - auth_bp = Blueprint('auth', __name__) -def register(): - return Plugin(auth_bp) +class AuthRoutePlugin(Plugin): + def __init__(self, conf): + super().__init__(blueprint=auth_bp) ################################################# # Routes # @@ -31,84 +31,83 @@ def register(): # DELETE: logout / delete token # ################################################# + @auth_bp.route("/auth", methods=['POST']) + def _create_token(): + """ Login User -@auth_bp.route("/auth", methods=['POST']) -def _create_token(): - """ Login User + Login in User and create an AccessToken for the User. + Requires POST data {'userid': string, 'password': string} + Returns: + A JSON-File with user information and created token or errors + """ + logger.debug("Start log in.") + data = request.get_json() + try: + userid = data['userid'] + password = data['password'] + except KeyError: + raise BadRequest("Missing parameter(s)") - Login in User and create an AccessToken for the User. - Requires POST data {'userid': string, 'password': string} - Returns: - A JSON-File with user information and created token or errors - """ - logger.debug("Start log in.") - data = request.get_json() - try: - userid = data['userid'] - password = data['password'] - except KeyError: - raise BadRequest("Missing parameter(s)") + logger.debug("search user {{ {} }} in database".format(userid)) + user = userController.login_user(userid, password) + if not user: + raise Unauthorized + logger.debug("user is {{ {} }}".format(user)) + token = access_controller.create(user, user_agent=request.user_agent) + logger.debug("access token is {{ {} }}".format(token)) + logger.info("User {{ {} }} success login.".format(userid)) - logger.debug("search user {{ {} }} in database".format(userid)) - user = userController.login_user(userid, password) - if not user: - raise Unauthorized - logger.debug("user is {{ {} }}".format(user)) - token = access_controller.create(user, user_agent=request.user_agent) - logger.debug("access token is {{ {} }}".format(token)) - logger.info("User {{ {} }} success login.".format(userid)) - - # Lets cleanup the DB - access_controller.clear_expired() - return jsonify({"user": user, "token": token, "permissions": user.get_permissions()}) + # Lets cleanup the DB + access_controller.clear_expired() + return jsonify({"user": user, "token": token, "permissions": user.get_permissions()}) -@auth_bp.route("/auth", methods=['GET']) -@login_required() -def _get_tokens(access_token, **kwargs): - tokens = access_controller.get_users_tokens(access_token.user) - return jsonify(tokens) + @auth_bp.route("/auth", methods=['GET']) + @login_required() + def _get_tokens(access_token, **kwargs): + tokens = access_controller.get_users_tokens(access_token.user) + return jsonify(tokens) -@auth_bp.route("/auth/", methods=['DELETE']) -@login_required() -def _delete_token(token, access_token, **kwargs): - logger.debug("Try to delete access token {{ {} }}".format(token)) - token = access_controller.get_token(token, access_token.user) - if not token: - logger.debug("Token not found in database!") - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - access_controller.delete_token(token) - access_controller.clear_expired() - return jsonify({"ok": "ok"}) - - -@auth_bp.route("/auth/", methods=['GET']) -@login_required() -def _get_token(token, access_token, **kwargs): - logger.debug("get token {{ {} }}".format(token)) - token = access_controller.get_token(token, access_token.user) - if not token: - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - return jsonify(token) - - -@auth_bp.route("/auth/", methods=['PUT']) -@login_required() -def _set_lifetime(token, access_token, **kwargs): - token = access_controller.get_token(token, access_token.user) - if not token: - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - try: - lifetime = request.get_json()['value'] - logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) - access_controller.set_lifetime(token, lifetime) + @auth_bp.route("/auth/", methods=['DELETE']) + @login_required() + def _delete_token(token, access_token, **kwargs): + logger.debug("Try to delete access token {{ {} }}".format(token)) + token = access_controller.get_token(token, access_token.user) + if not token: + logger.debug("Token not found in database!") + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + access_controller.delete_token(token) + access_controller.clear_expired() return jsonify({"ok": "ok"}) - except (KeyError, TypeError): - raise BadRequest + + + @auth_bp.route("/auth/", methods=['GET']) + @login_required() + def _get_token(token, access_token, **kwargs): + logger.debug("get token {{ {} }}".format(token)) + token = access_controller.get_token(token, access_token.user) + if not token: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + return jsonify(token) + + + @auth_bp.route("/auth/", methods=['PUT']) + @login_required() + def _set_lifetime(token, access_token, **kwargs): + token = access_controller.get_token(token, access_token.user) + if not token: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + try: + lifetime = request.get_json()['value'] + logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) + access_controller.set_lifetime(token, lifetime) + return jsonify({"ok": "ok"}) + except (KeyError, TypeError): + raise BadRequest diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index 3f3c5c4..909ae26 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -1,29 +1,26 @@ -from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError +import ssl from ldap3.utils.hashed import hashed -from werkzeug.exceptions import BadRequest - -import flaschengeist.modules as modules +from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_SHA512 +from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from flask import current_app as app from flask_ldapconn import LDAPConn -from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_SHA512 -import ssl +from werkzeug.exceptions import BadRequest +from flaschengeist.modules import AuthPlugin from flaschengeist.system.models.user import User -from flaschengeist import logger -class AuthLDAP(modules.Auth): - _default = { - 'PORT': '389', - 'USE_SSL': 'False' - } - ldap = None - dn = None +class AuthLDAP(AuthPlugin): + def __init__(self, config): + super().__init__() - def configure(self, config): - for name in self._default: + defaults = { + 'PORT': '389', + 'USE_SSL': 'False' + } + for name in defaults: if name not in config: - config[name] = self._default[name] + config[name] = defaults[name] app.config.update( LDAP_SERVER=config['URL'], diff --git a/flaschengeist/modules/message_mail.py b/flaschengeist/modules/message_mail.py new file mode 100644 index 0000000..ead63ec --- /dev/null +++ b/flaschengeist/modules/message_mail.py @@ -0,0 +1,43 @@ +import smtplib +from email.mime.multipart import MIMEMultipart + +from flaschengeist.system.models.user import User +from flaschengeist.system.controller import userController +from flaschengeist.system.controller.messageController import Message + +from . import Plugin, send_message_hook + + +class MailMessagePlugin(Plugin): + def __init__(self, config): + super().__init__() + self.server = config['SERVER'] + self.port = config['PORT'] + self.user = config['USER'] + self.password = config['PASSWORD'] + self.crypt = config['CRYPT'] + self.mail = config['MAIL'] + + @send_message_hook + def send_mail(self, msg: Message): + if isinstance(msg.receiver, User): + recipients = [msg.receiver.mail] + else: + recipients = userController.get_user_by_role(msg.receiver) + + mail = MIMEMultipart() + mail['From'] = self.mail + mail['To'] = ", ".join(recipients) + mail['Subject'] = msg.subject + msg.attach(msg.message) + if not self.smtp: + self.__connect() + self.smtp.sendmail(self.mail, recipients, msg.as_string()) + + def __connect(self): + if self.crypt == 'SSL': + self.smtp = smtplib.SMTP_SSL(self.server, self.port) + if self.crypt == 'STARTTLS': + self.smtp = smtplib.SMTP(self.smtpServer, self.port) + self.smtp.starttls() + self.smtp.login(self.user, self.password) diff --git a/flaschengeist/modules/roles/__init__.py b/flaschengeist/modules/roles/__init__.py index b29363f..d6ff05a 100644 --- a/flaschengeist/modules/roles/__init__.py +++ b/flaschengeist/modules/roles/__init__.py @@ -1,16 +1,16 @@ from flask import Blueprint, request, jsonify -from werkzeug.exceptions import NotFound, BadRequest, Forbidden +from werkzeug.exceptions import NotFound, BadRequest from flaschengeist.modules import Plugin from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import roleController roles_bp = Blueprint("roles", __name__) -permissions = {} -def register(): - return Plugin(roles_bp, permissions) +class RolesPlugin(Plugin): + def __init__(self, config): + super().__init__(config, roles_bp) ###################################################### # Routes # @@ -23,66 +23,60 @@ def register(): # DELETE: remove role # ###################################################### + @roles_bp.route("/roles", methods=['POST']) + @login_required() + def add_role(self): + data = request.get_json() + if not data or "name" not in data: + raise BadRequest + if "permissions" in data: + permissions = data["permissions"] + role = roleController.create_role(data["name"], permissions) + return jsonify({"ok": "ok", "id": role.id}) -@roles_bp.route("/roles", methods=['POST']) -@login_required() -def __add_role(): - data = request.get_json() - if not data or "name" not in data: - raise BadRequest - if "permissions" in data: - permissions = data["permissions"] - role = roleController.create_role(data["name"], permissions) - return jsonify({"ok": "ok", "id": role.id}) + @roles_bp.route("/roles", methods=['GET']) + @login_required() + def list_roles(self, **kwargs): + roles = roleController.get_roles() + return jsonify(roles) + @roles_bp.route("/roles/permissions", methods=['GET']) + @login_required() + def list_permissions(self, **kwargs): + permissions = roleController.get_permissions() + return jsonify(permissions) -@roles_bp.route("/roles", methods=['GET']) -@login_required() -def __list_roles(**kwargs): - roles = roleController.get_roles() - return jsonify(roles) - - -@roles_bp.route("/roles/permissions", methods=['GET']) -@login_required() -def __list_permissions(**kwargs): - permissions = roleController.get_permissions() - return jsonify(permissions) - - -@roles_bp.route("/roles/", methods=['GET']) -@login_required() -def __get_role(rid, **kwargs): - role = roleController.get_role(rid) - if role: - return jsonify({ - "id": role.id, - "name": role, - "permissions": role.permissions - }) - raise NotFound - - -@roles_bp.route("/roles/", methods=['PUT']) -@login_required() -def __edit_role(rid, **kwargs): - role = roleController.get_role(rid) - if not role: + @roles_bp.route("/roles/", methods=['GET']) + @login_required() + def __get_role(self, rid, **kwargs): + role = roleController.get_role(rid) + if role: + return jsonify({ + "id": role.id, + "name": role, + "permissions": role.permissions + }) raise NotFound - data = request.get_json() - if 'name' in data: - role.name = data["name"] - if "permissions" in data: - roleController.set_permissions(role, data["permissions"]) - roleController.update_role(role) - return jsonify({"ok": "ok"}) + @roles_bp.route("/roles/", methods=['PUT']) + @login_required() + def __edit_role(self, rid, **kwargs): + role = roleController.get_role(rid) + if not role: + raise NotFound + data = request.get_json() + if 'name' in data: + role.name = data["name"] + if "permissions" in data: + roleController.set_permissions(role, data["permissions"]) + roleController.update_role(role) + return jsonify({"ok": "ok"}) -@roles_bp.route("/roles/", methods=['DELETE']) -@login_required() -def __delete_role(rid, **kwargs): - if not roleController.delete_role(rid): - raise NotFound + @roles_bp.route("/roles/", methods=['DELETE']) + @login_required() + def __delete_role(self, rid, **kwargs): + if not roleController.delete_role(rid): + raise NotFound - return jsonify({"ok": "ok"}) + return jsonify({"ok": "ok"}) diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index 62bf9cd..be51c08 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -1,21 +1,20 @@ from dateutil import parser +from datetime import datetime, timedelta from flask import Blueprint, request, jsonify from werkzeug.exceptions import BadRequest, NotFound -from datetime import datetime, timedelta from flaschengeist.modules import Plugin -from flaschengeist.system.controller import eventController from flaschengeist.system.database import db -from flaschengeist.system.decorator import login_required from flaschengeist.system.models.event import EventKind +from flaschengeist.system.decorator import login_required +from flaschengeist.system.controller import eventController schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") -permissions = {} -def register(): - return Plugin(schedule_bp, permissions) - +class SchedulePlugin(Plugin): + def __init__(self, config): + super().__init__(blueprint=schedule_bp) #################################################################################### # Routes # @@ -38,161 +37,159 @@ def register(): # DELETE: remove user # #################################################################################### + @schedule_bp.route("/events/", methods=['GET']) + @login_required() # roles=['schedule_read']) + def __get_event(self, id, **kwargs): + event = eventController.get_event(id) + if not event: + raise NotFound + return jsonify(event) -@schedule_bp.route("/events/", methods=['GET']) -@login_required() # roles=['schedule_read']) -def __get_event(id, **kwargs): - event = eventController.get_event(id) - if not event: - raise NotFound - return jsonify(event) + @schedule_bp.route("/events", methods=['GET']) + @schedule_bp.route("/events//", methods=['GET']) + @schedule_bp.route("/events///", methods=['GET']) + @login_required() # roles=['schedule_read']) + def __get_events(self, year=datetime.now().year, month=datetime.now().month, day=None, **kwrags): + """Get Event objects for specified date (or month or year), + if nothing set then events for current month are returned - -@schedule_bp.route("/events", methods=['GET']) -@schedule_bp.route("/events//", methods=['GET']) -@schedule_bp.route("/events///", methods=['GET']) -@login_required() # roles=['schedule_read']) -def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, **kwrags): - """Get Event objects for specified date (or month or year), - if nothing set then events for current month are returned - - Args: - year (int, optional): year to query, defaults to current year - month (int, optional): month to query (if set), defaults to current month - day (int, optional): day to query events for (if set) - **kwrags: contains at least access_token (see flaschengeist.decorator) - Returns: - JSON list containing events found - Raises: - BadRequest: If date is invalid - """ - try: - begin = datetime(year=year, month=month, day=1) - if day: - begin += timedelta(days=day - 1) - end = begin + timedelta(days=1) - else: - if month == 12: - end = datetime(year=year + 1, month=1, day=1) + Args: + year (int, optional): year to query, defaults to current year + month (int, optional): month to query (if set), defaults to current month + day (int, optional): day to query events for (if set) + **kwrags: contains at least access_token (see flaschengeist.decorator) + Returns: + JSON list containing events found + Raises: + BadRequest: If date is invalid + """ + try: + begin = datetime(year=year, month=month, day=1) + if day: + begin += timedelta(days=day - 1) + end = begin + timedelta(days=1) else: - end = datetime(year=year, month=month+1, day=1) + if month == 12: + end = datetime(year=year + 1, month=1, day=1) + else: + end = datetime(year=year, month=month+1, day=1) - events = eventController.get_events(begin, end) - return jsonify(events) - except ValueError: - raise BadRequest("Invalid date given") + events = eventController.get_events(begin, end) + return jsonify(events) + except ValueError: + raise BadRequest("Invalid date given") -@schedule_bp.route("/eventKinds", methods=['POST']) -@login_required() -def __new_event_kind(**kwargs): - data = request.get_json() - if "name" not in data: - raise BadRequest - kind = eventController.create_event_kind(data["name"]) - return jsonify({"ok": "ok", "id": kind.id}) + @schedule_bp.route("/eventKinds", methods=['POST']) + @login_required() + def __new_event_kind(self, **kwargs): + data = request.get_json() + if "name" not in data: + raise BadRequest + kind = eventController.create_event_kind(data["name"]) + return jsonify({"ok": "ok", "id": kind.id}) -@schedule_bp.route("/slotKinds", methods=["POST"]) -@login_required() -def __new_slot_kind(**kwargs): - data = request.get_json() - if not data or "name" not in data: - raise BadRequest - kind = eventController.create_job_kind(data["name"]) - return jsonify({"ok": "ok", "id": kind.id}) + @schedule_bp.route("/slotKinds", methods=["POST"]) + @login_required() + def __new_slot_kind(self, **kwargs): + data = request.get_json() + if not data or "name" not in data: + raise BadRequest + kind = eventController.create_job_kind(data["name"]) + return jsonify({"ok": "ok", "id": kind.id}) -@schedule_bp.route("/events", methods=['POST']) -@login_required() -def __new_event(**kwargs): - data = request.get_json() - event = eventController.create_event(begin=parser.isoparse(data["begin"]), - end=parser.isoparse(data["end"]), - description=data["description"], - kind=EventKind.query.get(data["kind"])) - return jsonify({"ok": "ok", "id": event.id}) + @schedule_bp.route("/events", methods=['POST']) + @login_required() + def __new_event(self, **kwargs): + data = request.get_json() + event = eventController.create_event(begin=parser.isoparse(data["begin"]), + end=parser.isoparse(data["end"]), + description=data["description"], + kind=EventKind.query.get(data["kind"])) + return jsonify({"ok": "ok", "id": event.id}) -@schedule_bp.route("/events/", methods=["DELETE"]) -@login_required() -def __delete_event(id, **kwargs): - if not eventController.delete_event(id): - raise NotFound - db.session.commit() - return jsonify({'ok': 'ok'}) + @schedule_bp.route("/events/", methods=["DELETE"]) + @login_required() + def __delete_event(self, id, **kwargs): + if not eventController.delete_event(id): + raise NotFound + db.session.commit() + return jsonify({'ok': 'ok'}) -@schedule_bp.route("/eventKinds/", methods=["PUT"]) -@login_required() -def __edit_event_kind(id, **kwargs): - data = request.get_json() - if not data or "name" not in data: - raise BadRequest - eventController.rename_event_kind(id, data["name"]) - return jsonify({"ok": "ok"}) - - -@schedule_bp.route("/events//slots", methods=["GET"]) -@login_required() -def __get_slots(event_id, **kwargs): - event = eventController.get_event(event_id) - if not event: - raise NotFound - return jsonify({event.slots}) - - -@schedule_bp.route("/events//slots/", methods=["GET"]) -@login_required() -def __get_slot(event_id, slot_id, **kwargs): - slot = eventController.get_event_slot(slot_id, event_id) - if slot: - return jsonify(slot) - raise NotFound - - -@schedule_bp.route("/events//slots/", methods=["DELETE"]) -@login_required() -def __delete_slot(event_id, slot_id, **kwargs): - if eventController.delete_event_slot(slot_id, event_id): + @schedule_bp.route("/eventKinds/", methods=["PUT"]) + @login_required() + def __edit_event_kind(self, id, **kwargs): + data = request.get_json() + if not data or "name" not in data: + raise BadRequest + eventController.rename_event_kind(id, data["name"]) return jsonify({"ok": "ok"}) - raise NotFound -@schedule_bp.route("/events//slots/", methods=["PUT"]) -@login_required() -def __update_slot(event_id, slot_id, **kwargs): - data = request.get_json() - if not data: - raise BadRequest - - for job in data['jobs']: - eventController.add_job(job.kind, job.user) - if eventController.delete_event_slot(slot_id, event_id): - return jsonify({"ok": "ok"}) - raise NotFound + @schedule_bp.route("/events//slots", methods=["GET"]) + @login_required() + def __get_slots(self, event_id, **kwargs): + event = eventController.get_event(event_id) + if not event: + raise NotFound + return jsonify({event.slots}) -@schedule_bp.route("/events//slots", methods=["POST"]) -@login_required() -def __add_slot(event_id, **kwargs): - event = eventController.get_event(event_id) - if not event: + @schedule_bp.route("/events//slots/", methods=["GET"]) + @login_required() + def __get_slot(self, event_id, slot_id, **kwargs): + slot = eventController.get_event_slot(slot_id, event_id) + if slot: + return jsonify(slot) raise NotFound - data = request.get_json() - attr = {"job_slots": []} - try: - if "start" in data: - attr["start"] = parser.isoparse(data["start"]) - if "end" in data: - attr["end"] = parser.isoparse(data["end"]) - for job in data["jobs"]: - attr["job_slots"].append({"needed_persons": job["needed_persons"], "kind": job["kind"]}) - except KeyError: - raise BadRequest("Missing data in request") - eventController.add_slot(event, **attr) - return jsonify({"ok": "ok"}) -def __edit_event(): - ... + @schedule_bp.route("/events//slots/", methods=["DELETE"]) + @login_required() + def __delete_slot(self, event_id, slot_id, **kwargs): + if eventController.delete_event_slot(slot_id, event_id): + return jsonify({"ok": "ok"}) + raise NotFound + + + @schedule_bp.route("/events//slots/", methods=["PUT"]) + @login_required() + def __update_slot(self, event_id, slot_id, **kwargs): + data = request.get_json() + if not data: + raise BadRequest + + for job in data['jobs']: + eventController.add_job(job.kind, job.user) + if eventController.delete_event_slot(slot_id, event_id): + return jsonify({"ok": "ok"}) + raise NotFound + + + @schedule_bp.route("/events//slots", methods=["POST"]) + @login_required() + def __add_slot(self, event_id, **kwargs): + event = eventController.get_event(event_id) + if not event: + raise NotFound + data = request.get_json() + attr = {"job_slots": []} + try: + if "start" in data: + attr["start"] = parser.isoparse(data["start"]) + if "end" in data: + attr["end"] = parser.isoparse(data["end"]) + for job in data["jobs"]: + attr["job_slots"].append({"needed_persons": job["needed_persons"], "kind": job["kind"]}) + except KeyError: + raise BadRequest("Missing data in request") + eventController.add_slot(event, **attr) + return jsonify({"ok": "ok"}) + + + def __edit_event(self): + ... diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index d440750..53d3aef 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -10,8 +10,9 @@ users_bp = Blueprint("users", __name__) permissions = {'EDIT_USER': 'edit_user'} -def register(): - return Plugin(users_bp, permissions) +class UsersPlugin(Plugin): + def __init__(self, config): + super().__init__(blueprint=users_bp, permissions=permissions) ################################################# # Routes # @@ -23,49 +24,45 @@ def register(): # DELETE: remove user # ################################################# + @users_bp.route("/users", methods=['POST']) + def __registration(self): + logger.debug("Register new User...") + return jsonify({"ok": "ok... well not implemented"}) -@users_bp.route("/users", methods=['POST']) -def __registration(): - logger.debug("Register new User...") - return jsonify({"ok": "ok... well not implemented"}) + @users_bp.route("/users", methods=['GET']) + @login_required() + def __list_users(self, **kwargs): + logger.debug("Retrieve list of all users") + users = userController.get_users() + return jsonify(users) - -@users_bp.route("/users", methods=['GET']) -@login_required() -def __list_users(**kwargs): - logger.debug("Retrieve list of all users") - users = userController.get_users() - return jsonify(users) - - -@users_bp.route("/users/", methods=['GET']) -@login_required() -def __get_user(uid, **kwargs): - logger.debug("Get information of user {{ {} }}".format(uid)) - user = userController.get_user(uid) - if user: - return jsonify(user) - raise NotFound - - -@users_bp.route("/users/", methods=['PUT']) -@login_required() -def __edit_user(uid, **kwargs): - logger.debug("Modify information of user {{ {} }}".format(uid)) - user = userController.get_user(uid) - if not user: + @users_bp.route("/users/", methods=['GET']) + @login_required() + def __get_user(self, uid, **kwargs): + logger.debug("Get information of user {{ {} }}".format(uid)) + user = userController.get_user(uid) + if user: + return jsonify(user) raise NotFound - if uid != kwargs['access_token'].user.uid and user.has_permissions(permissions['EDIT_USER']): - return Forbidden + @users_bp.route("/users/", methods=['PUT']) + @login_required() + def __edit_user(self, uid, **kwargs): + logger.debug("Modify information of user {{ {} }}".format(uid)) + user = userController.get_user(uid) + if not user: + raise NotFound - data = request.get_json() - if 'password' not in data: - raise BadRequest("Password is missing") - for key in ["firstname", "lastname", "display_name", "mail"]: - if key in data: - setattr(user, key, data[key]) - new_password = data['new_password'] if 'new_password' in data else None - userController.modify_user(user, data['password'], new_password) - userController.update_user(user) - return jsonify({"ok": "ok"}) + if uid != kwargs['access_token'].user.uid and user.has_permissions(permissions['EDIT_USER']): + return Forbidden + + data = request.get_json() + if 'password' not in data: + raise BadRequest("Password is missing") + for key in ["firstname", "lastname", "display_name", "mail"]: + if key in data: + setattr(user, key, data[key]) + new_password = data['new_password'] if 'new_password' in data else None + userController.modify_user(user, data['password'], new_password) + userController.update_user(user) + return jsonify({"ok": "ok"}) diff --git a/flaschengeist/system/controller/emailController.py b/flaschengeist/system/controller/emailController.py deleted file mode 100644 index 067a34d..0000000 --- a/flaschengeist/system/controller/emailController.py +++ /dev/null @@ -1,119 +0,0 @@ -import smtplib -from datetime import datetime -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.header import Header -from geruecht.logger import getDebugLogger -from . import mailConfig - -debug = getDebugLogger() - -class EmailController(): - - def __init__(self): - debug.info("init email controller") - self.smtpServer = mailConfig['URL'] - self.port = mailConfig['port'] - self.user = mailConfig['user'] - self.passwd = mailConfig['passwd'] - self.crypt = mailConfig['crypt'] - self.email = mailConfig['email'] - - debug.debug("smtpServer is {{ {} }}, port is {{ {} }}, user is {{ {} }}, crypt is {{ {} }}, email is {{ {} }}".format(self.smtpServer, self.port, self.user, self.crypt, self.email)) - - def __connect__(self): - debug.info('connect to email server') - if self.crypt == 'SSL': - self.smtp = smtplib.SMTP_SSL(self.smtpServer, self.port) - log = self.smtp.ehlo() - debug.debug("ehlo is {{ {} }}".format(log)) - if self.crypt == 'STARTTLS': - self.smtp = smtplib.SMTP(self.smtpServer, self.port) - log = self.smtp.ehlo() - debug.debug("ehlo is {{ {} }}".format(log)) - log = self.smtp.starttls() - debug.debug("starttles is {{ {} }}".format(log)) - log = self.smtp.login(self.user, self.passwd) - debug.debug("login is {{ {} }}".format(log)) - - def jobTransact(self, user, jobtransact): - debug.info("create email jobtransact {{ {} }}for user {{ {} }}".format(jobtransact, user)) - date = '{}.{}.{}'.format(jobtransact['on_date']['day'], jobtransact['on_date']['month'], jobtransact['on_date']['year']) - from_user = '{} {}'.format(jobtransact['from_user']['firstname'], jobtransact['from_user']['lastname']) - job_kind = jobtransact['job_kind'] - subject = 'Dienstanfrage am {}'.format(date) - text = MIMEText( - "Hallo {} {},\n" - "{} fragt, ob du am {} den Dienst {} übernehmen willst.\nBeantworte die Anfrage im Userportal von Flaschengeist.".format(user.firstname, user.lastname, from_user, date, job_kind['name']), 'plain') - debug.debug("subject is {{ {} }}, text is {{ {} }}".format(subject, text.as_string())) - return (subject, text) - - def jobInvite(self, user, jobtransact): - debug.info("create email jobtransact {{ {} }}for user {{ {} }}".format(jobtransact, user)) - date = '{}.{}.{}'.format(jobtransact['on_date']['day'], jobtransact['on_date']['month'], jobtransact['on_date']['year']) - from_user = '{} {}'.format(jobtransact['from_user']['firstname'], jobtransact['from_user']['lastname']) - subject = 'Diensteinladung am {}'.format(date) - text = MIMEText( - "Hallo {} {},\n" - "{} fragt, ob du am {} mit Dienst haben willst.\nBeantworte die Anfrage im Userportal von Flaschengeist.".format(user.firstname, user.lastname, from_user, date), 'plain') - debug.debug("subject is {{ {} }}, text is {{ {} }}".format(subject, text.as_string())) - return (subject, text) - - def credit(self, user): - debug.info("create email credit for user {{ {} }}".format(user)) - subject = Header('Gerücht, bezahle deine Schulden!', 'utf-8') - sum = user.getGeruecht(datetime.now().year).getSchulden() - if sum < 0: - type = 'Schulden' - add = 'Bezahle diese umgehend an den Finanzer.' - else: - type = 'Guthaben' - add = '' - text = MIMEText( - "Hallo {} {},\nDu hast {} im Wert von {:.2f} €. {}\n\nDiese Nachricht wurde automatisch erstellt.".format( - user.firstname, user.lastname, type, abs(sum) / 100, add), 'plain') - debug.debug("subject is {{ {} }}, text is {{ {} }}".format(subject, text.as_string())) - return (subject, text) - - def passwordReset(self, user, data): - debug.info("create email passwort reset for user {{ {} }}".format(user)) - subject = Header("Password vergessen") - text = MIMEText( - "Hallo {} {},\nDu hast dein Password vergessen!\nDies wurde nun mit Flaschengeist zurückgesetzt.\nDein neues Passwort lautet:\n{}\n\nBitte ändere es sofort in deinem Flaschengeistprolif in https://flaschengeist.wu5.de.".format( - user.firstname, user.lastname, data['password'] - ), 'plain' - ) - debug.debug("subject is {{ {} }}, text is {{ {} }}".format(subject, text.as_string())) - return (subject, text) - - def sendMail(self, user, type='credit', jobtransact=None, **kwargs): - debug.info("send email to user {{ {} }}".format(user)) - try: - if user.mail == 'None' or not user.mail: - debug.warning("user {{ {} }} has no email-address".format(user)) - raise Exception("no valid Email") - msg = MIMEMultipart() - msg['From'] = self.email - msg['To'] = user.mail - - if type == 'credit': - subject, text = self.credit(user) - elif type == 'jobtransact': - subject, text = self.jobTransact(user, jobtransact) - elif type == 'jobinvite': - subject, text = self.jobInvite(user, jobtransact) - elif type == 'passwordReset': - subject, text = self.passwordReset(user, kwargs) - else: - raise Exception("Fail to send Email. No type is set. user={}, type={} , jobtransact={}".format(user, type, jobtransact)) - - msg['Subject'] = subject - msg.attach(text) - - debug.debug("send email {{ {} }} to user {{ {} }}".format(msg.as_string(), user)) - self.__connect__() - self.smtp.sendmail(self.email, user.mail, msg.as_string()) - return {'error': False, 'user': {'userId': user.uid, 'firstname': user.firstname, 'lastname': user.lastname}} - except Exception: - debug.warning("exception in send email", exc_info=True) - return {'error': True, 'user': {'userId': user.uid, 'firstname': user.firstname, 'lastname': user.lastname}} diff --git a/flaschengeist/system/controller/eventController.py b/flaschengeist/system/controller/eventController.py index 85c1a52..7c38bed 100644 --- a/flaschengeist/system/controller/eventController.py +++ b/flaschengeist/system/controller/eventController.py @@ -1,10 +1,9 @@ from werkzeug.exceptions import BadRequest, NotFound - -from flaschengeist.system.models.event import EventKind, Event, EventSlot, JobSlot, JobKind from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.system.database import db +from flaschengeist.system.models.event import EventKind, Event, EventSlot, JobSlot, JobKind def get_event(id): diff --git a/flaschengeist/system/controller/messageController.py b/flaschengeist/system/controller/messageController.py new file mode 100644 index 0000000..1f064d6 --- /dev/null +++ b/flaschengeist/system/controller/messageController.py @@ -0,0 +1,14 @@ +from flaschengeist.system.models.user import User, Role +from pyhooks import Hook + + +class Message: + def __init__(self, receiver: User or Role, message: str, subject: str): + self.message = message + self.subject = subject + self.receiver = receiver + + +@Hook +def send_message(message: Message): + pass diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index 049e2f4..03720c5 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -1,6 +1,6 @@ from flask import current_app -from flaschengeist.system.models.user import User +from flaschengeist.system.models.user import User, Role from flaschengeist.system.database import db from flaschengeist import logger @@ -44,5 +44,9 @@ def get_users(): return User.query.all() +def get_user_by_role(role: Role): + return User.query.join(User.roles).filter_by(role_id=role.id).all() + + def get_user(uid): return User.query.filter(User.uid == uid).one_or_none() diff --git a/setup.py b/setup.py index 706a52b..1b967fe 100644 --- a/setup.py +++ b/setup.py @@ -18,17 +18,18 @@ setup( "flask_cors", "werkzeug", "bjoern", - "python-dateutil" + "python-dateutil", + "pyhooks" ], extras_require={"ldap": ["flask_ldapconn", "ldap3"]}, entry_points={ "flaschengeist.plugin": [ - "auth = flaschengeist.modules.auth:register", - "users = flaschengeist.modules.users:register", - "roles = flaschengeist.modules.roles:register", - "schedule = flaschengeist.modules.schedule:register", - ], - "flaschengeist.auth": [ + "auth = flaschengeist.modules.auth:AuthRoutePlugin", + "users = flaschengeist.modules.users:UsersPlugin", + "roles = flaschengeist.modules.roles:RolesPlugin", + "schedule = flaschengeist.modules.schedule:SchedulePlugin", + "mail = flaschengeist.modules.message_mail:MailMessagePlugin", + "auth_plain = flaschengeist.modules.auth_plain:AuthPlain", "auth_ldap = flaschengeist.modules.auth_ldap:AuthLDAP [ldap]", ], From 41e60425a96eeddb45f3a9f9bfc5ef491ce8e071 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 15 Oct 2020 22:10:50 +0200 Subject: [PATCH 048/446] Format code with black (line length: 120) https://github.com/psf/black --- flaschengeist/__init__.py | 4 +- flaschengeist/app.py | 21 +++-- flaschengeist/modules/__init__.py | 4 +- flaschengeist/modules/auth/__init__.py | 50 +++++------ flaschengeist/modules/auth_ldap/__init__.py | 84 ++++++++++--------- flaschengeist/modules/auth_plain/__init__.py | 14 ++-- flaschengeist/modules/message_mail.py | 22 ++--- flaschengeist/modules/roles/__init__.py | 40 ++++----- flaschengeist/modules/schedule/__init__.py | 79 ++++++++--------- flaschengeist/modules/users/__init__.py | 36 ++++---- flaschengeist/system/config.py | 42 ++++------ .../controller/accessTokenController.py | 37 ++++---- .../system/controller/eventController.py | 5 +- .../system/controller/userController.py | 6 +- flaschengeist/system/decorator.py | 7 +- flaschengeist/system/models/accessToken.py | 30 +++---- flaschengeist/system/models/event.py | 32 +++---- flaschengeist/system/models/user.py | 74 ++++++++-------- setup.py | 3 +- 19 files changed, 284 insertions(+), 306 deletions(-) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 958aa0f..afbfbdc 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -11,11 +11,11 @@ from pathlib import Path from logging.config import dictConfig from werkzeug.local import LocalProxy -__version__ = pkg_resources.get_distribution('flaschengeist').version +__version__ = pkg_resources.get_distribution("flaschengeist").version _module_path = Path(__file__).parent logger = LocalProxy(lambda: logging.getLogger(__name__)) -with (_module_path / 'logging.yml').open(mode='rb') as file: +with (_module_path / "logging.yml").open(mode="rb") as file: config = yaml.safe_load(file.read()) logging.config.dictConfig(config) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 968e46e..f776167 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -32,31 +32,32 @@ class CustomJSONEncoder(JSONEncoder): def __load_plugins(app): - logger.info('Search for plugins') - app.config['FG_PLUGINS'] = {} - for entry_point in pkg_resources.iter_entry_points('flaschengeist.plugin'): + logger.info("Search for plugins") + app.config["FG_PLUGINS"] = {} + for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"): logger.debug("Found plugin: >{}<".format(entry_point.name)) plugin = None - if config.get(entry_point.name, 'enabled', fallback=False): + if config.get(entry_point.name, "enabled", fallback=False): plugin = entry_point.load()(config[entry_point.name] if config.has_section(entry_point.name) else {}) if plugin.blueprint: app.register_blueprint(plugin.blueprint) logger.info("Load plugin >{}<".format(entry_point.name)) if isinstance(plugin, AuthPlugin): - logger.debug('Found authentication plugin: %s', entry_point.name) - if entry_point.name == config['FLASCHENGEIST']['AUTH']: - app.config['FG_AUTH_BACKEND'] = plugin + logger.debug("Found authentication plugin: %s", entry_point.name) + if entry_point.name == config["FLASCHENGEIST"]["AUTH"]: + app.config["FG_AUTH_BACKEND"] = plugin else: del plugin else: app.config["FG_PLUGINS"][entry_point.name] = plugin - if 'FG_AUTH_BACKEND' not in app.config: - logger.error('No authentication plugin configured or authentication plugin not found') + if "FG_AUTH_BACKEND" not in app.config: + logger.error("No authentication plugin configured or authentication plugin not found") def install_all(): from flaschengeist.system.database import db from flaschengeist.system.models import user, event, accessToken + db.create_all() db.session.commit() for name, plugin in current_app.config["FG_PLUGINS"].items(): @@ -73,6 +74,7 @@ def create_app(): with app.app_context(): from .system.database import db + configure_app(app) db.init_app(app) __load_plugins(app) @@ -80,6 +82,7 @@ def create_app(): @app.route("/", methods=["GET"]) def __get_state(): from . import __version__ as version + return jsonify({"plugins": app.config["FG_PLUGINS"], "version": version}) @app.errorhandler(Exception) diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index c4191be..c594c46 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -9,7 +9,7 @@ class Plugin: self.permissions = permissions def install(self): - """ Installation routine + """Installation routine Is always called with Flask application context """ pass @@ -17,7 +17,7 @@ class Plugin: class AuthPlugin(Plugin): def login(self, user, pw): - """ Login routine, MUST BE IMPLEMENTED! + """Login routine, MUST BE IMPLEMENTED! Args: user: User class containing at least the uid diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index c6e4238..31218a5 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -14,37 +14,37 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import accessTokenController, userController access_controller = LocalProxy(lambda: accessTokenController.AccessTokenController()) -auth_bp = Blueprint('auth', __name__) +auth_bp = Blueprint("auth", __name__) class AuthRoutePlugin(Plugin): def __init__(self, conf): super().__init__(blueprint=auth_bp) -################################################# -# Routes # -# # -# /auth POST: login (new token) # -# GET: get all tokens for user # -# /auth/ GET: get lifetime of token # -# PUT: set new lifetime # -# DELETE: logout / delete token # -################################################# + ################################################# + # Routes # + # # + # /auth POST: login (new token) # + # GET: get all tokens for user # + # /auth/ GET: get lifetime of token # + # PUT: set new lifetime # + # DELETE: logout / delete token # + ################################################# - @auth_bp.route("/auth", methods=['POST']) + @auth_bp.route("/auth", methods=["POST"]) def _create_token(): - """ Login User + """Login User - Login in User and create an AccessToken for the User. - Requires POST data {'userid': string, 'password': string} - Returns: - A JSON-File with user information and created token or errors + Login in User and create an AccessToken for the User. + Requires POST data {'userid': string, 'password': string} + Returns: + A JSON-File with user information and created token or errors """ logger.debug("Start log in.") data = request.get_json() try: - userid = data['userid'] - password = data['password'] + userid = data["userid"] + password = data["password"] except KeyError: raise BadRequest("Missing parameter(s)") @@ -61,15 +61,13 @@ class AuthRoutePlugin(Plugin): access_controller.clear_expired() return jsonify({"user": user, "token": token, "permissions": user.get_permissions()}) - - @auth_bp.route("/auth", methods=['GET']) + @auth_bp.route("/auth", methods=["GET"]) @login_required() def _get_tokens(access_token, **kwargs): tokens = access_controller.get_users_tokens(access_token.user) return jsonify(tokens) - - @auth_bp.route("/auth/", methods=['DELETE']) + @auth_bp.route("/auth/", methods=["DELETE"]) @login_required() def _delete_token(token, access_token, **kwargs): logger.debug("Try to delete access token {{ {} }}".format(token)) @@ -83,8 +81,7 @@ class AuthRoutePlugin(Plugin): access_controller.clear_expired() return jsonify({"ok": "ok"}) - - @auth_bp.route("/auth/", methods=['GET']) + @auth_bp.route("/auth/", methods=["GET"]) @login_required() def _get_token(token, access_token, **kwargs): logger.debug("get token {{ {} }}".format(token)) @@ -95,8 +92,7 @@ class AuthRoutePlugin(Plugin): raise Forbidden return jsonify(token) - - @auth_bp.route("/auth/", methods=['PUT']) + @auth_bp.route("/auth/", methods=["PUT"]) @login_required() def _set_lifetime(token, access_token, **kwargs): token = access_controller.get_token(token, access_token.user) @@ -105,7 +101,7 @@ class AuthRoutePlugin(Plugin): # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden try: - lifetime = request.get_json()['value'] + lifetime = request.get_json()["value"] logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) access_controller.set_lifetime(token, lifetime) return jsonify({"ok": "ok"}) diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index 909ae26..2a97a77 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -14,83 +14,89 @@ class AuthLDAP(AuthPlugin): def __init__(self, config): super().__init__() - defaults = { - 'PORT': '389', - 'USE_SSL': 'False' - } + defaults = {"PORT": "389", "USE_SSL": "False"} for name in defaults: if name not in config: config[name] = defaults[name] app.config.update( - LDAP_SERVER=config['URL'], - LDAP_PORT=config.getint('PORT'), - LDAP_BINDDN=config['BINDDN'], + LDAP_SERVER=config["URL"], + LDAP_PORT=config.getint("PORT"), + LDAP_BINDDN=config["BINDDN"], LDAP_USE_TLS=False, - LDAP_USE_SSL=config.getboolean('USE_SSL'), + LDAP_USE_SSL=config.getboolean("USE_SSL"), LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2, LDAP_REQUIRE_CERT=ssl.CERT_NONE, - FORCE_ATTRIBUTE_VALUE_AS_LIST=True + FORCE_ATTRIBUTE_VALUE_AS_LIST=True, ) - if 'SECRET' in config: - app.config['LDAP_SECRET'] = config['SECRET'], + if "SECRET" in config: + app.config["LDAP_SECRET"] = (config["SECRET"],) self.ldap = LDAPConn(app) - self.dn = config['BASEDN'] + self.dn = config["BASEDN"] def login(self, user, password): if not user: return False - return self.ldap.authenticate(user.uid, password, 'uid', self.dn) + return self.ldap.authenticate(user.uid, password, "uid", self.dn) def update_user(self, user): - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(user.uid), SUBTREE, - attributes=['uid', 'givenName', 'sn', 'mail']) - r = self.ldap.connection.response[0]['attributes'] - if r['uid'][0] == user.uid: - user.set_attribute('DN', self.ldap.connection.response[0]['dn']) - user.firstname = r['givenName'][0] - user.lastname = r['sn'][0] - if r['mail']: - user.mail = r['mail'][0] - if 'displayName' in r: - user.display_name = r['displayName'][0] + self.ldap.connection.search( + "ou=user,{}".format(self.dn), + "(uid={})".format(user.uid), + SUBTREE, + attributes=["uid", "givenName", "sn", "mail"], + ) + r = self.ldap.connection.response[0]["attributes"] + if r["uid"][0] == user.uid: + user.set_attribute("DN", self.ldap.connection.response[0]["dn"]) + user.firstname = r["givenName"][0] + user.lastname = r["sn"][0] + if r["mail"]: + user.mail = r["mail"][0] + if "displayName" in r: + user.display_name = r["displayName"][0] for group in self._get_groups(user.uid): user.add_role(group) def _get_groups(self, uid): groups = [] - self.ldap.connection.search('ou=user,{}'.format(self.dn), '(uid={})'.format(uid), SUBTREE, - attributes=['gidNumber']) - main_group_number = self.ldap.connection.response[0]['attributes']['gidNumber'] + self.ldap.connection.search( + "ou=user,{}".format(self.dn), "(uid={})".format(uid), SUBTREE, attributes=["gidNumber"] + ) + main_group_number = self.ldap.connection.response[0]["attributes"]["gidNumber"] if main_group_number: if type(main_group_number) is list: main_group_number = main_group_number[0] - self.ldap.connection.search('ou=group,{}'.format(self.dn), '(gidNumber={})'.format(main_group_number), - attributes=['cn']) - groups.append(self.ldap.connection.response[0]['attributes']['cn'][0]) + self.ldap.connection.search( + "ou=group,{}".format(self.dn), "(gidNumber={})".format(main_group_number), attributes=["cn"] + ) + groups.append(self.ldap.connection.response[0]["attributes"]["cn"][0]) - self.ldap.connection.search('ou=group,{}'.format(self.dn), '(memberUID={})'.format(uid), SUBTREE, - attributes=['cn']) + self.ldap.connection.search( + "ou=group,{}".format(self.dn), "(memberUID={})".format(uid), SUBTREE, attributes=["cn"] + ) groups_data = self.ldap.connection.response for data in groups_data: - groups.append(data['attributes']['cn'][0]) + groups.append(data["attributes"]["cn"][0]) return groups def modify_user(self, user: User, password, new_password=None): try: - dn = user.attributes['DN'].value + dn = user.attributes["DN"].value ldap_conn = self.ldap.connect(dn, password) modifier = {} - for name, ldap_name in [("firstname", "givenName"), - ("lastname", "sn"), - ("mail", "mail"), - ("display_name", "displayName")]: + for name, ldap_name in [ + ("firstname", "givenName"), + ("lastname", "sn"), + ("mail", "mail"), + ("display_name", "displayName"), + ]: if getattr(user, name): modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] if new_password: salted_password = hashed(HASHED_SALTED_SHA512, new_password) - modifier['userPassword'] = [(MODIFY_REPLACE, [salted_password])] + modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])] ldap_conn.modify(dn, modifier) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/modules/auth_plain/__init__.py index 12ee1da..872d723 100644 --- a/flaschengeist/modules/auth_plain/__init__.py +++ b/flaschengeist/modules/auth_plain/__init__.py @@ -7,24 +7,24 @@ from flaschengeist.system.models.user import User def _hash_password(password): - salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') - pass_hash = hashlib.pbkdf2_hmac('sha3-512', password.encode('utf-8'), salt, 100000) + salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii") + pass_hash = hashlib.pbkdf2_hmac("sha3-512", password.encode("utf-8"), salt, 100000) pass_hash = binascii.hexlify(pass_hash) - return (salt + pass_hash).decode('ascii') + return (salt + pass_hash).decode("ascii") def _verify_password(stored_password, provided_password): salt = stored_password[:64] stored_password = stored_password[64:] - pass_hash = hashlib.pbkdf2_hmac('sha3-512', provided_password.encode('utf-8'), salt.encode('ascii'), 100000) - pass_hash = binascii.hexlify(pass_hash).decode('ascii') + pass_hash = hashlib.pbkdf2_hmac("sha3-512", provided_password.encode("utf-8"), salt.encode("ascii"), 100000) + pass_hash = binascii.hexlify(pass_hash).decode("ascii") return pass_hash == stored_password class AuthPlain(modules.Auth): def login(self, user: User, password: str): - if user and 'password' in user.attributes: - return _verify_password(user.attributes['password'].value, password) + if user and "password" in user.attributes: + return _verify_password(user.attributes["password"].value, password) return False def modify_user(self, user, password, new_password=None): diff --git a/flaschengeist/modules/message_mail.py b/flaschengeist/modules/message_mail.py index ead63ec..a9fd9e9 100644 --- a/flaschengeist/modules/message_mail.py +++ b/flaschengeist/modules/message_mail.py @@ -11,12 +11,12 @@ from . import Plugin, send_message_hook class MailMessagePlugin(Plugin): def __init__(self, config): super().__init__() - self.server = config['SERVER'] - self.port = config['PORT'] - self.user = config['USER'] - self.password = config['PASSWORD'] - self.crypt = config['CRYPT'] - self.mail = config['MAIL'] + self.server = config["SERVER"] + self.port = config["PORT"] + self.user = config["USER"] + self.password = config["PASSWORD"] + self.crypt = config["CRYPT"] + self.mail = config["MAIL"] @send_message_hook def send_mail(self, msg: Message): @@ -26,18 +26,18 @@ class MailMessagePlugin(Plugin): recipients = userController.get_user_by_role(msg.receiver) mail = MIMEMultipart() - mail['From'] = self.mail - mail['To'] = ", ".join(recipients) - mail['Subject'] = msg.subject + mail["From"] = self.mail + mail["To"] = ", ".join(recipients) + mail["Subject"] = msg.subject msg.attach(msg.message) if not self.smtp: self.__connect() self.smtp.sendmail(self.mail, recipients, msg.as_string()) def __connect(self): - if self.crypt == 'SSL': + if self.crypt == "SSL": self.smtp = smtplib.SMTP_SSL(self.server, self.port) - if self.crypt == 'STARTTLS': + if self.crypt == "STARTTLS": self.smtp = smtplib.SMTP(self.smtpServer, self.port) self.smtp.starttls() self.smtp.login(self.user, self.password) diff --git a/flaschengeist/modules/roles/__init__.py b/flaschengeist/modules/roles/__init__.py index d6ff05a..ec93bdf 100644 --- a/flaschengeist/modules/roles/__init__.py +++ b/flaschengeist/modules/roles/__init__.py @@ -12,18 +12,18 @@ class RolesPlugin(Plugin): def __init__(self, config): super().__init__(config, roles_bp) -###################################################### -# Routes # -# # -# /roles POST: register new # -# GET: get all roles # -# /roles/permissions GET: get all permissions # -# /roles/ GET: get role with rid # -# PUT: modify role / permission # -# DELETE: remove role # -###################################################### + ###################################################### + # Routes # + # # + # /roles POST: register new # + # GET: get all roles # + # /roles/permissions GET: get all permissions # + # /roles/ GET: get role with rid # + # PUT: modify role / permission # + # DELETE: remove role # + ###################################################### - @roles_bp.route("/roles", methods=['POST']) + @roles_bp.route("/roles", methods=["POST"]) @login_required() def add_role(self): data = request.get_json() @@ -34,31 +34,27 @@ class RolesPlugin(Plugin): role = roleController.create_role(data["name"], permissions) return jsonify({"ok": "ok", "id": role.id}) - @roles_bp.route("/roles", methods=['GET']) + @roles_bp.route("/roles", methods=["GET"]) @login_required() def list_roles(self, **kwargs): roles = roleController.get_roles() return jsonify(roles) - @roles_bp.route("/roles/permissions", methods=['GET']) + @roles_bp.route("/roles/permissions", methods=["GET"]) @login_required() def list_permissions(self, **kwargs): permissions = roleController.get_permissions() return jsonify(permissions) - @roles_bp.route("/roles/", methods=['GET']) + @roles_bp.route("/roles/", methods=["GET"]) @login_required() def __get_role(self, rid, **kwargs): role = roleController.get_role(rid) if role: - return jsonify({ - "id": role.id, - "name": role, - "permissions": role.permissions - }) + return jsonify({"id": role.id, "name": role, "permissions": role.permissions}) raise NotFound - @roles_bp.route("/roles/", methods=['PUT']) + @roles_bp.route("/roles/", methods=["PUT"]) @login_required() def __edit_role(self, rid, **kwargs): role = roleController.get_role(rid) @@ -66,14 +62,14 @@ class RolesPlugin(Plugin): raise NotFound data = request.get_json() - if 'name' in data: + if "name" in data: role.name = data["name"] if "permissions" in data: roleController.set_permissions(role, data["permissions"]) roleController.update_role(role) return jsonify({"ok": "ok"}) - @roles_bp.route("/roles/", methods=['DELETE']) + @roles_bp.route("/roles/", methods=["DELETE"]) @login_required() def __delete_role(self, rid, **kwargs): if not roleController.delete_role(rid): diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index be51c08..0805329 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -16,28 +16,28 @@ class SchedulePlugin(Plugin): def __init__(self, config): super().__init__(blueprint=schedule_bp) -#################################################################################### -# Routes # -# # -# /schedule/events POST: create new # -# GET: get all events this month # -# /schedule/events// GET: get events by month / date # -# /// # -# /schedule/events/ GET: get event by ID # -# DELETE: delete specified event # -# PUT: modify specified event # -# /schedule/events//slots GET: get EventSlots of Event # -# POST: add new EventSlot to Event # -# /schedule/events//slots/ PUT: modify EventSlot # -# GET: get specified EventSlot # -# /schedule/events//slots//jobs POST: add User # -# /schedule/eventKinds -# /schedule/eventKinds/ -# PUT: modify user # -# DELETE: remove user # -#################################################################################### + #################################################################################### + # Routes # + # # + # /schedule/events POST: create new # + # GET: get all events this month # + # /schedule/events// GET: get events by month / date # + # /// # + # /schedule/events/ GET: get event by ID # + # DELETE: delete specified event # + # PUT: modify specified event # + # /schedule/events//slots GET: get EventSlots of Event # + # POST: add new EventSlot to Event # + # /schedule/events//slots/ PUT: modify EventSlot # + # GET: get specified EventSlot # + # /schedule/events//slots//jobs POST: add User # + # /schedule/eventKinds + # /schedule/eventKinds/ + # PUT: modify user # + # DELETE: remove user # + #################################################################################### - @schedule_bp.route("/events/", methods=['GET']) + @schedule_bp.route("/events/", methods=["GET"]) @login_required() # roles=['schedule_read']) def __get_event(self, id, **kwargs): event = eventController.get_event(id) @@ -45,9 +45,9 @@ class SchedulePlugin(Plugin): raise NotFound return jsonify(event) - @schedule_bp.route("/events", methods=['GET']) - @schedule_bp.route("/events//", methods=['GET']) - @schedule_bp.route("/events///", methods=['GET']) + @schedule_bp.route("/events", methods=["GET"]) + @schedule_bp.route("/events//", methods=["GET"]) + @schedule_bp.route("/events///", methods=["GET"]) @login_required() # roles=['schedule_read']) def __get_events(self, year=datetime.now().year, month=datetime.now().month, day=None, **kwrags): """Get Event objects for specified date (or month or year), @@ -72,15 +72,14 @@ class SchedulePlugin(Plugin): if month == 12: end = datetime(year=year + 1, month=1, day=1) else: - end = datetime(year=year, month=month+1, day=1) + end = datetime(year=year, month=month + 1, day=1) events = eventController.get_events(begin, end) return jsonify(events) except ValueError: raise BadRequest("Invalid date given") - - @schedule_bp.route("/eventKinds", methods=['POST']) + @schedule_bp.route("/eventKinds", methods=["POST"]) @login_required() def __new_event_kind(self, **kwargs): data = request.get_json() @@ -89,7 +88,6 @@ class SchedulePlugin(Plugin): kind = eventController.create_event_kind(data["name"]) return jsonify({"ok": "ok", "id": kind.id}) - @schedule_bp.route("/slotKinds", methods=["POST"]) @login_required() def __new_slot_kind(self, **kwargs): @@ -99,26 +97,25 @@ class SchedulePlugin(Plugin): kind = eventController.create_job_kind(data["name"]) return jsonify({"ok": "ok", "id": kind.id}) - - @schedule_bp.route("/events", methods=['POST']) + @schedule_bp.route("/events", methods=["POST"]) @login_required() def __new_event(self, **kwargs): data = request.get_json() - event = eventController.create_event(begin=parser.isoparse(data["begin"]), - end=parser.isoparse(data["end"]), - description=data["description"], - kind=EventKind.query.get(data["kind"])) + event = eventController.create_event( + begin=parser.isoparse(data["begin"]), + end=parser.isoparse(data["end"]), + description=data["description"], + kind=EventKind.query.get(data["kind"]), + ) return jsonify({"ok": "ok", "id": event.id}) - @schedule_bp.route("/events/", methods=["DELETE"]) @login_required() def __delete_event(self, id, **kwargs): if not eventController.delete_event(id): raise NotFound db.session.commit() - return jsonify({'ok': 'ok'}) - + return jsonify({"ok": "ok"}) @schedule_bp.route("/eventKinds/", methods=["PUT"]) @login_required() @@ -129,7 +126,6 @@ class SchedulePlugin(Plugin): eventController.rename_event_kind(id, data["name"]) return jsonify({"ok": "ok"}) - @schedule_bp.route("/events//slots", methods=["GET"]) @login_required() def __get_slots(self, event_id, **kwargs): @@ -138,7 +134,6 @@ class SchedulePlugin(Plugin): raise NotFound return jsonify({event.slots}) - @schedule_bp.route("/events//slots/", methods=["GET"]) @login_required() def __get_slot(self, event_id, slot_id, **kwargs): @@ -147,7 +142,6 @@ class SchedulePlugin(Plugin): return jsonify(slot) raise NotFound - @schedule_bp.route("/events//slots/", methods=["DELETE"]) @login_required() def __delete_slot(self, event_id, slot_id, **kwargs): @@ -155,7 +149,6 @@ class SchedulePlugin(Plugin): return jsonify({"ok": "ok"}) raise NotFound - @schedule_bp.route("/events//slots/", methods=["PUT"]) @login_required() def __update_slot(self, event_id, slot_id, **kwargs): @@ -163,13 +156,12 @@ class SchedulePlugin(Plugin): if not data: raise BadRequest - for job in data['jobs']: + for job in data["jobs"]: eventController.add_job(job.kind, job.user) if eventController.delete_event_slot(slot_id, event_id): return jsonify({"ok": "ok"}) raise NotFound - @schedule_bp.route("/events//slots", methods=["POST"]) @login_required() def __add_slot(self, event_id, **kwargs): @@ -190,6 +182,5 @@ class SchedulePlugin(Plugin): eventController.add_slot(event, **attr) return jsonify({"ok": "ok"}) - def __edit_event(self): ... diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index 53d3aef..5b9fdd4 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -7,36 +7,36 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import userController users_bp = Blueprint("users", __name__) -permissions = {'EDIT_USER': 'edit_user'} +permissions = {"EDIT_USER": "edit_user"} class UsersPlugin(Plugin): def __init__(self, config): super().__init__(blueprint=users_bp, permissions=permissions) -################################################# -# Routes # -# # -# /users POST: register new # -# GET: get all users # -# /users/ GET: get user with uid # -# PUT: modify user # -# DELETE: remove user # -################################################# + ################################################# + # Routes # + # # + # /users POST: register new # + # GET: get all users # + # /users/ GET: get user with uid # + # PUT: modify user # + # DELETE: remove user # + ################################################# - @users_bp.route("/users", methods=['POST']) + @users_bp.route("/users", methods=["POST"]) def __registration(self): logger.debug("Register new User...") return jsonify({"ok": "ok... well not implemented"}) - @users_bp.route("/users", methods=['GET']) + @users_bp.route("/users", methods=["GET"]) @login_required() def __list_users(self, **kwargs): logger.debug("Retrieve list of all users") users = userController.get_users() return jsonify(users) - @users_bp.route("/users/", methods=['GET']) + @users_bp.route("/users/", methods=["GET"]) @login_required() def __get_user(self, uid, **kwargs): logger.debug("Get information of user {{ {} }}".format(uid)) @@ -45,7 +45,7 @@ class UsersPlugin(Plugin): return jsonify(user) raise NotFound - @users_bp.route("/users/", methods=['PUT']) + @users_bp.route("/users/", methods=["PUT"]) @login_required() def __edit_user(self, uid, **kwargs): logger.debug("Modify information of user {{ {} }}".format(uid)) @@ -53,16 +53,16 @@ class UsersPlugin(Plugin): if not user: raise NotFound - if uid != kwargs['access_token'].user.uid and user.has_permissions(permissions['EDIT_USER']): + if uid != kwargs["access_token"].user.uid and user.has_permissions(permissions["EDIT_USER"]): return Forbidden data = request.get_json() - if 'password' not in data: + if "password" not in data: raise BadRequest("Password is missing") for key in ["firstname", "lastname", "display_name", "mail"]: if key in data: setattr(user, key, data[key]) - new_password = data['new_password'] if 'new_password' in data else None - userController.modify_user(user, data['password'], new_password) + new_password = data["new_password"] if "new_password" in data else None + userController.modify_user(user, data["password"], new_password) userController.update_user(user) return jsonify({"ok": "ok"}) diff --git a/flaschengeist/system/config.py b/flaschengeist/system/config.py index 479b908..d4b9978 100644 --- a/flaschengeist/system/config.py +++ b/flaschengeist/system/config.py @@ -4,51 +4,37 @@ from pathlib import Path from werkzeug.middleware.proxy_fix import ProxyFix from .. import _module_path, logger -default = { - 'MAIL': { - 'CRYPT': 'SSL/STARTLS' - } -} +default = {"MAIL": {"CRYPT": "SSL/STARTLS"}} config = configparser.ConfigParser() config.read_dict(default) -paths = [_module_path, Path.home()/".config"] -if 'FLASCHENGEIST_CONF' in os.environ: +paths = [_module_path, Path.home() / ".config"] +if "FLASCHENGEIST_CONF" in os.environ: paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) for loc in paths: try: - with (loc/"flaschengeist.cfg").open() as source: + with (loc / "flaschengeist.cfg").open() as source: logger.info("Reading config file from >{}<".format(loc)) config.read_file(source) except IOError: pass # Always enable this builtin plugins! -config.read_dict({ - 'auth': { - 'enabled': True - }, - 'roles': { - 'enabled': True - }, - 'users': { - 'enabled': True - } -}) +config.read_dict({"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) def configure_app(app): - if not config.has_option('FLASCHENGEIST', 'SECRET_KEY'): - logger.warn('No secret key was configured, please configure one for production systems!') - app.config['SECRET_KEY'] = config.get('FLASCHENGEIST', 'SECRET_KEY', fallback='0a657b97ef546da90b2db91862ad4e29') + if not config.has_option("FLASCHENGEIST", "SECRET_KEY"): + logger.warn("No secret key was configured, please configure one for production systems!") + app.config["SECRET_KEY"] = config.get("FLASCHENGEIST", "SECRET_KEY", fallback="0a657b97ef546da90b2db91862ad4e29") - app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://{user}:{passwd}@{host}/{database}'.format( - user=config['DATABASE']['USER'], - passwd=config['DATABASE']['PASSWORD'], - host=config['DATABASE']['HOST'], - database=config['DATABASE']['DATABASE'] + app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://{user}:{passwd}@{host}/{database}".format( + user=config["DATABASE"]["USER"], + passwd=config["DATABASE"]["PASSWORD"], + host=config["DATABASE"]["HOST"], + database=config["DATABASE"]["DATABASE"], ) - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False if config.has_option("FLASCHENGEIST", "ROOT"): logger.debug("Setting application root to >{}<".format(config["FLASCHENGEIST"]["ROOT"])) diff --git a/flaschengeist/system/controller/accessTokenController.py b/flaschengeist/system/controller/accessTokenController.py index b82a2a3..17fe644 100644 --- a/flaschengeist/system/controller/accessTokenController.py +++ b/flaschengeist/system/controller/accessTokenController.py @@ -8,37 +8,37 @@ from . import Singleton class AccessTokenController(metaclass=Singleton): - """ Control all created AccessToken + """Control all created AccessToken - This Class create, delete, find and manage AccessToken. + This Class create, delete, find and manage AccessToken. - Attributes: - lifetime: Variable for the Lifetime of one AccessToken in seconds. + Attributes: + lifetime: Variable for the Lifetime of one AccessToken in seconds. """ def __init__(self, lifetime=1800): self.lifetime = lifetime def validate_token(self, token, user_agent, permissions): - """ Verify access token + """Verify access token - Verify an AccessToken and Roles so if the User has permission or not. - Retrieves the access token if valid else retrieves False + Verify an AccessToken and Roles so if the User has permission or not. + Retrieves the access token if valid else retrieves False - Args: - token: Token to verify. - user_agent: User agent of browser to check - permissions: Permissions needed to access restricted routes - Returns: - An the AccessToken for this given Token or False. + Args: + token: Token to verify. + user_agent: User agent of browser to check + permissions: Permissions needed to access restricted routes + Returns: + An the AccessToken for this given Token or False. """ logger.debug("check token {{ {} }} is valid".format(token)) access_token = AccessToken.query.filter_by(token=token).one_or_none() if access_token: logger.debug("token found, check if expired or invalid user agent differs") if access_token.expires >= datetime.utcnow() and ( - access_token.browser == user_agent.browser and - access_token.platform == user_agent.platform): + access_token.browser == user_agent.browser and access_token.platform == user_agent.platform + ): if not permissions or access_token.user.has_permissions(permissions): access_token.refresh() db.session.commit() @@ -50,7 +50,7 @@ class AccessTokenController(metaclass=Singleton): return False def create(self, user, user_agent=None) -> AccessToken: - """ Create an AccessToken + """Create an AccessToken Args: user: For which User is to create an AccessToken @@ -61,8 +61,9 @@ class AccessTokenController(metaclass=Singleton): """ logger.debug("create access token") token_str = secrets.token_hex(16) - token = AccessToken(token=token_str, user=user, lifetime=self.lifetime, - browser=user_agent.browser, platform=user_agent.platform) + token = AccessToken( + token=token_str, user=user, lifetime=self.lifetime, browser=user_agent.browser, platform=user_agent.platform + ) token.refresh() db.session.add(token) db.session.commit() diff --git a/flaschengeist/system/controller/eventController.py b/flaschengeist/system/controller/eventController.py index 7c38bed..0fa9e17 100644 --- a/flaschengeist/system/controller/eventController.py +++ b/flaschengeist/system/controller/eventController.py @@ -33,10 +33,7 @@ def delete_event(id): def create_event(begin, kind, end=None, description=None): try: - event = Event(begin=begin, - end=end, - description=description, - kind=kind) + event = Event(begin=begin, end=end, description=description, kind=kind) db.session.add(event) db.session.commit() return event diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index 03720c5..f986534 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -11,14 +11,14 @@ def login_user(username, password): if user is None: user = User(uid=username) db.session.add(user) - if current_app.config['FG_AUTH_BACKEND'].login(user, password): + if current_app.config["FG_AUTH_BACKEND"].login(user, password): update_user(user) return user return None def update_user(user): - current_app.config['FG_AUTH_BACKEND'].update_user(user) + current_app.config["FG_AUTH_BACKEND"].update_user(user) db.session.commit() @@ -37,7 +37,7 @@ def modify_user(user, password, new_password=None): NotImplemented: If backend is not capable of this operation BadRequest: Password is wrong or other logic issues """ - current_app.config['FG_AUTH_BACKEND'].modify_user(user, password, new_password) + current_app.config["FG_AUTH_BACKEND"].modify_user(user, password, new_password) def get_users(): diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index cb71ce1..9a8a6f4 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -7,6 +7,7 @@ from flaschengeist import logger def login_required(**kwargs): from .controller.accessTokenController import AccessTokenController + ac_controller = AccessTokenController() permissions = None if "permissions" in kwargs: @@ -15,14 +16,16 @@ def login_required(**kwargs): def real_decorator(func): @wraps(func) def wrapper(*args, **kwargs): - token = request.headers.get('Token') + token = request.headers.get("Token") access_token = ac_controller.validate_token(token, request.user_agent, permissions) if access_token: - kwargs['access_token'] = access_token + kwargs["access_token"] = access_token logger.debug("token {{ {} }} is valid".format(token)) return func(*args, **kwargs) else: logger.info("token {{ {} }} is not valid".format(token)) raise Unauthorized + return wrapper + return real_decorator diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/accessToken.py index 62149f8..f94212c 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -5,16 +5,17 @@ from flaschengeist import logger class AccessToken(db.Model): - """ Model for an AccessToken + """Model for an AccessToken - Args: - expires: Is a Datetime from current Time. - user: Is an User. - token: String to verify access later. + Args: + expires: Is a Datetime from current Time. + user: Is an User. + token: String to verify access later. """ - __tablename__ = 'session' + + __tablename__ = "session" id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + user_id = db.Column(db.Integer, db.ForeignKey("user.id")) user = db.relationship("User", back_populates="sessions") expires = db.Column(db.DateTime) @@ -24,25 +25,25 @@ class AccessToken(db.Model): platform = db.Column(db.String(30)) def refresh(self): - """ Update the Timestamp + """Update the Timestamp - Update the Timestamp to the current Time. + Update the Timestamp to the current Time. """ logger.debug("update timestamp from access token {{ {} }}".format(self)) self.expires = datetime.utcnow() + timedelta(seconds=self.lifetime) def serialize(self): - """ Create Dic to dump in JSON + """Create Dic to dump in JSON - Returns: - A Dic with static Attributes. + Returns: + A Dic with static Attributes. """ return { "token": self.token, "expires": self.expires.replace(tzinfo=timezone.utc), "lifetime": self.lifetime, "browser": self.browser, - "platform": self.platform + "platform": self.platform, } def __eq__(self, token): @@ -50,4 +51,5 @@ class AccessToken(db.Model): def __str__(self): return "AccessToken(user={}, token={}, expires={}, lifetime={})".format( - self.user, self.token, self.expires, self.lifetime) + self.user, self.token, self.expires, self.lifetime + ) diff --git a/flaschengeist/system/models/event.py b/flaschengeist/system/models/event.py index a81f38f..09ae7e9 100644 --- a/flaschengeist/system/models/event.py +++ b/flaschengeist/system/models/event.py @@ -3,46 +3,40 @@ from ..database import db class Event(db.Model): """Model for an Event""" - __tablename__ = 'event' + + __tablename__ = "event" id = db.Column(db.Integer, primary_key=True) begin = db.Column(db.DateTime, nullable=False) end = db.Column(db.DateTime) description = db.Column(db.String(240)) - kind_id = db.Column(db.Integer, db.ForeignKey('event_kind.id', ondelete="CASCADE"), nullable=False) + kind_id = db.Column(db.Integer, db.ForeignKey("event_kind.id", ondelete="CASCADE"), nullable=False) kind = db.relationship("EventKind") slots = db.relationship("EventSlot", back_populates="event", cascade="all, delete") - #notices = db.relationship("EventNotice", back_populates="event") + # notices = db.relationship("EventNotice", back_populates="event") def serialize(self): - return { - "id": self.id, - "begin": self.begin, - "end": self.end, - "description": self.description, - "kind": self.kind - } + return {"id": self.id, "begin": self.begin, "end": self.end, "description": self.description, "kind": self.kind} class EventKind(db.Model): """Model for an EventKind""" + __tablename__ = "event_kind" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(30), nullable=False, unique=True) def serialize(self): - return { - "id": self.id, - "name": self.name - } + return {"id": self.id, "name": self.name} class EventSlot(db.Model): """Model for an EventSlot""" + __tablename__ = "event_slot" id = db.Column(db.Integer, primary_key=True) start = db.Column(db.DateTime) end = db.Column(db.DateTime) - event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + event_id = db.Column(db.Integer, db.ForeignKey("event.id"), nullable=False) event = db.relationship("Event", back_populates="slots") slots = db.relationship("JobSlot", back_populates="event_slot") @@ -59,9 +53,9 @@ class JobSlot(db.Model): __tablename__ = "job_slot" id = db.Column(db.Integer, primary_key=True) needed_persons = db.Column(db.Numeric(precision=4, scale=2)) - event_slot_id = db.Column(db.Integer, db.ForeignKey('event_slot.id')) + event_slot_id = db.Column(db.Integer, db.ForeignKey("event_slot.id")) event_slot = db.relationship("EventSlot", back_populates="slots") - kind_id = db.Column(db.Integer, db.ForeignKey('job_kind.id')) + kind_id = db.Column(db.Integer, db.ForeignKey("job_kind.id")) kind = db.relationship("JobKind") jobs = db.relationship("Job", back_populates="slot") @@ -70,9 +64,9 @@ class Job(db.Model): __tablename__ = "job" id = db.Column(db.Integer, primary_key=True) value = db.Column(db.Numeric(precision=3, scale=2)) - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + user_id = db.Column(db.Integer, db.ForeignKey("user.id")) user = db.relationship("User") - slot_id = db.Column(db.Integer, db.ForeignKey('job_slot.id')) + slot_id = db.Column(db.Integer, db.ForeignKey("job_slot.id")) slot = db.relationship("JobSlot") diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index bc2fc7c..522611c 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -5,26 +5,28 @@ from werkzeug.local import LocalProxy logger = LocalProxy(lambda: current_app.logger) -association_table = db.Table('user_x_role', - db.Column('user_id', db.Integer, db.ForeignKey('user.id')), - db.Column('role_id', db.Integer, db.ForeignKey('role.id')) - ) +association_table = db.Table( + "user_x_role", + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("role_id", db.Integer, db.ForeignKey("role.id")), +) class User(db.Model): - """ Database Object for User + """Database Object for User - Table for all saved User + Table for all saved User - Attributes: - id: Id in Database as Primary Key. - uid: User ID used by authentication provider - display_name: Name to show - firstname: Firstname of the User - lastname: Lastname of the User - mail: mail address of the User + Attributes: + id: Id in Database as Primary Key. + uid: User ID used by authentication provider + display_name: Name to show + firstname: Firstname of the User + lastname: Lastname of the User + mail: mail address of the User """ - __tablename__ = 'user' + + __tablename__ = "user" id = db.Column(db.Integer, primary_key=True) uid = db.Column(db.String(30)) display_name = db.Column(db.String(30)) @@ -33,8 +35,9 @@ class User(db.Model): mail = db.Column(db.String(30)) roles = db.relationship("Role", secondary=association_table) sessions = db.relationship("AccessToken", back_populates="user") - attributes = db.relationship("UserAttribute", collection_class=attribute_mapped_collection('name'), - cascade="all, delete") + attributes = db.relationship( + "UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete" + ) def set_attribute(self, name, value): if name in self.attributes: @@ -50,16 +53,16 @@ class User(db.Model): def update_data(self, data): logger.debug("update data of user") - if 'uid' in data: - self.uid = data['uid'] - if 'firstname' in data: - self.firstname = data['firstname'] - if 'lastname' in data: - self.lastname = data['lastname'] - if 'mail' in data: - self.mail = data['mail'] - if 'display_name' in data: - self.display_name = data['display_name'] + if "uid" in data: + self.uid = data["uid"] + if "firstname" in data: + self.firstname = data["firstname"] + if "lastname" in data: + self.lastname = data["lastname"] + if "mail" in data: + self.mail = data["mail"] + if "display_name" in data: + self.display_name = data["display_name"] def get_permissions(self): return ["user"] + [permission.name for role in self.roles for permission in role.permissions] @@ -78,26 +81,27 @@ class User(db.Model): "firstname": self.firstname, "lastname": self.lastname, "mail": self.mail, - "roles": [r.name for r in self.roles] + "roles": [r.name for r in self.roles], } class UserAttribute(db.Model): - __tablename__ = 'user_attribute' + __tablename__ = "user_attribute" id = db.Column(db.Integer, primary_key=True) - user = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + user = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) name = db.Column(db.String(30)) value = db.Column(db.String(192)) -role_permission_association_table = db.Table('role_x_permission', - db.Column('role_id', db.Integer, db.ForeignKey('role.id')), - db.Column('permission_id', db.Integer, db.ForeignKey('permission.id')) - ) +role_permission_association_table = db.Table( + "role_x_permission", + db.Column("role_id", db.Integer, db.ForeignKey("role.id")), + db.Column("permission_id", db.Integer, db.ForeignKey("permission.id")), +) class Role(db.Model): - __tablename__ = 'role' + __tablename__ = "role" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(30), unique=True) permissions = db.relationship("Permission", secondary=role_permission_association_table, cascade="all, delete") @@ -107,7 +111,7 @@ class Role(db.Model): class Permission(db.Model): - __tablename__ = 'permission' + __tablename__ = "permission" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(30), unique=True) diff --git a/setup.py b/setup.py index 1b967fe..c0923cf 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ setup( "werkzeug", "bjoern", "python-dateutil", - "pyhooks" + "pyhooks", ], extras_require={"ldap": ["flask_ldapconn", "ldap3"]}, entry_points={ @@ -29,7 +29,6 @@ setup( "roles = flaschengeist.modules.roles:RolesPlugin", "schedule = flaschengeist.modules.schedule:SchedulePlugin", "mail = flaschengeist.modules.message_mail:MailMessagePlugin", - "auth_plain = flaschengeist.modules.auth_plain:AuthPlain", "auth_ldap = flaschengeist.modules.auth_ldap:AuthLDAP [ldap]", ], From 287cc91947ba402fa5d1dc058053668657f62228 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 15 Oct 2020 22:16:26 +0200 Subject: [PATCH 049/446] Added comment about git blame --- .git-blame-ignore-revs | 2 ++ flaschengeist/readme.md | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..2ee1cc3 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Migrate code style to Black +41e60425a96eeddb45f3a9f9bfc5ef491ce8e071 diff --git a/flaschengeist/readme.md b/flaschengeist/readme.md index 395a49b..d9a2434 100644 --- a/flaschengeist/readme.md +++ b/flaschengeist/readme.md @@ -1,6 +1,5 @@ # Flaschengeist - ## Installation ### Requirements - mysql or mariadb @@ -28,6 +27,16 @@ or with debug messages: run_flaschengeist --debug +## Development +### Misc +When using `git blame` use this to ignore the code formatting commits: + + $ git blame FILE.py --ignore-revs-file .git-blame-ignore-revs +Or if you just want to use `git blame`, configure git like this: + + $ git config blame.ignoreRevsFile .git-blame-ignore-revs + + ## Plugin Development ### File Structure flaschengeist-example-plugin From ec05cde746d449039b9909cad5b310e9e51bcc09 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 16 Oct 2020 00:37:57 +0200 Subject: [PATCH 050/446] Fixed Hooks, use own implementation. Fixed routes. --- flaschengeist/modules/__init__.py | 4 +- flaschengeist/modules/auth/__init__.py | 167 +++++----- flaschengeist/modules/message_mail.py | 22 +- flaschengeist/modules/roles/__init__.py | 116 +++---- flaschengeist/modules/schedule/__init__.py | 309 +++++++++--------- flaschengeist/modules/users/__init__.py | 78 ++--- flaschengeist/readme.md | 15 + .../system/controller/messageController.py | 3 +- flaschengeist/system/hook.py | 20 ++ setup.py | 1 - 10 files changed, 405 insertions(+), 330 deletions(-) create mode 100644 flaschengeist/system/hook.py diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index c594c46..fa0bb92 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -1,6 +1,6 @@ -from pyhooks import precall_register +from flaschengeist.system.hook import HookCall -send_message_hook = precall_register("send_message") +send_message_hook = HookCall("send_message") class Plugin: diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 31218a5..0d57e72 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -11,7 +11,7 @@ from werkzeug.local import LocalProxy from flaschengeist import logger from flaschengeist.modules import Plugin from flaschengeist.system.decorator import login_required -from flaschengeist.system.controller import accessTokenController, userController +from flaschengeist.system.controller import accessTokenController, userController, messageController access_controller = LocalProxy(lambda: accessTokenController.AccessTokenController()) auth_bp = Blueprint("auth", __name__) @@ -21,89 +21,96 @@ class AuthRoutePlugin(Plugin): def __init__(self, conf): super().__init__(blueprint=auth_bp) - ################################################# - # Routes # - # # - # /auth POST: login (new token) # - # GET: get all tokens for user # - # /auth/ GET: get lifetime of token # - # PUT: set new lifetime # - # DELETE: logout / delete token # - ################################################# +################################################# +# Routes # +# # +# /auth POST: login (new token) # +# GET: get all tokens for user # +# /auth/ GET: get lifetime of token # +# PUT: set new lifetime # +# DELETE: logout / delete token # +################################################# - @auth_bp.route("/auth", methods=["POST"]) - def _create_token(): - """Login User - Login in User and create an AccessToken for the User. - Requires POST data {'userid': string, 'password': string} - Returns: - A JSON-File with user information and created token or errors - """ - logger.debug("Start log in.") - data = request.get_json() - try: - userid = data["userid"] - password = data["password"] - except KeyError: - raise BadRequest("Missing parameter(s)") +@auth_bp.route("/auth", methods=["POST"]) +def _create_token(): + """Login User - logger.debug("search user {{ {} }} in database".format(userid)) - user = userController.login_user(userid, password) - if not user: - raise Unauthorized - logger.debug("user is {{ {} }}".format(user)) - token = access_controller.create(user, user_agent=request.user_agent) - logger.debug("access token is {{ {} }}".format(token)) - logger.info("User {{ {} }} success login.".format(userid)) + Login in User and create an AccessToken for the User. + Requires POST data {'userid': string, 'password': string} + Returns: + A JSON-File with user information and created token or errors + """ + logger.debug("Start log in.") + data = request.get_json() + try: + userid = data["userid"] + password = data["password"] + except KeyError: + raise BadRequest("Missing parameter(s)") - # Lets cleanup the DB - access_controller.clear_expired() - return jsonify({"user": user, "token": token, "permissions": user.get_permissions()}) + logger.debug("search user {{ {} }} in database".format(userid)) + user = userController.login_user(userid, password) + if not user: + raise Unauthorized + logger.debug("user is {{ {} }}".format(user)) + token = access_controller.create(user, user_agent=request.user_agent) + logger.debug("access token is {{ {} }}".format(token)) + logger.info("User {{ {} }} success login.".format(userid)) - @auth_bp.route("/auth", methods=["GET"]) - @login_required() - def _get_tokens(access_token, **kwargs): - tokens = access_controller.get_users_tokens(access_token.user) - return jsonify(tokens) + # Lets cleanup the DB + access_controller.clear_expired() + return jsonify({"user": user, "token": token, "permissions": user.get_permissions()}) - @auth_bp.route("/auth/", methods=["DELETE"]) - @login_required() - def _delete_token(token, access_token, **kwargs): - logger.debug("Try to delete access token {{ {} }}".format(token)) - token = access_controller.get_token(token, access_token.user) - if not token: - logger.debug("Token not found in database!") - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - access_controller.delete_token(token) - access_controller.clear_expired() + +@auth_bp.route("/auth", methods=["GET"]) +@login_required() +def _get_tokens(access_token, **kwargs): + tokens = access_controller.get_users_tokens(access_token.user) + a = messageController.Message(access_token.user, "Go", "Bar") + messageController.send_message(a) + return jsonify(tokens) + + +@login_required() +@auth_bp.route("/auth/", methods=["DELETE"]) +def _delete_token(token, access_token, **kwargs): + logger.debug("Try to delete access token {{ {} }}".format(token)) + token = access_controller.get_token(token, access_token.user) + if not token: + logger.debug("Token not found in database!") + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + access_controller.delete_token(token) + access_controller.clear_expired() + return jsonify({"ok": "ok"}) + + +@login_required() +@auth_bp.route("/auth/", methods=["GET"]) +def _get_token(token, access_token, **kwargs): + logger.debug("get token {{ {} }}".format(token)) + token = access_controller.get_token(token, access_token.user) + if not token: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + return jsonify(token) + + +@login_required() +@auth_bp.route("/auth/", methods=["PUT"]) +def _set_lifetime(token, access_token, **kwargs): + token = access_controller.get_token(token, access_token.user) + if not token: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + try: + lifetime = request.get_json()["value"] + logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) + access_controller.set_lifetime(token, lifetime) return jsonify({"ok": "ok"}) - - @auth_bp.route("/auth/", methods=["GET"]) - @login_required() - def _get_token(token, access_token, **kwargs): - logger.debug("get token {{ {} }}".format(token)) - token = access_controller.get_token(token, access_token.user) - if not token: - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - return jsonify(token) - - @auth_bp.route("/auth/", methods=["PUT"]) - @login_required() - def _set_lifetime(token, access_token, **kwargs): - token = access_controller.get_token(token, access_token.user) - if not token: - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - try: - lifetime = request.get_json()["value"] - logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) - access_controller.set_lifetime(token, lifetime) - return jsonify({"ok": "ok"}) - except (KeyError, TypeError): - raise BadRequest + except (KeyError, TypeError): + raise BadRequest diff --git a/flaschengeist/modules/message_mail.py b/flaschengeist/modules/message_mail.py index a9fd9e9..cdd06e0 100644 --- a/flaschengeist/modules/message_mail.py +++ b/flaschengeist/modules/message_mail.py @@ -1,6 +1,8 @@ import smtplib from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from flaschengeist import logger from flaschengeist.system.models.user import User from flaschengeist.system.controller import userController from flaschengeist.system.controller.messageController import Message @@ -18,9 +20,15 @@ class MailMessagePlugin(Plugin): self.crypt = config["CRYPT"] self.mail = config["MAIL"] - @send_message_hook + @send_message_hook + def dummy_send(msg): + self.send_mail(msg) + def send_mail(self, msg: Message): if isinstance(msg.receiver, User): + if not msg.receiver.mail: + logger.warn("Could not send Mail, mail missing: {}".format(msg.receiver)) + return recipients = [msg.receiver.mail] else: recipients = userController.get_user_by_role(msg.receiver) @@ -29,15 +37,17 @@ class MailMessagePlugin(Plugin): mail["From"] = self.mail mail["To"] = ", ".join(recipients) mail["Subject"] = msg.subject - msg.attach(msg.message) - if not self.smtp: + mail.attach(MIMEText(msg.message)) + if not hasattr(self, "smtp"): self.__connect() - self.smtp.sendmail(self.mail, recipients, msg.as_string()) + self.smtp.sendmail(self.mail, recipients, mail.as_string()) def __connect(self): if self.crypt == "SSL": self.smtp = smtplib.SMTP_SSL(self.server, self.port) - if self.crypt == "STARTTLS": - self.smtp = smtplib.SMTP(self.smtpServer, self.port) + elif self.crypt == "STARTTLS": + self.smtp = smtplib.SMTP(self.server, self.port) self.smtp.starttls() + else: + raise ValueError("Invalid CRYPT given") self.smtp.login(self.user, self.password) diff --git a/flaschengeist/modules/roles/__init__.py b/flaschengeist/modules/roles/__init__.py index ec93bdf..1decdea 100644 --- a/flaschengeist/modules/roles/__init__.py +++ b/flaschengeist/modules/roles/__init__.py @@ -12,67 +12,73 @@ class RolesPlugin(Plugin): def __init__(self, config): super().__init__(config, roles_bp) - ###################################################### - # Routes # - # # - # /roles POST: register new # - # GET: get all roles # - # /roles/permissions GET: get all permissions # - # /roles/ GET: get role with rid # - # PUT: modify role / permission # - # DELETE: remove role # - ###################################################### +###################################################### +# Routes # +# # +# /roles POST: register new # +# GET: get all roles # +# /roles/permissions GET: get all permissions # +# /roles/ GET: get role with rid # +# PUT: modify role / permission # +# DELETE: remove role # +###################################################### - @roles_bp.route("/roles", methods=["POST"]) - @login_required() - def add_role(self): - data = request.get_json() - if not data or "name" not in data: - raise BadRequest - if "permissions" in data: - permissions = data["permissions"] - role = roleController.create_role(data["name"], permissions) - return jsonify({"ok": "ok", "id": role.id}) - @roles_bp.route("/roles", methods=["GET"]) - @login_required() - def list_roles(self, **kwargs): - roles = roleController.get_roles() - return jsonify(roles) +@roles_bp.route("/roles", methods=["POST"]) +@login_required() +def add_role(self): + data = request.get_json() + if not data or "name" not in data: + raise BadRequest + if "permissions" in data: + permissions = data["permissions"] + role = roleController.create_role(data["name"], permissions) + return jsonify({"ok": "ok", "id": role.id}) - @roles_bp.route("/roles/permissions", methods=["GET"]) - @login_required() - def list_permissions(self, **kwargs): - permissions = roleController.get_permissions() - return jsonify(permissions) - @roles_bp.route("/roles/", methods=["GET"]) - @login_required() - def __get_role(self, rid, **kwargs): - role = roleController.get_role(rid) - if role: - return jsonify({"id": role.id, "name": role, "permissions": role.permissions}) +@roles_bp.route("/roles", methods=["GET"]) +@login_required() +def list_roles(**kwargs): + roles = roleController.get_roles() + return jsonify(roles) + + +@roles_bp.route("/roles/permissions", methods=["GET"]) +@login_required() +def list_permissions(**kwargs): + permissions = roleController.get_permissions() + return jsonify(permissions) + + +@roles_bp.route("/roles/", methods=["GET"]) +@login_required() +def __get_role(rid, **kwargs): + role = roleController.get_role(rid) + if role: + return jsonify({"id": role.id, "name": role, "permissions": role.permissions}) + raise NotFound + + +@roles_bp.route("/roles/", methods=["PUT"]) +@login_required() +def __edit_role(rid, **kwargs): + role = roleController.get_role(rid) + if not role: raise NotFound - @roles_bp.route("/roles/", methods=["PUT"]) - @login_required() - def __edit_role(self, rid, **kwargs): - role = roleController.get_role(rid) - if not role: - raise NotFound + data = request.get_json() + if "name" in data: + role.name = data["name"] + if "permissions" in data: + roleController.set_permissions(role, data["permissions"]) + roleController.update_role(role) + return jsonify({"ok": "ok"}) - data = request.get_json() - if "name" in data: - role.name = data["name"] - if "permissions" in data: - roleController.set_permissions(role, data["permissions"]) - roleController.update_role(role) - return jsonify({"ok": "ok"}) - @roles_bp.route("/roles/", methods=["DELETE"]) - @login_required() - def __delete_role(self, rid, **kwargs): - if not roleController.delete_role(rid): - raise NotFound +@roles_bp.route("/roles/", methods=["DELETE"]) +@login_required() +def __delete_role(rid, **kwargs): + if not roleController.delete_role(rid): + raise NotFound - return jsonify({"ok": "ok"}) + return jsonify({"ok": "ok"}) diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index 0805329..9527cb2 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -16,171 +16,184 @@ class SchedulePlugin(Plugin): def __init__(self, config): super().__init__(blueprint=schedule_bp) - #################################################################################### - # Routes # - # # - # /schedule/events POST: create new # - # GET: get all events this month # - # /schedule/events// GET: get events by month / date # - # /// # - # /schedule/events/ GET: get event by ID # - # DELETE: delete specified event # - # PUT: modify specified event # - # /schedule/events//slots GET: get EventSlots of Event # - # POST: add new EventSlot to Event # - # /schedule/events//slots/ PUT: modify EventSlot # - # GET: get specified EventSlot # - # /schedule/events//slots//jobs POST: add User # - # /schedule/eventKinds - # /schedule/eventKinds/ - # PUT: modify user # - # DELETE: remove user # - #################################################################################### +#################################################################################### +# Routes # +# # +# /schedule/events POST: create new # +# GET: get all events this month # +# /schedule/events// GET: get events by month / date # +# /// # +# /schedule/events/ GET: get event by ID # +# DELETE: delete specified event # +# PUT: modify specified event # +# /schedule/events//slots GET: get EventSlots of Event # +# POST: add new EventSlot to Event # +# /schedule/events//slots/ PUT: modify EventSlot # +# GET: get specified EventSlot # +# /schedule/events//slots//jobs POST: add User # +# /schedule/eventKinds +# /schedule/eventKinds/ +# PUT: modify user # +# DELETE: remove user # +#################################################################################### - @schedule_bp.route("/events/", methods=["GET"]) - @login_required() # roles=['schedule_read']) - def __get_event(self, id, **kwargs): - event = eventController.get_event(id) - if not event: - raise NotFound - return jsonify(event) - @schedule_bp.route("/events", methods=["GET"]) - @schedule_bp.route("/events//", methods=["GET"]) - @schedule_bp.route("/events///", methods=["GET"]) - @login_required() # roles=['schedule_read']) - def __get_events(self, year=datetime.now().year, month=datetime.now().month, day=None, **kwrags): - """Get Event objects for specified date (or month or year), - if nothing set then events for current month are returned +@schedule_bp.route("/events/", methods=["GET"]) +@login_required() # roles=['schedule_read']) +def __get_event(self, id, **kwargs): + event = eventController.get_event(id) + if not event: + raise NotFound + return jsonify(event) - Args: - year (int, optional): year to query, defaults to current year - month (int, optional): month to query (if set), defaults to current month - day (int, optional): day to query events for (if set) - **kwrags: contains at least access_token (see flaschengeist.decorator) - Returns: - JSON list containing events found - Raises: - BadRequest: If date is invalid - """ - try: - begin = datetime(year=year, month=month, day=1) - if day: - begin += timedelta(days=day - 1) - end = begin + timedelta(days=1) + +@schedule_bp.route("/events", methods=["GET"]) +@schedule_bp.route("/events//", methods=["GET"]) +@schedule_bp.route("/events///", methods=["GET"]) +@login_required() # roles=['schedule_read']) +def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, **kwargs): + """Get Event objects for specified date (or month or year), + if nothing set then events for current month are returned + + Args: + year (int, optional): year to query, defaults to current year + month (int, optional): month to query (if set), defaults to current month + day (int, optional): day to query events for (if set) + **kwargs: contains at least access_token (see flaschengeist.decorator) + Returns: + JSON list containing events found + Raises: + BadRequest: If date is invalid + """ + try: + begin = datetime(year=year, month=month, day=1) + if day: + begin += timedelta(days=day - 1) + end = begin + timedelta(days=1) + else: + if month == 12: + end = datetime(year=year + 1, month=1, day=1) else: - if month == 12: - end = datetime(year=year + 1, month=1, day=1) - else: - end = datetime(year=year, month=month + 1, day=1) + end = datetime(year=year, month=month + 1, day=1) - events = eventController.get_events(begin, end) - return jsonify(events) - except ValueError: - raise BadRequest("Invalid date given") + events = eventController.get_events(begin, end) + return jsonify(events) + except ValueError: + raise BadRequest("Invalid date given") - @schedule_bp.route("/eventKinds", methods=["POST"]) - @login_required() - def __new_event_kind(self, **kwargs): - data = request.get_json() - if "name" not in data: - raise BadRequest - kind = eventController.create_event_kind(data["name"]) - return jsonify({"ok": "ok", "id": kind.id}) - @schedule_bp.route("/slotKinds", methods=["POST"]) - @login_required() - def __new_slot_kind(self, **kwargs): - data = request.get_json() - if not data or "name" not in data: - raise BadRequest - kind = eventController.create_job_kind(data["name"]) - return jsonify({"ok": "ok", "id": kind.id}) +@schedule_bp.route("/eventKinds", methods=["POST"]) +@login_required() +def __new_event_kind(**kwargs): + data = request.get_json() + if "name" not in data: + raise BadRequest + kind = eventController.create_event_kind(data["name"]) + return jsonify({"ok": "ok", "id": kind.id}) - @schedule_bp.route("/events", methods=["POST"]) - @login_required() - def __new_event(self, **kwargs): - data = request.get_json() - event = eventController.create_event( - begin=parser.isoparse(data["begin"]), - end=parser.isoparse(data["end"]), - description=data["description"], - kind=EventKind.query.get(data["kind"]), - ) - return jsonify({"ok": "ok", "id": event.id}) - @schedule_bp.route("/events/", methods=["DELETE"]) - @login_required() - def __delete_event(self, id, **kwargs): - if not eventController.delete_event(id): - raise NotFound - db.session.commit() - return jsonify({"ok": "ok"}) +@schedule_bp.route("/slotKinds", methods=["POST"]) +@login_required() +def __new_slot_kind(**kwargs): + data = request.get_json() + if not data or "name" not in data: + raise BadRequest + kind = eventController.create_job_kind(data["name"]) + return jsonify({"ok": "ok", "id": kind.id}) - @schedule_bp.route("/eventKinds/", methods=["PUT"]) - @login_required() - def __edit_event_kind(self, id, **kwargs): - data = request.get_json() - if not data or "name" not in data: - raise BadRequest - eventController.rename_event_kind(id, data["name"]) - return jsonify({"ok": "ok"}) - @schedule_bp.route("/events//slots", methods=["GET"]) - @login_required() - def __get_slots(self, event_id, **kwargs): - event = eventController.get_event(event_id) - if not event: - raise NotFound - return jsonify({event.slots}) +@schedule_bp.route("/events", methods=["POST"]) +@login_required() +def __new_event(**kwargs): + data = request.get_json() + event = eventController.create_event( + begin=parser.isoparse(data["begin"]), + end=parser.isoparse(data["end"]), + description=data["description"], + kind=EventKind.query.get(data["kind"]), + ) + return jsonify({"ok": "ok", "id": event.id}) - @schedule_bp.route("/events//slots/", methods=["GET"]) - @login_required() - def __get_slot(self, event_id, slot_id, **kwargs): - slot = eventController.get_event_slot(slot_id, event_id) - if slot: - return jsonify(slot) + +@schedule_bp.route("/events/", methods=["DELETE"]) +@login_required() +def __delete_event(event_id, **kwargs): + if not eventController.delete_event(event_id): raise NotFound + db.session.commit() + return jsonify({"ok": "ok"}) - @schedule_bp.route("/events//slots/", methods=["DELETE"]) - @login_required() - def __delete_slot(self, event_id, slot_id, **kwargs): - if eventController.delete_event_slot(slot_id, event_id): - return jsonify({"ok": "ok"}) + +@schedule_bp.route("/eventKinds/", methods=["PUT"]) +@login_required() +def __edit_event_kind(event_id, **kwargs): + data = request.get_json() + if not data or "name" not in data: + raise BadRequest + eventController.rename_event_kind(event_id, data["name"]) + return jsonify({"ok": "ok"}) + + +@schedule_bp.route("/events//slots", methods=["GET"]) +@login_required() +def __get_slots(event_id, **kwargs): + event = eventController.get_event(event_id) + if not event: raise NotFound + return jsonify({event.slots}) - @schedule_bp.route("/events//slots/", methods=["PUT"]) - @login_required() - def __update_slot(self, event_id, slot_id, **kwargs): - data = request.get_json() - if not data: - raise BadRequest +@schedule_bp.route("/events//slots/", methods=["GET"]) +@login_required() +def __get_slot(event_id, slot_id, **kwargs): + slot = eventController.get_event_slot(slot_id, event_id) + if slot: + return jsonify(slot) + raise NotFound + + +@schedule_bp.route("/events//slots/", methods=["DELETE"]) +@login_required() +def __delete_slot(event_id, slot_id, **kwargs): + if eventController.delete_event_slot(slot_id, event_id): + return jsonify({"ok": "ok"}) + raise NotFound + + +@schedule_bp.route("/events//slots/", methods=["PUT"]) +@login_required() +def __update_slot(event_id, slot_id, **kwargs): + data = request.get_json() + if not data: + raise BadRequest + + for job in data["jobs"]: + eventController.add_job(job.kind, job.user) + if eventController.delete_event_slot(slot_id, event_id): + return jsonify({"ok": "ok"}) + raise NotFound + + +@schedule_bp.route("/events//slots", methods=["POST"]) +@login_required() +def __add_slot(event_id, **kwargs): + event = eventController.get_event(event_id) + if not event: + raise NotFound + data = request.get_json() + attr = {"job_slots": []} + try: + if "start" in data: + attr["start"] = parser.isoparse(data["start"]) + if "end" in data: + attr["end"] = parser.isoparse(data["end"]) for job in data["jobs"]: - eventController.add_job(job.kind, job.user) - if eventController.delete_event_slot(slot_id, event_id): - return jsonify({"ok": "ok"}) - raise NotFound + attr["job_slots"].append({"needed_persons": job["needed_persons"], "kind": job["kind"]}) + except KeyError: + raise BadRequest("Missing data in request") + eventController.add_slot(event, **attr) + return jsonify({"ok": "ok"}) - @schedule_bp.route("/events//slots", methods=["POST"]) - @login_required() - def __add_slot(self, event_id, **kwargs): - event = eventController.get_event(event_id) - if not event: - raise NotFound - data = request.get_json() - attr = {"job_slots": []} - try: - if "start" in data: - attr["start"] = parser.isoparse(data["start"]) - if "end" in data: - attr["end"] = parser.isoparse(data["end"]) - for job in data["jobs"]: - attr["job_slots"].append({"needed_persons": job["needed_persons"], "kind": job["kind"]}) - except KeyError: - raise BadRequest("Missing data in request") - eventController.add_slot(event, **attr) - return jsonify({"ok": "ok"}) - def __edit_event(self): - ... +def __edit_event(): + ... diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index 5b9fdd4..d168c3d 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -24,45 +24,49 @@ class UsersPlugin(Plugin): # DELETE: remove user # ################################################# - @users_bp.route("/users", methods=["POST"]) - def __registration(self): - logger.debug("Register new User...") - return jsonify({"ok": "ok... well not implemented"}) - @users_bp.route("/users", methods=["GET"]) - @login_required() - def __list_users(self, **kwargs): - logger.debug("Retrieve list of all users") - users = userController.get_users() - return jsonify(users) +@users_bp.route("/users", methods=["POST"]) +def __registration(self): + logger.debug("Register new User...") + return jsonify({"ok": "ok... well not implemented"}) - @users_bp.route("/users/", methods=["GET"]) - @login_required() - def __get_user(self, uid, **kwargs): - logger.debug("Get information of user {{ {} }}".format(uid)) - user = userController.get_user(uid) - if user: - return jsonify(user) + +@users_bp.route("/users", methods=["GET"]) +@login_required() +def __list_users(**kwargs): + logger.debug("Retrieve list of all users") + users = userController.get_users() + return jsonify(users) + + +@users_bp.route("/users/", methods=["GET"]) +@login_required() +def __get_user(uid, **kwargs): + logger.debug("Get information of user {{ {} }}".format(uid)) + user = userController.get_user(uid) + if user: + return jsonify(user) + raise NotFound + + +@users_bp.route("/users/", methods=["PUT"]) +@login_required() +def __edit_user(uid, **kwargs): + logger.debug("Modify information of user {{ {} }}".format(uid)) + user = userController.get_user(uid) + if not user: raise NotFound - @users_bp.route("/users/", methods=["PUT"]) - @login_required() - def __edit_user(self, uid, **kwargs): - logger.debug("Modify information of user {{ {} }}".format(uid)) - user = userController.get_user(uid) - if not user: - raise NotFound + if uid != kwargs["access_token"].user.uid and user.has_permissions(permissions["EDIT_USER"]): + return Forbidden - if uid != kwargs["access_token"].user.uid and user.has_permissions(permissions["EDIT_USER"]): - return Forbidden - - data = request.get_json() - if "password" not in data: - raise BadRequest("Password is missing") - for key in ["firstname", "lastname", "display_name", "mail"]: - if key in data: - setattr(user, key, data[key]) - new_password = data["new_password"] if "new_password" in data else None - userController.modify_user(user, data["password"], new_password) - userController.update_user(user) - return jsonify({"ok": "ok"}) + data = request.get_json() + if "password" not in data: + raise BadRequest("Password is missing") + for key in ["firstname", "lastname", "display_name", "mail"]: + if key in data: + setattr(user, key, data[key]) + new_password = data["new_password"] if "new_password" in data else None + userController.modify_user(user, data["password"], new_password) + userController.update_user(user) + return jsonify({"ok": "ok"}) diff --git a/flaschengeist/readme.md b/flaschengeist/readme.md index d9a2434..e3dc10c 100644 --- a/flaschengeist/readme.md +++ b/flaschengeist/readme.md @@ -28,7 +28,22 @@ or with debug messages: run_flaschengeist --debug ## Development +### Code Style +We enforce you to use PEP 8 code style with a line length of 120 as used by Black. +See also [Black Code Style](https://github.com/psf/black/blob/master/docs/the_black_code_style.md). + +#### Code formatting +We use [Black](https://github.com/psf/black) as the code formatter. + +Installation: + + pip install black +Usage: + + black -l 120 DIRECTORY_OR_FILE + ### Misc +#### Git blame When using `git blame` use this to ignore the code formatting commits: $ git blame FILE.py --ignore-revs-file .git-blame-ignore-revs diff --git a/flaschengeist/system/controller/messageController.py b/flaschengeist/system/controller/messageController.py index 1f064d6..6114aec 100644 --- a/flaschengeist/system/controller/messageController.py +++ b/flaschengeist/system/controller/messageController.py @@ -1,5 +1,6 @@ +from flaschengeist import logger +from flaschengeist.system.hook import Hook from flaschengeist.system.models.user import User, Role -from pyhooks import Hook class Message: diff --git a/flaschengeist/system/hook.py b/flaschengeist/system/hook.py new file mode 100644 index 0000000..729269f --- /dev/null +++ b/flaschengeist/system/hook.py @@ -0,0 +1,20 @@ +_hook_dict = {} + + +class Hook(object): + def __init__(self, function): + self.function = function + + def __call__(self, *args, **kwargs): + if self.function.__name__ in _hook_dict: + _hook_dict[self.function.__name__](*args, **kwargs) + self.function(*args, **kwargs) + + +class HookCall(object): + def __init__(self, name): + self.name = name + + def __call__(self, function): + _hook_dict[self.name] = function + return function \ No newline at end of file diff --git a/setup.py b/setup.py index c0923cf..a7f525f 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,6 @@ setup( "werkzeug", "bjoern", "python-dateutil", - "pyhooks", ], extras_require={"ldap": ["flask_ldapconn", "ldap3"]}, entry_points={ From c629f5abf38c7c8051cef70bd2caf45b79a85d2c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 19 Oct 2020 01:41:54 +0200 Subject: [PATCH 051/446] Rename AccessToken model to Session, same with controller. --- flaschengeist/__init__.py | 1 - flaschengeist/app.py | 5 +- flaschengeist/modules/__init__.py | 6 ++ flaschengeist/modules/auth/__init__.py | 40 +++++----- flaschengeist/modules/auth_ldap/__init__.py | 8 +- flaschengeist/modules/auth_plain/__init__.py | 4 +- .../modules/geruecht/baruser/routes.py | 6 +- .../dbCreditListController.py | 2 +- .../databaseController/dbUserController.py | 6 +- .../modules/geruecht/finanzer/routes.py | 8 +- .../geruecht/mainController/__init__.py | 4 +- .../mainCreditListController.py | 4 +- flaschengeist/modules/users/__init__.py | 2 +- ...okenController.py => sessionController.py} | 76 +++++++++---------- .../system/controller/userController.py | 6 +- flaschengeist/system/decorator.py | 6 +- .../models/{accessToken.py => session.py} | 26 +++---- flaschengeist/system/models/user.py | 10 +-- 18 files changed, 113 insertions(+), 107 deletions(-) rename flaschengeist/system/controller/{accessTokenController.py => sessionController.py} (55%) rename flaschengeist/system/models/{accessToken.py => session.py} (65%) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index afbfbdc..1f13a7c 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -1,7 +1,6 @@ """ Server-package Initialize app, CORS, database and add it to the application. - Initialize also a singleton for the AccessTokenController and start the Thread. """ import yaml diff --git a/flaschengeist/app.py b/flaschengeist/app.py index f776167..19b0254 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -56,11 +56,14 @@ def __load_plugins(app): def install_all(): from flaschengeist.system.database import db - from flaschengeist.system.models import user, event, accessToken + from flaschengeist.system.models import user, event, session db.create_all() db.session.commit() for name, plugin in current_app.config["FG_PLUGINS"].items(): + if not plugin: + logger.debug("Skip disabled plugin {}".format(name)) + continue logger.info("Install plugin {}".format(name)) plugin.install() if plugin.permissions: diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index fa0bb92..e84ac55 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -7,6 +7,7 @@ class Plugin: def __init__(self, config=None, blueprint=None, permissions={}): self.blueprint = blueprint self.permissions = permissions + self.version = "dummy" def install(self): """Installation routine @@ -14,6 +15,11 @@ class Plugin: """ pass + def serialize(self): + return { + "version": self.version, + } + class AuthPlugin(Plugin): def login(self, user, pw): diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 0d57e72..0c68b1d 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -11,9 +11,9 @@ from werkzeug.local import LocalProxy from flaschengeist import logger from flaschengeist.modules import Plugin from flaschengeist.system.decorator import login_required -from flaschengeist.system.controller import accessTokenController, userController, messageController +from flaschengeist.system.controller import sessionController, userController, messageController -access_controller = LocalProxy(lambda: accessTokenController.AccessTokenController()) +session_controller = LocalProxy(lambda: sessionController.SessionController()) auth_bp = Blueprint("auth", __name__) @@ -33,10 +33,10 @@ class AuthRoutePlugin(Plugin): @auth_bp.route("/auth", methods=["POST"]) -def _create_token(): +def _login(): """Login User - Login in User and create an AccessToken for the User. + Login in User and create a Session for the User. Requires POST data {'userid': string, 'password': string} Returns: A JSON-File with user information and created token or errors @@ -54,55 +54,55 @@ def _create_token(): if not user: raise Unauthorized logger.debug("user is {{ {} }}".format(user)) - token = access_controller.create(user, user_agent=request.user_agent) - logger.debug("access token is {{ {} }}".format(token)) + session = session_controller.create(user, user_agent=request.user_agent) + logger.debug("token is {{ {} }}".format(session.token)) logger.info("User {{ {} }} success login.".format(userid)) # Lets cleanup the DB - access_controller.clear_expired() - return jsonify({"user": user, "token": token, "permissions": user.get_permissions()}) + session_controller.clear_expired() + return jsonify({"session": session, "permissions": user.get_permissions()}) @auth_bp.route("/auth", methods=["GET"]) @login_required() def _get_tokens(access_token, **kwargs): - tokens = access_controller.get_users_tokens(access_token.user) + tokens = session_controller.get_users_sessions(access_token.user) a = messageController.Message(access_token.user, "Go", "Bar") messageController.send_message(a) return jsonify(tokens) -@login_required() @auth_bp.route("/auth/", methods=["DELETE"]) -def _delete_token(token, access_token, **kwargs): +@login_required() +def _delete_token(access_token, token, **kwargs): logger.debug("Try to delete access token {{ {} }}".format(token)) - token = access_controller.get_token(token, access_token.user) + token = session_controller.get_session(token, access_token.user) if not token: logger.debug("Token not found in database!") # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden - access_controller.delete_token(token) - access_controller.clear_expired() + session_controller.delete_session(token) + session_controller.clear_expired() return jsonify({"ok": "ok"}) -@login_required() @auth_bp.route("/auth/", methods=["GET"]) +@login_required() def _get_token(token, access_token, **kwargs): logger.debug("get token {{ {} }}".format(token)) - token = access_controller.get_token(token, access_token.user) + session = session_controller.get_session(token, access_token.user) if not token: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden - return jsonify(token) + return jsonify({"session": session, "permissions": session.user.get_permissions()}) -@login_required() @auth_bp.route("/auth/", methods=["PUT"]) +@login_required() def _set_lifetime(token, access_token, **kwargs): - token = access_controller.get_token(token, access_token.user) + token = session_controller.get_token(token, access_token.user) if not token: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same @@ -110,7 +110,7 @@ def _set_lifetime(token, access_token, **kwargs): try: lifetime = request.get_json()["value"] logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) - access_controller.set_lifetime(token, lifetime) + session_controller.set_lifetime(token, lifetime) return jsonify({"ok": "ok"}) except (KeyError, TypeError): raise BadRequest diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index 2a97a77..d8cd710 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -37,17 +37,17 @@ class AuthLDAP(AuthPlugin): def login(self, user, password): if not user: return False - return self.ldap.authenticate(user.uid, password, "uid", self.dn) + return self.ldap.authenticate(user.userid, password, "uid", self.dn) def update_user(self, user): self.ldap.connection.search( "ou=user,{}".format(self.dn), - "(uid={})".format(user.uid), + "(uid={})".format(user.userid), SUBTREE, attributes=["uid", "givenName", "sn", "mail"], ) r = self.ldap.connection.response[0]["attributes"] - if r["uid"][0] == user.uid: + if r["uid"][0] == user.userid: user.set_attribute("DN", self.ldap.connection.response[0]["dn"]) user.firstname = r["givenName"][0] user.lastname = r["sn"][0] @@ -55,7 +55,7 @@ class AuthLDAP(AuthPlugin): user.mail = r["mail"][0] if "displayName" in r: user.display_name = r["displayName"][0] - for group in self._get_groups(user.uid): + for group in self._get_groups(user.userid): user.add_role(group) def _get_groups(self, uid): diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/modules/auth_plain/__init__.py index 872d723..4bb87c0 100644 --- a/flaschengeist/modules/auth_plain/__init__.py +++ b/flaschengeist/modules/auth_plain/__init__.py @@ -2,7 +2,7 @@ import binascii import hashlib import os -import flaschengeist.modules as modules +from flaschengeist.modules import AuthPlugin from flaschengeist.system.models.user import User @@ -21,7 +21,7 @@ def _verify_password(stored_password, provided_password): return pass_hash == stored_password -class AuthPlain(modules.Auth): +class AuthPlain(AuthPlugin): def login(self, user: User, password: str): if user and "password" in user.attributes: return _verify_password(user.attributes["password"].value, password) diff --git a/flaschengeist/modules/geruecht/baruser/routes.py b/flaschengeist/modules/geruecht/baruser/routes.py index dc7c383..0d9dbf1 100644 --- a/flaschengeist/modules/geruecht/baruser/routes.py +++ b/flaschengeist/modules/geruecht/baruser/routes.py @@ -42,7 +42,7 @@ def _bar(**kwargs): type = 'credit' else: type = 'amount' - dic[user.uid] = {"username": user.uid, + dic[user.userid] = {"username": user.userid, "firstname": user.firstname, "lastname": user.lastname, "amount": all, @@ -50,8 +50,8 @@ def _bar(**kwargs): "type": type, "limit": user.limit, "autoLock": user.autoLock - } - dic[user.uid]['last_seen'] = {"year": user.last_seen.year, "month": user.last_seen.month, "day": user.last_seen.day, "hour": user.last_seen.hour, "minute": user.last_seen.minute, "second": user.last_seen.second} if user.last_seen else None + } + dic[user.userid]['last_seen'] = {"year": user.last_seen.year, "month": user.last_seen.month, "day": user.last_seen.day, "hour": user.last_seen.hour, "minute": user.last_seen.minute, "second": user.last_seen.second} if user.last_seen else None debug.debug("return {{ {} }}".format(dic)) return jsonify(dic) except Exception as err: diff --git a/flaschengeist/modules/geruecht/databaseController/dbCreditListController.py b/flaschengeist/modules/geruecht/databaseController/dbCreditListController.py index 62a3528..7011ea7 100644 --- a/flaschengeist/modules/geruecht/databaseController/dbCreditListController.py +++ b/flaschengeist/modules/geruecht/databaseController/dbCreditListController.py @@ -10,7 +10,7 @@ class Base: def getCreditListFromUser(self, user, **kwargs): try: if type(user) is User: - if user.uid == 'extern': + if user.userid == 'extern': return [] cursor = self.db.connection.cursor() if 'year' in kwargs: diff --git a/flaschengeist/modules/geruecht/databaseController/dbUserController.py b/flaschengeist/modules/geruecht/databaseController/dbUserController.py index 4c1dd1c..f93110e 100644 --- a/flaschengeist/modules/geruecht/databaseController/dbUserController.py +++ b/flaschengeist/modules/geruecht/databaseController/dbUserController.py @@ -83,7 +83,7 @@ class Base: cursor = self.db.connection.cursor() groups = self._convertGroupToString(user.group) cursor.execute("insert into user (uid, dn, firstname, lastname, gruppe, lockLimit, locked, autoLock, mail) VALUES ('{}','{}','{}','{}','{}',{},{},{},'{}')".format( - user.uid, user.dn, user.firstname, user.lastname, groups, user.limit, user.locked, user.autoLock, user.mail)) + user.userid, user.dn, user.firstname, user.lastname, groups, user.limit, user.locked, user.autoLock, user.mail)) self.db.connection.commit() except Exception as err: traceback.print_exc() @@ -96,7 +96,7 @@ class Base: cursor = self.db.connection.cursor() groups = self._convertGroupToString(user.group) sql = "update user set dn='{}', firstname='{}', lastname='{}', gruppe='{}', lockLimit={}, locked={}, autoLock={}, mail='{}' where uid='{}'".format( - user.dn, user.firstname, user.lastname, groups, user.limit, user.locked, user.autoLock, user.mail, user.uid) + user.dn, user.firstname, user.lastname, groups, user.limit, user.locked, user.autoLock, user.mail, user.userid) print(sql) cursor.execute(sql) self.db.connection.commit() @@ -109,7 +109,7 @@ class Base: try: cursor = self.db.connection.cursor() sql = "update user set last_seen='{}' where uid='{}'".format( - time, user.uid) + time, user.userid) print(sql) cursor.execute(sql) self.db.connection.commit() diff --git a/flaschengeist/modules/geruecht/finanzer/routes.py b/flaschengeist/modules/geruecht/finanzer/routes.py index 8b2e2fa..2e8d561 100644 --- a/flaschengeist/modules/geruecht/finanzer/routes.py +++ b/flaschengeist/modules/geruecht/finanzer/routes.py @@ -29,8 +29,8 @@ def _getFinanzer(**kwargs): users = mainController.getAllUsersfromDB() dic = {} for user in users: - dic[user.uid] = user.toJSON() - dic[user.uid]['creditList'] = { + dic[user.userid] = user.toJSON() + dic[user.userid]['creditList'] = { credit.year: credit.toJSON() for credit in user.geruechte} debug.debug("return {{ {} }}".format(dic)) return jsonify(dic) @@ -168,8 +168,8 @@ def _finanzerAddUser(**kwargs): users = mainController.getAllUsersfromDB() dic = {} for user in users: - dic[user.uid] = user.toJSON() - dic[user.uid]['creditList'] = { + dic[user.userid] = user.toJSON() + dic[user.userid]['creditList'] = { credit.year: credit.toJSON() for credit in user.geruechte} debug.debug("return {{ {} }}".format(dic)) return jsonify(dic), 200 diff --git a/flaschengeist/modules/geruecht/mainController/__init__.py b/flaschengeist/modules/geruecht/mainController/__init__.py index 61ce1b5..1d6f3f8 100644 --- a/flaschengeist/modules/geruecht/mainController/__init__.py +++ b/flaschengeist/modules/geruecht/mainController/__init__.py @@ -94,9 +94,9 @@ class MainController(#mainJobKindController.Base, def __updateDataFromLDAP(self, user): logger.info("update data from ldap for user {{ {} }}".format(user)) - groups = ldap.getGroup(user.uid) + groups = ldap.getGroup(user.userid) logger.debug("ldap gorups are {{ {} }}".format(groups)) - user_data = ldap.getUserData(user.uid) + user_data = ldap.getUserData(user.userid) logger.debug("ldap data is {{ {} }}".format(user_data)) user_data['gruppe'] = groups user_data['group'] = groups diff --git a/flaschengeist/modules/geruecht/mainController/mainCreditListController.py b/flaschengeist/modules/geruecht/mainController/mainCreditListController.py index 7e6f19f..9c56e6e 100644 --- a/flaschengeist/modules/geruecht/mainController/mainCreditListController.py +++ b/flaschengeist/modules/geruecht/mainController/mainCreditListController.py @@ -34,7 +34,7 @@ class Base: amount, username, month, year)) user = self.getUser(username) debug.debug("user is {{ {} }}".format(user)) - if user.uid == 'extern': + if user.userid == 'extern': debug.debug("user is extern user, so exit add amount") return if not user.locked or finanzer: @@ -60,7 +60,7 @@ class Base: credit, username, month, year)) user = self.getUser(username) debug.debug("user is {{ {} }}".format(user)) - if user.uid == 'extern': + if user.userid == 'extern': debug.debug("user is extern user, so exit add credit") return user.addCredit(credit, year=year, month=month) diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index d168c3d..28f2247 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -57,7 +57,7 @@ def __edit_user(uid, **kwargs): if not user: raise NotFound - if uid != kwargs["access_token"].user.uid and user.has_permissions(permissions["EDIT_USER"]): + if uid != kwargs["access_token"].user.userid and user.has_permissions(permissions["EDIT_USER"]): return Forbidden data = request.get_json() diff --git a/flaschengeist/system/controller/accessTokenController.py b/flaschengeist/system/controller/sessionController.py similarity index 55% rename from flaschengeist/system/controller/accessTokenController.py rename to flaschengeist/system/controller/sessionController.py index 17fe644..f92ace7 100644 --- a/flaschengeist/system/controller/accessTokenController.py +++ b/flaschengeist/system/controller/sessionController.py @@ -1,5 +1,5 @@ import secrets -from ..models.accessToken import AccessToken +from ..models.session import Session from flaschengeist.system.database import db from flaschengeist import logger from werkzeug.exceptions import Forbidden @@ -7,22 +7,22 @@ from datetime import datetime, timezone from . import Singleton -class AccessTokenController(metaclass=Singleton): - """Control all created AccessToken +class SessionController(metaclass=Singleton): + """Control all created Sessions - This Class create, delete, find and manage AccessToken. + This Class create, delete, find and manage Sessions. Attributes: - lifetime: Variable for the Lifetime of one AccessToken in seconds. + lifetime: Variable for the Lifetime of a Session in seconds. """ def __init__(self, lifetime=1800): self.lifetime = lifetime def validate_token(self, token, user_agent, permissions): - """Verify access token + """Verify session - Verify an AccessToken and Roles so if the User has permission or not. + Verify a Session and Roles so if the User has permission or not. Retrieves the access token if valid else retrieves False Args: @@ -30,10 +30,10 @@ class AccessTokenController(metaclass=Singleton): user_agent: User agent of browser to check permissions: Permissions needed to access restricted routes Returns: - An the AccessToken for this given Token or False. + A Session for this given Token or False. """ logger.debug("check token {{ {} }} is valid".format(token)) - access_token = AccessToken.query.filter_by(token=token).one_or_none() + access_token = Session.query.filter_by(token=token).one_or_none() if access_token: logger.debug("token found, check if expired or invalid user agent differs") if access_token.expires >= datetime.utcnow() and ( @@ -45,34 +45,34 @@ class AccessTokenController(metaclass=Singleton): return access_token else: logger.debug("access token is out of date or invalid client used") - self.delete_token(access_token) + self.delete_session(access_token) logger.debug("no valid access token with token: {{ {} }} and permissions: {{ {} }}".format(token, permissions)) return False - def create(self, user, user_agent=None) -> AccessToken: - """Create an AccessToken + def create(self, user, user_agent=None) -> Session: + """Create a Session Args: - user: For which User is to create an AccessToken + user: For which User is to create a Session user_agent: User agent to identify session Returns: - AccessToken: A created Token for User + Session: A created Token for User """ logger.debug("create access token") token_str = secrets.token_hex(16) - token = AccessToken( + session = Session( token=token_str, user=user, lifetime=self.lifetime, browser=user_agent.browser, platform=user_agent.platform ) - token.refresh() - db.session.add(token) + session.refresh() + db.session.add(session) db.session.commit() - logger.debug("access token is {{ {} }}".format(token)) - return token + logger.debug("access token is {{ {} }}".format(session.token)) + return session - def get_token(self, token, owner=None): - """Retrieves AccessToken from token string + def get_session(self, token, owner=None): + """Retrieves Session from token string Args: token (str): Token string @@ -81,38 +81,38 @@ class AccessTokenController(metaclass=Singleton): Raises: Forbidden: Raised if owner is set but does not match Returns: - AccessToken: Token object identified by given token string + Session: Token object identified by given token string """ - access_token = AccessToken.query.filter(AccessToken.token == token).one_or_none() - if access_token and (owner and owner != access_token.user): + session = Session.query.filter(Session.token == token).one_or_none() + if session and (owner and owner != session.user): raise Forbidden - return access_token + return session - def get_users_tokens(self, user): - return AccessToken.query.filter(AccessToken.user == user) + def get_users_sessions(self, user): + return Session.query.filter(Session.user == user) @staticmethod - def delete_token(token: AccessToken): - """Deletes given AccessToken + def delete_session(token: Session): + """Deletes given Session Args: - token (AccessToken): Token to delete + token (Session): Token to delete """ db.session.delete(token) db.session.commit() @staticmethod - def update_token(token): - token.refresh() + def update_session(session): + session.refresh() db.session.commit() - def set_lifetime(self, token, lifetime): - token.lifetime = lifetime - self.update_token(token) + def set_lifetime(self, session, lifetime): + session.lifetime = lifetime + self.update_session(session) def clear_expired(self): """Remove expired tokens from database""" - logger.debug("Clear expired AccessToken") - deleted = AccessToken.query.filter(AccessToken.expires < datetime.utcnow()).delete() - logger.debug("{} tokens have been removed".format(deleted)) + logger.debug("Clear expired Sessions") + deleted = Session.query.filter(Session.expires < datetime.utcnow()).delete() + logger.debug("{} sessions have been removed".format(deleted)) db.session.commit() diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index f986534..67a2008 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -7,9 +7,9 @@ from flaschengeist import logger def login_user(username, password): logger.info("login user {{ {} }}".format(username)) - user = User.query.filter_by(uid=username).one_or_none() + user = User.query.filter(User.userid == username).one_or_none() if user is None: - user = User(uid=username) + user = User(userid=username) db.session.add(user) if current_app.config["FG_AUTH_BACKEND"].login(user, password): update_user(user) @@ -49,4 +49,4 @@ def get_user_by_role(role: Role): def get_user(uid): - return User.query.filter(User.uid == uid).one_or_none() + return User.query.filter(User.userid == uid).one_or_none() diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index 9a8a6f4..4b703f5 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -6,9 +6,9 @@ from flaschengeist import logger def login_required(**kwargs): - from .controller.accessTokenController import AccessTokenController + from .controller.sessionController import SessionController - ac_controller = AccessTokenController() + ac_controller = SessionController() permissions = None if "permissions" in kwargs: permissions = kwargs["roles"] @@ -16,7 +16,7 @@ def login_required(**kwargs): def real_decorator(func): @wraps(func) def wrapper(*args, **kwargs): - token = request.headers.get("Token") + token = request.headers.get("Authorization").split(" ")[-1] access_token = ac_controller.validate_token(token, request.user_agent, permissions) if access_token: kwargs["access_token"] = access_token diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/session.py similarity index 65% rename from flaschengeist/system/models/accessToken.py rename to flaschengeist/system/models/session.py index f94212c..2a1857a 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/session.py @@ -1,11 +1,13 @@ from datetime import datetime, timedelta, timezone + +from .user import User from ..database import db from secrets import compare_digest from flaschengeist import logger -class AccessToken(db.Model): - """Model for an AccessToken +class Session(db.Model): + """Model for a Session Args: expires: Is a Datetime from current Time. @@ -16,20 +18,20 @@ class AccessToken(db.Model): __tablename__ = "session" id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id")) - user = db.relationship("User", back_populates="sessions") + user: User = db.relationship("User", back_populates="sessions") - expires = db.Column(db.DateTime) - token = db.Column(db.String(32), unique=True) - lifetime = db.Column(db.Integer) - browser = db.Column(db.String(30)) - platform = db.Column(db.String(30)) + expires: datetime = db.Column(db.DateTime) + token: str = db.Column(db.String(32), unique=True) + lifetime: int = db.Column(db.Integer) + browser: str = db.Column(db.String(30)) + platform: str = db.Column(db.String(30)) def refresh(self): """Update the Timestamp Update the Timestamp to the current Time. """ - logger.debug("update timestamp from access token {{ {} }}".format(self)) + logger.debug("update timestamp from session with token {{ {} }}".format(self)) self.expires = datetime.utcnow() + timedelta(seconds=self.lifetime) def serialize(self): @@ -42,14 +44,10 @@ class AccessToken(db.Model): "token": self.token, "expires": self.expires.replace(tzinfo=timezone.utc), "lifetime": self.lifetime, + "user": self.user, "browser": self.browser, "platform": self.platform, } def __eq__(self, token): return compare_digest(self.token, token) - - def __str__(self): - return "AccessToken(user={}, token={}, expires={}, lifetime={})".format( - self.user, self.token, self.expires, self.lifetime - ) diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index 522611c..8fb1624 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -28,13 +28,13 @@ class User(db.Model): __tablename__ = "user" id = db.Column(db.Integer, primary_key=True) - uid = db.Column(db.String(30)) + userid = db.Column(db.String(30)) display_name = db.Column(db.String(30)) firstname = db.Column(db.String(30)) lastname = db.Column(db.String(30)) mail = db.Column(db.String(30)) roles = db.relationship("Role", secondary=association_table) - sessions = db.relationship("AccessToken", back_populates="user") + sessions = db.relationship("Session", back_populates="user") attributes = db.relationship( "UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete" ) @@ -53,8 +53,8 @@ class User(db.Model): def update_data(self, data): logger.debug("update data of user") - if "uid" in data: - self.uid = data["uid"] + if "userid" in data: + self.userid = data["userid"] if "firstname" in data: self.firstname = data["firstname"] if "lastname" in data: @@ -76,7 +76,7 @@ class User(db.Model): def serialize(self): return { - "userid": self.uid, + "userid": self.userid, "display_name": self.display_name, "firstname": self.firstname, "lastname": self.lastname, From 233660d452523100cffabe541af502446e00cddf Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 19 Oct 2020 01:42:54 +0200 Subject: [PATCH 052/446] Added export target to starter script. Allows export of typescript interfaces of backend models --- run_flaschengeist | 65 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/run_flaschengeist b/run_flaschengeist index 4a89b5d..3d1ce1f 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,11 +1,14 @@ #!/usr/bin/python3 -import sys +import inspect import argparse import bjoern +import sqlalchemy +from sqlalchemy.orm import RelationshipProperty, ColumnProperty def install(arguments): from flaschengeist.app import create_app, install_all + app = create_app() with app.app_context(): install_all() @@ -13,6 +16,7 @@ def install(arguments): def run(arguments): from flaschengeist.app import create_app + app = create_app() with app.app_context(): if arguments.debug: @@ -21,18 +25,69 @@ def run(arguments): bjoern.run(app, arguments.host, arguments.port, reuse_port=True) -if __name__ == '__main__': +def export(arguments): + import flaschengeist.system.models as models + known = [] + done = [] + + def orm_type(attr): + if hasattr(attr, 'type'): + tt = attr.type + if isinstance(attr, ColumnProperty): + tt = attr.columns[0].type + elif isinstance(attr, RelationshipProperty): + proto = "{}" + if attr.key.endswith("s"): + proto = "Array<{}>" + return proto.format(attr.mapper.class_.__name__) + else: + raise TypeError("Couldn't inspect type.") + tt = tt.__str__().split("(")[0] + return {"INTEGER": "number", + "VARCHAR": "string", + "DATETIME": "Date"}[tt] + + def walker(mod, file): + if inspect.isclass(mod[1]) and mod[1].__module__.startswith(models.__name__) and mod[0] not in done: + mapper = sqlalchemy.inspect(mod[1], False) + if mapper is not None: + file.write("interface {} {{\n".format(mod[0])) + for desc in mapper.attrs: + file.write(" {}: {};\n".format(desc.key, orm_type(desc))) + file.write("}\n") + done.append(mod[0]) + + elif inspect.ismodule(mod[1]) and mod[1].__name__.startswith(models.__name__) and mod[1].__name__ not in known: + known.append(mod[1].__name__) + for cls in inspect.getmembers(mod[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): + walker(cls, file) + + from flaschengeist.app import create_app + + app = create_app() + with app.app_context(): + with open(arguments.file, "w") as file: + walker(("models", models), file) + + +if __name__ == "__main__": # create the top-level parser parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(help='sub-command help', dest="sub_command") + subparsers = parser.add_subparsers(help="sub-command help", dest="sub_command") subparsers.required = True - parser_run = subparsers.add_parser('run', help='run flaschengeist') + parser_run = subparsers.add_parser("run", help="run flaschengeist") parser_run.set_defaults(func=run) parser_run.add_argument("--host", help="set hostname to listen on", default="127.0.0.1") parser_run.add_argument("--port", help="set port to listen on", type=int, default=5000) parser_run.add_argument("--debug", help="run in debug mode", action="store_true") - parser_install = subparsers.add_parser('install', help='run database setup for flaschengeist and all installed plugins') + parser_install = subparsers.add_parser( + "install", help="run database setup for flaschengeist and all installed plugins" + ) parser_install.set_defaults(func=install) + parser_export = subparsers.add_parser("export", help="export models to typescript interfaces") + parser_export.set_defaults(func=export) + parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts") + args = parser.parse_args() args.func(args) From 28b202cf305fecb60576e43e3328036e198cfdff Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 19 Oct 2020 13:16:23 +0200 Subject: [PATCH 053/446] Detect version of plugins from setup.py, updated Readme --- flaschengeist/modules/__init__.py | 4 +++- flaschengeist/readme.md | 33 ++++++++++++------------------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index e84ac55..806bd95 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -1,3 +1,5 @@ +import pkg_resources + from flaschengeist.system.hook import HookCall send_message_hook = HookCall("send_message") @@ -7,7 +9,7 @@ class Plugin: def __init__(self, config=None, blueprint=None, permissions={}): self.blueprint = blueprint self.permissions = permissions - self.version = "dummy" + self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version def install(self): """Installation routine diff --git a/flaschengeist/readme.md b/flaschengeist/readme.md index e3dc10c..d860d7c 100644 --- a/flaschengeist/readme.md +++ b/flaschengeist/readme.md @@ -68,23 +68,18 @@ Or if you just want to use `git blame`, configure git like this: example_bp = Blueprint("example", __name__, url_prefix="/example") permissions = {"EXAMPLE_HELLO": "example_hello"} - - def register(): - # If no model is needed: - # return Plugin(schedule_bp, permissions) - # else if model is used: - class ExamplePlugin(Plugin): - def install(self): - from flaschengeist.system.database import db - import .model - db.create_all() - db.session.commit() - - return ExamplePlugin(schedule_bp, permissions) - - - - @schedule_bp.route("/hello", methods=['GET']) + class PluginExample(Plugin): + def __init__(self, conf): + super().__init__(blueprint=example_bp, permissions=permissions) + + def install(self): + from flaschengeist.system.database import db + import .model + db.create_all() + db.session.commit() + + + @example_bp.route("/hello", methods=['GET']) @login_required(roles=['example_hello']) def __hello(id, **kwargs): return "Hello" @@ -94,8 +89,6 @@ Optional, only needed if you need your own models (database) from flaschengeist.system.database import db - model_name = __name__ - class ExampleModel(db.Model): """Example Model""" @@ -115,7 +108,7 @@ Optional, only needed if you need your own models (database) ], entry_points={ "flaschengeist.plugin": [ - "example = flaschengeist-example-plugin:register" "roles = flaschengeist.modules.roles:register", + "example = flaschengeist-example-plugin:ExampleModel" ] }, ) From addfb7c7c463de5f44733896b06cb5fb480bedf8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 19 Oct 2020 16:48:34 +0200 Subject: [PATCH 054/446] Some more on autodetection API interfaces for frontend * Breaks API for Session and User --- flaschengeist/modules/auth/__init__.py | 28 +++-- flaschengeist/modules/roles/__init__.py | 1 + flaschengeist/modules/schedule/__init__.py | 1 + .../system/controller/sessionController.py | 8 +- .../system/controller/userController.py | 2 + flaschengeist/system/hook.py | 2 +- flaschengeist/system/models/__init__.py | 7 ++ flaschengeist/system/models/session.py | 27 ++--- flaschengeist/system/models/user.py | 109 ++++++++---------- run_flaschengeist | 65 ++++++----- 10 files changed, 131 insertions(+), 119 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 0c68b1d..1c692fb 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -12,6 +12,7 @@ from flaschengeist import logger from flaschengeist.modules import Plugin from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import sessionController, userController, messageController +from flaschengeist.system.models.session import Session session_controller = LocalProxy(lambda: sessionController.SessionController()) auth_bp = Blueprint("auth", __name__) @@ -21,6 +22,7 @@ class AuthRoutePlugin(Plugin): def __init__(self, conf): super().__init__(blueprint=auth_bp) + ################################################# # Routes # # # @@ -60,21 +62,21 @@ def _login(): # Lets cleanup the DB session_controller.clear_expired() - return jsonify({"session": session, "permissions": user.get_permissions()}) + return jsonify({"session": session, "user": user}) @auth_bp.route("/auth", methods=["GET"]) @login_required() -def _get_tokens(access_token, **kwargs): - tokens = session_controller.get_users_sessions(access_token.user) - a = messageController.Message(access_token.user, "Go", "Bar") +def _get_sessions(access_token: Session, **kwargs): + tokens = session_controller.get_users_sessions(access_token._user) + a = messageController.Message(access_token._user, "Go", "Bar") messageController.send_message(a) return jsonify(tokens) @auth_bp.route("/auth/", methods=["DELETE"]) @login_required() -def _delete_token(access_token, token, **kwargs): +def _delete_session(access_token, token, **kwargs): logger.debug("Try to delete access token {{ {} }}".format(token)) token = session_controller.get_session(token, access_token.user) if not token: @@ -89,14 +91,26 @@ def _delete_token(access_token, token, **kwargs): @auth_bp.route("/auth/", methods=["GET"]) @login_required() -def _get_token(token, access_token, **kwargs): +def _get_session(token, access_token, **kwargs): logger.debug("get token {{ {} }}".format(token)) session = session_controller.get_session(token, access_token.user) if not token: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden - return jsonify({"session": session, "permissions": session.user.get_permissions()}) + return jsonify(session) + + +@auth_bp.route("/auth//user", methods=["GET"]) +@login_required() +def _get_assocd_user(token, access_token, **kwargs): + logger.debug("get token {{ {} }}".format(token)) + session = session_controller.get_session(token, access_token.user) + if not token: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + return jsonify(session._user) @auth_bp.route("/auth/", methods=["PUT"]) diff --git a/flaschengeist/modules/roles/__init__.py b/flaschengeist/modules/roles/__init__.py index 1decdea..059dfc0 100644 --- a/flaschengeist/modules/roles/__init__.py +++ b/flaschengeist/modules/roles/__init__.py @@ -12,6 +12,7 @@ class RolesPlugin(Plugin): def __init__(self, config): super().__init__(config, roles_bp) + ###################################################### # Routes # # # diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index 9527cb2..9892b7f 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -16,6 +16,7 @@ class SchedulePlugin(Plugin): def __init__(self, config): super().__init__(blueprint=schedule_bp) + #################################################################################### # Routes # # # diff --git a/flaschengeist/system/controller/sessionController.py b/flaschengeist/system/controller/sessionController.py index f92ace7..2f55b2f 100644 --- a/flaschengeist/system/controller/sessionController.py +++ b/flaschengeist/system/controller/sessionController.py @@ -62,7 +62,11 @@ class SessionController(metaclass=Singleton): logger.debug("create access token") token_str = secrets.token_hex(16) session = Session( - token=token_str, user=user, lifetime=self.lifetime, browser=user_agent.browser, platform=user_agent.platform + token=token_str, + _user=user, + lifetime=self.lifetime, + browser=user_agent.browser, + platform=user_agent.platform, ) session.refresh() db.session.add(session) @@ -89,7 +93,7 @@ class SessionController(metaclass=Singleton): return session def get_users_sessions(self, user): - return Session.query.filter(Session.user == user) + return Session.query.filter(Session._user == user) @staticmethod def delete_session(token: Session): diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index 67a2008..b58cc04 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -19,6 +19,8 @@ def login_user(username, password): def update_user(user): current_app.config["FG_AUTH_BACKEND"].update_user(user) + if not user.display_name: + user.display_name = user.firstname[0] + user.lastname db.session.commit() diff --git a/flaschengeist/system/hook.py b/flaschengeist/system/hook.py index 729269f..e855341 100644 --- a/flaschengeist/system/hook.py +++ b/flaschengeist/system/hook.py @@ -17,4 +17,4 @@ class HookCall(object): def __call__(self, function): _hook_dict[self.name] = function - return function \ No newline at end of file + return function diff --git a/flaschengeist/system/models/__init__.py b/flaschengeist/system/models/__init__.py index e69de29..6463684 100644 --- a/flaschengeist/system/models/__init__.py +++ b/flaschengeist/system/models/__init__.py @@ -0,0 +1,7 @@ +class ModelSerializeMixin: + def serialize(self): + d = {param: getattr(self, param) for param in self.__class__.__annotations__ if not param.startswith("_")} + if len(d) == 1: + key, value = d.popitem() + return value + return d diff --git a/flaschengeist/system/models/session.py b/flaschengeist/system/models/session.py index 2a1857a..488fe46 100644 --- a/flaschengeist/system/models/session.py +++ b/flaschengeist/system/models/session.py @@ -1,12 +1,13 @@ from datetime import datetime, timedelta, timezone +from . import ModelSerializeMixin from .user import User from ..database import db from secrets import compare_digest from flaschengeist import logger -class Session(db.Model): +class Session(db.Model, ModelSerializeMixin): """Model for a Session Args: @@ -16,16 +17,16 @@ class Session(db.Model): """ __tablename__ = "session" - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey("user.id")) - user: User = db.relationship("User", back_populates="sessions") - expires: datetime = db.Column(db.DateTime) token: str = db.Column(db.String(32), unique=True) lifetime: int = db.Column(db.Integer) browser: str = db.Column(db.String(30)) platform: str = db.Column(db.String(30)) + _id = db.Column("id", db.Integer, primary_key=True) + _user: User = db.relationship("User", back_populates="_sessions") + _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id")) + def refresh(self): """Update the Timestamp @@ -33,21 +34,7 @@ class Session(db.Model): """ logger.debug("update timestamp from session with token {{ {} }}".format(self)) self.expires = datetime.utcnow() + timedelta(seconds=self.lifetime) - - def serialize(self): - """Create Dic to dump in JSON - - Returns: - A Dic with static Attributes. - """ - return { - "token": self.token, - "expires": self.expires.replace(tzinfo=timezone.utc), - "lifetime": self.lifetime, - "user": self.user, - "browser": self.browser, - "platform": self.platform, - } + self.expires.replace(tzinfo=timezone.utc) def __eq__(self, token): return compare_digest(self.token, token) diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index 8fb1624..d6a2f64 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -1,9 +1,10 @@ -from ..database import db -from sqlalchemy.orm.collections import attribute_mapped_collection -from flask import current_app -from werkzeug.local import LocalProxy +from typing import List -logger = LocalProxy(lambda: current_app.logger) +from sqlalchemy.orm.collections import attribute_mapped_collection + +from . import ModelSerializeMixin +from ..database import db +from ... import logger association_table = db.Table( "user_x_role", @@ -11,8 +12,31 @@ association_table = db.Table( db.Column("role_id", db.Integer, db.ForeignKey("role.id")), ) +role_permission_association_table = db.Table( + "role_x_permission", + db.Column("role_id", db.Integer, db.ForeignKey("role.id")), + db.Column("permission_id", db.Integer, db.ForeignKey("permission.id")), +) -class User(db.Model): + +class Permission(db.Model, ModelSerializeMixin): + __tablename__ = "permission" + name: str = db.Column(db.String(30), unique=True) + + _id = db.Column("id", db.Integer, primary_key=True) + + +class Role(db.Model, ModelSerializeMixin): + __tablename__ = "role" + name: str = db.Column(db.String(30), unique=True) + permissions: [Permission] = db.relationship( + "Permission", secondary=role_permission_association_table, cascade="all, delete" + ) + + _id = db.Column("id", db.Integer, primary_key=True) + + +class User(db.Model, ModelSerializeMixin): """Database Object for User Table for all saved User @@ -27,23 +51,24 @@ class User(db.Model): """ __tablename__ = "user" - id = db.Column(db.Integer, primary_key=True) - userid = db.Column(db.String(30)) - display_name = db.Column(db.String(30)) - firstname = db.Column(db.String(30)) - lastname = db.Column(db.String(30)) - mail = db.Column(db.String(30)) - roles = db.relationship("Role", secondary=association_table) - sessions = db.relationship("Session", back_populates="user") - attributes = db.relationship( - "UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete" + userid: str = db.Column(db.String(30)) + display_name: str = db.Column(db.String(30)) + firstname: str = db.Column(db.String(30)) + lastname: str = db.Column(db.String(30)) + mail: str = db.Column(db.String(30)) + roles: [Role] = db.relationship("Role", secondary=association_table) + + _id = db.Column("id", db.Integer, primary_key=True) + _sessions = db.relationship("Session", back_populates="_user") + _attributes = db.relationship( + "_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete" ) def set_attribute(self, name, value): - if name in self.attributes: - self.attributes[name].value = value + if name in self._attributes: + self._attributes[name].value = value else: - self.attributes[name] = UserAttribute(name=name, value=value) + self._attributes[name] = _UserAttribute(name=name, value=value) def add_role(self, name): r = Role.query.filter_by(name=name).first() @@ -74,46 +99,10 @@ class User(db.Model): return True return False - def serialize(self): - return { - "userid": self.userid, - "display_name": self.display_name, - "firstname": self.firstname, - "lastname": self.lastname, - "mail": self.mail, - "roles": [r.name for r in self.roles], - } - -class UserAttribute(db.Model): +class _UserAttribute(db.Model, ModelSerializeMixin): __tablename__ = "user_attribute" - id = db.Column(db.Integer, primary_key=True) - user = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - name = db.Column(db.String(30)) - value = db.Column(db.String(192)) - - -role_permission_association_table = db.Table( - "role_x_permission", - db.Column("role_id", db.Integer, db.ForeignKey("role.id")), - db.Column("permission_id", db.Integer, db.ForeignKey("permission.id")), -) - - -class Role(db.Model): - __tablename__ = "role" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(30), unique=True) - permissions = db.relationship("Permission", secondary=role_permission_association_table, cascade="all, delete") - - def serialize(self): - return self.name - - -class Permission(db.Model): - __tablename__ = "permission" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(30), unique=True) - - def serialize(self): - return self.name + id = db.Column("id", db.Integer, primary_key=True) + user: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), nullable=False) + name: str = db.Column(db.String(30)) + value: str = db.Column(db.String(192)) diff --git a/run_flaschengeist b/run_flaschengeist index 3d1ce1f..7942c54 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,6 +1,8 @@ #!/usr/bin/python3 import inspect import argparse +from datetime import datetime + import bjoern import sqlalchemy from sqlalchemy.orm import RelationshipProperty, ColumnProperty @@ -27,47 +29,53 @@ def run(arguments): def export(arguments): import flaschengeist.system.models as models + known = [] done = [] + classes = {} - def orm_type(attr): - if hasattr(attr, 'type'): - tt = attr.type - if isinstance(attr, ColumnProperty): - tt = attr.columns[0].type - elif isinstance(attr, RelationshipProperty): - proto = "{}" - if attr.key.endswith("s"): - proto = "Array<{}>" - return proto.format(attr.mapper.class_.__name__) + def pytype(cls): + if isinstance(cls, list): + return "Array<{}>".format(pytype(cls[0])) + mapper = {"str": "string", "int": "number", "datetime": "Date"} + if cls.__name__ in mapper: + return mapper[cls.__name__] else: - raise TypeError("Couldn't inspect type.") - tt = tt.__str__().split("(")[0] - return {"INTEGER": "number", - "VARCHAR": "string", - "DATETIME": "Date"}[tt] + return cls.__name__ - def walker(mod, file): - if inspect.isclass(mod[1]) and mod[1].__module__.startswith(models.__name__) and mod[0] not in done: - mapper = sqlalchemy.inspect(mod[1], False) - if mapper is not None: - file.write("interface {} {{\n".format(mod[0])) - for desc in mapper.attrs: - file.write(" {}: {};\n".format(desc.key, orm_type(desc))) - file.write("}\n") - done.append(mod[0]) - - elif inspect.ismodule(mod[1]) and mod[1].__name__.startswith(models.__name__) and mod[1].__name__ not in known: + def walker(mod): + if inspect.ismodule(mod[1]) and mod[1].__name__.startswith(models.__name__) and mod[1].__name__ not in known: known.append(mod[1].__name__) for cls in inspect.getmembers(mod[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): - walker(cls, file) + walker(cls) + elif ( + inspect.isclass(mod[1]) + and mod[1].__module__.startswith(models.__name__) + and mod[0] not in classes + and not mod[0].startswith("_") + and hasattr(mod[1], "__annotations__") + ): + d = {param: pytype(ptype) for param, ptype in mod[1].__annotations__.items() if not param.startswith("_")} + if len(d) == 1: + key, value = d.popitem() + classes[mod[0]] = value + else: + classes[mod[0]] = d from flaschengeist.app import create_app app = create_app() with app.app_context(): + walker(("models", models)) with open(arguments.file, "w") as file: - walker(("models", models), file) + for cls, params in classes.items(): + if isinstance(params, str): + file.write("type {} = {};\n".format(cls, params)) + else: + file.write("interface {} {{\n".format(cls)) + for name in params: + file.write(" {}: {};\n".format(name, params[name])) + file.write("}\n") if __name__ == "__main__": @@ -88,6 +96,5 @@ if __name__ == "__main__": parser_export.set_defaults(func=export) parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts") - args = parser.parse_args() args.func(args) From db96d4b178e22a4db675ce2691f06f91dbd46322 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 20 Oct 2020 17:53:29 +0200 Subject: [PATCH 055/446] Made database datetime timezone aware --- flaschengeist/app.py | 1 + flaschengeist/modules/auth/__init__.py | 2 +- .../system/controller/sessionController.py | 11 +++--- flaschengeist/system/models/__init__.py | 35 +++++++++++++++++++ flaschengeist/system/models/session.py | 7 ++-- 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 19b0254..bfe8cd3 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -21,6 +21,7 @@ class CustomJSONEncoder(JSONEncoder): if isinstance(o, datetime): return o.isoformat() + # Check if iterable try: iterable = iter(o) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 1c692fb..8f7a426 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -14,7 +14,7 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import sessionController, userController, messageController from flaschengeist.system.models.session import Session -session_controller = LocalProxy(lambda: sessionController.SessionController()) +session_controller: sessionController.SessionController = LocalProxy(lambda: sessionController.SessionController()) auth_bp = Blueprint("auth", __name__) diff --git a/flaschengeist/system/controller/sessionController.py b/flaschengeist/system/controller/sessionController.py index 2f55b2f..b7607c9 100644 --- a/flaschengeist/system/controller/sessionController.py +++ b/flaschengeist/system/controller/sessionController.py @@ -36,7 +36,7 @@ class SessionController(metaclass=Singleton): access_token = Session.query.filter_by(token=token).one_or_none() if access_token: logger.debug("token found, check if expired or invalid user agent differs") - if access_token.expires >= datetime.utcnow() and ( + if access_token.expires >= datetime.now(timezone.utc) and ( access_token.browser == user_agent.browser and access_token.platform == user_agent.platform ): if not permissions or access_token.user.has_permissions(permissions): @@ -71,7 +71,6 @@ class SessionController(metaclass=Singleton): session.refresh() db.session.add(session) db.session.commit() - logger.debug("access token is {{ {} }}".format(session.token)) return session @@ -95,8 +94,7 @@ class SessionController(metaclass=Singleton): def get_users_sessions(self, user): return Session.query.filter(Session._user == user) - @staticmethod - def delete_session(token: Session): + def delete_session(self, token: Session): """Deletes given Session Args: @@ -105,8 +103,7 @@ class SessionController(metaclass=Singleton): db.session.delete(token) db.session.commit() - @staticmethod - def update_session(session): + def update_session(self, session): session.refresh() db.session.commit() @@ -117,6 +114,6 @@ class SessionController(metaclass=Singleton): def clear_expired(self): """Remove expired tokens from database""" logger.debug("Clear expired Sessions") - deleted = Session.query.filter(Session.expires < datetime.utcnow()).delete() + deleted = Session.query.filter(Session.expires < datetime.now(timezone.utc)).delete() logger.debug("{} sessions have been removed".format(deleted)) db.session.commit() diff --git a/flaschengeist/system/models/__init__.py b/flaschengeist/system/models/__init__.py index 6463684..2fd22e1 100644 --- a/flaschengeist/system/models/__init__.py +++ b/flaschengeist/system/models/__init__.py @@ -1,3 +1,7 @@ +import datetime +from sqlalchemy.types import DateTime, TypeDecorator + + class ModelSerializeMixin: def serialize(self): d = {param: getattr(self, param) for param in self.__class__.__annotations__ if not param.startswith("_")} @@ -5,3 +9,34 @@ class ModelSerializeMixin: key, value = d.popitem() return value return d + + +class UtcDateTime(TypeDecorator): + """Almost equivalent to :class:`~sqlalchemy.types.DateTime` with + ``timezone=True`` option, but it differs from that by: + - Never silently take naive :class:`~datetime.datetime`, instead it + always raise :exc:`ValueError` unless time zone aware value. + - :class:`~datetime.datetime` value's :attr:`~datetime.datetime.tzinfo` + is always converted to UTC. + - Unlike SQLAlchemy's built-in :class:`~sqlalchemy.types.DateTime`, + it never return naive :class:`~datetime.datetime`, but time zone + aware value, even with SQLite or MySQL. + """ + + impl = DateTime(timezone=True) + + def process_bind_param(self, value, dialect): + if value is not None: + if not isinstance(value, datetime.datetime): + raise TypeError('expected datetime.datetime, not ' + + repr(value)) + elif value.tzinfo is None: + raise ValueError('naive datetime is disallowed') + return value.astimezone(datetime.timezone.utc) + + def process_result_value(self, value, dialect): + if value is not None: + if value.tzinfo is not None: + value = value.astimezone(datetime.timezone.utc) + value = value.replace(tzinfo=datetime.timezone.utc) + return value diff --git a/flaschengeist/system/models/session.py b/flaschengeist/system/models/session.py index 488fe46..a17ba8b 100644 --- a/flaschengeist/system/models/session.py +++ b/flaschengeist/system/models/session.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta, timezone -from . import ModelSerializeMixin +from . import ModelSerializeMixin, UtcDateTime from .user import User from ..database import db from secrets import compare_digest @@ -17,7 +17,7 @@ class Session(db.Model, ModelSerializeMixin): """ __tablename__ = "session" - expires: datetime = db.Column(db.DateTime) + expires: datetime = db.Column(UtcDateTime) token: str = db.Column(db.String(32), unique=True) lifetime: int = db.Column(db.Integer) browser: str = db.Column(db.String(30)) @@ -33,8 +33,7 @@ class Session(db.Model, ModelSerializeMixin): Update the Timestamp to the current Time. """ logger.debug("update timestamp from session with token {{ {} }}".format(self)) - self.expires = datetime.utcnow() + timedelta(seconds=self.lifetime) - self.expires.replace(tzinfo=timezone.utc) + self.expires = datetime.now(timezone.utc) + timedelta(seconds=self.lifetime) def __eq__(self, token): return compare_digest(self.token, token) From 854a1f61564602b60c95776a70fc517e2f5bfa37 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 20 Oct 2020 18:52:02 +0200 Subject: [PATCH 056/446] Improved some permission related stuff, rewrote session controller --- flaschengeist/modules/__init__.py | 3 +- flaschengeist/modules/auth/__init__.py | 22 +-- flaschengeist/modules/roles/__init__.py | 11 +- flaschengeist/modules/schedule/__init__.py | 14 +- flaschengeist/modules/users/__init__.py | 6 +- flaschengeist/system/controller/__init__.py | 7 - .../system/controller/roleController.py | 1 - .../system/controller/sessionController.py | 180 +++++++++--------- flaschengeist/system/decorator.py | 24 +-- 9 files changed, 125 insertions(+), 143 deletions(-) diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index 806bd95..9b085fb 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -6,7 +6,7 @@ send_message_hook = HookCall("send_message") class Plugin: - def __init__(self, config=None, blueprint=None, permissions={}): + def __init__(self, config=None, blueprint=None, permissions=[]): self.blueprint = blueprint self.permissions = permissions self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version @@ -20,6 +20,7 @@ class Plugin: def serialize(self): return { "version": self.version, + "permissions": self.permissions } diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 8f7a426..594bb01 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -6,7 +6,6 @@ from flask import Blueprint, request, jsonify from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized -from werkzeug.local import LocalProxy from flaschengeist import logger from flaschengeist.modules import Plugin @@ -14,7 +13,6 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import sessionController, userController, messageController from flaschengeist.system.models.session import Session -session_controller: sessionController.SessionController = LocalProxy(lambda: sessionController.SessionController()) auth_bp = Blueprint("auth", __name__) @@ -56,19 +54,19 @@ def _login(): if not user: raise Unauthorized logger.debug("user is {{ {} }}".format(user)) - session = session_controller.create(user, user_agent=request.user_agent) + session = sessionController.create(user, user_agent=request.user_agent) logger.debug("token is {{ {} }}".format(session.token)) logger.info("User {{ {} }} success login.".format(userid)) # Lets cleanup the DB - session_controller.clear_expired() + sessionController.clear_expired() return jsonify({"session": session, "user": user}) @auth_bp.route("/auth", methods=["GET"]) @login_required() def _get_sessions(access_token: Session, **kwargs): - tokens = session_controller.get_users_sessions(access_token._user) + tokens = sessionController.get_users_sessions(access_token._user) a = messageController.Message(access_token._user, "Go", "Bar") messageController.send_message(a) return jsonify(tokens) @@ -78,14 +76,14 @@ def _get_sessions(access_token: Session, **kwargs): @login_required() def _delete_session(access_token, token, **kwargs): logger.debug("Try to delete access token {{ {} }}".format(token)) - token = session_controller.get_session(token, access_token.user) + token = sessionController.get_session(token, access_token.user) if not token: logger.debug("Token not found in database!") # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden - session_controller.delete_session(token) - session_controller.clear_expired() + sessionController.delete_session(token) + sessionController.clear_expired() return jsonify({"ok": "ok"}) @@ -93,7 +91,7 @@ def _delete_session(access_token, token, **kwargs): @login_required() def _get_session(token, access_token, **kwargs): logger.debug("get token {{ {} }}".format(token)) - session = session_controller.get_session(token, access_token.user) + session = sessionController.get_session(token, access_token.user) if not token: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same @@ -105,7 +103,7 @@ def _get_session(token, access_token, **kwargs): @login_required() def _get_assocd_user(token, access_token, **kwargs): logger.debug("get token {{ {} }}".format(token)) - session = session_controller.get_session(token, access_token.user) + session = sessionController.get_session(token, access_token.user) if not token: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same @@ -116,7 +114,7 @@ def _get_assocd_user(token, access_token, **kwargs): @auth_bp.route("/auth/", methods=["PUT"]) @login_required() def _set_lifetime(token, access_token, **kwargs): - token = session_controller.get_token(token, access_token.user) + token = sessionController.get_token(token, access_token.user) if not token: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same @@ -124,7 +122,7 @@ def _set_lifetime(token, access_token, **kwargs): try: lifetime = request.get_json()["value"] logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) - session_controller.set_lifetime(token, lifetime) + sessionController.set_lifetime(token, lifetime) return jsonify({"ok": "ok"}) except (KeyError, TypeError): raise BadRequest diff --git a/flaschengeist/modules/roles/__init__.py b/flaschengeist/modules/roles/__init__.py index 059dfc0..ba332e7 100644 --- a/flaschengeist/modules/roles/__init__.py +++ b/flaschengeist/modules/roles/__init__.py @@ -6,11 +6,12 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import roleController roles_bp = Blueprint("roles", __name__) +roles_permission = "roles_edit" class RolesPlugin(Plugin): def __init__(self, config): - super().__init__(config, roles_bp) + super().__init__(config, roles_bp, permissions=[roles_permission]) ###################################################### @@ -26,8 +27,8 @@ class RolesPlugin(Plugin): @roles_bp.route("/roles", methods=["POST"]) -@login_required() -def add_role(self): +@login_required(permissions=[roles_permission]) +def add_role(**kwargs): data = request.get_json() if not data or "name" not in data: raise BadRequest @@ -61,7 +62,7 @@ def __get_role(rid, **kwargs): @roles_bp.route("/roles/", methods=["PUT"]) -@login_required() +@login_required(permissions=[roles_permission]) def __edit_role(rid, **kwargs): role = roleController.get_role(rid) if not role: @@ -77,7 +78,7 @@ def __edit_role(rid, **kwargs): @roles_bp.route("/roles/", methods=["DELETE"]) -@login_required() +@login_required(permissions=[roles_permission]) def __delete_role(rid, **kwargs): if not roleController.delete_role(rid): raise NotFound diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index 9892b7f..70b5a2b 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -10,11 +10,13 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import eventController schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") +schedule_perms = {"EDIT_EVENT": "schedule_edit_event", + "NEW_EVENT": "schedule_create_event"} class SchedulePlugin(Plugin): def __init__(self, config): - super().__init__(blueprint=schedule_bp) + super().__init__(blueprint=schedule_bp, permissions=schedule_perms.values()) #################################################################################### @@ -40,8 +42,8 @@ class SchedulePlugin(Plugin): @schedule_bp.route("/events/", methods=["GET"]) -@login_required() # roles=['schedule_read']) -def __get_event(self, id, **kwargs): +@login_required() +def __get_event(id, **kwargs): event = eventController.get_event(id) if not event: raise NotFound @@ -51,7 +53,7 @@ def __get_event(self, id, **kwargs): @schedule_bp.route("/events", methods=["GET"]) @schedule_bp.route("/events//", methods=["GET"]) @schedule_bp.route("/events///", methods=["GET"]) -@login_required() # roles=['schedule_read']) +@login_required() def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, **kwargs): """Get Event objects for specified date (or month or year), if nothing set then events for current month are returned @@ -104,7 +106,7 @@ def __new_slot_kind(**kwargs): @schedule_bp.route("/events", methods=["POST"]) -@login_required() +@login_required(permissions=[schedule_perms["NEW_EVENT"]]) def __new_event(**kwargs): data = request.get_json() event = eventController.create_event( @@ -117,7 +119,7 @@ def __new_event(**kwargs): @schedule_bp.route("/events/", methods=["DELETE"]) -@login_required() +@login_required(permissions=[schedule_perms["EDIT_EVENT"]]) def __delete_event(event_id, **kwargs): if not eventController.delete_event(event_id): raise NotFound diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index 28f2247..fd2d11c 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -7,12 +7,12 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import userController users_bp = Blueprint("users", __name__) -permissions = {"EDIT_USER": "edit_user"} +users_perm = "users_edit_other" class UsersPlugin(Plugin): def __init__(self, config): - super().__init__(blueprint=users_bp, permissions=permissions) + super().__init__(blueprint=users_bp, permissions=[users_perm]) ################################################# # Routes # @@ -57,7 +57,7 @@ def __edit_user(uid, **kwargs): if not user: raise NotFound - if uid != kwargs["access_token"].user.userid and user.has_permissions(permissions["EDIT_USER"]): + if uid != kwargs["access_token"].user.userid and user.has_permissions([users_perm]): return Forbidden data = request.get_json() diff --git a/flaschengeist/system/controller/__init__.py b/flaschengeist/system/controller/__init__.py index 3776cb9..e69de29 100644 --- a/flaschengeist/system/controller/__init__.py +++ b/flaschengeist/system/controller/__init__.py @@ -1,7 +0,0 @@ -class Singleton(type): - _instances = {} - - def __call__(cls, *args, **kwargs): - if cls not in cls._instances: - cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) - return cls._instances[cls] diff --git a/flaschengeist/system/controller/roleController.py b/flaschengeist/system/controller/roleController.py index cdea0bc..71baa80 100644 --- a/flaschengeist/system/controller/roleController.py +++ b/flaschengeist/system/controller/roleController.py @@ -1,4 +1,3 @@ -from flask import current_app from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import BadRequest diff --git a/flaschengeist/system/controller/sessionController.py b/flaschengeist/system/controller/sessionController.py index b7607c9..4be467b 100644 --- a/flaschengeist/system/controller/sessionController.py +++ b/flaschengeist/system/controller/sessionController.py @@ -4,116 +4,112 @@ from flaschengeist.system.database import db from flaschengeist import logger from werkzeug.exceptions import Forbidden from datetime import datetime, timezone -from . import Singleton + +lifetime = 1800 -class SessionController(metaclass=Singleton): - """Control all created Sessions +def validate_token(token, user_agent, permissions): + """Verify session - This Class create, delete, find and manage Sessions. + Verify a Session and Roles so if the User has permission or not. + Retrieves the access token if valid else retrieves False - Attributes: - lifetime: Variable for the Lifetime of a Session in seconds. + Args: + token: Token to verify. + user_agent: User agent of browser to check + permissions: Permissions needed to access restricted routes + Returns: + A Session for this given Token or False. """ + logger.debug("check token {{ {} }} is valid".format(token)) + access_token = Session.query.filter_by(token=token).one_or_none() + if access_token: + logger.debug("token found, check if expired or invalid user agent differs") + if access_token.expires >= datetime.now(timezone.utc) and ( + access_token.browser == user_agent.browser and access_token.platform == user_agent.platform + ): + if not permissions or access_token.user.has_permissions(permissions): + access_token.refresh() + db.session.commit() + return access_token + else: + logger.debug("access token is out of date or invalid client used") + delete_session(access_token) + logger.debug("no valid access token with token: {{ {} }} and permissions: {{ {} }}".format(token, permissions)) + return False - def __init__(self, lifetime=1800): - self.lifetime = lifetime - def validate_token(self, token, user_agent, permissions): - """Verify session +def create(user, user_agent=None) -> Session: + """Create a Session - Verify a Session and Roles so if the User has permission or not. - Retrieves the access token if valid else retrieves False + Args: + user: For which User is to create a Session + user_agent: User agent to identify session - Args: - token: Token to verify. - user_agent: User agent of browser to check - permissions: Permissions needed to access restricted routes - Returns: - A Session for this given Token or False. - """ - logger.debug("check token {{ {} }} is valid".format(token)) - access_token = Session.query.filter_by(token=token).one_or_none() - if access_token: - logger.debug("token found, check if expired or invalid user agent differs") - if access_token.expires >= datetime.now(timezone.utc) and ( - access_token.browser == user_agent.browser and access_token.platform == user_agent.platform - ): - if not permissions or access_token.user.has_permissions(permissions): - access_token.refresh() - db.session.commit() - return access_token - else: - logger.debug("access token is out of date or invalid client used") - self.delete_session(access_token) - logger.debug("no valid access token with token: {{ {} }} and permissions: {{ {} }}".format(token, permissions)) - return False + Returns: + Session: A created Token for User + """ + logger.debug("create access token") + token_str = secrets.token_hex(16) + session = Session( + token=token_str, + _user=user, + lifetime=lifetime, + browser=user_agent.browser, + platform=user_agent.platform, + ) + session.refresh() + db.session.add(session) + db.session.commit() + logger.debug("access token is {{ {} }}".format(session.token)) + return session - def create(self, user, user_agent=None) -> Session: - """Create a Session - Args: - user: For which User is to create a Session - user_agent: User agent to identify session +def get_session(token, owner=None): + """Retrieves Session from token string - Returns: - Session: A created Token for User - """ - logger.debug("create access token") - token_str = secrets.token_hex(16) - session = Session( - token=token_str, - _user=user, - lifetime=self.lifetime, - browser=user_agent.browser, - platform=user_agent.platform, - ) - session.refresh() - db.session.add(session) - db.session.commit() - logger.debug("access token is {{ {} }}".format(session.token)) - return session + Args: + token (str): Token string + owner (User, optional): User owning the token - def get_session(self, token, owner=None): - """Retrieves Session from token string + Raises: + Forbidden: Raised if owner is set but does not match + Returns: + Session: Token object identified by given token string + """ + session = Session.query.filter(Session.token == token).one_or_none() + if session and (owner and owner != session.user): + raise Forbidden + return session - Args: - token (str): Token string - owner (User, optional): User owning the token - Raises: - Forbidden: Raised if owner is set but does not match - Returns: - Session: Token object identified by given token string - """ - session = Session.query.filter(Session.token == token).one_or_none() - if session and (owner and owner != session.user): - raise Forbidden - return session +def get_users_sessions(user): + return Session.query.filter(Session._user == user) - def get_users_sessions(self, user): - return Session.query.filter(Session._user == user) - def delete_session(self, token: Session): - """Deletes given Session +def delete_session(token: Session): + """Deletes given Session - Args: - token (Session): Token to delete - """ - db.session.delete(token) - db.session.commit() + Args: + token (Session): Token to delete + """ + db.session.delete(token) + db.session.commit() - def update_session(self, session): - session.refresh() - db.session.commit() - def set_lifetime(self, session, lifetime): - session.lifetime = lifetime - self.update_session(session) +def update_session(session): + session.refresh() + db.session.commit() - def clear_expired(self): - """Remove expired tokens from database""" - logger.debug("Clear expired Sessions") - deleted = Session.query.filter(Session.expires < datetime.now(timezone.utc)).delete() - logger.debug("{} sessions have been removed".format(deleted)) - db.session.commit() + +def set_lifetime(session, lifetime): + session.lifetime = lifetime + update_session(session) + + +def clear_expired(): + """Remove expired tokens from database""" + logger.debug("Clear expired Sessions") + deleted = Session.query.filter(Session.expires < datetime.now(timezone.utc)).delete() + logger.debug("{} sessions have been removed".format(deleted)) + db.session.commit() diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index 4b703f5..39b5694 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -3,21 +3,15 @@ from flask import request from werkzeug.exceptions import Unauthorized from flaschengeist import logger +from flaschengeist.system.controller import sessionController -def login_required(**kwargs): - from .controller.sessionController import SessionController - - ac_controller = SessionController() - permissions = None - if "permissions" in kwargs: - permissions = kwargs["roles"] - - def real_decorator(func): +def login_required(permissions=None): + def wrap(func): @wraps(func) - def wrapper(*args, **kwargs): - token = request.headers.get("Authorization").split(" ")[-1] - access_token = ac_controller.validate_token(token, request.user_agent, permissions) + def wrapped_f(*args, **kwargs): + token = list(filter(None, request.headers.get("Authorization").split(" ")))[-1] + access_token = sessionController.validate_token(token, request.user_agent, permissions) if access_token: kwargs["access_token"] = access_token logger.debug("token {{ {} }} is valid".format(token)) @@ -25,7 +19,5 @@ def login_required(**kwargs): else: logger.info("token {{ {} }} is not valid".format(token)) raise Unauthorized - - return wrapper - - return real_decorator + return wrapped_f + return wrap From 92626dc0c642ca8bf7744313290994255060b926 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 20 Oct 2020 19:25:10 +0200 Subject: [PATCH 057/446] Some work on event models --- .../system/controller/userController.py | 2 +- flaschengeist/system/models/event.py | 57 ++++++++----------- 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index b58cc04..1e33f24 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -20,7 +20,7 @@ def login_user(username, password): def update_user(user): current_app.config["FG_AUTH_BACKEND"].update_user(user) if not user.display_name: - user.display_name = user.firstname[0] + user.lastname + user.display_name = "{} {}".format(user.firstname, user.lastname) db.session.commit() diff --git a/flaschengeist/system/models/event.py b/flaschengeist/system/models/event.py index 09ae7e9..00d8a43 100644 --- a/flaschengeist/system/models/event.py +++ b/flaschengeist/system/models/event.py @@ -1,76 +1,65 @@ +from . import ModelSerializeMixin from ..database import db -class Event(db.Model): +class Event(db.Model, ModelSerializeMixin): """Model for an Event""" - __tablename__ = "event" id = db.Column(db.Integer, primary_key=True) begin = db.Column(db.DateTime, nullable=False) end = db.Column(db.DateTime) description = db.Column(db.String(240)) - kind_id = db.Column(db.Integer, db.ForeignKey("event_kind.id", ondelete="CASCADE"), nullable=False) kind = db.relationship("EventKind") slots = db.relationship("EventSlot", back_populates="event", cascade="all, delete") # notices = db.relationship("EventNotice", back_populates="event") - def serialize(self): - return {"id": self.id, "begin": self.begin, "end": self.end, "description": self.description, "kind": self.kind} + _kind_id = db.Column(db.Integer, db.ForeignKey("event_kind.id", ondelete="CASCADE"), nullable=False) -class EventKind(db.Model): +class EventKind(db.Model, ModelSerializeMixin): """Model for an EventKind""" - __tablename__ = "event_kind" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(30), nullable=False, unique=True) - def serialize(self): - return {"id": self.id, "name": self.name} - -class EventSlot(db.Model): +class EventSlot(db.Model, ModelSerializeMixin): """Model for an EventSlot""" - __tablename__ = "event_slot" id = db.Column(db.Integer, primary_key=True) start = db.Column(db.DateTime) end = db.Column(db.DateTime) - event_id = db.Column(db.Integer, db.ForeignKey("event.id"), nullable=False) - event = db.relationship("Event", back_populates="slots") slots = db.relationship("JobSlot", back_populates="event_slot") - def serialize(self): - return { - "id": self.id, - "start": self.start, - "end": self.end, - "event": self.event_id, - } + _event_id = db.Column(db.Integer, db.ForeignKey("event.id"), nullable=False) + _event = db.relationship("Event", back_populates="slots") -class JobSlot(db.Model): +class JobSlot(db.Model, ModelSerializeMixin): __tablename__ = "job_slot" id = db.Column(db.Integer, primary_key=True) needed_persons = db.Column(db.Numeric(precision=4, scale=2)) - event_slot_id = db.Column(db.Integer, db.ForeignKey("event_slot.id")) - event_slot = db.relationship("EventSlot", back_populates="slots") - kind_id = db.Column(db.Integer, db.ForeignKey("job_kind.id")) kind = db.relationship("JobKind") jobs = db.relationship("Job", back_populates="slot") + _event_slot_id = db.Column(db.Integer, db.ForeignKey("event_slot.id")) + _event_slot = db.relationship("EventSlot", back_populates="slots") + _kind_id = db.Column(db.Integer, db.ForeignKey("job_kind.id")) -class Job(db.Model): + +class Job(db.Model, ModelSerializeMixin): __tablename__ = "job" - id = db.Column(db.Integer, primary_key=True) - value = db.Column(db.Numeric(precision=3, scale=2)) - user_id = db.Column(db.Integer, db.ForeignKey("user.id")) user = db.relationship("User") - slot_id = db.Column(db.Integer, db.ForeignKey("job_slot.id")) - slot = db.relationship("JobSlot") + value = db.Column(db.Numeric(precision=3, scale=2)) + + _user_id = db.Column(db.Integer, db.ForeignKey("user.id")) + _slot_id = db.Column(db.Integer, db.ForeignKey("job_slot.id")) + _slot = db.relationship("JobSlot") + _id = db.Column(db.Integer, primary_key=True) -class JobKind(db.Model): +class JobKind(db.Model, ModelSerializeMixin): __tablename__ = "job_kind" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(30)) + name = db.Column(db.String(30), nullable=False, unique=True) + + _id = db.Column(db.Integer, primary_key=True) From b8db07b741f38d2e0b81fb4edaefe24a9d4ebb76 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 20 Oct 2020 19:34:14 +0200 Subject: [PATCH 058/446] Fixed plugin permissions installation --- flaschengeist/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index bfe8cd3..32648f0 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -68,7 +68,7 @@ def install_all(): logger.info("Install plugin {}".format(name)) plugin.install() if plugin.permissions: - roleController.create_permissions(plugin.permissions.values()) + roleController.create_permissions(plugin.permissions) def create_app(): From ba0c76a72719e4306a6133e3d660ac439ce718a4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 23 Oct 2020 02:03:06 +0200 Subject: [PATCH 059/446] Improved Typescript Interface generation, enabled it for events --- flaschengeist/system/models/__init__.py | 5 +- flaschengeist/system/models/event.py | 95 ++++++++++++++----------- flaschengeist/system/models/user.py | 8 +-- run_flaschengeist | 33 +++++---- 4 files changed, 74 insertions(+), 67 deletions(-) diff --git a/flaschengeist/system/models/__init__.py b/flaschengeist/system/models/__init__.py index 2fd22e1..595a52e 100644 --- a/flaschengeist/system/models/__init__.py +++ b/flaschengeist/system/models/__init__.py @@ -28,10 +28,9 @@ class UtcDateTime(TypeDecorator): def process_bind_param(self, value, dialect): if value is not None: if not isinstance(value, datetime.datetime): - raise TypeError('expected datetime.datetime, not ' + - repr(value)) + raise TypeError("expected datetime.datetime, not " + repr(value)) elif value.tzinfo is None: - raise ValueError('naive datetime is disallowed') + raise ValueError("naive datetime is disallowed") return value.astimezone(datetime.timezone.utc) def process_result_value(self, value, dialect): diff --git a/flaschengeist/system/models/event.py b/flaschengeist/system/models/event.py index 00d8a43..94b6e50 100644 --- a/flaschengeist/system/models/event.py +++ b/flaschengeist/system/models/event.py @@ -1,65 +1,74 @@ +from datetime import datetime +from typing import Optional + from . import ModelSerializeMixin +from .user import User from ..database import db -class Event(db.Model, ModelSerializeMixin): - """Model for an Event""" - __tablename__ = "event" - id = db.Column(db.Integer, primary_key=True) - begin = db.Column(db.DateTime, nullable=False) - end = db.Column(db.DateTime) - description = db.Column(db.String(240)) - kind = db.relationship("EventKind") - slots = db.relationship("EventSlot", back_populates="event", cascade="all, delete") - # notices = db.relationship("EventNotice", back_populates="event") - - _kind_id = db.Column(db.Integer, db.ForeignKey("event_kind.id", ondelete="CASCADE"), nullable=False) - - -class EventKind(db.Model, ModelSerializeMixin): - """Model for an EventKind""" - __tablename__ = "event_kind" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(30), nullable=False, unique=True) - - class EventSlot(db.Model, ModelSerializeMixin): """Model for an EventSlot""" + __tablename__ = "event_slot" - id = db.Column(db.Integer, primary_key=True) - start = db.Column(db.DateTime) - end = db.Column(db.DateTime) - slots = db.relationship("JobSlot", back_populates="event_slot") + id: int = db.Column(db.Integer, primary_key=True) + start: datetime = db.Column(db.DateTime) + end: Optional[datetime] = db.Column(db.DateTime) + slots: [any] = db.relationship("JobSlot", back_populates="_event_slot") - _event_id = db.Column(db.Integer, db.ForeignKey("event.id"), nullable=False) - _event = db.relationship("Event", back_populates="slots") + _event_id = db.Column("event_id", db.Integer, db.ForeignKey("event.id"), nullable=False) + _event = db.relationship("Event", back_populates="_slots") -class JobSlot(db.Model, ModelSerializeMixin): - __tablename__ = "job_slot" - id = db.Column(db.Integer, primary_key=True) - needed_persons = db.Column(db.Numeric(precision=4, scale=2)) - kind = db.relationship("JobKind") - jobs = db.relationship("Job", back_populates="slot") +class EventKind(db.Model, ModelSerializeMixin): + """Model for an EventKind""" - _event_slot_id = db.Column(db.Integer, db.ForeignKey("event_slot.id")) - _event_slot = db.relationship("EventSlot", back_populates="slots") - _kind_id = db.Column(db.Integer, db.ForeignKey("job_kind.id")) + __tablename__ = "event_kind" + name: str = db.Column(db.String(30), nullable=False, unique=True) + + _id: int = db.Column("id", db.Integer, primary_key=True) + + +class Event(db.Model, ModelSerializeMixin): + """Model for an Event""" + + __tablename__ = "event" + id: int = db.Column(db.Integer, primary_key=True) + begin: datetime = db.Column(db.DateTime, nullable=False) + end: datetime = db.Column(db.DateTime) + description: str = db.Column(db.String(240)) + kind: EventKind = db.relationship("EventKind") + # notices = db.relationship("EventNotice", back_populates="event") + + _kind_id = db.Column("kind_id", db.Integer, db.ForeignKey("event_kind.id", ondelete="CASCADE"), nullable=False) + _slots: [EventSlot] = db.relationship("EventSlot", back_populates="_event", cascade="all, delete") class Job(db.Model, ModelSerializeMixin): __tablename__ = "job" - user = db.relationship("User") - value = db.Column(db.Numeric(precision=3, scale=2)) + _user: User = db.relationship("User") + # user: str = column_property(_user.userid) + value: float = db.Column(db.Numeric(precision=3, scale=2)) - _user_id = db.Column(db.Integer, db.ForeignKey("user.id")) - _slot_id = db.Column(db.Integer, db.ForeignKey("job_slot.id")) + _id = db.Column("id", db.Integer, primary_key=True) + _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id")) + _slot_id = db.Column("slot_id", db.Integer, db.ForeignKey("job_slot.id")) _slot = db.relationship("JobSlot") - _id = db.Column(db.Integer, primary_key=True) class JobKind(db.Model, ModelSerializeMixin): __tablename__ = "job_kind" - name = db.Column(db.String(30), nullable=False, unique=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) - _id = db.Column(db.Integer, primary_key=True) + _id = db.Column("id", db.Integer, primary_key=True) + + +class JobSlot(db.Model, ModelSerializeMixin): + __tablename__ = "job_slot" + id: int = db.Column(db.Integer, primary_key=True) + needed_persons: float = db.Column(db.Numeric(precision=4, scale=2)) + kind: JobKind = db.relationship("JobKind") + jobs: [Job] = db.relationship("Job", back_populates="_slot") + + _event_slot_id = db.Column("event_slot_id", db.Integer, db.ForeignKey("event_slot.id")) + _event_slot = db.relationship("EventSlot", back_populates="slots") + _kind_id = db.Column("kind_id", db.Integer, db.ForeignKey("job_kind.id")) diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index d6a2f64..fa4bfe6 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -92,12 +92,8 @@ class User(db.Model, ModelSerializeMixin): def get_permissions(self): return ["user"] + [permission.name for role in self.roles for permission in role.permissions] - def has_permissions(self, permissions): - for role in self.roles: - for permission in role.permissions: - if permission.name in permissions: - return True - return False + def has_permission(self, permission): + return permission in self.get_permissions() class _UserAttribute(db.Model, ModelSerializeMixin): diff --git a/run_flaschengeist b/run_flaschengeist index 7942c54..db91492 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,11 +1,8 @@ #!/usr/bin/python3 import inspect import argparse -from datetime import datetime - import bjoern -import sqlalchemy -from sqlalchemy.orm import RelationshipProperty, ColumnProperty +import typing def install(arguments): @@ -36,12 +33,16 @@ def export(arguments): def pytype(cls): if isinstance(cls, list): - return "Array<{}>".format(pytype(cls[0])) - mapper = {"str": "string", "int": "number", "datetime": "Date"} - if cls.__name__ in mapper: - return mapper[cls.__name__] - else: - return cls.__name__ + return "", "Array<{}>".format(pytype(cls[0])[1]) + #if typing.get_origin(cls) is typing.Optional: + # return "?", pytype(typing.get_args(cls)[1]) + mapper = {"str": "string", "int": "number", "float": "number", "datetime": "Date"} + if hasattr(cls, "__name__"): + if cls.__name__ in mapper: + return "", mapper[cls.__name__] + else: + return "", cls.__name__ + return "?", "any" def walker(mod): if inspect.ismodule(mod[1]) and mod[1].__name__.startswith(models.__name__) and mod[1].__name__ not in known: @@ -58,7 +59,7 @@ def export(arguments): d = {param: pytype(ptype) for param, ptype in mod[1].__annotations__.items() if not param.startswith("_")} if len(d) == 1: key, value = d.popitem() - classes[mod[0]] = value + classes[mod[0]] = value[1] else: classes[mod[0]] = d @@ -68,14 +69,16 @@ def export(arguments): with app.app_context(): walker(("models", models)) with open(arguments.file, "w") as file: + file.write("declare namespace FG {\n") for cls, params in classes.items(): if isinstance(params, str): - file.write("type {} = {};\n".format(cls, params)) + file.write("\ttype {} = {};\n".format(cls, params)) else: - file.write("interface {} {{\n".format(cls)) + file.write("\tinterface {} {{\n".format(cls)) for name in params: - file.write(" {}: {};\n".format(name, params[name])) - file.write("}\n") + file.write("\t\t{}{}: {};\n".format(name, *params[name])) + file.write("\t}\n") + file.write("}\n") if __name__ == "__main__": From d3a2b40834d29afcb0b557f6ee1df97babf942c9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 23 Oct 2020 02:29:55 +0200 Subject: [PATCH 060/446] Added some permissions, reworked permission system. --- flaschengeist/modules/__init__.py | 20 ++++++---- flaschengeist/modules/roles/__init__.py | 23 +++++------ flaschengeist/modules/schedule/__init__.py | 40 ++++++++++++------- flaschengeist/modules/users/__init__.py | 18 ++++++--- .../system/controller/roleController.py | 14 ++++--- .../system/controller/userController.py | 12 +++++- flaschengeist/system/decorator.py | 6 ++- flaschengeist/system/models/event.py | 8 +++- 8 files changed, 92 insertions(+), 49 deletions(-) diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index 9b085fb..e7faac1 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -18,10 +18,7 @@ class Plugin: pass def serialize(self): - return { - "version": self.version, - "permissions": self.permissions - } + return {"version": self.version, "permissions": self.permissions} class AuthPlugin(Plugin): @@ -38,9 +35,9 @@ class AuthPlugin(Plugin): def update_user(self, user): """If backend is using external data, then update this user instance with external data - - Args: - user: User object + ) + Args: + user: User object """ pass @@ -57,3 +54,12 @@ class AuthPlugin(Plugin): Error: Other errors if backend went mad (are not handled and will result in a 500 error) """ raise NotImplemented + + def delete_user(self, user): + """If backend is using (writeable) external data, then delete the user from external database. + + Args: + user: User object + + """ + pass diff --git a/flaschengeist/modules/roles/__init__.py b/flaschengeist/modules/roles/__init__.py index ba332e7..ece1db9 100644 --- a/flaschengeist/modules/roles/__init__.py +++ b/flaschengeist/modules/roles/__init__.py @@ -6,12 +6,13 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import roleController roles_bp = Blueprint("roles", __name__) -roles_permission = "roles_edit" +_permission_edit = "roles_edit" +_permission_delete = "roles_delete" class RolesPlugin(Plugin): def __init__(self, config): - super().__init__(config, roles_bp, permissions=[roles_permission]) + super().__init__(config, roles_bp, permissions=[_permission_edit, _permission_delete]) ###################################################### @@ -27,7 +28,7 @@ class RolesPlugin(Plugin): @roles_bp.route("/roles", methods=["POST"]) -@login_required(permissions=[roles_permission]) +@login_required(permission=_permission_edit) def add_role(**kwargs): data = request.get_json() if not data or "name" not in data: @@ -41,7 +42,7 @@ def add_role(**kwargs): @roles_bp.route("/roles", methods=["GET"]) @login_required() def list_roles(**kwargs): - roles = roleController.get_roles() + roles = roleController.get_all() return jsonify(roles) @@ -55,18 +56,16 @@ def list_permissions(**kwargs): @roles_bp.route("/roles/", methods=["GET"]) @login_required() def __get_role(rid, **kwargs): - role = roleController.get_role(rid) + role = roleController.get(rid) if role: return jsonify({"id": role.id, "name": role, "permissions": role.permissions}) raise NotFound @roles_bp.route("/roles/", methods=["PUT"]) -@login_required(permissions=[roles_permission]) +@login_required(permission=_permission_edit) def __edit_role(rid, **kwargs): - role = roleController.get_role(rid) - if not role: - raise NotFound + role = roleController.get(rid) data = request.get_json() if "name" in data: @@ -78,9 +77,9 @@ def __edit_role(rid, **kwargs): @roles_bp.route("/roles/", methods=["DELETE"]) -@login_required(permissions=[roles_permission]) +@login_required(permission=_permission_delete) def __delete_role(rid, **kwargs): - if not roleController.delete_role(rid): - raise NotFound + role = roleController.get(rid) + roleController.delete(role) return jsonify({"ok": "ok"}) diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index 70b5a2b..3fceeff 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -10,13 +10,25 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import eventController schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") -schedule_perms = {"EDIT_EVENT": "schedule_edit_event", - "NEW_EVENT": "schedule_create_event"} +_permission_edit_type = "schedule_edit_type" +_permission_edit = "schedule_edit" +_permission_create = "schedule_create" +_permission_delete = "schedule_delete" +_permission_assign = "schedule_assign_other" class SchedulePlugin(Plugin): def __init__(self, config): - super().__init__(blueprint=schedule_bp, permissions=schedule_perms.values()) + super().__init__( + blueprint=schedule_bp, + permissions=[ + _permission_create, + _permission_edit, + _permission_edit_type, + _permission_delete, + _permission_assign, + ], + ) #################################################################################### @@ -41,10 +53,10 @@ class SchedulePlugin(Plugin): #################################################################################### -@schedule_bp.route("/events/", methods=["GET"]) +@schedule_bp.route("/events/", methods=["GET"]) @login_required() -def __get_event(id, **kwargs): - event = eventController.get_event(id) +def __get_event(eid, **kwargs): + event = eventController.get_event(eid) if not event: raise NotFound return jsonify(event) @@ -86,7 +98,7 @@ def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, @schedule_bp.route("/eventKinds", methods=["POST"]) -@login_required() +@login_required(permission=_permission_edit_type) def __new_event_kind(**kwargs): data = request.get_json() if "name" not in data: @@ -96,7 +108,7 @@ def __new_event_kind(**kwargs): @schedule_bp.route("/slotKinds", methods=["POST"]) -@login_required() +@login_required(permission=_permission_edit_type) def __new_slot_kind(**kwargs): data = request.get_json() if not data or "name" not in data: @@ -106,7 +118,7 @@ def __new_slot_kind(**kwargs): @schedule_bp.route("/events", methods=["POST"]) -@login_required(permissions=[schedule_perms["NEW_EVENT"]]) +@login_required(permission=_permission_create) def __new_event(**kwargs): data = request.get_json() event = eventController.create_event( @@ -119,7 +131,7 @@ def __new_event(**kwargs): @schedule_bp.route("/events/", methods=["DELETE"]) -@login_required(permissions=[schedule_perms["EDIT_EVENT"]]) +@login_required(permission=_permission_delete) def __delete_event(event_id, **kwargs): if not eventController.delete_event(event_id): raise NotFound @@ -128,7 +140,7 @@ def __delete_event(event_id, **kwargs): @schedule_bp.route("/eventKinds/", methods=["PUT"]) -@login_required() +@login_required(permission=_permission_edit_type) def __edit_event_kind(event_id, **kwargs): data = request.get_json() if not data or "name" not in data: @@ -156,7 +168,7 @@ def __get_slot(event_id, slot_id, **kwargs): @schedule_bp.route("/events//slots/", methods=["DELETE"]) -@login_required() +@login_required(permission=_permission_delete) def __delete_slot(event_id, slot_id, **kwargs): if eventController.delete_event_slot(slot_id, event_id): return jsonify({"ok": "ok"}) @@ -164,7 +176,7 @@ def __delete_slot(event_id, slot_id, **kwargs): @schedule_bp.route("/events//slots/", methods=["PUT"]) -@login_required() +@login_required(permission=_permission_edit) def __update_slot(event_id, slot_id, **kwargs): data = request.get_json() if not data: @@ -178,7 +190,7 @@ def __update_slot(event_id, slot_id, **kwargs): @schedule_bp.route("/events//slots", methods=["POST"]) -@login_required() +@login_required(permission=_permission_edit) def __add_slot(event_id, **kwargs): event = eventController.get_event(event_id) if not event: diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index fd2d11c..bba35f3 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -7,12 +7,13 @@ from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import userController users_bp = Blueprint("users", __name__) -users_perm = "users_edit_other" +_permission_edit = "users_edit_other" +_permission_delete = "users_delete_other" class UsersPlugin(Plugin): def __init__(self, config): - super().__init__(blueprint=users_bp, permissions=[users_perm]) + super().__init__(blueprint=users_bp, permissions=[_permission_edit, _permission_delete]) ################################################# # Routes # @@ -49,15 +50,22 @@ def __get_user(uid, **kwargs): raise NotFound +@users_bp.route("/users/", methods=["DELETE"]) +@login_required(permission=_permission_delete) +def __delete_user(uid, **kwargs): + logger.debug("Delete user {{ {} }}".format(uid)) + user = userController.get_user(uid) + userController.delete(user) + return jsonify({"ok": "ok"}) + + @users_bp.route("/users/", methods=["PUT"]) @login_required() def __edit_user(uid, **kwargs): logger.debug("Modify information of user {{ {} }}".format(uid)) user = userController.get_user(uid) - if not user: - raise NotFound - if uid != kwargs["access_token"].user.userid and user.has_permissions([users_perm]): + if uid != kwargs["access_token"].user.userid and user.has_permission(_permission_edit): return Forbidden data = request.get_json() diff --git a/flaschengeist/system/controller/roleController.py b/flaschengeist/system/controller/roleController.py index 71baa80..0758c53 100644 --- a/flaschengeist/system/controller/roleController.py +++ b/flaschengeist/system/controller/roleController.py @@ -1,17 +1,19 @@ from sqlalchemy.exc import IntegrityError -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, NotFound from flaschengeist.system.models.user import Role, Permission from flaschengeist.system.database import db from flaschengeist import logger -def get_roles(): +def get_all(): return Role.query.all() -def get_role(rid): - return Role.query.get(rid) +def get(rid): + role = Role.query.get(rid).one_or_none() + if not role: + raise NotFound def get_permissions(): @@ -47,9 +49,9 @@ def create_role(name, permissions=[]): return role.id -def delete_role(id): +def delete(role): try: - num = Role.query.filter(Role.id == id).delete() + num = Role.query.filter(Role.id == role.id).delete() except IntegrityError: logger.debug("IntegrityError: Role might still be in use", exc_info=True) raise BadRequest("Role still in use") diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index 1e33f24..4871fc7 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -1,4 +1,5 @@ from flask import current_app +from werkzeug.exceptions import NotFound from flaschengeist.system.models.user import User, Role from flaschengeist.system.database import db @@ -51,4 +52,13 @@ def get_user_by_role(role: Role): def get_user(uid): - return User.query.filter(User.userid == uid).one_or_none() + user = User.query.filter(User.userid == uid).one_or_none() + if not user: + raise NotFound + return user + + +def delete(user): + current_app.config["FG_AUTH_BACKEND"].delete_user(user) + db.session.delete(user) + db.session.commit() diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index 39b5694..4e5e2e6 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -6,12 +6,12 @@ from flaschengeist import logger from flaschengeist.system.controller import sessionController -def login_required(permissions=None): +def login_required(permission=None): def wrap(func): @wraps(func) def wrapped_f(*args, **kwargs): token = list(filter(None, request.headers.get("Authorization").split(" ")))[-1] - access_token = sessionController.validate_token(token, request.user_agent, permissions) + access_token = sessionController.validate_token(token, request.user_agent, permission) if access_token: kwargs["access_token"] = access_token logger.debug("token {{ {} }} is valid".format(token)) @@ -19,5 +19,7 @@ def login_required(permissions=None): else: logger.info("token {{ {} }} is not valid".format(token)) raise Unauthorized + return wrapped_f + return wrap diff --git a/flaschengeist/system/models/event.py b/flaschengeist/system/models/event.py index 94b6e50..5a28ee8 100644 --- a/flaschengeist/system/models/event.py +++ b/flaschengeist/system/models/event.py @@ -45,15 +45,19 @@ class Event(db.Model, ModelSerializeMixin): class Job(db.Model, ModelSerializeMixin): __tablename__ = "job" - _user: User = db.relationship("User") - # user: str = column_property(_user.userid) + user: str = None value: float = db.Column(db.Numeric(precision=3, scale=2)) _id = db.Column("id", db.Integer, primary_key=True) _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id")) + _user: User = db.relationship("User") _slot_id = db.Column("slot_id", db.Integer, db.ForeignKey("job_slot.id")) _slot = db.relationship("JobSlot") + @property + def user(self): + return self._user.userid + class JobKind(db.Model, ModelSerializeMixin): __tablename__ = "job_kind" From dc6b30e4e762bd2e21492b3f94c2e0690707542f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 24 Oct 2020 20:09:45 +0200 Subject: [PATCH 061/446] Improved modify_user for backend plugins --- flaschengeist/modules/__init__.py | 2 +- flaschengeist/modules/auth_ldap/__init__.py | 13 ++++++++---- flaschengeist/modules/auth_plain/__init__.py | 11 +++++++--- .../system/controller/userController.py | 16 +++++++++----- flaschengeist/system/models/user.py | 21 ++++--------------- 5 files changed, 33 insertions(+), 30 deletions(-) diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/modules/__init__.py index e7faac1..e957513 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/modules/__init__.py @@ -46,7 +46,7 @@ class AuthPlugin(Plugin): Args: user: User object - password: Password (some backends need the current password for changes) + password: Password (some backends need the current password for changes) if None force edit (admin) new_password: If set a password change is requested Raises: NotImplemented: If backend does not support this feature (or no password change) diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index d8cd710..ef48869 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -8,6 +8,7 @@ from werkzeug.exceptions import BadRequest from flaschengeist.modules import AuthPlugin from flaschengeist.system.models.user import User +import flaschengeist.system.controller.userController as userController class AuthLDAP(AuthPlugin): @@ -33,6 +34,8 @@ class AuthLDAP(AuthPlugin): app.config["LDAP_SECRET"] = (config["SECRET"],) self.ldap = LDAPConn(app) self.dn = config["BASEDN"] + self.admin_dn = config["ADMIN_DN"] + self.admin_secret = config["ADMIN_SECRET"] def login(self, user, password): if not user: @@ -55,8 +58,7 @@ class AuthLDAP(AuthPlugin): user.mail = r["mail"][0] if "displayName" in r: user.display_name = r["displayName"][0] - for group in self._get_groups(user.userid): - user.add_role(group) + userController.set_roles(user, self._get_groups(user.userid)) def _get_groups(self, uid): groups = [] @@ -84,7 +86,10 @@ class AuthLDAP(AuthPlugin): def modify_user(self, user: User, password, new_password=None): try: dn = user.attributes["DN"].value - ldap_conn = self.ldap.connect(dn, password) + if password: + ldap_conn = self.ldap.connect(dn, password) + else: + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) modifier = {} for name, ldap_name in [ ("firstname", "givenName"), @@ -92,7 +97,7 @@ class AuthLDAP(AuthPlugin): ("mail", "mail"), ("display_name", "displayName"), ]: - if getattr(user, name): + if hasattr(user, name): modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] if new_password: salted_password = hashed(HASHED_SALTED_SHA512, new_password) diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/modules/auth_plain/__init__.py index 4bb87c0..7d2e3a9 100644 --- a/flaschengeist/modules/auth_plain/__init__.py +++ b/flaschengeist/modules/auth_plain/__init__.py @@ -2,6 +2,8 @@ import binascii import hashlib import os +from werkzeug.exceptions import BadRequest + from flaschengeist.modules import AuthPlugin from flaschengeist.system.models.user import User @@ -23,9 +25,12 @@ def _verify_password(stored_password, provided_password): class AuthPlain(AuthPlugin): def login(self, user: User, password: str): - if user and "password" in user.attributes: - return _verify_password(user.attributes["password"].value, password) + if user.has_attribute("password"): + return _verify_password(user.get_attributes("password"), password) return False def modify_user(self, user, password, new_password=None): - pass + if password is not None and not self.login(user, password): + raise BadRequest + if new_password: + user.attributes["password"].value = _hash_password(new_password) diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index 4871fc7..9415bf6 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -1,5 +1,5 @@ from flask import current_app -from werkzeug.exceptions import NotFound +from werkzeug.exceptions import NotFound, BadRequest from flaschengeist.system.models.user import User, Role from flaschengeist.system.database import db @@ -25,15 +25,21 @@ def update_user(user): db.session.commit() +def set_roles(user: User, roles: [str]): + user.roles.clear() + for role_name in roles: + role = Role.query.filter(Role.name == role_name).one_or_one() + if not role: + raise BadRequest("Role not found >{}<".format(role_name)) + user.roles.append(role) + + def modify_user(user, password, new_password=None): """Modify given user on the backend Args: user: User object to sync with backend - password: Cu db.session.commit() - - # TODO: is this needed? - def user_has_rorrent password (most backends are needing this) + password: Current password (most backends are needing this) new_password (optional): New password, if password should be changed Raises: diff --git a/flaschengeist/system/models/user.py b/flaschengeist/system/models/user.py index fa4bfe6..a527478 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/system/models/user.py @@ -70,24 +70,11 @@ class User(db.Model, ModelSerializeMixin): else: self._attributes[name] = _UserAttribute(name=name, value=value) - def add_role(self, name): - r = Role.query.filter_by(name=name).first() - if not r: - r = Role(name=name) - self.roles.append(r) + def has_attribute(self, name): + return name in self._attributes - def update_data(self, data): - logger.debug("update data of user") - if "userid" in data: - self.userid = data["userid"] - if "firstname" in data: - self.firstname = data["firstname"] - if "lastname" in data: - self.lastname = data["lastname"] - if "mail" in data: - self.mail = data["mail"] - if "display_name" in data: - self.display_name = data["display_name"] + def get_attribute(self, name): + return self._attributes[name].value def get_permissions(self): return ["user"] + [permission.name for role in self.roles for permission in role.permissions] From d2858c8c76e3cf121179036dfb389788b15ccd85 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 24 Oct 2020 20:10:43 +0200 Subject: [PATCH 062/446] [Plugin] Users now allows setting the role of an user --- flaschengeist/modules/users/__init__.py | 30 +++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index bba35f3..f48cfe5 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -8,12 +8,13 @@ from flaschengeist.system.controller import userController users_bp = Blueprint("users", __name__) _permission_edit = "users_edit_other" +_permission_set_roles = "users_set_roles" _permission_delete = "users_delete_other" class UsersPlugin(Plugin): def __init__(self, config): - super().__init__(blueprint=users_bp, permissions=[_permission_edit, _permission_delete]) + super().__init__(blueprint=users_bp, permissions=[_permission_edit, _permission_delete, _permission_set_roles]) ################################################# # Routes # @@ -64,17 +65,28 @@ def __delete_user(uid, **kwargs): def __edit_user(uid, **kwargs): logger.debug("Modify information of user {{ {} }}".format(uid)) user = userController.get_user(uid) - - if uid != kwargs["access_token"].user.userid and user.has_permission(_permission_edit): - return Forbidden - data = request.get_json() - if "password" not in data: - raise BadRequest("Password is missing") + + password = None + new_password = data["new_password"] if "new_password" in data else None + + if uid != kwargs["access_token"].user.userid: + if not user.has_permission(_permission_edit): + return Forbidden + else: + if "password" not in data: + raise BadRequest("Password is missing") + password = data["password"] + for key in ["firstname", "lastname", "display_name", "mail"]: if key in data: setattr(user, key, data[key]) - new_password = data["new_password"] if "new_password" in data else None - userController.modify_user(user, data["password"], new_password) + + if "roles" in data: + if not user.has_permission(_permission_set_roles): + raise Forbidden + userController.set_roles(user, data["roles"]) + + userController.modify_user(user, password, new_password) userController.update_user(user) return jsonify({"ok": "ok"}) From f238491206aa74dc672e0607a67d137c058ac3a6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 24 Oct 2020 20:11:50 +0200 Subject: [PATCH 063/446] [Skript] run_flaschegeist now allows setting the typescript namespace --- run_flaschengeist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_flaschengeist b/run_flaschengeist index db91492..083e36a 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -28,7 +28,6 @@ def export(arguments): import flaschengeist.system.models as models known = [] - done = [] classes = {} def pytype(cls): @@ -69,7 +68,7 @@ def export(arguments): with app.app_context(): walker(("models", models)) with open(arguments.file, "w") as file: - file.write("declare namespace FG {\n") + file.write("declare namespace {} \{\n".format(arguments.namespace)) for cls, params in classes.items(): if isinstance(params, str): file.write("\ttype {} = {};\n".format(cls, params)) @@ -98,6 +97,7 @@ if __name__ == "__main__": parser_export = subparsers.add_parser("export", help="export models to typescript interfaces") parser_export.set_defaults(func=export) parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts") + parser_export.add_argument("--namespace", help="Namespace of declarations", default="FG") args = parser.parse_args() args.func(args) From e14553651f9439355babc99b8b3b6757997283ce Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 27 Oct 2020 13:36:23 +0100 Subject: [PATCH 064/446] [Plugin] Fixed auth_ldap usage of User --- flaschengeist/modules/auth_ldap/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index ef48869..1ed906b 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -85,7 +85,7 @@ class AuthLDAP(AuthPlugin): def modify_user(self, user: User, password, new_password=None): try: - dn = user.attributes["DN"].value + dn = user.get_attribute("DN") if password: ldap_conn = self.ldap.connect(dn, password) else: From c3b5721202cace4f91e9f8cff5771d25b0c24cf0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 27 Oct 2020 13:37:13 +0100 Subject: [PATCH 065/446] [System] Fixed usage of protected members --- .../system/controller/sessionController.py | 18 +++++++++--------- .../system/controller/userController.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/flaschengeist/system/controller/sessionController.py b/flaschengeist/system/controller/sessionController.py index 4be467b..e9699f7 100644 --- a/flaschengeist/system/controller/sessionController.py +++ b/flaschengeist/system/controller/sessionController.py @@ -22,19 +22,19 @@ def validate_token(token, user_agent, permissions): A Session for this given Token or False. """ logger.debug("check token {{ {} }} is valid".format(token)) - access_token = Session.query.filter_by(token=token).one_or_none() - if access_token: + session = Session.query.filter_by(token=token).one_or_none() + if session: logger.debug("token found, check if expired or invalid user agent differs") - if access_token.expires >= datetime.now(timezone.utc) and ( - access_token.browser == user_agent.browser and access_token.platform == user_agent.platform + if session.expires >= datetime.now(timezone.utc) and ( + session.browser == user_agent.browser and session.platform == user_agent.platform ): - if not permissions or access_token.user.has_permissions(permissions): - access_token.refresh() + if not permissions or session._user.has_permissions(permissions): + session.refresh() db.session.commit() - return access_token + return session else: logger.debug("access token is out of date or invalid client used") - delete_session(access_token) + delete_session(session) logger.debug("no valid access token with token: {{ {} }} and permissions: {{ {} }}".format(token, permissions)) return False @@ -78,7 +78,7 @@ def get_session(token, owner=None): Session: Token object identified by given token string """ session = Session.query.filter(Session.token == token).one_or_none() - if session and (owner and owner != session.user): + if session and (owner and owner != session._user): raise Forbidden return session diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/system/controller/userController.py index 9415bf6..15b7ab2 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/system/controller/userController.py @@ -28,7 +28,7 @@ def update_user(user): def set_roles(user: User, roles: [str]): user.roles.clear() for role_name in roles: - role = Role.query.filter(Role.name == role_name).one_or_one() + role = Role.query.filter(Role.name == role_name).one_or_none() if not role: raise BadRequest("Role not found >{}<".format(role_name)) user.roles.append(role) From 8ac43826dc6938df1bd7d8fb6b602b4c0fc2cb9a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 27 Oct 2020 13:38:03 +0100 Subject: [PATCH 066/446] [Model] Event has userid not user --- flaschengeist/system/models/event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/system/models/event.py b/flaschengeist/system/models/event.py index 5a28ee8..c187bbe 100644 --- a/flaschengeist/system/models/event.py +++ b/flaschengeist/system/models/event.py @@ -45,7 +45,7 @@ class Event(db.Model, ModelSerializeMixin): class Job(db.Model, ModelSerializeMixin): __tablename__ = "job" - user: str = None + userid: str = None value: float = db.Column(db.Numeric(precision=3, scale=2)) _id = db.Column("id", db.Integer, primary_key=True) @@ -55,7 +55,7 @@ class Job(db.Model, ModelSerializeMixin): _slot = db.relationship("JobSlot") @property - def user(self): + def userid(self): return self._user.userid From a3106ccf1fa7636e736dd618a186898172b5611f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 27 Oct 2020 13:43:01 +0100 Subject: [PATCH 067/446] [Doc] Readme windows --- flaschengeist/logging.yml | 2 +- flaschengeist/readme.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/flaschengeist/logging.yml b/flaschengeist/logging.yml index 96b71e8..ddb8341 100644 --- a/flaschengeist/logging.yml +++ b/flaschengeist/logging.yml @@ -19,7 +19,7 @@ handlers: class: logging.handlers.WatchedFileHandler level: DEBUG formatter: debug - filename: flaschengeist/log/debug/debug.log + filename: flaschengeist-debug.log encoding: utf8 loggers: diff --git a/flaschengeist/readme.md b/flaschengeist/readme.md index d860d7c..3f4aaff 100644 --- a/flaschengeist/readme.md +++ b/flaschengeist/readme.md @@ -9,6 +9,10 @@ or with ldap support pip3 install --user ".[ldap]" +#### Windows +Same as above, but for mysql you have to follow this guide: + +https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/ ### Configuration 1. Rename `flaschengeist.example.cfg` to `flaschengeist.cfg` From 6b094bc3f80a4269751baf3ec1a42829760e045e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 27 Oct 2020 14:37:39 +0100 Subject: [PATCH 068/446] Verbiete uploade der Config-Datei MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Außerdem private Startoption --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 71796df..8b76607 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,9 @@ test_pricelist/ test_project/ config.yml geruecht.config.yml + +# config +flaschengeist/flaschengeist.cfg + +# start flaschengeist in pycharme professional +run_flaschengeist_pycharm.py From a0b8dbe36a3af0489b0983b16e1e3004404b4dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 28 Oct 2020 12:58:34 +0100 Subject: [PATCH 069/446] Fixed hidden attributes in auth and users --- flaschengeist/modules/auth/__init__.py | 8 ++++---- flaschengeist/modules/users/__init__.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 594bb01..1c3f036 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -76,7 +76,7 @@ def _get_sessions(access_token: Session, **kwargs): @login_required() def _delete_session(access_token, token, **kwargs): logger.debug("Try to delete access token {{ {} }}".format(token)) - token = sessionController.get_session(token, access_token.user) + token = sessionController.get_session(token, access_token._user) if not token: logger.debug("Token not found in database!") # Return 403 error, so that users can not bruteforce tokens @@ -91,7 +91,7 @@ def _delete_session(access_token, token, **kwargs): @login_required() def _get_session(token, access_token, **kwargs): logger.debug("get token {{ {} }}".format(token)) - session = sessionController.get_session(token, access_token.user) + session = sessionController.get_session(token, access_token._user) if not token: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same @@ -103,7 +103,7 @@ def _get_session(token, access_token, **kwargs): @login_required() def _get_assocd_user(token, access_token, **kwargs): logger.debug("get token {{ {} }}".format(token)) - session = sessionController.get_session(token, access_token.user) + session = sessionController.get_session(token, access_token._user) if not token: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same @@ -114,7 +114,7 @@ def _get_assocd_user(token, access_token, **kwargs): @auth_bp.route("/auth/", methods=["PUT"]) @login_required() def _set_lifetime(token, access_token, **kwargs): - token = sessionController.get_token(token, access_token.user) + token = sessionController.get_token(token, access_token._user) if not token: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index f48cfe5..3f7cec5 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -62,7 +62,7 @@ def __delete_user(uid, **kwargs): @users_bp.route("/users/", methods=["PUT"]) @login_required() -def __edit_user(uid, **kwargs): +def __edit_user(uid, access_token ,**kwargs): logger.debug("Modify information of user {{ {} }}".format(uid)) user = userController.get_user(uid) data = request.get_json() @@ -70,7 +70,7 @@ def __edit_user(uid, **kwargs): password = None new_password = data["new_password"] if "new_password" in data else None - if uid != kwargs["access_token"].user.userid: + if uid != access_token._user.userid: if not user.has_permission(_permission_edit): return Forbidden else: From 216b7577408439d53d824f89c7fe8576614cc3cb Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 28 Oct 2020 14:21:20 +0100 Subject: [PATCH 070/446] [System] Reworked logging and configuration, breaks configs. --- flaschengeist/__init__.py | 9 +--- flaschengeist/app.py | 6 +-- ...example.cfg => flaschengeist.example.toml} | 43 +++++++++------- flaschengeist/logging.toml | 29 +++++++++++ flaschengeist/logging.yml | 31 ------------ flaschengeist/modules/auth_ldap/__init__.py | 17 +++---- flaschengeist/system/config.py | 49 ++++++++++++------- 7 files changed, 96 insertions(+), 88 deletions(-) rename flaschengeist/{flaschengeist.example.cfg => flaschengeist.example.toml} (67%) create mode 100644 flaschengeist/logging.toml delete mode 100644 flaschengeist/logging.yml diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 1f13a7c..25564a2 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -3,18 +3,11 @@ Initialize app, CORS, database and add it to the application. """ -import yaml import logging import pkg_resources from pathlib import Path -from logging.config import dictConfig from werkzeug.local import LocalProxy __version__ = pkg_resources.get_distribution("flaschengeist").version _module_path = Path(__file__).parent -logger = LocalProxy(lambda: logging.getLogger(__name__)) - - -with (_module_path / "logging.yml").open(mode="rb") as file: - config = yaml.safe_load(file.read()) - logging.config.dictConfig(config) +logger = LocalProxy(lambda: logging.getLogger(__name__)) \ No newline at end of file diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 32648f0..9d28eb4 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -38,14 +38,14 @@ def __load_plugins(app): for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"): logger.debug("Found plugin: >{}<".format(entry_point.name)) plugin = None - if config.get(entry_point.name, "enabled", fallback=False): - plugin = entry_point.load()(config[entry_point.name] if config.has_section(entry_point.name) else {}) + if entry_point.name in config and config[entry_point.name].get("enabled", False): + plugin = entry_point.load()(config[entry_point.name]) if plugin.blueprint: app.register_blueprint(plugin.blueprint) logger.info("Load plugin >{}<".format(entry_point.name)) if isinstance(plugin, AuthPlugin): logger.debug("Found authentication plugin: %s", entry_point.name) - if entry_point.name == config["FLASCHENGEIST"]["AUTH"]: + if entry_point.name == config["FLASCHENGEIST"]["auth"]: app.config["FG_AUTH_BACKEND"] = plugin else: del plugin diff --git a/flaschengeist/flaschengeist.example.cfg b/flaschengeist/flaschengeist.example.toml similarity index 67% rename from flaschengeist/flaschengeist.example.cfg rename to flaschengeist/flaschengeist.example.toml index 938602c..093a9a8 100644 --- a/flaschengeist/flaschengeist.example.cfg +++ b/flaschengeist/flaschengeist.example.toml @@ -1,30 +1,27 @@ [FLASCHENGEIST] # Select authentication provider (builtin: auth_plain, auth_ldap) -AUTH = auth_plain +auth = "auth_plain" # Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn -# PROXY = false +#proxy = false # Set root path, prefixes all routes -# ROOT = / +#root = / +# Set secret key +secret_key = "V3ryS3cr3t" + +[LOGGING] +file = "/tmp/flaschengeist-debug.log" +# DEBUG INFO WARNING ERROR +#level = "WARNING" [DATABASE] -USER = -HOST = -PASSWORD = -DATABASE = +user = "user" +host = "127.0.0.1" +password = "password" +database = "database" [auth_plain] enabled = true -#[mail] -# enabled = true -# SERVER = -# PORT = -# USER = -# PASSWORD = -# MAIL = -# SSL or STARTLS -# CRYPT = SSL - #[auth_ldap] # enabled = true # URL = @@ -38,8 +35,18 @@ enabled = true ############################ # Configuration of plugins # ############################ +#[mail] +# enabled = true +# SERVER = +# PORT = +# USER = +# PASSWORD = +# MAIL = +# SSL or STARTLS +# CRYPT = SSL + [geruecht] -enabled = true +enabled = false [schubu] enabled = false diff --git a/flaschengeist/logging.toml b/flaschengeist/logging.toml new file mode 100644 index 0000000..7ff7c08 --- /dev/null +++ b/flaschengeist/logging.toml @@ -0,0 +1,29 @@ +version = 1 +disable_existing_loggers = false + +[formatters] + [formatters.simple] + format = "%(asctime)s - %(name)s - %(message)s" + [formatters.extended] + format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s" + +[handlers] + [handlers.console] + class = "logging.StreamHandler" + level = "DEBUG" + formatter = "simple" + stream = "ext://sys.stdout" + [handlers.file] + class = "logging.handlers.WatchedFileHandler" + level = "WARNING" + formatter = "extended" + encoding = "utf8" + filename = "flaschengeist.log" + +[loggers] + [loggers.werkzeug] + level = "WARNING" + +[root] + level = "WARNING" + handlers = ["console"] \ No newline at end of file diff --git a/flaschengeist/logging.yml b/flaschengeist/logging.yml deleted file mode 100644 index ddb8341..0000000 --- a/flaschengeist/logging.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: 1 -disable_existing_loggers: True - -formatters: - debug: - format: "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s" - - simple: - format: "%(asctime)s - %(name)s - %(message)s" - -handlers: - console: - class: logging.StreamHandler - level: DEBUG - formatter: debug - stream: ext://sys.stdout - - debug: - class: logging.handlers.WatchedFileHandler - level: DEBUG - formatter: debug - filename: flaschengeist-debug.log - encoding: utf8 - -loggers: - werkzeug: - level: WARNING - -root: - level: DEBUG - handlers: [console, debug] diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index 1ed906b..17fbe84 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -12,20 +12,18 @@ import flaschengeist.system.controller.userController as userController class AuthLDAP(AuthPlugin): - def __init__(self, config): + def __init__(self, cfg): super().__init__() - defaults = {"PORT": "389", "USE_SSL": "False"} - for name in defaults: - if name not in config: - config[name] = defaults[name] + config = {"PORT": 389, "USE_SSL": False} + config.update(cfg) app.config.update( LDAP_SERVER=config["URL"], - LDAP_PORT=config.getint("PORT"), + LDAP_PORT=config["PORT"], LDAP_BINDDN=config["BINDDN"], LDAP_USE_TLS=False, - LDAP_USE_SSL=config.getboolean("USE_SSL"), + LDAP_USE_SSL=config["USE_SSL"], LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2, LDAP_REQUIRE_CERT=ssl.CERT_NONE, FORCE_ATTRIBUTE_VALUE_AS_LIST=True, @@ -34,8 +32,9 @@ class AuthLDAP(AuthPlugin): app.config["LDAP_SECRET"] = (config["SECRET"],) self.ldap = LDAPConn(app) self.dn = config["BASEDN"] - self.admin_dn = config["ADMIN_DN"] - self.admin_secret = config["ADMIN_SECRET"] + if "ADMIN_DN" in config: + self.admin_dn = config["ADMIN_DN"] + self.admin_secret = config["ADMIN_SECRET"] def login(self, user, password): if not user: diff --git a/flaschengeist/system/config.py b/flaschengeist/system/config.py index d4b9978..fa6aae3 100644 --- a/flaschengeist/system/config.py +++ b/flaschengeist/system/config.py @@ -1,44 +1,55 @@ +import logging.config import os -import configparser +import toml from pathlib import Path from werkzeug.middleware.proxy_fix import ProxyFix from .. import _module_path, logger -default = {"MAIL": {"CRYPT": "SSL/STARTLS"}} - -config = configparser.ConfigParser() -config.read_dict(default) +# Default config: +config = {} paths = [_module_path, Path.home() / ".config"] if "FLASCHENGEIST_CONF" in os.environ: paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) for loc in paths: try: - with (loc / "flaschengeist.cfg").open() as source: + with (loc / "flaschengeist.toml").open() as source: logger.info("Reading config file from >{}<".format(loc)) - config.read_file(source) + config.update(toml.load(source)) except IOError: pass - # Always enable this builtin plugins! -config.read_dict({"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) +config.update({"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) def configure_app(app): - if not config.has_option("FLASCHENGEIST", "SECRET_KEY"): + logger_config = toml.load(_module_path / "logging.toml") + if "LOGGING" in config: + if "level" in config["LOGGING"]: + logger_config["loggers"]["flaschengeist"] = {"level": config["LOGGING"]["level"]} + if "file" in config["LOGGING"]: + logger_config["root"]["handlers"].append("file") + logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"] + path = Path(config["LOGGING"]["file"]) + path.parent.mkdir(parents=True, exist_ok=True) + logging.config.dictConfig(logger_config) + + if "secret_key" not in config["FLASCHENGEIST"]: logger.warn("No secret key was configured, please configure one for production systems!") - app.config["SECRET_KEY"] = config.get("FLASCHENGEIST", "SECRET_KEY", fallback="0a657b97ef546da90b2db91862ad4e29") + app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29" + else: + app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://{user}:{passwd}@{host}/{database}".format( - user=config["DATABASE"]["USER"], - passwd=config["DATABASE"]["PASSWORD"], - host=config["DATABASE"]["HOST"], - database=config["DATABASE"]["DATABASE"], + user=config["DATABASE"]["user"], + passwd=config["DATABASE"]["password"], + host=config["DATABASE"]["host"], + database=config["DATABASE"]["database"] ) app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False - if config.has_option("FLASCHENGEIST", "ROOT"): - logger.debug("Setting application root to >{}<".format(config["FLASCHENGEIST"]["ROOT"])) - app.config["APPLICATION_ROOT"] = config["FLASCHENGEIST"]["ROOT"] - if config.getboolean("FLASCHENGEIST", "PROXY", fallback=False): + if "root" in config["FLASCHENGEIST"]: + logger.debug("Setting application root to >{}<".format(config["FLASCHENGEIST"]["root"])) + app.config["APPLICATION_ROOT"] = config["FLASCHENGEIST"]["root"] + if config["FLASCHENGEIST"].get("proxy", False): logger.debug("Fixing wsgi_app for using behind a proxy server") app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) From 254a64efecdc6bd4852d7644aff8f38faf4f3bc8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 28 Oct 2020 14:21:54 +0100 Subject: [PATCH 071/446] [System][Doc] Made bjoern optional --- flaschengeist/readme.md | 6 +++++- run_flaschengeist | 8 +++++--- setup.py | 7 +++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/flaschengeist/readme.md b/flaschengeist/readme.md index 3f4aaff..a158069 100644 --- a/flaschengeist/readme.md +++ b/flaschengeist/readme.md @@ -3,12 +3,16 @@ ## Installation ### Requirements - mysql or mariadb -- python 3.4+ +- python 3.6+ ### Install python files pip3 install --user . or with ldap support pip3 install --user ".[ldap]" +or if you want to use bjoern as the HTTP server: + + pip3 install --user ".[ldap,bjoern]" + #### Windows Same as above, but for mysql you have to follow this guide: diff --git a/run_flaschengeist b/run_flaschengeist index 083e36a..c42bc85 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,8 +1,6 @@ #!/usr/bin/python3 import inspect import argparse -import bjoern -import typing def install(arguments): @@ -21,7 +19,11 @@ def run(arguments): if arguments.debug: app.run(arguments.host, arguments.port, debug=True) else: - bjoern.run(app, arguments.host, arguments.port, reuse_port=True) + try: + import bjoern + bjoern.run(app, arguments.host, arguments.port, reuse_port=True) + except ImportError: + app.run(arguments.host, arguments.port, debug=False) def export(arguments): diff --git a/setup.py b/setup.py index a7f525f..55f8789 100644 --- a/setup.py +++ b/setup.py @@ -8,19 +8,18 @@ setup( author_email="tim@groeger-clan.de", description="Does things", packages=find_packages(), - package_data={"": ["*.yml"]}, + package_data={"": ["*.toml"]}, scripts=["run_flaschengeist"], install_requires=[ "Flask >= 1.1", - "PyYAML>=5.3.1", + "toml", "sqlalchemy>=1.3", "flask_sqlalchemy", "flask_cors", "werkzeug", - "bjoern", "python-dateutil", ], - extras_require={"ldap": ["flask_ldapconn", "ldap3"]}, + extras_require={"ldap": ["flask_ldapconn", "ldap3"], "bjoern": ["bjoern"]}, entry_points={ "flaschengeist.plugin": [ "auth = flaschengeist.modules.auth:AuthRoutePlugin", From bda76e200a7a3fc59f8c5f7c3b8cdb4af22f1989 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 28 Oct 2020 14:42:48 +0100 Subject: [PATCH 072/446] [System] Consistent variable names --- flaschengeist/modules/auth/__init__.py | 36 ++++++++++------------ flaschengeist/modules/schedule/__init__.py | 2 +- flaschengeist/modules/users/__init__.py | 4 +-- flaschengeist/system/decorator.py | 6 ++-- 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 1c3f036..01f9495 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -65,34 +65,32 @@ def _login(): @auth_bp.route("/auth", methods=["GET"]) @login_required() -def _get_sessions(access_token: Session, **kwargs): - tokens = sessionController.get_users_sessions(access_token._user) - a = messageController.Message(access_token._user, "Go", "Bar") - messageController.send_message(a) - return jsonify(tokens) +def _get_sessions(current_session, **kwargs): + sessions = sessionController.get_users_sessions(current_session._user) + return jsonify(sessions) @auth_bp.route("/auth/", methods=["DELETE"]) @login_required() -def _delete_session(access_token, token, **kwargs): +def _delete_session(token, current_session, **kwargs): logger.debug("Try to delete access token {{ {} }}".format(token)) - token = sessionController.get_session(token, access_token._user) - if not token: + session = sessionController.get_session(token, current_session._user) + if not session: logger.debug("Token not found in database!") # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden - sessionController.delete_session(token) + sessionController.delete_session(session) sessionController.clear_expired() return jsonify({"ok": "ok"}) @auth_bp.route("/auth/", methods=["GET"]) @login_required() -def _get_session(token, access_token, **kwargs): +def _get_session(token, current_session, **kwargs): logger.debug("get token {{ {} }}".format(token)) - session = sessionController.get_session(token, access_token._user) - if not token: + session = sessionController.get_session(token, current_session._user) + if not session: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden @@ -101,10 +99,10 @@ def _get_session(token, access_token, **kwargs): @auth_bp.route("/auth//user", methods=["GET"]) @login_required() -def _get_assocd_user(token, access_token, **kwargs): +def _get_assocd_user(token, current_session, **kwargs): logger.debug("get token {{ {} }}".format(token)) - session = sessionController.get_session(token, access_token._user) - if not token: + session = sessionController.get_session(token, current_session._user) + if not session: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden @@ -113,16 +111,16 @@ def _get_assocd_user(token, access_token, **kwargs): @auth_bp.route("/auth/", methods=["PUT"]) @login_required() -def _set_lifetime(token, access_token, **kwargs): - token = sessionController.get_token(token, access_token._user) - if not token: +def _set_lifetime(token, current_session, **kwargs): + session = sessionController.get_session(token, current_session._user) + if not session: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden try: lifetime = request.get_json()["value"] logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) - sessionController.set_lifetime(token, lifetime) + sessionController.set_lifetime(session, lifetime) return jsonify({"ok": "ok"}) except (KeyError, TypeError): raise BadRequest diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/modules/schedule/__init__.py index 3fceeff..7ab76a3 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/modules/schedule/__init__.py @@ -74,7 +74,7 @@ def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, year (int, optional): year to query, defaults to current year month (int, optional): month to query (if set), defaults to current month day (int, optional): day to query events for (if set) - **kwargs: contains at least access_token (see flaschengeist.decorator) + **kwargs: contains at least current_session (see flaschengeist.decorator) Returns: JSON list containing events found Raises: diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/modules/users/__init__.py index 3f7cec5..0309967 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/modules/users/__init__.py @@ -62,7 +62,7 @@ def __delete_user(uid, **kwargs): @users_bp.route("/users/", methods=["PUT"]) @login_required() -def __edit_user(uid, access_token ,**kwargs): +def __edit_user(uid, current_session, **kwargs): logger.debug("Modify information of user {{ {} }}".format(uid)) user = userController.get_user(uid) data = request.get_json() @@ -70,7 +70,7 @@ def __edit_user(uid, access_token ,**kwargs): password = None new_password = data["new_password"] if "new_password" in data else None - if uid != access_token._user.userid: + if uid != current_session._user.userid: if not user.has_permission(_permission_edit): return Forbidden else: diff --git a/flaschengeist/system/decorator.py b/flaschengeist/system/decorator.py index 4e5e2e6..aa6533d 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/system/decorator.py @@ -11,9 +11,9 @@ def login_required(permission=None): @wraps(func) def wrapped_f(*args, **kwargs): token = list(filter(None, request.headers.get("Authorization").split(" ")))[-1] - access_token = sessionController.validate_token(token, request.user_agent, permission) - if access_token: - kwargs["access_token"] = access_token + session = sessionController.validate_token(token, request.user_agent, permission) + if session: + kwargs["current_session"] = session logger.debug("token {{ {} }} is valid".format(token)) return func(*args, **kwargs) else: From 005abd6f562907d67eeb59de4a9eb8fa22c524ad Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 28 Oct 2020 14:44:19 +0100 Subject: [PATCH 073/446] [Git] Updated ignore list --- .gitignore | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 8b76607..c84197f 100644 --- a/.gitignore +++ b/.gitignore @@ -122,14 +122,8 @@ dmypy.json .vscode/ *.log -# custom -test_pricelist/ -test_project/ -config.yml -geruecht.config.yml - # config -flaschengeist/flaschengeist.cfg +flaschengeist/flaschengeist.toml # start flaschengeist in pycharme professional run_flaschengeist_pycharm.py From 97b6d9d979b304174d06db9506037a12fa3cebfc Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 28 Oct 2020 20:30:21 +0100 Subject: [PATCH 074/446] [Plugin] LDAP: Fixed password change --- flaschengeist/modules/auth_ldap/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index 17fbe84..ddc0fb1 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -1,6 +1,6 @@ import ssl from ldap3.utils.hashed import hashed -from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_SHA512 +from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_SHA512, HASHED_SALTED_MD5 from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from flask import current_app as app from flask_ldapconn import LDAPConn @@ -99,7 +99,8 @@ class AuthLDAP(AuthPlugin): if hasattr(user, name): modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] if new_password: - salted_password = hashed(HASHED_SALTED_SHA512, new_password) + # TODO: Use secure hash! + salted_password = hashed(HASHED_SALTED_MD5, new_password) modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])] ldap_conn.modify(dn, modifier) except (LDAPPasswordIsMandatoryError, LDAPBindError): From 50b6ac85ce228805fd45ffafc57c550281290188 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Oct 2020 02:07:40 +0100 Subject: [PATCH 075/446] [Plugin] auth_* Fixed some minor issues --- flaschengeist/modules/auth_ldap/__init__.py | 3 ++- flaschengeist/modules/auth_plain/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/modules/auth_ldap/__init__.py index ddc0fb1..be99afd 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/modules/auth_ldap/__init__.py @@ -1,6 +1,6 @@ import ssl from ldap3.utils.hashed import hashed -from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_SHA512, HASHED_SALTED_MD5 +from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_MD5 from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from flask import current_app as app from flask_ldapconn import LDAPConn @@ -32,6 +32,7 @@ class AuthLDAP(AuthPlugin): app.config["LDAP_SECRET"] = (config["SECRET"],) self.ldap = LDAPConn(app) self.dn = config["BASEDN"] + # TODO: might not be set if modify is called if "ADMIN_DN" in config: self.admin_dn = config["ADMIN_DN"] self.admin_secret = config["ADMIN_SECRET"] diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/modules/auth_plain/__init__.py index 7d2e3a9..99acb8e 100644 --- a/flaschengeist/modules/auth_plain/__init__.py +++ b/flaschengeist/modules/auth_plain/__init__.py @@ -26,11 +26,11 @@ def _verify_password(stored_password, provided_password): class AuthPlain(AuthPlugin): def login(self, user: User, password: str): if user.has_attribute("password"): - return _verify_password(user.get_attributes("password"), password) + return _verify_password(user.get_attribute("password"), password) return False def modify_user(self, user, password, new_password=None): if password is not None and not self.login(user, password): raise BadRequest if new_password: - user.attributes["password"].value = _hash_password(new_password) + user.set_attribute("password", _hash_password(new_password)) From 6dfdffebf9299a3b4c212724d759a87cf5073ded Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 02:12:06 +0100 Subject: [PATCH 076/446] [Doc] Some more documentation --- flaschengeist/modules/auth/__init__.py | 29 +++++++++++--------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 01f9495..c19e4a9 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -1,8 +1,15 @@ -############################################# -# Plugin: Auth # -# Functionality: Allow management of # -# authentication, login, logout, etc # -############################################# +"""Flaschengeist plugin: Auth + +Allow management of authentication, login, logout, etc. + + Routes + +/auth POST: login (new token) + GET: get all tokens for user +/auth/ GET: get lifetime of token + PUT: set new lifetime + DELETE: logout / delete token +""" from flask import Blueprint, request, jsonify from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized @@ -11,7 +18,6 @@ from flaschengeist import logger from flaschengeist.modules import Plugin from flaschengeist.system.decorator import login_required from flaschengeist.system.controller import sessionController, userController, messageController -from flaschengeist.system.models.session import Session auth_bp = Blueprint("auth", __name__) @@ -21,17 +27,6 @@ class AuthRoutePlugin(Plugin): super().__init__(blueprint=auth_bp) -################################################# -# Routes # -# # -# /auth POST: login (new token) # -# GET: get all tokens for user # -# /auth/ GET: get lifetime of token # -# PUT: set new lifetime # -# DELETE: logout / delete token # -################################################# - - @auth_bp.route("/auth", methods=["POST"]) def _login(): """Login User From 56fff76bc2ddd8f5e5418b620fca1e47a11319a8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 02:28:15 +0100 Subject: [PATCH 077/446] [Doc] pdoc route documentation test --- flaschengeist/modules/auth/__init__.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index c19e4a9..5c279b3 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -2,13 +2,13 @@ Allow management of authentication, login, logout, etc. - Routes + Routes -/auth POST: login (new token) + /auth POST: login (new token) GET: get all tokens for user -/auth/ GET: get lifetime of token + /auth/ GET: get lifetime of token PUT: set new lifetime - DELETE: logout / delete token + DELETE: logout / delete token """ from flask import Blueprint, request, jsonify @@ -29,12 +29,14 @@ class AuthRoutePlugin(Plugin): @auth_bp.route("/auth", methods=["POST"]) def _login(): - """Login User + """Login in an user and create a `flaschengeist.system.models.Session` for the user. + + Route: /auth + POST-data: {'userid': string, 'password': string} - Login in User and create a Session for the User. - Requires POST data {'userid': string, 'password': string} Returns: - A JSON-File with user information and created token or errors + A JSON object with `flaschengeist.system.models.user.User` and created + `flaschengeist.system.models.session.Session` or HTTP error """ logger.debug("Start log in.") data = request.get_json() @@ -48,7 +50,6 @@ def _login(): user = userController.login_user(userid, password) if not user: raise Unauthorized - logger.debug("user is {{ {} }}".format(user)) session = sessionController.create(user, user_agent=request.user_agent) logger.debug("token is {{ {} }}".format(session.token)) logger.info("User {{ {} }} success login.".format(userid)) From 4a7caad7e810ddb2c4d32a1cb582195fa598cf0d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 02:46:29 +0100 Subject: [PATCH 078/446] [Doc] pdoc route documentation test --- flaschengeist/modules/auth/__init__.py | 16 ++++++++-------- flaschengeist/system/models/__init__.py | 13 ++++++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index 5c279b3..d50223a 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -1,14 +1,13 @@ -"""Flaschengeist plugin: Auth +"""Authentication plugin, provides basic routes Allow management of authentication, login, logout, etc. - Routes - - /auth POST: login (new token) + ```Routes + /auth POST: login (new token) GET: get all tokens for user - /auth/ GET: get lifetime of token + /auth/ GET: get lifetime of token PUT: set new lifetime - DELETE: logout / delete token + DELETE: logout / delete token``` """ from flask import Blueprint, request, jsonify @@ -17,7 +16,7 @@ from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from flaschengeist import logger from flaschengeist.modules import Plugin from flaschengeist.system.decorator import login_required -from flaschengeist.system.controller import sessionController, userController, messageController +from flaschengeist.system.controller import sessionController, userController auth_bp = Blueprint("auth", __name__) @@ -31,7 +30,8 @@ class AuthRoutePlugin(Plugin): def _login(): """Login in an user and create a `flaschengeist.system.models.Session` for the user. - Route: /auth + Route: ``/auth`` + POST-data: {'userid': string, 'password': string} Returns: diff --git a/flaschengeist/system/models/__init__.py b/flaschengeist/system/models/__init__.py index 595a52e..016672a 100644 --- a/flaschengeist/system/models/__init__.py +++ b/flaschengeist/system/models/__init__.py @@ -4,6 +4,8 @@ from sqlalchemy.types import DateTime, TypeDecorator class ModelSerializeMixin: def serialize(self): + """Return: + Dict of all not private or protected annotated member variables.""" d = {param: getattr(self, param) for param in self.__class__.__annotations__ if not param.startswith("_")} if len(d) == 1: key, value = d.popitem() @@ -12,14 +14,15 @@ class ModelSerializeMixin: class UtcDateTime(TypeDecorator): - """Almost equivalent to :class:`~sqlalchemy.types.DateTime` with + """Almost equivalent to `sqlalchemy.types.DateTime` with ``timezone=True`` option, but it differs from that by: - - Never silently take naive :class:`~datetime.datetime`, instead it + + - Never silently take naive :class:`datetime.datetime`, instead it always raise :exc:`ValueError` unless time zone aware value. - - :class:`~datetime.datetime` value's :attr:`~datetime.datetime.tzinfo` + - :class:`datetime.datetime` value's :attr:`datetime.datetime.tzinfo` is always converted to UTC. - - Unlike SQLAlchemy's built-in :class:`~sqlalchemy.types.DateTime`, - it never return naive :class:`~datetime.datetime`, but time zone + - Unlike SQLAlchemy's built-in :class:`sqlalchemy.types.DateTime`, + it never return naive :class:`datetime.datetime`, but time zone aware value, even with SQLite or MySQL. """ From 58302595f372ecc6ef366cdbc707704e22220b14 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 03:06:18 +0100 Subject: [PATCH 079/446] [Doc] Added full documentation to Auth --- flaschengeist/modules/auth/__init__.py | 98 ++++++++++++++++++-------- 1 file changed, 70 insertions(+), 28 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index d50223a..c565741 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -1,13 +1,6 @@ """Authentication plugin, provides basic routes Allow management of authentication, login, logout, etc. - - ```Routes - /auth POST: login (new token) - GET: get all tokens for user - /auth/ GET: get lifetime of token - PUT: set new lifetime - DELETE: logout / delete token``` """ from flask import Blueprint, request, jsonify @@ -27,10 +20,10 @@ class AuthRoutePlugin(Plugin): @auth_bp.route("/auth", methods=["POST"]) -def _login(): - """Login in an user and create a `flaschengeist.system.models.Session` for the user. +def login(): + """Login in an user and create a session - Route: ``/auth`` + Route: ``/auth`` | Method: ``POST`` POST-data: {'userid': string, 'password': string} @@ -61,14 +54,28 @@ def _login(): @auth_bp.route("/auth", methods=["GET"]) @login_required() -def _get_sessions(current_session, **kwargs): +def get_sessions(current_session, **kwargs): + """Get all valid sessions of current user + + Route: ``/auth`` | Method: ``GET`` + + Returns: + A JSON array of `flaschengeist.system.models.session.Session` or HTTP error + """ sessions = sessionController.get_users_sessions(current_session._user) return jsonify(sessions) @auth_bp.route("/auth/", methods=["DELETE"]) @login_required() -def _delete_session(token, current_session, **kwargs): +def delete_session(token, current_session, **kwargs): + """Delete a session aka "logout" + + Route: ``/auth/`` | Method: ``DELETE`` + + Returns: + 200 Status (empty) or HTTP error + """ logger.debug("Try to delete access token {{ {} }}".format(token)) session = sessionController.get_session(token, current_session._user) if not session: @@ -78,12 +85,23 @@ def _delete_session(token, current_session, **kwargs): raise Forbidden sessionController.delete_session(session) sessionController.clear_expired() - return jsonify({"ok": "ok"}) + return "" @auth_bp.route("/auth/", methods=["GET"]) @login_required() -def _get_session(token, current_session, **kwargs): +def get_session(token, current_session, **kwargs): + """Retrieve information about a session + + Route: ``/auth/`` | Method: ``GET`` + + Attributes: + token: Token identifying session to retrieve + current_session: Session sent with Authorization Header + + Returns: + JSON encoded `flaschengeist.system.models.session.Session` or HTTP error + """ logger.debug("get token {{ {} }}".format(token)) session = sessionController.get_session(token, current_session._user) if not session: @@ -93,21 +111,22 @@ def _get_session(token, current_session, **kwargs): return jsonify(session) -@auth_bp.route("/auth//user", methods=["GET"]) -@login_required() -def _get_assocd_user(token, current_session, **kwargs): - logger.debug("get token {{ {} }}".format(token)) - session = sessionController.get_session(token, current_session._user) - if not session: - # Return 403 error, so that users can not bruteforce tokens - # Valid tokens from other users and invalid tokens now are looking the same - raise Forbidden - return jsonify(session._user) - - @auth_bp.route("/auth/", methods=["PUT"]) @login_required() -def _set_lifetime(token, current_session, **kwargs): +def set_lifetime(token, current_session, **kwargs): + """Set lifetime of a session + + Route: ``/auth/`` | Method: ``PUT`` + + POST-data: ``{value: int}`` + + Attributes: + token: Token identifying the session + current_session: Session sent with Authorization Header + + Returns: + HTTP-200 (empty) or HTTP error + """ session = sessionController.get_session(token, current_session._user) if not session: # Return 403 error, so that users can not bruteforce tokens @@ -117,6 +136,29 @@ def _set_lifetime(token, current_session, **kwargs): lifetime = request.get_json()["value"] logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) sessionController.set_lifetime(session, lifetime) - return jsonify({"ok": "ok"}) + return "" except (KeyError, TypeError): raise BadRequest + + +@auth_bp.route("/auth//user", methods=["GET"]) +@login_required() +def get_assocd_user(token, current_session, **kwargs): + """Retrieve user owning a session + + Route: ``/auth//user`` | Method: ``GET`` + + Attributes: + token: Token identifying the session + current_session: Session sent with Authorization Header + + Returns: + JSON encoded `flaschengeist.system.models.user.User` or HTTP error + """ + logger.debug("get token {{ {} }}".format(token)) + session = sessionController.get_session(token, current_session._user) + if not session: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + return jsonify(session._user) From a5d3b837cdd0cbb5cd67ef84d2c92149def37ae2 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 03:30:46 +0100 Subject: [PATCH 080/446] Restructured project, renamed modules, removed geruecht as it is dead. --- ...example.toml => flaschengeist.example.toml | 0 flaschengeist/__init__.py | 3 +- flaschengeist/app.py | 11 +- flaschengeist/{system => }/config.py | 2 +- .../baruser => controller}/__init__.py | 0 .../controller/eventController.py | 4 +- .../controller/messageController.py | 5 +- .../{system => }/controller/roleController.py | 4 +- .../controller/sessionController.py | 4 +- .../{system => }/controller/userController.py | 4 +- flaschengeist/{system => }/database.py | 0 flaschengeist/{system => }/decorator.py | 2 +- flaschengeist/{system => }/hook.py | 0 flaschengeist/{system => }/models/__init__.py | 0 flaschengeist/{system => }/models/event.py | 2 +- flaschengeist/{system => }/models/session.py | 2 +- flaschengeist/{system => }/models/user.py | 5 +- .../modules/geruecht/baruser/routes.py | 224 ----------- flaschengeist/modules/geruecht/creditList.py | 324 --------------- .../geruecht/databaseController/__init__.py | 72 ---- .../dbAccessTokenController.py | 82 ---- .../dbCreditListController.py | 73 ---- .../dbFreeDrinkListConfigController.py | 256 ------------ .../dbJobInviteController.py | 84 ---- .../databaseController/dbJobKindController.py | 132 ------ .../dbJobRequesController.py | 97 ----- .../dbPricelistController.py | 134 ------ .../dbRegistrationController.py | 32 -- .../databaseController/dbUserController.py | 215 ---------- .../databaseController/dbWorkerController.py | 81 ---- .../dbWorkgroupController.py | 126 ------ .../modules/geruecht/finanzer/__init__.py | 1 - .../modules/geruecht/finanzer/routes.py | 206 ---------- .../modules/geruecht/gastro/__init__.py | 0 .../modules/geruecht/gastro/routes.py | 97 ----- .../geruecht/mainController/__init__.py | 144 ------- .../mainCreditListController.py | 86 ---- .../mainFreeDrinkListConfigController.py | 52 --- .../mainController/mainJobInviteController.py | 42 -- .../mainController/mainJobKindController.py | 90 ----- .../mainJobRequestController.py | 46 --- .../mainController/mainPasswordReset.py | 39 -- .../mainController/mainPricelistController.py | 50 --- .../mainRegistrationController.py | 14 - .../mainController/mainWorkerController.py | 58 --- .../mainController/mainWorkgroupController.py | 42 -- .../modules/geruecht/vorstand/__init__.py | 0 .../modules/geruecht/vorstand/routes.py | 380 ------------------ .../{modules => plugins}/__init__.py | 2 +- .../{modules => plugins}/auth/__init__.py | 6 +- .../auth_ldap/__init__.py | 6 +- .../auth_plain/__init__.py | 4 +- .../{modules => plugins}/message_mail.py | 6 +- .../{modules => plugins}/roles/__init__.py | 6 +- .../{modules => plugins}/schedule/__init__.py | 10 +- .../{modules => plugins}/users/__init__.py | 6 +- flaschengeist/system/__init__.py | 0 flaschengeist/system/controller/__init__.py | 0 flaschengeist/readme.md => readme.md | 0 run_flaschengeist | 3 +- setup.py | 14 +- 61 files changed, 53 insertions(+), 3337 deletions(-) rename flaschengeist/flaschengeist.example.toml => flaschengeist.example.toml (100%) rename flaschengeist/{system => }/config.py (98%) rename flaschengeist/{modules/geruecht/baruser => controller}/__init__.py (100%) rename flaschengeist/{system => }/controller/eventController.py (94%) rename flaschengeist/{system => }/controller/messageController.py (64%) rename flaschengeist/{system => }/controller/roleController.py (92%) rename flaschengeist/{system => }/controller/sessionController.py (97%) rename flaschengeist/{system => }/controller/userController.py (95%) rename flaschengeist/{system => }/database.py (100%) rename flaschengeist/{system => }/decorator.py (92%) rename flaschengeist/{system => }/hook.py (100%) rename flaschengeist/{system => }/models/__init__.py (100%) rename flaschengeist/{system => }/models/event.py (98%) rename flaschengeist/{system => }/models/session.py (97%) rename flaschengeist/{system => }/models/user.py (97%) delete mode 100644 flaschengeist/modules/geruecht/baruser/routes.py delete mode 100644 flaschengeist/modules/geruecht/creditList.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/__init__.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbAccessTokenController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbCreditListController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbFreeDrinkListConfigController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbJobInviteController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbJobKindController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbJobRequesController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbPricelistController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbRegistrationController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbUserController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbWorkerController.py delete mode 100644 flaschengeist/modules/geruecht/databaseController/dbWorkgroupController.py delete mode 100644 flaschengeist/modules/geruecht/finanzer/__init__.py delete mode 100644 flaschengeist/modules/geruecht/finanzer/routes.py delete mode 100644 flaschengeist/modules/geruecht/gastro/__init__.py delete mode 100644 flaschengeist/modules/geruecht/gastro/routes.py delete mode 100644 flaschengeist/modules/geruecht/mainController/__init__.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainCreditListController.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainFreeDrinkListConfigController.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainJobInviteController.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainJobKindController.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainJobRequestController.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainPasswordReset.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainPricelistController.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainRegistrationController.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainWorkerController.py delete mode 100644 flaschengeist/modules/geruecht/mainController/mainWorkgroupController.py delete mode 100644 flaschengeist/modules/geruecht/vorstand/__init__.py delete mode 100644 flaschengeist/modules/geruecht/vorstand/routes.py rename flaschengeist/{modules => plugins}/__init__.py (97%) rename flaschengeist/{modules => plugins}/auth/__init__.py (96%) rename flaschengeist/{modules => plugins}/auth_ldap/__init__.py (96%) rename flaschengeist/{modules => plugins}/auth_plain/__init__.py (92%) rename flaschengeist/{modules => plugins}/message_mail.py (90%) rename flaschengeist/{modules => plugins}/roles/__init__.py (94%) rename flaschengeist/{modules => plugins}/schedule/__init__.py (96%) rename flaschengeist/{modules => plugins}/users/__init__.py (94%) delete mode 100644 flaschengeist/system/__init__.py delete mode 100644 flaschengeist/system/controller/__init__.py rename flaschengeist/readme.md => readme.md (100%) diff --git a/flaschengeist/flaschengeist.example.toml b/flaschengeist.example.toml similarity index 100% rename from flaschengeist/flaschengeist.example.toml rename to flaschengeist.example.toml diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 25564a2..8980c61 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -10,4 +10,5 @@ from werkzeug.local import LocalProxy __version__ = pkg_resources.get_distribution("flaschengeist").version _module_path = Path(__file__).parent -logger = LocalProxy(lambda: logging.getLogger(__name__)) \ No newline at end of file + +logger: logging.Logger = LocalProxy(lambda: logging.getLogger(__name__)) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 9d28eb4..057b177 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -6,9 +6,9 @@ from flask.json import JSONEncoder, jsonify from werkzeug.exceptions import HTTPException from . import logger -from .modules import AuthPlugin -from .system.config import config, configure_app -from .system.controller import roleController +from .plugins import AuthPlugin +from flaschengeist.config import config, configure_app +from flaschengeist.controller import roleController class CustomJSONEncoder(JSONEncoder): @@ -56,8 +56,7 @@ def __load_plugins(app): def install_all(): - from flaschengeist.system.database import db - from flaschengeist.system.models import user, event, session + from flaschengeist.database import db db.create_all() db.session.commit() @@ -77,7 +76,7 @@ def create_app(): CORS(app) with app.app_context(): - from .system.database import db + from flaschengeist.database import db configure_app(app) db.init_app(app) diff --git a/flaschengeist/system/config.py b/flaschengeist/config.py similarity index 98% rename from flaschengeist/system/config.py rename to flaschengeist/config.py index fa6aae3..5d1fc6a 100644 --- a/flaschengeist/system/config.py +++ b/flaschengeist/config.py @@ -3,7 +3,7 @@ import os import toml from pathlib import Path from werkzeug.middleware.proxy_fix import ProxyFix -from .. import _module_path, logger +from flaschengeist import _module_path, logger # Default config: config = {} diff --git a/flaschengeist/modules/geruecht/baruser/__init__.py b/flaschengeist/controller/__init__.py similarity index 100% rename from flaschengeist/modules/geruecht/baruser/__init__.py rename to flaschengeist/controller/__init__.py diff --git a/flaschengeist/system/controller/eventController.py b/flaschengeist/controller/eventController.py similarity index 94% rename from flaschengeist/system/controller/eventController.py rename to flaschengeist/controller/eventController.py index 0fa9e17..4e0b6b5 100644 --- a/flaschengeist/system/controller/eventController.py +++ b/flaschengeist/controller/eventController.py @@ -2,8 +2,8 @@ from werkzeug.exceptions import BadRequest, NotFound from sqlalchemy.exc import IntegrityError from flaschengeist import logger -from flaschengeist.system.database import db -from flaschengeist.system.models.event import EventKind, Event, EventSlot, JobSlot, JobKind +from flaschengeist.database import db +from flaschengeist.models.event import EventKind, Event, EventSlot, JobSlot, JobKind def get_event(id): diff --git a/flaschengeist/system/controller/messageController.py b/flaschengeist/controller/messageController.py similarity index 64% rename from flaschengeist/system/controller/messageController.py rename to flaschengeist/controller/messageController.py index 6114aec..16976d3 100644 --- a/flaschengeist/system/controller/messageController.py +++ b/flaschengeist/controller/messageController.py @@ -1,6 +1,5 @@ -from flaschengeist import logger -from flaschengeist.system.hook import Hook -from flaschengeist.system.models.user import User, Role +from flaschengeist.hook import Hook +from flaschengeist.models.user import User, Role class Message: diff --git a/flaschengeist/system/controller/roleController.py b/flaschengeist/controller/roleController.py similarity index 92% rename from flaschengeist/system/controller/roleController.py rename to flaschengeist/controller/roleController.py index 0758c53..43c82e8 100644 --- a/flaschengeist/system/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -1,8 +1,8 @@ from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import BadRequest, NotFound -from flaschengeist.system.models.user import Role, Permission -from flaschengeist.system.database import db +from flaschengeist.models.user import Role, Permission +from flaschengeist.database import db from flaschengeist import logger diff --git a/flaschengeist/system/controller/sessionController.py b/flaschengeist/controller/sessionController.py similarity index 97% rename from flaschengeist/system/controller/sessionController.py rename to flaschengeist/controller/sessionController.py index e9699f7..61ce3a4 100644 --- a/flaschengeist/system/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -1,6 +1,6 @@ import secrets -from ..models.session import Session -from flaschengeist.system.database import db +from flaschengeist.models.session import Session +from flaschengeist.database import db from flaschengeist import logger from werkzeug.exceptions import Forbidden from datetime import datetime, timezone diff --git a/flaschengeist/system/controller/userController.py b/flaschengeist/controller/userController.py similarity index 95% rename from flaschengeist/system/controller/userController.py rename to flaschengeist/controller/userController.py index 15b7ab2..4ee2340 100644 --- a/flaschengeist/system/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,8 +1,8 @@ from flask import current_app from werkzeug.exceptions import NotFound, BadRequest -from flaschengeist.system.models.user import User, Role -from flaschengeist.system.database import db +from flaschengeist.models.user import User, Role +from flaschengeist.database import db from flaschengeist import logger diff --git a/flaschengeist/system/database.py b/flaschengeist/database.py similarity index 100% rename from flaschengeist/system/database.py rename to flaschengeist/database.py diff --git a/flaschengeist/system/decorator.py b/flaschengeist/decorator.py similarity index 92% rename from flaschengeist/system/decorator.py rename to flaschengeist/decorator.py index aa6533d..02ff521 100644 --- a/flaschengeist/system/decorator.py +++ b/flaschengeist/decorator.py @@ -3,7 +3,7 @@ from flask import request from werkzeug.exceptions import Unauthorized from flaschengeist import logger -from flaschengeist.system.controller import sessionController +from flaschengeist.controller import sessionController def login_required(permission=None): diff --git a/flaschengeist/system/hook.py b/flaschengeist/hook.py similarity index 100% rename from flaschengeist/system/hook.py rename to flaschengeist/hook.py diff --git a/flaschengeist/system/models/__init__.py b/flaschengeist/models/__init__.py similarity index 100% rename from flaschengeist/system/models/__init__.py rename to flaschengeist/models/__init__.py diff --git a/flaschengeist/system/models/event.py b/flaschengeist/models/event.py similarity index 98% rename from flaschengeist/system/models/event.py rename to flaschengeist/models/event.py index c187bbe..6e433f1 100644 --- a/flaschengeist/system/models/event.py +++ b/flaschengeist/models/event.py @@ -3,7 +3,7 @@ from typing import Optional from . import ModelSerializeMixin from .user import User -from ..database import db +from flaschengeist.database import db class EventSlot(db.Model, ModelSerializeMixin): diff --git a/flaschengeist/system/models/session.py b/flaschengeist/models/session.py similarity index 97% rename from flaschengeist/system/models/session.py rename to flaschengeist/models/session.py index a17ba8b..0d49c8b 100644 --- a/flaschengeist/system/models/session.py +++ b/flaschengeist/models/session.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta, timezone from . import ModelSerializeMixin, UtcDateTime from .user import User -from ..database import db +from flaschengeist.database import db from secrets import compare_digest from flaschengeist import logger diff --git a/flaschengeist/system/models/user.py b/flaschengeist/models/user.py similarity index 97% rename from flaschengeist/system/models/user.py rename to flaschengeist/models/user.py index a527478..11df9e5 100644 --- a/flaschengeist/system/models/user.py +++ b/flaschengeist/models/user.py @@ -1,10 +1,7 @@ -from typing import List - from sqlalchemy.orm.collections import attribute_mapped_collection from . import ModelSerializeMixin -from ..database import db -from ... import logger +from flaschengeist.database import db association_table = db.Table( "user_x_role", diff --git a/flaschengeist/modules/geruecht/baruser/routes.py b/flaschengeist/modules/geruecht/baruser/routes.py deleted file mode 100644 index 0d9dbf1..0000000 --- a/flaschengeist/modules/geruecht/baruser/routes.py +++ /dev/null @@ -1,224 +0,0 @@ -from flask import Blueprint, request, jsonify -import geruecht.controller.ldapController as lc -import geruecht.controller.mainController as mc -import geruecht.controller.accesTokenController as ac -from datetime import datetime -from geruecht.model import BAR, MONEY, USER, VORSTAND, EXTERN -from geruecht.decorator import login_required -from geruecht.logger import getDebugLogger, getCreditLogger - -debug = getDebugLogger() -creditL = getCreditLogger() - -baruser = Blueprint("baruser", __name__) - -ldap = lc.LDAPController() -mainController = mc.MainController() -accesTokenController = ac.AccesTokenController() - - -@baruser.route("/bar") -@login_required(groups=[BAR], bar=True) -def _bar(**kwargs): - """ Main function for Baruser - - Returns JSON-file with all Users, who hast amounts in this month. - - Returns: - JSON-File with Users, who has amounts in this month - or ERROR 401 Permission Denied - """ - debug.info("/bar") - try: - dic = {} - users = mainController.getAllUsersfromDB() - for user in users: - geruecht = None - geruecht = user.getGeruecht(datetime.now().year) - if geruecht is not None: - all = geruecht.getSchulden() - if all != 0: - if all >= 0: - type = 'credit' - else: - type = 'amount' - dic[user.userid] = {"username": user.userid, - "firstname": user.firstname, - "lastname": user.lastname, - "amount": all, - "locked": user.locked, - "type": type, - "limit": user.limit, - "autoLock": user.autoLock - } - dic[user.userid]['last_seen'] = {"year": user.last_seen.year, "month": user.last_seen.month, "day": user.last_seen.day, "hour": user.last_seen.hour, "minute": user.last_seen.minute, "second": user.last_seen.second} if user.last_seen else None - debug.debug("return {{ {} }}".format(dic)) - return jsonify(dic) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@baruser.route("/baradd", methods=['POST']) -@login_required(groups=[BAR], bar=True) -def _baradd(**kwargs): - """ Function for Baruser to add amount - - This function added to the user with the posted userID the posted amount. - - Returns: - JSON-File with userID and the amount - or ERROR 401 Permission Denied - """ - debug.info("/baradd") - try: - data = request.get_json() - userID = data['userId'] - amount = int(data['amount']) - amountl = amount - date = datetime.now() - mainController.addAmount( - userID, amount, year=date.year, month=date.month, bar=True) - user = mainController.getUser(userID) - geruecht = user.getGeruecht(year=date.year) - month = geruecht.getMonth(month=date.month) - amount = abs(month[0] - month[1]) - all = geruecht.getSchulden() - if all >= 0: - type = 'credit' - else: - type = 'amount' - dic = user.toJSON() - dic['amount'] = all - dic['type'] = type - dic['last_seen'] = {"year": user.last_seen.year, "month": user.last_seen.month, "day": user.last_seen.day, "hour": user.last_seen.hour, "minute": user.last_seen.minute, "second": user.last_seen.second} if user.last_seen else None - debug.debug("return {{ {} }}".format(dic)) - creditL.info("{} Baruser {} {} fügt {} {} {} € Schulden hinzu.".format( - date, kwargs['accToken'].user.firstname, kwargs['accToken'].user.lastname, user.firstname, user.lastname, amountl/100)) - return jsonify(dic) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@baruser.route("/barGetUsers") -@login_required(groups=[BAR, MONEY], bar=True) -def _getUsers(**kwargs): - """ Get Users without amount - - This Function returns all Users, who hasn't an amount in this month. - - Returns: - JSON-File with Users - or ERROR 401 Permission Denied - """ - debug.info("/barGetUsers") - try: - retVal = {} - retVal = ldap.getAllUser() - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@baruser.route("/bar/storno", methods=['POST']) -@login_required(groups=[BAR], bar=True) -def _storno(**kwargs): - """ Function for Baruser to storno amount - - This function added to the user with the posted userID the posted amount. - - Returns: - JSON-File with userID and the amount - or ERROR 401 Permission Denied - """ - debug.info("/bar/storno") - try: - data = request.get_json() - userID = data['userId'] - amount = int(data['amount']) - amountl = amount - date = datetime.now() - mainController.addCredit( - userID, amount, year=date.year, month=date.month) - user = mainController.getUser(userID) - geruecht = user.getGeruecht(year=date.year) - month = geruecht.getMonth(month=date.month) - amount = abs(month[0] - month[1]) - all = geruecht.getSchulden() - if all >= 0: - type = 'credit' - else: - type = 'amount' - dic = user.toJSON() - dic['amount'] = all - dic['type'] = type - dic['last_seen'] = {"year": user.last_seen.year, "month": user.last_seen.month, "day": user.last_seen.day, - "hour": user.last_seen.hour, "minute": user.last_seen.minute, - "second": user.last_seen.second} if user.last_seen else None - debug.debug("return {{ {} }}".format(dic)) - creditL.info("{} Baruser {} {} storniert {} € von {} {}".format( - date, kwargs['accToken'].user.firstname, kwargs['accToken'].user.lastname, amountl/100, user.firstname, user.lastname)) - return jsonify(dic) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@baruser.route("/barGetUser", methods=['POST']) -@login_required(groups=[BAR], bar=True) -def _getUser(**kwargs): - debug.info("/barGetUser") - try: - data = request.get_json() - username = data['userId'] - user = mainController.getUser(username) - amount = user.getGeruecht(datetime.now().year).getSchulden() - if amount >= 0: - type = 'credit' - else: - type = 'amount' - - retVal = user.toJSON() - retVal['amount'] = amount - retVal['type'] = type - - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@baruser.route("/search", methods=['GET']) -@login_required(groups=[BAR, MONEY, USER, VORSTAND], bar=True) -def _search(**kwargs): - debug.info("/search") - try: - retVal = ldap.getAllUser() - for user in retVal: - if user['username'] == 'extern': - retVal.remove(user) - break - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@baruser.route("/bar/lock", methods=['GET', 'POST']) -@login_required(groups=[BAR], bar=True) -def _lockbar(**kwargs): - - debug.info('/bar/lock') - accToken = kwargs['accToken'] - if request.method == "POST": - data = request.get_json() - accToken.lock_bar = data['value'] - accToken = accesTokenController.updateAccessToken(accToken) - accToken = accesTokenController.validateAccessToken(accToken.token, [USER, EXTERN]) - debug.debug('return {{ "value": {} }}'.format(accToken.lock_bar)) - return jsonify({'value': accToken.lock_bar}) - diff --git a/flaschengeist/modules/geruecht/creditList.py b/flaschengeist/modules/geruecht/creditList.py deleted file mode 100644 index 9c909f8..0000000 --- a/flaschengeist/modules/geruecht/creditList.py +++ /dev/null @@ -1,324 +0,0 @@ -from datetime import datetime -from geruecht.logger import getDebugLogger - -debug = getDebugLogger() - -def create_empty_data(): - empty_data = {'id': 0, - 'jan_guthaben': 0, - 'jan_schulden': 0, - 'feb_guthaben': 0, - 'feb_schulden': 0, - 'maer_guthaben': 0, - 'maer_schulden': 0, - 'apr_guthaben': 0, - 'apr_schulden': 0, - 'mai_guthaben': 0, - 'mai_schulden': 0, - 'jun_guthaben': 0, - 'jun_schulden': 0, - 'jul_guthaben': 0, - 'jul_schulden': 0, - 'aug_guthaben': 0, - 'aug_schulden': 0, - 'sep_guthaben': 0, - 'sep_schulden': 0, - 'okt_guthaben': 0, - 'okt_schulden': 0, - 'nov_guthaben': 0, - 'nov_schulden': 0, - 'dez_guthaben': 0, - 'dez_schulden': 0, - 'last_schulden': 0, - 'year_date': datetime.now().year, - 'user_id': 0} - return empty_data - -class CreditList(): - """ DataBase Object Credit List: - - Attributes: - id: id in Database. Is the Primary Key - _guthaben: Credit of the Month. - _schulden: Debt of the Month. - - last_schulden: Debt or Credit of last Year. - year: Year of all Credits and Debts. - user_id: id from the User. - """ - def __init__(self, data): - debug.debug("init creditlist") - self.id = int(data['id']) - - self.jan_guthaben = int(data['jan_guthaben']) - self.jan_schulden = int(data['jan_schulden']) - - self.feb_guthaben = int(data['feb_guthaben']) - self.feb_schulden = int(data['feb_schulden']) - - self.maer_guthaben = int(data['maer_guthaben']) - self.maer_schulden = int(data['maer_schulden']) - - self.apr_guthaben = int(data['apr_guthaben']) - self.apr_schulden = int(data['apr_schulden']) - - self.mai_guthaben = int(data['mai_guthaben']) - self.mai_schulden = int(data['mai_schulden']) - - self.jun_guthaben = int(data['jun_guthaben']) - self.jun_schulden = int(data['jun_schulden']) - - self.jul_guthaben = int(data['jul_guthaben']) - self.jul_schulden = int(data['jul_schulden']) - - self.aug_guthaben = int(data['aug_guthaben']) - self.aug_schulden = int(data['aug_schulden']) - - self.sep_guthaben = int(data['sep_guthaben']) - self.sep_schulden = int(data['sep_schulden']) - - self.okt_guthaben = int(data['okt_guthaben']) - self.okt_schulden = int(data['okt_schulden']) - - self.nov_guthaben = int(data['nov_guthaben']) - self.nov_schulden = int(data['nov_schulden']) - - self.dez_guthaben = int(data['dez_guthaben']) - self.dez_schulden = int(data['dez_schulden']) - - self.last_schulden = int(data['last_schulden']) - - self.year = int(data['year_date']) - - self.user_id = int(data['user_id']) - - debug.debug("credit list is {{ {} }}".format(self)) - - def getSchulden(self): - """ Get Schulden - - This function calculate the total amount of them self. - From the Credit of the Month will the Amount of the Month subtract. - Finaly all Month will added together. - At Last the amount from last year will be subtract. - - If the Return Value is negativ, the User has an Credit, else the User has an amount. - - Returns: - double of the calculated amount - """ - debug.info("calculate amount") - jan = self.jan_guthaben - self.jan_schulden - feb = self.feb_guthaben - self.feb_schulden - maer = self.maer_guthaben - self.maer_schulden - apr = self.apr_guthaben - self.apr_schulden - mai = self.mai_guthaben - self.mai_schulden - jun = self.jun_guthaben - self.jun_schulden - jul = self.jul_guthaben - self.jul_schulden - aug = self.aug_guthaben - self.aug_schulden - sep = self.sep_guthaben - self.sep_schulden - okt = self.okt_guthaben - self.okt_schulden - nov = self.nov_guthaben - self.nov_schulden - dez = self.dez_guthaben - self.dez_schulden - - sum = jan + feb + maer + apr + mai + jun + jul + aug + sep + okt + nov + dez - self.last_schulden - debug.debug("amount is {{ {} }}".format(sum)) - return sum - - def getMonth(self, month=datetime.now().month): - """ Get Amount from month - - This function returns the amount and credit of the month. - By default is month the actual month - - Args: - month: which month you want to get the amount(1-12) - - Returns: - double (credit, amount) - """ - debug.info("get credit and amount from month {{ {} }}".format(month)) - retValue = None - - if month == 1: - retValue = (self.jan_guthaben, self.jan_schulden) - elif month == 2: - retValue = (self.feb_guthaben, self.feb_schulden) - elif month == 3: - retValue = (self.maer_guthaben, self.maer_schulden) - elif month == 4: - retValue = (self.apr_guthaben, self.apr_schulden) - elif month == 5: - retValue = (self.mai_guthaben, self.mai_schulden) - elif month == 6: - retValue = (self.jun_guthaben, self.jun_schulden) - elif month == 7: - retValue = (self.jul_guthaben, self.jul_schulden) - elif month == 8: - retValue = (self.aug_guthaben, self.aug_schulden) - elif month == 9: - retValue = (self.sep_guthaben, self.sep_schulden) - elif month == 10: - retValue = (self.okt_guthaben, self.okt_schulden) - elif month == 11: - retValue = (self.nov_guthaben, self.nov_schulden) - elif month == 12: - retValue = (self.dez_guthaben, self.dez_schulden) - debug.debug("credit and amount is {{ {} }}".format(retValue)) - return retValue - - def addAmount(self, amount, month=datetime.now().month): - """ Add Amount - - This function add an amount to a month and returns the credit and amount of the month. - By default is month the actual month. - - Args: - amount: the amount which is to add - month: in which month to add the amount (1-12) - - Returns: - double (credit, amount) - """ - debug.info("add amount in month {{ {} }}".format(month)) - if month == 1: - self.jan_schulden += amount - retValue = (self.jan_guthaben, self.jan_schulden) - elif month == 2: - self.feb_schulden += amount - retValue = (self.feb_guthaben, self.feb_schulden) - elif month == 3: - self.maer_schulden += amount - retValue = (self.maer_guthaben, self.maer_schulden) - elif month == 4: - self.apr_schulden += amount - retValue = (self.apr_guthaben, self.apr_schulden) - elif month == 5: - self.mai_schulden += amount - retValue = (self.mai_guthaben, self.mai_schulden) - elif month == 6: - self.jun_schulden += amount - retValue = (self.jun_guthaben, self.jun_schulden) - elif month == 7: - self.jul_schulden += amount - retValue = (self.jul_guthaben, self.jul_schulden) - elif month == 8: - self.aug_schulden += amount - retValue = (self.aug_guthaben, self.aug_schulden) - elif month == 9: - self.sep_schulden += amount - retValue = (self.sep_guthaben, self.sep_schulden) - elif month == 10: - self.okt_schulden += amount - retValue = (self.okt_guthaben, self.okt_schulden) - elif month == 11: - self.nov_schulden += amount - retValue = (self.nov_guthaben, self.nov_schulden) - elif month == 12: - self.dez_schulden += amount - retValue = (self.dez_guthaben, self.dez_schulden) - debug.debug("credit and amount is {{ {} }}".format(retValue)) - return retValue - - def addCredit(self, credit, month=datetime.now().month): - """ Add Credit - - This function add an credit to a month and returns the credit and amount of the month. - By default is month the actual month. - - Args: - credit: the credit which is to add - month: in which month to add the credit (1-12) - - Returns: - double (credit, amount) - """ - debug.info("add credit in month {{ {} }}".format(month)) - if month == 1: - self.jan_guthaben += credit - retValue = (self.jan_guthaben, self.jan_schulden) - elif month == 2: - self.feb_guthaben += credit - retValue = (self.feb_guthaben, self.feb_schulden) - elif month == 3: - self.maer_guthaben += credit - retValue = (self.maer_guthaben, self.maer_schulden) - elif month == 4: - self.apr_guthaben += credit - retValue = (self.apr_guthaben, self.apr_schulden) - elif month == 5: - self.mai_guthaben += credit - retValue = (self.mai_guthaben, self.mai_schulden) - elif month == 6: - self.jun_guthaben += credit - retValue = (self.jun_guthaben, self.jun_schulden) - elif month == 7: - self.jul_guthaben += credit - retValue = (self.jul_guthaben, self.jul_schulden) - elif month == 8: - self.aug_guthaben += credit - retValue = (self.aug_guthaben, self.aug_schulden) - elif month == 9: - self.sep_guthaben += credit - retValue = (self.sep_guthaben, self.sep_schulden) - elif month == 10: - self.okt_guthaben += credit - retValue = (self.okt_guthaben, self.okt_schulden) - elif month == 11: - self.nov_guthaben += credit - retValue = (self.nov_guthaben, self.nov_schulden) - elif month == 12: - self.dez_guthaben += credit - retValue = (self.dez_guthaben, self.dez_schulden) - debug.debug("credit and amount is {{ {} }}".format(retValue)) - return retValue - - def toJSON(self): - """ Create Dic to dump in JSON - - Returns: - A Dic with static Attributes. - """ - dic = { - "jan": { - "credit": self.jan_guthaben, - "depts": self.jan_schulden}, - "feb": { - "credit": self.feb_guthaben, - "depts": self.feb_schulden}, - "maer": { - "credit": self.maer_guthaben, - "depts": self.maer_schulden}, - "apr": { - "credit": self.apr_guthaben, - "depts": self.apr_schulden}, - "mai": { - "credit": self.mai_guthaben, - "depts": self.mai_schulden}, - "jun": { - "credit": self.jun_guthaben, - "depts": self.jun_schulden}, - "jul": { - "credit": self.jul_guthaben, - "depts": self.jul_schulden}, - "aug": { - "credit": self.aug_guthaben, - "depts": self.aug_schulden}, - "sep": { - "credit": self.sep_guthaben, - "depts": self.sep_schulden}, - "okt": { - "credit": self.okt_guthaben, - "depts": self.okt_schulden}, - "nov": { - "credit": self.nov_guthaben, - "depts": self.nov_schulden}, - "dez": { - "credit": self.dez_guthaben, - "depts": self.dez_schulden}, - "last": self.last_schulden - } - return dic - - def __repr__(self): - return "CreditList(year: {}, userID: {}, amount: {})".format(self.year, self.user_id, self.toJSON()) diff --git a/flaschengeist/modules/geruecht/databaseController/__init__.py b/flaschengeist/modules/geruecht/databaseController/__init__.py deleted file mode 100644 index de5ff2d..0000000 --- a/flaschengeist/modules/geruecht/databaseController/__init__.py +++ /dev/null @@ -1,72 +0,0 @@ -from flaschengeist.modules.geruecht.mainController import Singleton -from geruecht import db -from ..databaseController import dbUserController, dbCreditListController, dbJobKindController, dbPricelistController, dbWorkerController, dbWorkgroupController, dbJobInviteController, dbJobRequesController, dbAccessTokenController, dbRegistrationController, dbFreeDrinkListConfigController -from geruecht.exceptions import DatabaseExecption -import traceback -from MySQLdb._exceptions import IntegrityError - -class DatabaseController(dbUserController.Base, - dbCreditListController.Base, - dbWorkerController.Base, - dbWorkgroupController.Base, - dbPricelistController.Base, - dbJobKindController.Base, - dbJobInviteController.Base, - dbJobRequesController.Base, - dbAccessTokenController.Base, - dbRegistrationController.Base, - dbFreeDrinkListConfigController.Base, - metaclass=Singleton): - ''' - DatabaesController - - Connect to the Database and execute sql-executions - ''' - - def __init__(self): - self.db = db - - def getLockedDay(self, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from locked_days where daydate='{}'".format(date)) - data = cursor.fetchone() - return data - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def setLockedDay(self, date, locked, hard=False): - try: - cursor = self.db.connection.cursor() - sql = "insert into locked_days (daydate, locked) VALUES ('{}', {})".format(date, locked) - cursor.execute(sql) - self.db.connection.commit() - return self.getLockedDay(date) - except IntegrityError as err: - self.db.connection.rollback() - try: - exists = self.getLockedDay(date) - if hard: - sql = "update locked_days set locked={} where id={}".format(locked, exists['id']) - else: - sql = False - if sql: - cursor.execute(sql) - self.db.connection.commit() - return self.getLockedDay(date) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - -if __name__ == '__main__': - db = DatabaseController() - user = db.getUser('jhille') - db.getCreditListFromUser(user, year=2018) diff --git a/flaschengeist/modules/geruecht/databaseController/dbAccessTokenController.py b/flaschengeist/modules/geruecht/databaseController/dbAccessTokenController.py deleted file mode 100644 index 13ae442..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbAccessTokenController.py +++ /dev/null @@ -1,82 +0,0 @@ -import traceback -from geruecht.exceptions import DatabaseExecption -from geruecht.model.accessToken import AccessToken - - -class Base: - - def getAccessToken(self, item): - try: - cursor = self.db.connection.cursor() - if type(item) == str: - sql = "select * from session where token='{}'".format(item) - elif type(item) == int: - sql = 'select * from session where id={}'.format(item) - else: - raise DatabaseExecption("item as no type int or str. name={}, type={}".format(item, type(item))) - cursor.execute(sql) - session = cursor.fetchone() - retVal = AccessToken(session['id'], self.getUserById(session['user']), session['token'], session['lifetime'], lock_bar=bool(session['lock_bar']),timestamp=session['timestamp'], browser=session['browser'], platform=session['platform']) if session != None else None - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def getAccessTokensFromUser(self, user): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from session where user={}".format(user.id)) - sessions = cursor.fetchall() - retVal = [ - AccessToken(session['id'], self.getUserById(session['user']), session['token'], session['lifetime'], - lock_bar=bool(session['lock_bar']), timestamp=session['timestamp'], browser=session['browser'], platform=session['platform']) for session in sessions] - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getAccessTokens(self): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from session") - sessions = cursor.fetchall() - retVal = [AccessToken(session['id'], self.getUserById(session['user']), session['token'], session['lifetime'], lock_bar=bool(session['lock_bar']),timestamp=session['timestamp'], browser=session['browser'], platform=session['platform']) for session in sessions] - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def createAccessToken(self, user, token, lifetime, timestamp, lock_bar, user_agent=None): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into session (user, timestamp, lock_bar, token, lifetime, browser, platform) VALUES ({}, '{}', {}, '{}', {}, '{}', '{}')".format(user.id, timestamp, lock_bar, token, lifetime, user_agent.browser if user_agent else 'NULL', user_agent.platform if user_agent else 'NULL')) - self.db.connection.commit() - return self.getAccessToken(token) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def updateAccessToken(self, accToken): - try: - cursor = self.db.connection.cursor() - cursor.execute("update session set timestamp='{}', lock_bar={}, lifetime={} where id={}".format(accToken.timestamp, accToken.lock_bar, accToken.lifetime, accToken.id)) - self.db.connection.commit() - return self.getAccessToken(accToken.id) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def deleteAccessToken(self, accToken): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from session where id={}".format(accToken.id)) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbCreditListController.py b/flaschengeist/modules/geruecht/databaseController/dbCreditListController.py deleted file mode 100644 index 7011ea7..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbCreditListController.py +++ /dev/null @@ -1,73 +0,0 @@ -import traceback -from datetime import datetime - -from geruecht.exceptions import DatabaseExecption -from geruecht.model.creditList import CreditList -from geruecht.model.user import User - - -class Base: - def getCreditListFromUser(self, user, **kwargs): - try: - if type(user) is User: - if user.userid == 'extern': - return [] - cursor = self.db.connection.cursor() - if 'year' in kwargs: - sql = "select * from creditList where user_id={} and year_date={}".format(user.id if type(user) is User else user, kwargs['year']) - else: - sql = "select * from creditList where user_id={}".format(user.id if type(user) is User else user) - cursor.execute(sql) - data = cursor.fetchall() - if len(data) == 0: - return self.createCreditList(user_id=user.id, year=datetime.now().year) - elif len(data) == 1: - return [CreditList(data[0])] - else: - return [CreditList(value) for value in data] - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - - def createCreditList(self, user_id, year=datetime.now().year): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into creditList (year_date, user_id) values ({},{})".format(year, user_id)) - self.db.connection.commit() - return self.getCreditListFromUser(user_id) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - - def updateCreditList(self, creditlist): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from creditList where user_id={} and year_date={}".format(creditlist.user_id, creditlist.year)) - data = cursor.fetchall() - if len(data) == 0: - self.createCreditList(creditlist.user_id, creditlist.year) - sql = "update creditList set jan_guthaben={}, jan_schulden={},feb_guthaben={}, feb_schulden={}, maer_guthaben={}, maer_schulden={}, apr_guthaben={}, apr_schulden={}, mai_guthaben={}, mai_schulden={}, jun_guthaben={}, jun_schulden={}, jul_guthaben={}, jul_schulden={}, aug_guthaben={}, aug_schulden={},sep_guthaben={}, sep_schulden={},okt_guthaben={}, okt_schulden={}, nov_guthaben={}, nov_schulden={}, dez_guthaben={}, dez_schulden={}, last_schulden={} where year_date={} and user_id={}".format(creditlist.jan_guthaben, creditlist.jan_schulden, - creditlist.feb_guthaben, creditlist.feb_schulden, - creditlist.maer_guthaben, creditlist.maer_schulden, - creditlist.apr_guthaben, creditlist.apr_schulden, - creditlist.mai_guthaben, creditlist.mai_schulden, - creditlist.jun_guthaben, creditlist.jun_schulden, - creditlist.jul_guthaben, creditlist.jul_schulden, - creditlist.aug_guthaben, creditlist.aug_schulden, - creditlist.sep_guthaben, creditlist.sep_schulden, - creditlist.okt_guthaben, creditlist.okt_schulden, - creditlist.nov_guthaben, creditlist.nov_schulden, - creditlist.dez_guthaben, creditlist.dez_schulden, - creditlist.last_schulden, creditlist.year, creditlist.user_id) - print(sql) - cursor = self.db.connection.cursor() - cursor.execute(sql) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbFreeDrinkListConfigController.py b/flaschengeist/modules/geruecht/databaseController/dbFreeDrinkListConfigController.py deleted file mode 100644 index 5844e54..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbFreeDrinkListConfigController.py +++ /dev/null @@ -1,256 +0,0 @@ -import traceback -from datetime import datetime -from datetime import timedelta -from geruecht.exceptions import DatabaseExecption - -class Base: - def get_free_drink_list_config(self, id): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'select * from free_drink_list_config where id={id}') - data = cursor.fetchone() - if data['drink_id'] != None: - data['drink'] = self.getDrinkPrice(data['drink_id']) - data['free_drink_types'] = self.get_free_drink_list_types_for_drink(data['id']) - return data - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def get_free_drink_list_configs(self): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from free_drink_list_config") - retVal = cursor.fetchall() - for data in retVal: - if data['drink_id'] != None: - data['drink'] = self.getDrinkPrice(data['drink_id']) - data['free_drink_types'] = self.get_free_drink_list_types_for_drink(data['id']) - - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def set_free_drink_list_config(self, free_drink_list_config): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'insert into free_drink_list_config (drink_id, label, price) values ({free_drink_list_config["drink"]["id"]}, "{free_drink_list_config["label"]}", {free_drink_list_config["price"]})') - self.db.connection.commit() - cursor.execute(f'select id from free_drink_list_config where drink_id={free_drink_list_config["drink"]["id"]} and label="{free_drink_list_config["label"]}" and price={free_drink_list_config["price"]}') - data = cursor.fetchone() - for free_drink_type in free_drink_list_config["free_drink_types"]: - cursor.execute( - f'insert into free_drink_list_type_config (free_drink_list_config_id, free_drink_list_type_id) values ({data["id"]},{free_drink_type["id"]})') - self.db.connection.commit() - return self.get_free_drink_list_configs() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def update_free_drink_list_config(self, free_drink_list_config): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'update free_drink_list_config set drink_id={free_drink_list_config["drink"]["id"]}, label="{free_drink_list_config["label"]}", price={free_drink_list_config["price"]} where id={free_drink_list_config["id"]}') - cursor.execute(f'delete from free_drink_list_type_config where free_drink_list_config_id={free_drink_list_config["id"]}') - for free_drink_type in free_drink_list_config["free_drink_types"]: - cursor.execute(f'insert into free_drink_list_type_config (free_drink_list_config_id, free_drink_list_type_id) values ({free_drink_list_config["id"]},{free_drink_type["id"]})') - self.db.connection.commit() - return self.get_free_drink_list_configs() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def delete_free_drink_list_config(self, free_drink_list_config): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'delete from free_drink_list_type_config where free_drink_list_config_id={free_drink_list_config["id"]}') - cursor.execute(f'delete from free_drink_list_history where free_drink_config_id={free_drink_list_config["id"]}') - cursor.execute(f'delete from free_drink_list_config where id={free_drink_list_config["id"]}') - self.db.connection.commit() - return self.get_free_drink_list_configs() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def get_free_drink_list_types(self): - try: - cursor = self.db.connection.cursor() - cursor.execute('select * from free_drink_list_type') - return cursor.fetchall() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def get_free_drink_list_types_for_drink(self, id): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'select a.* from free_drink_list_type a, free_drink_list_type_config b where free_drink_list_config_id={id} and b.free_drink_list_type_id=a.id') - return cursor.fetchall() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def get_free_drink_list_type(self, name): - try: - cursor = self.db.connection.cursor() - if type(name) == str: - sql = f'select * from free_drink_list_type where name={name}' - elif type(name) == int: - sql = f'select * from free_drink_list_type where id={name}' - else: - raise DatabaseExecption("name as no type int or str. name={}, type={}".format(name, type(name))) - cursor.execute(sql) - return cursor.fetchone() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def set_free_drink_list_history(self, user, free_drink_list_config): - try: - cursor = self.db.connection.cursor() - if 'free_drink_list_reason_id' in free_drink_list_config and 'description' in free_drink_list_config: - sql = f'insert into free_drink_list_history (timestamp, free_drink_config_id, user_id, free_drink_type_id, free_drink_list_reason_id, description) values ("{datetime.now()}", {free_drink_list_config["id"]}, {user.id}, {free_drink_list_config["free_drink_type_id"]}, {free_drink_list_config["free_drink_list_reason_id"]}, "{free_drink_list_config["description"]}")' - else: - sql = f'insert into free_drink_list_history (timestamp, free_drink_config_id, user_id, free_drink_type_id) values ("{datetime.now()}", {free_drink_list_config["id"]}, {user.id}, {free_drink_list_config["free_drink_type_id"]})' - cursor.execute(sql) - self.db.connection.commit() - return self.get_free_drink_list_history_by_user(user) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def get_free_drink_list_history_by_user(self, user): - try: - cursor = self.db.connection.cursor() - now = datetime.now() - worker = self.getWorker(user, now) - if worker: - timestamp = worker["startdatetime"] - else: - timestamp = datetime.now() - timedelta(minutes=30) - cursor.execute(f'select * from free_drink_list_history where timestamp>="{timestamp}" and (user_id={user.id} or free_drink_type_id=3)') - retVal = cursor.fetchall() - for data in retVal: - data['timestamp'] = {'year': data['timestamp'].year, - 'month': data['timestamp'].month, - 'day': data['timestamp'].day, - 'hour': data['timestamp'].hour, - 'minute': data['timestamp'].minute, - 'second': data['timestamp'].second} - data['free_drink_config'] = self.get_free_drink_list_config(data['free_drink_config_id']) - data['free_drink_type'] = self.get_free_drink_list_type(data['free_drink_type_id']) - data['free_drink_list_reason'] = self.get_free_drink_list_reason(data['free_drink_list_reason_id']) if data['free_drink_list_reason_id'] else None - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def get_free_drink_list_history_from_to(self, from_date, to_date): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'select * from free_drink_list_history where timestamp>="{from_date}" and timestamp<="{to_date}"') - retVal = cursor.fetchall() - for data in retVal: - data['timestamp'] = {'year': data['timestamp'].year, - 'month': data['timestamp'].month, - 'day': data['timestamp'].day, - 'hour': data['timestamp'].hour, - 'minute': data['timestamp'].minute, - 'second': data['timestamp'].second} - data['free_drink_config'] = self.get_free_drink_list_config(data['free_drink_config_id']) - data['free_drink_type'] = self.get_free_drink_list_type(data['free_drink_type_id']) - data['free_drink_list_reason'] = self.get_free_drink_list_reason(data['free_drink_list_reason_id']) if \ - data['free_drink_list_reason_id'] else None - data['user'] = self.getUserById(data['user_id'], workgroups=False, geruecht=False).toJSON() - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def update_free_drink_list_history(self, free_drink_list_history): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'update free_drink_list_history set canceled={free_drink_list_history["canceled"]} where id={free_drink_list_history["id"]}') - self.db.connection.commit() - return True - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def delete_free_drink_list_history(self, free_drink_list_history): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'delete from free_drink_list_history where id={free_drink_list_history["id"]}') - self.db.connection.commit() - return True - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def get_free_drink_list_reason(self, id): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'select * from free_drink_list_reason where id={id}') - return cursor.fetchone() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def get_free_drink_list_reasons(self): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'select * from free_drink_list_reason') - return cursor.fetchall() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def set_free_drink_list_reason(self, free_drink_list_reason): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'insert into free_drink_list_reason (name) values ("{free_drink_list_reason["name"]}")') - self.db.connection.commit() - return self.get_free_drink_list_reasons() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def update_free_drink_list_reason(self, free_drink_list_reason): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'update free_drink_list_reason set name="{free_drink_list_reason["name"]}" where id={free_drink_list_reason["id"]}') - self.db.connection.commit() - return self.get_free_drink_list_reasons() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def delete_free_drink_list_reason(self, free_drink_list_reason): - try: - cursor = self.db.connection.cursor() - cursor.execute(f'update free_drink_list_history set free_drink_list_reason_id=NULL where free_drink_list_reason_id={free_drink_list_reason["id"]}') - cursor.execute(f'delete from free_drink_list_reason where id={free_drink_list_reason["id"]}') - self.db.connection.commit() - return self.get_free_drink_list_reasons() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbJobInviteController.py b/flaschengeist/modules/geruecht/databaseController/dbJobInviteController.py deleted file mode 100644 index 91d54ad..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbJobInviteController.py +++ /dev/null @@ -1,84 +0,0 @@ -import traceback - -from geruecht.exceptions import DatabaseExecption - - -class Base: - def getJobInvite(self, from_user, to_user, date, id=None): - try: - cursor = self.db.connection.cursor() - if id: - cursor.execute("select * from job_invites where id={}".format(id)) - else: - cursor.execute("select * from job_invites where from_user={} and to_user={} and on_date='{}'".format(from_user['id'], to_user['id'], date)) - retVal = cursor.fetchone() - retVal['to_user'] = self.getUserById(retVal['to_user']).toJSON() - retVal['from_user'] = self.getUserById(retVal['from_user']).toJSON() - retVal['on_date'] = {'year': retVal['on_date'].year, 'month': retVal['on_date'].month, 'day': retVal['on_date'].day} - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getJobInvitesFromUser(self, from_user, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from job_invites where from_user={} and on_date>='{}'".format(from_user['id'], date)) - retVal = cursor.fetchall() - for item in retVal: - item['from_user'] = from_user - item['to_user'] = self.getUserById(item['to_user']).toJSON() - item['on_date'] = {'year': item['on_date'].year, 'month': item['on_date'].month, 'day': item['on_date'].day} - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getJobInvitesToUser(self, to_user, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from job_invites where to_user={} and on_date>='{}'".format(to_user['id'], date)) - retVal = cursor.fetchall() - for item in retVal: - item['from_user'] = self.getUserById(item['from_user']).toJSON() - item['to_user'] = to_user - item['on_date'] = {'year': item['on_date'].year, 'month': item['on_date'].month, 'day': item['on_date'].day} - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def setJobInvite(self, from_user, to_user, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into job_invites (from_user, to_user, on_date) values ({}, {}, '{}')".format(from_user['id'], to_user['id'], date)) - self.db.connection.commit() - return self.getJobInvite(from_user, to_user, date) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def updateJobInvite(self, jobinvite): - try: - cursor = self.db.connection.cursor() - cursor.execute("update job_invites set watched={} where id={}".format(jobinvite['watched'], jobinvite['id'])) - self.db.connection.commit() - return self.getJobInvite(None, None, None, jobinvite['id']) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def deleteJobInvite(self, jobinvite): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from job_invites where id={}".format(jobinvite['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbJobKindController.py b/flaschengeist/modules/geruecht/databaseController/dbJobKindController.py deleted file mode 100644 index d131eb3..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbJobKindController.py +++ /dev/null @@ -1,132 +0,0 @@ -import traceback - -from geruecht.exceptions import DatabaseExecption - - -class Base: - def getAllJobKinds(self): - try: - cursor = self.db.connection.cursor() - cursor.execute('select * from job_kind') - list = cursor.fetchall() - for item in list: - item['workgroup'] = self.getWorkgroup(item['workgroup']) if item['workgroup'] != None else None - return list - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def getJobKind(self, name): - try: - cursor = self.db.connection.cursor() - if type(name) == str: - sql = "select * from job_kind where name='{}'".format(name) - elif type(name) == int: - sql = 'select * from job_kind where id={}'.format(name) - else: - raise DatabaseExecption("name as no type int or str. name={}, type={}".format(name, type(name))) - cursor.execute(sql) - retVal = cursor.fetchone() - retVal['workgroup'] = self.getWorkgroup(retVal['workgroup']) if retVal['workgroup'] != None else None - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def setJobKind(self, name, workgroup_id): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into job_kind (name, workgroup) values ('{}', {})".format(name, workgroup_id if workgroup_id != None else 'NULL')) - self.db.connection.commit() - return self.getJobKind(name) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def updateJobKind(self, jobkind): - try: - cursor = self.db.connection.cursor() - cursor.execute("update job_kind set name='{}', workgroup={} where id={}".format(jobkind['name'], jobkind['workgroup']['id'] if jobkind['workgroup'] != None else 'NULL', jobkind['id'])) - self.db.connection.commit() - return self.getJobKind(jobkind['id']) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def deleteJobKind(self, jobkind): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from job_kind where id={}".format(jobkind['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def setJobKindDates(self, date, jobkind, maxpersons): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into job_kind_dates (daydate, job_kind, maxpersons) values ('{}', {}, {})".format(date, jobkind['id'] if jobkind != None else 'NULL', maxpersons)) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def updateJobKindDates(self, jobkindDate): - try: - cursor = self.db.connection.cursor() - cursor.execute("update job_kind_dates set job_kind={}, maxpersons='{}' where id={}".format(jobkindDate['job_kind']['id'] if jobkindDate['job_kind'] != None else 'NULL', jobkindDate['maxpersons'], jobkindDate['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def getJobKindDates(self, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from job_kind_dates where daydate='{}'".format(date)) - list = cursor.fetchall() - for item in list: - item['job_kind'] = self.getJobKind(item['job_kind']) if item['job_kind'] != None else None - item['daydate'] = {'year': item['daydate'].year, 'month': item['daydate'].month, 'day': item['daydate'].day} - return list - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def getJobKindDate(self, date, job_kind): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from job_kind_dates where daydate='{}' and job_kind={}".format(date, job_kind['id'])) - item = cursor.fetchone() - if item: - item['job_kind'] = self.getJobKind(item['job_kind']) if item['job_kind'] != None else None - item['daydate'] = {'year': item['daydate'].year, 'month': item['daydate'].month, 'day': item['daydate'].day} - else: - item = { - 'job_kind': self.getJobKind(1), - 'daydate': {'year': date.year, 'month': date.month, 'day': date.day}, - 'maxpersons': 2 - } - return item - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def deleteJobKindDates(self, jobkinddates): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from job_kind_dates where id={}".format(jobkinddates['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbJobRequesController.py b/flaschengeist/modules/geruecht/databaseController/dbJobRequesController.py deleted file mode 100644 index 8f77752..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbJobRequesController.py +++ /dev/null @@ -1,97 +0,0 @@ -import traceback - -from geruecht.exceptions import DatabaseExecption - - -class Base: - def getJobRequest(self, from_user, to_user, date, id=None): - try: - cursor = self.db.connection.cursor() - if id: - cursor.execute("select * from job_request where id={}".format(id)) - else: - cursor.execute("select * from job_request where from_user={} and to_user={} and on_date='{}'".format(from_user['id'], to_user['id'], date)) - retVal = cursor.fetchone() - retVal['to_user'] = self.getUserById(retVal['to_user']).toJSON() - retVal['from_user'] = self.getUserById(retVal['from_user']).toJSON() - retVal['on_date'] = {'year': retVal['on_date'].year, 'month': retVal['on_date'].month, 'day': retVal['on_date'].day} - retVal['job_kind'] = self.getJobKind(retVal['job_kind']) - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getJobRequestsFromUser(self, from_user, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from job_request where from_user={} and on_date>='{}'".format(from_user['id'], date)) - retVal = cursor.fetchall() - for item in retVal: - item['from_user'] = from_user - item['to_user'] = self.getUserById(item['to_user']).toJSON() - item['on_date'] = {'year': item['on_date'].year, 'month': item['on_date'].month, 'day': item['on_date'].day} - item['job_kind'] = self.getJobKind(item['job_kind']) - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getJobRequestsToUser(self, to_user, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from job_request where to_user={} and on_date>='{}'".format(to_user['id'], date)) - retVal = cursor.fetchall() - for item in retVal: - item['from_user'] = self.getUserById(item['from_user']).toJSON() - item['to_user'] = to_user - item['on_date'] = {'year': item['on_date'].year, 'month': item['on_date'].month, 'day': item['on_date'].day} - item['job_kind'] = self.getJobKind(item['job_kind']) - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def setJobRequest(self, from_user, to_user, date, job_kind): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into job_request (from_user, to_user, on_date, job_kind) values ({}, {}, '{}', {})".format(from_user['id'], to_user['id'], date, job_kind['id'])) - self.db.connection.commit() - return self.getJobRequest(from_user, to_user, date) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def updateJobRequest(self, jobrequest): - try: - cursor = self.db.connection.cursor() - cursor.execute("update job_request set watched={}, answered={}, accepted={} where id={}".format(jobrequest['watched'], jobrequest['answered'], jobrequest['accepted'], jobrequest['id'])) - self.db.connection.commit() - return self.getJobRequest(None, None, None, jobrequest['id']) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def updateAllJobRequest(self, jobrequest): - try: - cursor = self.db.connection.cursor() - cursor.execute("update job_request set answered={} where from_user={} and on_date='{}'".format(jobrequest['answered'], jobrequest['from_user']['id'], jobrequest['on_date'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def deleteJobRequest(self, jobrequest): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from job_request where id={}".format(jobrequest['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbPricelistController.py b/flaschengeist/modules/geruecht/databaseController/dbPricelistController.py deleted file mode 100644 index 11f23d1..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbPricelistController.py +++ /dev/null @@ -1,134 +0,0 @@ -import traceback - -from geruecht.exceptions import DatabaseExecption - - -class Base: - def getPriceList(self): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from pricelist") - retVal = cursor.fetchall() - for data in retVal: - data['drink_type'] = self.getDrinkType(data['type']) - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def getDrinkPrice(self, name): - try: - cursor = self.db.connection.cursor() - if type(name) == str: - sql = "select * from pricelist where name='{}'".format(name) - elif type(name) == int: - sql = 'select * from pricelist where id={}'.format(name) - else: - raise DatabaseExecption("name as no type int or str. name={}, type={}".format(name, type(name))) - cursor.execute(sql) - retVal = cursor.fetchone() - if retVal: - retVal['drink_type'] = self.getDrinkType(retVal['type']) - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def setDrinkPrice(self, drink): - try: - cursor = self.db.connection.cursor() - cursor.execute( - "insert into pricelist (name, price, price_big, price_club, price_club_big, premium, premium_club, price_extern_club, type) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)", - ( - drink['name'], drink['price'], drink['price_big'], drink['price_club'], drink['price_club_big'], - drink['premium'], drink['premium_club'], drink['price_extern_club'], drink['type'])) - self.db.connection.commit() - return self.getDrinkPrice(str(drink['name'])) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def updateDrinkPrice(self, drink): - try: - cursor = self.db.connection.cursor() - cursor.execute("update pricelist set name=%s, price=%s, price_big=%s, price_club=%s, price_club_big=%s, premium=%s, premium_club=%s, price_extern_club=%s, type=%s where id=%s", - ( - drink['name'], drink['price'], drink['price_big'], drink['price_club'], drink['price_club_big'], drink['premium'], drink['premium_club'], drink['price_extern_club'], drink['type'], drink['id'] - )) - self.db.connection.commit() - return self.getDrinkPrice(drink['id']) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def deleteDrink(self, drink): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from pricelist where id={}".format(drink['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Database: {}".format(err)) - - def getDrinkType(self, name): - try: - cursor = self.db.connection.cursor() - if type(name) == str: - sql = "select * from drink_type where name='{}'".format(name) - elif type(name) == int: - sql = 'select * from drink_type where id={}'.format(name) - else: - raise DatabaseExecption("name as no type int or str. name={}, type={}".format(name, type(name))) - cursor.execute(sql) - return cursor.fetchone() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def setDrinkType(self, name): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into drink_type (name) values ('{}')".format(name)) - self.db.connection.commit() - return self.getDrinkType(name) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Database: {}".format(err)) - - def updateDrinkType(self, type): - try: - cursor = self.db.connection.cursor() - cursor.execute("update drink_type set name='{}' where id={}".format(type['name'], type['id'])) - self.db.connection.commit() - return self.getDrinkType(type['id']) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Database: {}".format(err)) - - def deleteDrinkType(self, type): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from drink_type where id={}".format(type['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def getAllDrinkTypes(self): - try: - cursor = self.db.connection.cursor() - cursor.execute('select * from drink_type') - return cursor.fetchall() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Database: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbRegistrationController.py b/flaschengeist/modules/geruecht/databaseController/dbRegistrationController.py deleted file mode 100644 index 39aa2e1..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbRegistrationController.py +++ /dev/null @@ -1,32 +0,0 @@ -import traceback -from geruecht.exceptions import DatabaseExecption - -class Base: - def setNewRegistration(self, data): - try: - cursor = self.db.connection.cursor() - if data['entryDate']: - sql = "insert into registration_list (firstname, lastname, clubname, email, keynumber, birthdate, entrydate) VALUES ('{}', '{}', '{}', '{}', {}, '{}', '{}')".format( - data['firstName'], - data['lastName'], - data['clubName'] if data['clubName'] else 'NULL', - data['mail'], - data['keynumber'] if data['keynumber'] else 'NULL', - data['birthDate'], - data['entryDate'] - ) - else: - sql = "insert into registration_list (firstname, lastname, clubname, email, keynumber, birthdate) VALUES ('{}', '{}', '{}', '{}', {}, '{}')".format( - data['firstName'], - data['lastName'], - data['clubName'] if data['clubName'] else 'NULL', - data['mail'], - data['keynumber'] if data['keynumber'] else 'NULL', - data['birthDate'] - ) - cursor.execute(sql) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbUserController.py b/flaschengeist/modules/geruecht/databaseController/dbUserController.py deleted file mode 100644 index f93110e..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbUserController.py +++ /dev/null @@ -1,215 +0,0 @@ -from geruecht.exceptions import DatabaseExecption, UsernameExistDB -from geruecht.model.user import User -import traceback - -class Base: - def getAllUser(self, extern=False, workgroups=True): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from user") - data = cursor.fetchall() - - if data: - retVal = [] - for value in data: - if extern and value['uid'] == 'extern': - continue - user = User(value) - creditLists = self.getCreditListFromUser(user) - user.initGeruechte(creditLists) - if workgroups: - user.workgroups = self.getWorkgroupsOfUser(user.id) - retVal.append(user) - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getUser(self, username, workgroups=True): - try: - retVal = None - cursor = self.db.connection.cursor() - cursor.execute("select * from user where uid='{}'".format(username)) - data = cursor.fetchone() - if data: - retVal = User(data) - creditLists = self.getCreditListFromUser(retVal) - retVal.initGeruechte(creditLists) - if workgroups: - retVal.workgroups = self.getWorkgroupsOfUser(retVal.id) - if retVal: - if retVal.uid == username: - return retVal - else: - return None - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getUserById(self, id, workgroups=True, geruecht=True): - try: - retVal = None - cursor = self.db.connection.cursor() - cursor.execute("select * from user where id={}".format(id)) - data = cursor.fetchone() - if data: - retVal = User(data) - if geruecht: - creditLists = self.getCreditListFromUser(retVal) - retVal.initGeruechte(creditLists) - if workgroups: - retVal.workgroups = self.getWorkgroupsOfUser(retVal.id) - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def _convertGroupToString(self, groups): - retVal = '' - print('groups: {}'.format(groups)) - if groups: - for group in groups: - if len(retVal) != 0: - retVal += ',' - retVal += group - return retVal - - - def insertUser(self, user): - try: - cursor = self.db.connection.cursor() - groups = self._convertGroupToString(user.group) - cursor.execute("insert into user (uid, dn, firstname, lastname, gruppe, lockLimit, locked, autoLock, mail) VALUES ('{}','{}','{}','{}','{}',{},{},{},'{}')".format( - user.userid, user.dn, user.firstname, user.lastname, groups, user.limit, user.locked, user.autoLock, user.mail)) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - - def updateUser(self, user): - try: - cursor = self.db.connection.cursor() - groups = self._convertGroupToString(user.group) - sql = "update user set dn='{}', firstname='{}', lastname='{}', gruppe='{}', lockLimit={}, locked={}, autoLock={}, mail='{}' where uid='{}'".format( - user.dn, user.firstname, user.lastname, groups, user.limit, user.locked, user.autoLock, user.mail, user.userid) - print(sql) - cursor.execute(sql) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def updateLastSeen(self, user, time): - try: - cursor = self.db.connection.cursor() - sql = "update user set last_seen='{}' where uid='{}'".format( - time, user.userid) - print(sql) - cursor.execute(sql) - self.db.connection.commit() - - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def changeUsername(self, user, newUsername): - try: - cursor= self.db.connection.cursor() - cursor.execute("select * from user where uid='{}'".format(newUsername)) - data = cursor.fetchall() - if data: - raise UsernameExistDB("Username already exists") - else: - cursor.execute("update user set uid='{}' where id={}".format(newUsername, user.id)) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getAllStatus(self): - try: - cursor = self.db.connection.cursor() - cursor.execute('select * from statusgroup') - return cursor.fetchall() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def getStatus(self, name): - try: - cursor = self.db.connection.cursor() - if type(name) == str: - sql = "select * from statusgroup where name='{}'".format(name) - elif type(name) == int: - sql = 'select * from statusgroup where id={}'.format(name) - else: - raise DatabaseExecption("name as no type int or str. name={}, type={}".format(name, type(name))) - cursor.execute(sql) - return cursor.fetchone() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def setStatus(self, name): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into statusgroup (name) values ('{}')".format(name)) - self.db.connection.commit() - return self.getStatus(name) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def updateStatus(self, status): - try: - cursor = self.db.connection.cursor() - cursor.execute("update statusgroup set name='{}' where id={}".format(status['name'], status['id'])) - self.db.connection.commit() - return self.getStatus(status['id']) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def deleteStatus(self, status): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from statusgroup where id={}".format(status['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def updateStatusOfUser(self, username, status): - try: - cursor = self.db.connection.cursor() - cursor.execute("update user set statusgroup={} where uid='{}'".format(status['id'], username)) - self.db.connection.commit() - return self.getUser(username) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def updateVotingOfUser(self, username, voting): - try: - cursor = self.db.connection.cursor() - cursor.execute("update user set voting={} where uid='{}'".format(voting, username)) - self.db.connection.commit() - return self.getUser(username) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbWorkerController.py b/flaschengeist/modules/geruecht/databaseController/dbWorkerController.py deleted file mode 100644 index c54f16d..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbWorkerController.py +++ /dev/null @@ -1,81 +0,0 @@ -import traceback -from datetime import timedelta - -from geruecht.exceptions import DatabaseExecption - - -class Base: - def getWorker(self, user, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from bardienste where user_id={} and startdatetime<='{}' and enddatetime>='{}'".format(user.id, date, date)) - data = cursor.fetchone() - return {"user": user.toJSON(), "startdatetime": data['startdatetime'], "enddatetime": data['enddatetime'], "start": { "year": data['startdatetime'].year, "month": data['startdatetime'].month, "day": data['startdatetime'].day}, "job_kind": self.getJobKind(data['job_kind']) if data['job_kind'] != None else None} if data else None - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getWorkers(self, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from bardienste where startdatetime='{}'".format(date)) - data = cursor.fetchall() - retVal = [] - return [{"user": self.getUserById(work['user_id']).toJSON(), "startdatetime": work['startdatetime'], "enddatetime": work['enddatetime'], "start": { "year": work['startdatetime'].year, "month": work['startdatetime'].month, "day": work['startdatetime'].day}, "job_kind": self.getJobKind(work['job_kind']) if work['job_kind'] != None else None} for work in data] - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def getWorkersWithJobKind(self, date, job_kind): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from bardienste where startdatetime='{}' and job_kind={} {}".format(date, job_kind['id'], "or job_kind='null'" if job_kind['id'] is 1 else '')) - data = cursor.fetchall() - retVal = [] - return [{"user": self.getUserById(work['user_id']).toJSON(), "startdatetime": work['startdatetime'], "enddatetime": work['enddatetime'], "start": { "year": work['startdatetime'].year, "month": work['startdatetime'].month, "day": work['startdatetime'].day}, "job_kind": self.getJobKind(work['job_kind']) if work['job_kind'] != None else None} for work in data] - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def setWorker(self, user, date, job_kind=None): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into bardienste (user_id, startdatetime, enddatetime, job_kind) values ({},'{}','{}', {})".format(user.id, date, date + timedelta(days=1), job_kind['id'] if job_kind != None else 'NULL')) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def changeWorker(self, from_user, to_user, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("update bardienste set user_id={} where user_id={} and startdatetime='{}'".format(to_user['id'], from_user['id'], date)) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) - - def deleteAllWorkerWithJobKind(self, date, job_kind): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from bardienste where startdatetime='{}' and job_kind={}".format(date, job_kind['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def deleteWorker(self, user, date): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from bardienste where user_id={} and startdatetime='{}'".format(user.id, date)) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Datatabase: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/databaseController/dbWorkgroupController.py b/flaschengeist/modules/geruecht/databaseController/dbWorkgroupController.py deleted file mode 100644 index c67f5d2..0000000 --- a/flaschengeist/modules/geruecht/databaseController/dbWorkgroupController.py +++ /dev/null @@ -1,126 +0,0 @@ -import traceback - -from geruecht.exceptions import DatabaseExecption - - -class Base: - def getAllWorkgroups(self): - try: - cursor = self.db.connection.cursor() - cursor.execute('select * from workgroup') - list = cursor.fetchall() - for item in list: - if item['boss'] != None: - item['boss']=self.getUserById(item['boss'], workgroups=False).toJSON() - return list - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def getWorkgroup(self, name): - try: - cursor = self.db.connection.cursor() - if type(name) == str: - sql = "select * from workgroup where name='{}'".format(name) - elif type(name) == int: - sql = 'select * from workgroup where id={}'.format(name) - else: - raise DatabaseExecption("name as no type int or str. name={}, type={}".format(name, type(name))) - cursor.execute(sql) - retVal = cursor.fetchone() - retVal['boss'] = self.getUserById(retVal['boss'], workgroups=False).toJSON() if retVal['boss'] != None else None - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def setWorkgroup(self, name, boss): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into workgroup (name, boss) values ('{}', {})".format(name, boss['id'])) - self.db.connection.commit() - return self.getWorkgroup(name) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def updateWorkgroup(self, workgroup): - try: - cursor = self.db.connection.cursor() - cursor.execute("update workgroup set name='{}', boss={} where id={}".format(workgroup['name'], workgroup['boss']['id'], workgroup['id'])) - self.db.connection.commit() - return self.getWorkgroup(workgroup['id']) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def deleteWorkgroup(self, workgroup): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from workgroup where id={}".format(workgroup['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went worng with Databes: {}".format(err)) - - def getWorkgroupsOfUser(self, userid): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from user_workgroup where user_id={} ".format(userid)) - knots = cursor.fetchall() - retVal = [self.getWorkgroup(knot['workgroup_id']) for knot in knots] - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def getUsersOfWorkgroups(self, workgroupid): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from user_workgroup where workgroup_id={}".format(workgroupid)) - knots = cursor.fetchall() - retVal = [self.getUserById(knot['user_id'], workgroups=False).toJSON() for knot in knots] - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def getUserWorkgroup(self, user, workgroup): - try: - cursor = self.db.connection.cursor() - cursor.execute("select * from user_workgroup where workgroup_id={} and user_id={}".format(workgroup['id'], user['id'])) - knot = cursor.fetchone() - retVal = {"workgroup": self.getWorkgroup(workgroup['id']), "user": self.getUserById(user['id'], workgroups=False).toJSON()} - return retVal - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def setUserWorkgroup(self, user, workgroup): - try: - cursor = self.db.connection.cursor() - cursor.execute("insert into user_workgroup (user_id, workgroup_id) VALUES ({}, {})".format(user['id'], workgroup['id'])) - self.db.connection.commit() - return self.getUserWorkgroup(user, workgroup) - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) - - def deleteWorkgroupsOfUser(self, user): - try: - cursor = self.db.connection.cursor() - cursor.execute("delete from user_workgroup where user_id={}".format(user['id'])) - self.db.connection.commit() - except Exception as err: - traceback.print_exc() - self.db.connection.rollback() - raise DatabaseExecption("Something went wrong with Database: {}".format(err)) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/finanzer/__init__.py b/flaschengeist/modules/geruecht/finanzer/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/flaschengeist/modules/geruecht/finanzer/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/flaschengeist/modules/geruecht/finanzer/routes.py b/flaschengeist/modules/geruecht/finanzer/routes.py deleted file mode 100644 index 2e8d561..0000000 --- a/flaschengeist/modules/geruecht/finanzer/routes.py +++ /dev/null @@ -1,206 +0,0 @@ -from flask import Blueprint, request, jsonify -from datetime import datetime -import geruecht.controller.mainController as mc -from geruecht.model import MONEY -from geruecht.decorator import login_required -from geruecht.logger import getDebugLogger, getCreditLogger - -debug = getDebugLogger() -creditL = getCreditLogger() - -finanzer = Blueprint("finanzer", __name__) - -mainController = mc.MainController() - - -@finanzer.route("/getFinanzerMain") -@login_required(groups=[MONEY]) -def _getFinanzer(**kwargs): - """ Function for /getFinanzerMain - - Retrieves all User for the groupe 'moneymaster' - - Returns: - A JSON-File with Users - or ERROR 401 Permission Denied. - """ - debug.info("/getFinanzerMain") - try: - users = mainController.getAllUsersfromDB() - dic = {} - for user in users: - dic[user.userid] = user.toJSON() - dic[user.userid]['creditList'] = { - credit.year: credit.toJSON() for credit in user.geruechte} - debug.debug("return {{ {} }}".format(dic)) - return jsonify(dic) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@finanzer.route("/finanzerAddAmount", methods=['POST']) -@login_required(groups=[MONEY]) -def _addAmount(**kwargs): - """ Add Amount to User - - This Function add an amount to the user with posted userID. - If year is not posted the default is the actual Year. - If month is not posted the default is the actual Month. - - Returns: - JSON-File with geruecht of year - or ERROR 401 Permission Denied - """ - debug.info("/finanzerAddAmount") - try: - data = request.get_json() - userID = data['userId'] - amount = int(data['amount']) - try: - year = int(data['year']) - except KeyError: - year = datetime.now().year - try: - month = int(data['month']) - except KeyError: - month = datetime.now().month - mainController.addAmount( - userID, amount, year=year, month=month, finanzer=True) - user = mainController.getUser(userID) - retVal = {str(geruecht.year): geruecht.toJSON() - for geruecht in user.geruechte} - retVal['locked'] = user.locked - debug.debug("return {{ {} }}".format(retVal)) - creditL.info("{} Finanzer {} {} fügt {} {} {} € Schulden hinzu.".format(datetime(year, month, 1).date( - ), kwargs['accToken'].user.firstname, kwargs['accToken'].user.lastname, user.firstname, user.lastname, amount/100)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@finanzer.route("/finanzerAddCredit", methods=['POST']) -@login_required(groups=[MONEY]) -def _addCredit(**kwargs): - """ Add Credit to User - - This Function add an credit to the user with posted userID. - If year is not posted the default is the actual Year. - If month is not posted the default is the actual Month. - - Returns: - JSON-File with geruecht of year - or ERROR 401 Permission Denied - """ - debug.info("/finanzerAddCredit") - try: - data = request.get_json() - userID = data['userId'] - credit = int(data['credit']) - - try: - year = int(data['year']) - except KeyError: - year = datetime.now().year - try: - month = int(data['month']) - except KeyError: - month = datetime.now().month - - mainController.addCredit( - userID, credit, year=year, month=month).toJSON() - user = mainController.getUser(userID) - retVal = {str(geruecht.year): geruecht.toJSON() - for geruecht in user.geruechte} - retVal['locked'] = user.locked - debug.debug("return {{ {} }}".format(retVal)) - creditL.info("{} Finanzer {} {} fügt {} {} {} € Guthaben hinzu.".format(datetime(year, month, 1).date( - ), kwargs['accToken'].user.firstname, kwargs['accToken'].user.lastname, user.firstname, user.lastname, credit / 100)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@finanzer.route("/finanzerLock", methods=['POST']) -@login_required(groups=[MONEY]) -def _finanzerLock(**kwargs): - debug.info("/finanzerLock") - try: - data = request.get_json() - username = data['userId'] - locked = bool(data['locked']) - retVal = mainController.lockUser(username, locked).toJSON() - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@finanzer.route("/finanzerSetConfig", methods=['POST']) -@login_required(groups=[MONEY]) -def _finanzerSetConfig(**kwargs): - debug.info("/finanzerSetConfig") - try: - data = request.get_json() - username = data['userId'] - autoLock = bool(data['autoLock']) - limit = int(data['limit']) - retVal = mainController.updateConfig( - username, {'lockLimit': limit, 'autoLock': autoLock}).toJSON() - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@finanzer.route("/finanzerAddUser", methods=['POST']) -@login_required(groups=[MONEY]) -def _finanzerAddUser(**kwargs): - debug.info("/finanzerAddUser") - try: - data = request.get_json() - username = data['userId'] - mainController.getUser(username) - users = mainController.getAllUsersfromDB() - dic = {} - for user in users: - dic[user.userid] = user.toJSON() - dic[user.userid]['creditList'] = { - credit.year: credit.toJSON() for credit in user.geruechte} - debug.debug("return {{ {} }}".format(dic)) - return jsonify(dic), 200 - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@finanzer.route("/finanzerSendOneMail", methods=['POST']) -@login_required(groups=[MONEY]) -def _finanzerSendOneMail(**kwargs): - debug.info("/finanzerSendOneMail") - try: - data = request.get_json() - username = data['userId'] - retVal = mainController.sendMail(username) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@finanzer.route("/finanzerSendAllMail", methods=['GET']) -@login_required(groups=[MONEY]) -def _finanzerSendAllMail(**kwargs): - debug.info("/finanzerSendAllMail") - try: - retVal = mainController.sendAllMail() - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 diff --git a/flaschengeist/modules/geruecht/gastro/__init__.py b/flaschengeist/modules/geruecht/gastro/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/flaschengeist/modules/geruecht/gastro/routes.py b/flaschengeist/modules/geruecht/gastro/routes.py deleted file mode 100644 index baca57a..0000000 --- a/flaschengeist/modules/geruecht/gastro/routes.py +++ /dev/null @@ -1,97 +0,0 @@ -from flask import request, jsonify, Blueprint -from geruecht.decorator import login_required -import geruecht.controller.mainController as mc -from geruecht.model import GASTRO -from geruecht.logger import getCreditLogger, getDebugLogger - -debug = getDebugLogger() - -gastrouser = Blueprint('gastrouser', __name__) - -mainController = mc.MainController() - - -@gastrouser.route('/gastro/setDrink', methods=['POST']) -@login_required(groups=[GASTRO]) -def setDrink(**kwargs): - debug.info("/gastro/setDrink") - try: - data = request.get_json() - retVal = mainController.setDrinkPrice(data) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@gastrouser.route('/gastro/updateDrink', methods=['POST']) -@login_required(groups=[GASTRO]) -def updateDrink(**kwargs): - debug.info("/gastro/updateDrink") - try: - data = request.get_json() - retVal = mainController.updateDrinkPrice(data) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@gastrouser.route('/gastro/deleteDrink', methods=['POST']) -@login_required(groups=[GASTRO]) -def deleteDrink(**kwargs): - debug.info("/gastro/dleteDrink") - try: - data = request.get_json() - id = data['id'] - mainController.deletDrinkPrice({"id": id}) - debug.debug("return ok") - return jsonify({"ok": "ok"}) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@gastrouser.route('/gastro/setDrinkType', methods=['POST']) -@login_required(groups=[GASTRO]) -def setType(**kwark): - debug.info("/gastro/setDrinkType") - try: - data = request.get_json() - name = data['name'] - retVal = mainController.setDrinkType(name) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@gastrouser.route('/gastro/updateDrinkType', methods=['POST']) -@login_required(groups=[GASTRO]) -def updateType(**kwargs): - debug.info("/gastro/updateDrinkType") - try: - data = request.get_json() - retVal = mainController.updateDrinkType(data) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@gastrouser.route('/gastro/deleteDrinkType', methods=['POST']) -@login_required(groups=[GASTRO]) -def deleteType(**kwargs): - debug.info("/gastro/deleteDrinkType") - try: - data = request.get_json() - mainController.deleteDrinkType(data) - debug.debug("return ok") - return jsonify({"ok": "ok"}) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 diff --git a/flaschengeist/modules/geruecht/mainController/__init__.py b/flaschengeist/modules/geruecht/mainController/__init__.py deleted file mode 100644 index 1d6f3f8..0000000 --- a/flaschengeist/modules/geruecht/mainController/__init__.py +++ /dev/null @@ -1,144 +0,0 @@ -from flaschengeist.system.controller import Singleton, userController -from flaschengeist.system.models.user import User -from datetime import datetime, timedelta -from flaschengeist.system.database import db -from flask import current_app -from werkzeug.local import LocalProxy -logger = LocalProxy(lambda: current_app.logger) - -class MainController(#mainJobKindController.Base, - #mainCreditListController.Base, - #mainPricelistController.Base, - #mainWorkerController.Base, - #mainWorkgroupController.Base, - #mainJobInviteController.Base, - #mainJobRequestController.Base, - #mainRegistrationController.Base, - #mainPasswordReset.Base, - #mainFreeDrinkListConfigController.Base, - metaclass=Singleton): - - def __init__(self): - logger.debug("init UserController") - pass - - def setLockedDay(self, date, locked, hard=False): - logger.info( - "set day locked on {{ {} }} with state {{ {} }}".format(date, locked)) - retVal = db.setLockedDay(date.date(), locked, hard) - logger.debug("seted day locked is {{ {} }}".format(retVal)) - return retVal - - def getLockedDays(self, from_date, to_date): - logger.info("get locked days from {{ {} }} to {{ {} }}".format( - from_date.date(), to_date.date())) - oneDay = timedelta(1) - delta = to_date.date() - from_date.date() - retVal = [] - startdate = from_date - oneDay - for _ in range(delta.days + 1): - startdate += oneDay - lockday = self.getLockedDay(startdate) - retVal.append(lockday) - logger.debug("lock days are {{ {} }}".format(retVal)) - return retVal - - def getLockedDaysFromList(self, date_list): - logger.info("get locked days from list {{ {} }}".format(date_list)) - retVal = [] - for on_date in date_list: - day = datetime(on_date['on_date']['year'], on_date['on_date']['month'], on_date['on_date']['day'], 12) - retVal.append(self.getLockedDay(day)) - return retVal - - def getLockedDay(self, date): - logger.info("get locked day on {{ {} }}".format(date)) - now = datetime.now() - logger.debug("now is {{ {} }}".format(now)) - oldMonth = False - logger.debug("check if date old month or current month") - for i in range(1, 8): - if datetime(now.year, now.month, i).weekday() == 2: - if now.day < i: - oldMonth = True - break - logger.debug("oldMonth is {{ {} }}".format(oldMonth)) - lockedYear = now.year - lockedMonth = now.month if now.month < now.month else now.month - \ - 1 if oldMonth else now.month - endDay = 1 - logger.debug("calculate end day of month") - lockedYear = lockedYear if lockedMonth != 12 else (lockedYear + 1) - lockedMonth = (lockedMonth + 1) if lockedMonth != 12 else 1 - for i in range(1, 8): - nextMonth = datetime(lockedYear, lockedMonth, i) - if nextMonth.weekday() == 2: - endDay = i - break - - monthLockedEndDate = datetime( - lockedYear, lockedMonth, endDay) - timedelta(1) - logger.debug("get lock day from database") - retVal = db.getLockedDay(date.date()) - if not retVal: - logger.debug( - "lock day not exists, retVal is {{ {} }}".format(retVal)) - if date.date() <= monthLockedEndDate.date(): - logger.debug("lock day {{ {} }}".format(date.date())) - self.setLockedDay(date, True) - retVal = db.getLockedDay(date.date()) - else: - retVal = {"daydate": date.date(), "locked": False} - logger.debug("locked day is {{ {} }}".format(retVal)) - return retVal - - def __updateDataFromLDAP(self, user): - logger.info("update data from ldap for user {{ {} }}".format(user)) - groups = ldap.getGroup(user.userid) - logger.debug("ldap gorups are {{ {} }}".format(groups)) - user_data = ldap.getUserData(user.userid) - logger.debug("ldap data is {{ {} }}".format(user_data)) - user_data['gruppe'] = groups - user_data['group'] = groups - user.updateData(user_data) - db.updateUser(user) - - def checkBarUser(self, user): - logger.info("check if user {{ {} }} is baruser") - date = datetime.now() - zero = date.replace(hour=0, minute=0, second=0, microsecond=0) - end = zero + timedelta(hours=12) - startdatetime = date.replace( - hour=12, minute=0, second=0, microsecond=0) - if date > zero and end > date: - startdatetime = startdatetime - timedelta(days=1) - enddatetime = startdatetime + timedelta(days=1) - logger.debug("startdatetime is {{ {} }} and enddatetime is {{ {} }}".format( - startdatetime, end)) - result = False - if date >= startdatetime and date < enddatetime: - result = db.getWorker(user, startdatetime) - logger.debug("worker is {{ {} }}".format(result)) - return True if result else False - - def sendMail(self, username): - logger.info("send mail to user {{ {} }}".format(username)) - if type(username) == User: - user = username - if type(username) == str: - user = db.getUser(username) - retVal = emailController.sendMail(user) - logger.debug("send mail is {{ {} }}".format(retVal)) - return retVal - - def sendAllMail(self): - debug.info("send mail to all users") - retVal = [] - users = db.getAllUser() - debug.debug("users are {{ {} }}".format(users)) - for user in users: - retVal.append(self.sendMail(user)) - debug.debug("send mails are {{ {} }}".format(retVal)) - return retVal - - diff --git a/flaschengeist/modules/geruecht/mainController/mainCreditListController.py b/flaschengeist/modules/geruecht/mainController/mainCreditListController.py deleted file mode 100644 index 9c56e6e..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainCreditListController.py +++ /dev/null @@ -1,86 +0,0 @@ -from datetime import datetime - -import geruecht.controller.databaseController as dc -import geruecht.controller.emailController as ec -from geruecht.logger import getDebugLogger - -db = dc.DatabaseController() -emailController = ec.EmailController() -debug = getDebugLogger() - -class Base: - def autoLock(self, user): - debug.info("start autolock of user {{ {} }}".format(user)) - if user.autoLock: - debug.debug("autolock is active") - credit = user.getGeruecht(year=datetime.now().year).getSchulden() - limit = -1*user.limit - if credit <= limit: - debug.debug( - "credit {{ {} }} is more than user limit {{ {} }}".format(credit, limit)) - debug.debug("lock user") - user.updateData({'locked': True}) - debug.debug("send mail to user") - emailController.sendMail(user) - else: - debug.debug( - "cretid {{ {} }} is less than user limit {{ {} }}".format(credit, limit)) - debug.debug("unlock user") - user.updateData({'locked': False}) - db.updateUser(user) - - def addAmount(self, username, amount, year, month, finanzer=False, bar=False): - debug.info("add amount {{ {} }} to user {{ {} }} no month {{ {} }}, year {{ {} }}".format( - amount, username, month, year)) - user = self.getUser(username) - debug.debug("user is {{ {} }}".format(user)) - if user.userid == 'extern': - debug.debug("user is extern user, so exit add amount") - return - if not user.locked or finanzer: - debug.debug("user is not locked {{ {} }} or is finanzer execution {{ {} }}".format( - user.locked, finanzer)) - user.addAmount(amount, year=year, month=month) - if bar: - user.last_seen = datetime.now() - db.updateLastSeen(user, user.last_seen) - creditLists = user.updateGeruecht() - debug.debug("creditList is {{ {} }}".format(creditLists)) - for creditList in creditLists: - debug.debug("update creditlist {{ {} }}".format(creditList)) - db.updateCreditList(creditList) - debug.debug("do autolock") - self.autoLock(user) - retVal = user.getGeruecht(year) - debug.debug("updated creditlists is {{ {} }}".format(retVal)) - return retVal - - def addCredit(self, username, credit, year, month): - debug.info("add credit {{ {} }} to user {{ {} }} on month {{ {} }}, year {{ {} }}".format( - credit, username, month, year)) - user = self.getUser(username) - debug.debug("user is {{ {} }}".format(user)) - if user.userid == 'extern': - debug.debug("user is extern user, so exit add credit") - return - user.addCredit(credit, year=year, month=month) - creditLists = user.updateGeruecht() - debug.debug("creditlists are {{ {} }}".format(creditLists)) - for creditList in creditLists: - debug.debug("update creditlist {{ {} }}".format(creditList)) - db.updateCreditList(creditList) - debug.debug("do autolock") - self.autoLock(user) - retVal = user.getGeruecht(year) - debug.debug("updated creditlists are {{ {} }}".format(retVal)) - return retVal - - def __updateGeruechte(self, user): - debug.debug("update creditlists") - user.getGeruecht(datetime.now().year) - creditLists = user.updateGeruecht() - debug.debug("creditlists are {{ {} }}".format(creditLists)) - if user.getGeruecht(datetime.now().year).getSchulden() != 0: - for creditList in creditLists: - debug.debug("update creditlist {{ {} }}".format(creditList)) - db.updateCreditList(creditList) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/mainController/mainFreeDrinkListConfigController.py b/flaschengeist/modules/geruecht/mainController/mainFreeDrinkListConfigController.py deleted file mode 100644 index 1e8fe9e..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainFreeDrinkListConfigController.py +++ /dev/null @@ -1,52 +0,0 @@ -import geruecht.controller.databaseController as dc -from geruecht.logger import getDebugLogger -from datetime import datetime - -db = dc.DatabaseController() -debug = getDebugLogger() - -class Base: - def get_free_drink_list_configs(self): - return db.get_free_drink_list_configs() - - def set_free_drink_list_config(self, data): - return db.set_free_drink_list_config(data) - - def update_free_drink_list_config(self, data): - return db.update_free_drink_list_config(data) - - def delete_free_drink_list_config(self, data): - return db.delete_free_drink_list_config(data) - - def set_free_drink_list_history(self, user, data): - return db.set_free_drink_list_history(user, data) - - def get_free_drink_list_history(self, user): - return db.get_free_drink_list_history_by_user(user) - - def delete_free_drink_list_history(self, data): - return db.delete_free_drink_list_history(data) - - def update_free_drink_list_history(self, user, data): - db.update_free_drink_list_history(data) - return db.get_free_drink_list_history_by_user(user) - - def get_free_drink_list_history_from_to(self, data): - from_date = datetime(data["from_date"]["year"], data["from_date"]["month"], data["from_date"]["day"]) - to_date = datetime(data["to_date"]["year"], data["to_date"]["month"], data["to_date"]["day"]) - return db.get_free_drink_list_history_from_to(from_date, to_date) - - def get_free_drink_list_reasons(self): - return db.get_free_drink_list_reasons() - - def set_free_drink_list_reason(self, data): - return db.set_free_drink_list_reason(data) - - def update_free_drink_list_reason(self, data): - return db.update_free_drink_list_reason(data) - - def delete_free_drink_list_reason(self, data): - return db.delete_free_drink_list_reason(data) - - def get_free_drink_types(self): - return db.get_free_drink_list_types() \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/mainController/mainJobInviteController.py b/flaschengeist/modules/geruecht/mainController/mainJobInviteController.py deleted file mode 100644 index ed8e50c..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainJobInviteController.py +++ /dev/null @@ -1,42 +0,0 @@ -from datetime import date - -import geruecht.controller.databaseController as dc -import geruecht.controller.emailController as ec -from geruecht import getDebugLogger - -db = dc.DatabaseController() -debug = getDebugLogger() -emailController = ec.EmailController() - -class Base: - def getJobInvites(self, from_user, to_user, date): - debug.info("get JobInvites from_user {{ {} }} to_user {{ {} }} on date {{ {} }}".format(from_user, to_user, date)) - if from_user is None: - retVal = db.getJobInvitesToUser(to_user, date) - elif to_user is None: - retVal = db.getJobInvitesFromUser(from_user, date) - else: - raise Exception("from_user {{ {} }} and to_user {{ {} }} are None".format(from_user, to_user)) - return retVal - - def setJobInvites(self, data): - debug.info("set new JobInvites data {{ {} }}".format(data)) - retVal = [] - for jobInvite in data: - from_user = jobInvite['from_user'] - to_user = jobInvite['to_user'] - on_date = date(jobInvite['date']['year'], jobInvite['date']['month'], jobInvite['date']['day']) - debug.info("set new JobInvite from_user {{ {} }}, to_user {{ {} }}, on_date {{ {} }}") - setJobInvite = db.setJobInvite(from_user, to_user, on_date) - retVal.append(setJobInvite) - emailController.sendMail(db.getUserById(to_user['id'], False), type='jobinvite', jobtransact=setJobInvite) - debug.debug("seted JobInvites are {{ {} }}".format(retVal)) - return retVal - - def updateJobInvites(self, data): - debug.info("update JobInvites data {{ {} }}".format(data)) - return db.updateJobInvite(data) - - def deleteJobInvite(self, jobInvite): - debug.info("delete JobInvite {{ {} }}".format(jobInvite)) - db.deleteJobInvite(jobInvite) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/mainController/mainJobKindController.py b/flaschengeist/modules/geruecht/mainController/mainJobKindController.py deleted file mode 100644 index 3eb67f0..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainJobKindController.py +++ /dev/null @@ -1,90 +0,0 @@ -from datetime import date, timedelta, datetime, time -import geruecht.controller.databaseController as dc -from geruecht.logger import getDebugLogger - -db = dc.DatabaseController() -debug = getDebugLogger() - -class Base: - def getAllJobKinds(self): - debug.info("get all jobkinds") - retVal = db.getAllJobKinds() - debug.debug("jobkinds are {{ {} }}".format(retVal)) - return retVal - - def getJobKind(self, name): - debug.info("get jobkinds {{ {} }}".format(name)) - retVal = db.getJobKind(name) - debug.debug("jobkind is {{ {} }} is {{ {} }}".format(name, retVal)) - return retVal - - def setJobKind(self, name, workgroup=None): - debug.info("set jobkind {{ {} }} ".format(name)) - retVal = db.setJobKind(name, workgroup) - debug.debug( - "seted jobkind {{ {} }} is {{ {} }}".format(name, retVal)) - return retVal - - def deleteJobKind(self, jobkind): - debug.info("delete jobkind {{ {} }}".format(jobkind)) - db.deleteJobKind(jobkind) - - def updateJobKind(self, jobkind): - debug.info("update workgroup {{ {} }}".format(jobkind)) - retVal = db.updateJobKind(jobkind) - debug.debug("updated jobkind is {{ {} }}".format(retVal)) - return retVal - - def getJobKindDates(self, date): - debug.info("get jobkinddates on {{ {} }}".format(date)) - retVal = db.getJobKindDates(date) - debug.debug("jobkinddates are {{ {} }}".format(retVal)) - return retVal - - def updateJobKindDates(self, jobkindDate): - debug.info("update jobkinddate {{ {} }}".format(jobkindDate)) - retVal = db.updateJobKindDates(jobkindDate) - debug.debug("updated jobkind is {{ {} }}".format(retVal)) - return retVal - - def deleteJobKindDates(self, jobkinddates): - debug.info("delete jobkinddates {{ {} }}".format(jobkinddates)) - db.deleteJobKindDates(jobkinddates) - - def setJobKindDates(self, datum, jobkind, maxpersons): - debug.info("set jobkinddates with {{ {}, {}, {}, }}".format(datum, jobkind, maxpersons)) - retVal = db.setJobKindDates(datum, jobkind, maxpersons) - debug.debug("seted jobkinddates is {{ {} }}".format(retVal)) - return retVal - - def controllJobKindDates(self, jobkinddates): - debug.info("controll jobkinddates {{ {} }}".format(jobkinddates)) - datum = None - for jobkinddate in jobkinddates: - datum = date(jobkinddate['daydate']['year'], jobkinddate['daydate']['month'], jobkinddate['daydate']['day']) - if jobkinddate['id'] == -1: - if jobkinddate['job_kind']: - self.setJobKindDates(datum, jobkinddate['job_kind'], jobkinddate['maxpersons']) - if jobkinddate['id'] == 0: - jobkinddate['id'] = jobkinddate['backupid'] - db.deleteAllWorkerWithJobKind(datetime.combine(datum, time(12)), jobkinddate['job_kind']) - self.deleteJobKindDates(jobkinddate) - if jobkinddate['id'] >= 1: - self.updateJobKindDates(jobkinddate) - retVal = self.getJobKindDates(datum) if datum != None else [] - debug.debug("controlled jobkinddates {{ {} }}".format(retVal)) - return retVal - - def getJobKindDatesFromTo(self, from_date, to_date): - debug.info("get locked days from {{ {} }} to {{ {} }}".format( - from_date.date(), to_date.date())) - oneDay = timedelta(1) - delta = to_date.date() - from_date.date() - retVal = [] - startdate = from_date - oneDay - for _ in range(delta.days + 1): - startdate += oneDay - jobkinddate = self.getJobKindDates(startdate) - retVal.append(jobkinddate) - debug.debug("lock days are {{ {} }}".format(retVal)) - return retVal \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/mainController/mainJobRequestController.py b/flaschengeist/modules/geruecht/mainController/mainJobRequestController.py deleted file mode 100644 index e31845e..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainJobRequestController.py +++ /dev/null @@ -1,46 +0,0 @@ -from datetime import date, time, datetime -import geruecht.controller.emailController as ec -import geruecht.controller.databaseController as dc -from geruecht import getDebugLogger - -db = dc.DatabaseController() -debug = getDebugLogger() -emailController = ec.EmailController() - -class Base: - def getJobRequests(self, from_user, to_user, date): - debug.info("get JobRequests from_user {{ {} }} to_user {{ {} }} on date {{ {} }}".format(from_user, to_user, date)) - if from_user is None: - retVal = db.getJobRequestsToUser(to_user, date) - elif to_user is None: - retVal = db.getJobRequestsFromUser(from_user, date) - else: - raise Exception("from_user {{ {} }} and to_user {{ {} }} are None".format(from_user, to_user)) - return retVal - - def setJobRequests(self, data): - debug.info("set new JobRequests data {{ {} }}".format(data)) - retVal = [] - for jobRequest in data: - from_user = jobRequest['from_user'] - to_user = jobRequest['to_user'] - on_date = date(jobRequest['date']['year'], jobRequest['date']['month'], jobRequest['date']['day']) - job_kind = jobRequest['job_kind'] - debug.info("set new JobRequest from_user {{ {} }}, to_user {{ {} }}, on_date {{ {} }}") - setJobRequest = db.setJobRequest(from_user, to_user, on_date, job_kind) - retVal.append(setJobRequest) - emailController.sendMail(db.getUserById(to_user['id']), type='jobtransact', jobtransact=setJobRequest) - - debug.debug("seted JobRequests are {{ {} }}".format(retVal)) - return retVal - - def updateJobRequests(self, data): - debug.info("update JobRequest data {{ {} }}".format(data)) - if data['accepted']: - self.changeWorker(data['from_user'], data['to_user'], datetime.combine(data['on_date'], time(12))) - db.updateAllJobRequest(data) - return db.updateJobRequest(data) - - def deleteJobRequest(self, jobRequest): - debug.info("delete JobRequest {{ {} }}".format(jobRequest)) - db.deleteJobRequest(jobRequest) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/mainController/mainPasswordReset.py b/flaschengeist/modules/geruecht/mainController/mainPasswordReset.py deleted file mode 100644 index 8cc031d..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainPasswordReset.py +++ /dev/null @@ -1,39 +0,0 @@ -from geruecht import ldap, ldapConfig, getDebugLogger -import geruecht.controller.emailController as ec -from ldap3.utils.hashed import hashed -from ldap3 import HASHED_SALTED_MD5, MODIFY_REPLACE -import string -import random - -emailController = ec.EmailController() -debug = getDebugLogger() - -def randomString(stringLength=8): - letters = string.ascii_letters + string.digits - return ''.join(random.choice(letters) for i in range(stringLength)) - -class Base: - def resetPassword(self, data): - debug.info("forgot password {{ {} }}".format(data)) - adminConn = ldap.connect(ldapConfig['ADMIN_DN'], ldapConfig['ADMIN_SECRET']) - if 'username' in data: - search = 'uid={}'.format(data['username'].lower()) - elif 'mail' in data: - search = 'mail={}'.format(data['mail'].lower()) - else: - debug.error("username or mail not set") - raise Exception('username or mail not set') - adminConn.search(ldapConfig['DN'], '(&(objectClass=person)({}))'.format(search), - attributes=['cn', 'sn', 'givenName', 'uid', 'mail']) - for user in adminConn.response: - user_dn = user['dn'] - uid = user['attributes']['uid'][0] - mail = user['attributes']['mail'][0] - mody = {} - password = randomString() - salted_password = hashed(HASHED_SALTED_MD5, password) - mody['userPassword'] = [(MODIFY_REPLACE, [salted_password])] - debug.info("reset password for {{ {} }}".format(user_dn)) - adminConn.modify(user_dn, mody) - emailController.sendMail(self.getUser(uid), type='passwordReset', password=password) - return mail \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/mainController/mainPricelistController.py b/flaschengeist/modules/geruecht/mainController/mainPricelistController.py deleted file mode 100644 index 06cec00..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainPricelistController.py +++ /dev/null @@ -1,50 +0,0 @@ -import geruecht.controller.databaseController as dc -from geruecht.logger import getDebugLogger - -db = dc.DatabaseController() -debug = getDebugLogger() - -class Base: - def deleteDrinkType(self, type): - debug.info("delete drink type {{ {} }}".format(type)) - db.deleteDrinkType(type) - - def updateDrinkType(self, type): - debug.info("update drink type {{ {} }}".format(type)) - retVal = db.updateDrinkType(type) - debug.debug("updated drink type is {{ {} }}".format(retVal)) - return retVal - - def setDrinkType(self, type): - debug.info("set drink type {{ {} }}".format(type)) - retVal = db.setDrinkType(type) - debug.debug("seted drink type is {{ {} }}".format(retVal)) - return retVal - - def deletDrinkPrice(self, drink): - debug.info("delete drink {{ {} }}".format(drink)) - db.deleteDrink(drink) - - def setDrinkPrice(self, drink): - debug.info("set drink {{ {} }}".format(drink)) - retVal = db.setDrinkPrice(drink) - debug.debug("seted drink is {{ {} }}".format(retVal)) - return retVal - - def updateDrinkPrice(self, drink): - debug.info("update drink {{ {} }}".format(drink)) - retVal = db.updateDrinkPrice(drink) - debug.debug("updated drink is {{ {} }}".format(retVal)) - return retVal - - def getAllDrinkTypes(self): - debug.info("get all drink types") - retVal = db.getAllDrinkTypes() - debug.debug("all drink types are {{ {} }}".format(retVal)) - return retVal - - def getPricelist(self): - debug.info("get all drinks") - list = db.getPriceList() - debug.debug("all drinks are {{ {} }}".format(list)) - return list \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/mainController/mainRegistrationController.py b/flaschengeist/modules/geruecht/mainController/mainRegistrationController.py deleted file mode 100644 index a32893f..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainRegistrationController.py +++ /dev/null @@ -1,14 +0,0 @@ -from datetime import date - -import geruecht.controller.databaseController as dc -from geruecht.logger import getDebugLogger - -db = dc.DatabaseController() -debug = getDebugLogger() - -class Base: - def setNewRegistration(self, data): - debug.info("set new registration {{ {} }}".format(data)) - data['birthDate'] = date(int(data['birthDate']['year']), int(data['birthDate']['month']), int(data['birthDate']['day'])) - data['entryDate'] = date(int(data['entryDate']['year']), int(data['entryDate']['month']), int(data['entryDate']['day'])) if data['entryDate'] else None - db.setNewRegistration(data) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/mainController/mainWorkerController.py b/flaschengeist/modules/geruecht/mainController/mainWorkerController.py deleted file mode 100644 index 91a3068..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainWorkerController.py +++ /dev/null @@ -1,58 +0,0 @@ -from datetime import time, datetime - -import geruecht.controller.databaseController as dc -from geruecht.exceptions import DayLocked -from geruecht.logger import getDebugLogger - -db = dc.DatabaseController() -debug = getDebugLogger() - -class Base: - def getWorker(self, date, username=None): - debug.info("get worker {{ {} }} on {{ {} }}".format(username, date)) - if (username): - user = self.getUser(username) - debug.debug("user is {{ {} }}".format(user)) - retVal = [db.getWorker(user, date)] - debug.debug("worker is {{ {} }}".format(retVal)) - return retVal - retVal = db.getWorkers(date) - debug.debug("workers are {{ {} }}".format(retVal)) - return retVal - - def addWorker(self, username, date, job_kind=None, userExc=False): - debug.info("add job user {{ {} }} on {{ {} }} with job_kind {{ {} }}".format(username, date, job_kind)) - if (userExc): - debug.debug("this is a user execution, check if day is locked") - lockedDay = self.getLockedDay(date) - if lockedDay: - if lockedDay['locked']: - debug.debug("day is lockey. user cant get job") - raise DayLocked("Day is locked. You can't get the Job") - user = self.getUser(username) - debug.debug("user is {{ {} }}".format(user)) - debug.debug("check if user has job on date") - if (not db.getWorker(user, date) and len(db.getWorkersWithJobKind(date, job_kind)) < db.getJobKindDate(date.date(), job_kind)['maxpersons']): - debug.debug("set job to user") - db.setWorker(user, date, job_kind=job_kind) - retVal = self.getWorker(date, username=username) - debug.debug("worker on date is {{ {} }}".format(retVal)) - return retVal - - def changeWorker(self, from_user, to_user, date): - debug.info("change worker from {{ {} }} to {{ {} }} on {{ {} }}".format(from_user, to_user, date)) - db.changeWorker(from_user, to_user, date) - - def deleteWorker(self, username, date, userExc=False): - debug.info( - "delete worker {{ {} }} on date {{ {} }}".format(username, date)) - user = self.getUser(username) - debug.debug("user is {{ {} }}".format(user)) - if userExc: - debug.debug("is user execution, check if day locked") - lockedDay = self.getLockedDay(date) - if lockedDay: - if lockedDay['locked']: - raise DayLocked( - "Day is locked. You can't delete the Job") - db.deleteWorker(user, date) \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/mainController/mainWorkgroupController.py b/flaschengeist/modules/geruecht/mainController/mainWorkgroupController.py deleted file mode 100644 index c32132e..0000000 --- a/flaschengeist/modules/geruecht/mainController/mainWorkgroupController.py +++ /dev/null @@ -1,42 +0,0 @@ -import geruecht.controller.databaseController as dc -from geruecht.logger import getDebugLogger - -db = dc.DatabaseController() -debug = getDebugLogger() - -class Base: - def updateWorkgroupsOfUser(self, user, workgroups): - debug.info("update workgroups {{ {} }} of user {{ {} }}".format(workgroups, user)) - db.deleteWorkgroupsOfUser(user) - for workgroup in workgroups: - db.setUserWorkgroup(user, workgroup) - return db.getWorkgroupsOfUser(user['id']) - - def getAllWorkgroups(self): - debug.info("get all workgroups") - retVal = db.getAllWorkgroups() - debug.debug("workgroups are {{ {} }}".format(retVal)) - return retVal - - def getWorkgroups(self, name): - debug.info("get Workgroup {{ {} }}".format(name)) - retVal = db.getWorkgroup(name) - debug.debug("workgroup is {{ {} }} is {{ {} }}".format(name, retVal)) - return retVal - - def setWorkgroup(self, name, boss): - debug.info("set workgroup {{ {} }} with boss {{ {} }}".format(name, boss)) - retVal = db.setWorkgroup(name, boss) - debug.debug( - "seted workgroup {{ {} }} is {{ {} }}".format(name, retVal)) - return retVal - - def deleteWorkgroup(self, workgroup): - debug.info("delete workgroup {{ {} }}".format(workgroup)) - db.deleteWorkgroup(workgroup) - - def updateWorkgroup(self, workgroup): - debug.info("update workgroup {{ {} }}".format(workgroup)) - retVal = db.updateWorkgroup(workgroup) - debug.debug("updated workgroup is {{ {} }}".format(retVal)) - return retVal \ No newline at end of file diff --git a/flaschengeist/modules/geruecht/vorstand/__init__.py b/flaschengeist/modules/geruecht/vorstand/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/flaschengeist/modules/geruecht/vorstand/routes.py b/flaschengeist/modules/geruecht/vorstand/routes.py deleted file mode 100644 index 143a6c6..0000000 --- a/flaschengeist/modules/geruecht/vorstand/routes.py +++ /dev/null @@ -1,380 +0,0 @@ -from flask import Blueprint, request, jsonify -from datetime import datetime, time, date -import geruecht.controller.mainController as mc -import geruecht.controller.ldapController as lc -from geruecht.decorator import login_required -from geruecht.model import MONEY, GASTRO, VORSTAND -from geruecht.logger import getDebugLogger, getJobsLogger - -debug = getDebugLogger() -jobL = getJobsLogger() - -vorstand = Blueprint("vorstand", __name__) -mainController = mc.MainController() -ldap = lc.LDAPController() - - -@vorstand.route('/um/setStatus', methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _setStatus(**kwargs): - debug.info("/um/setStatus") - try: - data = request.get_json() - name = data['name'] - retVal = mainController.setStatus(name) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@vorstand.route('/um/updateStatus', methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _updateStatus(**kwargs): - debug.info("/um/updateStatus") - try: - data = request.get_json() - retVal = mainController.updateStatus(data) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@vorstand.route('/um/deleteStatus', methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _deleteStatus(**kwargs): - debug.info("/um/deleteStatus") - try: - data = request.get_json() - mainController.deleteStatus(data) - debug.debug("return ok") - return jsonify({"ok": "ok"}) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 409 - - -@vorstand.route('/um/updateStatusUser', methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _updateStatusUser(**kwargs): - debug.info("/um/updateStatusUser") - try: - data = request.get_json() - username = data['username'] - status = data['status'] - retVal = mainController.updateStatusOfUser(username, status).toJSON() - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@vorstand.route('/um/updateVoting', methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _updateVoting(**kwargs): - debug.info("/um/updateVoting") - try: - data = request.get_json() - username = data['username'] - voting = data['voting'] - retVal = mainController.updateVotingOfUser(username, voting).toJSON() - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route('/um/updateWorkgroups', methods=['POST']) -@login_required(groups=[VORSTAND]) -def _updateWorkgroups(**kwargs): - debug.info("/um/updateWorkgroups") - try: - data = request.get_json() - retVal = mainController.updateWorkgroupsOfUser({"id": data['id']}, data['workgroups']) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal), 200 - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/sm/addUser", methods=['POST', 'GET']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _addUser(**kwargs): - debug.info("/sm/addUser") - try: - data = request.get_json() - user = data['user'] - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - job_kind = None - if 'job_kind' in data: - job_kind = data['job_kind'] - retVal = mainController.addWorker(user['username'], date, job_kind=job_kind) - debug.debug("retrun {{ {} }}".format(retVal)) - userl = mainController.getUser(user['username']) - jobL.info("Vorstand {} {} schreibt Mitglied {} {} am {} zum Dienst ein".format( - kwargs['accToken'].user.firstname, kwargs['accToken'].user.lastname, userl.firstname, userl.lastname, date.date())) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@vorstand.route("/sm/getUser", methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _getUser(**kwargs): - debug.info("/sm/getUser") - try: - data = request.get_json() - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - lockedDay = mainController.getLockedDay(date) - lockedDay = { - 'date': { - 'year': year, - 'month': month, - 'day': day - }, - 'locked': lockedDay['locked'] - } - retVal = { - 'worker': mainController.getWorker(date), - 'day': lockedDay - } - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@vorstand.route("/sm/deleteUser", methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _deletUser(**kwargs): - debug.info("/sm/deletUser") - try: - data = request.get_json() - user = data['user'] - day = data['day'] - month = data['month'] - year = data['year'] - date = datetime(year, month, day, 12) - mainController.deleteWorker(user['username'], date) - debug.debug("return ok") - user = mainController.getUser(user['username']) - jobL.info("Vorstand {} {} entfernt Mitglied {} {} am {} vom Dienst".format( - kwargs['accToken'].user.firstname, kwargs['accToken'].user.lastname, user.firstname, user.lastname, date.date())) - return jsonify({"ok": "ok"}) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/wgm/getAllWorkgroups", methods=['GET']) -@login_required(bar=True) -def _getAllWorkgroups(**kwargs): - try: - debug.info("get all workgroups") - retVal = mainController.getAllWorkgroups() - debug.info("return all workgroups {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get all workgroups.", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/wgm/getWorkgroup", methods=['POST']) -@login_required(bar=True) -def _getWorkgroup(**kwargs): - try: - debug.info("get workgroup") - data = request.get_json() - name = data['name'] - debug.info("get workgroup {{ {} }}".format(name)) - retVal = mainController.getWorkgroups(name) - debug.info( - "return workgroup {{ {} }} : {{ {} }}".format(name, retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get workgroup.", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/wgm/workgroup", methods=['POST', 'PUT', 'DELETE']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _workgroup(**kwargs): - debug.info("/wgm/workgroup") - try: - data = request.get_json() - if request.method == 'PUT': - name = data['name'] - boss = None - if 'boss' in data: - boss = data['boss'] - retVal = mainController.setWorkgroup(name, boss) - debug.debug("return {{ {} }}".format(retVal)) - if request.method == 'POST': - retVal = mainController.updateWorkgroup(data) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/wgm/deleteWorkgroup", methods=['POST']) -@login_required(groups=[VORSTAND]) -def _deleteWorkgroup(**kwargs): - try: - data = request.get_json() - debug.info("/wgm/deleteWorkgroup") - mainController.deleteWorkgroup(data) - retVal = {"ok": "ok"} - debug.debug("return ok") - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/sm/getAllJobKinds", methods=['GET']) -@login_required(bar=True) -def _getAllJobKinds(**kwargs): - try: - debug.info("get all jobkinds") - retVal = mainController.getAllJobKinds() - debug.info("return all jobkinds {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get all workgroups.", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/sm/getJobKind", methods=['POST']) -@login_required(bar=True) -def _getJobKinds(**kwargs): - try: - debug.info("get jobkind") - data = request.get_json() - name = data['name'] - debug.info("get jobkind {{ {} }}".format(name)) - retVal = mainController.getJobKind(name) - debug.info( - "return workgroup {{ {} }} : {{ {} }}".format(name, retVal)) - return jsonify(retVal) - except Exception as err: - debug.warning("exception in get workgroup.", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/sm/JobKind", methods=['POST', 'PUT', 'DELETE']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _JobKinds(**kwargs): - debug.info("/sm/JobKind") - try: - data = request.get_json() - if request.method == 'PUT': - name = data['name'] - workgroup = None - if 'workgroup' in data: - workgroup = data['workgroup'] - retVal = mainController.setJobKind(name, workgroup) - debug.debug("return {{ {} }}".format(retVal)) - if request.method == 'POST': - retVal = mainController.updateJobKind(data) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/sm/deleteJobKind", methods=['POST']) -@login_required(groups=[VORSTAND]) -def _deleteJobKind(**kwargs): - try: - data = request.get_json() - debug.info("/sm/deleteJobKind") - mainController.deleteJobKind(data) - retVal = {"ok": "ok"} - debug.debug("return ok") - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/jk/getJobKindDates", methods=['POST']) -@login_required() -def _getJobKindDates(**kwargs): - try: - debug.info("/jk/getJobKindDates") - data = request.get_json() - datum = date(data['year'], data['month'], data['day']) - retVal = mainController.getJobKindDates(datum) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/jk/JobKindDate", methods=['POST']) -@login_required(groups=[VORSTAND]) -def _jobKindDates(**kwargs): - try: - debug.info("/jk/JobKindDate") - data = request.get_json() - retVal = mainController.controllJobKindDates(data) - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - -@vorstand.route("/sm/lockDay", methods=['POST']) -@login_required(groups=[MONEY, GASTRO, VORSTAND]) -def _lockDay(**kwargs): - debug.info("/sm/lockDay") - try: - data = request.get_json() - year = data['year'] - month = data['month'] - day = data['day'] - locked = data['locked'] - date = datetime(year, month, day, 12) - lockedDay = mainController.setLockedDay(date, locked, True) - if not lockedDay: - retVal = { - 'date': { - 'year': year, - 'month': month, - 'day': day - }, - 'locked': False - } - else: - retVal = { - 'date': { - 'year': year, - 'month': month, - 'day': day - }, - 'locked': lockedDay['locked'] - } - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({'error': err}), 409 - - -@vorstand.route("/sm/searchWithExtern", methods=['GET']) -@login_required(groups=[VORSTAND]) -def _search(**kwargs): - debug.info("/sm/searchWithExtern") - try: - retVal = ldap.getAllUser() - debug.debug("return {{ {} }}".format(retVal)) - return jsonify(retVal) - except Exception as err: - debug.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 diff --git a/flaschengeist/modules/__init__.py b/flaschengeist/plugins/__init__.py similarity index 97% rename from flaschengeist/modules/__init__.py rename to flaschengeist/plugins/__init__.py index e957513..873bf0c 100644 --- a/flaschengeist/modules/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -1,6 +1,6 @@ import pkg_resources -from flaschengeist.system.hook import HookCall +from flaschengeist.hook import HookCall send_message_hook = HookCall("send_message") diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py similarity index 96% rename from flaschengeist/modules/auth/__init__.py rename to flaschengeist/plugins/auth/__init__.py index c565741..d289d5c 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -7,9 +7,9 @@ from flask import Blueprint, request, jsonify from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from flaschengeist import logger -from flaschengeist.modules import Plugin -from flaschengeist.system.decorator import login_required -from flaschengeist.system.controller import sessionController, userController +from flaschengeist.plugins import Plugin +from flaschengeist.decorator import login_required +from flaschengeist.controller import sessionController, userController auth_bp = Blueprint("auth", __name__) diff --git a/flaschengeist/modules/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py similarity index 96% rename from flaschengeist/modules/auth_ldap/__init__.py rename to flaschengeist/plugins/auth_ldap/__init__.py index be99afd..21e9d2d 100644 --- a/flaschengeist/modules/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -6,9 +6,9 @@ from flask import current_app as app from flask_ldapconn import LDAPConn from werkzeug.exceptions import BadRequest -from flaschengeist.modules import AuthPlugin -from flaschengeist.system.models.user import User -import flaschengeist.system.controller.userController as userController +from flaschengeist.plugins import AuthPlugin +from flaschengeist.models.user import User +import flaschengeist.controller.userController as userController class AuthLDAP(AuthPlugin): diff --git a/flaschengeist/modules/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py similarity index 92% rename from flaschengeist/modules/auth_plain/__init__.py rename to flaschengeist/plugins/auth_plain/__init__.py index 99acb8e..6820831 100644 --- a/flaschengeist/modules/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -4,8 +4,8 @@ import os from werkzeug.exceptions import BadRequest -from flaschengeist.modules import AuthPlugin -from flaschengeist.system.models.user import User +from flaschengeist.plugins import AuthPlugin +from flaschengeist.models.user import User def _hash_password(password): diff --git a/flaschengeist/modules/message_mail.py b/flaschengeist/plugins/message_mail.py similarity index 90% rename from flaschengeist/modules/message_mail.py rename to flaschengeist/plugins/message_mail.py index cdd06e0..b3f34b7 100644 --- a/flaschengeist/modules/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -3,9 +3,9 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from flaschengeist import logger -from flaschengeist.system.models.user import User -from flaschengeist.system.controller import userController -from flaschengeist.system.controller.messageController import Message +from flaschengeist.models.user import User +from flaschengeist.controller import userController +from flaschengeist.controller.messageController import Message from . import Plugin, send_message_hook diff --git a/flaschengeist/modules/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py similarity index 94% rename from flaschengeist/modules/roles/__init__.py rename to flaschengeist/plugins/roles/__init__.py index ece1db9..d181025 100644 --- a/flaschengeist/modules/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -1,9 +1,9 @@ from flask import Blueprint, request, jsonify from werkzeug.exceptions import NotFound, BadRequest -from flaschengeist.modules import Plugin -from flaschengeist.system.decorator import login_required -from flaschengeist.system.controller import roleController +from flaschengeist.plugins import Plugin +from flaschengeist.decorator import login_required +from flaschengeist.controller import roleController roles_bp = Blueprint("roles", __name__) _permission_edit = "roles_edit" diff --git a/flaschengeist/modules/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py similarity index 96% rename from flaschengeist/modules/schedule/__init__.py rename to flaschengeist/plugins/schedule/__init__.py index 7ab76a3..218bd53 100644 --- a/flaschengeist/modules/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -3,11 +3,11 @@ from datetime import datetime, timedelta from flask import Blueprint, request, jsonify from werkzeug.exceptions import BadRequest, NotFound -from flaschengeist.modules import Plugin -from flaschengeist.system.database import db -from flaschengeist.system.models.event import EventKind -from flaschengeist.system.decorator import login_required -from flaschengeist.system.controller import eventController +from flaschengeist.plugins import Plugin +from flaschengeist.database import db +from flaschengeist.models.event import EventKind +from flaschengeist.decorator import login_required +from flaschengeist.controller import eventController schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") _permission_edit_type = "schedule_edit_type" diff --git a/flaschengeist/modules/users/__init__.py b/flaschengeist/plugins/users/__init__.py similarity index 94% rename from flaschengeist/modules/users/__init__.py rename to flaschengeist/plugins/users/__init__.py index 0309967..756ff01 100644 --- a/flaschengeist/modules/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -2,9 +2,9 @@ from flask import Blueprint, request, jsonify from werkzeug.exceptions import NotFound, BadRequest, Forbidden from flaschengeist import logger -from flaschengeist.modules import Plugin -from flaschengeist.system.decorator import login_required -from flaschengeist.system.controller import userController +from flaschengeist.plugins import Plugin +from flaschengeist.decorator import login_required +from flaschengeist.controller import userController users_bp = Blueprint("users", __name__) _permission_edit = "users_edit_other" diff --git a/flaschengeist/system/__init__.py b/flaschengeist/system/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/flaschengeist/system/controller/__init__.py b/flaschengeist/system/controller/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/flaschengeist/readme.md b/readme.md similarity index 100% rename from flaschengeist/readme.md rename to readme.md diff --git a/run_flaschengeist b/run_flaschengeist index c42bc85..1ee770f 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -5,7 +5,6 @@ import argparse def install(arguments): from flaschengeist.app import create_app, install_all - app = create_app() with app.app_context(): install_all() @@ -27,7 +26,7 @@ def run(arguments): def export(arguments): - import flaschengeist.system.models as models + import flaschengeist.models as models known = [] classes = {} diff --git a/setup.py b/setup.py index 55f8789..3111e62 100644 --- a/setup.py +++ b/setup.py @@ -22,13 +22,13 @@ setup( extras_require={"ldap": ["flask_ldapconn", "ldap3"], "bjoern": ["bjoern"]}, entry_points={ "flaschengeist.plugin": [ - "auth = flaschengeist.modules.auth:AuthRoutePlugin", - "users = flaschengeist.modules.users:UsersPlugin", - "roles = flaschengeist.modules.roles:RolesPlugin", - "schedule = flaschengeist.modules.schedule:SchedulePlugin", - "mail = flaschengeist.modules.message_mail:MailMessagePlugin", - "auth_plain = flaschengeist.modules.auth_plain:AuthPlain", - "auth_ldap = flaschengeist.modules.auth_ldap:AuthLDAP [ldap]", + "auth = flaschengeist.plugins.auth:AuthRoutePlugin", + "users = flaschengeist.plugins.users:UsersPlugin", + "roles = flaschengeist.plugins.roles:RolesPlugin", + "schedule = flaschengeist.plugins.schedule:SchedulePlugin", + "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", + "auth_plain = flaschengeist.plugins.auth_plain:AuthPlain", + "auth_ldap = flaschengeist.plugins.auth_ldap:AuthLDAP [ldap]", ], }, ) From 8a9776ae0e5cb08c5a0e10cbe710205aa9516055 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 04:05:59 +0100 Subject: [PATCH 081/446] [Doc] More documentation on decorator and plugins roles and auth* --- flaschengeist/controller/roleController.py | 8 +- flaschengeist/decorator.py | 9 ++ flaschengeist/plugins/auth/__init__.py | 10 +- flaschengeist/plugins/auth_ldap/__init__.py | 2 + flaschengeist/plugins/auth_plain/__init__.py | 44 ++++--- flaschengeist/plugins/roles/__init__.py | 126 +++++++++++++------ 6 files changed, 134 insertions(+), 65 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 43c82e8..7100570 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -10,8 +10,8 @@ def get_all(): return Role.query.all() -def get(rid): - role = Role.query.get(rid).one_or_none() +def get(role_name): + role = Role.query.filter(Role.name == role_name).one_or_none() if not role: raise NotFound @@ -42,11 +42,11 @@ def create_permissions(permissions): db.session.commit() -def create_role(name, permissions=[]): +def create_role(name: str, permissions=[]): role = Role(name=name) db.session.add(role) set_permissions(role, permissions) - return role.id + return role def delete(role): diff --git a/flaschengeist/decorator.py b/flaschengeist/decorator.py index 02ff521..e9f61f8 100644 --- a/flaschengeist/decorator.py +++ b/flaschengeist/decorator.py @@ -7,6 +7,15 @@ from flaschengeist.controller import sessionController def login_required(permission=None): + """Decorator use to make a route only accessible by logged in users. + Sets ``current_session`` into kwargs of wrapped function with session identified by Authorization header. + + Attributes: + permission: Optional permission needed for this route + + Returns: + Wrapped function with login (and permission) guard + """ def wrap(func): @wraps(func) def wrapped_f(*args, **kwargs): diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index d289d5c..593a160 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -28,8 +28,8 @@ def login(): POST-data: {'userid': string, 'password': string} Returns: - A JSON object with `flaschengeist.system.models.user.User` and created - `flaschengeist.system.models.session.Session` or HTTP error + A JSON object with `flaschengeist.models.user.User` and created + `flaschengeist.models.session.Session` or HTTP error """ logger.debug("Start log in.") data = request.get_json() @@ -60,7 +60,7 @@ def get_sessions(current_session, **kwargs): Route: ``/auth`` | Method: ``GET`` Returns: - A JSON array of `flaschengeist.system.models.session.Session` or HTTP error + A JSON array of `flaschengeist.models.session.Session` or HTTP error """ sessions = sessionController.get_users_sessions(current_session._user) return jsonify(sessions) @@ -100,7 +100,7 @@ def get_session(token, current_session, **kwargs): current_session: Session sent with Authorization Header Returns: - JSON encoded `flaschengeist.system.models.session.Session` or HTTP error + JSON encoded `flaschengeist.models.session.Session` or HTTP error """ logger.debug("get token {{ {} }}".format(token)) session = sessionController.get_session(token, current_session._user) @@ -153,7 +153,7 @@ def get_assocd_user(token, current_session, **kwargs): current_session: Session sent with Authorization Header Returns: - JSON encoded `flaschengeist.system.models.user.User` or HTTP error + JSON encoded `flaschengeist.models.user.User` or HTTP error """ logger.debug("get token {{ {} }}".format(token)) session = sessionController.get_session(token, current_session._user) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 21e9d2d..54d97ea 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -1,3 +1,5 @@ +"""LDAP Authentication Provider Plugin""" + import ssl from ldap3.utils.hashed import hashed from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_MD5 diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 6820831..cdccb5b 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -1,36 +1,40 @@ -import binascii -import hashlib -import os +"""Authentication Provider Plugin +Allows simple authentication using Username-Password pair with password saved into +Flaschengeist database (as User attribute) +""" +import os +import hashlib +import binascii from werkzeug.exceptions import BadRequest from flaschengeist.plugins import AuthPlugin from flaschengeist.models.user import User -def _hash_password(password): - salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii") - pass_hash = hashlib.pbkdf2_hmac("sha3-512", password.encode("utf-8"), salt, 100000) - pass_hash = binascii.hexlify(pass_hash) - return (salt + pass_hash).decode("ascii") - - -def _verify_password(stored_password, provided_password): - salt = stored_password[:64] - stored_password = stored_password[64:] - pass_hash = hashlib.pbkdf2_hmac("sha3-512", provided_password.encode("utf-8"), salt.encode("ascii"), 100000) - pass_hash = binascii.hexlify(pass_hash).decode("ascii") - return pass_hash == stored_password - - class AuthPlain(AuthPlugin): def login(self, user: User, password: str): if user.has_attribute("password"): - return _verify_password(user.get_attribute("password"), password) + return AuthPlain._verify_password(user.get_attribute("password"), password) return False def modify_user(self, user, password, new_password=None): if password is not None and not self.login(user, password): raise BadRequest if new_password: - user.set_attribute("password", _hash_password(new_password)) + user.set_attribute("password", AuthPlain._hash_password(new_password)) + + @staticmethod + def _hash_password(password): + salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii") + pass_hash = hashlib.pbkdf2_hmac("sha3-512", password.encode("utf-8"), salt, 100000) + pass_hash = binascii.hexlify(pass_hash) + return (salt + pass_hash).decode("ascii") + + @staticmethod + def _verify_password(stored_password, provided_password): + salt = stored_password[:64] + stored_password = stored_password[64:] + pass_hash = hashlib.pbkdf2_hmac("sha3-512", provided_password.encode("utf-8"), salt.encode("ascii"), 100000) + pass_hash = binascii.hexlify(pass_hash).decode("ascii") + return pass_hash == stored_password diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index d181025..26588ab 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -1,3 +1,8 @@ +"""Roles plugin + +Provides routes used to configure roles and permissions of users / roles. +""" + from flask import Blueprint, request, jsonify from werkzeug.exceptions import NotFound, BadRequest @@ -15,57 +20,98 @@ class RolesPlugin(Plugin): super().__init__(config, roles_bp, permissions=[_permission_edit, _permission_delete]) -###################################################### -# Routes # -# # -# /roles POST: register new # -# GET: get all roles # -# /roles/permissions GET: get all permissions # -# /roles/ GET: get role with rid # -# PUT: modify role / permission # -# DELETE: remove role # -###################################################### +@roles_bp.route("/roles", methods=["GET"]) +@login_required() +def list_roles(current_session): + """List all existing roles + + Route: ``/roles`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encodes array of `flaschengeist.models.user.Role` + """ + roles = roleController.get_all() + return jsonify(roles) @roles_bp.route("/roles", methods=["POST"]) @login_required(permission=_permission_edit) -def add_role(**kwargs): +def create_role(current_session): + """Create new role + + Route: ``/roles`` | Method: ``POST`` + + POST-data: ``{name: string, permissions?: string[]}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + HTTP-200 or HTTP error + """ data = request.get_json() if not data or "name" not in data: raise BadRequest if "permissions" in data: permissions = data["permissions"] - role = roleController.create_role(data["name"], permissions) - return jsonify({"ok": "ok", "id": role.id}) - - -@roles_bp.route("/roles", methods=["GET"]) -@login_required() -def list_roles(**kwargs): - roles = roleController.get_all() - return jsonify(roles) + roleController.create_role(data["name"], permissions) @roles_bp.route("/roles/permissions", methods=["GET"]) @login_required() -def list_permissions(**kwargs): +def list_permissions(current_session): + """List all existing permissions + + Route: ``/roles/permissions`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded list of `flaschengeist.models.user.Permission` + """ permissions = roleController.get_permissions() return jsonify(permissions) -@roles_bp.route("/roles/", methods=["GET"]) +@roles_bp.route("/roles/", methods=["GET"]) @login_required() -def __get_role(rid, **kwargs): - role = roleController.get(rid) - if role: - return jsonify({"id": role.id, "name": role, "permissions": role.permissions}) - raise NotFound +def get_role(role_name, current_session): + """Get role by name + + Route: ``/roles/`` | Method: ``GET`` + + Args: + role_name: Name of role to retrieve + current_session: Session sent with Authorization Header + + Returns: + JSON encoded `flaschengeist.models.user.Role` or HTTP error + """ + role = roleController.get(role_name) + return jsonify(role) -@roles_bp.route("/roles/", methods=["PUT"]) +@roles_bp.route("/roles/", methods=["PUT"]) @login_required(permission=_permission_edit) -def __edit_role(rid, **kwargs): - role = roleController.get(rid) +def edit_role(role_name, current_session): + """Edit role, rename and / or set permissions + + Route: ``/roles/`` | Method: ``PUT`` + + POST-data: ``{name?: string, permissions?: string[]}`` + + Args: + role_name: Name of role + current_session: Session sent with Authorization Header + + Returns: + HTTP-200 or HTTP error + """ + role = roleController.get(role_name) data = request.get_json() if "name" in data: @@ -73,13 +119,21 @@ def __edit_role(rid, **kwargs): if "permissions" in data: roleController.set_permissions(role, data["permissions"]) roleController.update_role(role) - return jsonify({"ok": "ok"}) -@roles_bp.route("/roles/", methods=["DELETE"]) +@roles_bp.route("/roles/", methods=["DELETE"]) @login_required(permission=_permission_delete) -def __delete_role(rid, **kwargs): - role = roleController.get(rid) - roleController.delete(role) +def delete_role(role_name, current_session): + """Delete role - return jsonify({"ok": "ok"}) + Route: ``/roles/`` | Method: ``DELETE`` + + Args: + role_name: Name of role + current_session: Session sent with Authorization Header + + Returns: + HTTP-200 or HTTP error + """ + role = roleController.get(role_name) + roleController.delete(role) From e0d3b211bb580e5c26b73151cacfc4e093061937 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 05:53:15 +0100 Subject: [PATCH 082/446] [Doc] User plugin documentation created --- flaschengeist/config.py | 2 +- flaschengeist/decorator.py | 1 + flaschengeist/models/__init__.py | 6 +- flaschengeist/plugins/roles/__init__.py | 2 +- flaschengeist/plugins/users/__init__.py | 95 +++++++++++++++++-------- 5 files changed, 73 insertions(+), 33 deletions(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 5d1fc6a..cda1e4f 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -43,7 +43,7 @@ def configure_app(app): user=config["DATABASE"]["user"], passwd=config["DATABASE"]["password"], host=config["DATABASE"]["host"], - database=config["DATABASE"]["database"] + database=config["DATABASE"]["database"], ) app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False diff --git a/flaschengeist/decorator.py b/flaschengeist/decorator.py index e9f61f8..848b0ee 100644 --- a/flaschengeist/decorator.py +++ b/flaschengeist/decorator.py @@ -16,6 +16,7 @@ def login_required(permission=None): Returns: Wrapped function with login (and permission) guard """ + def wrap(func): @wraps(func) def wrapped_f(*args, **kwargs): diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 016672a..415ffee 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -4,8 +4,10 @@ from sqlalchemy.types import DateTime, TypeDecorator class ModelSerializeMixin: def serialize(self): - """Return: - Dict of all not private or protected annotated member variables.""" + """Serialize class to dict + Returns: + Dict of all not private or protected annotated member variables. + """ d = {param: getattr(self, param) for param in self.__class__.__annotations__ if not param.startswith("_")} if len(d) == 1: key, value = d.popitem() diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index 26588ab..edb6a37 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -31,7 +31,7 @@ def list_roles(current_session): current_session: Session sent with Authorization Header Returns: - JSON encodes array of `flaschengeist.models.user.Role` + JSON encoded array of `flaschengeist.models.user.Role` """ roles = roleController.get_all() return jsonify(roles) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 756ff01..648ba1d 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -1,3 +1,8 @@ +"""Users plugin + +Provides routes used to manage users +""" + from flask import Blueprint, request, jsonify from werkzeug.exceptions import NotFound, BadRequest, Forbidden @@ -16,16 +21,6 @@ class UsersPlugin(Plugin): def __init__(self, config): super().__init__(blueprint=users_bp, permissions=[_permission_edit, _permission_delete, _permission_set_roles]) - ################################################# - # Routes # - # # - # /users POST: register new # - # GET: get all users # - # /users/ GET: get user with uid # - # PUT: modify user # - # DELETE: remove user # - ################################################# - @users_bp.route("/users", methods=["POST"]) def __registration(self): @@ -35,42 +30,85 @@ def __registration(self): @users_bp.route("/users", methods=["GET"]) @login_required() -def __list_users(**kwargs): +def list_users(current_session): + """List all existing users + + Route: ``/users`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded array of `flaschengeist.models.user.User` or HTTP error + """ logger.debug("Retrieve list of all users") users = userController.get_users() return jsonify(users) -@users_bp.route("/users/", methods=["GET"]) +@users_bp.route("/users/", methods=["GET"]) @login_required() -def __get_user(uid, **kwargs): - logger.debug("Get information of user {{ {} }}".format(uid)) - user = userController.get_user(uid) - if user: - return jsonify(user) - raise NotFound +def get_user(userid, current_session): + """Retrieve user by userid + + Route: ``/users/`` | Method: ``GET`` + + Args: + userid: UserID of user to retrieve + current_session: Session sent with Authorization Header + + Returns: + JSON encoded `flaschengeist.models.user.User` or HTTP error + """ + logger.debug("Get information of user {{ {} }}".format(userid)) + user = userController.get_user(userid) + return jsonify(user) -@users_bp.route("/users/", methods=["DELETE"]) +@users_bp.route("/users/", methods=["DELETE"]) @login_required(permission=_permission_delete) -def __delete_user(uid, **kwargs): - logger.debug("Delete user {{ {} }}".format(uid)) - user = userController.get_user(uid) +def delete_user(userid, current_session): + """Delete user by userid + + Route: ``/users/`` | Method: ``DELETE`` + + Args: + userid: UserID of user to retrieve + current_session: Session sent with Authorization Header + + Returns: + HTTP-200 or HTTP error + """ + logger.debug("Delete user {{ {} }}".format(userid)) + user = userController.get_user(userid) userController.delete(user) - return jsonify({"ok": "ok"}) -@users_bp.route("/users/", methods=["PUT"]) +@users_bp.route("/users/", methods=["PUT"]) @login_required() -def __edit_user(uid, current_session, **kwargs): - logger.debug("Modify information of user {{ {} }}".format(uid)) - user = userController.get_user(uid) +def edit_user(userid, current_session): + """Modify user by userid + + Route: ``/users/`` | Method: ``PUT`` + + POST-data: ```{firstname?: string, lastname?: string, display_name?: string, mail?: string, + password?: string, roles?: string[]}``` + + Args: + userid: UserID of user to retrieve + current_session: Session sent with Authorization Header + + Returns: + HTTP-200 or HTTP error + """ + logger.debug("Modify information of user {{ {} }}".format(userid)) + user = userController.get_user(userid) data = request.get_json() password = None new_password = data["new_password"] if "new_password" in data else None - if uid != current_session._user.userid: + if userid != current_session._user.userid: if not user.has_permission(_permission_edit): return Forbidden else: @@ -89,4 +127,3 @@ def __edit_user(uid, current_session, **kwargs): userController.modify_user(user, password, new_password) userController.update_user(user) - return jsonify({"ok": "ok"}) From 32783041d86e4c55558e516b3fabcedebd457016 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 22:15:37 +0100 Subject: [PATCH 083/446] [Plugin] Added balance plugin Extends the users plugin. * Users can have an account with a balance * Users can send from them to others * Admins can set the balance * Admins can set limits --- flaschengeist.example.toml | 8 +- flaschengeist/plugins/balance/__init__.py | 166 ++++++++++++++++++ .../plugins/balance/balance_controller.py | 82 +++++++++ flaschengeist/plugins/balance/models.py | 22 +++ flaschengeist/plugins/balance/permissions.py | 17 ++ 5 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 flaschengeist/plugins/balance/__init__.py create mode 100644 flaschengeist/plugins/balance/balance_controller.py create mode 100644 flaschengeist/plugins/balance/models.py create mode 100644 flaschengeist/plugins/balance/permissions.py diff --git a/flaschengeist.example.toml b/flaschengeist.example.toml index 093a9a8..a804467 100644 --- a/flaschengeist.example.toml +++ b/flaschengeist.example.toml @@ -4,7 +4,7 @@ auth = "auth_plain" # Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn #proxy = false # Set root path, prefixes all routes -#root = / +#root = /api # Set secret key secret_key = "V3ryS3cr3t" @@ -45,8 +45,8 @@ enabled = true # SSL or STARTLS # CRYPT = SSL +[balance] +enabled = true + [geruecht] enabled = false - -[schubu] -enabled = false diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py new file mode 100644 index 0000000..b3ddfcb --- /dev/null +++ b/flaschengeist/plugins/balance/__init__.py @@ -0,0 +1,166 @@ +"""Balance plugin + +Extends users plugin with balance functions +""" + +from datetime import datetime, timezone +from flask import Blueprint, jsonify, request +from werkzeug.exceptions import Forbidden, BadRequest +from backports.datetime_fromisoformat import MonkeyPatch + +from flaschengeist.plugins import Plugin +from flaschengeist.models.session import Session +from flaschengeist.decorator import login_required +from flaschengeist.controller import userController + +from . import balance_controller, permissions + +MonkeyPatch.patch_fromisoformat() +balance_bp = Blueprint("balance", __name__) + + +class BalancePlugin(Plugin): + def __init__(self, config): + super().__init__(blueprint=balance_bp, permissions=permissions.permissions) + + def install(self): + from flaschengeist.database import db + from .models import Transaction + + db.create_all() + + +@balance_bp.route("/users//balance/limit", methods=["GET"]) +@login_required() +def get_limit(userid, current_session: Session): + """Get set limit of an user + + Route: ``/users//balance/limit`` | Method: ``GET`` + + Args: + userid: Userid identifying the user + current_session: Session sent with Authorization Header + + Returns: + JSON object containing the limit (or Null if no limit set) or HTTP error + """ + user = userController.get_user(userid) + if (user != current_session._user and not current_session._user.has_permission(permissions.SET_LIMIT)) or ( + user == current_session._user and not user.has_permission(permissions.SHOW) + ): + raise Forbidden + + return jsonify({"limit": balance_controller.get_limit(user)}) + + +@balance_bp.route("/users//balance/limit", methods=["PUT"]) +@login_required() +def set_limit(userid, current_session: Session): + """Set the limit of an user + + Route: ``/users//balance/limit`` | Method: ``PUT`` + + POST-data: ``{limit: float}`` + + Args: + userid: Userid identifying the user + current_session: Session sent with Authorization Header + + Returns: + HTTP-200 or HTTP error + """ + user = userController.get_user(userid) + if not current_session._user.has_permission(permissions.SET_LIMIT): + raise Forbidden + + data = request.get_json() + try: + limit = data["limit"] + except (TypeError, KeyError): + raise BadRequest + balance_controller.set_limit(user, limit) + + +@balance_bp.route("/users//balance", methods=["GET"]) +@login_required() +def get_balance(userid, current_session: Session): + """Get balance of user, optionally filtered + + Route: ``/users//balance`` | Method: ``GET`` + + GET-parameters: ```{from?: string, to?: string}``` + + Args: + userid: Userid of user to get balance from + current_session: Session sent with Authorization Header + + Returns: + JSON object containing credit, debit and balance or HTTP error + """ + if (userid == current_session._user.userid and not current_session._user.has_permission(permissions.SHOW)) or ( + userid != current_session._user.userid and not current_session._user.has_permission(permissions.SHOW_OTHER) + ): + raise Forbidden + + # Might raise NotFound + user = userController.get_user(userid) + + start = request.args.get("from") + if start: + start = datetime.fromisoformat(start).replace(tzinfo=timezone.utc) + else: + start = datetime.fromtimestamp(0, tz=timezone.utc) + + end = request.args.get("to") + if end: + end = datetime.fromisoformat(end).replace(tzinfo=timezone.utc) + else: + end = datetime.now(tz=timezone.utc) + + balance = balance_controller.get(user, start, end) + return jsonify({"credit": balance[0], "debit": balance[1], "balance": balance[2]}) + + +@balance_bp.route("/users//balance", methods=["PUT"]) +@login_required() +def change_balance(userid, current_session: Session): + """Change balance of an user + If ``sender`` is preset in POST-data, the action is handled as a transfer from ``sender`` to user. + + Route: ``/users//balance`` | Method: ``PUT`` + + POST-data: ``{amount: float, sender: string}`` + + Args: + userid: userid identifying user to change balance + current_session: Session sent with Authorization Header + + Returns: + JSON object containing credit, debit and balance or HTTP error + """ + + data = request.get_json() + try: + amount = data["amount"] + except (TypeError, KeyError): + raise BadRequest + + sender = data.get("sender", None) + user = userController.get_user(userid) + + if sender: + sender = userController.get_user(sender) + if sender == user: + raise BadRequest + + if (sender == current_session._user and sender.has_permission(permissions.SEND)) or ( + sender != current_session._user and current_session._user.has_permission(permissions.SEND_OTHER) + ): + return balance_controller.send(sender, user, data["amount"], current_session._user) + + elif (amount < 0 and current_session._user.has_permission(permissions.SUB)) or ( + amount > 0 and current_session._user.has_permission(permissions.ADD) + ): + return balance_controller.change_balance(user, data["amount"], current_session._user) + + raise Forbidden diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py new file mode 100644 index 0000000..dd9112c --- /dev/null +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -0,0 +1,82 @@ +from flaschengeist.models.user import User +from sqlalchemy import func +from datetime import datetime + +from werkzeug.exceptions import BadRequest + +from flaschengeist import logger +from flaschengeist.database import db + +from .models import Transaction +from . import permissions + + +__attribute_limit = "balance_limit" + + +def set_limit(user: User, limit: float): + user.set_attribute(__attribute_limit, limit) + db.session.commit() + + +def get_limit(user: User) -> float: + return user.get_attribute(__attribute_limit, default=None) + + +def get(user, start: datetime, end: datetime): + credit = ( + db.session.query(func.sum(Transaction.amount)) + .filter(Transaction.receiver == user) + .filter(start <= Transaction.time) + .filter(Transaction.time <= end) + .scalar() + ) or 0 + logger.debug(credit) + if credit is None: + credit = 0 + debit = ( + db.session.query(func.sum(Transaction.amount)) + .filter(Transaction.sender == user and start <= Transaction.time <= end) + .all()[0][0] + ) + if debit is None: + debit = 0 + return credit, debit, credit - debit + + +def send(sender: User, receiver, amount: float, author: User): + """Send credit from one user to an other + + Args: + sender: User who sends the amount + receiver: User who receives the amount + amount: Amount to send + author: User authoring this transaction + Raises: + BadRequest if amount <= 0 + """ + if amount <= 0: + raise BadRequest + + if sender.has_attribute(__attribute_limit): + if (get(sender)[2] - amount) < sender.get_attribute(__attribute_limit) and not author.has_permission( + permissions.EXCEED_LIMIT + ): + raise BadRequest("Limit exceeded") + + transaction = Transaction(sender=sender, receiver=receiver, amount=amount, author=author) + db.session.add(transaction) + db.session.commit() + + +def change_balance(user, amount: float, author): + """Change balance of user + + Args: + user: User to change balance + amount: Amount to change balance + author: User authoring this transaction + """ + sender = user if amount < 0 else None + receiver = user if amount > 0 else None + send(sender, receiver, abs(amount), author) diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py new file mode 100644 index 0000000..4a967d5 --- /dev/null +++ b/flaschengeist/plugins/balance/models.py @@ -0,0 +1,22 @@ +from datetime import datetime, timezone + +from flaschengeist.models.user import User + +from flaschengeist.database import db +from flaschengeist.models import ModelSerializeMixin, UtcDateTime + + +class Transaction(db.Model, ModelSerializeMixin): + __tablename__ = "balance_transaction" + time: datetime = db.Column(UtcDateTime, nullable=False, default=datetime.now(tz=timezone.utc)) + amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) + + _receiver_id = db.Column("receiver_id", db.Integer, db.ForeignKey("user.id")) + _sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id")) + _author_id = db.Column("author_id", db.Integer, db.ForeignKey("user.id"), nullable=False) + + sender: User = db.relationship("User", foreign_keys=[_sender_id]) + receiver: User = db.relationship("User", foreign_keys=[_receiver_id]) + author: User = db.relationship("User", foreign_keys=[_author_id]) + + _id = db.Column("id", db.Integer, primary_key=True) diff --git a/flaschengeist/plugins/balance/permissions.py b/flaschengeist/plugins/balance/permissions.py new file mode 100644 index 0000000..43a13ee --- /dev/null +++ b/flaschengeist/plugins/balance/permissions.py @@ -0,0 +1,17 @@ +# Show own and others balance +SHOW = "balance_show" +SHOW_OTHER = "balance_show_others" +# Credit balance +ADD = "balance_add" +# Debit balance +SUB = "balance_sub" +# Send from to other +SEND = "balance_send" +# Send from other to another +SEND_OTHER = "balance_send_others" +# Can set limit for users +SET_LIMIT = "balance_set_limit" +# Allow sending / sub while exceeding the set limit +EXCEED_LIMIT = "balance_exceed_limit" + +permissions = [value for key, value in globals().items() if not key.startswith("_")] From ff6c973eeff35f57928f83df1db77b549e2f9bc4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 22:17:43 +0100 Subject: [PATCH 084/446] [System] Fixed issue when Authorization header is missing --- flaschengeist/decorator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flaschengeist/decorator.py b/flaschengeist/decorator.py index 848b0ee..9e2bbfd 100644 --- a/flaschengeist/decorator.py +++ b/flaschengeist/decorator.py @@ -20,7 +20,11 @@ def login_required(permission=None): def wrap(func): @wraps(func) def wrapped_f(*args, **kwargs): - token = list(filter(None, request.headers.get("Authorization").split(" ")))[-1] + try: + token = list(filter(None, request.headers.get("Authorization").split(" ")))[-1] + except AttributeError: + raise Unauthorized + session = sessionController.validate_token(token, request.user_agent, permission) if session: kwargs["current_session"] = session From 9bbcaa5bc91bfaad3d656503ee43444a493bcbef Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 22:18:46 +0100 Subject: [PATCH 085/446] [DB] Breaking change: User Attribute is now a pickle type --- flaschengeist/models/user.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 11df9e5..dc68ddb 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -70,8 +70,10 @@ class User(db.Model, ModelSerializeMixin): def has_attribute(self, name): return name in self._attributes - def get_attribute(self, name): - return self._attributes[name].value + def get_attribute(self, name, default=None): + if name in self._attributes: + return self._attributes[name].value + return default def get_permissions(self): return ["user"] + [permission.name for role in self.roles for permission in role.permissions] @@ -85,4 +87,4 @@ class _UserAttribute(db.Model, ModelSerializeMixin): id = db.Column("id", db.Integer, primary_key=True) user: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), nullable=False) name: str = db.Column(db.String(30)) - value: str = db.Column(db.String(192)) + value: any = db.Column(db.PickleType(protocol=4)) From f60c06bc1717a7330e39c5e704ed439eeb8dbaea Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 22:19:16 +0100 Subject: [PATCH 086/446] Some Cleanup of setup.py and documentation in auth --- flaschengeist/plugins/auth/__init__.py | 2 +- setup.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 593a160..b65dc87 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -25,7 +25,7 @@ def login(): Route: ``/auth`` | Method: ``POST`` - POST-data: {'userid': string, 'password': string} + POST-data: ``{userid: string, password: string}`` Returns: A JSON object with `flaschengeist.models.user.User` and created diff --git a/setup.py b/setup.py index 3111e62..c6c3874 100644 --- a/setup.py +++ b/setup.py @@ -10,6 +10,7 @@ setup( packages=find_packages(), package_data={"": ["*.toml"]}, scripts=["run_flaschengeist"], + python_requires=">=3.6", install_requires=[ "Flask >= 1.1", "toml", @@ -18,17 +19,22 @@ setup( "flask_cors", "werkzeug", "python-dateutil", + # Needed for python < 3.7 + "backports-datetime-fromisoformat", ], extras_require={"ldap": ["flask_ldapconn", "ldap3"], "bjoern": ["bjoern"]}, entry_points={ "flaschengeist.plugin": [ + # Authentication providers + "auth_plain = flaschengeist.plugins.auth_plain:AuthPlain", + "auth_ldap = flaschengeist.plugins.auth_ldap:AuthLDAP [ldap]", + # Route providers (and misc) "auth = flaschengeist.plugins.auth:AuthRoutePlugin", "users = flaschengeist.plugins.users:UsersPlugin", "roles = flaschengeist.plugins.roles:RolesPlugin", + "balance = flaschengeist.plugins.balance:BalancePlugin", "schedule = flaschengeist.plugins.schedule:SchedulePlugin", "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", - "auth_plain = flaschengeist.plugins.auth_plain:AuthPlain", - "auth_ldap = flaschengeist.plugins.auth_ldap:AuthLDAP [ldap]", ], }, ) From 94559201419d20d65e8fc385cc518e5b95b87c4d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2020 23:59:07 +0100 Subject: [PATCH 087/446] [System] config now deep update when multiple config files are used --- flaschengeist/config.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index cda1e4f..100d6e3 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -1,24 +1,37 @@ -import logging.config import os import toml +import logging.config +import collections.abc + from pathlib import Path from werkzeug.middleware.proxy_fix import ProxyFix from flaschengeist import _module_path, logger + +def __update(d, u): + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = __update(d.get(k, {}), v) + else: + d[k] = v + return d + + # Default config: config = {} -paths = [_module_path, Path.home() / ".config"] + +__paths = [_module_path, Path.home() / ".config"] if "FLASCHENGEIST_CONF" in os.environ: - paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) -for loc in paths: + __paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) +for loc in __paths: try: with (loc / "flaschengeist.toml").open() as source: logger.info("Reading config file from >{}<".format(loc)) - config.update(toml.load(source)) + __update(config, toml.load(source)) except IOError: pass # Always enable this builtin plugins! -config.update({"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) +__update(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) def configure_app(app): From 5da5fcde8f7f050a025a5fca111470784a70a0f9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 31 Oct 2020 00:00:23 +0100 Subject: [PATCH 088/446] [System] Some improvements on models and decorator * User: userid is now not nullable * Session: __eq__ fixed * decorator: split decorator and session extration --- flaschengeist/decorator.py | 34 ++++++++++++++++++--------------- flaschengeist/models/session.py | 7 +++++-- flaschengeist/models/user.py | 2 +- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/flaschengeist/decorator.py b/flaschengeist/decorator.py index 9e2bbfd..822000e 100644 --- a/flaschengeist/decorator.py +++ b/flaschengeist/decorator.py @@ -1,11 +1,25 @@ from functools import wraps -from flask import request from werkzeug.exceptions import Unauthorized from flaschengeist import logger from flaschengeist.controller import sessionController +def extract_session(permission=None): + from flask import request + try: + token = list(filter(None, request.headers.get("Authorization").split(" ")))[-1] + except AttributeError: + logger.debug("Missing Authorization header or ill-formed") + raise Unauthorized + + session = sessionController.validate_token(token, request.user_agent, permission) + if not session: + logger.debug("token {{ {} }} is invalid".format(token)) + raise Unauthorized + return session + + def login_required(permission=None): """Decorator use to make a route only accessible by logged in users. Sets ``current_session`` into kwargs of wrapped function with session identified by Authorization header. @@ -16,23 +30,13 @@ def login_required(permission=None): Returns: Wrapped function with login (and permission) guard """ - def wrap(func): @wraps(func) def wrapped_f(*args, **kwargs): - try: - token = list(filter(None, request.headers.get("Authorization").split(" ")))[-1] - except AttributeError: - raise Unauthorized - - session = sessionController.validate_token(token, request.user_agent, permission) - if session: - kwargs["current_session"] = session - logger.debug("token {{ {} }} is valid".format(token)) - return func(*args, **kwargs) - else: - logger.info("token {{ {} }} is not valid".format(token)) - raise Unauthorized + session = extract_session(permission) + kwargs["current_session"] = session + logger.debug("token {{ {} }} is valid".format(session.token)) + return func(*args, **kwargs) return wrapped_f diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index 0d49c8b..05da44c 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -32,8 +32,11 @@ class Session(db.Model, ModelSerializeMixin): Update the Timestamp to the current Time. """ - logger.debug("update timestamp from session with token {{ {} }}".format(self)) + logger.debug("update timestamp from session with token {{ {} }}".format(self.token)) self.expires = datetime.now(timezone.utc) + timedelta(seconds=self.lifetime) def __eq__(self, token): - return compare_digest(self.token, token) + if isinstance(token, str): + return compare_digest(self.token, token) + else: + return super(Session, self).__eq__(token) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index dc68ddb..4166cc7 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -48,7 +48,7 @@ class User(db.Model, ModelSerializeMixin): """ __tablename__ = "user" - userid: str = db.Column(db.String(30)) + userid: str = db.Column(db.String(30), nullable=False) display_name: str = db.Column(db.String(30)) firstname: str = db.Column(db.String(30)) lastname: str = db.Column(db.String(30)) From de5a2e1c65c489c01553c6a8973a9bda9bf65e8f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 31 Oct 2020 00:02:02 +0100 Subject: [PATCH 089/446] Added registration feature --- flaschengeist.example.toml | 9 ++++++ flaschengeist/controller/userController.py | 14 +++++++++ flaschengeist/plugins/__init__.py | 20 +++++++++---- flaschengeist/plugins/auth_plain/__init__.py | 9 ++++++ flaschengeist/plugins/users/__init__.py | 31 ++++++++++++++++---- 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/flaschengeist.example.toml b/flaschengeist.example.toml index a804467..a07cd66 100644 --- a/flaschengeist.example.toml +++ b/flaschengeist.example.toml @@ -32,6 +32,15 @@ enabled = true ## ADMIN_DN: ## ADMIN_SECRET: +#[users] +# allways enabled +# +## allowed values: false, "managed", "public" +## false: Disable registration +## "managed": only users with matching permission are allowed to register new users +## "public": Also unautheticated users can register an account +# registration = False + ############################ # Configuration of plugins # ############################ diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 4ee2340..d5dd7f8 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -68,3 +68,17 @@ def delete(user): current_app.config["FG_AUTH_BACKEND"].delete_user(user) db.session.delete(user) db.session.commit() + + +def register(data): + for required in ["firstname", "lastname", "mail"]: + if required not in data: + raise BadRequest("Missing required parameters") + allowed_keys = User().serialize().keys() + user = User(**{key: value for key, value in data.items() if key in allowed_keys}) + + current_app.config["FG_AUTH_BACKEND"].create_user(user, data["password"]) + + db.session.add(user) + db.session.commit() + return user diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 873bf0c..46aa1a1 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -1,4 +1,5 @@ import pkg_resources +from werkzeug.exceptions import MethodNotAllowed from flaschengeist.hook import HookCall @@ -31,13 +32,12 @@ class AuthPlugin(Plugin): Returns: Must return False if not found or invalid credentials, True if success """ - raise NotImplementedError + raise NotImplemented def update_user(self, user): """If backend is using external data, then update this user instance with external data - ) - Args: - user: User object + Args: + user: User object """ pass @@ -55,6 +55,16 @@ class AuthPlugin(Plugin): """ raise NotImplemented + def create_user(self, user, password): + """If backend is using (writeable) external data, then create a new user on the external database. + + Args: + user: User object + password: string + + """ + raise MethodNotAllowed + def delete_user(self, user): """If backend is using (writeable) external data, then delete the user from external database. @@ -62,4 +72,4 @@ class AuthPlugin(Plugin): user: User object """ - pass + raise MethodNotAllowed diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index cdccb5b..49575e5 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -24,6 +24,15 @@ class AuthPlain(AuthPlugin): if new_password: user.set_attribute("password", AuthPlain._hash_password(new_password)) + def create_user(self, user, password): + if not user.userid: + raise BadRequest("userid is missing for new user") + hashed = AuthPlain._hash_password(password) + user.set_attribute("password", hashed) + + def delete_user(self, user): + pass + @staticmethod def _hash_password(password): salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii") diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 648ba1d..d79cc5c 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -2,19 +2,20 @@ Provides routes used to manage users """ - +from flaschengeist.config import config from flask import Blueprint, request, jsonify -from werkzeug.exceptions import NotFound, BadRequest, Forbidden +from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed from flaschengeist import logger from flaschengeist.plugins import Plugin -from flaschengeist.decorator import login_required +from flaschengeist.decorator import login_required, extract_session from flaschengeist.controller import userController users_bp = Blueprint("users", __name__) _permission_edit = "users_edit_other" _permission_set_roles = "users_set_roles" _permission_delete = "users_delete_other" +_permission_register = "users_register" class UsersPlugin(Plugin): @@ -23,9 +24,29 @@ class UsersPlugin(Plugin): @users_bp.route("/users", methods=["POST"]) -def __registration(self): +def register(): + """Register a new user + + Route: ``/users`` | Method: ``POST`` + + POST-data: Same as `flaschengeist.models.user.User` + ``password?: string`` + + Returns: + JSON encoded `flaschengeist.models.user.User` or HTTP error + """ + registration = config["users"].get("registration", False) + if not registration or registration not in ["managed", "public"]: + logger.debug("Config for Registration is set to >{}<".format(registration)) + raise MethodNotAllowed + if registration == "managed": + extract_session(_permission_register) + + data = request.get_json() + if not data: + raise BadRequest logger.debug("Register new User...") - return jsonify({"ok": "ok... well not implemented"}) + + return jsonify(userController.register(data)) @users_bp.route("/users", methods=["GET"]) From 278111bf5e2d66fb5a60aedb7954c8e419775c8f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 31 Oct 2020 01:17:14 +0100 Subject: [PATCH 090/446] Added wsgi file --- flaschengeist.wsgi | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 flaschengeist.wsgi diff --git a/flaschengeist.wsgi b/flaschengeist.wsgi new file mode 100644 index 0000000..62158bc --- /dev/null +++ b/flaschengeist.wsgi @@ -0,0 +1,3 @@ +#!/usr/bin/python3 +from flaschengeist.app import create_app +application = create_app() From e5b39a6ef661ad640cefabc14a0893d3f4e36bdc Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 31 Oct 2020 15:20:28 +0100 Subject: [PATCH 091/446] [Plugin] Fixed return values in auth and users routes --- flaschengeist.wsgi | 5 +++++ flaschengeist/plugins/auth/__init__.py | 8 ++++---- flaschengeist/plugins/users/__init__.py | 14 +++++++++----- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/flaschengeist.wsgi b/flaschengeist.wsgi index 62158bc..069b135 100644 --- a/flaschengeist.wsgi +++ b/flaschengeist.wsgi @@ -1,3 +1,8 @@ #!/usr/bin/python3 +# If you use a virtual env, this might become handy: +# activate_this = '/path/to/env/bin/activate_this.py' +# with open(activate_this) as file_: +# exec(file_.read(), dict(__file__=activate_this)) + from flaschengeist.app import create_app application = create_app() diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index b65dc87..ef84ae7 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -2,8 +2,8 @@ Allow management of authentication, login, logout, etc. """ - from flask import Blueprint, request, jsonify +from http.client import CREATED, NO_CONTENT from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from flaschengeist import logger @@ -49,7 +49,7 @@ def login(): # Lets cleanup the DB sessionController.clear_expired() - return jsonify({"session": session, "user": user}) + return {"session": session, "user": user}, CREATED @auth_bp.route("/auth", methods=["GET"]) @@ -125,7 +125,7 @@ def set_lifetime(token, current_session, **kwargs): current_session: Session sent with Authorization Header Returns: - HTTP-200 (empty) or HTTP error + HTTP-204 or HTTP error """ session = sessionController.get_session(token, current_session._user) if not session: @@ -136,7 +136,7 @@ def set_lifetime(token, current_session, **kwargs): lifetime = request.get_json()["value"] logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) sessionController.set_lifetime(session, lifetime) - return "" + return "", NO_CONTENT except (KeyError, TypeError): raise BadRequest diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index d79cc5c..87875cf 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -2,8 +2,10 @@ Provides routes used to manage users """ +from http.client import NO_CONTENT, CREATED + from flaschengeist.config import config -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, make_response from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed from flaschengeist import logger @@ -44,9 +46,9 @@ def register(): data = request.get_json() if not data: raise BadRequest - logger.debug("Register new User...") - return jsonify(userController.register(data)) + logger.debug("Register new User...") + return make_response(jsonify(userController.register(data)), CREATED) @users_bp.route("/users", methods=["GET"]) @@ -98,11 +100,12 @@ def delete_user(userid, current_session): current_session: Session sent with Authorization Header Returns: - HTTP-200 or HTTP error + HTTP-204 or HTTP error """ logger.debug("Delete user {{ {} }}".format(userid)) user = userController.get_user(userid) userController.delete(user) + return "", NO_CONTENT @users_bp.route("/users/", methods=["PUT"]) @@ -120,7 +123,7 @@ def edit_user(userid, current_session): current_session: Session sent with Authorization Header Returns: - HTTP-200 or HTTP error + HTTP-204 or HTTP error """ logger.debug("Modify information of user {{ {} }}".format(userid)) user = userController.get_user(userid) @@ -148,3 +151,4 @@ def edit_user(userid, current_session): userController.modify_user(user, password, new_password) userController.update_user(user) + return "", NO_CONTENT From 39f34ff4344622ca487a1be5f97c873935dd6129 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 31 Oct 2020 15:23:49 +0100 Subject: [PATCH 092/446] [Plugin] Fixed return values for balance and roles routes --- flaschengeist/plugins/balance/__init__.py | 8 ++++++-- flaschengeist/plugins/roles/__init__.py | 6 +++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index b3ddfcb..5a24c8a 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -3,6 +3,7 @@ Extends users plugin with balance functions """ +from http.client import NO_CONTENT from datetime import datetime, timezone from flask import Blueprint, jsonify, request from werkzeug.exceptions import Forbidden, BadRequest @@ -79,6 +80,7 @@ def set_limit(userid, current_session: Session): except (TypeError, KeyError): raise BadRequest balance_controller.set_limit(user, limit) + return "", NO_CONTENT @balance_bp.route("/users//balance", methods=["GET"]) @@ -156,11 +158,13 @@ def change_balance(userid, current_session: Session): if (sender == current_session._user and sender.has_permission(permissions.SEND)) or ( sender != current_session._user and current_session._user.has_permission(permissions.SEND_OTHER) ): - return balance_controller.send(sender, user, data["amount"], current_session._user) + balance_controller.send(sender, user, data["amount"], current_session._user) + return "", NO_CONTENT elif (amount < 0 and current_session._user.has_permission(permissions.SUB)) or ( amount > 0 and current_session._user.has_permission(permissions.ADD) ): - return balance_controller.change_balance(user, data["amount"], current_session._user) + balance_controller.change_balance(user, data["amount"], current_session._user) + return "", NO_CONTENT raise Forbidden diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index edb6a37..b3710f6 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -3,8 +3,9 @@ Provides routes used to configure roles and permissions of users / roles. """ +from werkzeug.exceptions import BadRequest from flask import Blueprint, request, jsonify -from werkzeug.exceptions import NotFound, BadRequest +from http.client import CREATED, NO_CONTENT from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required @@ -58,6 +59,7 @@ def create_role(current_session): if "permissions" in data: permissions = data["permissions"] roleController.create_role(data["name"], permissions) + return "", CREATED @roles_bp.route("/roles/permissions", methods=["GET"]) @@ -119,6 +121,7 @@ def edit_role(role_name, current_session): if "permissions" in data: roleController.set_permissions(role, data["permissions"]) roleController.update_role(role) + return "", NO_CONTENT @roles_bp.route("/roles/", methods=["DELETE"]) @@ -137,3 +140,4 @@ def delete_role(role_name, current_session): """ role = roleController.get(role_name) roleController.delete(role) + return "", NO_CONTENT From 7b2334bd98987fae82e067eea87532f061ecb392 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 31 Oct 2020 18:03:04 +0100 Subject: [PATCH 093/446] [Plugin] Remove redundant code, balance and roles --- flaschengeist/plugins/balance/__init__.py | 6 ++---- flaschengeist/plugins/roles/__init__.py | 7 +++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 5a24c8a..144e799 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -84,7 +84,7 @@ def set_limit(userid, current_session: Session): @balance_bp.route("/users//balance", methods=["GET"]) -@login_required() +@login_required(permission=permissions.SHOW) def get_balance(userid, current_session: Session): """Get balance of user, optionally filtered @@ -99,9 +99,7 @@ def get_balance(userid, current_session: Session): Returns: JSON object containing credit, debit and balance or HTTP error """ - if (userid == current_session._user.userid and not current_session._user.has_permission(permissions.SHOW)) or ( - userid != current_session._user.userid and not current_session._user.has_permission(permissions.SHOW_OTHER) - ): + if userid != current_session._user.userid and not current_session._user.has_permission(permissions.SHOW_OTHER): raise Forbidden # Might raise NotFound diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index b3710f6..be7ab4f 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -13,12 +13,11 @@ from flaschengeist.controller import roleController roles_bp = Blueprint("roles", __name__) _permission_edit = "roles_edit" -_permission_delete = "roles_delete" class RolesPlugin(Plugin): def __init__(self, config): - super().__init__(config, roles_bp, permissions=[_permission_edit, _permission_delete]) + super().__init__(config, roles_bp, permissions=[_permission_edit]) @roles_bp.route("/roles", methods=["GET"]) @@ -125,7 +124,7 @@ def edit_role(role_name, current_session): @roles_bp.route("/roles/", methods=["DELETE"]) -@login_required(permission=_permission_delete) +@login_required(permission=_permission_edit) def delete_role(role_name, current_session): """Delete role @@ -136,7 +135,7 @@ def delete_role(role_name, current_session): current_session: Session sent with Authorization Header Returns: - HTTP-200 or HTTP error + HTTP-204 or HTTP error """ role = roleController.get(role_name) roleController.delete(role) From 66d559f63b60ffe536b6441dcb99209140e84ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 31 Oct 2020 21:30:48 +0100 Subject: [PATCH 094/446] Fixed Bug to check Permissions --- flaschengeist/controller/sessionController.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py index 61ce3a4..7d5e3e8 100644 --- a/flaschengeist/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -28,7 +28,7 @@ def validate_token(token, user_agent, permissions): if session.expires >= datetime.now(timezone.utc) and ( session.browser == user_agent.browser and session.platform == user_agent.platform ): - if not permissions or session._user.has_permissions(permissions): + if not permissions or session._user.has_permission(permissions): session.refresh() db.session.commit() return session From 425eb1c84932c200e72ea055f150284c43e74fd7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 1 Nov 2020 16:16:51 +0100 Subject: [PATCH 095/446] [Plugin] balance: Added config option to add a default limit --- flaschengeist.example.toml | 2 ++ flaschengeist/controller/userController.py | 2 ++ flaschengeist/plugins/__init__.py | 1 + flaschengeist/plugins/balance/__init__.py | 10 +++++++++- flaschengeist/plugins/balance/balance_controller.py | 7 ++++--- 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/flaschengeist.example.toml b/flaschengeist.example.toml index a07cd66..e0a0cda 100644 --- a/flaschengeist.example.toml +++ b/flaschengeist.example.toml @@ -56,6 +56,8 @@ enabled = true [balance] enabled = true +# Enable a default limit, will be set if no other limit is set +# limit = -10.00 [geruecht] enabled = false diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index d5dd7f8..d1ff44a 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,6 +1,7 @@ from flask import current_app from werkzeug.exceptions import NotFound, BadRequest +from flaschengeist.hook import Hook from flaschengeist.models.user import User, Role from flaschengeist.database import db from flaschengeist import logger @@ -18,6 +19,7 @@ def login_user(username, password): return None +@Hook def update_user(user): current_app.config["FG_AUTH_BACKEND"].update_user(user) if not user.display_name: diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 46aa1a1..bfc73fe 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -4,6 +4,7 @@ from werkzeug.exceptions import MethodNotAllowed from flaschengeist.hook import HookCall send_message_hook = HookCall("send_message") +update_user_hook = HookCall("update_user") class Plugin: diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 144e799..0d29b26 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -9,10 +9,11 @@ from flask import Blueprint, jsonify, request from werkzeug.exceptions import Forbidden, BadRequest from backports.datetime_fromisoformat import MonkeyPatch -from flaschengeist.plugins import Plugin +from flaschengeist import logger from flaschengeist.models.session import Session from flaschengeist.decorator import login_required from flaschengeist.controller import userController +from flaschengeist.plugins import Plugin, update_user_hook from . import balance_controller, permissions @@ -24,6 +25,13 @@ class BalancePlugin(Plugin): def __init__(self, config): super().__init__(blueprint=balance_bp, permissions=permissions.permissions) + @update_user_hook + def set_default_limit(user): + if "limit" in config: + limit = config["limit"] + logger.debug("Setting default limit of {} to user {}".format(limit, user.userid)) + balance_controller.set_limit(user, limit, override=False) + def install(self): from flaschengeist.database import db from .models import Transaction diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index dd9112c..5d9e609 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -14,9 +14,10 @@ from . import permissions __attribute_limit = "balance_limit" -def set_limit(user: User, limit: float): - user.set_attribute(__attribute_limit, limit) - db.session.commit() +def set_limit(user: User, limit: float, override=True): + if override or not user.has_attribute(__attribute_limit): + user.set_attribute(__attribute_limit, limit) + db.session.commit() def get_limit(user: User) -> float: From 2f9446be2ff4fee1ed6c5a7178119e7756982b00 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 1 Nov 2020 16:25:54 +0100 Subject: [PATCH 096/446] [Doc] Some more documentation --- flaschengeist/hook.py | 4 ++++ flaschengeist/plugins/__init__.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/flaschengeist/hook.py b/flaschengeist/hook.py index e855341..067d4e5 100644 --- a/flaschengeist/hook.py +++ b/flaschengeist/hook.py @@ -2,6 +2,9 @@ _hook_dict = {} class Hook(object): + """Decorator for Hooks + Use to decorate system hooks where plugins should be able to hook in + """ def __init__(self, function): self.function = function @@ -12,6 +15,7 @@ class Hook(object): class HookCall(object): + """Decorator for functions to be called if a Hook is called""" def __init__(self, name): self.name = name diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index bfc73fe..f329472 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -4,11 +4,27 @@ from werkzeug.exceptions import MethodNotAllowed from flaschengeist.hook import HookCall send_message_hook = HookCall("send_message") +"""Hook for sending messages, register to send the message +Args: + message: Message object to send +""" + update_user_hook = HookCall("update_user") +"""When ever an user update is done, this is called before. +Args: + user: User object +""" class Plugin: + """Base class for all Plugins""" def __init__(self, config=None, blueprint=None, permissions=[]): + """Constructor called by create_app + Args: + config: Dict configuration containing the plugin section + blueprint: A flask blueprint containing all plugin routes + permissions: List of permissions of this Plugin + """ self.blueprint = blueprint self.permissions = permissions self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version @@ -20,6 +36,11 @@ class Plugin: pass def serialize(self): + """Serialize a plugin into a dict + + Returns: + Dict containing version and permissions of the plugin + """ return {"version": self.version, "permissions": self.permissions} From 36c4027c5dd1c631bf6ca84890b6ad9c916e1888 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 1 Nov 2020 18:37:08 +0100 Subject: [PATCH 097/446] [System] Some cleanup --- flaschengeist/decorator.py | 2 ++ flaschengeist/hook.py | 2 ++ flaschengeist/models/__init__.py | 9 ++++++++- run_flaschengeist | 10 ++++++++-- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/flaschengeist/decorator.py b/flaschengeist/decorator.py index 822000e..e29fb4a 100644 --- a/flaschengeist/decorator.py +++ b/flaschengeist/decorator.py @@ -7,6 +7,7 @@ from flaschengeist.controller import sessionController def extract_session(permission=None): from flask import request + try: token = list(filter(None, request.headers.get("Authorization").split(" ")))[-1] except AttributeError: @@ -30,6 +31,7 @@ def login_required(permission=None): Returns: Wrapped function with login (and permission) guard """ + def wrap(func): @wraps(func) def wrapped_f(*args, **kwargs): diff --git a/flaschengeist/hook.py b/flaschengeist/hook.py index 067d4e5..0af84f3 100644 --- a/flaschengeist/hook.py +++ b/flaschengeist/hook.py @@ -5,6 +5,7 @@ class Hook(object): """Decorator for Hooks Use to decorate system hooks where plugins should be able to hook in """ + def __init__(self, function): self.function = function @@ -16,6 +17,7 @@ class Hook(object): class HookCall(object): """Decorator for functions to be called if a Hook is called""" + def __init__(self, name): self.name = name diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 415ffee..0346a2a 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -3,12 +3,19 @@ from sqlalchemy.types import DateTime, TypeDecorator class ModelSerializeMixin: + """Mixin class used for models to serialize them automatically + Ignores private and protected members as well as members marked as not to publish (name ends with _) + """ def serialize(self): """Serialize class to dict Returns: Dict of all not private or protected annotated member variables. """ - d = {param: getattr(self, param) for param in self.__class__.__annotations__ if not param.startswith("_")} + d = { + param: getattr(self, param) + for param in self.__class__.__annotations__ + if not param.startswith("_") and not param.endswith("_") + } if len(d) == 1: key, value = d.popitem() return value diff --git a/run_flaschengeist b/run_flaschengeist index 1ee770f..f9951e9 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -5,6 +5,7 @@ import argparse def install(arguments): from flaschengeist.app import create_app, install_all + app = create_app() with app.app_context(): install_all() @@ -20,6 +21,7 @@ def run(arguments): else: try: import bjoern + bjoern.run(app, arguments.host, arguments.port, reuse_port=True) except ImportError: app.run(arguments.host, arguments.port, debug=False) @@ -34,7 +36,7 @@ def export(arguments): def pytype(cls): if isinstance(cls, list): return "", "Array<{}>".format(pytype(cls[0])[1]) - #if typing.get_origin(cls) is typing.Optional: + # if typing.get_origin(cls) is typing.Optional: # return "?", pytype(typing.get_args(cls)[1]) mapper = {"str": "string", "int": "number", "float": "number", "datetime": "Date"} if hasattr(cls, "__name__"): @@ -56,7 +58,11 @@ def export(arguments): and not mod[0].startswith("_") and hasattr(mod[1], "__annotations__") ): - d = {param: pytype(ptype) for param, ptype in mod[1].__annotations__.items() if not param.startswith("_")} + d = { + param: pytype(ptype) + for param, ptype in mod[1].__annotations__.items() + if not param.startswith("_") and not param.endswith("_") + } if len(d) == 1: key, value = d.popitem() classes[mod[0]] = value[1] From d439cd93c8d9fc9d6d7622219808f341e8a5a332 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 1 Nov 2020 18:38:53 +0100 Subject: [PATCH 098/446] [Plugin] balance: Enhanced the model by adding serialization members --- .git-blame-ignore-revs | 1 + flaschengeist/plugins/balance/models.py | 26 +++++++++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 2ee1cc3..36f331f 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,2 +1,3 @@ # Migrate code style to Black 41e60425a96eeddb45f3a9f9bfc5ef491ce8e071 +36c4027c5dd1c631bf6ca84890b6ad9c916e1888 diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 4a967d5..5219751 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -8,15 +8,33 @@ from flaschengeist.models import ModelSerializeMixin, UtcDateTime class Transaction(db.Model, ModelSerializeMixin): __tablename__ = "balance_transaction" + id: int = db.Column("id", db.Integer, primary_key=True) time: datetime = db.Column(UtcDateTime, nullable=False, default=datetime.now(tz=timezone.utc)) amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) + # Dummy properties used for JSON serialization (userid instead of full user) + sender_id: str = "" + receiver_id: str = "" + author_id: str = "" + + # Protected foreign key properties _receiver_id = db.Column("receiver_id", db.Integer, db.ForeignKey("user.id")) _sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id")) _author_id = db.Column("author_id", db.Integer, db.ForeignKey("user.id"), nullable=False) - sender: User = db.relationship("User", foreign_keys=[_sender_id]) - receiver: User = db.relationship("User", foreign_keys=[_receiver_id]) - author: User = db.relationship("User", foreign_keys=[_author_id]) + # Not exported relationships just in backend only + sender_: User = db.relationship("User", foreign_keys=[_sender_id]) + receiver_: User = db.relationship("User", foreign_keys=[_receiver_id]) + author_: User = db.relationship("User", foreign_keys=[_author_id]) - _id = db.Column("id", db.Integer, primary_key=True) + @property + def sender_id(self): + return self.sender_.userid + + @property + def receiver_id(self): + return self.receiver_.userid + + @property + def author_id(self): + return self.author_.userid From d07aa977b503551ccf166f0d88933df5f50d1676 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 1 Nov 2020 18:43:44 +0100 Subject: [PATCH 099/446] [Doc] Some more documentation on Plugin --- flaschengeist/plugins/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index f329472..d8b779a 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -4,13 +4,13 @@ from werkzeug.exceptions import MethodNotAllowed from flaschengeist.hook import HookCall send_message_hook = HookCall("send_message") -"""Hook for sending messages, register to send the message +"""Hook decorator for sending messages, register to send the message Args: message: Message object to send """ update_user_hook = HookCall("update_user") -"""When ever an user update is done, this is called before. +"""Hook decorator, when ever an user update is done, this is called before. Args: user: User object """ @@ -18,6 +18,7 @@ Args: class Plugin: """Base class for all Plugins""" + def __init__(self, config=None, blueprint=None, permissions=[]): """Constructor called by create_app Args: From d1fcbcf68fad314823317d0bb4dab1e0fdf82215 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 03:29:29 +0100 Subject: [PATCH 100/446] [Plugin] balance: Fixed controller --- flaschengeist/models/__init__.py | 1 + .../plugins/balance/balance_controller.py | 33 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 0346a2a..4023099 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -6,6 +6,7 @@ class ModelSerializeMixin: """Mixin class used for models to serialize them automatically Ignores private and protected members as well as members marked as not to publish (name ends with _) """ + def serialize(self): """Serialize class to dict Returns: diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 5d9e609..a36bd91 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -1,16 +1,13 @@ -from flaschengeist.models.user import User from sqlalchemy import func -from datetime import datetime - +from datetime import datetime, timezone from werkzeug.exceptions import BadRequest -from flaschengeist import logger from flaschengeist.database import db +from flaschengeist.models.user import User from .models import Transaction from . import permissions - __attribute_limit = "balance_limit" @@ -24,24 +21,28 @@ def get_limit(user: User) -> float: return user.get_attribute(__attribute_limit, default=None) -def get(user, start: datetime, end: datetime): +def get(user, start: datetime = None, end: datetime = None): + if not start: + start = datetime.fromtimestamp(0, tz=timezone.utc) + if not end: + end = datetime.now(tz=timezone.utc) + credit = ( db.session.query(func.sum(Transaction.amount)) - .filter(Transaction.receiver == user) + .filter(Transaction.receiver_ == user) .filter(start <= Transaction.time) .filter(Transaction.time <= end) .scalar() ) or 0 - logger.debug(credit) - if credit is None: - credit = 0 + debit = ( db.session.query(func.sum(Transaction.amount)) - .filter(Transaction.sender == user and start <= Transaction.time <= end) - .all()[0][0] - ) - if debit is None: - debit = 0 + .filter(Transaction.sender_ == user) + .filter(start <= Transaction.time) + .filter(Transaction.time <= end) + .scalar() + ) or 0 + return credit, debit, credit - debit @@ -65,7 +66,7 @@ def send(sender: User, receiver, amount: float, author: User): ): raise BadRequest("Limit exceeded") - transaction = Transaction(sender=sender, receiver=receiver, amount=amount, author=author) + transaction = Transaction(sender_=sender, receiver_=receiver, amount=amount, author_=author) db.session.add(transaction) db.session.commit() From 56a0a8e06a8ac5d8e57f10c3418fd0a87c344042 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 04:38:38 +0100 Subject: [PATCH 101/446] [System][Script] Improved run_flaschengeist to also export plugin models --- flaschengeist/plugins/__init__.py | 3 +- flaschengeist/plugins/balance/__init__.py | 6 +- run_flaschengeist | 125 +++++++++++++--------- 3 files changed, 78 insertions(+), 56 deletions(-) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index d8b779a..7e13aca 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -17,7 +17,8 @@ Args: class Plugin: - """Base class for all Plugins""" + """Base class for all Plugins + If your class uses custom models add a static property called ``models``""" def __init__(self, config=None, blueprint=None, permissions=[]): """Constructor called by create_app diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 0d29b26..1b89504 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -15,13 +15,15 @@ from flaschengeist.decorator import login_required from flaschengeist.controller import userController from flaschengeist.plugins import Plugin, update_user_hook -from . import balance_controller, permissions +from . import balance_controller, permissions, models MonkeyPatch.patch_fromisoformat() balance_bp = Blueprint("balance", __name__) class BalancePlugin(Plugin): + models = models + def __init__(self, config): super().__init__(blueprint=balance_bp, permissions=permissions.permissions) @@ -34,8 +36,6 @@ class BalancePlugin(Plugin): def install(self): from flaschengeist.database import db - from .models import Transaction - db.create_all() diff --git a/run_flaschengeist b/run_flaschengeist index f9951e9..b9d7429 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,6 +1,70 @@ #!/usr/bin/python3 import inspect import argparse +import pkg_resources + + +class InterfaceGenerator: + known = [] + classes = {} + mapper = {"str": "string", "int": "number", "float": "number", "datetime": "Date"} + + def __init__(self, namespace, filename): + self.basename = "" + self.namespace = namespace + self.filename = filename + + def pytype(self, cls): + if isinstance(cls, list): + return "", "Array<{}>".format(self.pytype(cls[0])[1]) + # if typing.get_origin(cls) is typing.Optional: + # return "?", pytype(typing.get_args(cls)[1]) + if hasattr(cls, "__name__"): + if cls.__name__ in self.mapper: + return "", self.mapper[cls.__name__] + else: + return "", cls.__name__ + return "?", "any" + + def walker(self, module): + if inspect.ismodule(module[1]) and module[1].__name__.startswith(self.basename) and module[1].__name__ not in self.known: + self.known.append(module[1].__name__) + for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): + self.walker(cls) + elif ( + inspect.isclass(module[1]) + and module[1].__module__.startswith(self.basename) + and module[0] not in self.classes + and not module[0].startswith("_") + and hasattr(module[1], "__annotations__") + ): + d = { + param: self.pytype(ptype) + for param, ptype in module[1].__annotations__.items() + if not param.startswith("_") and not param.endswith("_") + } + if len(d) == 1: + key, value = d.popitem() + self.classes[module[0]] = value[1] + else: + self.classes[module[0]] = d + + def run(self, models): + self.basename = models.__name__ + self.walker(("models", models)) + + def write(self): + with open(self.filename, "w") as file: + file.write("declare namespace {} {{\n".format(self.namespace)) + for cls, params in self.classes.items(): + if isinstance(params, str): + file.write("\ttype {} = {};\n".format(cls, params)) + else: + file.write("\tinterface {} {{\n".format(cls)) + for name in params: + file.write("\t\t{}{}: {};\n".format(name, *params[name])) + file.write("\t}\n") + file.write("}\n") def install(arguments): @@ -29,62 +93,18 @@ def run(arguments): def export(arguments): import flaschengeist.models as models - - known = [] - classes = {} - - def pytype(cls): - if isinstance(cls, list): - return "", "Array<{}>".format(pytype(cls[0])[1]) - # if typing.get_origin(cls) is typing.Optional: - # return "?", pytype(typing.get_args(cls)[1]) - mapper = {"str": "string", "int": "number", "float": "number", "datetime": "Date"} - if hasattr(cls, "__name__"): - if cls.__name__ in mapper: - return "", mapper[cls.__name__] - else: - return "", cls.__name__ - return "?", "any" - - def walker(mod): - if inspect.ismodule(mod[1]) and mod[1].__name__.startswith(models.__name__) and mod[1].__name__ not in known: - known.append(mod[1].__name__) - for cls in inspect.getmembers(mod[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): - walker(cls) - elif ( - inspect.isclass(mod[1]) - and mod[1].__module__.startswith(models.__name__) - and mod[0] not in classes - and not mod[0].startswith("_") - and hasattr(mod[1], "__annotations__") - ): - d = { - param: pytype(ptype) - for param, ptype in mod[1].__annotations__.items() - if not param.startswith("_") and not param.endswith("_") - } - if len(d) == 1: - key, value = d.popitem() - classes[mod[0]] = value[1] - else: - classes[mod[0]] = d - from flaschengeist.app import create_app app = create_app() with app.app_context(): - walker(("models", models)) - with open(arguments.file, "w") as file: - file.write("declare namespace {} \{\n".format(arguments.namespace)) - for cls, params in classes.items(): - if isinstance(params, str): - file.write("\ttype {} = {};\n".format(cls, params)) - else: - file.write("\tinterface {} {{\n".format(cls)) - for name in params: - file.write("\t\t{}{}: {};\n".format(name, *params[name])) - file.write("\t}\n") - file.write("}\n") + gen = InterfaceGenerator(arguments.namespace, arguments.file) + gen.run(models) + if arguments.plugins: + for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"): + plg = entry_point.load() + if hasattr(plg, "models"): + gen.run(plg.models) + gen.write() if __name__ == "__main__": @@ -105,6 +125,7 @@ if __name__ == "__main__": parser_export.set_defaults(func=export) parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts") parser_export.add_argument("--namespace", help="Namespace of declarations", default="FG") + parser_export.add_argument("--plugins", help="Also export plugins", action="store_true") args = parser.parse_args() args.func(args) From 22499b7ece5591d4109381c3898523621fecf275 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 13:32:44 +0100 Subject: [PATCH 102/446] [System][Plugin] Allow military timezone names for datetime --- flaschengeist/controller/messageController.py | 2 +- flaschengeist/controller/userController.py | 2 +- flaschengeist/plugins/__init__.py | 2 +- flaschengeist/plugins/balance/__init__.py | 8 ++++---- flaschengeist/utils/__init__.py | 0 flaschengeist/utils/datetime.py | 11 +++++++++++ flaschengeist/{ => utils}/hook.py | 0 7 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 flaschengeist/utils/__init__.py create mode 100644 flaschengeist/utils/datetime.py rename flaschengeist/{ => utils}/hook.py (100%) diff --git a/flaschengeist/controller/messageController.py b/flaschengeist/controller/messageController.py index 16976d3..f43afc8 100644 --- a/flaschengeist/controller/messageController.py +++ b/flaschengeist/controller/messageController.py @@ -1,4 +1,4 @@ -from flaschengeist.hook import Hook +from flaschengeist.utils.hook import Hook from flaschengeist.models.user import User, Role diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index d1ff44a..9106032 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,7 +1,7 @@ from flask import current_app from werkzeug.exceptions import NotFound, BadRequest -from flaschengeist.hook import Hook +from flaschengeist.utils.hook import Hook from flaschengeist.models.user import User, Role from flaschengeist.database import db from flaschengeist import logger diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 7e13aca..349cc32 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -1,7 +1,7 @@ import pkg_resources from werkzeug.exceptions import MethodNotAllowed -from flaschengeist.hook import HookCall +from flaschengeist.utils.hook import HookCall send_message_hook = HookCall("send_message") """Hook decorator for sending messages, register to send the message diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 1b89504..295342f 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -7,17 +7,16 @@ from http.client import NO_CONTENT from datetime import datetime, timezone from flask import Blueprint, jsonify, request from werkzeug.exceptions import Forbidden, BadRequest -from backports.datetime_fromisoformat import MonkeyPatch from flaschengeist import logger from flaschengeist.models.session import Session +from flaschengeist.utils.datetime import from_iso_format from flaschengeist.decorator import login_required from flaschengeist.controller import userController from flaschengeist.plugins import Plugin, update_user_hook from . import balance_controller, permissions, models -MonkeyPatch.patch_fromisoformat() balance_bp = Blueprint("balance", __name__) @@ -36,6 +35,7 @@ class BalancePlugin(Plugin): def install(self): from flaschengeist.database import db + db.create_all() @@ -115,13 +115,13 @@ def get_balance(userid, current_session: Session): start = request.args.get("from") if start: - start = datetime.fromisoformat(start).replace(tzinfo=timezone.utc) + start = from_iso_format(start) else: start = datetime.fromtimestamp(0, tz=timezone.utc) end = request.args.get("to") if end: - end = datetime.fromisoformat(end).replace(tzinfo=timezone.utc) + end = from_iso_format(end) else: end = datetime.now(tz=timezone.utc) diff --git a/flaschengeist/utils/__init__.py b/flaschengeist/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flaschengeist/utils/datetime.py b/flaschengeist/utils/datetime.py new file mode 100644 index 0000000..eb2ba82 --- /dev/null +++ b/flaschengeist/utils/datetime.py @@ -0,0 +1,11 @@ +import datetime +from backports.datetime_fromisoformat import MonkeyPatch +MonkeyPatch.patch_fromisoformat() + + +def from_iso_format(date_str): + """Z-suffix aware version of `datetime.datetime.fromisoformat`""" + time = datetime.datetime.fromisoformat(date_str.replace('Z', '+00:00')) + if time.tzinfo: + return time.astimezone(tzinfo=datetime.timezone.utc) + return time.replace(tzinfo=datetime.timezone.utc) diff --git a/flaschengeist/hook.py b/flaschengeist/utils/hook.py similarity index 100% rename from flaschengeist/hook.py rename to flaschengeist/utils/hook.py From 79c05fa2f48a25c19ebbb89ff0e50b834a2802d7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 13:38:52 +0100 Subject: [PATCH 103/446] [System] Fixed typo in utils --- flaschengeist/utils/datetime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/utils/datetime.py b/flaschengeist/utils/datetime.py index eb2ba82..7c50c41 100644 --- a/flaschengeist/utils/datetime.py +++ b/flaschengeist/utils/datetime.py @@ -7,5 +7,5 @@ def from_iso_format(date_str): """Z-suffix aware version of `datetime.datetime.fromisoformat`""" time = datetime.datetime.fromisoformat(date_str.replace('Z', '+00:00')) if time.tzinfo: - return time.astimezone(tzinfo=datetime.timezone.utc) + return time.astimezone(datetime.timezone.utc) return time.replace(tzinfo=datetime.timezone.utc) From 67fb895cf47bb95b26e2115c634f9e261ec526b7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 15:45:24 +0100 Subject: [PATCH 104/446] [Plugin] auth: Handle exception if data is not json --- flaschengeist/plugins/auth/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index ef84ae7..9889e04 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -36,7 +36,7 @@ def login(): try: userid = data["userid"] password = data["password"] - except KeyError: + except (KeyError, ValueError): raise BadRequest("Missing parameter(s)") logger.debug("search user {{ {} }} in database".format(userid)) From 63660743bd7297e7fe8c778f90414822ea4a0a50 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 1 Nov 2020 18:43:13 +0100 Subject: [PATCH 105/446] [Plugin] schedule: Restructure plugin --- flaschengeist/plugins/schedule/__init__.py | 48 ++++++++----------- .../schedule/event_controller.py} | 2 +- .../event.py => plugins/schedule/models.py} | 4 +- flaschengeist/plugins/schedule/permissions.py | 19 ++++++++ 4 files changed, 41 insertions(+), 32 deletions(-) rename flaschengeist/{controller/eventController.py => plugins/schedule/event_controller.py} (96%) rename flaschengeist/{models/event.py => plugins/schedule/models.py} (96%) create mode 100644 flaschengeist/plugins/schedule/permissions.py diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 218bd53..075775b 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -3,31 +3,21 @@ from datetime import datetime, timedelta from flask import Blueprint, request, jsonify from werkzeug.exceptions import BadRequest, NotFound -from flaschengeist.plugins import Plugin from flaschengeist.database import db -from flaschengeist.models.event import EventKind +from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required -from flaschengeist.controller import eventController + +from .models import EventKind +from . import event_controller, permissions schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") -_permission_edit_type = "schedule_edit_type" -_permission_edit = "schedule_edit" -_permission_create = "schedule_create" -_permission_delete = "schedule_delete" -_permission_assign = "schedule_assign_other" class SchedulePlugin(Plugin): def __init__(self, config): super().__init__( blueprint=schedule_bp, - permissions=[ - _permission_create, - _permission_edit, - _permission_edit_type, - _permission_delete, - _permission_assign, - ], + permissions=permissions.permissions, ) @@ -56,7 +46,7 @@ class SchedulePlugin(Plugin): @schedule_bp.route("/events/", methods=["GET"]) @login_required() def __get_event(eid, **kwargs): - event = eventController.get_event(eid) + event = event_controller.get_event(eid) if not event: raise NotFound return jsonify(event) @@ -91,7 +81,7 @@ def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, else: end = datetime(year=year, month=month + 1, day=1) - events = eventController.get_events(begin, end) + events = event_controller.get_events(begin, end) return jsonify(events) except ValueError: raise BadRequest("Invalid date given") @@ -103,7 +93,7 @@ def __new_event_kind(**kwargs): data = request.get_json() if "name" not in data: raise BadRequest - kind = eventController.create_event_kind(data["name"]) + kind = event_controller.create_event_kind(data["name"]) return jsonify({"ok": "ok", "id": kind.id}) @@ -113,7 +103,7 @@ def __new_slot_kind(**kwargs): data = request.get_json() if not data or "name" not in data: raise BadRequest - kind = eventController.create_job_kind(data["name"]) + kind = event_controller.create_job_kind(data["name"]) return jsonify({"ok": "ok", "id": kind.id}) @@ -121,7 +111,7 @@ def __new_slot_kind(**kwargs): @login_required(permission=_permission_create) def __new_event(**kwargs): data = request.get_json() - event = eventController.create_event( + event = event_controller.create_event( begin=parser.isoparse(data["begin"]), end=parser.isoparse(data["end"]), description=data["description"], @@ -133,7 +123,7 @@ def __new_event(**kwargs): @schedule_bp.route("/events/", methods=["DELETE"]) @login_required(permission=_permission_delete) def __delete_event(event_id, **kwargs): - if not eventController.delete_event(event_id): + if not event_controller.delete_event(event_id): raise NotFound db.session.commit() return jsonify({"ok": "ok"}) @@ -145,14 +135,14 @@ def __edit_event_kind(event_id, **kwargs): data = request.get_json() if not data or "name" not in data: raise BadRequest - eventController.rename_event_kind(event_id, data["name"]) + event_controller.rename_event_kind(event_id, data["name"]) return jsonify({"ok": "ok"}) @schedule_bp.route("/events//slots", methods=["GET"]) @login_required() def __get_slots(event_id, **kwargs): - event = eventController.get_event(event_id) + event = event_controller.get_event(event_id) if not event: raise NotFound return jsonify({event.slots}) @@ -161,7 +151,7 @@ def __get_slots(event_id, **kwargs): @schedule_bp.route("/events//slots/", methods=["GET"]) @login_required() def __get_slot(event_id, slot_id, **kwargs): - slot = eventController.get_event_slot(slot_id, event_id) + slot = event_controller.get_event_slot(slot_id, event_id) if slot: return jsonify(slot) raise NotFound @@ -170,7 +160,7 @@ def __get_slot(event_id, slot_id, **kwargs): @schedule_bp.route("/events//slots/", methods=["DELETE"]) @login_required(permission=_permission_delete) def __delete_slot(event_id, slot_id, **kwargs): - if eventController.delete_event_slot(slot_id, event_id): + if event_controller.delete_event_slot(slot_id, event_id): return jsonify({"ok": "ok"}) raise NotFound @@ -183,8 +173,8 @@ def __update_slot(event_id, slot_id, **kwargs): raise BadRequest for job in data["jobs"]: - eventController.add_job(job.kind, job.user) - if eventController.delete_event_slot(slot_id, event_id): + event_controller.add_job(job.kind, job.user) + if event_controller.delete_event_slot(slot_id, event_id): return jsonify({"ok": "ok"}) raise NotFound @@ -192,7 +182,7 @@ def __update_slot(event_id, slot_id, **kwargs): @schedule_bp.route("/events//slots", methods=["POST"]) @login_required(permission=_permission_edit) def __add_slot(event_id, **kwargs): - event = eventController.get_event(event_id) + event = event_controller.get_event(event_id) if not event: raise NotFound data = request.get_json() @@ -206,7 +196,7 @@ def __add_slot(event_id, **kwargs): attr["job_slots"].append({"needed_persons": job["needed_persons"], "kind": job["kind"]}) except KeyError: raise BadRequest("Missing data in request") - eventController.add_slot(event, **attr) + event_controller.add_slot(event, **attr) return jsonify({"ok": "ok"}) diff --git a/flaschengeist/controller/eventController.py b/flaschengeist/plugins/schedule/event_controller.py similarity index 96% rename from flaschengeist/controller/eventController.py rename to flaschengeist/plugins/schedule/event_controller.py index 4e0b6b5..c8faf8b 100644 --- a/flaschengeist/controller/eventController.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -3,7 +3,7 @@ from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.database import db -from flaschengeist.models.event import EventKind, Event, EventSlot, JobSlot, JobKind +from flaschengeist.plugins.schedule.models import EventKind, Event, EventSlot, JobSlot, JobKind def get_event(id): diff --git a/flaschengeist/models/event.py b/flaschengeist/plugins/schedule/models.py similarity index 96% rename from flaschengeist/models/event.py rename to flaschengeist/plugins/schedule/models.py index 6e433f1..26d2988 100644 --- a/flaschengeist/models/event.py +++ b/flaschengeist/plugins/schedule/models.py @@ -1,8 +1,8 @@ from datetime import datetime from typing import Optional -from . import ModelSerializeMixin -from .user import User +from flaschengeist.models import ModelSerializeMixin +from flaschengeist.models.user import User from flaschengeist.database import db diff --git a/flaschengeist/plugins/schedule/permissions.py b/flaschengeist/plugins/schedule/permissions.py new file mode 100644 index 0000000..b73873a --- /dev/null +++ b/flaschengeist/plugins/schedule/permissions.py @@ -0,0 +1,19 @@ +CREATE = "schedule_create" +"""Can create events""" + +EDIT = "schedule_edit" +"""Can edit events""" + +DELETE = "schedule_delete" +"""Can delete events""" + +EVENT_TYPE = "schedule_event_type" +"""Can create and edit EventTypes""" + +ASSIGN = "schedule_assign" +"""Can self assign to jobs""" + +ASSIGN_OTHER = "schedule_assign_other" +"""Can assign other users to jobs""" + +permissions = [value for key, value in globals().items() if not key.startswith("_")] From b4234c43b80fbeba78d32818cb52fae5ff3e51d7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 1 Nov 2020 19:31:51 +0100 Subject: [PATCH 106/446] [Plugin] schedule: Fixed models --- flaschengeist/plugins/schedule/__init__.py | 65 ++++++------- flaschengeist/plugins/schedule/models.py | 108 +++++++++++---------- 2 files changed, 87 insertions(+), 86 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 075775b..a4dcaa1 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -1,3 +1,7 @@ +"""Schedule plugin + +Provides duty schedule / duty roster functions +""" from dateutil import parser from datetime import datetime, timedelta from flask import Blueprint, request, jsonify @@ -7,8 +11,8 @@ from flaschengeist.database import db from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required -from .models import EventKind from . import event_controller, permissions +from .models import EventType schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") @@ -21,32 +25,21 @@ class SchedulePlugin(Plugin): ) -#################################################################################### -# Routes # -# # -# /schedule/events POST: create new # -# GET: get all events this month # -# /schedule/events// GET: get events by month / date # -# /// # -# /schedule/events/ GET: get event by ID # -# DELETE: delete specified event # -# PUT: modify specified event # -# /schedule/events//slots GET: get EventSlots of Event # -# POST: add new EventSlot to Event # -# /schedule/events//slots/ PUT: modify EventSlot # -# GET: get specified EventSlot # -# /schedule/events//slots//jobs POST: add User # -# /schedule/eventKinds -# /schedule/eventKinds/ -# PUT: modify user # -# DELETE: remove user # -#################################################################################### - - -@schedule_bp.route("/events/", methods=["GET"]) +@schedule_bp.route("/events/", methods=["GET"]) @login_required() -def __get_event(eid, **kwargs): - event = event_controller.get_event(eid) +def get_event(event_id, current_session): + """Get event by id + + Route: ``/events/`` | Method: ``GET`` + + Args: + event_id: ID identifying the event + current_session: Session sent with Authorization Header + + Returns: + JSON encoded event object + """ + event = event_controller.get_event(event_id) if not event: raise NotFound return jsonify(event) @@ -87,8 +80,8 @@ def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, raise BadRequest("Invalid date given") -@schedule_bp.route("/eventKinds", methods=["POST"]) -@login_required(permission=_permission_edit_type) +@schedule_bp.route("/event-types", methods=["POST"]) +@login_required(permission=permissions.EVENT_TYPE) def __new_event_kind(**kwargs): data = request.get_json() if "name" not in data: @@ -98,7 +91,7 @@ def __new_event_kind(**kwargs): @schedule_bp.route("/slotKinds", methods=["POST"]) -@login_required(permission=_permission_edit_type) +@login_required(permission=permissions.EVENT_TYPE) def __new_slot_kind(**kwargs): data = request.get_json() if not data or "name" not in data: @@ -108,20 +101,20 @@ def __new_slot_kind(**kwargs): @schedule_bp.route("/events", methods=["POST"]) -@login_required(permission=_permission_create) +@login_required(permission=permissions.CREATE) def __new_event(**kwargs): data = request.get_json() event = event_controller.create_event( begin=parser.isoparse(data["begin"]), end=parser.isoparse(data["end"]), description=data["description"], - kind=EventKind.query.get(data["kind"]), + type=EventType.query.get(data["kind"]), ) return jsonify({"ok": "ok", "id": event.id}) @schedule_bp.route("/events/", methods=["DELETE"]) -@login_required(permission=_permission_delete) +@login_required(permission=permissions.DELETE) def __delete_event(event_id, **kwargs): if not event_controller.delete_event(event_id): raise NotFound @@ -130,7 +123,7 @@ def __delete_event(event_id, **kwargs): @schedule_bp.route("/eventKinds/", methods=["PUT"]) -@login_required(permission=_permission_edit_type) +@login_required(permission=permissions.EVENT_TYPE) def __edit_event_kind(event_id, **kwargs): data = request.get_json() if not data or "name" not in data: @@ -158,7 +151,7 @@ def __get_slot(event_id, slot_id, **kwargs): @schedule_bp.route("/events//slots/", methods=["DELETE"]) -@login_required(permission=_permission_delete) +@login_required(permission=permissions.DELETE) def __delete_slot(event_id, slot_id, **kwargs): if event_controller.delete_event_slot(slot_id, event_id): return jsonify({"ok": "ok"}) @@ -166,7 +159,7 @@ def __delete_slot(event_id, slot_id, **kwargs): @schedule_bp.route("/events//slots/", methods=["PUT"]) -@login_required(permission=_permission_edit) +@login_required(permission=permissions.EDIT) def __update_slot(event_id, slot_id, **kwargs): data = request.get_json() if not data: @@ -180,7 +173,7 @@ def __update_slot(event_id, slot_id, **kwargs): @schedule_bp.route("/events//slots", methods=["POST"]) -@login_required(permission=_permission_edit) +@login_required(permission=permissions.EDIT) def __add_slot(event_id, **kwargs): event = event_controller.get_event(event_id) if not event: diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index 26d2988..a6792da 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -5,74 +5,82 @@ from flaschengeist.models import ModelSerializeMixin from flaschengeist.models.user import User from flaschengeist.database import db - -class EventSlot(db.Model, ModelSerializeMixin): - """Model for an EventSlot""" - - __tablename__ = "event_slot" - id: int = db.Column(db.Integer, primary_key=True) - start: datetime = db.Column(db.DateTime) - end: Optional[datetime] = db.Column(db.DateTime) - slots: [any] = db.relationship("JobSlot", back_populates="_event_slot") - - _event_id = db.Column("event_id", db.Integer, db.ForeignKey("event.id"), nullable=False) - _event = db.relationship("Event", back_populates="_slots") +######### +# Types # +######### -class EventKind(db.Model, ModelSerializeMixin): - """Model for an EventKind""" - - __tablename__ = "event_kind" +class EventType(db.Model, ModelSerializeMixin): + __tablename__ = "event_type" + id_: int = db.Column("id", db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) - _id: int = db.Column("id", db.Integer, primary_key=True) +class JobType(db.Model, ModelSerializeMixin): + __tablename__ = "job_type" + id_ = db.Column("id", db.Integer, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) -class Event(db.Model, ModelSerializeMixin): - """Model for an Event""" - - __tablename__ = "event" - id: int = db.Column(db.Integer, primary_key=True) - begin: datetime = db.Column(db.DateTime, nullable=False) - end: datetime = db.Column(db.DateTime) - description: str = db.Column(db.String(240)) - kind: EventKind = db.relationship("EventKind") - # notices = db.relationship("EventNotice", back_populates="event") - - _kind_id = db.Column("kind_id", db.Integer, db.ForeignKey("event_kind.id", ondelete="CASCADE"), nullable=False) - _slots: [EventSlot] = db.relationship("EventSlot", back_populates="_event", cascade="all, delete") +######## +# Jobs # +######## class Job(db.Model, ModelSerializeMixin): __tablename__ = "job" userid: str = None - value: float = db.Column(db.Numeric(precision=3, scale=2)) + value: float = db.Column(db.Numeric(precision=3, scale=2), nullable=False) - _id = db.Column("id", db.Integer, primary_key=True) - _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id")) - _user: User = db.relationship("User") - _slot_id = db.Column("slot_id", db.Integer, db.ForeignKey("job_slot.id")) - _slot = db.relationship("JobSlot") + id_ = db.Column("id", db.Integer, primary_key=True) + _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False) + _slot_id = db.Column("slot_id", db.Integer, db.ForeignKey("job_slot.id"), nullable=False) + + user_: User = db.relationship("User") + slot_ = db.relationship("JobSlot") @property def userid(self): return self._user.userid -class JobKind(db.Model, ModelSerializeMixin): - __tablename__ = "job_kind" - name: str = db.Column(db.String(30), nullable=False, unique=True) - - _id = db.Column("id", db.Integer, primary_key=True) - - class JobSlot(db.Model, ModelSerializeMixin): __tablename__ = "job_slot" - id: int = db.Column(db.Integer, primary_key=True) - needed_persons: float = db.Column(db.Numeric(precision=4, scale=2)) - kind: JobKind = db.relationship("JobKind") - jobs: [Job] = db.relationship("Job", back_populates="_slot") - - _event_slot_id = db.Column("event_slot_id", db.Integer, db.ForeignKey("event_slot.id")) - _event_slot = db.relationship("EventSlot", back_populates="slots") _kind_id = db.Column("kind_id", db.Integer, db.ForeignKey("job_kind.id")) + _event_slot_id = db.Column("event_slot_id", db.Integer, db.ForeignKey("event_slot.id")) + + id: int = db.Column(db.Integer, primary_key=True) + type: JobType = db.relationship("JobKind") + users: [Job] = db.relationship("Job", back_populates="_slot") + required_jobs: float = db.Column(db.Numeric(precision=4, scale=2)) + + event_slot_ = db.relationship("EventSlot", back_populates="slots") + +########## +# Events # +########## + + +class EventSlot(db.Model, ModelSerializeMixin): + """Model for an EventSlot""" + __tablename__ = "event_slot" + _event_id = db.Column("event_id", db.Integer, db.ForeignKey("event.id"), nullable=False) + + id: int = db.Column(db.Integer, primary_key=True) + start: datetime = db.Column(db.DateTime) + end: Optional[datetime] = db.Column(db.DateTime) + jobs: [JobSlot] = db.relationship("JobSlot", back_populates="_event_slot") + + event_ = db.relationship("Event", back_populates="_slots") + + +class Event(db.Model, ModelSerializeMixin): + """Model for an Event""" + __tablename__ = "event" + _kind_id = db.Column("kind_id", db.Integer, db.ForeignKey("event_kind.id", ondelete="CASCADE"), nullable=False) + + id: int = db.Column(db.Integer, primary_key=True) + begin: datetime = db.Column(db.DateTime, nullable=False) + end: datetime = db.Column(db.DateTime) + description: str = db.Column(db.String(240)) + type: EventType = db.relationship("EventKind") + slots: [EventSlot] = db.relationship("EventSlot", back_populates="_event", cascade="all, delete") From 7dec0144d96c2c1f701182406eb2fba2717c16c9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 03:41:57 +0100 Subject: [PATCH 107/446] [Plugin] schedule: Fixed models and controller --- .../plugins/schedule/event_controller.py | 61 ++++++++++++------- flaschengeist/plugins/schedule/models.py | 18 +++--- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index c8faf8b..aa086af 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -3,7 +3,46 @@ from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.database import db -from flaschengeist.plugins.schedule.models import EventKind, Event, EventSlot, JobSlot, JobKind +from flaschengeist.plugins.schedule.models import EventType, Event, EventSlot, JobSlot, JobType + + +def get_event_types(): + return EventType.query.all() + + +def get_event_type(name): + et = EventType.query.filter(EventType.name == name).one_or_none() + if not et: + raise NotFound + return et + + +def create_event_type(name): + try: + event = EventType(name=name) + db.session.add(event) + db.session.commit() + return event + except IntegrityError: + raise BadRequest("Name already exists") + + +def rename_event_type(name, new_name): + event_type = get_event_type(name) + event_type.name = new_name + try: + db.session.commit() + except IntegrityError: + raise BadRequest("Name already exists") + + +def delete_event_type(name): + event_type = get_event_type(name) + db.session.delete(event_type) + try: + db.session.commit() + except IntegrityError: + raise BadRequest("Type still in use") def get_event(id): @@ -53,26 +92,6 @@ def create_job_kind(name): raise BadRequest("Name already exists") -def create_event_kind(name): - try: - event = EventKind(name=name) - db.session.add(event) - db.session.commit() - return event - except IntegrityError: - logger.debug("IntegrityError: Looks like there is a name collision", exc_info=True) - raise BadRequest("Name already exists") - - -def rename_event_kind(id, name): - ek = EventKind.query.get(id) - if not ek: - raise NotFound - ek.name = name - try: - db.session.commit() - except IntegrityError: - raise BadRequest("Name already exists") def add_slot(event, job_slots, needed_persons, start=None, end=None): diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index a6792da..4ab1455 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -45,15 +45,15 @@ class Job(db.Model, ModelSerializeMixin): class JobSlot(db.Model, ModelSerializeMixin): __tablename__ = "job_slot" - _kind_id = db.Column("kind_id", db.Integer, db.ForeignKey("job_kind.id")) + _type_id = db.Column("type_id", db.Integer, db.ForeignKey("job_type.id")) _event_slot_id = db.Column("event_slot_id", db.Integer, db.ForeignKey("event_slot.id")) id: int = db.Column(db.Integer, primary_key=True) - type: JobType = db.relationship("JobKind") - users: [Job] = db.relationship("Job", back_populates="_slot") + type: JobType = db.relationship("JobType") + users: [Job] = db.relationship("Job", back_populates="slot_") required_jobs: float = db.Column(db.Numeric(precision=4, scale=2)) - event_slot_ = db.relationship("EventSlot", back_populates="slots") + event_slot_ = db.relationship("EventSlot", back_populates="jobs") ########## # Events # @@ -68,19 +68,19 @@ class EventSlot(db.Model, ModelSerializeMixin): id: int = db.Column(db.Integer, primary_key=True) start: datetime = db.Column(db.DateTime) end: Optional[datetime] = db.Column(db.DateTime) - jobs: [JobSlot] = db.relationship("JobSlot", back_populates="_event_slot") + jobs: [JobSlot] = db.relationship("JobSlot", back_populates="event_slot_") - event_ = db.relationship("Event", back_populates="_slots") + event_ = db.relationship("Event", back_populates="slots") class Event(db.Model, ModelSerializeMixin): """Model for an Event""" __tablename__ = "event" - _kind_id = db.Column("kind_id", db.Integer, db.ForeignKey("event_kind.id", ondelete="CASCADE"), nullable=False) + _type_id = db.Column("type_id", db.Integer, db.ForeignKey("event_type.id", ondelete="CASCADE"), nullable=False) id: int = db.Column(db.Integer, primary_key=True) begin: datetime = db.Column(db.DateTime, nullable=False) end: datetime = db.Column(db.DateTime) description: str = db.Column(db.String(240)) - type: EventType = db.relationship("EventKind") - slots: [EventSlot] = db.relationship("EventSlot", back_populates="_event", cascade="all, delete") + type: EventType = db.relationship("EventType") + slots: [EventSlot] = db.relationship("EventSlot", back_populates="event_", cascade="all, delete") From 363ec6530be7842e987566573ea39f6f605cc432 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 03:59:18 +0100 Subject: [PATCH 108/446] [Plugin] schedule: Added EventType and JobType support --- flaschengeist/plugins/schedule/__init__.py | 173 ++++++++++++++---- .../plugins/schedule/event_controller.py | 39 ++++ flaschengeist/plugins/schedule/permissions.py | 3 + 3 files changed, 182 insertions(+), 33 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index a4dcaa1..7c539c4 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -2,6 +2,8 @@ Provides duty schedule / duty roster functions """ +import http + from dateutil import parser from datetime import datetime, timedelta from flask import Blueprint, request, jsonify @@ -25,6 +27,137 @@ class SchedulePlugin(Plugin): ) +@schedule_bp.route("/event-types", methods=["GET"]) +@login_required() +def get_event_types(current_session): + """Get all EventTypes + + Route: ``/event-types`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded list of EventTypes HTTP-error + """ + types = event_controller.get_event_types() + return jsonify(types) + + +@schedule_bp.route("/event-types", methods=["POST"]) +@login_required(permission=permissions.EVENT_TYPE) +def new_event_type(current_session): + """Create a new EventType + + Route: ``/event-types`` | Method: ``POST`` + + POST-data: ``{name: string}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + HTTP-Created or HTTP-error + """ + data = request.get_json() + if "name" not in data: + raise BadRequest + event_controller.create_event_type(data["name"]) + return "", http.HTTPStatus.CREATED + + +@schedule_bp.route("/event-types/", methods=["PUT", "DELETE"]) +@login_required(permission=permissions.EVENT_TYPE) +def modify_event_type(name, current_session): + """Rename or delete an event type + + Route: ``/event-types/`` | Method: ``PUT`` or ``DELETE`` + + POST-data: (if renaming) ``{name: string}`` + + Args: + name: Name identifying the EventType + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ + if request.method == "DELETE": + event_controller.delete_event_type(name) + else: + data = request.get_json() + if "name" not in data: + raise BadRequest("Parameter missing in data") + event_controller.rename_event_type(name, data["name"]) + return "", http.HTTPStatus.NO_CONTENT + + +@schedule_bp.route("/job-types", methods=["GET"]) +@login_required() +def get_job_types(current_session): + """Get all JobTypes + + Route: ``/job-types`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded list of JobType HTTP-error + """ + types = event_controller.get_job_types() + return jsonify(types) + + +@schedule_bp.route("/job-types", methods=["POST"]) +@login_required(permission=permissions.JOB_TYPE) +def new_job_type(current_session): + """Create a new JobType + + Route: ``/job-types`` | Method: ``POST`` + + POST-data: ``{name: string}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + HTTP-Created or HTTP-error + """ + data = request.get_json() + if "name" not in data: + raise BadRequest + event_controller.create_job_type(data["name"]) + return "", http.HTTPStatus.CREATED + + +@schedule_bp.route("/job-types/", methods=["PUT", "DELETE"]) +@login_required(permission=permissions.JOB_TYPE) +def modify_job_type(name, current_session): + """Rename or delete a JobType + + Route: ``/job-types/`` | Method: ``PUT`` or ``DELETE`` + + POST-data: (if renaming) ``{name: string}`` + + Args: + name: Name identifying the JobType + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ + if request.method == "DELETE": + event_controller.delete_name_type(name) + else: + data = request.get_json() + if "name" not in data: + raise BadRequest("Parameter missing in data") + event_controller.rename_name_type(name, data["name"]) + return "", http.HTTPStatus.NO_CONTENT + + +########### TODO: Ab hier ############ @schedule_bp.route("/events/", methods=["GET"]) @login_required() def get_event(event_id, current_session): @@ -49,17 +182,21 @@ def get_event(event_id, current_session): @schedule_bp.route("/events//", methods=["GET"]) @schedule_bp.route("/events///", methods=["GET"]) @login_required() -def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, **kwargs): +def get_events(current_session, year=datetime.now().year, month=datetime.now().month, day=None): """Get Event objects for specified date (or month or year), if nothing set then events for current month are returned + Route: ``/events[//[/]]`` | Method: ``GET`` + Args: year (int, optional): year to query, defaults to current year month (int, optional): month to query (if set), defaults to current month day (int, optional): day to query events for (if set) - **kwargs: contains at least current_session (see flaschengeist.decorator) + current_session: Session sent with Authorization Header + Returns: - JSON list containing events found + JSON encoded list containing events found or HTTP-error + Raises: BadRequest: If date is invalid """ @@ -80,26 +217,6 @@ def __get_events(year=datetime.now().year, month=datetime.now().month, day=None, raise BadRequest("Invalid date given") -@schedule_bp.route("/event-types", methods=["POST"]) -@login_required(permission=permissions.EVENT_TYPE) -def __new_event_kind(**kwargs): - data = request.get_json() - if "name" not in data: - raise BadRequest - kind = event_controller.create_event_kind(data["name"]) - return jsonify({"ok": "ok", "id": kind.id}) - - -@schedule_bp.route("/slotKinds", methods=["POST"]) -@login_required(permission=permissions.EVENT_TYPE) -def __new_slot_kind(**kwargs): - data = request.get_json() - if not data or "name" not in data: - raise BadRequest - kind = event_controller.create_job_kind(data["name"]) - return jsonify({"ok": "ok", "id": kind.id}) - - @schedule_bp.route("/events", methods=["POST"]) @login_required(permission=permissions.CREATE) def __new_event(**kwargs): @@ -122,16 +239,6 @@ def __delete_event(event_id, **kwargs): return jsonify({"ok": "ok"}) -@schedule_bp.route("/eventKinds/", methods=["PUT"]) -@login_required(permission=permissions.EVENT_TYPE) -def __edit_event_kind(event_id, **kwargs): - data = request.get_json() - if not data or "name" not in data: - raise BadRequest - event_controller.rename_event_kind(event_id, data["name"]) - return jsonify({"ok": "ok"}) - - @schedule_bp.route("/events//slots", methods=["GET"]) @login_required() def __get_slots(event_id, **kwargs): diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index aa086af..8436285 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -45,6 +45,45 @@ def delete_event_type(name): raise BadRequest("Type still in use") +def get_job_types(): + return JobType.query.all() + + +def get_job_type(name): + job_type = JobType.query.filter(JobType.name == name).one_or_none() + if not job_type: + raise NotFound + return job_type + + +def create_job_type(name): + try: + job_type = JobType(name=name) + db.session.add(job_type) + db.session.commit() + return job_type + except IntegrityError: + raise BadRequest("Name already exists") + + +def rename_job_type(name, new_name): + job_type = get_job_type(name) + job_type.name = new_name + try: + db.session.commit() + except IntegrityError: + raise BadRequest("Name already exists") + + +def delete_job_type(name): + job_type = get_job_type(name) + db.session.delete(job_type) + try: + db.session.commit() + except IntegrityError: + raise BadRequest("Type still in use") + + def get_event(id): return Event.query.get(id) diff --git a/flaschengeist/plugins/schedule/permissions.py b/flaschengeist/plugins/schedule/permissions.py index b73873a..f889793 100644 --- a/flaschengeist/plugins/schedule/permissions.py +++ b/flaschengeist/plugins/schedule/permissions.py @@ -10,6 +10,9 @@ DELETE = "schedule_delete" EVENT_TYPE = "schedule_event_type" """Can create and edit EventTypes""" +JOB_TYPE = "schedule_job_type" +"""Can create and edit JobTypes""" + ASSIGN = "schedule_assign" """Can self assign to jobs""" From 4da4c1ee01d676049c1af866763dd761fd0735ea Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 06:30:30 +0100 Subject: [PATCH 109/446] [Plugin] schedule: Working Event, EventSlot, EventType and JobType --- flaschengeist/plugins/schedule/__init__.py | 238 ++++++++++++------ .../plugins/schedule/event_controller.py | 70 +++--- flaschengeist/plugins/schedule/models.py | 19 +- setup.py | 1 - 4 files changed, 210 insertions(+), 118 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 7c539c4..fb5e4a5 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -2,24 +2,25 @@ Provides duty schedule / duty roster functions """ -import http - -from dateutil import parser -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from http.client import NO_CONTENT, CREATED from flask import Blueprint, request, jsonify from werkzeug.exceptions import BadRequest, NotFound +from backports.datetime_fromisoformat import MonkeyPatch -from flaschengeist.database import db from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required from . import event_controller, permissions -from .models import EventType +from . import models +MonkeyPatch.patch_fromisoformat() schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") class SchedulePlugin(Plugin): + models = models + def __init__(self, config): super().__init__( blueprint=schedule_bp, @@ -63,7 +64,7 @@ def new_event_type(current_session): if "name" not in data: raise BadRequest event_controller.create_event_type(data["name"]) - return "", http.HTTPStatus.CREATED + return "", CREATED @schedule_bp.route("/event-types/", methods=["PUT", "DELETE"]) @@ -89,7 +90,7 @@ def modify_event_type(name, current_session): if "name" not in data: raise BadRequest("Parameter missing in data") event_controller.rename_event_type(name, data["name"]) - return "", http.HTTPStatus.NO_CONTENT + return "", NO_CONTENT @schedule_bp.route("/job-types", methods=["GET"]) @@ -128,7 +129,7 @@ def new_job_type(current_session): if "name" not in data: raise BadRequest event_controller.create_job_type(data["name"]) - return "", http.HTTPStatus.CREATED + return "", CREATED @schedule_bp.route("/job-types/", methods=["PUT", "DELETE"]) @@ -154,10 +155,9 @@ def modify_job_type(name, current_session): if "name" not in data: raise BadRequest("Parameter missing in data") event_controller.rename_name_type(name, data["name"]) - return "", http.HTTPStatus.NO_CONTENT + return "", NO_CONTENT -########### TODO: Ab hier ############ @schedule_bp.route("/events/", methods=["GET"]) @login_required() def get_event(event_id, current_session): @@ -173,8 +173,6 @@ def get_event(event_id, current_session): JSON encoded event object """ event = event_controller.get_event(event_id) - if not event: - raise NotFound return jsonify(event) @@ -196,9 +194,6 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m Returns: JSON encoded list containing events found or HTTP-error - - Raises: - BadRequest: If date is invalid """ try: begin = datetime(year=year, month=month, day=1) @@ -217,88 +212,169 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m raise BadRequest("Invalid date given") +def _event_slot_from_data(data): + try: + start = datetime.fromisoformat(data["start"]).replace(tzinfo=timezone.utc) + end = datetime.fromisoformat(data["end"]).replace(tzinfo=timezone.utc) if "end" in data else None + # jobs = ... + except (NotFound, KeyError, ValueError): + raise BadRequest("Missing POST parameter") + return {"start": start, "end": end} + + @schedule_bp.route("/events", methods=["POST"]) @login_required(permission=permissions.CREATE) -def __new_event(**kwargs): +def create_event(current_session): + """Create an new event + + Route: ``/events`` | Method: ``POST`` + + POST-data: See interfaces for Event, can already contain slots + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + data = request.get_json() + try: + start = datetime.fromisoformat(data["start"]).replace(tzinfo=timezone.utc) + event_type = event_controller.get_event_type(data["type"]) + except (NotFound, KeyError, ValueError): + raise BadRequest("Missing POST parameter") + data = request.get_json() event = event_controller.create_event( - begin=parser.isoparse(data["begin"]), - end=parser.isoparse(data["end"]), - description=data["description"], - type=EventType.query.get(data["kind"]), + start=start, event_type=event_type, description=data["description"] if "description" in data else None ) - return jsonify({"ok": "ok", "id": event.id}) + if "slots" in data: + for slot in data["slots"]: + event_controller.add_event_slot(event, **_event_slot_from_data(slot)) + return jsonify(event) + + +@schedule_bp.route("/events/", methods=["PUT"]) +@login_required(permission=permissions.EDIT) +def modify_event(event_id, current_session): + """Modify an event + + Route: ``/events/`` | Method: ``PUT`` + + POST-data: See interfaces for Event, can already contain slots + + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + data = request.get_json() + if "start" in data: + event.start = datetime.fromisoformat(data["start"]).replace(tzinfo=timezone.utc) + if "description" in data: + event.description = data["description"] + if "type" in data: + event_type = event_controller.get_event_type(data["type"]) + event.type = event_type + event_controller.update() + return jsonify(event) @schedule_bp.route("/events/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) -def __delete_event(event_id, **kwargs): - if not event_controller.delete_event(event_id): - raise NotFound - db.session.commit() - return jsonify({"ok": "ok"}) +def delete_event(event_id, current_session): + """Delete an event + Route: ``/events/`` | Method: ``DELETE`` -@schedule_bp.route("/events//slots", methods=["GET"]) -@login_required() -def __get_slots(event_id, **kwargs): - event = event_controller.get_event(event_id) - if not event: - raise NotFound - return jsonify({event.slots}) + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header - -@schedule_bp.route("/events//slots/", methods=["GET"]) -@login_required() -def __get_slot(event_id, slot_id, **kwargs): - slot = event_controller.get_event_slot(slot_id, event_id) - if slot: - return jsonify(slot) - raise NotFound - - -@schedule_bp.route("/events//slots/", methods=["DELETE"]) -@login_required(permission=permissions.DELETE) -def __delete_slot(event_id, slot_id, **kwargs): - if event_controller.delete_event_slot(slot_id, event_id): - return jsonify({"ok": "ok"}) - raise NotFound - - -@schedule_bp.route("/events//slots/", methods=["PUT"]) -@login_required(permission=permissions.EDIT) -def __update_slot(event_id, slot_id, **kwargs): - data = request.get_json() - if not data: - raise BadRequest - - for job in data["jobs"]: - event_controller.add_job(job.kind, job.user) - if event_controller.delete_event_slot(slot_id, event_id): - return jsonify({"ok": "ok"}) - raise NotFound + Returns: + HTTP-NoContent or HTTP-error + """ + event_controller.delete_event(event_id) + return "", NO_CONTENT @schedule_bp.route("/events//slots", methods=["POST"]) @login_required(permission=permissions.EDIT) -def __add_slot(event_id, **kwargs): +def add_event_slot(event_id, current_session): + """Add an new EventSlot to an Event + + Route: ``/events//slots`` | Method: ``POST`` + + POST-data: See TS interface for EventSlot + + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ event = event_controller.get_event(event_id) - if not event: - raise NotFound data = request.get_json() - attr = {"job_slots": []} - try: - if "start" in data: - attr["start"] = parser.isoparse(data["start"]) - if "end" in data: - attr["end"] = parser.isoparse(data["end"]) - for job in data["jobs"]: - attr["job_slots"].append({"needed_persons": job["needed_persons"], "kind": job["kind"]}) - except KeyError: - raise BadRequest("Missing data in request") - event_controller.add_slot(event, **attr) - return jsonify({"ok": "ok"}) + if not data: + raise BadRequest("Missing POST parameters") + + event_controller.add_event_slot(event, **_event_slot_from_data(data)) + return jsonify(event) -def __edit_event(): - ... +@schedule_bp.route("/events//slots/", methods=["PUT"]) +@login_required(permission=permissions.EDIT) +def update_event_slot(event_id, slot_id, current_session): + """Update an EventSlot + + Route: ``/events//slots/`` | Method: ``PUT`` + + POST-data: See TS interface for EventSlot + + Args: + event_id: Identifier of the event + slot_id: Identifier of the slot + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + slot = event_controller.get_event_slot(slot_id) + if slot not in event.slots: + raise NotFound + + data = _event_slot_from_data(request.get_json()) + slot.start = data["start"] + if "end" in data: + slot.end = data["end"] + event_controller.update() + return jsonify(event) + + +@schedule_bp.route("/events//slots/", methods=["DELETE"]) +@login_required(permission=permissions.EDIT) +def delete_event_slot(event_id, slot_id, current_session): + """Delete an EventSlot + + Route: ``/events//slots/`` | Method: ``DELETE`` + + Args: + event_id: Identifier of the event + slot_id: Identifier of the slot + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + event_controller.remove_event_slot(event, slot_id) + return jsonify(event) + + +# TODO: JobSlot, Job! +# TODO: JobTransfer diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index 8436285..2ae486c 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -6,6 +6,10 @@ from flaschengeist.database import db from flaschengeist.plugins.schedule.models import EventType, Event, EventSlot, JobSlot, JobType +def update(): + db.session.commit() + + def get_event_types(): return EventType.query.all() @@ -84,34 +88,41 @@ def delete_job_type(name): raise BadRequest("Type still in use") -def get_event(id): - return Event.query.get(id) +def get_event(event_id): + event = Event.query.get(event_id) + if event is None: + raise NotFound + return event -def get_events(begin, end): +def get_events(start, end): """Query events which start from begin until end Args: - begin (datetime): Earliest start + start (datetime): Earliest start end (datetime): Latest start Returns: collection of Event objects """ - return Event.query.filter((begin <= Event.begin), (Event.begin < end)) + return Event.query.filter((start <= Event.start), (Event.start < end)).all() -def delete_event(id): +def delete_event(event_id): """Delete event with given ID Args: - id: id of Event to delete + event_id: id of Event to delete - Returns: True if successful, False if Event is not found + Raises: + NotFound if not found """ - return Event.query.filter(Event.id == id).delete() == 1 + event = get_event(event_id) + db.session.delete(event) + db.session.commit() -def create_event(begin, kind, end=None, description=None): +def create_event(event_type, start, slots=[], description=None): try: - event = Event(begin=begin, end=end, description=description, kind=kind) + logger.debug(event_type) + event = Event(start=start, description=description, type=event_type, slots=slots) db.session.add(event) db.session.commit() return event @@ -120,24 +131,25 @@ def create_event(begin, kind, end=None, description=None): raise BadRequest -def create_job_kind(name): - try: - kind = JobKind(name=name) - db.session.add(kind) - db.session.commit() - return kind - except IntegrityError: - logger.debug("IntegrityError: Looks like there is a name collision", exc_info=True) - raise BadRequest("Name already exists") +def get_event_slot(slot_id): + slot = EventSlot.query.get(slot_id) + if slot is None: + raise NotFound + return slot - - -def add_slot(event, job_slots, needed_persons, start=None, end=None): - event_slot = EventSlot(start=start, end=end) - for slot in job_slots: - kind = JobKind.query.get(slot.id) - job_slot = JobSlot(kind=kind, needed_persons=slot.needed_persons) - event_slot.add_slot(job_slot) - event.add_slot(event_slot) +def add_event_slot(event, start, end=None): + event_slot = EventSlot(start=start, end=end, event_=event) + if start < event.start: + raise BadRequest("Start before event start") + db.session.add(event_slot) + db.session.commit() + + +def remove_event_slot(event, slot_id): + slot = get_event_slot(slot_id) + if slot in event.slots: + event.slots.remove(slot) + else: + raise NotFound db.session.commit() diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index 4ab1455..8122087 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional -from flaschengeist.models import ModelSerializeMixin +from flaschengeist.models import ModelSerializeMixin, UtcDateTime from flaschengeist.models.user import User from flaschengeist.database import db @@ -21,6 +21,7 @@ class JobType(db.Model, ModelSerializeMixin): id_ = db.Column("id", db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) + ######## # Jobs # ######## @@ -55,6 +56,7 @@ class JobSlot(db.Model, ModelSerializeMixin): event_slot_ = db.relationship("EventSlot", back_populates="jobs") + ########## # Events # ########## @@ -62,12 +64,13 @@ class JobSlot(db.Model, ModelSerializeMixin): class EventSlot(db.Model, ModelSerializeMixin): """Model for an EventSlot""" + __tablename__ = "event_slot" _event_id = db.Column("event_id", db.Integer, db.ForeignKey("event.id"), nullable=False) id: int = db.Column(db.Integer, primary_key=True) - start: datetime = db.Column(db.DateTime) - end: Optional[datetime] = db.Column(db.DateTime) + start: datetime = db.Column(UtcDateTime) + end: Optional[datetime] = db.Column(UtcDateTime) jobs: [JobSlot] = db.relationship("JobSlot", back_populates="event_slot_") event_ = db.relationship("Event", back_populates="slots") @@ -75,12 +78,14 @@ class EventSlot(db.Model, ModelSerializeMixin): class Event(db.Model, ModelSerializeMixin): """Model for an Event""" + __tablename__ = "event" _type_id = db.Column("type_id", db.Integer, db.ForeignKey("event_type.id", ondelete="CASCADE"), nullable=False) id: int = db.Column(db.Integer, primary_key=True) - begin: datetime = db.Column(db.DateTime, nullable=False) - end: datetime = db.Column(db.DateTime) - description: str = db.Column(db.String(240)) + start: datetime = db.Column(UtcDateTime, nullable=False) + description: Optional[str] = db.Column(db.String(240)) type: EventType = db.relationship("EventType") - slots: [EventSlot] = db.relationship("EventSlot", back_populates="event_", cascade="all, delete") + slots: [EventSlot] = db.relationship( + "EventSlot", back_populates="event_", cascade="all,delete,delete-orphan", order_by="EventSlot.start" + ) diff --git a/setup.py b/setup.py index c6c3874..527dca7 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,6 @@ setup( "flask_sqlalchemy", "flask_cors", "werkzeug", - "python-dateutil", # Needed for python < 3.7 "backports-datetime-fromisoformat", ], From ac1189ecaa9c78101ebd8fb33616432b3768e4ad Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 13:45:10 +0100 Subject: [PATCH 110/446] [Plugin] schedule: Use utils to parse datetime --- flaschengeist/plugins/schedule/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index fb5e4a5..169d836 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -6,15 +6,14 @@ from datetime import datetime, timedelta, timezone from http.client import NO_CONTENT, CREATED from flask import Blueprint, request, jsonify from werkzeug.exceptions import BadRequest, NotFound -from backports.datetime_fromisoformat import MonkeyPatch from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required +from flaschengeist.utils.datetime import from_iso_format from . import event_controller, permissions from . import models -MonkeyPatch.patch_fromisoformat() schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") @@ -214,8 +213,8 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m def _event_slot_from_data(data): try: - start = datetime.fromisoformat(data["start"]).replace(tzinfo=timezone.utc) - end = datetime.fromisoformat(data["end"]).replace(tzinfo=timezone.utc) if "end" in data else None + start = from_iso_format(data["start"]) + end = from_iso_format(data["end"]) if "end" in data else None # jobs = ... except (NotFound, KeyError, ValueError): raise BadRequest("Missing POST parameter") @@ -239,7 +238,7 @@ def create_event(current_session): """ data = request.get_json() try: - start = datetime.fromisoformat(data["start"]).replace(tzinfo=timezone.utc) + start = from_iso_format(data["start"]) event_type = event_controller.get_event_type(data["type"]) except (NotFound, KeyError, ValueError): raise BadRequest("Missing POST parameter") @@ -273,7 +272,7 @@ def modify_event(event_id, current_session): event = event_controller.get_event(event_id) data = request.get_json() if "start" in data: - event.start = datetime.fromisoformat(data["start"]).replace(tzinfo=timezone.utc) + event.start = from_iso_format(data["start"]) if "description" in data: event.description = data["description"] if "type" in data: From 5a8a4aa23dd67ead29af7e199ce3425a2b174c36 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 15:44:43 +0100 Subject: [PATCH 111/446] [Plugin] schedule: Basics work (all models) --- flaschengeist/plugins/schedule/__init__.py | 157 +++++++++++++++--- .../plugins/schedule/event_controller.py | 37 ++++- flaschengeist/plugins/schedule/models.py | 25 +-- 3 files changed, 178 insertions(+), 41 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 169d836..38cb80a 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -5,11 +5,13 @@ Provides duty schedule / duty roster functions from datetime import datetime, timedelta, timezone from http.client import NO_CONTENT, CREATED from flask import Blueprint, request, jsonify -from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.exceptions import BadRequest, NotFound, Forbidden from flaschengeist.plugins import Plugin +from flaschengeist.models.session import Session from flaschengeist.decorator import login_required from flaschengeist.utils.datetime import from_iso_format +from flaschengeist.controller import userController from . import event_controller, permissions from . import models @@ -32,7 +34,7 @@ class SchedulePlugin(Plugin): def get_event_types(current_session): """Get all EventTypes - Route: ``/event-types`` | Method: ``GET`` + Route: ``/schedule/event-types`` | Method: ``GET`` Args: current_session: Session sent with Authorization Header @@ -49,7 +51,7 @@ def get_event_types(current_session): def new_event_type(current_session): """Create a new EventType - Route: ``/event-types`` | Method: ``POST`` + Route: ``/schedule/event-types`` | Method: ``POST`` POST-data: ``{name: string}`` @@ -71,7 +73,7 @@ def new_event_type(current_session): def modify_event_type(name, current_session): """Rename or delete an event type - Route: ``/event-types/`` | Method: ``PUT`` or ``DELETE`` + Route: ``/schedule/event-types/`` | Method: ``PUT`` or ``DELETE`` POST-data: (if renaming) ``{name: string}`` @@ -97,7 +99,7 @@ def modify_event_type(name, current_session): def get_job_types(current_session): """Get all JobTypes - Route: ``/job-types`` | Method: ``GET`` + Route: ``/schedule/job-types`` | Method: ``GET`` Args: current_session: Session sent with Authorization Header @@ -114,7 +116,7 @@ def get_job_types(current_session): def new_job_type(current_session): """Create a new JobType - Route: ``/job-types`` | Method: ``POST`` + Route: ``/schedule/job-types`` | Method: ``POST`` POST-data: ``{name: string}`` @@ -122,38 +124,38 @@ def new_job_type(current_session): current_session: Session sent with Authorization Header Returns: - HTTP-Created or HTTP-error + JSON encoded JobType or HTTP-error """ data = request.get_json() if "name" not in data: raise BadRequest - event_controller.create_job_type(data["name"]) - return "", CREATED + jt = event_controller.create_job_type(data["name"]) + return jsonify(jt) -@schedule_bp.route("/job-types/", methods=["PUT", "DELETE"]) +@schedule_bp.route("/job-types/", methods=["PUT", "DELETE"]) @login_required(permission=permissions.JOB_TYPE) -def modify_job_type(name, current_session): +def modify_job_type(type_id, current_session): """Rename or delete a JobType - Route: ``/job-types/`` | Method: ``PUT`` or ``DELETE`` + Route: ``/schedule/job-types/`` | Method: ``PUT`` or ``DELETE`` POST-data: (if renaming) ``{name: string}`` Args: - name: Name identifying the JobType + type_id: Identifier of the JobType current_session: Session sent with Authorization Header Returns: HTTP-NoContent or HTTP-error """ if request.method == "DELETE": - event_controller.delete_name_type(name) + event_controller.delete_job_type(type_id) else: data = request.get_json() if "name" not in data: raise BadRequest("Parameter missing in data") - event_controller.rename_name_type(name, data["name"]) + event_controller.rename_job_type(type_id, data["name"]) return "", NO_CONTENT @@ -162,7 +164,7 @@ def modify_job_type(name, current_session): def get_event(event_id, current_session): """Get event by id - Route: ``/events/`` | Method: ``GET`` + Route: ``/schedule/events/`` | Method: ``GET`` Args: event_id: ID identifying the event @@ -183,7 +185,7 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m """Get Event objects for specified date (or month or year), if nothing set then events for current month are returned - Route: ``/events[//[/]]`` | Method: ``GET`` + Route: ``/schedule/events[//[/]]`` | Method: ``GET`` Args: year (int, optional): year to query, defaults to current year @@ -195,15 +197,15 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m JSON encoded list containing events found or HTTP-error """ try: - begin = datetime(year=year, month=month, day=1) + begin = datetime(year=year, month=month, day=1, tzinfo=timezone.utc) if day: begin += timedelta(days=day - 1) end = begin + timedelta(days=1) else: if month == 12: - end = datetime(year=year + 1, month=1, day=1) + end = datetime(year=year + 1, month=1, day=1, tzinfo=timezone.utc) else: - end = datetime(year=year, month=month + 1, day=1) + end = datetime(year=year, month=month + 1, day=1, tzinfo=timezone.utc) events = event_controller.get_events(begin, end) return jsonify(events) @@ -226,7 +228,7 @@ def _event_slot_from_data(data): def create_event(current_session): """Create an new event - Route: ``/events`` | Method: ``POST`` + Route: ``/schedule/events`` | Method: ``POST`` POST-data: See interfaces for Event, can already contain slots @@ -258,7 +260,7 @@ def create_event(current_session): def modify_event(event_id, current_session): """Modify an event - Route: ``/events/`` | Method: ``PUT`` + Route: ``/schedule/events/`` | Method: ``PUT`` POST-data: See interfaces for Event, can already contain slots @@ -287,7 +289,7 @@ def modify_event(event_id, current_session): def delete_event(event_id, current_session): """Delete an event - Route: ``/events/`` | Method: ``DELETE`` + Route: ``/schedule/events/`` | Method: ``DELETE`` Args: event_id: Identifier of the event @@ -305,7 +307,7 @@ def delete_event(event_id, current_session): def add_event_slot(event_id, current_session): """Add an new EventSlot to an Event - Route: ``/events//slots`` | Method: ``POST`` + Route: ``/schedule/events//slots`` | Method: ``POST`` POST-data: See TS interface for EventSlot @@ -330,7 +332,7 @@ def add_event_slot(event_id, current_session): def update_event_slot(event_id, slot_id, current_session): """Update an EventSlot - Route: ``/events//slots/`` | Method: ``PUT`` + Route: ``/schedule/events//slots/`` | Method: ``PUT`` POST-data: See TS interface for EventSlot @@ -360,7 +362,7 @@ def update_event_slot(event_id, slot_id, current_session): def delete_event_slot(event_id, slot_id, current_session): """Delete an EventSlot - Route: ``/events//slots/`` | Method: ``DELETE`` + Route: ``/schedule/events//slots/`` | Method: ``DELETE`` Args: event_id: Identifier of the event @@ -375,5 +377,106 @@ def delete_event_slot(event_id, slot_id, current_session): return jsonify(event) -# TODO: JobSlot, Job! +@schedule_bp.route("/events//slots//jobs", methods=["POST"]) +@login_required(permission=permissions.EDIT) +def add_job_slot(event_id, slot_id, current_session): + """Add an new JobSlot to an Event / EventSlot + + Route: ``/schedule/events//slots//jobs`` | Method: ``POST`` + + POST-data: ``{type: string, required_jobs: number}`` + + Args: + event_id: Identifier of the event + slot_id: Identifier of the slot + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + slot = event_controller.get_event_slot(slot_id) + if slot not in event.slots: + raise NotFound + + data = request.get_json() + try: + job_type = event_controller.get_job_type(data["type"]) + required_jobs = data["required_jobs"] + except (KeyError, ValueError): + raise BadRequest("Missing POST parameters") + + event_controller.add_job_slot(slot, job_type, required_jobs) + return jsonify(event) + + +@schedule_bp.route("/events//slots//jobs/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE) +def delete_job_slot(event_id, slot_id, job_type, current_session): + """Delete a JobSlot + + Route: ``/schedule/events//slots//jobs/`` | Method: ``DELETE`` + + Args: + event_id: Identifier of the event + slot_id: Identifier of the EventSlot + job_type: Identifier of the JobSlot + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + job_slot = event_controller.get_job_slot(slot_id, job_type) + event_controller.delete_job_slot(job_slot) + return jsonify(event) + + +@schedule_bp.route("/events//slots//jobs/", methods=["PUT"]) +@login_required() +def update_job_slot(event_id, slot_id, job_type, current_session: Session): + """Edit JobSlot or add user to the Slot + + Route: ``/schedule/events//slots//jobs/`` | Method: ``PUT`` + + POST-data: See TS interface for EventSlot or ``{user: {userid: string, value: number}}`` + + Args: + event_id: Identifier of the event + slot_id: Identifier of the slot + job_type: Identifier of the JobSlot + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + slot = event_controller.get_job_slot(slot_id, job_type) + + data = request.get_json() + if not data: + raise BadRequest + + if ("user" not in data or len(data) > 1) and not current_session._user.has_permission(permissions.EDIT): + raise Forbidden + + if "user" in data: + try: + user = userController.get_user(data["user"]["userid"]) + value = data["user"]["value"] + if (user == current_session._user and not user.has_permission(permissions.ASSIGN)) or (user != current_session._user and not current_session._user.has_permission(permissions.ASSIGN_OTHER)): + raise Forbidden + event_controller.assign_job(slot, user, value) + except (KeyError, ValueError): + raise BadRequest + + if "required_jobs" in data: + slot.required_jobs = data["required_jobs"] + if "type" in data: + slot.type = event_controller.get_job_type(data["type"]) + event_controller.update() + + return jsonify(event) + + # TODO: JobTransfer diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index 2ae486c..0124ab5 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -3,7 +3,7 @@ from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.database import db -from flaschengeist.plugins.schedule.models import EventType, Event, EventSlot, JobSlot, JobType +from flaschengeist.plugins.schedule.models import EventType, Event, EventSlot, JobSlot, JobType, Job def update(): @@ -53,8 +53,8 @@ def get_job_types(): return JobType.query.all() -def get_job_type(name): - job_type = JobType.query.filter(JobType.name == name).one_or_none() +def get_job_type(type_id): + job_type = JobType.query.get(type_id) if not job_type: raise NotFound return job_type @@ -153,3 +153,34 @@ def remove_event_slot(event, slot_id): else: raise NotFound db.session.commit() + + +def get_job_slot(event_slot_id, job_type): + jt = JobSlot.query.filter(JobSlot._type_id == job_type).filter(JobSlot._event_slot_id == event_slot_id).one_or_none() + if jt is None: + raise NotFound + return jt + + +def add_job_slot(event_slot, job_type, required_jobs): + job_slot = JobSlot(type=job_type, required_jobs=required_jobs, event_slot_=event_slot) + try: + db.session.add(job_slot) + db.session.commit() + except IntegrityError: + raise BadRequest("JobSlot with that type already exists on this EventSlot") + + +def delete_job_slot(job_slot): + db.session.delete(job_slot) + db.session.commit() + + +def assign_job(job_slot, user, value): + job = Job.query.get((job_slot.id_, user._id)) + if job: + job.value = value + else: + job = Job(user_=user, value=value, slot_=job_slot) + db.session.add(job) + db.session.commit() diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index 8122087..e015243 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -1,6 +1,8 @@ from datetime import datetime from typing import Optional +from sqlalchemy import UniqueConstraint + from flaschengeist.models import ModelSerializeMixin, UtcDateTime from flaschengeist.models.user import User from flaschengeist.database import db @@ -18,7 +20,7 @@ class EventType(db.Model, ModelSerializeMixin): class JobType(db.Model, ModelSerializeMixin): __tablename__ = "job_type" - id_ = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column("id", db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) @@ -29,33 +31,34 @@ class JobType(db.Model, ModelSerializeMixin): class Job(db.Model, ModelSerializeMixin): __tablename__ = "job" - userid: str = None - value: float = db.Column(db.Numeric(precision=3, scale=2), nullable=False) + userid: str = "" + value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) - id_ = db.Column("id", db.Integer, primary_key=True) - _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False) - _slot_id = db.Column("slot_id", db.Integer, db.ForeignKey("job_slot.id"), nullable=False) + _slot_id = db.Column("slot_id", db.Integer, db.ForeignKey("job_slot.id"), nullable=False, primary_key=True) + _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True) user_: User = db.relationship("User") slot_ = db.relationship("JobSlot") @property def userid(self): - return self._user.userid + return self.user_.userid class JobSlot(db.Model, ModelSerializeMixin): __tablename__ = "job_slot" - _type_id = db.Column("type_id", db.Integer, db.ForeignKey("job_type.id")) - _event_slot_id = db.Column("event_slot_id", db.Integer, db.ForeignKey("event_slot.id")) + _type_id = db.Column("type_id", db.Integer, db.ForeignKey("job_type.id"), nullable=False) + _event_slot_id = db.Column("event_slot_id", db.Integer, db.ForeignKey("event_slot.id"), nullable=False) - id: int = db.Column(db.Integer, primary_key=True) + id_: int = db.Column("id", db.Integer, primary_key=True) type: JobType = db.relationship("JobType") users: [Job] = db.relationship("Job", back_populates="slot_") - required_jobs: float = db.Column(db.Numeric(precision=4, scale=2)) + required_jobs: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False)) event_slot_ = db.relationship("EventSlot", back_populates="jobs") + __table_args__ = (UniqueConstraint('type_id', 'event_slot_id', name='_type_event_slot_uc'),) + ########## # Events # From d8100b129e40a6319fa0264628b74c4315c5ecac Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 16:30:10 +0100 Subject: [PATCH 112/446] [Plugin] schedule: Allow creating JobSlots from POST --- flaschengeist/plugins/schedule/__init__.py | 19 ++++++++++++------- .../plugins/schedule/event_controller.py | 1 + 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 38cb80a..4b2a4de 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -213,14 +213,19 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m raise BadRequest("Invalid date given") -def _event_slot_from_data(data): +def _add_event_slot(event, data): + end = None try: start = from_iso_format(data["start"]) - end = from_iso_format(data["end"]) if "end" in data else None - # jobs = ... - except (NotFound, KeyError, ValueError): + except (KeyError, ValueError): raise BadRequest("Missing POST parameter") - return {"start": start, "end": end} + if "end" in data: + end = from_iso_format(data["end"]) + event_slot = event_controller.add_event_slot(event, start, end) + if "jobs" in data: + for job_data in data["jobs"]: + job_type = event_controller.get_job_type(job_data["type"]) + event_controller.add_job_slot(event_slot, job_type, job_data["required_jobs"]) @schedule_bp.route("/events", methods=["POST"]) @@ -251,7 +256,7 @@ def create_event(current_session): ) if "slots" in data: for slot in data["slots"]: - event_controller.add_event_slot(event, **_event_slot_from_data(slot)) + _add_event_slot(event, slot) return jsonify(event) @@ -323,7 +328,7 @@ def add_event_slot(event_id, current_session): if not data: raise BadRequest("Missing POST parameters") - event_controller.add_event_slot(event, **_event_slot_from_data(data)) + _add_event_slot(event, data) return jsonify(event) diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index 0124ab5..8968049 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -144,6 +144,7 @@ def add_event_slot(event, start, end=None): raise BadRequest("Start before event start") db.session.add(event_slot) db.session.commit() + return event_slot def remove_event_slot(event, slot_id): From 03aefbb35a824522aedd6e5f2ef35956686a4186 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2020 17:12:59 +0100 Subject: [PATCH 113/446] [Plugin] balance: Allow debiting from own account if DEBIT_OWN is set --- flaschengeist/plugins/balance/__init__.py | 10 +++++--- .../plugins/balance/balance_controller.py | 5 ++++ flaschengeist/plugins/balance/permissions.py | 25 ++++++++++++------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 295342f..862cdad 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -167,9 +167,13 @@ def change_balance(userid, current_session: Session): balance_controller.send(sender, user, data["amount"], current_session._user) return "", NO_CONTENT - elif (amount < 0 and current_session._user.has_permission(permissions.SUB)) or ( - amount > 0 and current_session._user.has_permission(permissions.ADD) - ): + elif ( + amount < 0 + and ( + (user == current_session._user and user.has_permission(permissions.DEBIT_OWN)) + or current_session._user.has_permission(permissions.DEBIT) + ) + ) or (amount > 0 and current_session._user.has_permission(permissions.CREDIT)): balance_controller.change_balance(user, data["amount"], current_session._user) return "", NO_CONTENT diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index a36bd91..596f9ce 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -1,3 +1,8 @@ +# German: Soll -> Abgang vom Konto +# Haben -> Zugang aufs Konto +# English: Debit -> from account +# Credit -> to account + from sqlalchemy import func from datetime import datetime, timezone from werkzeug.exceptions import BadRequest diff --git a/flaschengeist/plugins/balance/permissions.py b/flaschengeist/plugins/balance/permissions.py index 43a13ee..713d204 100644 --- a/flaschengeist/plugins/balance/permissions.py +++ b/flaschengeist/plugins/balance/permissions.py @@ -1,17 +1,24 @@ -# Show own and others balance SHOW = "balance_show" +"""Show own balance""" SHOW_OTHER = "balance_show_others" -# Credit balance -ADD = "balance_add" -# Debit balance -SUB = "balance_sub" -# Send from to other +"""Show others balance""" + +CREDIT = "balance_credit" +"""Credit balances (give)""" + +DEBIT = "balance_debit" +"""Debit balances (take)""" +DEBIT_OWN = "balance_debit_own" +"""Debit own balance""" + SEND = "balance_send" -# Send from other to another +"""Send from to other""" SEND_OTHER = "balance_send_others" -# Can set limit for users +"""Send from other to another""" + SET_LIMIT = "balance_set_limit" -# Allow sending / sub while exceeding the set limit +"""Can set limit for users""" EXCEED_LIMIT = "balance_exceed_limit" +"""Allow sending / sub while exceeding the set limit""" permissions = [value for key, value in globals().items() if not key.startswith("_")] From 7aba295e459b622f82aa769df54c47162dd42629 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 5 Nov 2020 03:57:55 +0100 Subject: [PATCH 114/446] Fixed members of Session for frontend usage --- flaschengeist/models/session.py | 5 +++ flaschengeist/plugins/schedule/__init__.py | 38 ++++++++++--------- .../plugins/schedule/event_controller.py | 4 +- flaschengeist/plugins/schedule/models.py | 2 +- flaschengeist/utils/datetime.py | 3 +- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index 05da44c..df95b85 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -22,11 +22,16 @@ class Session(db.Model, ModelSerializeMixin): lifetime: int = db.Column(db.Integer) browser: str = db.Column(db.String(30)) platform: str = db.Column(db.String(30)) + userid: str = "" _id = db.Column("id", db.Integer, primary_key=True) _user: User = db.relationship("User", back_populates="_sessions") _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id")) + @property + def userid(self): + return self._user.userid + def refresh(self): """Update the Timestamp diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 4b2a4de..f0d27df 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -367,16 +367,16 @@ def update_event_slot(event_id, slot_id, current_session): def delete_event_slot(event_id, slot_id, current_session): """Delete an EventSlot - Route: ``/schedule/events//slots/`` | Method: ``DELETE`` + Route: ``/schedule/events//slots/`` | Method: ``DELETE`` - Args: - event_id: Identifier of the event - slot_id: Identifier of the slot - current_session: Session sent with Authorization Header + Args: + event_id: Identifier of the event + slot_id: Identifier of the slot + current_session: Session sent with Authorization Header - Returns: - JSON encoded Event object or HTTP-error - """ + Returns: + JSON encoded Event object or HTTP-error + """ event = event_controller.get_event(event_id) event_controller.remove_event_slot(event, slot_id) return jsonify(event) @@ -420,17 +420,17 @@ def add_job_slot(event_id, slot_id, current_session): def delete_job_slot(event_id, slot_id, job_type, current_session): """Delete a JobSlot - Route: ``/schedule/events//slots//jobs/`` | Method: ``DELETE`` + Route: ``/schedule/events//slots//jobs/`` | Method: ``DELETE`` - Args: - event_id: Identifier of the event - slot_id: Identifier of the EventSlot - job_type: Identifier of the JobSlot - current_session: Session sent with Authorization Header + Args: + event_id: Identifier of the event + slot_id: Identifier of the EventSlot + job_type: Identifier of the JobSlot + current_session: Session sent with Authorization Header - Returns: - JSON encoded Event object or HTTP-error - """ + Returns: + JSON encoded Event object or HTTP-error + """ event = event_controller.get_event(event_id) job_slot = event_controller.get_job_slot(slot_id, job_type) event_controller.delete_job_slot(job_slot) @@ -469,7 +469,9 @@ def update_job_slot(event_id, slot_id, job_type, current_session: Session): try: user = userController.get_user(data["user"]["userid"]) value = data["user"]["value"] - if (user == current_session._user and not user.has_permission(permissions.ASSIGN)) or (user != current_session._user and not current_session._user.has_permission(permissions.ASSIGN_OTHER)): + if (user == current_session._user and not user.has_permission(permissions.ASSIGN)) or ( + user != current_session._user and not current_session._user.has_permission(permissions.ASSIGN_OTHER) + ): raise Forbidden event_controller.assign_job(slot, user, value) except (KeyError, ValueError): diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index 8968049..b9f0775 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -157,7 +157,9 @@ def remove_event_slot(event, slot_id): def get_job_slot(event_slot_id, job_type): - jt = JobSlot.query.filter(JobSlot._type_id == job_type).filter(JobSlot._event_slot_id == event_slot_id).one_or_none() + jt = ( + JobSlot.query.filter(JobSlot._type_id == job_type).filter(JobSlot._event_slot_id == event_slot_id).one_or_none() + ) if jt is None: raise NotFound return jt diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index e015243..62cbaac 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -57,7 +57,7 @@ class JobSlot(db.Model, ModelSerializeMixin): event_slot_ = db.relationship("EventSlot", back_populates="jobs") - __table_args__ = (UniqueConstraint('type_id', 'event_slot_id', name='_type_event_slot_uc'),) + __table_args__ = (UniqueConstraint("type_id", "event_slot_id", name="_type_event_slot_uc"),) ########## diff --git a/flaschengeist/utils/datetime.py b/flaschengeist/utils/datetime.py index 7c50c41..dc62371 100644 --- a/flaschengeist/utils/datetime.py +++ b/flaschengeist/utils/datetime.py @@ -1,11 +1,12 @@ import datetime from backports.datetime_fromisoformat import MonkeyPatch + MonkeyPatch.patch_fromisoformat() def from_iso_format(date_str): """Z-suffix aware version of `datetime.datetime.fromisoformat`""" - time = datetime.datetime.fromisoformat(date_str.replace('Z', '+00:00')) + time = datetime.datetime.fromisoformat(date_str.replace("Z", "+00:00")) if time.tzinfo: return time.astimezone(datetime.timezone.utc) return time.replace(tzinfo=datetime.timezone.utc) From 6f0e9854d6e600a32f625a13f435b4ead960449d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 6 Nov 2020 01:13:52 +0100 Subject: [PATCH 115/446] [API][Plugin] Bugfix and API change * users: Fixed bug in edit_user where if modify by admin * API: Users return list of roles as string not Roles --- flaschengeist/controller/userController.py | 6 +++--- flaschengeist/models/user.py | 9 +++++++-- flaschengeist/plugins/auth/__init__.py | 2 +- flaschengeist/plugins/users/__init__.py | 18 ++++++++++++------ 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 9106032..dc9aa43 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -28,12 +28,12 @@ def update_user(user): def set_roles(user: User, roles: [str]): - user.roles.clear() + user.roles_.clear() for role_name in roles: role = Role.query.filter(Role.name == role_name).one_or_none() if not role: raise BadRequest("Role not found >{}<".format(role_name)) - user.roles.append(role) + user.roles_.append(role) def modify_user(user, password, new_password=None): @@ -56,7 +56,7 @@ def get_users(): def get_user_by_role(role: Role): - return User.query.join(User.roles).filter_by(role_id=role.id).all() + return User.query.join(User.roles_).filter_by(role_id=role.id).all() def get_user(uid): diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 4166cc7..fe55ee8 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -53,14 +53,19 @@ class User(db.Model, ModelSerializeMixin): firstname: str = db.Column(db.String(30)) lastname: str = db.Column(db.String(30)) mail: str = db.Column(db.String(30)) - roles: [Role] = db.relationship("Role", secondary=association_table) + roles: [str] = [] + roles_: [Role] = db.relationship("Role", secondary=association_table) _id = db.Column("id", db.Integer, primary_key=True) _sessions = db.relationship("Session", back_populates="_user") _attributes = db.relationship( "_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete" ) + @property + def roles(self): + return [role.name for role in self.roles_] + def set_attribute(self, name, value): if name in self._attributes: self._attributes[name].value = value @@ -76,7 +81,7 @@ class User(db.Model, ModelSerializeMixin): return default def get_permissions(self): - return ["user"] + [permission.name for role in self.roles for permission in role.permissions] + return ["user"] + [permission.name for role in self.roles_ for permission in role.permissions] def has_permission(self, permission): return permission in self.get_permissions() diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 9889e04..8bd867f 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -49,7 +49,7 @@ def login(): # Lets cleanup the DB sessionController.clear_expired() - return {"session": session, "user": user}, CREATED + return {"session": session, "user": user, "permissions": user.get_permissions()}, CREATED @auth_bp.route("/auth", methods=["GET"]) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 87875cf..ef7a7d7 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -9,6 +9,7 @@ from flask import Blueprint, request, jsonify, make_response from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed from flaschengeist import logger +from flaschengeist.models.user import User from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required, extract_session from flaschengeist.controller import userController @@ -81,11 +82,14 @@ def get_user(userid, current_session): current_session: Session sent with Authorization Header Returns: - JSON encoded `flaschengeist.models.user.User` or HTTP error + JSON encoded `flaschengeist.models.user.User` or if userid is current user also containing permissions or HTTP error """ logger.debug("Get information of user {{ {} }}".format(userid)) - user = userController.get_user(userid) - return jsonify(user) + user: User = userController.get_user(userid) + serial = user.serialize() + if (userid == current_session._user.userid): + serial['permissions'] = user.get_permissions() + return jsonify(serial) @users_bp.route("/users/", methods=["DELETE"]) @@ -132,9 +136,11 @@ def edit_user(userid, current_session): password = None new_password = data["new_password"] if "new_password" in data else None + author = user if userid != current_session._user.userid: - if not user.has_permission(_permission_edit): - return Forbidden + author = current_session._user + if not author.has_permission(_permission_edit): + raise Forbidden else: if "password" not in data: raise BadRequest("Password is missing") @@ -145,7 +151,7 @@ def edit_user(userid, current_session): setattr(user, key, data[key]) if "roles" in data: - if not user.has_permission(_permission_set_roles): + if not author.has_permission(_permission_set_roles): raise Forbidden userController.set_roles(user, data["roles"]) From 824ffc86758c7b9637a4a8458c80a537a7183057 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 9 Nov 2020 03:44:35 +0100 Subject: [PATCH 116/446] [Plugin] Roles: Fixed controller and Model * Identify role by id not name, as name might change * Set permissions and Delete Role are fixed (db exception was thrown) --- flaschengeist/controller/roleController.py | 19 ++++++++++++------- flaschengeist/models/user.py | 5 ++--- flaschengeist/plugins/roles/__init__.py | 22 +++++++++++----------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 7100570..185bffa 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -11,9 +11,13 @@ def get_all(): def get(role_name): - role = Role.query.filter(Role.name == role_name).one_or_none() + if type(role_name) is int: + role = Role.query.get(role_name) + else: + role = Role.query.filter(Role.name == role_name).one_or_none() if not role: raise NotFound + return role def get_permissions(): @@ -25,11 +29,12 @@ def update_role(role): def set_permissions(role, permissions): + role.permissions.clear() for name in permissions: - p = Permission.query.filter(Permission.name == name).one_or_none() - if not p: + p = Permission.query.filter(Permission.name.in_(permissions)).all() + if not p or len(p) < len(permissions): raise BadRequest("Invalid permission name >{}<".format(name)) - role.permissions.append(p) + role.permissions.extend(p) db.session.commit() @@ -50,10 +55,10 @@ def create_role(name: str, permissions=[]): def delete(role): + role.permissions.clear() try: - num = Role.query.filter(Role.id == role.id).delete() + db.session.delete(role) + db.session.commit() except IntegrityError: logger.debug("IntegrityError: Role might still be in use", exc_info=True) raise BadRequest("Role still in use") - db.session.commit() - return num == 1 diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index fe55ee8..ae55369 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -25,13 +25,12 @@ class Permission(db.Model, ModelSerializeMixin): class Role(db.Model, ModelSerializeMixin): __tablename__ = "role" + id: int = db.Column(db.Integer, primary_key=True) name: str = db.Column(db.String(30), unique=True) permissions: [Permission] = db.relationship( - "Permission", secondary=role_permission_association_table, cascade="all, delete" + "Permission", secondary=role_permission_association_table ) - _id = db.Column("id", db.Integer, primary_key=True) - class User(db.Model, ModelSerializeMixin): """Database Object for User diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index be7ab4f..ee8c6d4 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -50,7 +50,7 @@ def create_role(current_session): current_session: Session sent with Authorization Header Returns: - HTTP-200 or HTTP error + HTTP-201 or HTTP error """ data = request.get_json() if not data or "name" not in data: @@ -96,23 +96,23 @@ def get_role(role_name, current_session): return jsonify(role) -@roles_bp.route("/roles/", methods=["PUT"]) +@roles_bp.route("/roles/", methods=["PUT"]) @login_required(permission=_permission_edit) -def edit_role(role_name, current_session): +def edit_role(role_id, current_session): """Edit role, rename and / or set permissions - Route: ``/roles/`` | Method: ``PUT`` + Route: ``/roles/`` | Method: ``PUT`` POST-data: ``{name?: string, permissions?: string[]}`` Args: - role_name: Name of role + role_id: Identifier of the role current_session: Session sent with Authorization Header Returns: HTTP-200 or HTTP error """ - role = roleController.get(role_name) + role = roleController.get(role_id) data = request.get_json() if "name" in data: @@ -123,20 +123,20 @@ def edit_role(role_name, current_session): return "", NO_CONTENT -@roles_bp.route("/roles/", methods=["DELETE"]) +@roles_bp.route("/roles/", methods=["DELETE"]) @login_required(permission=_permission_edit) -def delete_role(role_name, current_session): +def delete_role(role_id, current_session): """Delete role - Route: ``/roles/`` | Method: ``DELETE`` + Route: ``/roles/`` | Method: ``DELETE`` Args: - role_name: Name of role + role_id: Identifier of the role current_session: Session sent with Authorization Header Returns: HTTP-204 or HTTP error """ - role = roleController.get(role_name) + role = roleController.get(role_id) roleController.delete(role) return "", NO_CONTENT From 7074c29d63bf946ab67b544debf734b26c4fba0b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 11 Nov 2020 23:56:07 +0100 Subject: [PATCH 117/446] [Script] Fixed export function to work with Optional (requires python 3.8+) --- flaschengeist/utils/HTTP.py | 0 run_flaschengeist | 38 ++++++++++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 flaschengeist/utils/HTTP.py diff --git a/flaschengeist/utils/HTTP.py b/flaschengeist/utils/HTTP.py new file mode 100644 index 0000000..e69de29 diff --git a/run_flaschengeist b/run_flaschengeist index b9d7429..e7419cb 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,43 +1,63 @@ #!/usr/bin/python3 import inspect import argparse +import sys + import pkg_resources class InterfaceGenerator: known = [] classes = {} - mapper = {"str": "string", "int": "number", "float": "number", "datetime": "Date"} + mapper = {"str": "string", "int": "number", "float": "number", "datetime": "Date", "NoneType": "null"} def __init__(self, namespace, filename): self.basename = "" self.namespace = namespace self.filename = filename + self.this_type = None def pytype(self, cls): if isinstance(cls, list): return "", "Array<{}>".format(self.pytype(cls[0])[1]) - # if typing.get_origin(cls) is typing.Optional: - # return "?", pytype(typing.get_args(cls)[1]) + if sys.version_info >= (3, 8): + import typing + + if isinstance(cls, typing.ForwardRef): + return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__ + if typing.get_origin(cls) == typing.Union: + types = typing.get_args(cls) + if len(types) == 2 and types[-1] is type(None): + return "?", self.pytype(types[0])[1] + else: + return "", "|".join([self.pytype(pt)[1] for pt in types]) if hasattr(cls, "__name__"): if cls.__name__ in self.mapper: return "", self.mapper[cls.__name__] else: return "", cls.__name__ + print( + "WARNING: This python version might not detect all types (try >= 3.8). Could not identify >{}<".format(cls) + ) return "?", "any" def walker(self, module): - if inspect.ismodule(module[1]) and module[1].__name__.startswith(self.basename) and module[1].__name__ not in self.known: + if ( + inspect.ismodule(module[1]) + and module[1].__name__.startswith(self.basename) + and module[1].__name__ not in self.known + ): self.known.append(module[1].__name__) for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): self.walker(cls) elif ( - inspect.isclass(module[1]) - and module[1].__module__.startswith(self.basename) - and module[0] not in self.classes - and not module[0].startswith("_") - and hasattr(module[1], "__annotations__") + inspect.isclass(module[1]) + and module[1].__module__.startswith(self.basename) + and module[0] not in self.classes + and not module[0].startswith("_") + and hasattr(module[1], "__annotations__") ): + self.this_type = module[0] d = { param: self.pytype(ptype) for param, ptype in module[1].__annotations__.items() From 53c39f4f920e6c31921e45e9fb203f50e5a48ac7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 12 Nov 2020 00:12:52 +0100 Subject: [PATCH 118/446] [System] Added util for 201&204 HTTP response and some code formatting --- flaschengeist/models/user.py | 4 +--- flaschengeist/plugins/users/__init__.py | 4 ++-- flaschengeist/utils/HTTP.py | 11 +++++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index ae55369..51aefaf 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -27,9 +27,7 @@ class Role(db.Model, ModelSerializeMixin): __tablename__ = "role" id: int = db.Column(db.Integer, primary_key=True) name: str = db.Column(db.String(30), unique=True) - permissions: [Permission] = db.relationship( - "Permission", secondary=role_permission_association_table - ) + permissions: [Permission] = db.relationship("Permission", secondary=role_permission_association_table) class User(db.Model, ModelSerializeMixin): diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index ef7a7d7..0dfab0c 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -87,8 +87,8 @@ def get_user(userid, current_session): logger.debug("Get information of user {{ {} }}".format(userid)) user: User = userController.get_user(userid) serial = user.serialize() - if (userid == current_session._user.userid): - serial['permissions'] = user.get_permissions() + if userid == current_session._user.userid: + serial["permissions"] = user.get_permissions() return jsonify(serial) diff --git a/flaschengeist/utils/HTTP.py b/flaschengeist/utils/HTTP.py index e69de29..8fe57a9 100644 --- a/flaschengeist/utils/HTTP.py +++ b/flaschengeist/utils/HTTP.py @@ -0,0 +1,11 @@ +from http.client import NO_CONTENT, CREATED + +from flask import make_response, jsonify + + +def no_content(): + return make_response(jsonify(""), NO_CONTENT) + + +def created(obj=None): + return make_response(jsonify(obj if obj is not None else ""), CREATED) From c524f2a7db5c83029f23f3389a3e9fc2dc4a5929 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 12 Nov 2020 16:58:40 +0100 Subject: [PATCH 119/446] [System] Fixed user controller to allow new roles --- flaschengeist/controller/userController.py | 6 ++++-- flaschengeist/models/user.py | 2 +- flaschengeist/plugins/auth_ldap/__init__.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index dc9aa43..6008569 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -27,12 +27,14 @@ def update_user(user): db.session.commit() -def set_roles(user: User, roles: [str]): +def set_roles(user: User, roles: [str], create=False): user.roles_.clear() for role_name in roles: role = Role.query.filter(Role.name == role_name).one_or_none() if not role: - raise BadRequest("Role not found >{}<".format(role_name)) + if not create: + raise BadRequest("Role not found >{}<".format(role_name)) + role = Role(name=role_name) user.roles_.append(role) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 51aefaf..0afafef 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -52,7 +52,7 @@ class User(db.Model, ModelSerializeMixin): mail: str = db.Column(db.String(30)) roles: [str] = [] - roles_: [Role] = db.relationship("Role", secondary=association_table) + roles_: [Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") _id = db.Column("id", db.Integer, primary_key=True) _sessions = db.relationship("Session", back_populates="_user") _attributes = db.relationship( diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 54d97ea..89e690c 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -60,7 +60,7 @@ class AuthLDAP(AuthPlugin): user.mail = r["mail"][0] if "displayName" in r: user.display_name = r["displayName"][0] - userController.set_roles(user, self._get_groups(user.userid)) + userController.set_roles(user, self._get_groups(user.userid), create=True) def _get_groups(self, uid): groups = [] From 130774e665a7ebeade0e864ed1c7e2eec34ff186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 12 Nov 2020 19:29:24 +0100 Subject: [PATCH 120/446] =?UTF-8?q?[LDAP]=20User=20k=C3=B6nnen=20erstellt?= =?UTF-8?q?=20werden.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * erstelle neuen user im ldap (ohne rollen) --- flaschengeist/plugins/auth_ldap/__init__.py | 47 +++++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 89e690c..201ed7b 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -62,20 +62,51 @@ class AuthLDAP(AuthPlugin): user.display_name = r["displayName"][0] userController.set_roles(user, self._get_groups(user.userid), create=True) + def create_user(self, user, password): + try: + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + self.ldap.connection.search( + "ou=user,{}".format(self.dn), "(uidNumber=*)", SUBTREE, attributes=["uidNumber"] + ) + uidNumbers = sorted(self.ldap.response(), key = lambda i: i['attributes']['uidNumber'], reverse=True) + uidNumber = uidNumbers[0]['attributes']['uidNumber'] + 1 + dn = f'cn={user.firstname} {user.lastname},ou=user,{self.dn}' + object_class = ['inetOrgPerson', 'posixAccount', 'person', 'organizationalPerson'] + attributes = { + 'sn': user.firstname, + 'givenName': user.lastname, + 'gidNumber': 15000, + 'homeDirectory': f'/home/{user.userid}', + 'loginShell': '/bin/bash', + 'uid': user.userid, + 'userPassword': hashed(HASHED_SALTED_MD5, password), + 'uidNumber': uidNumber + + } + test = ldap_conn.add(dn, object_class, attributes) + print(test) + except (LDAPPasswordIsMandatoryError, LDAPBindError): + raise BadRequest + except Exception as e: + pass + def _get_groups(self, uid): groups = [] self.ldap.connection.search( "ou=user,{}".format(self.dn), "(uid={})".format(uid), SUBTREE, attributes=["gidNumber"] ) - main_group_number = self.ldap.connection.response[0]["attributes"]["gidNumber"] - if main_group_number: - if type(main_group_number) is list: - main_group_number = main_group_number[0] - self.ldap.connection.search( - "ou=group,{}".format(self.dn), "(gidNumber={})".format(main_group_number), attributes=["cn"] - ) - groups.append(self.ldap.connection.response[0]["attributes"]["cn"][0]) + + # Maingroup ist uninteressant + + #main_group_number = self.ldap.connection.response[0]["attributes"]["gidNumber"] + #if main_group_number: + # if type(main_group_number) is list: + # main_group_number = main_group_number[0] + # self.ldap.connection.search( + # "ou=group,{}".format(self.dn), "(gidNumber={})".format(main_group_number), attributes=["cn"] + # ) + # groups.append(self.ldap.connection.response[0]["attributes"]["cn"][0]) self.ldap.connection.search( "ou=group,{}".format(self.dn), "(memberUID={})".format(uid), SUBTREE, attributes=["cn"] From 0f64c718f5bb2782ad69a641b77fce9888dd2918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 12 Nov 2020 19:30:18 +0100 Subject: [PATCH 121/446] =?UTF-8?q?[System]=20Usermodel=20ge=C3=A4ndert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * firstname und lastname nun 50 Zeichen lang * email 60 zeichen lang --- flaschengeist/controller/userController.py | 2 +- flaschengeist/models/user.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 6008569..de30a3f 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -23,7 +23,7 @@ def login_user(username, password): def update_user(user): current_app.config["FG_AUTH_BACKEND"].update_user(user) if not user.display_name: - user.display_name = "{} {}".format(user.firstname, user.lastname) + user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) db.session.commit() diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 0afafef..689c592 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -47,9 +47,9 @@ class User(db.Model, ModelSerializeMixin): __tablename__ = "user" userid: str = db.Column(db.String(30), nullable=False) display_name: str = db.Column(db.String(30)) - firstname: str = db.Column(db.String(30)) - lastname: str = db.Column(db.String(30)) - mail: str = db.Column(db.String(30)) + firstname: str = db.Column(db.String(50)) + lastname: str = db.Column(db.String(50)) + mail: str = db.Column(db.String(60)) roles: [str] = [] roles_: [Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") From fb631f92e9f391f1bb4997d337b337e33a9c9fa1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 12 Nov 2020 21:48:33 +0100 Subject: [PATCH 122/446] [Controller] Fixed bug in registration, thanks @crimsen --- flaschengeist/controller/userController.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index de30a3f..3085c97 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -79,7 +79,10 @@ def register(data): if required not in data: raise BadRequest("Missing required parameters") allowed_keys = User().serialize().keys() - user = User(**{key: value for key, value in data.items() if key in allowed_keys}) + values = {key: value for key, value in data.items() if key in allowed_keys} + roles = values.pop("roles", default=[]) + user = User(**values) + set_roles(user, roles) current_app.config["FG_AUTH_BACKEND"].create_user(user, data["password"]) From 90f5267a36688efecca78ac307e961c1f67190b9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 12 Nov 2020 21:48:33 +0100 Subject: [PATCH 123/446] [Controller] Fixed bug in registration, thanks @crimsen --- flaschengeist/controller/userController.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index de30a3f..c912d9c 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -79,7 +79,10 @@ def register(data): if required not in data: raise BadRequest("Missing required parameters") allowed_keys = User().serialize().keys() - user = User(**{key: value for key, value in data.items() if key in allowed_keys}) + values = {key: value for key, value in data.items() if key in allowed_keys} + roles = values.pop("roles", []) + user = User(**values) + set_roles(user, roles) current_app.config["FG_AUTH_BACKEND"].create_user(user, data["password"]) From 65af9ab367a9416365f788f32812bd1dafa98def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 12 Nov 2020 22:47:10 +0100 Subject: [PATCH 124/446] [LDAP] Rollen updaten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * LDAP-Rollen werden geupdatet, wenn User geändert wird * LDAP-Rollen werden geupdatet, wenn eine neue Person hinzugefügt wird. --- flaschengeist/plugins/auth_ldap/__init__.py | 25 +++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 201ed7b..b9412b1 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -2,7 +2,7 @@ import ssl from ldap3.utils.hashed import hashed -from ldap3 import SUBTREE, MODIFY_REPLACE, HASHED_SALTED_MD5 +from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5 from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from flask import current_app as app from flask_ldapconn import LDAPConn @@ -83,8 +83,8 @@ class AuthLDAP(AuthPlugin): 'uidNumber': uidNumber } - test = ldap_conn.add(dn, object_class, attributes) - print(test) + ldap_conn.add(dn, object_class, attributes) + self.set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest except Exception as e: @@ -116,7 +116,23 @@ class AuthLDAP(AuthPlugin): groups.append(data["attributes"]["cn"][0]) return groups - def modify_user(self, user: User, password, new_password=None): + def set_roles(self, user: User): + try: + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + self.ldap.connection.search(f"ou=group,{self.dn}", "(cn=*)", SUBTREE, attributes=["cn", "gidNumber"]) + ldap_roles = self.ldap.response() + for ldap_role in ldap_roles: + if ldap_role["attributes"]["cn"][0] in user.roles: + modify = {'memberUid': [(MODIFY_ADD, [user.userid])]} + else: + modify = {'memberUid': [(MODIFY_DELETE, [user.userid])]} + test = ldap_conn.modify(ldap_role["dn"], modify) + + except (LDAPPasswordIsMandatoryError, LDAPBindError): + raise BadRequest + + + def modify_user(self, user: User, password=None, new_password=None): try: dn = user.get_attribute("DN") if password: @@ -137,5 +153,6 @@ class AuthLDAP(AuthPlugin): salted_password = hashed(HASHED_SALTED_MD5, new_password) modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])] ldap_conn.modify(dn, modifier) + self.set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest From 96765ee932374d1af25ecf4ebab62b6d945d3f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 12 Nov 2020 23:42:03 +0100 Subject: [PATCH 125/446] =?UTF-8?q?[LDAP]=20Neue=20Rollen=20werden=20hinzu?= =?UTF-8?q?gef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flaschengeist.example.toml | 6 ++++-- flaschengeist/plugins/auth_ldap/__init__.py | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/flaschengeist.example.toml b/flaschengeist.example.toml index e0a0cda..c177f23 100644 --- a/flaschengeist.example.toml +++ b/flaschengeist.example.toml @@ -27,10 +27,12 @@ enabled = true # URL = # PORT = # BINDDN = +# BASEDN = # SECRET = # USE_SSL = -## ADMIN_DN: -## ADMIN_SECRET: +# ADMIN_DN = +# ADMIN_SECRET = +# gidNumber = #[users] # allways enabled diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index b9412b1..0eb27c3 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -34,6 +34,7 @@ class AuthLDAP(AuthPlugin): app.config["LDAP_SECRET"] = (config["SECRET"],) self.ldap = LDAPConn(app) self.dn = config["BASEDN"] + self.gidNumber = config['gidNumber'] # TODO: might not be set if modify is called if "ADMIN_DN" in config: self.admin_dn = config["ADMIN_DN"] @@ -75,7 +76,7 @@ class AuthLDAP(AuthPlugin): attributes = { 'sn': user.firstname, 'givenName': user.lastname, - 'gidNumber': 15000, + 'gidNumber': self.gidNumber, 'homeDirectory': f'/home/{user.userid}', 'loginShell': '/bin/bash', 'uid': user.userid, @@ -121,12 +122,24 @@ class AuthLDAP(AuthPlugin): ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) self.ldap.connection.search(f"ou=group,{self.dn}", "(cn=*)", SUBTREE, attributes=["cn", "gidNumber"]) ldap_roles = self.ldap.response() + + gidNumbers = sorted(ldap_roles, key=lambda i: i['attributes']['gidNumber'], reverse=True) + gidNumber = gidNumbers[0]['attributes']['gidNumber'] + 1 + + for user_role in user.roles: + if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: + ldap_conn.add(f"cn={user_role},ou=group,{self.dn}", ["posixGroup"], attributes={"gidNumber": gidNumber}) + + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + self.ldap.connection.search(f"ou=group,{self.dn}", "(cn=*)", SUBTREE, attributes=["cn", "gidNumber"]) + ldap_roles = self.ldap.response() + for ldap_role in ldap_roles: if ldap_role["attributes"]["cn"][0] in user.roles: modify = {'memberUid': [(MODIFY_ADD, [user.userid])]} else: modify = {'memberUid': [(MODIFY_DELETE, [user.userid])]} - test = ldap_conn.modify(ldap_role["dn"], modify) + ldap_conn.modify(ldap_role["dn"], modify) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest From cbcd5b39a355e4428b44d6d27f960ba2947d6c1a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 13 Nov 2020 01:20:25 +0100 Subject: [PATCH 126/446] [Plugin] Added plugin function when roles are modified LDAP: Use same config style as the rest. --- flaschengeist.example.toml | 22 +++---- flaschengeist/controller/roleController.py | 9 ++- flaschengeist/plugins/__init__.py | 12 ++++ flaschengeist/plugins/auth_ldap/__init__.py | 68 +++++++++------------ flaschengeist/plugins/roles/__init__.py | 3 +- 5 files changed, 62 insertions(+), 52 deletions(-) diff --git a/flaschengeist.example.toml b/flaschengeist.example.toml index c177f23..6953678 100644 --- a/flaschengeist.example.toml +++ b/flaschengeist.example.toml @@ -24,23 +24,23 @@ enabled = true #[auth_ldap] # enabled = true -# URL = -# PORT = -# BINDDN = -# BASEDN = -# SECRET = -# USE_SSL = -# ADMIN_DN = -# ADMIN_SECRET = -# gidNumber = +# host = +# port = +# bind_dn = +# base_dn = +# secret = +# use_ssl = +# admin_dn = +# admin_dn = +# default_gid = #[users] -# allways enabled +# always enabled # ## allowed values: false, "managed", "public" ## false: Disable registration ## "managed": only users with matching permission are allowed to register new users -## "public": Also unautheticated users can register an account +## "public": Also unauthenticated users can register an account # registration = False ############################ diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 185bffa..b7e3039 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -1,3 +1,4 @@ +from flask import current_app from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import BadRequest, NotFound @@ -24,7 +25,12 @@ def get_permissions(): return Permission.query.all() -def update_role(role): +def rename(role, new_name): + if db.session.query(db.exists().where(Role.name == new_name)).scalar(): + raise BadRequest("Name already used") + + current_app.config["FG_AUTH_BACKEND"].modify_role(role.name, new_name) + role.name = new_name db.session.commit() @@ -62,3 +68,4 @@ def delete(role): except IntegrityError: logger.debug("IntegrityError: Role might still be in use", exc_info=True) raise BadRequest("Role still in use") + current_app.config["FG_AUTH_BACKEND"].modify_role(role.name, None) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 349cc32..2082116 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -1,4 +1,5 @@ import pkg_resources +from typing import Optional from werkzeug.exceptions import MethodNotAllowed from flaschengeist.utils.hook import HookCall @@ -67,6 +68,7 @@ class AuthPlugin(Plugin): def modify_user(self, user, password, new_password=None): """If backend is using (writeable) external data, then update the external database with the user provided. + User might have roles not existing on the external database, so you might have to create those. Args: user: User object @@ -79,6 +81,16 @@ class AuthPlugin(Plugin): """ raise NotImplemented + def modify_role(self, old_name: str, new_name: Optional[str]): + """A call to this function indicated that a role was deleted (and has no users) + Might be used if modify_user is implemented. + + Args: + old_name: Name of the modified role + new_name: New role name or None if deleted + """ + pass + def create_user(self, user, password): """If backend is using (writeable) external data, then create a new user on the external database. diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 0eb27c3..f37bb17 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -6,8 +6,9 @@ from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SAL from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from flask import current_app as app from flask_ldapconn import LDAPConn -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, InternalServerError +from flaschengeist import logger from flaschengeist.plugins import AuthPlugin from flaschengeist.models.user import User import flaschengeist.controller.userController as userController @@ -17,28 +18,30 @@ class AuthLDAP(AuthPlugin): def __init__(self, cfg): super().__init__() - config = {"PORT": 389, "USE_SSL": False} + config = {"port": 389, "use_ssl": False} config.update(cfg) app.config.update( - LDAP_SERVER=config["URL"], - LDAP_PORT=config["PORT"], - LDAP_BINDDN=config["BINDDN"], + LDAP_SERVER=config["host"], + LDAP_PORT=config["port"], + LDAP_BINDDN=config["bind_dn"], LDAP_USE_TLS=False, - LDAP_USE_SSL=config["USE_SSL"], + LDAP_USE_SSL=config["use_ssl"], LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2, LDAP_REQUIRE_CERT=ssl.CERT_NONE, FORCE_ATTRIBUTE_VALUE_AS_LIST=True, ) if "SECRET" in config: - app.config["LDAP_SECRET"] = (config["SECRET"],) + app.config["LDAP_SECRET"] = (config["secret"],) self.ldap = LDAPConn(app) - self.dn = config["BASEDN"] - self.gidNumber = config['gidNumber'] + self.dn = config["base_dn"] + self.default_gid = config['default_gid'] # TODO: might not be set if modify is called - if "ADMIN_DN" in config: - self.admin_dn = config["ADMIN_DN"] - self.admin_secret = config["ADMIN_SECRET"] + if "admin_dn" in config: + self.admin_dn = config["admin_dn"] + self.admin_secret = config["admin_secret"] + else: + self.admin_dn = None def login(self, user, password): if not user: @@ -64,6 +67,10 @@ class AuthLDAP(AuthPlugin): userController.set_roles(user, self._get_groups(user.userid), create=True) def create_user(self, user, password): + if self.admin_dn is None: + logger.error("admin_dn missing in ldap config!") + raise InternalServerError + try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) self.ldap.connection.search( @@ -76,7 +83,7 @@ class AuthLDAP(AuthPlugin): attributes = { 'sn': user.firstname, 'givenName': user.lastname, - 'gidNumber': self.gidNumber, + 'gidNumber': self.default_gid, 'homeDirectory': f'/home/{user.userid}', 'loginShell': '/bin/bash', 'uid': user.userid, @@ -85,30 +92,12 @@ class AuthLDAP(AuthPlugin): } ldap_conn.add(dn, object_class, attributes) - self.set_roles(user) + self._set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest - except Exception as e: - pass def _get_groups(self, uid): groups = [] - - self.ldap.connection.search( - "ou=user,{}".format(self.dn), "(uid={})".format(uid), SUBTREE, attributes=["gidNumber"] - ) - - # Maingroup ist uninteressant - - #main_group_number = self.ldap.connection.response[0]["attributes"]["gidNumber"] - #if main_group_number: - # if type(main_group_number) is list: - # main_group_number = main_group_number[0] - # self.ldap.connection.search( - # "ou=group,{}".format(self.dn), "(gidNumber={})".format(main_group_number), attributes=["cn"] - # ) - # groups.append(self.ldap.connection.response[0]["attributes"]["cn"][0]) - self.ldap.connection.search( "ou=group,{}".format(self.dn), "(memberUID={})".format(uid), SUBTREE, attributes=["cn"] ) @@ -117,18 +106,18 @@ class AuthLDAP(AuthPlugin): groups.append(data["attributes"]["cn"][0]) return groups - def set_roles(self, user: User): + def _set_roles(self, user: User): try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) self.ldap.connection.search(f"ou=group,{self.dn}", "(cn=*)", SUBTREE, attributes=["cn", "gidNumber"]) ldap_roles = self.ldap.response() - gidNumbers = sorted(ldap_roles, key=lambda i: i['attributes']['gidNumber'], reverse=True) - gidNumber = gidNumbers[0]['attributes']['gidNumber'] + 1 + gid_numbers = sorted(ldap_roles, key=lambda i: i['attributes']['gidNumber'], reverse=True) + gid_number = gid_numbers[0]['attributes']['gidNumber'] + 1 for user_role in user.roles: if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: - ldap_conn.add(f"cn={user_role},ou=group,{self.dn}", ["posixGroup"], attributes={"gidNumber": gidNumber}) + ldap_conn.add(f"cn={user_role},ou=group,{self.dn}", ["posixGroup"], attributes={"gidNumber": gid_number}) ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) self.ldap.connection.search(f"ou=group,{self.dn}", "(cn=*)", SUBTREE, attributes=["cn", "gidNumber"]) @@ -144,8 +133,11 @@ class AuthLDAP(AuthPlugin): except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest - def modify_user(self, user: User, password=None, new_password=None): + if self.admin_dn is None: + logger.error("admin_dn missing in ldap config!") + raise InternalServerError + try: dn = user.get_attribute("DN") if password: @@ -166,6 +158,6 @@ class AuthLDAP(AuthPlugin): salted_password = hashed(HASHED_SALTED_MD5, new_password) modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])] ldap_conn.modify(dn, modifier) - self.set_roles(user) + self._set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index ee8c6d4..15ebe47 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -116,10 +116,9 @@ def edit_role(role_id, current_session): data = request.get_json() if "name" in data: - role.name = data["name"] + roleController.rename(role, data["name"]) if "permissions" in data: roleController.set_permissions(role, data["permissions"]) - roleController.update_role(role) return "", NO_CONTENT From 2e77855fe9ef03ae5477c29a8def3130e61ffb1b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 13 Nov 2020 03:57:23 +0100 Subject: [PATCH 127/446] [System] Fixed HTTP status when user has insufficient permission --- flaschengeist/controller/sessionController.py | 17 +++++++++++------ flaschengeist/decorator.py | 3 --- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py index 7d5e3e8..499913e 100644 --- a/flaschengeist/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -2,13 +2,13 @@ import secrets from flaschengeist.models.session import Session from flaschengeist.database import db from flaschengeist import logger -from werkzeug.exceptions import Forbidden +from werkzeug.exceptions import Forbidden, Unauthorized from datetime import datetime, timezone lifetime = 1800 -def validate_token(token, user_agent, permissions): +def validate_token(token, user_agent, permission): """Verify session Verify a Session and Roles so if the User has permission or not. @@ -17,9 +17,12 @@ def validate_token(token, user_agent, permissions): Args: token: Token to verify. user_agent: User agent of browser to check - permissions: Permissions needed to access restricted routes + permission: Permission needed to access restricted routes Returns: - A Session for this given Token or False. + A Session for this given Token + Raises: + Unauthorized: If token is invalid or expired + Forbidden: If permission is insufficient """ logger.debug("check token {{ {} }} is valid".format(token)) session = Session.query.filter_by(token=token).one_or_none() @@ -28,15 +31,17 @@ def validate_token(token, user_agent, permissions): if session.expires >= datetime.now(timezone.utc) and ( session.browser == user_agent.browser and session.platform == user_agent.platform ): - if not permissions or session._user.has_permission(permissions): + if not permission or session._user.has_permission(permission): session.refresh() db.session.commit() return session + else: + raise Forbidden else: logger.debug("access token is out of date or invalid client used") delete_session(session) logger.debug("no valid access token with token: {{ {} }} and permissions: {{ {} }}".format(token, permissions)) - return False + raise Unauthorized def create(user, user_agent=None) -> Session: diff --git a/flaschengeist/decorator.py b/flaschengeist/decorator.py index e29fb4a..18ded1a 100644 --- a/flaschengeist/decorator.py +++ b/flaschengeist/decorator.py @@ -15,9 +15,6 @@ def extract_session(permission=None): raise Unauthorized session = sessionController.validate_token(token, request.user_agent, permission) - if not session: - logger.debug("token {{ {} }} is invalid".format(token)) - raise Unauthorized return session From f9a873d3032167366fcfa83964af18be6196bf4e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 13 Nov 2020 08:24:25 +0100 Subject: [PATCH 128/446] [System] Fixed typo --- flaschengeist/controller/sessionController.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py index 499913e..0737cd2 100644 --- a/flaschengeist/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -40,7 +40,7 @@ def validate_token(token, user_agent, permission): else: logger.debug("access token is out of date or invalid client used") delete_session(session) - logger.debug("no valid access token with token: {{ {} }} and permissions: {{ {} }}".format(token, permissions)) + logger.debug("no valid access token with token: {{ {} }} and permission: {{ {} }}".format(token, permission)) raise Unauthorized From 89257a7d37d8e7f9d17460032d88a796e766dbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 14 Nov 2020 13:16:30 +0100 Subject: [PATCH 129/446] [auth_ldap] delete unused roles in ldap --- flaschengeist/plugins/auth_ldap/__init__.py | 91 +++++++++++++++------ 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index f37bb17..3474f44 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -35,7 +35,7 @@ class AuthLDAP(AuthPlugin): app.config["LDAP_SECRET"] = (config["secret"],) self.ldap = LDAPConn(app) self.dn = config["base_dn"] - self.default_gid = config['default_gid'] + self.default_gid = config["default_gid"] # TODO: might not be set if modify is called if "admin_dn" in config: self.admin_dn = config["admin_dn"] @@ -74,22 +74,33 @@ class AuthLDAP(AuthPlugin): try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) self.ldap.connection.search( - "ou=user,{}".format(self.dn), "(uidNumber=*)", SUBTREE, attributes=["uidNumber"] + "ou=user,{}".format(self.dn), + "(uidNumber=*)", + SUBTREE, + attributes=["uidNumber"], ) - uidNumbers = sorted(self.ldap.response(), key = lambda i: i['attributes']['uidNumber'], reverse=True) - uidNumber = uidNumbers[0]['attributes']['uidNumber'] + 1 - dn = f'cn={user.firstname} {user.lastname},ou=user,{self.dn}' - object_class = ['inetOrgPerson', 'posixAccount', 'person', 'organizationalPerson'] + uidNumbers = sorted( + self.ldap.response(), + key=lambda i: i["attributes"]["uidNumber"], + reverse=True, + ) + uidNumber = uidNumbers[0]["attributes"]["uidNumber"] + 1 + dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}" + object_class = [ + "inetOrgPerson", + "posixAccount", + "person", + "organizationalPerson", + ] attributes = { - 'sn': user.firstname, - 'givenName': user.lastname, - 'gidNumber': self.default_gid, - 'homeDirectory': f'/home/{user.userid}', - 'loginShell': '/bin/bash', - 'uid': user.userid, - 'userPassword': hashed(HASHED_SALTED_MD5, password), - 'uidNumber': uidNumber - + "sn": user.firstname, + "givenName": user.lastname, + "gidNumber": self.default_gid, + "homeDirectory": f"/home/{user.userid}", + "loginShell": "/bin/bash", + "uid": user.userid, + "userPassword": hashed(HASHED_SALTED_MD5, password), + "uidNumber": uidNumber, } ldap_conn.add(dn, object_class, attributes) self._set_roles(user) @@ -99,37 +110,63 @@ class AuthLDAP(AuthPlugin): def _get_groups(self, uid): groups = [] self.ldap.connection.search( - "ou=group,{}".format(self.dn), "(memberUID={})".format(uid), SUBTREE, attributes=["cn"] + "ou=group,{}".format(self.dn), + "(memberUID={})".format(uid), + SUBTREE, + attributes=["cn"], ) groups_data = self.ldap.connection.response for data in groups_data: groups.append(data["attributes"]["cn"][0]) return groups + def _get_all_roles(self, ldap_conn): + self.ldap.connection.search( + f"ou=group,{self.dn}", + "(cn=*)", + SUBTREE, + attributes=["cn", "gidNumber", "memberUid"], + ) + return self.ldap.response() + + def _delete_unsed_roles(self): + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_roles = self._get_all_roles(ldap_conn) + for role in ldap_roles: + if len(role["attributes"]["memberUid"]) == 0: + ldap_conn.delete(role["dn"]) + def _set_roles(self, user: User): try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - self.ldap.connection.search(f"ou=group,{self.dn}", "(cn=*)", SUBTREE, attributes=["cn", "gidNumber"]) - ldap_roles = self.ldap.response() - gid_numbers = sorted(ldap_roles, key=lambda i: i['attributes']['gidNumber'], reverse=True) - gid_number = gid_numbers[0]['attributes']['gidNumber'] + 1 + ldap_roles = self._get_all_roles(ldap_conn) + + gid_numbers = sorted( + ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True + ) + gid_number = gid_numbers[0]["attributes"]["gidNumber"] + 1 for user_role in user.roles: - if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: - ldap_conn.add(f"cn={user_role},ou=group,{self.dn}", ["posixGroup"], attributes={"gidNumber": gid_number}) + if user_role not in [ + role["attributes"]["cn"][0] for role in ldap_roles + ]: + ldap_conn.add( + f"cn={user_role},ou=group,{self.dn}", + ["posixGroup"], + attributes={"gidNumber": gid_number}, + ) - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - self.ldap.connection.search(f"ou=group,{self.dn}", "(cn=*)", SUBTREE, attributes=["cn", "gidNumber"]) - ldap_roles = self.ldap.response() + ldap_roles = self._get_all_roles(ldap_conn) for ldap_role in ldap_roles: if ldap_role["attributes"]["cn"][0] in user.roles: - modify = {'memberUid': [(MODIFY_ADD, [user.userid])]} + modify = {"memberUid": [(MODIFY_ADD, [user.userid])]} else: - modify = {'memberUid': [(MODIFY_DELETE, [user.userid])]} + modify = {"memberUid": [(MODIFY_DELETE, [user.userid])]} ldap_conn.modify(ldap_role["dn"], modify) + self._delete_unsed_roles() except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest From 2365f075884bdeba62139c77f982388512d685e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 14 Nov 2020 13:16:30 +0100 Subject: [PATCH 130/446] =?UTF-8?q?[auth=5Fldap]=20l=C3=B6sche=20nicht=20b?= =?UTF-8?q?enutzte=20gruppen=20im=20ldap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit benötigt eine neue konfiguration des ldaps. Maingruppen dürfen nicht in der gleichen organisationunit wie alle anderen rollen sein. --- flaschengeist/plugins/auth_ldap/__init__.py | 91 +++++++++++++++------ 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index f37bb17..3474f44 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -35,7 +35,7 @@ class AuthLDAP(AuthPlugin): app.config["LDAP_SECRET"] = (config["secret"],) self.ldap = LDAPConn(app) self.dn = config["base_dn"] - self.default_gid = config['default_gid'] + self.default_gid = config["default_gid"] # TODO: might not be set if modify is called if "admin_dn" in config: self.admin_dn = config["admin_dn"] @@ -74,22 +74,33 @@ class AuthLDAP(AuthPlugin): try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) self.ldap.connection.search( - "ou=user,{}".format(self.dn), "(uidNumber=*)", SUBTREE, attributes=["uidNumber"] + "ou=user,{}".format(self.dn), + "(uidNumber=*)", + SUBTREE, + attributes=["uidNumber"], ) - uidNumbers = sorted(self.ldap.response(), key = lambda i: i['attributes']['uidNumber'], reverse=True) - uidNumber = uidNumbers[0]['attributes']['uidNumber'] + 1 - dn = f'cn={user.firstname} {user.lastname},ou=user,{self.dn}' - object_class = ['inetOrgPerson', 'posixAccount', 'person', 'organizationalPerson'] + uidNumbers = sorted( + self.ldap.response(), + key=lambda i: i["attributes"]["uidNumber"], + reverse=True, + ) + uidNumber = uidNumbers[0]["attributes"]["uidNumber"] + 1 + dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}" + object_class = [ + "inetOrgPerson", + "posixAccount", + "person", + "organizationalPerson", + ] attributes = { - 'sn': user.firstname, - 'givenName': user.lastname, - 'gidNumber': self.default_gid, - 'homeDirectory': f'/home/{user.userid}', - 'loginShell': '/bin/bash', - 'uid': user.userid, - 'userPassword': hashed(HASHED_SALTED_MD5, password), - 'uidNumber': uidNumber - + "sn": user.firstname, + "givenName": user.lastname, + "gidNumber": self.default_gid, + "homeDirectory": f"/home/{user.userid}", + "loginShell": "/bin/bash", + "uid": user.userid, + "userPassword": hashed(HASHED_SALTED_MD5, password), + "uidNumber": uidNumber, } ldap_conn.add(dn, object_class, attributes) self._set_roles(user) @@ -99,37 +110,63 @@ class AuthLDAP(AuthPlugin): def _get_groups(self, uid): groups = [] self.ldap.connection.search( - "ou=group,{}".format(self.dn), "(memberUID={})".format(uid), SUBTREE, attributes=["cn"] + "ou=group,{}".format(self.dn), + "(memberUID={})".format(uid), + SUBTREE, + attributes=["cn"], ) groups_data = self.ldap.connection.response for data in groups_data: groups.append(data["attributes"]["cn"][0]) return groups + def _get_all_roles(self, ldap_conn): + self.ldap.connection.search( + f"ou=group,{self.dn}", + "(cn=*)", + SUBTREE, + attributes=["cn", "gidNumber", "memberUid"], + ) + return self.ldap.response() + + def _delete_unsed_roles(self): + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_roles = self._get_all_roles(ldap_conn) + for role in ldap_roles: + if len(role["attributes"]["memberUid"]) == 0: + ldap_conn.delete(role["dn"]) + def _set_roles(self, user: User): try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - self.ldap.connection.search(f"ou=group,{self.dn}", "(cn=*)", SUBTREE, attributes=["cn", "gidNumber"]) - ldap_roles = self.ldap.response() - gid_numbers = sorted(ldap_roles, key=lambda i: i['attributes']['gidNumber'], reverse=True) - gid_number = gid_numbers[0]['attributes']['gidNumber'] + 1 + ldap_roles = self._get_all_roles(ldap_conn) + + gid_numbers = sorted( + ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True + ) + gid_number = gid_numbers[0]["attributes"]["gidNumber"] + 1 for user_role in user.roles: - if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: - ldap_conn.add(f"cn={user_role},ou=group,{self.dn}", ["posixGroup"], attributes={"gidNumber": gid_number}) + if user_role not in [ + role["attributes"]["cn"][0] for role in ldap_roles + ]: + ldap_conn.add( + f"cn={user_role},ou=group,{self.dn}", + ["posixGroup"], + attributes={"gidNumber": gid_number}, + ) - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - self.ldap.connection.search(f"ou=group,{self.dn}", "(cn=*)", SUBTREE, attributes=["cn", "gidNumber"]) - ldap_roles = self.ldap.response() + ldap_roles = self._get_all_roles(ldap_conn) for ldap_role in ldap_roles: if ldap_role["attributes"]["cn"][0] in user.roles: - modify = {'memberUid': [(MODIFY_ADD, [user.userid])]} + modify = {"memberUid": [(MODIFY_ADD, [user.userid])]} else: - modify = {'memberUid': [(MODIFY_DELETE, [user.userid])]} + modify = {"memberUid": [(MODIFY_DELETE, [user.userid])]} ldap_conn.modify(ldap_role["dn"], modify) + self._delete_unsed_roles() except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest From c7642758edf0f56689057968b5026d3de35b6de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 15 Nov 2020 01:21:32 +0100 Subject: [PATCH 131/446] [LDAP] editieren von bestehenden rollen. --- flaschengeist/plugins/auth_ldap/__init__.py | 29 +++++++++++++++------ 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 3474f44..42e795d 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -1,6 +1,8 @@ """LDAP Authentication Provider Plugin""" import ssl +from typing import Optional + from ldap3.utils.hashed import hashed from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5 from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError @@ -129,13 +131,6 @@ class AuthLDAP(AuthPlugin): ) return self.ldap.response() - def _delete_unsed_roles(self): - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - ldap_roles = self._get_all_roles(ldap_conn) - for role in ldap_roles: - if len(role["attributes"]["memberUid"]) == 0: - ldap_conn.delete(role["dn"]) - def _set_roles(self, user: User): try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) @@ -166,7 +161,25 @@ class AuthLDAP(AuthPlugin): modify = {"memberUid": [(MODIFY_DELETE, [user.userid])]} ldap_conn.modify(ldap_role["dn"], modify) - self._delete_unsed_roles() + except (LDAPPasswordIsMandatoryError, LDAPBindError): + raise BadRequest + + def modify_role(self, old_name: str, new_name: Optional[str]): + if self.admin_dn is None: + logger.error("admin_dn missing in ldap config!") + raise InternalServerError + try: + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_conn.search( + f"ou=group,{self.dn}", f"(cn={old_name})", SUBTREE, attributes=["cn"] + ) + if len(ldap_conn.response) >= 0: + dn = ldap_conn.response[0]["dn"] + if new_name: + ldap_conn.modify_dn(dn, f"cn={new_name}") + else: + ldap_conn.delete(dn) + except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest From 04753e9a41cd35bcddf9b81eaf7a5807814d7dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 15 Nov 2020 14:19:59 +0100 Subject: [PATCH 132/446] [Role] Fix Rolerename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wenn gleicher name mitgesendet wird, wird die umbenennung nicht durchgeführt. --- flaschengeist/controller/roleController.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index b7e3039..95f0930 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -26,6 +26,8 @@ def get_permissions(): def rename(role, new_name): + if role.name == new_name: + return if db.session.query(db.exists().where(Role.name == new_name)).scalar(): raise BadRequest("Name already used") From 1d36aa40336e7ad2e04c59df6d308332a1b7f2a0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 15 Nov 2020 15:49:18 +0100 Subject: [PATCH 133/446] [Script][System] Added date as export format and added birthday as user attribute --- flaschengeist/app.py | 4 ++-- flaschengeist/models/user.py | 11 ++++++++--- run_flaschengeist | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 057b177..7e4c8d2 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,7 +1,7 @@ import pkg_resources from flask import Flask, current_app from flask_cors import CORS -from datetime import datetime +from datetime import datetime, date from flask.json import JSONEncoder, jsonify from werkzeug.exceptions import HTTPException @@ -19,7 +19,7 @@ class CustomJSONEncoder(JSONEncoder): except AttributeError: pass - if isinstance(o, datetime): + if isinstance(o, datetime) or isinstance(o, date): return o.isoformat() # Check if iterable diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 689c592..eec15d6 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,3 +1,6 @@ +from datetime import date +from typing import Optional + from sqlalchemy.orm.collections import attribute_mapped_collection from . import ModelSerializeMixin @@ -42,14 +45,16 @@ class User(db.Model, ModelSerializeMixin): firstname: Firstname of the User lastname: Lastname of the User mail: mail address of the User + birthday: Birthday of the user """ __tablename__ = "user" userid: str = db.Column(db.String(30), nullable=False) display_name: str = db.Column(db.String(30)) - firstname: str = db.Column(db.String(50)) - lastname: str = db.Column(db.String(50)) - mail: str = db.Column(db.String(60)) + firstname: str = db.Column(db.String(50), nullable=False) + lastname: str = db.Column(db.String(50), nullable=False) + mail: str = db.Column(db.String(60), nullable=False) + birthday: Optional[date] = db.Column(db.Date) roles: [str] = [] roles_: [Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") diff --git a/run_flaschengeist b/run_flaschengeist index e7419cb..23f338a 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -9,7 +9,7 @@ import pkg_resources class InterfaceGenerator: known = [] classes = {} - mapper = {"str": "string", "int": "number", "float": "number", "datetime": "Date", "NoneType": "null"} + mapper = {"str": "string", "int": "number", "float": "number", "date": "Date", "datetime": "Date", "NoneType": "null"} def __init__(self, namespace, filename): self.basename = "" From 602e1bc941029ec5a9cec11aee8d46369ab98d38 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 15 Nov 2020 18:53:46 +0100 Subject: [PATCH 134/446] [System] Detect offline database --- flaschengeist/app.py | 4 ++++ flaschengeist/controller/userController.py | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 7e4c8d2..ad44085 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -3,6 +3,7 @@ from flask import Flask, current_app from flask_cors import CORS from datetime import datetime, date from flask.json import JSONEncoder, jsonify +from sqlalchemy.exc import OperationalError from werkzeug.exceptions import HTTPException from . import logger @@ -93,6 +94,9 @@ def create_app(): if isinstance(e, HTTPException): logger.debug(e.description, exc_info=True) return jsonify({"error": e.description}), e.code + if isinstance(e, OperationalError): + logger.error(e, exc_info=True) + return {"error": "Database unavailable"}, 504 logger.error(str(e), exc_info=True) return jsonify({"error": "Internal server error occurred"}), 500 diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index c912d9c..776dbce 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,4 +1,5 @@ from flask import current_app +from sqlalchemy.orm.exc import NoResultFound from werkzeug.exceptions import NotFound, BadRequest from flaschengeist.utils.hook import Hook @@ -9,8 +10,9 @@ from flaschengeist import logger def login_user(username, password): logger.info("login user {{ {} }}".format(username)) - user = User.query.filter(User.userid == username).one_or_none() - if user is None: + try: + user = User.query.filter(User.userid == username).one() + except NoResultFound: user = User(userid=username) db.session.add(user) if current_app.config["FG_AUTH_BACKEND"].login(user, password): From 9409533f7c4c909cc0cc58e8a658f6cd4265f3d4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 15 Nov 2020 19:44:49 +0100 Subject: [PATCH 135/446] [Plugin] Users: Allow roles in data if not changed. --- flaschengeist/models/__init__.py | 4 ++++ flaschengeist/plugins/auth_ldap/__init__.py | 12 +++--------- flaschengeist/plugins/users/__init__.py | 10 ++++++++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 4023099..14a36fb 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -38,6 +38,10 @@ class UtcDateTime(TypeDecorator): impl = DateTime(timezone=True) + @staticmethod + def current_utc(): + return datetime.datetime.now(tz=datetime.timezone.utc) + def process_bind_param(self, value, dialect): if value is not None: if not isinstance(value, datetime.datetime): diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 42e795d..482630b 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -137,15 +137,11 @@ class AuthLDAP(AuthPlugin): ldap_roles = self._get_all_roles(ldap_conn) - gid_numbers = sorted( - ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True - ) + gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True) gid_number = gid_numbers[0]["attributes"]["gidNumber"] + 1 for user_role in user.roles: - if user_role not in [ - role["attributes"]["cn"][0] for role in ldap_roles - ]: + if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: ldap_conn.add( f"cn={user_role},ou=group,{self.dn}", ["posixGroup"], @@ -170,9 +166,7 @@ class AuthLDAP(AuthPlugin): raise InternalServerError try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - ldap_conn.search( - f"ou=group,{self.dn}", f"(cn={old_name})", SUBTREE, attributes=["cn"] - ) + ldap_conn.search(f"ou=group,{self.dn}", f"(cn={old_name})", SUBTREE, attributes=["cn"]) if len(ldap_conn.response) >= 0: dn = ldap_conn.response[0]["dn"] if new_name: diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 0dfab0c..71595af 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -13,6 +13,7 @@ from flaschengeist.models.user import User from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required, extract_session from flaschengeist.controller import userController +from flaschengeist.utils.datetime import from_iso_format users_bp = Blueprint("users", __name__) _permission_edit = "users_edit_other" @@ -149,11 +150,16 @@ def edit_user(userid, current_session): for key in ["firstname", "lastname", "display_name", "mail"]: if key in data: setattr(user, key, data[key]) + if "birthday" in data: + user.birthday = from_iso_format(data["birthday"]) if "roles" in data: + roles = set(data["roles"]) if not author.has_permission(_permission_set_roles): - raise Forbidden - userController.set_roles(user, data["roles"]) + if len(roles) != len(user.roles) or set(user.roles) != roles: + raise Forbidden + else: + userController.set_roles(user, roles) userController.modify_user(user, password, new_password) userController.update_user(user) From a270857c41177747ae0349b152306a69035cbc70 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 16 Nov 2020 02:30:24 +0100 Subject: [PATCH 136/446] [Plugin]users, auth_ldap: Implemented avatar --- flaschengeist/controller/userController.py | 15 +++ flaschengeist/models/user.py | 1 + flaschengeist/plugins/__init__.py | 14 ++ flaschengeist/plugins/auth_ldap/__init__.py | 138 ++++++++++++-------- flaschengeist/plugins/users/__init__.py | 35 ++++- 5 files changed, 147 insertions(+), 56 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 776dbce..5db035b 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -8,6 +8,21 @@ from flaschengeist.database import db from flaschengeist import logger +class Avatar: + mimetype = "" + binary = bytearray() + + +@Hook +def load_avatar(avatar: Avatar, user: User): + pass + + +@Hook +def save_avatar(avatar: Avatar, user: User): + pass + + def login_user(username, password): logger.info("login user {{ {} }}".format(username)) try: diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index eec15d6..f938046 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -54,6 +54,7 @@ class User(db.Model, ModelSerializeMixin): firstname: str = db.Column(db.String(50), nullable=False) lastname: str = db.Column(db.String(50), nullable=False) mail: str = db.Column(db.String(60), nullable=False) + avatar_url: Optional[str] = db.Column(db.String(60)) birthday: Optional[date] = db.Column(db.Date) roles: [str] = [] diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 2082116..ed7a82b 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -10,6 +10,20 @@ Args: message: Message object to send """ +load_avatar_hook = HookCall("load_avatar") +"""Hook decorator for loading Avatar data +Args: + avatar: Avatar object + user: User object to load from +""" + +save_avatar_hook = HookCall("save_avatar") +"""Hook decorator for saving Avatar data +Args: + avatar: Avatar object + user: User object to save +""" + update_user_hook = HookCall("update_user") """Hook decorator, when ever an user update is done, this is called before. Args: diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 482630b..f421c19 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -2,16 +2,15 @@ import ssl from typing import Optional - -from ldap3.utils.hashed import hashed -from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5 -from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError -from flask import current_app as app from flask_ldapconn import LDAPConn +from flask import current_app as app +from ldap3.utils.hashed import hashed +from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError +from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5 from werkzeug.exceptions import BadRequest, InternalServerError from flaschengeist import logger -from flaschengeist.plugins import AuthPlugin +from flaschengeist.plugins import AuthPlugin, load_avatar_hook, save_avatar_hook from flaschengeist.models.user import User import flaschengeist.controller.userController as userController @@ -19,10 +18,8 @@ import flaschengeist.controller.userController as userController class AuthLDAP(AuthPlugin): def __init__(self, cfg): super().__init__() - config = {"port": 389, "use_ssl": False} config.update(cfg) - app.config.update( LDAP_SERVER=config["host"], LDAP_PORT=config["port"], @@ -38,6 +35,7 @@ class AuthLDAP(AuthPlugin): self.ldap = LDAPConn(app) self.dn = config["base_dn"] self.default_gid = config["default_gid"] + # TODO: might not be set if modify is called if "admin_dn" in config: self.admin_dn = config["admin_dn"] @@ -45,6 +43,14 @@ class AuthLDAP(AuthPlugin): else: self.admin_dn = None + @load_avatar_hook + def load_avatar(avatar, user): + self.__load_avatar(avatar, user) + + @save_avatar_hook + def load_avatar(avatar, user): + self.__save_avatar(avatar, user) + def login(self, user, password): if not user: return False @@ -64,6 +70,7 @@ class AuthLDAP(AuthPlugin): user.lastname = r["sn"][0] if r["mail"]: user.mail = r["mail"][0] + user.avatar_url = f"/api/users/{user.userid}/avatar" if "displayName" in r: user.display_name = r["displayName"][0] userController.set_roles(user, self._get_groups(user.userid), create=True) @@ -109,6 +116,75 @@ class AuthLDAP(AuthPlugin): except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest + def modify_role(self, old_name: str, new_name: Optional[str]): + if self.admin_dn is None: + logger.error("admin_dn missing in ldap config!") + raise InternalServerError + try: + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_conn.search(f"ou=group,{self.dn}", f"(cn={old_name})", SUBTREE, attributes=["cn"]) + if len(ldap_conn.response) >= 0: + dn = ldap_conn.response[0]["dn"] + if new_name: + ldap_conn.modify_dn(dn, f"cn={new_name}") + else: + ldap_conn.delete(dn) + + except (LDAPPasswordIsMandatoryError, LDAPBindError): + raise BadRequest + + def modify_user(self, user: User, password=None, new_password=None): + try: + dn = user.get_attribute("DN") + if password: + ldap_conn = self.ldap.connect(dn, password) + else: + if self.admin_dn is None: + logger.error("admin_dn missing in ldap config!") + raise InternalServerError + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + modifier = {} + for name, ldap_name in [ + ("firstname", "givenName"), + ("lastname", "sn"), + ("mail", "mail"), + ("display_name", "displayName"), + ]: + if hasattr(user, name): + modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] + if new_password: + # TODO: Use secure hash! + salted_password = hashed(HASHED_SALTED_MD5, new_password) + modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])] + ldap_conn.modify(dn, modifier) + self._set_roles(user) + except (LDAPPasswordIsMandatoryError, LDAPBindError): + raise BadRequest + + def __load_avatar(self, avatar, user): + self.ldap.connection.search( + "ou=user,{}".format(self.dn), + "(uid={})".format(user.userid), + SUBTREE, + attributes=["uid", "jpegPhoto"], + ) + r = self.ldap.connection.response[0]["attributes"] + if r["uid"][0] == user.userid: + avatar.mimetype = "image/jpeg" + avatar.binary.clear() + avatar.binary.extend(r["jpegPhoto"][0]) + + def __save_avatar(self, avatar, user): + if avatar.mimetype != "image/jpeg": + raise BadRequest("Unsupported image Format") + # Maybe use Pillow to convert here + if self.admin_dn is None: + logger.error("admin_dn missing in ldap config!") + raise InternalServerError + dn = user.get_attribute("DN") + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) + def _get_groups(self, uid): groups = [] self.ldap.connection.search( @@ -159,49 +235,3 @@ class AuthLDAP(AuthPlugin): except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest - - def modify_role(self, old_name: str, new_name: Optional[str]): - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") - raise InternalServerError - try: - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - ldap_conn.search(f"ou=group,{self.dn}", f"(cn={old_name})", SUBTREE, attributes=["cn"]) - if len(ldap_conn.response) >= 0: - dn = ldap_conn.response[0]["dn"] - if new_name: - ldap_conn.modify_dn(dn, f"cn={new_name}") - else: - ldap_conn.delete(dn) - - except (LDAPPasswordIsMandatoryError, LDAPBindError): - raise BadRequest - - def modify_user(self, user: User, password=None, new_password=None): - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") - raise InternalServerError - - try: - dn = user.get_attribute("DN") - if password: - ldap_conn = self.ldap.connect(dn, password) - else: - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - modifier = {} - for name, ldap_name in [ - ("firstname", "givenName"), - ("lastname", "sn"), - ("mail", "mail"), - ("display_name", "displayName"), - ]: - if hasattr(user, name): - modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] - if new_password: - # TODO: Use secure hash! - salted_password = hashed(HASHED_SALTED_MD5, new_password) - modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])] - ldap_conn.modify(dn, modifier) - self._set_roles(user) - except (LDAPPasswordIsMandatoryError, LDAPBindError): - raise BadRequest diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 71595af..3e39532 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -5,14 +5,15 @@ Provides routes used to manage users from http.client import NO_CONTENT, CREATED from flaschengeist.config import config -from flask import Blueprint, request, jsonify, make_response -from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed +from flask import Blueprint, request, jsonify, make_response, Response +from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound from flaschengeist import logger from flaschengeist.models.user import User from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required, extract_session from flaschengeist.controller import userController +from flaschengeist.utils.HTTP import created from flaschengeist.utils.datetime import from_iso_format users_bp = Blueprint("users", __name__) @@ -93,6 +94,36 @@ def get_user(userid, current_session): return jsonify(serial) +@users_bp.route("/users//avatar", methods=["GET"]) +def get_avatar(userid): + user = userController.get_user(userid) + avatar = userController.Avatar() + userController.load_avatar(avatar, user) + if len(avatar.binary) > 0: + response = Response(avatar.binary, mimetype=avatar.mimetype) + response.add_etag() + return response.make_conditional(request) + raise NotFound + + +@users_bp.route("/users//avatar", methods=["POST"]) +@login_required() +def set_avatar(userid, current_session): + user = userController.get_user(userid) + if userid != current_session._user.userid and not current_session._user.has_permission(_permission_edit): + raise Forbidden + + file = request.files.get("file") + if file: + avatar = userController.Avatar() + avatar.mimetype = file.content_type + avatar.binary = bytearray(file.stream.read()) + userController.save_avatar(avatar, user) + return created() + else: + raise BadRequest + + @users_bp.route("/users/", methods=["DELETE"]) @login_required(permission=_permission_delete) def delete_user(userid, current_session): From 09c7f4a258baf4ed1ba6e4af1ba97005e0c87029 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 16 Nov 2020 13:35:23 +0100 Subject: [PATCH 137/446] [Plugin] auth_ldap: Use Pillow to convert avatar if installed --- flaschengeist/plugins/auth_ldap/__init__.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index f421c19..e433fc7 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -1,5 +1,5 @@ """LDAP Authentication Provider Plugin""" - +import io import ssl from typing import Optional from flask_ldapconn import LDAPConn @@ -176,8 +176,21 @@ class AuthLDAP(AuthPlugin): def __save_avatar(self, avatar, user): if avatar.mimetype != "image/jpeg": - raise BadRequest("Unsupported image Format") - # Maybe use Pillow to convert here + # Try converting using Pillow (if installed) + try: + from PIL import Image + image = Image.open(io.BytesIO(avatar.binary)) + image_bytes = io.BytesIO() + image.save(image_bytes, format="JPEG") + avatar.binary = image_bytes.getvalue() + avatar.mimetype = "image/jpeg" + except ImportError: + logger.debug("Pillow not installed for image conversion") + raise BadRequest("Unsupported image format") + except IOError: + logger.debug(f"Could not convert avatar from '{avatar.mimetype}' to JPEG") + raise BadRequest("Unsupported image format") + if self.admin_dn is None: logger.error("admin_dn missing in ldap config!") raise InternalServerError From 39a259a6934872d371432f5b722d2d811f0db6ed Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 16 Nov 2020 14:21:19 +0100 Subject: [PATCH 138/446] [Plugin] roles: New permission needed for deleting roles --- flaschengeist/plugins/roles/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index 15ebe47..fd93a03 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -13,11 +13,12 @@ from flaschengeist.controller import roleController roles_bp = Blueprint("roles", __name__) _permission_edit = "roles_edit" +_permission_delete = "roles_delete" class RolesPlugin(Plugin): def __init__(self, config): - super().__init__(config, roles_bp, permissions=[_permission_edit]) + super().__init__(config, roles_bp, permissions=[_permission_edit, _permission_delete]) @roles_bp.route("/roles", methods=["GET"]) @@ -123,7 +124,7 @@ def edit_role(role_id, current_session): @roles_bp.route("/roles/", methods=["DELETE"]) -@login_required(permission=_permission_edit) +@login_required(permission=_permission_delete) def delete_role(role_id, current_session): """Delete role From 28865649b442503c465af7aa5f2fb1cdc76fa657 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 17 Nov 2020 03:28:04 +0100 Subject: [PATCH 139/446] [Plugin] Use plugin function instead of HookCall --- flaschengeist/controller/roleController.py | 13 ++- flaschengeist/controller/userController.py | 23 ++---- flaschengeist/models/user.py | 7 ++ flaschengeist/plugins/__init__.py | 50 ++++++------ flaschengeist/plugins/auth_ldap/__init__.py | 89 +++++++++++---------- flaschengeist/plugins/users/__init__.py | 9 +-- 6 files changed, 103 insertions(+), 88 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 95f0930..e478eec 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -1,10 +1,10 @@ -from flask import current_app from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import BadRequest, NotFound from flaschengeist.models.user import Role, Permission from flaschengeist.database import db from flaschengeist import logger +from flaschengeist.utils.hook import Hook def get_all(): @@ -25,14 +25,21 @@ def get_permissions(): return Permission.query.all() +@Hook +def role_updated(role, old_name): + """Hook used when roles are updated""" + pass + + def rename(role, new_name): if role.name == new_name: return if db.session.query(db.exists().where(Role.name == new_name)).scalar(): raise BadRequest("Name already used") - current_app.config["FG_AUTH_BACKEND"].modify_role(role.name, new_name) + old_name = role.name role.name = new_name + role_updated(role, old_name) db.session.commit() @@ -70,4 +77,4 @@ def delete(role): except IntegrityError: logger.debug("IntegrityError: Role might still be in use", exc_info=True) raise BadRequest("Role still in use") - current_app.config["FG_AUTH_BACKEND"].modify_role(role.name, None) + role_updated(None, role.name) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 5db035b..275f1ff 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -8,21 +8,6 @@ from flaschengeist.database import db from flaschengeist import logger -class Avatar: - mimetype = "" - binary = bytearray() - - -@Hook -def load_avatar(avatar: Avatar, user: User): - pass - - -@Hook -def save_avatar(avatar: Avatar, user: User): - pass - - def login_user(username, password): logger.info("login user {{ {} }}".format(username)) try: @@ -106,3 +91,11 @@ def register(data): db.session.add(user) db.session.commit() return user + + +def load_avatar(user: User): + return current_app.config["FG_AUTH_BACKEND"].get_avatar(user) + + +def save_avatar(user, avatar): + return current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index f938046..57ea213 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -96,3 +96,10 @@ class _UserAttribute(db.Model, ModelSerializeMixin): user: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), nullable=False) name: str = db.Column(db.String(30)) value: any = db.Column(db.PickleType(protocol=4)) + + +class _Avatar: + """Wrapper class for avatar binaries""" + + mimetype = "" + binary = bytearray() diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index ed7a82b..bc66893 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -1,7 +1,7 @@ import pkg_resources -from typing import Optional -from werkzeug.exceptions import MethodNotAllowed +from werkzeug.exceptions import MethodNotAllowed, NotFound +from flaschengeist.models.user import _Avatar from flaschengeist.utils.hook import HookCall send_message_hook = HookCall("send_message") @@ -10,18 +10,11 @@ Args: message: Message object to send """ -load_avatar_hook = HookCall("load_avatar") -"""Hook decorator for loading Avatar data +role_updated = HookCall("role_updated") +"""Hook decorator for when roles are modified Args: - avatar: Avatar object - user: User object to load from -""" - -save_avatar_hook = HookCall("save_avatar") -"""Hook decorator for saving Avatar data -Args: - avatar: Avatar object - user: User object to save + role: Role object containing the modified role (None if deleted) + old_name: Old name if the name was changed """ update_user_hook = HookCall("update_user") @@ -95,16 +88,6 @@ class AuthPlugin(Plugin): """ raise NotImplemented - def modify_role(self, old_name: str, new_name: Optional[str]): - """A call to this function indicated that a role was deleted (and has no users) - Might be used if modify_user is implemented. - - Args: - old_name: Name of the modified role - new_name: New role name or None if deleted - """ - pass - def create_user(self, user, password): """If backend is using (writeable) external data, then create a new user on the external database. @@ -123,3 +106,24 @@ class AuthPlugin(Plugin): """ raise MethodNotAllowed + + def get_avatar(self, user) -> _Avatar: + """Retrieve avatar for given user (if supported by auth backend) + + Args: + user: User to retrieve the avatar for + Raises: + NotFound: If no avatar found or not implemented + """ + raise NotFound + + def set_avatar(self, user, avatar: _Avatar): + """Set the avatar for given user (if supported by auth backend) + + Args: + user: User to set the avatar for + avatar: Avatar to set + Raises: + MethodNotAllowed: If not supported by Backend + """ + raise MethodNotAllowed diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index e433fc7..d374597 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -7,11 +7,11 @@ from flask import current_app as app from ldap3.utils.hashed import hashed from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5 -from werkzeug.exceptions import BadRequest, InternalServerError +from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from flaschengeist import logger -from flaschengeist.plugins import AuthPlugin, load_avatar_hook, save_avatar_hook -from flaschengeist.models.user import User +from flaschengeist.plugins import AuthPlugin, role_updated +from flaschengeist.models.user import User, Role, _Avatar import flaschengeist.controller.userController as userController @@ -43,13 +43,9 @@ class AuthLDAP(AuthPlugin): else: self.admin_dn = None - @load_avatar_hook - def load_avatar(avatar, user): - self.__load_avatar(avatar, user) - - @save_avatar_hook - def load_avatar(avatar, user): - self.__save_avatar(avatar, user) + @role_updated + def _role_updated(role, old_name): + self.__modify_role(role, old_name) def login(self, user, password): if not user: @@ -88,12 +84,12 @@ class AuthLDAP(AuthPlugin): SUBTREE, attributes=["uidNumber"], ) - uidNumbers = sorted( - self.ldap.response(), - key=lambda i: i["attributes"]["uidNumber"], - reverse=True, + uid_number = ( + sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][ + "attributes" + ]["uidNumber"] + + 1 ) - uidNumber = uidNumbers[0]["attributes"]["uidNumber"] + 1 dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}" object_class = [ "inetOrgPerson", @@ -109,30 +105,13 @@ class AuthLDAP(AuthPlugin): "loginShell": "/bin/bash", "uid": user.userid, "userPassword": hashed(HASHED_SALTED_MD5, password), - "uidNumber": uidNumber, + "uidNumber": uid_number, } ldap_conn.add(dn, object_class, attributes) self._set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest - def modify_role(self, old_name: str, new_name: Optional[str]): - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") - raise InternalServerError - try: - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - ldap_conn.search(f"ou=group,{self.dn}", f"(cn={old_name})", SUBTREE, attributes=["cn"]) - if len(ldap_conn.response) >= 0: - dn = ldap_conn.response[0]["dn"] - if new_name: - ldap_conn.modify_dn(dn, f"cn={new_name}") - else: - ldap_conn.delete(dn) - - except (LDAPPasswordIsMandatoryError, LDAPBindError): - raise BadRequest - def modify_user(self, user: User, password=None, new_password=None): try: dn = user.get_attribute("DN") @@ -161,7 +140,7 @@ class AuthLDAP(AuthPlugin): except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest - def __load_avatar(self, avatar, user): + def get_avatar(self, user): self.ldap.connection.search( "ou=user,{}".format(self.dn), "(uid={})".format(user.userid), @@ -169,16 +148,21 @@ class AuthLDAP(AuthPlugin): attributes=["uid", "jpegPhoto"], ) r = self.ldap.connection.response[0]["attributes"] - if r["uid"][0] == user.userid: - avatar.mimetype = "image/jpeg" - avatar.binary.clear() - avatar.binary.extend(r["jpegPhoto"][0]) - def __save_avatar(self, avatar, user): + if r["uid"][0] == user.userid: + avatar = _Avatar() + avatar.mimetype = "image/jpeg" + avatar.binary.extend(r["jpegPhoto"][0]) + return avatar + else: + raise NotFound + + def set_avatar(self, user, avatar: _Avatar): if avatar.mimetype != "image/jpeg": # Try converting using Pillow (if installed) try: from PIL import Image + image = Image.open(io.BytesIO(avatar.binary)) image_bytes = io.BytesIO() image.save(image_bytes, format="JPEG") @@ -198,6 +182,27 @@ class AuthLDAP(AuthPlugin): ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) + def __modify_role( + self, + role: Optional[Role], + old_name: str, + ): + if self.admin_dn is None: + logger.error("admin_dn missing in ldap config!") + raise InternalServerError + try: + ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_conn.search(f"ou=group,{self.dn}", f"(cn={old_name})", SUBTREE, attributes=["cn"]) + if len(ldap_conn.response) >= 0: + dn = ldap_conn.response[0]["dn"] + if role: + ldap_conn.modify_dn(dn, f"cn={role.name}") + else: + ldap_conn.delete(dn) + + except (LDAPPasswordIsMandatoryError, LDAPBindError): + raise BadRequest + def _get_groups(self, uid): groups = [] self.ldap.connection.search( @@ -211,7 +216,7 @@ class AuthLDAP(AuthPlugin): groups.append(data["attributes"]["cn"][0]) return groups - def _get_all_roles(self, ldap_conn): + def _get_all_roles(self): self.ldap.connection.search( f"ou=group,{self.dn}", "(cn=*)", @@ -224,7 +229,7 @@ class AuthLDAP(AuthPlugin): try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - ldap_roles = self._get_all_roles(ldap_conn) + ldap_roles = self._get_all_roles() gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True) gid_number = gid_numbers[0]["attributes"]["gidNumber"] + 1 @@ -237,7 +242,7 @@ class AuthLDAP(AuthPlugin): attributes={"gidNumber": gid_number}, ) - ldap_roles = self._get_all_roles(ldap_conn) + ldap_roles = self._get_all_roles() for ldap_role in ldap_roles: if ldap_role["attributes"]["cn"][0] in user.roles: diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 3e39532..24e4968 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -9,7 +9,7 @@ from flask import Blueprint, request, jsonify, make_response, Response from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound from flaschengeist import logger -from flaschengeist.models.user import User +from flaschengeist.models.user import User, _Avatar from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required, extract_session from flaschengeist.controller import userController @@ -97,8 +97,7 @@ def get_user(userid, current_session): @users_bp.route("/users//avatar", methods=["GET"]) def get_avatar(userid): user = userController.get_user(userid) - avatar = userController.Avatar() - userController.load_avatar(avatar, user) + avatar = userController.load_avatar(user) if len(avatar.binary) > 0: response = Response(avatar.binary, mimetype=avatar.mimetype) response.add_etag() @@ -115,10 +114,10 @@ def set_avatar(userid, current_session): file = request.files.get("file") if file: - avatar = userController.Avatar() + avatar = _Avatar() avatar.mimetype = file.content_type avatar.binary = bytearray(file.stream.read()) - userController.save_avatar(avatar, user) + userController.save_avatar(user, avatar) return created() else: raise BadRequest From 88ff46c193593bb329c007fd3a0e9d410b86d6f5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 17 Nov 2020 03:32:47 +0100 Subject: [PATCH 140/446] [Plugin] auth_plain: Implemented Avatar --- flaschengeist/plugins/auth_plain/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 49575e5..ce9d006 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -6,7 +6,7 @@ Flaschengeist database (as User attribute) import os import hashlib import binascii -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, NotFound from flaschengeist.plugins import AuthPlugin from flaschengeist.models.user import User @@ -33,6 +33,14 @@ class AuthPlain(AuthPlugin): def delete_user(self, user): pass + def get_avatar(self, user): + if not user.has_attribute("avatar"): + raise NotFound + return user.get_attribute("avatar") + + def set_avatar(self, user, avatar): + user.set_attribute("avatar", avatar) + @staticmethod def _hash_password(password): salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii") From 58e121473b9f7e5127d175a87ab669b9011eafb6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 17 Nov 2020 17:46:07 +0100 Subject: [PATCH 141/446] Fixed avatar URL --- flaschengeist/controller/userController.py | 8 ++++++-- flaschengeist/plugins/auth_ldap/__init__.py | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 275f1ff..96c52ab 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,4 +1,4 @@ -from flask import current_app +from flask import current_app, url_for from sqlalchemy.orm.exc import NoResultFound from werkzeug.exceptions import NotFound, BadRequest @@ -98,4 +98,8 @@ def load_avatar(user: User): def save_avatar(user, avatar): - return current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) + user.avatar_url = "" + r = current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) + if not user.avatar_url: + user.avatar_url = url_for('users.get_avatar', userid=user.userid) + db.session.commit() diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index d374597..1d22822 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -66,7 +66,6 @@ class AuthLDAP(AuthPlugin): user.lastname = r["sn"][0] if r["mail"]: user.mail = r["mail"][0] - user.avatar_url = f"/api/users/{user.userid}/avatar" if "displayName" in r: user.display_name = r["displayName"][0] userController.set_roles(user, self._get_groups(user.userid), create=True) From 2d6b86e2eb8029e063974efe45e2f68d219c54c5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 18 Nov 2020 00:39:25 +0100 Subject: [PATCH 142/446] [System][Plugin] Improved Hooks, roles and auth_ldap improvements * Hooks now allow multiple hooked functions * Hooks can now be called before and after a function call * Fixed issue in datetime util when string is None or empty * Roles: Return new created role as json * auth_ldap: Use new Hooks * auth_ldap: Fixed an issue where ldap response is not checked (when role gets renamed) --- flaschengeist/controller/roleController.py | 41 ++++++++++----------- flaschengeist/controller/userController.py | 2 +- flaschengeist/plugins/__init__.py | 23 ++++++------ flaschengeist/plugins/auth_ldap/__init__.py | 20 +++++----- flaschengeist/plugins/roles/__init__.py | 10 ++--- flaschengeist/utils/datetime.py | 2 + flaschengeist/utils/hook.py | 31 ++++++++++++---- 7 files changed, 72 insertions(+), 57 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index e478eec..501da56 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -26,21 +26,21 @@ def get_permissions(): @Hook -def role_updated(role, old_name): - """Hook used when roles are updated""" - pass - - -def rename(role, new_name): - if role.name == new_name: - return - if db.session.query(db.exists().where(Role.name == new_name)).scalar(): - raise BadRequest("Name already used") - - old_name = role.name - role.name = new_name - role_updated(role, old_name) - db.session.commit() +def update_role(role, new_name): + if new_name is None: + try: + logger.debug(f"Hallo, dies ist die {role.serialize()}") + db.session.delete(role) + logger.debug(f"Hallo, dies ist die {role.serialize()}") + db.session.commit() + except IntegrityError: + logger.debug("IntegrityError: Role might still be in use", exc_info=True) + raise BadRequest("Role still in use") + elif role.name != new_name: + if db.session.query(db.exists().where(Role.name == new_name)).scalar(): + raise BadRequest("Name already used") + role.name = new_name + db.session.commit() def set_permissions(role, permissions): @@ -63,18 +63,15 @@ def create_permissions(permissions): def create_role(name: str, permissions=[]): + logger.debug(f"Create new role with name: {name}") role = Role(name=name) db.session.add(role) set_permissions(role, permissions) + db.session.commit() + logger.debug(f"Created role: {role.serialize()}") return role def delete(role): role.permissions.clear() - try: - db.session.delete(role) - db.session.commit() - except IntegrityError: - logger.debug("IntegrityError: Role might still be in use", exc_info=True) - raise BadRequest("Role still in use") - role_updated(None, role.name) + update_role(role, None) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 96c52ab..910951e 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -101,5 +101,5 @@ def save_avatar(user, avatar): user.avatar_url = "" r = current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) if not user.avatar_url: - user.avatar_url = url_for('users.get_avatar', userid=user.userid) + user.avatar_url = url_for("users.get_avatar", userid=user.userid) db.session.commit() diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index bc66893..a61e7f3 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -2,22 +2,21 @@ import pkg_resources from werkzeug.exceptions import MethodNotAllowed, NotFound from flaschengeist.models.user import _Avatar -from flaschengeist.utils.hook import HookCall +from flaschengeist.utils.hook import HookBefore, HookAfter -send_message_hook = HookCall("send_message") -"""Hook decorator for sending messages, register to send the message -Args: - message: Message object to send -""" - -role_updated = HookCall("role_updated") +before_role_updated = HookBefore("update_role") """Hook decorator for when roles are modified Args: - role: Role object containing the modified role (None if deleted) - old_name: Old name if the name was changed + role: Role object to modify + new_name: New name if the name was changed (None if delete) """ - -update_user_hook = HookCall("update_user") +after_role_updated = HookAfter("update_role") +"""Hook decorator for when roles are modified +Args: + role: Role object containing the modified role + new_name: New name if the name was changed (None if deleted) +""" +before_update_user = HookBefore("update_user") """Hook decorator, when ever an user update is done, this is called before. Args: user: User object diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 1d22822..619ac94 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -10,7 +10,7 @@ from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SAL from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from flaschengeist import logger -from flaschengeist.plugins import AuthPlugin, role_updated +from flaschengeist.plugins import AuthPlugin, after_role_updated from flaschengeist.models.user import User, Role, _Avatar import flaschengeist.controller.userController as userController @@ -43,9 +43,9 @@ class AuthLDAP(AuthPlugin): else: self.admin_dn = None - @role_updated - def _role_updated(role, old_name): - self.__modify_role(role, old_name) + @after_role_updated + def _role_updated(role, new_name): + self.__modify_role(role, new_name) def login(self, user, password): if not user: @@ -183,19 +183,19 @@ class AuthLDAP(AuthPlugin): def __modify_role( self, - role: Optional[Role], - old_name: str, + role: Role, + new_name: Optional[str], ): if self.admin_dn is None: logger.error("admin_dn missing in ldap config!") raise InternalServerError try: ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - ldap_conn.search(f"ou=group,{self.dn}", f"(cn={old_name})", SUBTREE, attributes=["cn"]) - if len(ldap_conn.response) >= 0: + ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"]) + if len(ldap_conn.response) > 0: dn = ldap_conn.response[0]["dn"] - if role: - ldap_conn.modify_dn(dn, f"cn={role.name}") + if new_name: + ldap_conn.modify_dn(dn, f"cn={new_name}") else: ldap_conn.delete(dn) diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index fd93a03..de0c505 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -10,6 +10,7 @@ from http.client import CREATED, NO_CONTENT from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required from flaschengeist.controller import roleController +from flaschengeist.utils.HTTP import created roles_bp = Blueprint("roles", __name__) _permission_edit = "roles_edit" @@ -51,15 +52,14 @@ def create_role(current_session): current_session: Session sent with Authorization Header Returns: - HTTP-201 or HTTP error + HTTP-201 and json encoded created Role or HTTP error """ data = request.get_json() if not data or "name" not in data: raise BadRequest if "permissions" in data: permissions = data["permissions"] - roleController.create_role(data["name"], permissions) - return "", CREATED + return created(roleController.create_role(data["name"], permissions)) @roles_bp.route("/roles/permissions", methods=["GET"]) @@ -116,10 +116,10 @@ def edit_role(role_id, current_session): role = roleController.get(role_id) data = request.get_json() - if "name" in data: - roleController.rename(role, data["name"]) if "permissions" in data: roleController.set_permissions(role, data["permissions"]) + if "name" in data: + roleController.update_role(role, data["name"]) return "", NO_CONTENT diff --git a/flaschengeist/utils/datetime.py b/flaschengeist/utils/datetime.py index dc62371..cf97a00 100644 --- a/flaschengeist/utils/datetime.py +++ b/flaschengeist/utils/datetime.py @@ -6,6 +6,8 @@ MonkeyPatch.patch_fromisoformat() def from_iso_format(date_str): """Z-suffix aware version of `datetime.datetime.fromisoformat`""" + if not date_str: + return None time = datetime.datetime.fromisoformat(date_str.replace("Z", "+00:00")) if time.tzinfo: return time.astimezone(datetime.timezone.utc) diff --git a/flaschengeist/utils/hook.py b/flaschengeist/utils/hook.py index 0af84f3..02db156 100644 --- a/flaschengeist/utils/hook.py +++ b/flaschengeist/utils/hook.py @@ -1,4 +1,4 @@ -_hook_dict = {} +_hook_dict = ({}, {}) class Hook(object): @@ -10,17 +10,34 @@ class Hook(object): self.function = function def __call__(self, *args, **kwargs): - if self.function.__name__ in _hook_dict: - _hook_dict[self.function.__name__](*args, **kwargs) - self.function(*args, **kwargs) + # Hooks before + for function in _hook_dict[0].get(self.function.__name__, []): + function(*args, **kwargs) + # Main function + ret = self.function(*args, **kwargs) + # Hooks after + for function in _hook_dict[1].get(self.function.__name__, []): + function(*args, **kwargs) + return ret -class HookCall(object): - """Decorator for functions to be called if a Hook is called""" +class HookBefore(object): + """Decorator for functions to be called before a Hook-Function is called""" def __init__(self, name): self.name = name def __call__(self, function): - _hook_dict[self.name] = function + _hook_dict[0].setdefault(self.name, []).append(function) + return function + + +class HookAfter(object): + """Decorator for functions to be called after a Hook-Function is called""" + + def __init__(self, name): + self.name = name + + def __call__(self, function): + _hook_dict[1].setdefault(self.name, []).append(function) return function From 40651c327995aab2005c26ccd8b9c71fedb8f3ce Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 18 Nov 2020 01:55:02 +0100 Subject: [PATCH 143/446] [Script] Respect root if running devel server --- run_flaschengeist | 28 ++++++++++++++++++++++------ setup.py | 2 +- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/run_flaschengeist b/run_flaschengeist index 23f338a..3cf034f 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -4,6 +4,26 @@ import argparse import sys import pkg_resources +from werkzeug.middleware.dispatcher import DispatcherMiddleware + +from flaschengeist.config import config + + +class PrefixMiddleware(object): + + def __init__(self, app, prefix=''): + self.app = app + self.prefix = prefix + + def __call__(self, environ, start_response): + + if environ['PATH_INFO'].startswith(self.prefix): + environ['PATH_INFO'] = environ['PATH_INFO'][len(self.prefix):] + environ['SCRIPT_NAME'] = self.prefix + return self.app(environ, start_response) + else: + start_response('404', [('Content-Type', 'text/plain')]) + return ["This url does not belong to the app.".encode()] class InterfaceGenerator: @@ -100,15 +120,11 @@ def run(arguments): app = create_app() with app.app_context(): + app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", "")) if arguments.debug: app.run(arguments.host, arguments.port, debug=True) else: - try: - import bjoern - - bjoern.run(app, arguments.host, arguments.port, reuse_port=True) - except ImportError: - app.run(arguments.host, arguments.port, debug=False) + app.run(arguments.host, arguments.port, debug=False) def export(arguments): diff --git a/setup.py b/setup.py index 527dca7..d97177b 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ setup( # Needed for python < 3.7 "backports-datetime-fromisoformat", ], - extras_require={"ldap": ["flask_ldapconn", "ldap3"], "bjoern": ["bjoern"]}, + extras_require={"ldap": ["flask_ldapconn", "ldap3"]}, entry_points={ "flaschengeist.plugin": [ # Authentication providers From 6612a84cd379af09373ffeaab7109696a4cdfa3a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 18 Nov 2020 01:56:33 +0100 Subject: [PATCH 144/446] [System] avatar URL needs to be generated as path might change --- flaschengeist/config.py | 2 +- flaschengeist/models/user.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 100d6e3..1ae840f 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -47,7 +47,7 @@ def configure_app(app): logging.config.dictConfig(logger_config) if "secret_key" not in config["FLASCHENGEIST"]: - logger.warn("No secret key was configured, please configure one for production systems!") + logger.warning("No secret key was configured, please configure one for production systems!") app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29" else: app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 57ea213..cd1bfce 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,6 +1,7 @@ from datetime import date from typing import Optional +from flask import url_for from sqlalchemy.orm.collections import attribute_mapped_collection from . import ModelSerializeMixin @@ -54,9 +55,9 @@ class User(db.Model, ModelSerializeMixin): firstname: str = db.Column(db.String(50), nullable=False) lastname: str = db.Column(db.String(50), nullable=False) mail: str = db.Column(db.String(60), nullable=False) - avatar_url: Optional[str] = db.Column(db.String(60)) birthday: Optional[date] = db.Column(db.Date) roles: [str] = [] + avatar_url: Optional[str] = "" roles_: [Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") _id = db.Column("id", db.Integer, primary_key=True) @@ -65,6 +66,10 @@ class User(db.Model, ModelSerializeMixin): "_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete" ) + @property + def avatar_url(self): + return url_for('users.get_avatar', userid=self.userid) + @property def roles(self): return [role.name for role in self.roles_] From 4a4930d683c4f584a772fd0fe9570685ba53710c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 18 Nov 2020 02:47:40 +0100 Subject: [PATCH 145/446] [Plugin] Schedule: Mostly final backend implemented. Tested. --- flaschengeist/plugins/schedule/__init__.py | 167 ++++-------------- .../plugins/schedule/event_controller.py | 68 +++---- flaschengeist/plugins/schedule/models.py | 59 +++---- 3 files changed, 88 insertions(+), 206 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index f0d27df..e1b3a81 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -15,6 +15,8 @@ from flaschengeist.controller import userController from . import event_controller, permissions from . import models +from ... import logger +from ...utils.HTTP import no_content schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") @@ -213,7 +215,7 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m raise BadRequest("Invalid date given") -def _add_event_slot(event, data): +def _add_job(event, data): end = None try: start = from_iso_format(data["start"]) @@ -221,11 +223,11 @@ def _add_event_slot(event, data): raise BadRequest("Missing POST parameter") if "end" in data: end = from_iso_format(data["end"]) - event_slot = event_controller.add_event_slot(event, start, end) - if "jobs" in data: - for job_data in data["jobs"]: - job_type = event_controller.get_job_type(job_data["type"]) - event_controller.add_job_slot(event_slot, job_type, job_data["required_jobs"]) + + if "required_services" not in data: + raise BadRequest + job_type = event_controller.get_job_type(data["type"]) + event_controller.add_job(event, job_type, data["required_services"], start, end, comment=data.get("comment")) @schedule_bp.route("/events", methods=["POST"]) @@ -235,7 +237,7 @@ def create_event(current_session): Route: ``/schedule/events`` | Method: ``POST`` - POST-data: See interfaces for Event, can already contain slots + POST-data: See interfaces for Event, can already contain jobs Args: current_session: Session sent with Authorization Header @@ -254,9 +256,9 @@ def create_event(current_session): event = event_controller.create_event( start=start, event_type=event_type, description=data["description"] if "description" in data else None ) - if "slots" in data: - for slot in data["slots"]: - _add_event_slot(event, slot) + if "jobs" in data: + for job in data["jobs"]: + _add_job(event, job) return jsonify(event) @@ -307,14 +309,14 @@ def delete_event(event_id, current_session): return "", NO_CONTENT -@schedule_bp.route("/events//slots", methods=["POST"]) +@schedule_bp.route("/events//jobs", methods=["POST"]) @login_required(permission=permissions.EDIT) -def add_event_slot(event_id, current_session): - """Add an new EventSlot to an Event +def add_job(event_id, current_session): + """Add an new Job to an Event / EventSlot - Route: ``/schedule/events//slots`` | Method: ``POST`` + Route: ``/schedule/events//jobs`` | Method: ``POST`` - POST-data: See TS interface for EventSlot + POST-data: See Job Args: event_id: Identifier of the event @@ -324,139 +326,48 @@ def add_event_slot(event_id, current_session): JSON encoded Event object or HTTP-error """ event = event_controller.get_event(event_id) - data = request.get_json() - if not data: - raise BadRequest("Missing POST parameters") - - _add_event_slot(event, data) + _add_job(event, request.get_json()) return jsonify(event) -@schedule_bp.route("/events//slots/", methods=["PUT"]) -@login_required(permission=permissions.EDIT) -def update_event_slot(event_id, slot_id, current_session): - """Update an EventSlot - - Route: ``/schedule/events//slots/`` | Method: ``PUT`` - - POST-data: See TS interface for EventSlot - - Args: - event_id: Identifier of the event - slot_id: Identifier of the slot - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Event object or HTTP-error - """ - event = event_controller.get_event(event_id) - slot = event_controller.get_event_slot(slot_id) - if slot not in event.slots: - raise NotFound - - data = _event_slot_from_data(request.get_json()) - slot.start = data["start"] - if "end" in data: - slot.end = data["end"] - event_controller.update() - return jsonify(event) - - -@schedule_bp.route("/events//slots/", methods=["DELETE"]) -@login_required(permission=permissions.EDIT) -def delete_event_slot(event_id, slot_id, current_session): - """Delete an EventSlot - - Route: ``/schedule/events//slots/`` | Method: ``DELETE`` - - Args: - event_id: Identifier of the event - slot_id: Identifier of the slot - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Event object or HTTP-error - """ - event = event_controller.get_event(event_id) - event_controller.remove_event_slot(event, slot_id) - return jsonify(event) - - -@schedule_bp.route("/events//slots//jobs", methods=["POST"]) -@login_required(permission=permissions.EDIT) -def add_job_slot(event_id, slot_id, current_session): - """Add an new JobSlot to an Event / EventSlot - - Route: ``/schedule/events//slots//jobs`` | Method: ``POST`` - - POST-data: ``{type: string, required_jobs: number}`` - - Args: - event_id: Identifier of the event - slot_id: Identifier of the slot - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Event object or HTTP-error - """ - event = event_controller.get_event(event_id) - slot = event_controller.get_event_slot(slot_id) - if slot not in event.slots: - raise NotFound - - data = request.get_json() - try: - job_type = event_controller.get_job_type(data["type"]) - required_jobs = data["required_jobs"] - except (KeyError, ValueError): - raise BadRequest("Missing POST parameters") - - event_controller.add_job_slot(slot, job_type, required_jobs) - return jsonify(event) - - -@schedule_bp.route("/events//slots//jobs/", methods=["DELETE"]) +@schedule_bp.route("/events//jobs/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) -def delete_job_slot(event_id, slot_id, job_type, current_session): - """Delete a JobSlot +def delete_job(event_id, job_id, current_session): + """Delete a Job - Route: ``/schedule/events//slots//jobs/`` | Method: ``DELETE`` + Route: ``/events//jobs/`` | Method: ``DELETE`` Args: event_id: Identifier of the event - slot_id: Identifier of the EventSlot - job_type: Identifier of the JobSlot + job_id: Identifier of the Job current_session: Session sent with Authorization Header Returns: - JSON encoded Event object or HTTP-error + HTTP-no-content or HTTP error """ - event = event_controller.get_event(event_id) - job_slot = event_controller.get_job_slot(slot_id, job_type) - event_controller.delete_job_slot(job_slot) - return jsonify(event) + job_slot = event_controller.get_job(job_id, event_id) + event_controller.delete_job(job_slot) + return no_content() -@schedule_bp.route("/events//slots//jobs/", methods=["PUT"]) +@schedule_bp.route("/events//jobs/", methods=["PUT"]) @login_required() -def update_job_slot(event_id, slot_id, job_type, current_session: Session): - """Edit JobSlot or add user to the Slot +def update_job(event_id, job_id, current_session: Session): + """Edit Job or assign user to the Job - Route: ``/schedule/events//slots//jobs/`` | Method: ``PUT`` + Route: ``/events//jobs/`` | Method: ``PUT`` - POST-data: See TS interface for EventSlot or ``{user: {userid: string, value: number}}`` + POST-data: See TS interface for Job or ``{user: {userid: string, value: number}}`` Args: event_id: Identifier of the event - slot_id: Identifier of the slot - job_type: Identifier of the JobSlot + job_id: Identifier of the Job current_session: Session sent with Authorization Header Returns: JSON encoded Event object or HTTP-error """ - event = event_controller.get_event(event_id) - slot = event_controller.get_job_slot(slot_id, job_type) + job = event_controller.get_job(job_id, event_id) data = request.get_json() if not data: @@ -473,17 +384,17 @@ def update_job_slot(event_id, slot_id, job_type, current_session: Session): user != current_session._user and not current_session._user.has_permission(permissions.ASSIGN_OTHER) ): raise Forbidden - event_controller.assign_job(slot, user, value) + event_controller.assign_to_job(job, user, value) except (KeyError, ValueError): raise BadRequest - if "required_jobs" in data: - slot.required_jobs = data["required_jobs"] + if "required_services" in data: + job.required_services = data["required_services"] if "type" in data: - slot.type = event_controller.get_job_type(data["type"]) + job.type = event_controller.get_job_type(data["type"]) event_controller.update() - return jsonify(event) + return jsonify(job.event_) # TODO: JobTransfer diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index b9f0775..44cd246 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -3,7 +3,7 @@ from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.database import db -from flaschengeist.plugins.schedule.models import EventType, Event, EventSlot, JobSlot, JobType, Job +from flaschengeist.plugins.schedule.models import EventType, Event, Job, JobType, Service def update(): @@ -55,6 +55,7 @@ def get_job_types(): def get_job_type(type_id): job_type = JobType.query.get(type_id) + print(job_type) if not job_type: raise NotFound return job_type @@ -119,10 +120,10 @@ def delete_event(event_id): db.session.commit() -def create_event(event_type, start, slots=[], description=None): +def create_event(event_type, start, jobs=[], description=None): try: logger.debug(event_type) - event = Event(start=start, description=description, type=event_type, slots=slots) + event = Event(start=start, description=description, type=event_type, jobs=jobs) db.session.add(event) db.session.commit() return event @@ -131,59 +132,38 @@ def create_event(event_type, start, slots=[], description=None): raise BadRequest -def get_event_slot(slot_id): - slot = EventSlot.query.get(slot_id) - if slot is None: +def get_job(job_slot_id, event_id): + js = Job.query.filter(Job.id == job_slot_id).filter(Job._event_id == event_id).one_or_none() + if js is None: raise NotFound - return slot + return js -def add_event_slot(event, start, end=None): - event_slot = EventSlot(start=start, end=end, event_=event) - if start < event.start: - raise BadRequest("Start before event start") - db.session.add(event_slot) - db.session.commit() - return event_slot +def add_job(event, job_type, required_services, start, end=None, comment=None): + job = Job(required_services=required_services, type=job_type, start=start, end=end, comment=comment) + event.jobs.append(job) + update() + return job -def remove_event_slot(event, slot_id): - slot = get_event_slot(slot_id) - if slot in event.slots: - event.slots.remove(slot) - else: - raise NotFound - db.session.commit() - - -def get_job_slot(event_slot_id, job_type): - jt = ( - JobSlot.query.filter(JobSlot._type_id == job_type).filter(JobSlot._event_slot_id == event_slot_id).one_or_none() - ) - if jt is None: - raise NotFound - return jt - - -def add_job_slot(event_slot, job_type, required_jobs): - job_slot = JobSlot(type=job_type, required_jobs=required_jobs, event_slot_=event_slot) +def update(): try: - db.session.add(job_slot) db.session.commit() except IntegrityError: - raise BadRequest("JobSlot with that type already exists on this EventSlot") + logger.debug("Error, looks like a Job with that type already exists on an event", exc_info=True) + raise BadRequest() -def delete_job_slot(job_slot): - db.session.delete(job_slot) +def delete_job(job: Job): + db.session.delete(job) db.session.commit() -def assign_job(job_slot, user, value): - job = Job.query.get((job_slot.id_, user._id)) - if job: - job.value = value +def assign_to_job(job: Job, user, value): + service = Service.query.get((job.id, user._id)) + if service: + service.value = value else: - job = Job(user_=user, value=value, slot_=job_slot) - db.session.add(job) + service = Service(user_=user, value=value, job_=job) + db.session.add(service) db.session.commit() diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index 62cbaac..e09b81c 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -13,13 +13,13 @@ from flaschengeist.database import db class EventType(db.Model, ModelSerializeMixin): - __tablename__ = "event_type" + __tablename__ = "schedule_event_type" id_: int = db.Column("id", db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) class JobType(db.Model, ModelSerializeMixin): - __tablename__ = "job_type" + __tablename__ = "schedule_job_type" id: int = db.Column("id", db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) @@ -29,35 +29,38 @@ class JobType(db.Model, ModelSerializeMixin): ######## -class Job(db.Model, ModelSerializeMixin): - __tablename__ = "job" +class Service(db.Model, ModelSerializeMixin): + __tablename__ = "schedule_service" userid: str = "" value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) - _slot_id = db.Column("slot_id", db.Integer, db.ForeignKey("job_slot.id"), nullable=False, primary_key=True) + _job_id = db.Column("job_id", db.Integer, db.ForeignKey("schedule_job.id"), nullable=False, primary_key=True) _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True) user_: User = db.relationship("User") - slot_ = db.relationship("JobSlot") + job_ = db.relationship("Job") @property def userid(self): return self.user_.userid -class JobSlot(db.Model, ModelSerializeMixin): - __tablename__ = "job_slot" - _type_id = db.Column("type_id", db.Integer, db.ForeignKey("job_type.id"), nullable=False) - _event_slot_id = db.Column("event_slot_id", db.Integer, db.ForeignKey("event_slot.id"), nullable=False) +class Job(db.Model, ModelSerializeMixin): + __tablename__ = "schedule_job" + _type_id = db.Column("type_id", db.Integer, db.ForeignKey("schedule_job_type.id"), nullable=False) + _event_id = db.Column("event_id", db.Integer, db.ForeignKey("schedule_event.id"), nullable=False) - id_: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column("id", db.Integer, primary_key=True) + start: datetime = db.Column(UtcDateTime, nullable=False) + end: Optional[datetime] = db.Column(UtcDateTime) + comment: str = db.Column(db.String(256)) type: JobType = db.relationship("JobType") - users: [Job] = db.relationship("Job", back_populates="slot_") - required_jobs: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False)) + services: [Service] = db.relationship("Service", back_populates="job_") + required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) - event_slot_ = db.relationship("EventSlot", back_populates="jobs") + event_ = db.relationship("Event", back_populates="jobs") - __table_args__ = (UniqueConstraint("type_id", "event_slot_id", name="_type_event_slot_uc"),) + __table_args__ = (UniqueConstraint("type_id", "start", name="_type_start_uc"),) ########## @@ -65,30 +68,18 @@ class JobSlot(db.Model, ModelSerializeMixin): ########## -class EventSlot(db.Model, ModelSerializeMixin): - """Model for an EventSlot""" - - __tablename__ = "event_slot" - _event_id = db.Column("event_id", db.Integer, db.ForeignKey("event.id"), nullable=False) - - id: int = db.Column(db.Integer, primary_key=True) - start: datetime = db.Column(UtcDateTime) - end: Optional[datetime] = db.Column(UtcDateTime) - jobs: [JobSlot] = db.relationship("JobSlot", back_populates="event_slot_") - - event_ = db.relationship("Event", back_populates="slots") - - class Event(db.Model, ModelSerializeMixin): """Model for an Event""" - __tablename__ = "event" - _type_id = db.Column("type_id", db.Integer, db.ForeignKey("event_type.id", ondelete="CASCADE"), nullable=False) + __tablename__ = "schedule_event" + _type_id = db.Column( + "type_id", db.Integer, db.ForeignKey("schedule_event_type.id", ondelete="CASCADE"), nullable=False + ) id: int = db.Column(db.Integer, primary_key=True) start: datetime = db.Column(UtcDateTime, nullable=False) - description: Optional[str] = db.Column(db.String(240)) + description: Optional[str] = db.Column(db.String(255)) type: EventType = db.relationship("EventType") - slots: [EventSlot] = db.relationship( - "EventSlot", back_populates="event_", cascade="all,delete,delete-orphan", order_by="EventSlot.start" + jobs: [Job] = db.relationship( + "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" ) From 737dd9d5cffa2c9ee1fa5b58930f6b2f2b41dac9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 18 Nov 2020 02:48:44 +0100 Subject: [PATCH 146/446] [Plugin]auth: Fixed possible issue with POST paramenters on login --- flaschengeist/models/user.py | 2 +- flaschengeist/plugins/auth/__init__.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index cd1bfce..98a2f4a 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -68,7 +68,7 @@ class User(db.Model, ModelSerializeMixin): @property def avatar_url(self): - return url_for('users.get_avatar', userid=self.userid) + return url_for("users.get_avatar", userid=self.userid) @property def roles(self): diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 8bd867f..95ba2d3 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -34,9 +34,9 @@ def login(): logger.debug("Start log in.") data = request.get_json() try: - userid = data["userid"] - password = data["password"] - except (KeyError, ValueError): + userid = str(data["userid"]) + password = str(data["password"]) + except (KeyError, ValueError, TypeError): raise BadRequest("Missing parameter(s)") logger.debug("search user {{ {} }} in database".format(userid)) From 57930837ac444f536b176cca7c6e56f33176bb1c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 18 Nov 2020 02:55:31 +0100 Subject: [PATCH 147/446] [Plugin] balance: Added reverting feature --- flaschengeist/plugins/balance/__init__.py | 49 ++++++++++++++----- .../plugins/balance/balance_controller.py | 35 +++++++++++-- flaschengeist/plugins/balance/models.py | 37 +++++++++----- flaschengeist/plugins/balance/permissions.py | 3 ++ flaschengeist/plugins/message_mail.py | 4 +- 5 files changed, 97 insertions(+), 31 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 862cdad..425feba 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -3,17 +3,17 @@ Extends users plugin with balance functions """ -from http.client import NO_CONTENT from datetime import datetime, timezone -from flask import Blueprint, jsonify, request +from flask import Blueprint, request from werkzeug.exceptions import Forbidden, BadRequest from flaschengeist import logger +from flaschengeist.utils import HTTP from flaschengeist.models.session import Session from flaschengeist.utils.datetime import from_iso_format from flaschengeist.decorator import login_required from flaschengeist.controller import userController -from flaschengeist.plugins import Plugin, update_user_hook +from flaschengeist.plugins import Plugin, before_update_user from . import balance_controller, permissions, models @@ -26,7 +26,7 @@ class BalancePlugin(Plugin): def __init__(self, config): super().__init__(blueprint=balance_bp, permissions=permissions.permissions) - @update_user_hook + @before_update_user def set_default_limit(user): if "limit" in config: limit = config["limit"] @@ -59,7 +59,7 @@ def get_limit(userid, current_session: Session): ): raise Forbidden - return jsonify({"limit": balance_controller.get_limit(user)}) + return {"limit": balance_controller.get_limit(user)} @balance_bp.route("/users//balance/limit", methods=["PUT"]) @@ -88,7 +88,7 @@ def set_limit(userid, current_session: Session): except (TypeError, KeyError): raise BadRequest balance_controller.set_limit(user, limit) - return "", NO_CONTENT + return HTTP.no_content() @balance_bp.route("/users//balance", methods=["GET"]) @@ -125,8 +125,8 @@ def get_balance(userid, current_session: Session): else: end = datetime.now(tz=timezone.utc) - balance = balance_controller.get(user, start, end) - return jsonify({"credit": balance[0], "debit": balance[1], "balance": balance[2]}) + balance = balance_controller.get_balance(user, start, end) + return {"credit": balance[0], "debit": balance[1], "balance": balance[2]} @balance_bp.route("/users//balance", methods=["PUT"]) @@ -144,7 +144,7 @@ def change_balance(userid, current_session: Session): current_session: Session sent with Authorization Header Returns: - JSON object containing credit, debit and balance or HTTP error + JSON encoded transaction (201) or HTTP error """ data = request.get_json() @@ -164,8 +164,7 @@ def change_balance(userid, current_session: Session): if (sender == current_session._user and sender.has_permission(permissions.SEND)) or ( sender != current_session._user and current_session._user.has_permission(permissions.SEND_OTHER) ): - balance_controller.send(sender, user, data["amount"], current_session._user) - return "", NO_CONTENT + return HTTP.created(balance_controller.send(sender, user, data["amount"], current_session._user)) elif ( amount < 0 @@ -174,7 +173,31 @@ def change_balance(userid, current_session: Session): or current_session._user.has_permission(permissions.DEBIT) ) ) or (amount > 0 and current_session._user.has_permission(permissions.CREDIT)): - balance_controller.change_balance(user, data["amount"], current_session._user) - return "", NO_CONTENT + return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session._user)) raise Forbidden + + +@balance_bp.route("/balance/", methods=["DELETE"]) +@login_required() +def reverse_transaction(transaction_id, current_session: Session): + """Reverse a transaction + + Route: ``/balance/`` | Method: ``DELETE`` + + Args: + transaction_id: Identifier of the transaction + current_session: Session sent with Authorization Header + + Returns: + JSON encoded reversal (transaction) (201) or HTTP error + """ + + transaction = balance_controller.get_transaction(transaction_id) + if current_session._user.has_permission(permissions.REVERSAL) or ( + transaction.sender_ == current_session._user + and (datetime.now(tz=timezone.utc) - transaction.time).total_seconds() < 10 + ): + reversal = balance_controller.reverse_transaction(transaction, current_session._user) + return HTTP.created(reversal) + raise Forbidden diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 596f9ce..57925f6 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -5,13 +5,14 @@ from sqlalchemy import func from datetime import datetime, timezone -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, NotFound from flaschengeist.database import db from flaschengeist.models.user import User from .models import Transaction from . import permissions +from ... import logger __attribute_limit = "balance_limit" @@ -26,7 +27,7 @@ def get_limit(user: User) -> float: return user.get_attribute(__attribute_limit, default=None) -def get(user, start: datetime = None, end: datetime = None): +def get_balance(user, start: datetime = None, end: datetime = None): if not start: start = datetime.fromtimestamp(0, tz=timezone.utc) if not end: @@ -35,6 +36,7 @@ def get(user, start: datetime = None, end: datetime = None): credit = ( db.session.query(func.sum(Transaction.amount)) .filter(Transaction.receiver_ == user) + .filter(Transaction._reversal_id.is_(None)) # ignore reverted transactions .filter(start <= Transaction.time) .filter(Transaction.time <= end) .scalar() @@ -43,6 +45,7 @@ def get(user, start: datetime = None, end: datetime = None): debit = ( db.session.query(func.sum(Transaction.amount)) .filter(Transaction.sender_ == user) + .filter(Transaction._reversal_id.is_(None)) # ignore reverted transactions .filter(start <= Transaction.time) .filter(Transaction.time <= end) .scalar() @@ -59,13 +62,15 @@ def send(sender: User, receiver, amount: float, author: User): receiver: User who receives the amount amount: Amount to send author: User authoring this transaction + Returns: + Transaction that was created Raises: BadRequest if amount <= 0 """ if amount <= 0: raise BadRequest - if sender.has_attribute(__attribute_limit): + if sender and sender.has_attribute(__attribute_limit): if (get(sender)[2] - amount) < sender.get_attribute(__attribute_limit) and not author.has_permission( permissions.EXCEED_LIMIT ): @@ -74,6 +79,7 @@ def send(sender: User, receiver, amount: float, author: User): transaction = Transaction(sender_=sender, receiver_=receiver, amount=amount, author_=author) db.session.add(transaction) db.session.commit() + return transaction def change_balance(user, amount: float, author): @@ -86,4 +92,25 @@ def change_balance(user, amount: float, author): """ sender = user if amount < 0 else None receiver = user if amount > 0 else None - send(sender, receiver, abs(amount), author) + return send(sender, receiver, abs(amount), author) + + +def get_transaction(transaction_id) -> Transaction: + transaction = Transaction.query.get(transaction_id) + if not transaction: + raise NotFound + return transaction + + +def reverse_transaction(transaction: Transaction, author: User): + """Reverse a transaction + + Args: + transaction: Transaction to reverse + author: User that wants the transaction to be reverted + """ + reversal = send(transaction.receiver_, transaction.sender_, transaction.amount, author) + reversal.reversal = transaction + transaction.reversal = reversal + db.session.commit() + return reversal diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 5219751..8df224c 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -1,4 +1,5 @@ -from datetime import datetime, timezone +from datetime import datetime +from typing import Optional from flaschengeist.models.user import User @@ -8,19 +9,22 @@ from flaschengeist.models import ModelSerializeMixin, UtcDateTime class Transaction(db.Model, ModelSerializeMixin): __tablename__ = "balance_transaction" - id: int = db.Column("id", db.Integer, primary_key=True) - time: datetime = db.Column(UtcDateTime, nullable=False, default=datetime.now(tz=timezone.utc)) - amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) - - # Dummy properties used for JSON serialization (userid instead of full user) - sender_id: str = "" - receiver_id: str = "" - author_id: str = "" - # Protected foreign key properties _receiver_id = db.Column("receiver_id", db.Integer, db.ForeignKey("user.id")) _sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id")) _author_id = db.Column("author_id", db.Integer, db.ForeignKey("user.id"), nullable=False) + _reversal_id = db.Column("reversal_id", db.Integer, db.ForeignKey("balance_transaction.id")) + + # Public and exported member + id: int = db.Column("id", db.Integer, primary_key=True) + time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) + amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) + reversal: Optional["Transaction"] = db.relationship("Transaction", foreign_keys=[_reversal_id]) + + # Dummy properties used for JSON serialization (userid instead of full user) + sender_id: Optional[str] = "" + receiver_id: Optional[str] = "" + author_id: Optional[str] = "" # Not exported relationships just in backend only sender_: User = db.relationship("User", foreign_keys=[_sender_id]) @@ -29,12 +33,21 @@ class Transaction(db.Model, ModelSerializeMixin): @property def sender_id(self): - return self.sender_.userid + return self.sender_.userid if self.sender_ else None @property def receiver_id(self): - return self.receiver_.userid + return self.receiver_.userid if self.receiver_ else None @property def author_id(self): return self.author_.userid + + # Override to prevent circular dependencies (endless JSON) + def serialize(self): + d = super().serialize() + if d["reversal"]: + d["reversal"].reversal = None + else: + d["reversal"] = None + return d diff --git a/flaschengeist/plugins/balance/permissions.py b/flaschengeist/plugins/balance/permissions.py index 713d204..feb0c81 100644 --- a/flaschengeist/plugins/balance/permissions.py +++ b/flaschengeist/plugins/balance/permissions.py @@ -21,4 +21,7 @@ SET_LIMIT = "balance_set_limit" EXCEED_LIMIT = "balance_exceed_limit" """Allow sending / sub while exceeding the set limit""" +REVERSAL = "balance_reversal" +"""Allow reverting transactions""" + permissions = [value for key, value in globals().items() if not key.startswith("_")] diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index b3f34b7..9e6875f 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -7,7 +7,7 @@ from flaschengeist.models.user import User from flaschengeist.controller import userController from flaschengeist.controller.messageController import Message -from . import Plugin, send_message_hook +from . import Plugin class MailMessagePlugin(Plugin): @@ -20,7 +20,7 @@ class MailMessagePlugin(Plugin): self.crypt = config["CRYPT"] self.mail = config["MAIL"] - @send_message_hook + # @send_message_hook def dummy_send(msg): self.send_mail(msg) From 4b2cb56fbe875e191f4dd34d347e82ed38fa82b4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 18 Nov 2020 03:17:04 +0100 Subject: [PATCH 148/446] [System] Fix issue with avatar in userController --- flaschengeist/controller/userController.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 910951e..77615ba 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -98,8 +98,5 @@ def load_avatar(user: User): def save_avatar(user, avatar): - user.avatar_url = "" - r = current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) - if not user.avatar_url: - user.avatar_url = url_for("users.get_avatar", userid=user.userid) + current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) db.session.commit() From 1b1dd8d7a7f6d8ca823b802740349ed1d387cf9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 20 Nov 2020 20:16:08 +0100 Subject: [PATCH 149/446] [Avatar] Fix, sodass korrekte url rausgegeben wird. --- flaschengeist/models/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 98a2f4a..219900b 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,7 +1,7 @@ from datetime import date from typing import Optional -from flask import url_for +from flask import url_for, request from sqlalchemy.orm.collections import attribute_mapped_collection from . import ModelSerializeMixin @@ -68,7 +68,7 @@ class User(db.Model, ModelSerializeMixin): @property def avatar_url(self): - return url_for("users.get_avatar", userid=self.userid) + return f'{request.url_root}/{url_for("users.get_avatar", userid=self.userid)}' @property def roles(self): From 4534d0ff157919b813ffa1d643dddc49d58ce5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 20 Nov 2020 20:16:08 +0100 Subject: [PATCH 150/446] Revert "[Avatar] Fix, sodass korrekte url rausgegeben wird." This reverts commit 1b1dd8d7a7f6d8ca823b802740349ed1d387cf9e. --- flaschengeist/models/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 219900b..98a2f4a 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,7 +1,7 @@ from datetime import date from typing import Optional -from flask import url_for, request +from flask import url_for from sqlalchemy.orm.collections import attribute_mapped_collection from . import ModelSerializeMixin @@ -68,7 +68,7 @@ class User(db.Model, ModelSerializeMixin): @property def avatar_url(self): - return f'{request.url_root}/{url_for("users.get_avatar", userid=self.userid)}' + return url_for("users.get_avatar", userid=self.userid) @property def roles(self): From 991ffb2766cfebca0723edd01ec13d23b0378911 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 25 Nov 2020 20:35:11 +0100 Subject: [PATCH 151/446] [Plugin]balance: Fixed typo in function name and fixed db model --- flaschengeist/plugins/balance/__init__.py | 2 +- flaschengeist/plugins/balance/balance_controller.py | 2 +- flaschengeist/plugins/balance/models.py | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 425feba..55ff854 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -164,7 +164,7 @@ def change_balance(userid, current_session: Session): if (sender == current_session._user and sender.has_permission(permissions.SEND)) or ( sender != current_session._user and current_session._user.has_permission(permissions.SEND_OTHER) ): - return HTTP.created(balance_controller.send(sender, user, data["amount"], current_session._user)) + return HTTP.created(balance_controller.send(sender, user, amount, current_session._user)) elif ( amount < 0 diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 57925f6..e305b0b 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -71,7 +71,7 @@ def send(sender: User, receiver, amount: float, author: User): raise BadRequest if sender and sender.has_attribute(__attribute_limit): - if (get(sender)[2] - amount) < sender.get_attribute(__attribute_limit) and not author.has_permission( + if (get_balance(sender)[2] - amount) < sender.get_attribute(__attribute_limit) and not author.has_permission( permissions.EXCEED_LIMIT ): raise BadRequest("Limit exceeded") diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 8df224c..811124a 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -19,7 +19,9 @@ class Transaction(db.Model, ModelSerializeMixin): id: int = db.Column("id", db.Integer, primary_key=True) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) - reversal: Optional["Transaction"] = db.relationship("Transaction", foreign_keys=[_reversal_id]) + reversal: Optional["Transaction"] = db.relationship( + "Transaction", uselist=False, post_update=True, foreign_keys=[_reversal_id] + ) # Dummy properties used for JSON serialization (userid instead of full user) sender_id: Optional[str] = "" From a9970bec5bbd0994a825959927ad6468c7b25312 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 14 Jan 2021 19:09:01 +0100 Subject: [PATCH 152/446] Allow mail as login name. Implemented #428 --- flaschengeist/controller/userController.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 77615ba..b359d92 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -10,9 +10,15 @@ from flaschengeist import logger def login_user(username, password): logger.info("login user {{ {} }}".format(username)) - try: - user = User.query.filter(User.userid == username).one() - except NoResultFound: + mail = username.split("@") + mail = len(mail) == 2 and len(mail[0]) > 0 and len(mail[1]) > 0 + + query = User.userid == username + if mail: + query |= User.mail == username + user = User.query.filter(query).one_or_none() + if not user: + logger.debug("User not found in Database.") user = User(userid=username) db.session.add(user) if current_app.config["FG_AUTH_BACKEND"].login(user, password): From 23268f6557e4d4c1d4b1409f11bae99ec8d60db8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 18 Jan 2021 16:11:44 +0100 Subject: [PATCH 153/446] Fixed hook for mail sending --- flaschengeist/plugins/message_mail.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index 9e6875f..3d2f162 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -1,9 +1,10 @@ import smtplib -from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart from flaschengeist import logger from flaschengeist.models.user import User +from flaschengeist.utils.hook import HookAfter from flaschengeist.controller import userController from flaschengeist.controller.messageController import Message @@ -20,7 +21,7 @@ class MailMessagePlugin(Plugin): self.crypt = config["CRYPT"] self.mail = config["MAIL"] - # @send_message_hook + @HookAfter("send_message") def dummy_send(msg): self.send_mail(msg) From 559c8c5c9c8fffc84192e8d61f9f988e047f5f1e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 18 Jan 2021 16:12:11 +0100 Subject: [PATCH 154/446] Implemented function to delete all active sessions of an user --- flaschengeist/controller/sessionController.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py index 0737cd2..a3e08f8 100644 --- a/flaschengeist/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -92,6 +92,16 @@ def get_users_sessions(user): return Session.query.filter(Session._user == user) +def delete_sessions(user): + """Deletes all active sessions of a user + + Args: + user (User): User to delete all sessions for + """ + Session.query.filter(Session._user_id == user._id).delete() + db.session.commit() + + def delete_session(token: Session): """Deletes given Session From 1f93bc6d8042f6ffa1ff205a16f26ee97a5fb10c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 18 Jan 2021 16:17:40 +0100 Subject: [PATCH 155/446] [System] Implemented password reset function in user controller --- flaschengeist/controller/userController.py | 78 ++++++++++++++++++---- flaschengeist/models/user.py | 18 +++-- 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index b359d92..1781755 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,22 +1,19 @@ -from flask import current_app, url_for -from sqlalchemy.orm.exc import NoResultFound -from werkzeug.exceptions import NotFound, BadRequest +import secrets +from flask import current_app +from datetime import datetime, timedelta, timezone +from werkzeug.exceptions import NotFound, BadRequest, Forbidden -from flaschengeist.utils.hook import Hook -from flaschengeist.models.user import User, Role -from flaschengeist.database import db from flaschengeist import logger +from flaschengeist.database import db +from flaschengeist.utils.hook import Hook +from flaschengeist.models.user import User, Role, _PasswordReset +from flaschengeist.controller import messageController, sessionController def login_user(username, password): logger.info("login user {{ {} }}".format(username)) - mail = username.split("@") - mail = len(mail) == 2 and len(mail[0]) > 0 and len(mail[1]) > 0 - query = User.userid == username - if mail: - query |= User.mail == username - user = User.query.filter(query).one_or_none() + user = find_user(username) if not user: logger.debug("User not found in Database.") user = User(userid=username) @@ -27,6 +24,49 @@ def login_user(username, password): return None +def request_reset(user: User): + logger.debug(f"New password reset request for {user.userid}") + reset = _PasswordReset.query.get(user._id) + if not reset: + reset = _PasswordReset(_user_id=user._id) + db.session.add(reset) + + expires = datetime.now(tz=timezone.utc) + if not reset.expires or reset.expires < expires: + expires = expires + timedelta(hours=12) + reset.expires = expires + reset.token = secrets.token_urlsafe(16) + + subject = "Flaschengeist - Passwort zurücksetzten" + domain = "flaschengeist.local" + text = f"""Hallo {user.display_name}, +Jemand hat das Zurücksetzen des Passworts für dein Flaschengeist Benutzerkonto angefordert. + +Benutzername: {user.userid} + +Falls das nicht beabsichtigt war, ignoriere diese E-Mail einfach. Es wird dann nichts passieren. + +Um dein Passwort zurückzusetzen, besuche folgende Adresse, der Link ist 12 Stunden gültig: + + + """ + db.session.commit() + messageController.send_message(messageController.Message(user, text, subject)) + + +def reset_password(token: str, password: str): + reset = _PasswordReset.query.filter(_PasswordReset.token == token).one_or_none() + logger.debug(f"Token is {'valid' if reset else 'invalid'}") + if not reset or reset.expires < datetime.now(tz=timezone.utc): + raise Forbidden + + modify_user(reset.user, None, password) + sessionController.delete_sessions(reset.user) + + db.session.delete(reset) + db.session.commit() + + @Hook def update_user(user): current_app.config["FG_AUTH_BACKEND"].update_user(user) @@ -60,6 +100,10 @@ def modify_user(user, password, new_password=None): """ current_app.config["FG_AUTH_BACKEND"].modify_user(user, password, new_password) + if new_password: + # TODO: Password changed mail + logger.error(f"Password changed for user {user.userid}") + def get_users(): return User.query.all() @@ -76,6 +120,16 @@ def get_user(uid): return user +def find_user(uid_mail): + mail = uid_mail.split("@") + mail = len(mail) == 2 and len(mail[0]) > 0 and len(mail[1]) > 0 + + query = User.userid == uid_mail + if mail: + query |= User.mail == uid_mail + return User.query.filter(query).one_or_none() + + def delete(user): current_app.config["FG_AUTH_BACKEND"].delete_user(user) db.session.delete(user) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 98a2f4a..a25fd33 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,11 +1,10 @@ -from datetime import date -from typing import Optional - from flask import url_for +from typing import Optional +from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection -from . import ModelSerializeMixin -from flaschengeist.database import db +from ..database import db +from . import ModelSerializeMixin, UtcDateTime association_table = db.Table( "user_x_role", @@ -103,6 +102,15 @@ class _UserAttribute(db.Model, ModelSerializeMixin): value: any = db.Column(db.PickleType(protocol=4)) +class _PasswordReset(db.Model): + """Table containing password reset requests""" + __tablename__ = "password_reset" + _user_id: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), primary_key=True) + user: User = db.relationship("User", foreign_keys=[_user_id]) + token: str = db.Column(db.String(30)) + expires: datetime = db.Column(UtcDateTime) + + class _Avatar: """Wrapper class for avatar binaries""" From 049b64ffd58329ddeecccfb0336768ed4dbe5320 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 18 Jan 2021 16:18:16 +0100 Subject: [PATCH 156/446] [Plugin] auth: Implemented REST endpoint for password reset --- flaschengeist/plugins/auth/__init__.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 95ba2d3..fa9e789 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -2,12 +2,13 @@ Allow management of authentication, login, logout, etc. """ -from flask import Blueprint, request, jsonify from http.client import CREATED, NO_CONTENT -from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized +from flask import Blueprint, request, jsonify +from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized, NotFound from flaschengeist import logger from flaschengeist.plugins import Plugin +from flaschengeist.utils.HTTP import no_content from flaschengeist.decorator import login_required from flaschengeist.controller import sessionController, userController @@ -162,3 +163,20 @@ def get_assocd_user(token, current_session, **kwargs): # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden return jsonify(session._user) + + +@auth_bp.route("/auth/reset", methods=["POST"]) +def reset_password(): + data = request.get_json() + if "userid" in data: + try: + user = userController.find_user(data["userid"]) + userController.request_reset(user) + except NotFound: + pass + elif "password" in data and "token" in data: + userController.reset_password(data["token"], data["password"]) + else: + raise BadRequest("Missing parameter(s)") + + return no_content() From 7ec37914a13b3ef6c00748dad539d59fc4a8ad5f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 18 Jan 2021 18:05:10 +0100 Subject: [PATCH 157/446] [System] Send welcome and password-changed notifications, allow custom text per config file --- flaschengeist.example.toml | 31 +++++++++++++++ flaschengeist/controller/userController.py | 45 ++++++++++++++-------- flaschengeist/models/user.py | 2 +- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/flaschengeist.example.toml b/flaschengeist.example.toml index 6953678..efe1efe 100644 --- a/flaschengeist.example.toml +++ b/flaschengeist.example.toml @@ -7,6 +7,8 @@ auth = "auth_plain" #root = /api # Set secret key secret_key = "V3ryS3cr3t" +# Domain used by frontend +#domain = "flaschengeist.local" [LOGGING] file = "/tmp/flaschengeist-debug.log" @@ -34,6 +36,35 @@ enabled = true # admin_dn = # default_gid = +[MESSAGES] +welcome_subject = "Welcome to Flaschengeist {name}" +welcome_text = ''' +Hello {name}! +Welcome to Flaschengeist! +Have fun :) +''' + +password_subject = "Flaschengeist - Password reset" +password_text = ''' +Hello {name}! +There was a password reset request for username: {username} + +To change your password, click on this link: +{link} +''' + +password_changed_subject = "Flaschengeist - Password changed" +password_changed_text = ''' +Hello {name}! +Your password was changed for username: {username} + +If this was not you, please contact the support. +''' + +################## +# PLUGINS # +################## + #[users] # always enabled # diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 1781755..eefcdeb 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone from werkzeug.exceptions import NotFound, BadRequest, Forbidden from flaschengeist import logger +from flaschengeist.config import config from flaschengeist.database import db from flaschengeist.utils.hook import Hook from flaschengeist.models.user import User, Role, _PasswordReset @@ -35,26 +36,22 @@ def request_reset(user: User): if not reset.expires or reset.expires < expires: expires = expires + timedelta(hours=12) reset.expires = expires - reset.token = secrets.token_urlsafe(16) + reset.token = secrets.token_urlsafe(24) + db.session.commit() - subject = "Flaschengeist - Passwort zurücksetzten" - domain = "flaschengeist.local" - text = f"""Hallo {user.display_name}, -Jemand hat das Zurücksetzen des Passworts für dein Flaschengeist Benutzerkonto angefordert. - -Benutzername: {user.userid} - -Falls das nicht beabsichtigt war, ignoriere diese E-Mail einfach. Es wird dann nichts passieren. - -Um dein Passwort zurückzusetzen, besuche folgende Adresse, der Link ist 12 Stunden gültig: - - - """ - db.session.commit() + subject = str(config["MESSAGES"]["password_subject"]).format(name=user.display_name, username=user.userid) + text = str(config["MESSAGES"]["password_text"]).format( + name=user.display_name, + username=user.userid, + link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}' + ) messageController.send_message(messageController.Message(user, text, subject)) def reset_password(token: str, password: str): + if len(token) != 32: + raise BadRequest + reset = _PasswordReset.query.filter(_PasswordReset.token == token).one_or_none() logger.debug(f"Token is {'valid' if reset else 'invalid'}") if not reset or reset.expires < datetime.now(tz=timezone.utc): @@ -101,8 +98,13 @@ def modify_user(user, password, new_password=None): current_app.config["FG_AUTH_BACKEND"].modify_user(user, password, new_password) if new_password: - # TODO: Password changed mail - logger.error(f"Password changed for user {user.userid}") + logger.debug(f"Password changed for user {user.userid}") + subject = str(config["MESSAGES"]["password_changed_subject"]).format(name=user.display_name, username=user.userid) + text = str(config["MESSAGES"]["password_changed_text"]).format( + name=user.display_name, + username=user.userid, + ) + messageController.send_message(messageController.Message(user, text, subject)) def get_users(): @@ -150,6 +152,15 @@ def register(data): db.session.add(user) db.session.commit() + + if user.mail and len(user.mail) > 3: + subject = str(config["MESSAGES"]["welcome_subject"]).format(name=user.display_name, username=user.userid) + text = str(config["MESSAGES"]["welcome_text"]).format( + name=user.display_name, + username=user.userid, + ) + messageController.send_message(messageController.Message(user, text, subject)) + return user diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index a25fd33..62940d8 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -107,7 +107,7 @@ class _PasswordReset(db.Model): __tablename__ = "password_reset" _user_id: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), primary_key=True) user: User = db.relationship("User", foreign_keys=[_user_id]) - token: str = db.Column(db.String(30)) + token: str = db.Column(db.String(32)) expires: datetime = db.Column(UtcDateTime) From d0db878a5c573682999cce8ca728cc183f0455a1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 18 Jan 2021 18:31:13 +0100 Subject: [PATCH 158/446] [Plugin] auth_ldap, balance: Some minor reformatting --- flaschengeist/plugins/auth_ldap/__init__.py | 7 ++++--- flaschengeist/plugins/balance/__init__.py | 5 +---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 619ac94..541bac9 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -157,6 +157,10 @@ class AuthLDAP(AuthPlugin): raise NotFound def set_avatar(self, user, avatar: _Avatar): + if self.admin_dn is None: + logger.error("admin_dn missing in ldap config!") + raise InternalServerError + if avatar.mimetype != "image/jpeg": # Try converting using Pillow (if installed) try: @@ -174,9 +178,6 @@ class AuthLDAP(AuthPlugin): logger.debug(f"Could not convert avatar from '{avatar.mimetype}' to JPEG") raise BadRequest("Unsupported image format") - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") - raise InternalServerError dn = user.get_attribute("DN") ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 55ff854..a3b051d 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -63,7 +63,7 @@ def get_limit(userid, current_session: Session): @balance_bp.route("/users//balance/limit", methods=["PUT"]) -@login_required() +@login_required(permissions.SET_LIMIT) def set_limit(userid, current_session: Session): """Set the limit of an user @@ -79,9 +79,6 @@ def set_limit(userid, current_session: Session): HTTP-200 or HTTP error """ user = userController.get_user(userid) - if not current_session._user.has_permission(permissions.SET_LIMIT): - raise Forbidden - data = request.get_json() try: limit = data["limit"] From 68512a9851226c2bd70db47a04f9470b92478056 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 19 Jan 2021 03:29:26 +0100 Subject: [PATCH 159/446] [Plugin] auth_ldap: Implemented find_user * Search for user inside of auth backend --- flaschengeist/plugins/__init__.py | 10 +++++ flaschengeist/plugins/auth_ldap/__init__.py | 50 ++++++++++++++------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index a61e7f3..2aa8234 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -72,6 +72,16 @@ class AuthPlugin(Plugin): """ pass + def find_user(self, userid, mail=None): + """Find an user by userid or mail + Args: + userid: Userid to search + mail: If set, mail to search + Returns: + None or User + """ + return None + def modify_user(self, user, password, new_password=None): """If backend is using (writeable) external data, then update the external database with the user provided. User might have roles not existing on the external database, so you might have to create those. diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 541bac9..f666397 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -52,23 +52,16 @@ class AuthLDAP(AuthPlugin): return False return self.ldap.authenticate(user.userid, password, "uid", self.dn) + def find_user(self, userid, mail=None): + attr = self.__find(userid, mail) + if attr: + user = User(userid=attr["uid"][0]) + self.__update(user, attr) + return user + def update_user(self, user): - self.ldap.connection.search( - "ou=user,{}".format(self.dn), - "(uid={})".format(user.userid), - SUBTREE, - attributes=["uid", "givenName", "sn", "mail"], - ) - r = self.ldap.connection.response[0]["attributes"] - if r["uid"][0] == user.userid: - user.set_attribute("DN", self.ldap.connection.response[0]["dn"]) - user.firstname = r["givenName"][0] - user.lastname = r["sn"][0] - if r["mail"]: - user.mail = r["mail"][0] - if "displayName" in r: - user.display_name = r["displayName"][0] - userController.set_roles(user, self._get_groups(user.userid), create=True) + attr = self.__find(user.userid) + self.__update(user, attr) def create_user(self, user, password): if self.admin_dn is None: @@ -182,6 +175,31 @@ class AuthLDAP(AuthPlugin): ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) + def __find(self, userid, mail=None): + """Find attributes of an user by uid or mail in LDAP""" + con = self.ldap.connection + if not con: + con = self.ldap.connect(self.admin_dn, self.admin_secret) + con.search( + f"ou=user,{self.dn}", + f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})", + SUBTREE, + attributes=["uid", "givenName", "sn", "mail"], + ) + return con.response[0]["attributes"] + + def __update(self, user, attr): + """Update an User object with LDAP attributes""" + if attr["uid"][0] == user.userid: + user.set_attribute("DN", self.ldap.connection.response[0]["dn"]) + user.firstname = attr["givenName"][0] + user.lastname = attr["sn"][0] + if attr["mail"]: + user.mail = attr["mail"][0] + if "displayName" in attr: + user.display_name = attr["displayName"][0] + userController.set_roles(user, self._get_groups(user.userid), create=True) + def __modify_role( self, role: Role, From aeadc78acc03dd20c0bc6666ffc79cd2162a9e4f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 19 Jan 2021 03:30:49 +0100 Subject: [PATCH 160/446] [System][Plugin] auth: Using find_user for password reset, fixes #443 * find_user will also search auth backend for user, so password recovery will also work if user was never logged in on Flaschengeist. --- flaschengeist/controller/userController.py | 24 +++++++++++++++++++++- flaschengeist/plugins/auth/__init__.py | 6 ++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index eefcdeb..ed930e5 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -116,6 +116,13 @@ def get_user_by_role(role: Role): def get_user(uid): + """Get an user by userid from database + Args: + uid: Userid to search for + Returns: + User fround + Raises: + NotFound if not found""" user = User.query.filter(User.userid == uid).one_or_none() if not user: raise NotFound @@ -123,16 +130,31 @@ def get_user(uid): def find_user(uid_mail): + """Finding an user by userid or mail in database or auth-backend + Args: + uid_mail: userid and or mail to search for + Returns: + User if found or None + """ mail = uid_mail.split("@") mail = len(mail) == 2 and len(mail[0]) > 0 and len(mail[1]) > 0 query = User.userid == uid_mail if mail: query |= User.mail == uid_mail - return User.query.filter(query).one_or_none() + user = User.query.filter(query).one_or_none() + if user: + update_user(user) + else: + user = current_app.config["FG_AUTH_BACKEND"].find_user(uid_mail, uid_mail if mail else None) + if user: + db.session.add(user) + db.session.commit() + return user def delete(user): + """Delete given user""" current_app.config["FG_AUTH_BACKEND"].delete_user(user) db.session.delete(user) db.session.commit() diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index fa9e789..1ca0ad8 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -169,11 +169,9 @@ def get_assocd_user(token, current_session, **kwargs): def reset_password(): data = request.get_json() if "userid" in data: - try: - user = userController.find_user(data["userid"]) + user = userController.find_user(data["userid"]) + if user: userController.request_reset(user) - except NotFound: - pass elif "password" in data and "token" in data: userController.reset_password(data["token"], data["password"]) else: From f42d5956db39a0c810f17d5effeee522cd15937f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 20 Jan 2021 15:21:33 +0100 Subject: [PATCH 161/446] [Plugin] balance: Added shortcuts configuration for balance --- flaschengeist/plugins/balance/__init__.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index a3b051d..26127e0 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -4,7 +4,7 @@ Extends users plugin with balance functions """ from datetime import datetime, timezone -from flask import Blueprint, request +from flask import Blueprint, request, jsonify from werkzeug.exceptions import Forbidden, BadRequest from flaschengeist import logger @@ -39,6 +39,27 @@ class BalancePlugin(Plugin): db.create_all() +@balance_bp.route("/users//balance/shortcuts", methods=["GET"]) +@login_required() +def get_shortcuts(userid, current_session: Session): + """Get set limit of an user + + Route: ``/users//balance/limit`` | Method: ``GET`` + + Args: + userid: Userid identifying the user + current_session: Session sent with Authorization Header + + Returns: + JSON object containing the limit (or Null if no limit set) or HTTP error + """ + if userid != current_session._user.userid: + raise Forbidden + + user = userController.get_user(userid) + return jsonify(user.get_attribute("balance_shortcuts", [-2, -1, -0.5])) + + @balance_bp.route("/users//balance/limit", methods=["GET"]) @login_required() def get_limit(userid, current_session: Session): From 69ec4472c3ecd3e02551be09de5b3faea3bd87d7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 21 Jan 2021 14:08:06 +0100 Subject: [PATCH 162/446] [System][Plugin] users: Send users a link to set their own password and initially set random password --- flaschengeist.example.toml | 5 +++ flaschengeist/controller/userController.py | 52 ++++++++++++---------- flaschengeist/plugins/users/__init__.py | 9 +++- 3 files changed, 41 insertions(+), 25 deletions(-) diff --git a/flaschengeist.example.toml b/flaschengeist.example.toml index efe1efe..3f4ff49 100644 --- a/flaschengeist.example.toml +++ b/flaschengeist.example.toml @@ -41,6 +41,11 @@ welcome_subject = "Welcome to Flaschengeist {name}" welcome_text = ''' Hello {name}! Welcome to Flaschengeist! + +Your username is {username}, please set your password: +{password_link} +(If the link expires, please just use the Forgot-Password-function). + Have fun :) ''' diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index ed930e5..3f0c9b0 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -11,6 +11,23 @@ from flaschengeist.models.user import User, Role, _PasswordReset from flaschengeist.controller import messageController, sessionController +def _generate_password_reset(user): + """Generate a password reset link for the user""" + reset = _PasswordReset.query.get(user._id) + if not reset: + reset = _PasswordReset(_user_id=user._id) + db.session.add(reset) + + expires = datetime.now(tz=timezone.utc) + if not reset.expires or reset.expires < expires: + expires = expires + timedelta(hours=12) + reset.expires = expires + reset.token = secrets.token_urlsafe(24) + db.session.commit() + + return reset + + def login_user(username, password): logger.info("login user {{ {} }}".format(username)) @@ -27,17 +44,7 @@ def login_user(username, password): def request_reset(user: User): logger.debug(f"New password reset request for {user.userid}") - reset = _PasswordReset.query.get(user._id) - if not reset: - reset = _PasswordReset(_user_id=user._id) - db.session.add(reset) - - expires = datetime.now(tz=timezone.utc) - if not reset.expires or reset.expires < expires: - expires = expires + timedelta(hours=12) - reset.expires = expires - reset.token = secrets.token_urlsafe(24) - db.session.commit() + reset = _generate_password_reset(user) subject = str(config["MESSAGES"]["password_subject"]).format(name=user.display_name, username=user.userid) text = str(config["MESSAGES"]["password_text"]).format( @@ -161,27 +168,26 @@ def delete(user): def register(data): - for required in ["firstname", "lastname", "mail"]: - if required not in data: - raise BadRequest("Missing required parameters") allowed_keys = User().serialize().keys() values = {key: value for key, value in data.items() if key in allowed_keys} roles = values.pop("roles", []) user = User(**values) set_roles(user, roles) - current_app.config["FG_AUTH_BACKEND"].create_user(user, data["password"]) - + password = secrets.token_bytes(16) + current_app.config["FG_AUTH_BACKEND"].create_user(user, password) db.session.add(user) db.session.commit() - if user.mail and len(user.mail) > 3: - subject = str(config["MESSAGES"]["welcome_subject"]).format(name=user.display_name, username=user.userid) - text = str(config["MESSAGES"]["welcome_text"]).format( - name=user.display_name, - username=user.userid, - ) - messageController.send_message(messageController.Message(user, text, subject)) + reset = _generate_password_reset(user) + + subject = str(config["MESSAGES"]["welcome_subject"]).format(name=user.display_name, username=user.userid) + text = str(config["MESSAGES"]["welcome_text"]).format( + name=user.display_name, + username=user.userid, + password_link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}' + ) + messageController.send_message(messageController.Message(user, text, subject)) return user diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 24e4968..f5ed33d 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -24,17 +24,19 @@ _permission_register = "users_register" class UsersPlugin(Plugin): - def __init__(self, config): + def __init__(self, cfg): super().__init__(blueprint=users_bp, permissions=[_permission_edit, _permission_delete, _permission_set_roles]) @users_bp.route("/users", methods=["POST"]) def register(): """Register a new user + The password will be set to a random string of at lease 16byte entropy. + The user will receive a mail containing a link to set their own password. Route: ``/users`` | Method: ``POST`` - POST-data: Same as `flaschengeist.models.user.User` + ``password?: string`` + POST-data: Same as `flaschengeist.models.user.User` Returns: JSON encoded `flaschengeist.models.user.User` or HTTP error @@ -49,6 +51,9 @@ def register(): data = request.get_json() if not data: raise BadRequest + for required in ["firstname", "lastname", "mail"]: + if required not in data: + raise BadRequest("Missing required parameters") logger.debug("Register new User...") return make_response(jsonify(userController.register(data)), CREATED) From bdbb2d3e45f77f01efb8219b386c8e1bfd43b809 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 21 Jan 2021 14:08:45 +0100 Subject: [PATCH 163/446] [Plugin] balance: Do not provide default values for frontend, split this to the frontend project --- flaschengeist/plugins/balance/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 26127e0..e56e372 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -57,7 +57,7 @@ def get_shortcuts(userid, current_session: Session): raise Forbidden user = userController.get_user(userid) - return jsonify(user.get_attribute("balance_shortcuts", [-2, -1, -0.5])) + return jsonify(user.get_attribute("balance_shortcuts", [])) @balance_bp.route("/users//balance/limit", methods=["GET"]) From 826893e42e9fe54d663b63508ff939aef610dbdd Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 21 Jan 2021 20:24:10 +0100 Subject: [PATCH 164/446] [Plugin] balance: Allow saving shortcuts --- flaschengeist/controller/userController.py | 6 ++++++ flaschengeist/plugins/balance/__init__.py | 24 ++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 3f0c9b0..024297d 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -199,3 +199,9 @@ def load_avatar(user: User): def save_avatar(user, avatar): current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) db.session.commit() + + +def persist(user=None): + if user: + db.session.add(user) + db.session.commit() diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index e56e372..6860a8e 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -4,6 +4,8 @@ Extends users plugin with balance functions """ from datetime import datetime, timezone + +from flaschengeist.utils.HTTP import no_content from flask import Blueprint, request, jsonify from werkzeug.exceptions import Forbidden, BadRequest @@ -39,31 +41,41 @@ class BalancePlugin(Plugin): db.create_all() -@balance_bp.route("/users//balance/shortcuts", methods=["GET"]) +@balance_bp.route("/users//balance/shortcuts", methods=["GET", "PUT"]) @login_required() def get_shortcuts(userid, current_session: Session): - """Get set limit of an user + """Get balance shortcuts of an user - Route: ``/users//balance/limit`` | Method: ``GET`` + Route: ``/users//balance/shortcuts`` | Method: ``GET`` or ``PUT`` + POST-data: On ``PUT`` json encoded array of floats Args: userid: Userid identifying the user current_session: Session sent with Authorization Header Returns: - JSON object containing the limit (or Null if no limit set) or HTTP error + GET: JSON object containing the shortcuts as float array or HTTP error + PUT: HTTP-created or HTTP error """ if userid != current_session._user.userid: raise Forbidden user = userController.get_user(userid) - return jsonify(user.get_attribute("balance_shortcuts", [])) + if request.method == "GET": + return jsonify(user.get_attribute("balance_shortcuts", [])) + else: + data = request.get_json() + if not isinstance(data, list) or all(isinstance(n, int) for n in data): + raise BadRequest + user.set_attribute("balance_shortcuts", data) + userController.persist() + return no_content() @balance_bp.route("/users//balance/limit", methods=["GET"]) @login_required() def get_limit(userid, current_session: Session): - """Get set limit of an user + """Get limit of an user Route: ``/users//balance/limit`` | Method: ``GET`` From c4b80f27ee62f0fb873f9ca7d916afb56448e4a5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 22 Jan 2021 00:17:51 +0100 Subject: [PATCH 165/446] [Plugin] balance: Fixed shortcut data type check --- flaschengeist/plugins/balance/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 6860a8e..23a44ff 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -65,7 +65,7 @@ def get_shortcuts(userid, current_session: Session): return jsonify(user.get_attribute("balance_shortcuts", [])) else: data = request.get_json() - if not isinstance(data, list) or all(isinstance(n, int) for n in data): + if not isinstance(data, list) or not all(isinstance(n, (int, float)) for n in data): raise BadRequest user.set_attribute("balance_shortcuts", data) userController.persist() From 125ba1be78a20c89c395a869c4b654f06b8bf531 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 22 Jan 2021 14:11:16 +0100 Subject: [PATCH 166/446] [Plugin] users: Fixed installation of permissions and added documentation. --- flaschengeist/controller/userController.py | 8 +++++--- flaschengeist/models/user.py | 1 + flaschengeist/plugins/users/__init__.py | 17 +++++++---------- flaschengeist/plugins/users/permissions.py | 13 +++++++++++++ 4 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 flaschengeist/plugins/users/permissions.py diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 024297d..8c13782 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -50,7 +50,7 @@ def request_reset(user: User): text = str(config["MESSAGES"]["password_text"]).format( name=user.display_name, username=user.userid, - link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}' + link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}', ) messageController.send_message(messageController.Message(user, text, subject)) @@ -106,7 +106,9 @@ def modify_user(user, password, new_password=None): if new_password: logger.debug(f"Password changed for user {user.userid}") - subject = str(config["MESSAGES"]["password_changed_subject"]).format(name=user.display_name, username=user.userid) + subject = str(config["MESSAGES"]["password_changed_subject"]).format( + name=user.display_name, username=user.userid + ) text = str(config["MESSAGES"]["password_changed_text"]).format( name=user.display_name, username=user.userid, @@ -185,7 +187,7 @@ def register(data): text = str(config["MESSAGES"]["welcome_text"]).format( name=user.display_name, username=user.userid, - password_link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}' + password_link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}', ) messageController.send_message(messageController.Message(user, text, subject)) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 62940d8..f69ea1f 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -104,6 +104,7 @@ class _UserAttribute(db.Model, ModelSerializeMixin): class _PasswordReset(db.Model): """Table containing password reset requests""" + __tablename__ = "password_reset" _user_id: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), primary_key=True) user: User = db.relationship("User", foreign_keys=[_user_id]) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index f5ed33d..7bdbdec 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -8,6 +8,7 @@ from flaschengeist.config import config from flask import Blueprint, request, jsonify, make_response, Response from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound +from . import permissions from flaschengeist import logger from flaschengeist.models.user import User, _Avatar from flaschengeist.plugins import Plugin @@ -17,15 +18,11 @@ from flaschengeist.utils.HTTP import created from flaschengeist.utils.datetime import from_iso_format users_bp = Blueprint("users", __name__) -_permission_edit = "users_edit_other" -_permission_set_roles = "users_set_roles" -_permission_delete = "users_delete_other" -_permission_register = "users_register" class UsersPlugin(Plugin): def __init__(self, cfg): - super().__init__(blueprint=users_bp, permissions=[_permission_edit, _permission_delete, _permission_set_roles]) + super().__init__(blueprint=users_bp, permissions=permissions.permissions) @users_bp.route("/users", methods=["POST"]) @@ -46,7 +43,7 @@ def register(): logger.debug("Config for Registration is set to >{}<".format(registration)) raise MethodNotAllowed if registration == "managed": - extract_session(_permission_register) + extract_session(permissions.REGISTER) data = request.get_json() if not data: @@ -114,7 +111,7 @@ def get_avatar(userid): @login_required() def set_avatar(userid, current_session): user = userController.get_user(userid) - if userid != current_session._user.userid and not current_session._user.has_permission(_permission_edit): + if userid != current_session._user.userid and not current_session._user.has_permission(permissions.EDIT): raise Forbidden file = request.files.get("file") @@ -129,7 +126,7 @@ def set_avatar(userid, current_session): @users_bp.route("/users/", methods=["DELETE"]) -@login_required(permission=_permission_delete) +@login_required(permission=permissions.DELETE) def delete_user(userid, current_session): """Delete user by userid @@ -175,7 +172,7 @@ def edit_user(userid, current_session): author = user if userid != current_session._user.userid: author = current_session._user - if not author.has_permission(_permission_edit): + if not author.has_permission(permissions.EDIT): raise Forbidden else: if "password" not in data: @@ -190,7 +187,7 @@ def edit_user(userid, current_session): if "roles" in data: roles = set(data["roles"]) - if not author.has_permission(_permission_set_roles): + if not author.has_permission(permissions.SET_ROLES): if len(roles) != len(user.roles) or set(user.roles) != roles: raise Forbidden else: diff --git a/flaschengeist/plugins/users/permissions.py b/flaschengeist/plugins/users/permissions.py new file mode 100644 index 0000000..2406b95 --- /dev/null +++ b/flaschengeist/plugins/users/permissions.py @@ -0,0 +1,13 @@ +EDIT = "users_edit_other" +"""Can edit other users""" + +SET_ROLES = "users_set_roles" +"""Can assign roles to users""" + +DELETE = "users_delete" +"""Can delete users""" + +REGISTER = "users_register" +"""Can register new users""" + +permissions = [value for key, value in globals().items() if not key.startswith("_")] From fee476589871c5374afa6e030ae5be03e9575f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 22 Jan 2021 14:20:25 +0100 Subject: [PATCH 167/446] fixed install of permission users_register --- flaschengeist/plugins/users/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index f5ed33d..fb04d93 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -25,7 +25,15 @@ _permission_register = "users_register" class UsersPlugin(Plugin): def __init__(self, cfg): - super().__init__(blueprint=users_bp, permissions=[_permission_edit, _permission_delete, _permission_set_roles]) + super().__init__( + blueprint=users_bp, + permissions=[ + _permission_edit, + _permission_delete, + _permission_set_roles, + _permission_register, + ], + ) @users_bp.route("/users", methods=["POST"]) @@ -114,7 +122,10 @@ def get_avatar(userid): @login_required() def set_avatar(userid, current_session): user = userController.get_user(userid) - if userid != current_session._user.userid and not current_session._user.has_permission(_permission_edit): + if ( + userid != current_session._user.userid + and not current_session._user.has_permission(_permission_edit) + ): raise Forbidden file = request.files.get("file") From 83dba12ecb262842882c7dd1d32739ca2f4153f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 22 Jan 2021 14:24:12 +0100 Subject: [PATCH 168/446] Revert "fixed install of permission users_register" This reverts commit fee476589871c5374afa6e030ae5be03e9575f35. --- flaschengeist/plugins/users/__init__.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index fb04d93..f5ed33d 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -25,15 +25,7 @@ _permission_register = "users_register" class UsersPlugin(Plugin): def __init__(self, cfg): - super().__init__( - blueprint=users_bp, - permissions=[ - _permission_edit, - _permission_delete, - _permission_set_roles, - _permission_register, - ], - ) + super().__init__(blueprint=users_bp, permissions=[_permission_edit, _permission_delete, _permission_set_roles]) @users_bp.route("/users", methods=["POST"]) @@ -122,10 +114,7 @@ def get_avatar(userid): @login_required() def set_avatar(userid, current_session): user = userController.get_user(userid) - if ( - userid != current_session._user.userid - and not current_session._user.has_permission(_permission_edit) - ): + if userid != current_session._user.userid and not current_session._user.has_permission(_permission_edit): raise Forbidden file = request.files.get("file") From fe7166686da1a8142075c7e9d0924fc7b90b4d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 22 Jan 2021 17:03:11 +0100 Subject: [PATCH 169/446] fixed issue for user_avatar if userid is not set --- flaschengeist/models/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index f69ea1f..0d9d506 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -67,7 +67,7 @@ class User(db.Model, ModelSerializeMixin): @property def avatar_url(self): - return url_for("users.get_avatar", userid=self.userid) + return url_for("users.get_avatar", userid=self.userid) if self.userid else None @property def roles(self): From 94aac573e6915469536d8c9aeaf12d63ec527d6c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 23 Jan 2021 02:12:42 +0100 Subject: [PATCH 170/446] [System] Allow setting a test configuration for unit tests. --- flaschengeist/app.py | 4 +-- flaschengeist/config.py | 60 +++++++++++++++++++++++++---------------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index ad44085..1a80aa0 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -71,7 +71,7 @@ def install_all(): roleController.create_permissions(plugin.permissions) -def create_app(): +def create_app(test_config=None): app = Flask(__name__) app.json_encoder = CustomJSONEncoder CORS(app) @@ -79,7 +79,7 @@ def create_app(): with app.app_context(): from flaschengeist.database import db - configure_app(app) + configure_app(app, test_config) db.init_app(app) __load_plugins(app) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 1ae840f..e8726c6 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -8,33 +8,44 @@ from werkzeug.middleware.proxy_fix import ProxyFix from flaschengeist import _module_path, logger -def __update(d, u): +# Default config: +config = {} + + +def update_dict(d, u): for k, v in u.items(): if isinstance(v, collections.abc.Mapping): - d[k] = __update(d.get(k, {}), v) + d[k] = update_dict(d.get(k, {}), v) else: d[k] = v return d -# Default config: -config = {} +def read_configuration(): + global config -__paths = [_module_path, Path.home() / ".config"] -if "FLASCHENGEIST_CONF" in os.environ: - __paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) -for loc in __paths: - try: - with (loc / "flaschengeist.toml").open() as source: - logger.info("Reading config file from >{}<".format(loc)) - __update(config, toml.load(source)) - except IOError: - pass -# Always enable this builtin plugins! -__update(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) + paths = [_module_path, Path.home() / ".config"] + if "FLASCHENGEIST_CONF" in os.environ: + paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) + + for loc in paths: + try: + with (loc / "flaschengeist.toml").open() as source: + print("Reading config file from >{}<".format(loc)) + update_dict(config, toml.load(source)) + except IOError: + pass -def configure_app(app): +def configure_app(app, test_config=None): + global config + if test_config is None: + read_configuration() + else: + update_dict(config, test_config) + # Always enable this builtin plugins! + update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) + logger_config = toml.load(_module_path / "logging.toml") if "LOGGING" in config: if "level" in config["LOGGING"]: @@ -52,12 +63,15 @@ def configure_app(app): else: app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] - app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://{user}:{passwd}@{host}/{database}".format( - user=config["DATABASE"]["user"], - passwd=config["DATABASE"]["password"], - host=config["DATABASE"]["host"], - database=config["DATABASE"]["database"], - ) + if test_config is None: + app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://{user}:{passwd}@{host}/{database}".format( + user=config["DATABASE"]["user"], + passwd=config["DATABASE"]["password"], + host=config["DATABASE"]["host"], + database=config["DATABASE"]["database"], + ) + else: + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite+pysqlite://{config['DATABASE']['file_path']}" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False if "root" in config["FLASCHENGEIST"]: From 62815d9278a8fd67c2cc3b56c1e28ea51b6d2f3a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 23 Jan 2021 02:18:44 +0100 Subject: [PATCH 171/446] [System][Tests]: Added infrastructure for tests, added first unit test --- setup.py | 2 +- tests/conftest.py | 28 ++++++++++++++++++++++++++++ tests/test_auth.py | 16 ++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py diff --git a/setup.py b/setup.py index d97177b..97d3100 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ setup( # Needed for python < 3.7 "backports-datetime-fromisoformat", ], - extras_require={"ldap": ["flask_ldapconn", "ldap3"]}, + extras_require={"ldap": ["flask_ldapconn", "ldap3"], "tests": ["pytest"]}, entry_points={ "flaschengeist.plugin": [ # Authentication providers diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0c0931b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +import os +import tempfile +import pytest + +from flaschengeist.app import create_app, install_all + + +@pytest.fixture +def app(): + db_fd, db_path = tempfile.mkstemp() + app = create_app( + { + "TESTING": True, + "DATABASE": {"file_path": f"/{db_path}"}, + "FLASCHENGEIST": {"auth": "auth_plain"}, + "auth_plain": {"enabled": True}, + } + ) + with app.app_context(): + install_all() + yield app + os.close(db_fd) + os.unlink(db_path) + + +@pytest.fixture +def client(app): + return app.test_client() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..6dcf468 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,16 @@ +import pytest + +# with app.app_context(): +# engine = database.db.engine +# with engine.connect() as connection: +# connection.execute("") + + +def test_login_decorator(client): + """Testing the login_required decorator""" + # No header at all + assert client.get("/auth").status_code == 401 + # Invalid header + assert client.get("/auth", headers={"Authorization": "INVALID"}).status_code == 401 + # Invalid Token + assert client.get("/auth", headers={"Authorization": "Bearer INVALID"}).status_code == 401 From 4a13d3ceb140797faa3d6b0b6adb5c2fdffe8931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 23 Jan 2021 09:31:31 +0100 Subject: [PATCH 172/446] fixed issue that on register_user the birthday can be set --- flaschengeist/controller/userController.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 8c13782..283de3d 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -7,6 +7,7 @@ from flaschengeist import logger from flaschengeist.config import config from flaschengeist.database import db from flaschengeist.utils.hook import Hook +from flaschengeist.utils.datetime import from_iso_format from flaschengeist.models.user import User, Role, _PasswordReset from flaschengeist.controller import messageController, sessionController @@ -46,7 +47,9 @@ def request_reset(user: User): logger.debug(f"New password reset request for {user.userid}") reset = _generate_password_reset(user) - subject = str(config["MESSAGES"]["password_subject"]).format(name=user.display_name, username=user.userid) + subject = str(config["MESSAGES"]["password_subject"]).format( + name=user.display_name, username=user.userid + ) text = str(config["MESSAGES"]["password_text"]).format( name=user.display_name, username=user.userid, @@ -155,7 +158,9 @@ def find_user(uid_mail): if user: update_user(user) else: - user = current_app.config["FG_AUTH_BACKEND"].find_user(uid_mail, uid_mail if mail else None) + user = current_app.config["FG_AUTH_BACKEND"].find_user( + uid_mail, uid_mail if mail else None + ) if user: db.session.add(user) db.session.commit() @@ -173,6 +178,8 @@ def register(data): allowed_keys = User().serialize().keys() values = {key: value for key, value in data.items() if key in allowed_keys} roles = values.pop("roles", []) + if "birthday" in values: + values["birthday"] = from_iso_format(values["birthday"]).date() user = User(**values) set_roles(user, roles) @@ -183,7 +190,9 @@ def register(data): reset = _generate_password_reset(user) - subject = str(config["MESSAGES"]["welcome_subject"]).format(name=user.display_name, username=user.userid) + subject = str(config["MESSAGES"]["welcome_subject"]).format( + name=user.display_name, username=user.userid + ) text = str(config["MESSAGES"]["welcome_text"]).format( name=user.display_name, username=user.userid, From 9bbae33e4c2e2d4b756839a3d4e48a1b3a208f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 23 Jan 2021 16:08:14 +0100 Subject: [PATCH 173/446] change response of events --- flaschengeist/plugins/schedule/__init__.py | 4 ++-- flaschengeist/plugins/schedule/models.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index e1b3a81..09234ef 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -66,8 +66,8 @@ def new_event_type(current_session): data = request.get_json() if "name" not in data: raise BadRequest - event_controller.create_event_type(data["name"]) - return "", CREATED + event = event_controller.create_event_type(data["name"]) + return jsonify(event) @schedule_bp.route("/event-types/", methods=["PUT", "DELETE"]) diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index e09b81c..b78ecb3 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -14,7 +14,7 @@ from flaschengeist.database import db class EventType(db.Model, ModelSerializeMixin): __tablename__ = "schedule_event_type" - id_: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column("id", db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) From d2854315c13749d663718c1be56f41bed883f3f3 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 24 Jan 2021 16:19:46 +0100 Subject: [PATCH 174/446] [Test] Added setup.cfg to run tests and coverage, updated Readme --- readme.md | 25 ++++++++++++++++++------- setup.cfg | 9 +++++++++ setup.py | 2 +- 3 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 setup.cfg diff --git a/readme.md b/readme.md index a158069..51deeac 100644 --- a/readme.md +++ b/readme.md @@ -9,9 +9,9 @@ or with ldap support pip3 install --user ".[ldap]" -or if you want to use bjoern as the HTTP server: +or if you want to also run the tests: - pip3 install --user ".[ldap,bjoern]" + pip3 install --user ".[ldap,test]" #### Windows Same as above, but for mysql you have to follow this guide: @@ -19,7 +19,7 @@ Same as above, but for mysql you have to follow this guide: https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/ ### Configuration -1. Rename `flaschengeist.example.cfg` to `flaschengeist.cfg` +1. Rename `flaschengeist.example.toml` to `flaschengeist.toml` 2. Move it to either 1. the module path (where flaschegeist is installed) 2. `~/.config/` @@ -27,13 +27,24 @@ https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-w 3. Change at least the database parameters ### Database installation - run_flaschengeist --install + run_flaschengeist install ### Run - run_flaschengeist + run_flaschengeist run or with debug messages: - run_flaschengeist --debug + run_flaschengeist run --debug + +## Tests + $ pip install '.[test]' + $ pytest +Run with coverage report: + + $ coverage run -m pytest + $ coverage report +Or with html output (open `htmlcov/index.html` in a browser): + + $ coverage html ## Development ### Code Style @@ -74,7 +85,7 @@ Or if you just want to use `git blame`, configure git like this: example_bp = Blueprint("example", __name__, url_prefix="/example") - permissions = {"EXAMPLE_HELLO": "example_hello"} + permissions = ["example_hello"] class PluginExample(Plugin): def __init__(self, conf): diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6218c19 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,9 @@ +[bdist_wheel] +universal = True + +[tool:pytest] +testpaths = tests + +[coverage:run] +branch = True +source = flaschengeist \ No newline at end of file diff --git a/setup.py b/setup.py index 97d3100..d2c164b 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ setup( # Needed for python < 3.7 "backports-datetime-fromisoformat", ], - extras_require={"ldap": ["flask_ldapconn", "ldap3"], "tests": ["pytest"]}, + extras_require={"ldap": ["flask_ldapconn", "ldap3"], "test": ["pytest", "coverage"]}, entry_points={ "flaschengeist.plugin": [ # Authentication providers From 30305c26cc8b52a2237d3e2436605bfa8f43b7b6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 24 Jan 2021 16:20:25 +0100 Subject: [PATCH 175/446] [Plugin] Schedule: Identify EventTypes by ID not name --- flaschengeist/plugins/schedule/__init__.py | 32 +++++++++++-------- .../plugins/schedule/event_controller.py | 13 +++++--- flaschengeist/plugins/schedule/models.py | 6 ++-- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 09234ef..5f5b0f0 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -32,20 +32,26 @@ class SchedulePlugin(Plugin): @schedule_bp.route("/event-types", methods=["GET"]) +@schedule_bp.route("/event-types/", methods=["GET"]) @login_required() -def get_event_types(current_session): - """Get all EventTypes +def get_event_types(current_session, identifier=None): + """Get EventType(s) Route: ``/schedule/event-types`` | Method: ``GET`` + Route: ``/schedule/event-types/`` | Method: ``GET`` Args: current_session: Session sent with Authorization Header + identifier: If querying a specific EventType Returns: - JSON encoded list of EventTypes HTTP-error + JSON encoded (list of) EventType(s) or HTTP-error """ - types = event_controller.get_event_types() - return jsonify(types) + if identifier: + result = event_controller.get_event_type(identifier) + else: + result = event_controller.get_event_types() + return jsonify(result) @schedule_bp.route("/event-types", methods=["POST"]) @@ -66,33 +72,33 @@ def new_event_type(current_session): data = request.get_json() if "name" not in data: raise BadRequest - event = event_controller.create_event_type(data["name"]) - return jsonify(event) + event_type = event_controller.create_event_type(data["name"]) + return jsonify(event_type) -@schedule_bp.route("/event-types/", methods=["PUT", "DELETE"]) +@schedule_bp.route("/event-types/", methods=["PUT", "DELETE"]) @login_required(permission=permissions.EVENT_TYPE) -def modify_event_type(name, current_session): +def modify_event_type(identifier, current_session): """Rename or delete an event type - Route: ``/schedule/event-types/`` | Method: ``PUT`` or ``DELETE`` + Route: ``/schedule/event-types/`` | Method: ``PUT`` or ``DELETE`` POST-data: (if renaming) ``{name: string}`` Args: - name: Name identifying the EventType + identifier: Identifier of the EventType current_session: Session sent with Authorization Header Returns: HTTP-NoContent or HTTP-error """ if request.method == "DELETE": - event_controller.delete_event_type(name) + event_controller.delete_event_type(identifier) else: data = request.get_json() if "name" not in data: raise BadRequest("Parameter missing in data") - event_controller.rename_event_type(name, data["name"]) + event_controller.rename_event_type(identifier, data["name"]) return "", NO_CONTENT diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index 44cd246..d88ca64 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -14,8 +14,13 @@ def get_event_types(): return EventType.query.all() -def get_event_type(name): - et = EventType.query.filter(EventType.name == name).one_or_none() +def get_event_type(identifier): + """Get EventType by ID (int) or name (string)""" + + if isinstance(identifier, int): + et = EventType.query.get(identifier) + else: + et = EventType.query.filter(EventType.name == identifier).one_or_none() if not et: raise NotFound return et @@ -31,8 +36,8 @@ def create_event_type(name): raise BadRequest("Name already exists") -def rename_event_type(name, new_name): - event_type = get_event_type(name) +def rename_event_type(identifier, new_name): + event_type = get_event_type(identifier) event_type.name = new_name try: db.session.commit() diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index b78ecb3..1c7fa27 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -14,13 +14,13 @@ from flaschengeist.database import db class EventType(db.Model, ModelSerializeMixin): __tablename__ = "schedule_event_type" - id: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column(db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) class JobType(db.Model, ModelSerializeMixin): __tablename__ = "schedule_job_type" - id: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column(db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) @@ -50,7 +50,7 @@ class Job(db.Model, ModelSerializeMixin): _type_id = db.Column("type_id", db.Integer, db.ForeignKey("schedule_job_type.id"), nullable=False) _event_id = db.Column("event_id", db.Integer, db.ForeignKey("schedule_event.id"), nullable=False) - id: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column(db.Integer, primary_key=True) start: datetime = db.Column(UtcDateTime, nullable=False) end: Optional[datetime] = db.Column(UtcDateTime) comment: str = db.Column(db.String(256)) From 94ffc706e74bca92c19450740f10a23c2349fbea Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 25 Jan 2021 13:09:20 +0100 Subject: [PATCH 176/446] [System] config: Read default config. * Config file in module path is the default config, this allows simpler user configs. * Default values also for test configuration --- flaschengeist.example.toml | 101 ------------------------------------- flaschengeist/config.py | 18 ++++--- readme.md | 16 +++--- 3 files changed, 20 insertions(+), 115 deletions(-) delete mode 100644 flaschengeist.example.toml diff --git a/flaschengeist.example.toml b/flaschengeist.example.toml deleted file mode 100644 index 3f4ff49..0000000 --- a/flaschengeist.example.toml +++ /dev/null @@ -1,101 +0,0 @@ -[FLASCHENGEIST] -# Select authentication provider (builtin: auth_plain, auth_ldap) -auth = "auth_plain" -# Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn -#proxy = false -# Set root path, prefixes all routes -#root = /api -# Set secret key -secret_key = "V3ryS3cr3t" -# Domain used by frontend -#domain = "flaschengeist.local" - -[LOGGING] -file = "/tmp/flaschengeist-debug.log" -# DEBUG INFO WARNING ERROR -#level = "WARNING" - -[DATABASE] -user = "user" -host = "127.0.0.1" -password = "password" -database = "database" - -[auth_plain] -enabled = true - -#[auth_ldap] -# enabled = true -# host = -# port = -# bind_dn = -# base_dn = -# secret = -# use_ssl = -# admin_dn = -# admin_dn = -# default_gid = - -[MESSAGES] -welcome_subject = "Welcome to Flaschengeist {name}" -welcome_text = ''' -Hello {name}! -Welcome to Flaschengeist! - -Your username is {username}, please set your password: -{password_link} -(If the link expires, please just use the Forgot-Password-function). - -Have fun :) -''' - -password_subject = "Flaschengeist - Password reset" -password_text = ''' -Hello {name}! -There was a password reset request for username: {username} - -To change your password, click on this link: -{link} -''' - -password_changed_subject = "Flaschengeist - Password changed" -password_changed_text = ''' -Hello {name}! -Your password was changed for username: {username} - -If this was not you, please contact the support. -''' - -################## -# PLUGINS # -################## - -#[users] -# always enabled -# -## allowed values: false, "managed", "public" -## false: Disable registration -## "managed": only users with matching permission are allowed to register new users -## "public": Also unauthenticated users can register an account -# registration = False - -############################ -# Configuration of plugins # -############################ -#[mail] -# enabled = true -# SERVER = -# PORT = -# USER = -# PASSWORD = -# MAIL = -# SSL or STARTLS -# CRYPT = SSL - -[balance] -enabled = true -# Enable a default limit, will be set if no other limit is set -# limit = -10.00 - -[geruecht] -enabled = false diff --git a/flaschengeist/config.py b/flaschengeist/config.py index e8726c6..09ada68 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -21,12 +21,14 @@ def update_dict(d, u): return d -def read_configuration(): +def read_configuration(test_config): global config + paths = [_module_path] - paths = [_module_path, Path.home() / ".config"] - if "FLASCHENGEIST_CONF" in os.environ: - paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) + if not test_config: + paths.append(Path.home() / ".config") + if "FLASCHENGEIST_CONF" in os.environ: + paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) for loc in paths: try: @@ -35,14 +37,14 @@ def read_configuration(): update_dict(config, toml.load(source)) except IOError: pass + if test_config: + update_dict(config, test_config) def configure_app(app, test_config=None): global config - if test_config is None: - read_configuration() - else: - update_dict(config, test_config) + read_configuration(test_config) + # Always enable this builtin plugins! update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) diff --git a/readme.md b/readme.md index 51deeac..c950344 100644 --- a/readme.md +++ b/readme.md @@ -13,18 +13,22 @@ or if you want to also run the tests: pip3 install --user ".[ldap,test]" +You will also need a MySQL driver, recommended drivers are +- `mysqlclient` +- `PyMySQL` + #### Windows Same as above, but for mysql you have to follow this guide: https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/ ### Configuration -1. Rename `flaschengeist.example.toml` to `flaschengeist.toml` -2. Move it to either - 1. the module path (where flaschegeist is installed) - 2. `~/.config/` - 3. A custom path and set environment variable `FLASCHENGEIST_CONF` -3. Change at least the database parameters +Configuration is done within the a `flaschengeist.toml`file, you can copy the one located inside the module path +(where flaschegeist is installed) or create an empty one and place it inside either: +1. `~/.config/` +2. A custom path and set environment variable `FLASCHENGEIST_CONF` + +Change at least the database parameters! ### Database installation run_flaschengeist install From 5b5727872145acc63c750c68c70796189f98fbe5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 25 Jan 2021 13:10:28 +0100 Subject: [PATCH 177/446] [Plugin] schedule: Fixed invalid routes. --- flaschengeist/plugins/schedule/__init__.py | 4 ++-- flaschengeist/plugins/users/__init__.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 5f5b0f0..0cf6f54 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -32,7 +32,7 @@ class SchedulePlugin(Plugin): @schedule_bp.route("/event-types", methods=["GET"]) -@schedule_bp.route("/event-types/", methods=["GET"]) +@schedule_bp.route("/event-types/", methods=["GET"]) @login_required() def get_event_types(current_session, identifier=None): """Get EventType(s) @@ -76,7 +76,7 @@ def new_event_type(current_session): return jsonify(event_type) -@schedule_bp.route("/event-types/", methods=["PUT", "DELETE"]) +@schedule_bp.route("/event-types/", methods=["PUT", "DELETE"]) @login_required(permission=permissions.EVENT_TYPE) def modify_event_type(identifier, current_session): """Rename or delete an event type diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 7bdbdec..8b75e2f 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -3,15 +3,14 @@ Provides routes used to manage users """ from http.client import NO_CONTENT, CREATED - -from flaschengeist.config import config from flask import Blueprint, request, jsonify, make_response, Response from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound from . import permissions from flaschengeist import logger -from flaschengeist.models.user import User, _Avatar +from flaschengeist.config import config from flaschengeist.plugins import Plugin +from flaschengeist.models.user import User, _Avatar from flaschengeist.decorator import login_required, extract_session from flaschengeist.controller import userController from flaschengeist.utils.HTTP import created From a91f820b7f5509e3cf11fa2e75a8e96d470d8915 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 25 Jan 2021 13:12:04 +0100 Subject: [PATCH 178/446] [Test] Reading default database from file. * Providing default database with dummy data for testing * Added test for login and decorator --- tests/conftest.py | 30 +++++++++++++++++++++--------- tests/data.sql | 4 ++++ tests/test_auth.py | 24 ++++++++++++++++++++---- 3 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 tests/data.sql diff --git a/tests/conftest.py b/tests/conftest.py index 0c0931b..b99d8d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,25 +2,37 @@ import os import tempfile import pytest +from flaschengeist import database from flaschengeist.app import create_app, install_all +# read in SQL for populating test data +with open(os.path.join(os.path.dirname(__file__), "data.sql"), "r") as f: + _data_sql = [] + __sql_command = "" + for line in f.readlines(): + if not line.startswith("--"): + line = line.strip("\n") + __sql_command += line.strip("\n") + if __sql_command.endswith(";"): + _data_sql.append(__sql_command) + __sql_command = "" + + @pytest.fixture def app(): db_fd, db_path = tempfile.mkstemp() - app = create_app( - { - "TESTING": True, - "DATABASE": {"file_path": f"/{db_path}"}, - "FLASCHENGEIST": {"auth": "auth_plain"}, - "auth_plain": {"enabled": True}, - } - ) + app = create_app({"TESTING": True, "DATABASE": {"file_path": f"/{db_path}"}, "LOGGING": {"level": "DEBUG"}}) with app.app_context(): install_all() + engine = database.db.engine + with engine.connect() as connection: + for statement in _data_sql: + connection.execute(statement) yield app os.close(db_fd) - os.unlink(db_path) + # os.unlink(db_path) + print(db_path) @pytest.fixture diff --git a/tests/data.sql b/tests/data.sql new file mode 100644 index 0000000..6c6a989 --- /dev/null +++ b/tests/data.sql @@ -0,0 +1,4 @@ +INSERT INTO user ('userid', 'firstname', 'lastname', 'mail', 'id') VALUES ('user', 'Max', 'Mustermann', 'abc@def.gh', 1); +-- Password = 1234 +INSERT INTO user_attribute VALUES(1,1,'password',X'800495c4000000000000008cc0373731346161336536623932613830366664353038656631323932623134393936393561386463353536623037363761323037623238346264623833313265323333373066376233663462643332666332653766303537333564366335393133366463366234356539633865613835643661643435343931376636626663343163653333643635646530386634396231323061316236386162613164373663663333306564306463303737303733336136353363393538396536343266393865942e'); +INSERT INTO session ('expires', 'token', 'lifetime', 'id', 'user_id') VALUES ('2999-01-01 00:00:00', 'f4ecbe14be3527ca998143a49200e294', 600, 1, 1); \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py index 6dcf468..5e7cadb 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,9 +1,23 @@ import pytest -# with app.app_context(): -# engine = database.db.engine -# with engine.connect() as connection: -# connection.execute("") + +VALID_TOKEN = "f4ecbe14be3527ca998143a49200e294" +USERID = "user" +PASSWORD = "1234" + + +@pytest.mark.depends(on=["test_login_decorator"]) +def test_login(client): + """Testing login""" + result = client.post("/auth", json={"userid": USERID, "password": PASSWORD}) + json = result.get_json() + + # Login successful + assert result.status_code == 201 + # User set correctly + assert json["user"]["userid"] == USERID + # Token works + assert client.get("/auth", headers={"Authorization": f"Bearer {json['session']['token']}"}).status_code == 200 def test_login_decorator(client): @@ -14,3 +28,5 @@ def test_login_decorator(client): assert client.get("/auth", headers={"Authorization": "INVALID"}).status_code == 401 # Invalid Token assert client.get("/auth", headers={"Authorization": "Bearer INVALID"}).status_code == 401 + # Valid Token + assert client.get("/auth", headers={"Authorization": f"Bearer {VALID_TOKEN}"}).status_code == 200 From 2ca8d3b32454287f0281bb1060de469b977205d6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 25 Jan 2021 13:14:02 +0100 Subject: [PATCH 179/446] [Config] Added missing file --- flaschengeist/flaschengeist.toml | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 flaschengeist/flaschengeist.toml diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml new file mode 100644 index 0000000..0ec50a0 --- /dev/null +++ b/flaschengeist/flaschengeist.toml @@ -0,0 +1,102 @@ +# This is the example configuation and the default configuration +# All default values are uncommented, so set enabled ones to False to disabled them + +[FLASCHENGEIST] +# Select authentication provider (builtin: auth_plain, auth_ldap) +auth = "auth_plain" +# Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn +#proxy = false +# Set root path, prefixes all routes +#root = /api +# Set secret key +secret_key = "V3ryS3cr3t" +# Domain used by frontend +#domain = "flaschengeist.local" + +[LOGGING] +# Uncomment to enable logging to a file +#file = "/tmp/flaschengeist-debug.log" +# Logging level, possible: DEBUG INFO WARNING ERROR +level = "WARNING" + +[DATABASE] +# user = "user" +# host = "127.0.0.1" +# password = "password" +# database = "database" + +[auth_plain] +enabled = true + +#[auth_ldap] +# enabled = true +# host = +# port = +# bind_dn = +# base_dn = +# secret = +# use_ssl = +# admin_dn = +# admin_dn = +# default_gid = + +[MESSAGES] +welcome_subject = "Welcome to Flaschengeist {name}" +welcome_text = ''' +Hello {name}! +Welcome to Flaschengeist! + +Your username is {username}, please set your password: +{password_link} +(If the link expires, please just use the Forgot-Password-function). + +Have fun :) +''' + +password_subject = "Flaschengeist - Password reset" +password_text = ''' +Hello {name}! +There was a password reset request for username: {username} + +To change your password, click on this link: +{link} +''' + +password_changed_subject = "Flaschengeist - Password changed" +password_changed_text = ''' +Hello {name}! +Your password was changed for username: {username} + +If this was not you, please contact the support. +''' + +################## +# PLUGINS # +################## + +[users] +# always enabled + +## allowed values: false, "managed", "public" +## false: Disable registration +## "managed": only users with matching permission are allowed to register new users +## "public": Also unauthenticated users can register an account +# registration = False + +############################ +# Configuration of plugins # +############################ +[mail] +# enabled = true +# SERVER = +# PORT = +# USER = +# PASSWORD = +# MAIL = +# SSL or STARTLS +# CRYPT = SSL + +[balance] +# enabled = true +# Enable a default limit, will be set if no other limit is set +# limit = -10.00 From a8c7ddae3db028707696e71adb4ba03a2f6d44b9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 26 Jan 2021 21:32:13 +0100 Subject: [PATCH 180/446] [Plugin] schedule: Fix create event with EventType Object --- flaschengeist/plugins/schedule/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 0cf6f54..681d239 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -254,7 +254,10 @@ def create_event(current_session): data = request.get_json() try: start = from_iso_format(data["start"]) - event_type = event_controller.get_event_type(data["type"]) + data_type = data["type"] + if isinstance(data_type, dict): + data_type = data["type"]["id"] + event_type = event_controller.get_event_type(data_type) except (NotFound, KeyError, ValueError): raise BadRequest("Missing POST parameter") From a4a9d05a31145c1b6a8a8ddb48f1f45f7c10fd9d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 26 Jan 2021 22:25:30 +0100 Subject: [PATCH 181/446] [Plugin] schedule: Fixed set JobType by JSON. --- flaschengeist/plugins/schedule/__init__.py | 6 ++++-- flaschengeist/plugins/schedule/event_controller.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 681d239..178b0c1 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -232,7 +232,10 @@ def _add_job(event, data): if "required_services" not in data: raise BadRequest - job_type = event_controller.get_job_type(data["type"]) + job_type = data["type"] + if isinstance(job_type, dict): + job_type = data["type"]["id"] + job_type = event_controller.get_job_type(job_type) event_controller.add_job(event, job_type, data["required_services"], start, end, comment=data.get("comment")) @@ -261,7 +264,6 @@ def create_event(current_session): except (NotFound, KeyError, ValueError): raise BadRequest("Missing POST parameter") - data = request.get_json() event = event_controller.create_event( start=start, event_type=event_type, description=data["description"] if "description" in data else None ) diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index d88ca64..ef60f3c 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -19,8 +19,11 @@ def get_event_type(identifier): if isinstance(identifier, int): et = EventType.query.get(identifier) - else: + elif isinstance(identifier, str): et = EventType.query.filter(EventType.name == identifier).one_or_none() + else: + logger.debug("Invalid identifier type for EventType") + raise BadRequest if not et: raise NotFound return et From ba25d6177ad9c3bbd957115a961bf20838776edf Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 27 Jan 2021 02:34:04 +0100 Subject: [PATCH 182/446] [Plugin] auth_ldap: Fixed exception if no avatar is set on backend. --- flaschengeist/plugins/auth_ldap/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index f666397..b07cf36 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -137,11 +137,11 @@ class AuthLDAP(AuthPlugin): "ou=user,{}".format(self.dn), "(uid={})".format(user.userid), SUBTREE, - attributes=["uid", "jpegPhoto"], + attributes=["jpegPhoto"], ) r = self.ldap.connection.response[0]["attributes"] - if r["uid"][0] == user.userid: + if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0: avatar = _Avatar() avatar.mimetype = "image/jpeg" avatar.binary.extend(r["jpegPhoto"][0]) From 9eca15d0365a640544d0f7db9557131dc449dcb8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 27 Jan 2021 02:36:07 +0100 Subject: [PATCH 183/446] [Plugin] balance: Fix with reversed transaction and improvement on shortcuts. * Raise exception if transaction is already reversed. * Sort shortcuts --- flaschengeist/plugins/balance/__init__.py | 1 + flaschengeist/plugins/balance/balance_controller.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 23a44ff..54a0202 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -67,6 +67,7 @@ def get_shortcuts(userid, current_session: Session): data = request.get_json() if not isinstance(data, list) or not all(isinstance(n, (int, float)) for n in data): raise BadRequest + data.sort() user.set_attribute("balance_shortcuts", data) userController.persist() return no_content() diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index e305b0b..3bbb820 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -5,7 +5,7 @@ from sqlalchemy import func from datetime import datetime, timezone -from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.exceptions import BadRequest, NotFound, Conflict from flaschengeist.database import db from flaschengeist.models.user import User @@ -109,6 +109,8 @@ def reverse_transaction(transaction: Transaction, author: User): transaction: Transaction to reverse author: User that wants the transaction to be reverted """ + if transaction.reversal: + raise Conflict reversal = send(transaction.receiver_, transaction.sender_, transaction.amount, author) reversal.reversal = transaction transaction.reversal = reversal From 7c0d609b80694b09b3e78edd291182db10c57185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 27 Jan 2021 13:52:12 +0100 Subject: [PATCH 184/446] change return of set_lifetime os session sry i've not configured black to line_width=120 --- flaschengeist/plugins/auth/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 1ca0ad8..853902c 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -50,7 +50,11 @@ def login(): # Lets cleanup the DB sessionController.clear_expired() - return {"session": session, "user": user, "permissions": user.get_permissions()}, CREATED + return { + "session": session, + "user": user, + "permissions": user.get_permissions(), + }, CREATED @auth_bp.route("/auth", methods=["GET"]) @@ -135,9 +139,11 @@ def set_lifetime(token, current_session, **kwargs): raise Forbidden try: lifetime = request.get_json()["value"] - logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) + logger.debug( + "set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token) + ) sessionController.set_lifetime(session, lifetime) - return "", NO_CONTENT + return jsonify(sessionController.get_session(token, current_session._user)) except (KeyError, TypeError): raise BadRequest From 7ccae7e888731d6010e505360eca4e6bd4ed02ee Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 01:08:31 +0100 Subject: [PATCH 185/446] [Plugin] balance: Changed model * serialize Transaction with reversal and original Transaction IDs --- flaschengeist/plugins/auth/__init__.py | 4 +--- flaschengeist/plugins/balance/models.py | 18 ++++++------------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 853902c..a3fc44d 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -139,9 +139,7 @@ def set_lifetime(token, current_session, **kwargs): raise Forbidden try: lifetime = request.get_json()["value"] - logger.debug( - "set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token) - ) + logger.debug(f"set lifetime >{lifetime}< to access token >{token}<") sessionController.set_lifetime(session, lifetime) return jsonify(sessionController.get_session(token, current_session._user)) except (KeyError, TypeError): diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 811124a..e9f1d02 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -13,25 +13,24 @@ class Transaction(db.Model, ModelSerializeMixin): _receiver_id = db.Column("receiver_id", db.Integer, db.ForeignKey("user.id")) _sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id")) _author_id = db.Column("author_id", db.Integer, db.ForeignKey("user.id"), nullable=False) - _reversal_id = db.Column("reversal_id", db.Integer, db.ForeignKey("balance_transaction.id")) # Public and exported member id: int = db.Column("id", db.Integer, primary_key=True) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) - reversal: Optional["Transaction"] = db.relationship( - "Transaction", uselist=False, post_update=True, foreign_keys=[_reversal_id] - ) + reversal_id: int = db.Column(db.Integer, db.ForeignKey("balance_transaction.id")) # Dummy properties used for JSON serialization (userid instead of full user) sender_id: Optional[str] = "" receiver_id: Optional[str] = "" author_id: Optional[str] = "" + original_id: Optional[int] = -1 # Not exported relationships just in backend only sender_: User = db.relationship("User", foreign_keys=[_sender_id]) receiver_: User = db.relationship("User", foreign_keys=[_receiver_id]) author_: User = db.relationship("User", foreign_keys=[_author_id]) + original_ = db.relationship("Transaction", uselist=False, backref=db.backref("reversal_", remote_side=[id])) @property def sender_id(self): @@ -45,11 +44,6 @@ class Transaction(db.Model, ModelSerializeMixin): def author_id(self): return self.author_.userid - # Override to prevent circular dependencies (endless JSON) - def serialize(self): - d = super().serialize() - if d["reversal"]: - d["reversal"].reversal = None - else: - d["reversal"] = None - return d + @property + def original_id(self): + return self.original_.id if self.original_ else None From 708a45d43c63a0d7a11b51ed8a0312762e343615 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 01:09:06 +0100 Subject: [PATCH 186/446] [Plugin] balance: Added function and route for list of transactions --- flaschengeist/plugins/balance/__init__.py | 42 +++++++++++++++++++ .../plugins/balance/balance_controller.py | 20 +++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 54a0202..71c0bcf 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -160,6 +160,48 @@ def get_balance(userid, current_session: Session): return {"credit": balance[0], "debit": balance[1], "balance": balance[2]} +@balance_bp.route("/users//balance/transactions", methods=["GET"]) +@login_required(permission=permissions.SHOW) +def get_transactions(userid, current_session: Session): + """Get transactions of user, optionally filtered + + Route: ``/users//balance/transactions`` | Method: ``GET`` + + GET-parameters: ```{from?: string, to?: string, limit?: int, offset?: int}``` + + Args: + userid: Userid of user to get transactions from + current_session: Session sent with Authorization Header + + Returns: + JSON encoded list of transactions or HTTP error + """ + if userid != current_session._user.userid and not current_session._user.has_permission(permissions.SHOW_OTHER): + raise Forbidden + + # Might raise NotFound + user = userController.get_user(userid) + + start = request.args.get("from") + if start: + start = from_iso_format(start) + end = request.args.get("to") + if end: + end = from_iso_format(end) + limit = request.args.get("limit") + offset = request.args.get("offset") + try: + if limit: + limit = int(limit) + if offset: + offset = int(offset) + except ValueError: + raise BadRequest + + transactions = balance_controller.get_transactions(user, start, end, limit, offset) + return jsonify(transactions) + + @balance_bp.route("/users//balance", methods=["PUT"]) @login_required() def change_balance(userid, current_session: Session): diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 3bbb820..e2732ce 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -36,7 +36,6 @@ def get_balance(user, start: datetime = None, end: datetime = None): credit = ( db.session.query(func.sum(Transaction.amount)) .filter(Transaction.receiver_ == user) - .filter(Transaction._reversal_id.is_(None)) # ignore reverted transactions .filter(start <= Transaction.time) .filter(Transaction.time <= end) .scalar() @@ -45,7 +44,6 @@ def get_balance(user, start: datetime = None, end: datetime = None): debit = ( db.session.query(func.sum(Transaction.amount)) .filter(Transaction.sender_ == user) - .filter(Transaction._reversal_id.is_(None)) # ignore reverted transactions .filter(start <= Transaction.time) .filter(Transaction.time <= end) .scalar() @@ -102,6 +100,20 @@ def get_transaction(transaction_id) -> Transaction: return transaction +def get_transactions(user, start=None, end=None, limit=None, offset=None): + query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user)) + if start: + query = query.filter(start <= Transaction.time) + if end: + query = query.filter(Transaction.time >= end) + query = query.order_by(Transaction.time.desc()) + if offset is not None: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + return query.all() + + def reverse_transaction(transaction: Transaction, author: User): """Reverse a transaction @@ -109,10 +121,10 @@ def reverse_transaction(transaction: Transaction, author: User): transaction: Transaction to reverse author: User that wants the transaction to be reverted """ - if transaction.reversal: + if transaction.reversal_: raise Conflict reversal = send(transaction.receiver_, transaction.sender_, transaction.amount, author) - reversal.reversal = transaction + reversal.reversal_ = transaction transaction.reversal = reversal db.session.commit() return reversal From 862bafbbd3ae04765e5abbe81460933fd60389e9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 01:25:30 +0100 Subject: [PATCH 187/446] [Plugin] balance: Fixed transaction filter issue, fixed reverse * Filter by end now filters correctly * Reverse a transaction will now return correct reversal transaction --- flaschengeist/controller/userController.py | 12 +++--------- flaschengeist/plugins/balance/balance_controller.py | 4 ++-- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 283de3d..02cfc56 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -47,9 +47,7 @@ def request_reset(user: User): logger.debug(f"New password reset request for {user.userid}") reset = _generate_password_reset(user) - subject = str(config["MESSAGES"]["password_subject"]).format( - name=user.display_name, username=user.userid - ) + subject = str(config["MESSAGES"]["password_subject"]).format(name=user.display_name, username=user.userid) text = str(config["MESSAGES"]["password_text"]).format( name=user.display_name, username=user.userid, @@ -158,9 +156,7 @@ def find_user(uid_mail): if user: update_user(user) else: - user = current_app.config["FG_AUTH_BACKEND"].find_user( - uid_mail, uid_mail if mail else None - ) + user = current_app.config["FG_AUTH_BACKEND"].find_user(uid_mail, uid_mail if mail else None) if user: db.session.add(user) db.session.commit() @@ -190,9 +186,7 @@ def register(data): reset = _generate_password_reset(user) - subject = str(config["MESSAGES"]["welcome_subject"]).format( - name=user.display_name, username=user.userid - ) + subject = str(config["MESSAGES"]["welcome_subject"]).format(name=user.display_name, username=user.userid) text = str(config["MESSAGES"]["welcome_text"]).format( name=user.display_name, username=user.userid, diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index e2732ce..4cf0360 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -105,7 +105,7 @@ def get_transactions(user, start=None, end=None, limit=None, offset=None): if start: query = query.filter(start <= Transaction.time) if end: - query = query.filter(Transaction.time >= end) + query = query.filter(Transaction.time <= end) query = query.order_by(Transaction.time.desc()) if offset is not None: query = query.offset(offset) @@ -124,7 +124,7 @@ def reverse_transaction(transaction: Transaction, author: User): if transaction.reversal_: raise Conflict reversal = send(transaction.receiver_, transaction.sender_, transaction.amount, author) - reversal.reversal_ = transaction + reversal.original_ = transaction transaction.reversal = reversal db.session.commit() return reversal From 78338027e11db85def7a2b79c88d01563428a2eb Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 19:00:32 +0100 Subject: [PATCH 188/446] [Doc] Readme --- readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readme.md b/readme.md index c950344..1041e1e 100644 --- a/readme.md +++ b/readme.md @@ -74,6 +74,8 @@ Or if you just want to use `git blame`, configure git like this: $ git config blame.ignoreRevsFile .git-blame-ignore-revs +#### Ignore changes on config + git update-index --assume-unchanged flaschengeist/flaschengeist.toml ## Plugin Development ### File Structure From 2bbc4898e76649cb6520327cf578a110efd3a7b0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 20:07:41 +0100 Subject: [PATCH 189/446] [Plugin] Schedule: Added end as Event attribute --- flaschengeist/plugins/schedule/__init__.py | 9 +++++++-- flaschengeist/plugins/schedule/event_controller.py | 4 ++-- flaschengeist/plugins/schedule/models.py | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 178b0c1..ebe48ea 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -255,17 +255,22 @@ def create_event(current_session): JSON encoded Event object or HTTP-error """ data = request.get_json() + end = data.get("end", None) try: start = from_iso_format(data["start"]) + if end is not None: + end = from_iso_format(end) data_type = data["type"] if isinstance(data_type, dict): data_type = data["type"]["id"] event_type = event_controller.get_event_type(data_type) - except (NotFound, KeyError, ValueError): + except KeyError: raise BadRequest("Missing POST parameter") + except (NotFound, ValueError): + raise BadRequest("Invalid parameter") event = event_controller.create_event( - start=start, event_type=event_type, description=data["description"] if "description" in data else None + start=start, end=end, event_type=event_type, description=data.get("description", None) ) if "jobs" in data: for job in data["jobs"]: diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index ef60f3c..da72d98 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -128,10 +128,10 @@ def delete_event(event_id): db.session.commit() -def create_event(event_type, start, jobs=[], description=None): +def create_event(event_type, start, end=None, jobs=[], description=None): try: logger.debug(event_type) - event = Event(start=start, description=description, type=event_type, jobs=jobs) + event = Event(start=start, end=end, description=description, type=event_type, jobs=jobs) db.session.add(event) db.session.commit() return event diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index 1c7fa27..118c9b9 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -78,6 +78,7 @@ class Event(db.Model, ModelSerializeMixin): id: int = db.Column(db.Integer, primary_key=True) start: datetime = db.Column(UtcDateTime, nullable=False) + end: datetime = db.Column(UtcDateTime) description: Optional[str] = db.Column(db.String(255)) type: EventType = db.relationship("EventType") jobs: [Job] = db.relationship( From d1157f0f37109126fadc86a62a1874dba530f8ee Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 20:08:34 +0100 Subject: [PATCH 190/446] [Plugin] Balance: Return Transaction count when using limit parameter for pagination --- flaschengeist/plugins/balance/__init__.py | 11 ++++++----- flaschengeist/plugins/balance/balance_controller.py | 11 +++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 71c0bcf..b916656 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -164,6 +164,7 @@ def get_balance(userid, current_session: Session): @login_required(permission=permissions.SHOW) def get_transactions(userid, current_session: Session): """Get transactions of user, optionally filtered + Returns also count of transactions if limit is set (e.g. just count with limit = 0) Route: ``/users//balance/transactions`` | Method: ``GET`` @@ -174,7 +175,7 @@ def get_transactions(userid, current_session: Session): current_session: Session sent with Authorization Header Returns: - JSON encoded list of transactions or HTTP error + JSON Object {transactions: Transaction[], count?: number} or HTTP error """ if userid != current_session._user.userid and not current_session._user.has_permission(permissions.SHOW_OTHER): raise Forbidden @@ -191,15 +192,15 @@ def get_transactions(userid, current_session: Session): limit = request.args.get("limit") offset = request.args.get("offset") try: - if limit: + if limit is not None: limit = int(limit) - if offset: + if offset is not None: offset = int(offset) except ValueError: raise BadRequest - transactions = balance_controller.get_transactions(user, start, end, limit, offset) - return jsonify(transactions) + transactions, count = balance_controller.get_transactions(user, start, end, limit, offset) + return {"transactions": transactions, "count": count} @balance_bp.route("/users//balance", methods=["PUT"]) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 4cf0360..cd730d5 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -101,17 +101,20 @@ def get_transaction(transaction_id) -> Transaction: def get_transactions(user, start=None, end=None, limit=None, offset=None): + count = None query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user)) if start: query = query.filter(start <= Transaction.time) if end: query = query.filter(Transaction.time <= end) - query = query.order_by(Transaction.time.desc()) + query = query.order_by(Transaction.time) + if limit is not None: + count = query.count() + query = query.limit(limit) if offset is not None: query = query.offset(offset) - if limit is not None: - query = query.limit(limit) - return query.all() + + return query.all(), count def reverse_transaction(transaction: Transaction, author: User): From 06a806da3d491a01afee6dbb971c68ed96c9842b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 21:06:46 +0100 Subject: [PATCH 191/446] [Plugin] Balance: Allow filtering reversals and cancelled transactions --- flaschengeist/plugins/balance/__init__.py | 18 +++++++++++++++++- .../plugins/balance/balance_controller.py | 7 ++++++- flaschengeist/plugins/balance/models.py | 3 +-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index b916656..f67e924 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -41,6 +41,14 @@ class BalancePlugin(Plugin): db.create_all() +def str2bool(string: str): + if string.lower() in ["true", "yes", "1"]: + return True + elif string.lower() in ["false", "no", "0"]: + return False + raise ValueError + + @balance_bp.route("/users//balance/shortcuts", methods=["GET", "PUT"]) @login_required() def get_shortcuts(userid, current_session: Session): @@ -189,6 +197,8 @@ def get_transactions(userid, current_session: Session): end = request.args.get("to") if end: end = from_iso_format(end) + show_reversals = request.args.get("showReversals", False) + show_cancelled = request.args.get("showCancelled", True) limit = request.args.get("limit") offset = request.args.get("offset") try: @@ -196,10 +206,16 @@ def get_transactions(userid, current_session: Session): limit = int(limit) if offset is not None: offset = int(offset) + if not isinstance(show_reversals, bool): + show_reversals = str2bool(show_reversals) + if not isinstance(show_cancelled, bool): + show_cancelled = str2bool(show_cancelled) except ValueError: raise BadRequest - transactions, count = balance_controller.get_transactions(user, start, end, limit, offset) + transactions, count = balance_controller.get_transactions( + user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled + ) return {"transactions": transactions, "count": count} diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index cd730d5..475ef2a 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -100,13 +100,18 @@ def get_transaction(transaction_id) -> Transaction: return transaction -def get_transactions(user, start=None, end=None, limit=None, offset=None): +def get_transactions(user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True): count = None query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user)) if start: query = query.filter(start <= Transaction.time) if end: query = query.filter(Transaction.time <= end) + # Do not show reversals if disabled or cancelled ones are hidden + if not show_reversal or not show_cancelled: + query = query.filter(Transaction.original_ == None) + if not show_cancelled: + query = query.filter(Transaction.reversal_id.is_(None)) query = query.order_by(Transaction.time) if limit is not None: count = query.count() diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index e9f1d02..e981ad9 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -1,9 +1,8 @@ from datetime import datetime from typing import Optional -from flaschengeist.models.user import User - from flaschengeist.database import db +from flaschengeist.models.user import User from flaschengeist.models import ModelSerializeMixin, UtcDateTime From 13e0d60d8e64f8858dd7be685440a3455cf3430e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 21:19:32 +0100 Subject: [PATCH 192/446] [Plugin] Balance: Simplified get_balance --- .../plugins/balance/balance_controller.py | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 475ef2a..35050ff 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -28,27 +28,14 @@ def get_limit(user: User) -> float: def get_balance(user, start: datetime = None, end: datetime = None): - if not start: - start = datetime.fromtimestamp(0, tz=timezone.utc) - if not end: - end = datetime.now(tz=timezone.utc) - - credit = ( - db.session.query(func.sum(Transaction.amount)) - .filter(Transaction.receiver_ == user) - .filter(start <= Transaction.time) - .filter(Transaction.time <= end) - .scalar() - ) or 0 - - debit = ( - db.session.query(func.sum(Transaction.amount)) - .filter(Transaction.sender_ == user) - .filter(start <= Transaction.time) - .filter(Transaction.time <= end) - .scalar() - ) or 0 + query = db.session.query(func.sum(Transaction.amount)) + if start: + query = query.filter(start <= Transaction.time) + if end: + query = query.filter(Transaction.time <= end) + credit = query.filter(Transaction.receiver_ == user).scalar() or 0 + debit = query.filter(Transaction.sender_ == user).scalar() or 0 return credit, debit, credit - debit From b46e93fd26da036811a3715ce9334d8eeeedd7b8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 23:01:13 +0100 Subject: [PATCH 193/446] [System] UserController: Passwords should be strings * Even the inital dummy password --- flaschengeist/controller/userController.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 02cfc56..111db65 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -179,7 +179,7 @@ def register(data): user = User(**values) set_roles(user, roles) - password = secrets.token_bytes(16) + password = secrets.token_urlsafe(16) current_app.config["FG_AUTH_BACKEND"].create_user(user, password) db.session.add(user) db.session.commit() From 1246ce50fd4e779c4b20a077fdd3a122084ba750 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 29 Jan 2021 23:28:43 +0100 Subject: [PATCH 194/446] [Plugin] Balance: Route and method to query all balances. --- flaschengeist/plugins/balance/__init__.py | 17 +++++++++++++++ .../plugins/balance/balance_controller.py | 21 +++++++++++++++++++ flaschengeist/plugins/balance/models.py | 14 +++++++++++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index f67e924..c64ca0b 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -291,3 +291,20 @@ def reverse_transaction(transaction_id, current_session: Session): reversal = balance_controller.reverse_transaction(transaction, current_session._user) return HTTP.created(reversal) raise Forbidden + + +@balance_bp.route("/balance", methods=["GET"]) +@login_required(permission=permissions.SHOW_OTHER) +def get_balances(current_session: Session): + """Get all balances + + Route: ``/balance`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON Array containing credit, debit and userid for each user or HTTP error + """ + balances = balance_controller.get_balances() + return jsonify([{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()]) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 35050ff..f363855 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -39,6 +39,27 @@ def get_balance(user, start: datetime = None, end: datetime = None): return credit, debit, credit - debit +def get_balances(start: datetime = None, end: datetime = None): + debit = db.session.query(Transaction.sender_id, func.sum(Transaction.amount)).filter(Transaction.sender_ != None) + credit = db.session.query(Transaction.receiver_id, func.sum(Transaction.amount)).filter(Transaction.receiver_ != None) + if start: + debit = debit.filter(start <= Transaction.time) + credit = credit.filter(start <= Transaction.time) + if end: + debit = debit.filter(Transaction.time <= end) + credit = credit.filter(Transaction.time <= end) + + debit = debit.group_by(Transaction._sender_id).all() + credit = credit.group_by(Transaction._receiver_id).all() + all = {} + for uid, cred in credit: + all[uid] = [cred, 0] + for uid, deb in debit: + all.setdefault(uid, [0, 0]) + all[uid][1] = deb + return all + + def send(sender: User, receiver, amount: float, author: User): """Send credit from one user to an other diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index e981ad9..03ab890 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -1,6 +1,8 @@ from datetime import datetime from typing import Optional +from sqlalchemy.ext.hybrid import hybrid_property + from flaschengeist.database import db from flaschengeist.models.user import User from flaschengeist.models import ModelSerializeMixin, UtcDateTime @@ -31,14 +33,22 @@ class Transaction(db.Model, ModelSerializeMixin): author_: User = db.relationship("User", foreign_keys=[_author_id]) original_ = db.relationship("Transaction", uselist=False, backref=db.backref("reversal_", remote_side=[id])) - @property + @hybrid_property def sender_id(self): return self.sender_.userid if self.sender_ else None - @property + @sender_id.expression + def sender_id(cls): + return db.select([User.userid]).where(cls._sender_id == User._id).as_scalar() + + @hybrid_property def receiver_id(self): return self.receiver_.userid if self.receiver_ else None + @receiver_id.expression + def receiver_id(cls): + return db.select([User.userid]).where(cls._receiver_id == User._id).as_scalar() + @property def author_id(self): return self.author_.userid From 2daa79ee1572d786296447f453a7124e289332c5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 4 Feb 2021 03:32:16 +0100 Subject: [PATCH 195/446] [Plugin] user: Added permissions to model --- flaschengeist/models/user.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 0d9d506..fb0f050 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,5 +1,5 @@ from flask import url_for -from typing import Optional +from typing import Optional, Type from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection @@ -56,6 +56,7 @@ class User(db.Model, ModelSerializeMixin): mail: str = db.Column(db.String(60), nullable=False) birthday: Optional[date] = db.Column(db.Date) roles: [str] = [] + permissions: Optional[type([str])] = None avatar_url: Optional[str] = "" roles_: [Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") From 791656147efb9d2a3a9a33048c3cb8928ef06f47 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 4 Feb 2021 03:32:49 +0100 Subject: [PATCH 196/446] [Plugin] balance: Raise conflict if limit exceeded. --- flaschengeist/plugins/balance/balance_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index f363855..19ed72e 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -80,7 +80,7 @@ def send(sender: User, receiver, amount: float, author: User): if (get_balance(sender)[2] - amount) < sender.get_attribute(__attribute_limit) and not author.has_permission( permissions.EXCEED_LIMIT ): - raise BadRequest("Limit exceeded") + raise Conflict("Limit exceeded") transaction = Transaction(sender_=sender, receiver_=receiver, amount=amount, author_=author) db.session.add(transaction) From ed361a7361f5ede5405ca869b998a14248154995 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 7 Feb 2021 14:38:29 +0100 Subject: [PATCH 197/446] [System] Fixed some minor python warnings --- .gitignore | 1 - flaschengeist/models/user.py | 2 +- flaschengeist/plugins/balance/balance_controller.py | 7 ++++--- flaschengeist/plugins/message_mail.py | 2 +- run_flaschengeist | 2 -- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index c84197f..7718450 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,6 @@ coverage.xml *.pot # Django stuff: -*.log local_settings.py db.sqlite3 diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index fb0f050..b8e8b83 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,5 +1,5 @@ from flask import url_for -from typing import Optional, Type +from typing import Optional from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 19ed72e..ea4d499 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -4,7 +4,7 @@ # Credit -> to account from sqlalchemy import func -from datetime import datetime, timezone +from datetime import datetime from werkzeug.exceptions import BadRequest, NotFound, Conflict from flaschengeist.database import db @@ -12,7 +12,6 @@ from flaschengeist.models.user import User from .models import Transaction from . import permissions -from ... import logger __attribute_limit = "balance_limit" @@ -41,7 +40,9 @@ def get_balance(user, start: datetime = None, end: datetime = None): def get_balances(start: datetime = None, end: datetime = None): debit = db.session.query(Transaction.sender_id, func.sum(Transaction.amount)).filter(Transaction.sender_ != None) - credit = db.session.query(Transaction.receiver_id, func.sum(Transaction.amount)).filter(Transaction.receiver_ != None) + credit = db.session.query(Transaction.receiver_id, func.sum(Transaction.amount)).filter( + Transaction.receiver_ != None + ) if start: debit = debit.filter(start <= Transaction.time) credit = credit.filter(start <= Transaction.time) diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index 3d2f162..79b6a64 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -28,7 +28,7 @@ class MailMessagePlugin(Plugin): def send_mail(self, msg: Message): if isinstance(msg.receiver, User): if not msg.receiver.mail: - logger.warn("Could not send Mail, mail missing: {}".format(msg.receiver)) + logger.warning("Could not send Mail, mail missing: {}".format(msg.receiver)) return recipients = [msg.receiver.mail] else: diff --git a/run_flaschengeist b/run_flaschengeist index 3cf034f..522fbd0 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -2,9 +2,7 @@ import inspect import argparse import sys - import pkg_resources -from werkzeug.middleware.dispatcher import DispatcherMiddleware from flaschengeist.config import config From e7efa530719265bdb9434af49f62340d294534e6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 7 Feb 2021 16:33:48 +0100 Subject: [PATCH 198/446] [Plugin] Schedule: Allow retraction of service --- flaschengeist/plugins/schedule/event_controller.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index da72d98..dbc829d 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -169,9 +169,14 @@ def delete_job(job: Job): def assign_to_job(job: Job, user, value): service = Service.query.get((job.id, user._id)) - if service: - service.value = value + if value < 0: + if not service: + raise BadRequest + db.session.delete(service) else: - service = Service(user_=user, value=value, job_=job) - db.session.add(service) + if service: + service.value = value + else: + service = Service(user_=user, value=value, job_=job) + db.session.add(service) db.session.commit() From 06237754f127767990b314e11fc3d51648c817e6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 10 Feb 2021 17:40:47 +0100 Subject: [PATCH 199/446] [System][Plugin] moved decorator --- flaschengeist/plugins/auth/__init__.py | 13 +++------- flaschengeist/plugins/balance/__init__.py | 4 +-- flaschengeist/plugins/roles/__init__.py | 4 +-- flaschengeist/plugins/schedule/__init__.py | 5 ++-- flaschengeist/plugins/users/__init__.py | 5 +++- .../{decorator.py => utils/decorators.py} | 26 +++++++++++++++++++ 6 files changed, 40 insertions(+), 17 deletions(-) rename flaschengeist/{decorator.py => utils/decorators.py} (58%) diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index a3fc44d..07b00c4 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -2,14 +2,13 @@ Allow management of authentication, login, logout, etc. """ -from http.client import CREATED, NO_CONTENT from flask import Blueprint, request, jsonify -from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized, NotFound +from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from flaschengeist import logger from flaschengeist.plugins import Plugin -from flaschengeist.utils.HTTP import no_content -from flaschengeist.decorator import login_required +from flaschengeist.utils.HTTP import no_content, created +from flaschengeist.utils.decorators import login_required from flaschengeist.controller import sessionController, userController auth_bp = Blueprint("auth", __name__) @@ -50,11 +49,7 @@ def login(): # Lets cleanup the DB sessionController.clear_expired() - return { - "session": session, - "user": user, - "permissions": user.get_permissions(), - }, CREATED + return created(session) @auth_bp.route("/auth", methods=["GET"]) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index c64ca0b..3f458c7 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -13,7 +13,7 @@ from flaschengeist import logger from flaschengeist.utils import HTTP from flaschengeist.models.session import Session from flaschengeist.utils.datetime import from_iso_format -from flaschengeist.decorator import login_required +from flaschengeist.utils.decorators import login_required from flaschengeist.controller import userController from flaschengeist.plugins import Plugin, before_update_user @@ -75,7 +75,7 @@ def get_shortcuts(userid, current_session: Session): data = request.get_json() if not isinstance(data, list) or not all(isinstance(n, (int, float)) for n in data): raise BadRequest - data.sort() + data.sort(reverse=True) user.set_attribute("balance_shortcuts", data) userController.persist() return no_content() diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index de0c505..8ad1a35 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -5,10 +5,10 @@ Provides routes used to configure roles and permissions of users / roles. from werkzeug.exceptions import BadRequest from flask import Blueprint, request, jsonify -from http.client import CREATED, NO_CONTENT +from http.client import NO_CONTENT from flaschengeist.plugins import Plugin -from flaschengeist.decorator import login_required +from flaschengeist.utils.decorators import login_required from flaschengeist.controller import roleController from flaschengeist.utils.HTTP import created diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index ebe48ea..d21b4f3 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -3,19 +3,18 @@ Provides duty schedule / duty roster functions """ from datetime import datetime, timedelta, timezone -from http.client import NO_CONTENT, CREATED +from http.client import NO_CONTENT from flask import Blueprint, request, jsonify from werkzeug.exceptions import BadRequest, NotFound, Forbidden from flaschengeist.plugins import Plugin from flaschengeist.models.session import Session -from flaschengeist.decorator import login_required +from flaschengeist.utils.decorators import login_required from flaschengeist.utils.datetime import from_iso_format from flaschengeist.controller import userController from . import event_controller, permissions from . import models -from ... import logger from ...utils.HTTP import no_content schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 8b75e2f..02e565e 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -11,7 +11,7 @@ from flaschengeist import logger from flaschengeist.config import config from flaschengeist.plugins import Plugin from flaschengeist.models.user import User, _Avatar -from flaschengeist.decorator import login_required, extract_session +from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.controller import userController from flaschengeist.utils.HTTP import created from flaschengeist.utils.datetime import from_iso_format @@ -57,6 +57,7 @@ def register(): @users_bp.route("/users", methods=["GET"]) @login_required() +@headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) def list_users(current_session): """List all existing users @@ -75,6 +76,7 @@ def list_users(current_session): @users_bp.route("/users/", methods=["GET"]) @login_required() +@headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) def get_user(userid, current_session): """Retrieve user by userid @@ -96,6 +98,7 @@ def get_user(userid, current_session): @users_bp.route("/users//avatar", methods=["GET"]) +@headers({"Cache-Control": "public, max-age=604800"}) def get_avatar(userid): user = userController.get_user(userid) avatar = userController.load_avatar(user) diff --git a/flaschengeist/decorator.py b/flaschengeist/utils/decorators.py similarity index 58% rename from flaschengeist/decorator.py rename to flaschengeist/utils/decorators.py index 18ded1a..b26f66a 100644 --- a/flaschengeist/decorator.py +++ b/flaschengeist/utils/decorators.py @@ -40,3 +40,29 @@ def login_required(permission=None): return wrapped_f return wrap + + +def headers(headers={}, **headers_kwargs): + """ + Wrap a Flask route to add HTTP headers. + Either pass a dictionary of headers to be set as the headerDict keyword + argument, or pass header values as keyword arguments. Or both. + + The key and value of items in a dictionary will be converted to strings using + the `str` method, ensure both keys and values are serializable thusly. + Args: + headers: A dictionary of headers to be injected into the response headers. + Note, the supplied dictionary is first copied then mutated. + headers_kwargs: The headers to be injected into the response headers. + """ + _headerDict = headers.copy() + _headerDict.update(headers_kwargs) + + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + return f(*args, **kwargs), _headerDict + + return decorated_function + + return decorator From 29157dbc03897e0e170aab931d0a27e168dd1383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 10 Feb 2021 21:21:53 +0100 Subject: [PATCH 200/446] [config] add port-config for database --- flaschengeist/config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 09ada68..089dab9 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -9,7 +9,7 @@ from flaschengeist import _module_path, logger # Default config: -config = {} +config = {"DATABASE": {"port": 3306}} def update_dict(d, u): @@ -66,11 +66,12 @@ def configure_app(app, test_config=None): app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] if test_config is None: - app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://{user}:{passwd}@{host}/{database}".format( + app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://{user}:{passwd}@{host}:{port}/{database}".format( user=config["DATABASE"]["user"], passwd=config["DATABASE"]["password"], host=config["DATABASE"]["host"], database=config["DATABASE"]["database"], + port=config["DATABASE"]["port"], ) else: app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite+pysqlite://{config['DATABASE']['file_path']}" From a6a1de19de9aa3698f0878edd0ff81015123a8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 13 Feb 2021 14:13:46 +0100 Subject: [PATCH 201/446] [pricelist] first commit for pricelist plugin --- flaschengeist/plugins/pricelist/__init__.py | 127 +++++++++++ flaschengeist/plugins/pricelist/models.py | 90 ++++++++ .../plugins/pricelist/permissions.py | 23 ++ .../plugins/pricelist/pricelist_controller.py | 209 ++++++++++++++++++ setup.py | 1 + 5 files changed, 450 insertions(+) create mode 100644 flaschengeist/plugins/pricelist/__init__.py create mode 100644 flaschengeist/plugins/pricelist/models.py create mode 100644 flaschengeist/plugins/pricelist/permissions.py create mode 100644 flaschengeist/plugins/pricelist/pricelist_controller.py diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py new file mode 100644 index 0000000..8c9e3d9 --- /dev/null +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -0,0 +1,127 @@ +"""Pricelist plugin""" + +from flask import Blueprint, jsonify, request +from http.client import NO_CONTENT + +from flaschengeist.plugins import Plugin +from flaschengeist.utils.decorators import login_required +from werkzeug.exceptions import BadRequest + +from . import models +from . import pricelist_controller, permissions + +pricelist_bp = Blueprint("pricelist", __name__, url_prefix="/pricelist") + + +class PriceListPlugin(Plugin): + models = models + + def __init__(self, cfg): + super().__init__(blueprint=pricelist_bp, permissions=permissions.permissions) + config = {"discount": 0} + config.update(cfg) + + def install(self): + from flaschengeist.database import db + + db.create_all() + + +@pricelist_bp.route("/drink-types", methods=["GET"]) +@pricelist_bp.route("/drink-types/", methods=["GET"]) +def get_drink_types(identifier=None): + if identifier: + result = pricelist_controller.get_drink_type(identifier) + else: + result = pricelist_controller.get_drink_types() + return jsonify(result) + + +@pricelist_bp.route("/drink-types", methods=["POST"]) +@login_required(permission=permissions.CREATE_TYPE) +def new_drink_type(current_session): + data = request.get_json() + if "name" not in data: + raise BadRequest + drink_type = pricelist_controller.create_drink_type(data["name"]) + return jsonify(drink_type) + + +@pricelist_bp.route("/drink-types/", methods=["PUT"]) +@login_required(permission=permissions.EDIT_TYPE) +def update_drink_type(identifier, current_session): + data = request.get_json() + if "name" not in data: + raise BadRequest + drink_type = pricelist_controller.rename_drink_type(data["id"], data["name"]) + return jsonify(drink_type) + + +@pricelist_bp.route("/drink-types/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE_TYPE) +def delete_drink_type(identifier, current_session): + pricelist_controller.delete_drink_type(identifier) + return "", NO_CONTENT + + +@pricelist_bp.route("/tags", methods=["GET"]) +@pricelist_bp.route("/tags/", methods=["GET"]) +def get_tags(identifier=None): + if identifier: + result = pricelist_controller.get_tag(identifier) + else: + result = pricelist_controller.get_tags() + return jsonify(result) + + +@pricelist_bp.route("/tags", methods=["POST"]) +@login_required(permission=permissions.CREATE_TAG) +def new_tag(current_session): + data = request.get_json() + if "name" not in data: + raise BadRequest + drink_type = pricelist_controller.create_tag(data["name"]) + return jsonify(drink_type) + + +@pricelist_bp.route("/tags/", methods=["PUT"]) +@login_required(permission=permissions.EDIT_TAG) +def update_tag(identifier, current_session): + data = request.get_json() + if "name" not in data: + raise BadRequest + drink_type = pricelist_controller.rename_tag(data["name"]) + return jsonify(drink_type) + + +@pricelist_bp.route("/tags/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE_TAG) +def delete_tag(identifier, current_session): + pricelist_controller.delete_tag(identifier) + return "", NO_CONTENT + + +@pricelist_bp.route("/drinks", methods=["GET"]) +@pricelist_bp.route("/drinks/", methods=["GET"]) +def get_drinks(identifier=None): + if identifier: + result = pricelist_controller.get_drink(identifier) + else: + result = pricelist_controller.get_drinks() + return jsonify(result) + + +@pricelist_bp.route("/drinks/search/", methods=["GET"]) +def search_drinks(name): + return jsonify(pricelist_controller.get_drinks(name)) + + +@pricelist_bp.route("/drinks", methods=["POST"]) +@login_required(permission=permissions.CREATE) +def create_drink(current_session): + data = request.get_json() + if not all(item in data for item in ["name", "volume", "cost_price"]) or not all( + item in data for item in ["name", "ingredients"] + ): + raise BadRequest("No correct Keys to create drink") + return jsonify(pricelist_controller.create_drink(data)) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py new file mode 100644 index 0000000..1eb9ae6 --- /dev/null +++ b/flaschengeist/plugins/pricelist/models.py @@ -0,0 +1,90 @@ +from flaschengeist.database import db +from flaschengeist.models import ModelSerializeMixin + +from typing import Optional + +drink_tag_association = db.Table( + "drink_x_tag", + db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")), + db.Column("tag_id", db.Integer, db.ForeignKey("drink_tag.id")), +) + +drink_type_association = db.Table( + "drink_x_type", + db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")), + db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")), +) + + +class Tag(db.Model, ModelSerializeMixin): + """ + Tag + """ + + __tablename__ = "drink_tag" + id: int = db.Column("id", db.Integer, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +class DrinkType(db.Model, ModelSerializeMixin): + """ + DrinkType + """ + + __tablename__ = "drink_type" + id: int = db.Column("id", db.Integer, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +class DrinkPrice(db.Model, ModelSerializeMixin): + """ + PriceFromVolume + """ + + __tablename__ = "drink_price" + id: int = db.Column("id", db.Integer, primary_key=True) + volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) + price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + drink_id = db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")) + drink = db.relationship("Drink", back_populates="prices") + no_auto: bool = db.Column(db.Boolean, default=False) + public: bool = db.Column(db.Boolean, default=True) + description: Optional[str] = db.Column(db.String(30)) + round_step: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False, default=0.5) + + +class Ingredient(db.Model, ModelSerializeMixin): + """ + Drink Build + """ + + __tablename__ = "drink_ingredient" + id: int = db.Column("id", db.Integer, primary_key=True) + volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) + drink_parent_id: int = db.Column("drink_parent_id", db.Integer, db.ForeignKey("drink.id")) + drink_parent = db.relationship("Drink", foreign_keys=drink_parent_id) + drink_ingredient_id: int = db.Column("drink_ingredient_id", db.Integer, db.ForeignKey("drink.id")) + drink_ingredient: "Drink" = db.relationship("Drink", foreign_keys=drink_ingredient_id) + + +class Drink(db.Model, ModelSerializeMixin): + """ + DrinkPrice + """ + + __tablename__ = "drink" + id: int = db.Column("id", db.Integer, primary_key=True) + name: str = db.Column(db.String(60), nullable=False) + volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + cost_price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + discount: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) + extra_charge: Optional[float] = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), default=0) + prices: [DrinkPrice] = db.relationship( + "DrinkPrice", back_populates="drink", cascade="all,delete,delete-orphan", order_by=[DrinkPrice.volume] + ) + ingredients: [Ingredient] = db.relationship( + "Ingredient", back_populates="drink_parent", foreign_keys=Ingredient.drink_parent_id + ) + tags: [Optional[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") + type_id_ = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) + type = db.relationship("DrinkType") diff --git a/flaschengeist/plugins/pricelist/permissions.py b/flaschengeist/plugins/pricelist/permissions.py new file mode 100644 index 0000000..b92ab9a --- /dev/null +++ b/flaschengeist/plugins/pricelist/permissions.py @@ -0,0 +1,23 @@ +CREATE = "drink_create" +"""Can create drinks""" + +EDIT = "drink_edit" +"""Can edit drinks""" + +DELETE = "drink_delete" +"""Can delete drinks""" + +CREATE_TAG = "drink_tag_create" +"""Can create and edit Tags""" + +EDIT_TAG = "drink_tag_edit" + +DELETE_TAG = "drink_tag_delete" + +CREATE_TYPE = "drink_type_create" + +EDIT_TYPE = "drink_type_edit" + +DELETE_TYPE = "drink_type_delete" + +permissions = [value for key, value in globals().items() if not key.startswith("_")] diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py new file mode 100644 index 0000000..93c365c --- /dev/null +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -0,0 +1,209 @@ +from werkzeug.exceptions import BadRequest, NotFound +from sqlalchemy.exc import IntegrityError + +from flaschengeist import logger +from flaschengeist.database import db +from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType + +from math import ceil + + +def update(): + db.session.commit() + + +def get_tags(): + return Tag.query.all() + + +def get_tag(identifier): + if isinstance(identifier, int): + retVal = Tag.query.get(identifier) + elif isinstance(identifier, str): + retVal = Tag.query.filter(Tag.name == identifier).one_or_none() + else: + logger.debug("Invalid identifier type for Tag") + raise BadRequest + if not retVal: + raise NotFound + return retVal + + +def create_tag(name): + try: + tag = Tag(name=name) + db.session.add(tag) + update() + return tag + except IntegrityError: + raise BadRequest("Name already exists") + + +def rename_tag(identifier, new_name): + tag = get_tag(identifier) + tag.name = new_name + try: + update() + except IntegrityError: + raise BadRequest("Name already exists") + + +def delete_tag(identifier): + tag = get_tag(identifier) + db.session.delete(tag) + try: + update() + except IntegrityError: + raise BadRequest("Tag still in use") + + +def get_drink_types(): + return DrinkType.query.all() + + +def get_drink_type(identifier): + if isinstance(identifier, int): + retVal = DrinkType.query.get(identifier) + elif isinstance(identifier, str): + retVal = DrinkType.query.filter(Tag.name == identifier).one_or_none() + else: + logger.debug("Invalid identifier type for DrinkType") + raise BadRequest + if not retVal: + raise NotFound + return retVal + + +def create_drink_type(name): + try: + drinkType = DrinkType(name=name) + db.session.add(drinkType) + update() + return drinkType + except IntegrityError: + raise BadRequest("Name already exists") + + +def rename_drink_type(identifier, new_name): + drink_type = get_drink_type(identifier) + drink_type.name = new_name + try: + update() + except IntegrityError: + raise BadRequest("Name already exists") + return drink_type + + +def delete_drink_type(identifier): + drinkType = get_drink_type(identifier) + db.session.delete(drinkType) + try: + update() + except IntegrityError: + raise BadRequest("DrinkType still in use") + + +def round_price(price, round_step): + return round(ceil(float(price) / round_step) * round_step * 100) / 100 + + +def calc_prices(drink, prices): + retVal = [] + if len(drink.ingredients) > 0: + return calc_price_by_ingredients(drink, prices) + allowed_keys = DrinkPrice().serialize().keys() + for price in prices: + values = {key: value for key, value in price.items() if key in allowed_keys} + if values.get("no_auto"): + retVal.append(DrinkPrice(**values)) + else: + volume = float(values.pop("volume")) + if "price" in values: + values.pop("price") + _price = float(drink.cost_price) / float(drink.volume) * volume + _price += _price * float(drink.discount) + if drink.extra_charge: + _price += float(drink.extra_charge) + _price = round_price(_price, float(price.get("round_step"))) + retVal.append(DrinkPrice(volume=volume, price=_price, **values)) + return retVal + + +def calc_price_by_ingredients(drink, prices): + allowed_keys = DrinkPrice().serialize().keys() + retVal = [] + for price in prices: + values = {key: value for key, value in price.items() if key in allowed_keys} + if values.get("no_auto"): + retVal.append(DrinkPrice(**values)) + else: + volume = float(values.pop("volume")) + if "price" in values: + values.pop("price") + _price = 0 + for ingredient in drink.ingredients: + _price = ( + float(ingredient.drink_ingredient.cost_price) + / float(ingredient.drink_ingredient.volume) + * float(ingredient.volume) + ) + _price += _price * float(drink.discount) + float(drink.extra_charge) + _price = round_price(_price, price.get("round_step")) + retVal.append(DrinkPrice(volume=volume, price=_price, **values)) + return retVal + + +def get_drinks(name=None): + if name: + return Drink.query.filter(Drink.name.contains(name)).all() + return Drink.query.all() + + +def get_drink(identifier): + if isinstance(identifier, int): + retVal = Drink.query.get(identifier) + elif isinstance(identifier, str): + retVal = Drink.query.filter(Tag.name == identifier).one_or_none() + else: + logger.debug("Invalid identifier type for Drink") + raise BadRequest + if not retVal: + raise NotFound + return retVal + + +def add_prices(drink, prices): + for price in prices: + drink.prices.append(price) + + +def add_ingredients(drink, ingredients): + for identifier, volume in ingredients: + ingredient = Ingredient(volume=volume, drink_ingredient=get_drink(identifier)) + drink.ingredients.append(ingredient) + + +def create_drink(data): + allowed_keys = Drink().serialize().keys() + values = {key: value for key, value in data.items() if key in allowed_keys} + prices = values.pop("prices", []) + ingredients = values.pop("ingredients", []) + if "id" in values: + values.pop("id") + + drink = Drink(**values) + add_ingredients(drink, ingredients) + drink.prices = calc_prices(drink, prices) + db.session.add(drink) + update() + return drink + + +def delete_drink(identifier): + drink = get_drink(identifier) + for price in drink.prices: + db.session.delete(price) + for ingredient in drink.ingredients: + db.session.delete(ingredient) + db.session.delete(drink) + update() diff --git a/setup.py b/setup.py index d2c164b..5a6ecaf 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ setup( "balance = flaschengeist.plugins.balance:BalancePlugin", "schedule = flaschengeist.plugins.schedule:SchedulePlugin", "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", + "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin", ], }, ) From 24418f5bcb0e4a7d303705db5af48fc4ff9153ee Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 14 Feb 2021 19:11:39 +0100 Subject: [PATCH 202/446] [System] Models: Do not export optional, not set, parameters. --- flaschengeist/models/__init__.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 14a36fb..59bb5b5 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -1,4 +1,6 @@ +import sys import datetime + from sqlalchemy.types import DateTime, TypeDecorator @@ -7,6 +9,15 @@ class ModelSerializeMixin: Ignores private and protected members as well as members marked as not to publish (name ends with _) """ + def __is_optional(self, param): + if sys.version_info < (3, 8): + return False + + import typing + if typing.get_origin(self.__class__.__annotations__[param]) is typing.Union and \ + typing.get_args(self.__class__.__annotations__[param])[1] is None: + return getattr(self, param) is None + def serialize(self): """Serialize class to dict Returns: @@ -15,7 +26,7 @@ class ModelSerializeMixin: d = { param: getattr(self, param) for param in self.__class__.__annotations__ - if not param.startswith("_") and not param.endswith("_") + if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param) } if len(d) == 1: key, value = d.popitem() From 251648e9a47c50ce54c2cff1d1fafd039a197e0f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Feb 2021 12:41:25 +0100 Subject: [PATCH 203/446] [System] plugins can register a post install routine, for e.g. filling the database. --- flaschengeist/app.py | 6 +++++- flaschengeist/plugins/__init__.py | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 1a80aa0..c460e99 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -50,7 +50,7 @@ def __load_plugins(app): app.config["FG_AUTH_BACKEND"] = plugin else: del plugin - else: + if plugin: app.config["FG_PLUGINS"][entry_point.name] = plugin if "FG_AUTH_BACKEND" not in app.config: logger.error("No authentication plugin configured or authentication plugin not found") @@ -61,14 +61,18 @@ def install_all(): db.create_all() db.session.commit() + installed = [] for name, plugin in current_app.config["FG_PLUGINS"].items(): if not plugin: logger.debug("Skip disabled plugin {}".format(name)) continue logger.info("Install plugin {}".format(name)) plugin.install() + installed.append(plugin) if plugin.permissions: roleController.create_permissions(plugin.permissions) + for plugin in installed: + plugin.post_install() def create_app(test_config=None): diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 2aa8234..41a019e 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -44,6 +44,12 @@ class Plugin: """ pass + def post_install(self): + """Fill database or do other stuff + Called after all plugins are installed + """ + pass + def serialize(self): """Serialize a plugin into a dict From f9ac002b53ef9a4c9cb84c77664d3324f539f1d8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Feb 2021 12:42:31 +0100 Subject: [PATCH 204/446] [Plugin] auth_plain: Create admin user if installed and no users exist. --- flaschengeist/plugins/auth_plain/__init__.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index ce9d006..21f5254 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -9,10 +9,25 @@ import binascii from werkzeug.exceptions import BadRequest, NotFound from flaschengeist.plugins import AuthPlugin -from flaschengeist.models.user import User +from flaschengeist.models.user import User, Role, Permission +from flaschengeist.database import db +from flaschengeist import logger class AuthPlain(AuthPlugin): + def post_install(self): + if User.query.first() is None: + logger.info("Installing admin user") + role = Role(name="Superuser", permissions=Permission.query.all()) + admin = User(userid="admin", firstname="Admin", lastname="Admin", mail="", roles_=[role]) + self.modify_user(admin, None, "admin") + db.session.add(admin) + db.session.commit() + logger.warning("New administrator user was added, please change the password or remove it before going into" + "production mode. Initial credentials:\n" + "name: admin\n" + "password: admin") + def login(self, user: User, password: str): if user.has_attribute("password"): return AuthPlain._verify_password(user.get_attribute("password"), password) From 5826ec7a00c2305fdd6c055f5d006c5397cbeaee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 7 Mar 2021 13:29:43 +0100 Subject: [PATCH 205/446] [fix] ldap user right secret --- flaschengeist/plugins/auth_ldap/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index b07cf36..7bcefc7 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -30,8 +30,8 @@ class AuthLDAP(AuthPlugin): LDAP_REQUIRE_CERT=ssl.CERT_NONE, FORCE_ATTRIBUTE_VALUE_AS_LIST=True, ) - if "SECRET" in config: - app.config["LDAP_SECRET"] = (config["secret"],) + if "secret" in config: + app.config["LDAP_SECRET"] = config["secret"] self.ldap = LDAPConn(app) self.dn = config["base_dn"] self.default_gid = config["default_gid"] From 1b29e602f1be219bd6d2b56ccfc631b7884bd2dd Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 14 Mar 2021 12:56:04 +0100 Subject: [PATCH 206/446] [System][Plugin] Added plugin settings --- flaschengeist/app.py | 4 ++- flaschengeist/models/setting.py | 9 ++++++ flaschengeist/plugins/__init__.py | 37 +++++++++++++++++++++++ flaschengeist/plugins/balance/__init__.py | 6 ++-- 4 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 flaschengeist/models/setting.py diff --git a/flaschengeist/app.py b/flaschengeist/app.py index c460e99..4042601 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -40,7 +40,9 @@ def __load_plugins(app): logger.debug("Found plugin: >{}<".format(entry_point.name)) plugin = None if entry_point.name in config and config[entry_point.name].get("enabled", False): - plugin = entry_point.load()(config[entry_point.name]) + plugin = entry_point.load() + setattr(plugin, "_plugin_name", entry_point.name) + plugin = plugin(config[entry_point.name]) if plugin.blueprint: app.register_blueprint(plugin.blueprint) logger.info("Load plugin >{}<".format(entry_point.name)) diff --git a/flaschengeist/models/setting.py b/flaschengeist/models/setting.py new file mode 100644 index 0000000..8b3acc4 --- /dev/null +++ b/flaschengeist/models/setting.py @@ -0,0 +1,9 @@ +from ..database import db + + +class _PluginSetting(db.Model): + __tablename__ = "plugin_setting" + id = db.Column("id", db.Integer, primary_key=True) + plugin: str = db.Column(db.String(30)) + name: str = db.Column(db.String(30), nullable=False) + value: any = db.Column(db.PickleType(protocol=4)) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 41a019e..d4249a9 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -1,7 +1,10 @@ +import sqlalchemy import pkg_resources from werkzeug.exceptions import MethodNotAllowed, NotFound +from flaschengeist.database import db from flaschengeist.models.user import _Avatar +from flaschengeist.models.setting import _PluginSetting from flaschengeist.utils.hook import HookBefore, HookAfter before_role_updated = HookBefore("update_role") @@ -50,6 +53,40 @@ class Plugin: """ pass + def get_setting(self, name: str, **kwargs): + """Get plugin setting from database + Args: + name: string identifying the setting + default: Default value + Returns: + Value stored in database (native python) + """ + try: + setting = _PluginSetting.query\ + .filter(_PluginSetting.plugin == self._plugin_name)\ + .filter(_PluginSetting.name == name).one() + return setting.value + except sqlalchemy.orm.exc.NoResultFound: + if "default" in kwargs: + return kwargs["default"] + else: + raise KeyError + + def set_setting(self, name: str, value): + """Save setting in database + Args: + name: String identifying the setting + value: Value to be stored + """ + setting = _PluginSetting.query \ + .filter(_PluginSetting.plugin == self._plugin_name) \ + .filter(_PluginSetting.name == name).one_or_none() + if setting is not None: + setting.value = value + else: + db.session.add(_PluginSetting(plugin=self._plugin_name, name=name, value=value)) + db.session.commit() + def serialize(self): """Serialize a plugin into a dict diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 3f458c7..edaa2a4 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -30,10 +30,12 @@ class BalancePlugin(Plugin): @before_update_user def set_default_limit(user): - if "limit" in config: - limit = config["limit"] + try: + limit = self.get_setting("limit") logger.debug("Setting default limit of {} to user {}".format(limit, user.userid)) balance_controller.set_limit(user, limit, override=False) + except KeyError: + pass def install(self): from flaschengeist.database import db From 919a31bede9caa483b889fbd3057192c6bd6a72e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 14 Mar 2021 15:11:09 +0100 Subject: [PATCH 207/446] [System] Fixed plugin loading --- flaschengeist/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 4042601..6ec40ed 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -52,6 +52,7 @@ def __load_plugins(app): app.config["FG_AUTH_BACKEND"] = plugin else: del plugin + continue if plugin: app.config["FG_PLUGINS"][entry_point.name] = plugin if "FG_AUTH_BACKEND" not in app.config: From c3c35e2a6a8bf13687275b0f28b22e8b749dd9fb Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 14 Mar 2021 15:53:14 +0100 Subject: [PATCH 208/446] [Plugin] auth_ldap: Fixed searching for non existing user --- flaschengeist/plugins/auth_ldap/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 7bcefc7..c3ae7c6 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -54,7 +54,7 @@ class AuthLDAP(AuthPlugin): def find_user(self, userid, mail=None): attr = self.__find(userid, mail) - if attr: + if attr is not None: user = User(userid=attr["uid"][0]) self.__update(user, attr) return user @@ -186,7 +186,7 @@ class AuthLDAP(AuthPlugin): SUBTREE, attributes=["uid", "givenName", "sn", "mail"], ) - return con.response[0]["attributes"] + return con.response[0]["attributes"] if len(con.response) > 0 else None def __update(self, user, attr): """Update an User object with LDAP attributes""" From df1610557ffc5fb314250ff08cfa85938962c496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 15 Mar 2021 19:56:51 +0100 Subject: [PATCH 209/446] [pricelist] break api, new model --- flaschengeist/plugins/pricelist/__init__.py | 30 ++++- flaschengeist/plugins/pricelist/models.py | 90 ++++++++++--- .../plugins/pricelist/pricelist_controller.py | 126 ++++++------------ 3 files changed, 140 insertions(+), 106 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 8c9e3d9..5df895b 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -124,4 +124,32 @@ def create_drink(current_session): item in data for item in ["name", "ingredients"] ): raise BadRequest("No correct Keys to create drink") - return jsonify(pricelist_controller.create_drink(data)) + return "jsonify(pricelist_controller.create_drink(data))" + + +@pricelist_bp.route("/prices", methods=["GET"]) +@pricelist_bp.route("/prices/", methods=["GET"]) +def get_prices(identifier=None): + if identifier: + result = pricelist_controller.get_price(identifier) + else: + result = pricelist_controller.get_prices() + return jsonify(result) + + +@pricelist_bp.route("/prices/volumes/", methods=["POST"]) +def create_price(identifier): + data = request.get_json() + return jsonify(pricelist_controller.set_price(identifier, data)) + + +@pricelist_bp.route("/prices/", methods=["PUT"]) +def modify_price(identifier): + data = request.get_json() + return jsonify(pricelist_controller.update_price(identifier, data)) + + +@pricelist_bp.route("/prices/", methods=["DELETE"]) +def delete_price(identifier): + pricelist_controller.delete_price(identifier) + return NO_CONTENT diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 1eb9ae6..c64d6c3 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -43,28 +43,78 @@ class DrinkPrice(db.Model, ModelSerializeMixin): __tablename__ = "drink_price" id: int = db.Column("id", db.Integer, primary_key=True) - volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) - drink_id = db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")) - drink = db.relationship("Drink", back_populates="prices") - no_auto: bool = db.Column(db.Boolean, default=False) + volume_id_ = db.Column("volume_id", db.Integer, db.ForeignKey("drink_price_volume.id")) + volume = db.relationship("DrinkPriceVolume", back_populates="prices") public: bool = db.Column(db.Boolean, default=True) description: Optional[str] = db.Column(db.String(30)) - round_step: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False, default=0.5) -class Ingredient(db.Model, ModelSerializeMixin): +class ExtraIngredient(db.Model, ModelSerializeMixin): """ - Drink Build + ExtraIngredient + """ + + __tablename__ = "extra_ingredient" + id: int = db.Column("id", db.Integer, primary_key=True) + name: str = db.Column(db.String(30), unique=True, nullable=False) + price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + + +class DrinkIngredient(db.Model, ModelSerializeMixin): + """ + Drink Ingredient """ __tablename__ = "drink_ingredient" id: int = db.Column("id", db.Integer, primary_key=True) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) - drink_parent_id: int = db.Column("drink_parent_id", db.Integer, db.ForeignKey("drink.id")) - drink_parent = db.relationship("Drink", foreign_keys=drink_parent_id) drink_ingredient_id: int = db.Column("drink_ingredient_id", db.Integer, db.ForeignKey("drink.id")) - drink_ingredient: "Drink" = db.relationship("Drink", foreign_keys=drink_ingredient_id) + drink_ingredient: "Drink" = db.relationship("Drink") + price: float = 0 + + @property + def price(self): + return self.drink_ingredient.cost_price_pro_volume * self.volume + + +class Ingredient(db.Model, ModelSerializeMixin): + """ + Ingredient Associationtable + """ + + __tablename__ = "ingredient_association" + id: int = db.Column("id", db.Integer, primary_key=True) + volume_id: int = db.Column(db.Integer, db.ForeignKey("drink_price_volume.id")) + drink_ingredient_id = db.Column(db.Integer, db.ForeignKey("drink_ingredient.id")) + drink_ingredient: DrinkIngredient = db.relationship(DrinkIngredient) + extra_ingredient_id = db.Column(db.Integer, db.ForeignKey("extra_ingredient.id")) + extra_ingredient: ExtraIngredient = db.relationship(ExtraIngredient) + + +class DrinkPriceVolume(db.Model, ModelSerializeMixin): + """ + Drink Volumes and Prices + """ + + __tablename__ = "drink_price_volume" + id: int = db.Column("id", db.Integer, primary_key=True) + volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + prices: [DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan") + ingredients: [DrinkIngredient or ExtraIngredient] = [] + _ingredients: [Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) + + drink_id = db.Column(db.Integer, db.ForeignKey("drink.id"), nullable=False) + + @property + def ingredients(self): + retVal = [] + for ingredient in self._ingredients: + if ingredient.drink_ingredient_id != None: + retVal.append(ingredient.drink_ingredient) + if ingredient.extra_ingredient_id != None: + retVal.append(ingredient.extra_ingredient) + return retVal class Drink(db.Model, ModelSerializeMixin): @@ -74,17 +124,15 @@ class Drink(db.Model, ModelSerializeMixin): __tablename__ = "drink" id: int = db.Column("id", db.Integer, primary_key=True) + article_id: Optional[str] = db.Column(db.String(64)) + package_size: Optional[int] = db.Column(db.Integer) name: str = db.Column(db.String(60), nullable=False) - volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) - cost_price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) - discount: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) - extra_charge: Optional[float] = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), default=0) - prices: [DrinkPrice] = db.relationship( - "DrinkPrice", back_populates="drink", cascade="all,delete,delete-orphan", order_by=[DrinkPrice.volume] - ) - ingredients: [Ingredient] = db.relationship( - "Ingredient", back_populates="drink_parent", foreign_keys=Ingredient.drink_parent_id - ) + volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + cost_price_pro_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) + cost_price_package_netto: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) + tags: [Optional[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") type_id_ = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) - type = db.relationship("DrinkType") + type: DrinkType = db.relationship("DrinkType") + + volumes: [DrinkPriceVolume] = db.relationship(DrinkPriceVolume) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 93c365c..d308504 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -3,7 +3,7 @@ from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.database import db -from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType +from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient from math import ceil @@ -103,56 +103,6 @@ def delete_drink_type(identifier): raise BadRequest("DrinkType still in use") -def round_price(price, round_step): - return round(ceil(float(price) / round_step) * round_step * 100) / 100 - - -def calc_prices(drink, prices): - retVal = [] - if len(drink.ingredients) > 0: - return calc_price_by_ingredients(drink, prices) - allowed_keys = DrinkPrice().serialize().keys() - for price in prices: - values = {key: value for key, value in price.items() if key in allowed_keys} - if values.get("no_auto"): - retVal.append(DrinkPrice(**values)) - else: - volume = float(values.pop("volume")) - if "price" in values: - values.pop("price") - _price = float(drink.cost_price) / float(drink.volume) * volume - _price += _price * float(drink.discount) - if drink.extra_charge: - _price += float(drink.extra_charge) - _price = round_price(_price, float(price.get("round_step"))) - retVal.append(DrinkPrice(volume=volume, price=_price, **values)) - return retVal - - -def calc_price_by_ingredients(drink, prices): - allowed_keys = DrinkPrice().serialize().keys() - retVal = [] - for price in prices: - values = {key: value for key, value in price.items() if key in allowed_keys} - if values.get("no_auto"): - retVal.append(DrinkPrice(**values)) - else: - volume = float(values.pop("volume")) - if "price" in values: - values.pop("price") - _price = 0 - for ingredient in drink.ingredients: - _price = ( - float(ingredient.drink_ingredient.cost_price) - / float(ingredient.drink_ingredient.volume) - * float(ingredient.volume) - ) - _price += _price * float(drink.discount) + float(drink.extra_charge) - _price = round_price(_price, price.get("round_step")) - retVal.append(DrinkPrice(volume=volume, price=_price, **values)) - return retVal - - def get_drinks(name=None): if name: return Drink.query.filter(Drink.name.contains(name)).all() @@ -161,49 +111,57 @@ def get_drinks(name=None): def get_drink(identifier): if isinstance(identifier, int): - retVal = Drink.query.get(identifier) + return Drink.query.get(identifier) elif isinstance(identifier, str): - retVal = Drink.query.filter(Tag.name == identifier).one_or_none() + return Drink.query.filter(Tag.name == identifier).one_or_none() else: logger.debug("Invalid identifier type for Drink") raise BadRequest - if not retVal: - raise NotFound - return retVal + raise NotFound -def add_prices(drink, prices): - for price in prices: - drink.prices.append(price) +def get_volume(identifier): + return DrinkPriceVolume.query.get(identifier) -def add_ingredients(drink, ingredients): - for identifier, volume in ingredients: - ingredient = Ingredient(volume=volume, drink_ingredient=get_drink(identifier)) - drink.ingredients.append(ingredient) +def get_price(identifier): + if isinstance(identifier, int): + return DrinkPrice.query.get(identifier) + raise NotFound -def create_drink(data): - allowed_keys = Drink().serialize().keys() +def get_prices(volume_id=None): + if volume_id: + return DrinkPrice.query.filter(DrinkPrice.volume_id_ == volume_id).all() + return DrinkPrice.query.all() + + +def set_price(identifier, data): + allowed_keys = DrinkPrice().serialize().keys() values = {key: value for key, value in data.items() if key in allowed_keys} - prices = values.pop("prices", []) - ingredients = values.pop("ingredients", []) - if "id" in values: - values.pop("id") - - drink = Drink(**values) - add_ingredients(drink, ingredients) - drink.prices = calc_prices(drink, prices) - db.session.add(drink) - update() - return drink + price = DrinkPrice(**values) + volume = get_volume(identifier) + if not volume: + raise BadRequest + volume.prices.append(price) + db.session.add(price) + db.session.commit() + return price -def delete_drink(identifier): - drink = get_drink(identifier) - for price in drink.prices: - db.session.delete(price) - for ingredient in drink.ingredients: - db.session.delete(ingredient) - db.session.delete(drink) - update() +def update_price(identifier, data): + allowed_keys = DrinkPrice().serialize().keys() + if "id" in data: + data.pop("id") + values = {key: value for key, value in data.items() if key in allowed_keys} + price = get_price(identifier) + for key, value in values: + setattr(price, key, value) + db.session.commit() + return price + + +def delete_price(identifier): + price = get_price(identifier) + db.session.delete(price) + db.session.commit() From e26b7b8c962ff26948c230584aa80e6a5deeaa52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 15 Mar 2021 23:53:21 +0100 Subject: [PATCH 210/446] [pricelist] first step to add, modify and delete volumes --- flaschengeist/plugins/pricelist/__init__.py | 22 ++++++++- .../plugins/pricelist/pricelist_controller.py | 48 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 5df895b..db001e5 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -137,7 +137,7 @@ def get_prices(identifier=None): return jsonify(result) -@pricelist_bp.route("/prices/volumes/", methods=["POST"]) +@pricelist_bp.route("/volumes//prices", methods=["POST"]) def create_price(identifier): data = request.get_json() return jsonify(pricelist_controller.set_price(identifier, data)) @@ -152,4 +152,22 @@ def modify_price(identifier): @pricelist_bp.route("/prices/", methods=["DELETE"]) def delete_price(identifier): pricelist_controller.delete_price(identifier) - return NO_CONTENT + return "", NO_CONTENT + + +@pricelist_bp.route("/drinks//volumes", methods=["POST"]) +def set_volume(identifier): + data = request.get_json() + return jsonify(pricelist_controller.set_volume(identifier, data)) + + +@pricelist_bp.route("/volumes/", methods=["PUT"]) +def update_volume(identifier): + data = request.get_json() + return jsonify(pricelist_controller.update_volume(identifier, data)) + + +@pricelist_bp.route("/volumes/", methods=["DELETE"]) +def delete_volume(identifier): + pricelist_controller.delete_volume(identifier) + return "", NO_CONTENT diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index d308504..2e1b69f 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -124,6 +124,50 @@ def get_volume(identifier): return DrinkPriceVolume.query.get(identifier) +def get_volumes(drink_id=None): + if drink_id: + return DrinkPriceVolume.query.filter(DrinkPriceVolume.drink_id == drink_id).all() + return DrinkPriceVolume.query.all() + + +def set_volume(identifier, data): + allowed_keys = DrinkPriceVolume().serialize().keys() + if "id" in data: + data.pop("id") + values = {key: value for key, value in data.items() if key in allowed_keys} + + volume = DrinkPriceVolume(**values) + drink = get_drink(identifier) + if not drink: + raise BadRequest + drink.volumes.append(volume) + db.session.add(volume) + db.session.commit() + return volume + + +def update_volume(identifier, data): + allowed_keys = DrinkPriceVolume().serialize().keys() + if "id" in data: + data.pop("id") + values = {key: value for key, value in data.items() if key in allowed_keys} + if "prices" in values: + prices = values.pop("prices") + if "ingredients" in values: + ingredients = values.pop("ingredients") + volume = get_volume(identifier) + for key, value in values.items(): + setattr(volume, key, value) + db.session.commit() + return volume + + +def delete_volume(identifier): + volume = get_volume(identifier) + db.session.delete(volume) + db.session.commit() + + def get_price(identifier): if isinstance(identifier, int): return DrinkPrice.query.get(identifier) @@ -138,6 +182,8 @@ def get_prices(volume_id=None): def set_price(identifier, data): allowed_keys = DrinkPrice().serialize().keys() + if "id" in data: + data.pop("id") values = {key: value for key, value in data.items() if key in allowed_keys} price = DrinkPrice(**values) volume = get_volume(identifier) @@ -155,7 +201,7 @@ def update_price(identifier, data): data.pop("id") values = {key: value for key, value in data.items() if key in allowed_keys} price = get_price(identifier) - for key, value in values: + for key, value in values.items(): setattr(price, key, value) db.session.commit() return price From 0b60c27f32a520c526e8055fa0b01b12bf5cf43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 16 Mar 2021 18:11:04 +0100 Subject: [PATCH 211/446] [pricelist] fixed set volumes --- flaschengeist/plugins/pricelist/pricelist_controller.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 2e1b69f..e5725b0 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -135,7 +135,10 @@ def set_volume(identifier, data): if "id" in data: data.pop("id") values = {key: value for key, value in data.items() if key in allowed_keys} - + if "prices" in values: + prices = values.pop("prices") + if "ingredients" in values: + ingredients = values.pop("ingredients") volume = DrinkPriceVolume(**values) drink = get_drink(identifier) if not drink: From 5e0e5edf6f9a7aeac442be52104314bb7e0c6fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 16 Mar 2021 23:27:54 +0100 Subject: [PATCH 212/446] [pricelist] new api to add, delete and modify ingredients --- flaschengeist/plugins/pricelist/__init__.py | 23 ++++++ flaschengeist/plugins/pricelist/models.py | 18 ++--- .../plugins/pricelist/pricelist_controller.py | 80 ++++++++++++++++++- 3 files changed, 107 insertions(+), 14 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index db001e5..3866abf 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -161,6 +161,12 @@ def set_volume(identifier): return jsonify(pricelist_controller.set_volume(identifier, data)) +@pricelist_bp.route("/volumes//ingredients", methods=["POST"]) +def set_ingredient(identifier): + data = request.get_json() + return jsonify(pricelist_controller.set_ingredient(data, identifier)) + + @pricelist_bp.route("/volumes/", methods=["PUT"]) def update_volume(identifier): data = request.get_json() @@ -171,3 +177,20 @@ def update_volume(identifier): def delete_volume(identifier): pricelist_controller.delete_volume(identifier) return "", NO_CONTENT + + +@pricelist_bp.route("/ingredients/extraIngredients", methods=["GET"]) +def get_extraIngredients(): + return jsonify(pricelist_controller.get_extra_ingredients()) + + +@pricelist_bp.route("/ingredients/", methods=["PUT"]) +def update_ingredient(identifier): + data = request.get_json() + return jsonify(pricelist_controller.update_ingredient(identifier, data)) + + +@pricelist_bp.route("/ingredients/", methods=["DELETE"]) +def delete_ingredient(identifier): + pricelist_controller.delete_ingredient(identifier) + return "", NO_CONTENT diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index c64d6c3..1308f29 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -75,7 +75,10 @@ class DrinkIngredient(db.Model, ModelSerializeMixin): @property def price(self): - return self.drink_ingredient.cost_price_pro_volume * self.volume + try: + return self.drink_ingredient.cost_price_pro_volume * self.volume + except AttributeError: + pass class Ingredient(db.Model, ModelSerializeMixin): @@ -101,21 +104,10 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): id: int = db.Column("id", db.Integer, primary_key=True) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) prices: [DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan") - ingredients: [DrinkIngredient or ExtraIngredient] = [] - _ingredients: [Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) + ingredients: [Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) drink_id = db.Column(db.Integer, db.ForeignKey("drink.id"), nullable=False) - @property - def ingredients(self): - retVal = [] - for ingredient in self._ingredients: - if ingredient.drink_ingredient_id != None: - retVal.append(ingredient.drink_ingredient) - if ingredient.extra_ingredient_id != None: - retVal.append(ingredient.extra_ingredient) - return retVal - class Drink(db.Model, ModelSerializeMixin): """ diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index e5725b0..a8eb69c 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -160,7 +160,7 @@ def update_volume(identifier, data): ingredients = values.pop("ingredients") volume = get_volume(identifier) for key, value in values.items(): - setattr(volume, key, value) + setattr(volume, key, value if value != "" else None) db.session.commit() return volume @@ -214,3 +214,81 @@ def delete_price(identifier): price = get_price(identifier) db.session.delete(price) db.session.commit() + + +def set_drink_ingredient(data): + allowedKeys = DrinkIngredient().serialize().keys() + if "id" in data: + data.pop("id") + if "drink_ingredient" in data: + drink_ingredient_ = data.pop("drink_ingredient") + values = {key: value for key, value in data.items() if key in allowedKeys} + drink_ingredient = DrinkIngredient(**values) + if "id" in drink_ingredient_: + drink = get_drink(drink_ingredient_.get("id")) + if drink: + drink_ingredient.drink_ingredient = drink + db.session.add(drink_ingredient) + db.session.commit() + return drink_ingredient + + +def get_ingredient(identifier): + return Ingredient.query.get(identifier) + + +def set_ingredient(data, volume_id): + allowedKeys = Ingredient().serialize().keys() + if "id" in data: + data.pop("id") + values = {key: value for key, value in data.items() if key in allowedKeys} + if "drink_ingredient" in values: + drink_ingredient_values = values.get("drink_ingredient") + if "extra_ingredient" in values: + extra_ingredient_value = values.get("extra_ingredient") + + ingredient = Ingredient(**values) + volume = get_volume(volume_id) + if drink_ingredient_values: + ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_values) + if extra_ingredient_value: + if "id" in extra_ingredient_value: + ingredient.extra_ingredient = get_extra_ingredient(extra_ingredient_value.get("id")) + + volume.ingredients.append(ingredient) + db.session.add(ingredient) + db.session.commit() + return ingredient + + +def update_ingredient(identifier, data): + ingredient = get_ingredient(identifier) + if "extra_ingredient" in data and isinstance(data.get("extra_ingredient"), dict): + if "id" in data.get("extra_ingredient"): + ingredient.extra_ingredient = get_extra_ingredient(data.get("extra_ingredient").get("id")) + if "drink_ingredient" in data and ingredient.drink_ingredient and isinstance(data.get("drink_ingredient"), dict): + if "drink_ingredient" in data.get("drink_ingredient"): + if "id" in data.get("drink_ingredient").get("drink_ingredient"): + ingredient.drink_ingredient.drink_ingredient = get_drink( + data.get("drink_ingredient").get("drink_ingredient").get("id") + ) + if "volume" in data.get("drink_ingredient"): + ingredient.drink_ingredient.volume = data.get("drink_ingredient").get("volume") + db.session.commit() + return ingredient + + +def delete_ingredient(identifier): + ingredient = get_ingredient(identifier) + if ingredient.drink_ingredient: + db.session.delete(ingredient.drink_ingredient) + db.session.delete(ingredient) + db.session.commit() + + +def get_extra_ingredients(): + return ExtraIngredient.query.all() + + +def get_extra_ingredient(identifier): + return ExtraIngredient.query.get(identifier) From 642d95b2a5c6fbeac19d185d2679f94caccf9ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 17 Mar 2021 21:36:51 +0100 Subject: [PATCH 213/446] [pricelist] finish drinks, can add, modify and delete --- flaschengeist/plugins/pricelist/__init__.py | 18 +++++-- .../plugins/pricelist/pricelist_controller.py | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 3866abf..89967b9 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -120,11 +120,19 @@ def search_drinks(name): @login_required(permission=permissions.CREATE) def create_drink(current_session): data = request.get_json() - if not all(item in data for item in ["name", "volume", "cost_price"]) or not all( - item in data for item in ["name", "ingredients"] - ): - raise BadRequest("No correct Keys to create drink") - return "jsonify(pricelist_controller.create_drink(data))" + return jsonify(pricelist_controller.set_drink(data)) + + +@pricelist_bp.route("/drinks/", methods=["PUT"]) +def update_drink(identifier): + data = request.get_json() + return jsonify(pricelist_controller.update_drink(identifier, data)) + + +@pricelist_bp.route("/drinks/", methods=["DELETE"]) +def delete_drink(identifier): + pricelist_controller.delete_drink(identifier) + return "", NO_CONTENT @pricelist_bp.route("/prices", methods=["GET"]) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index a8eb69c..03bf389 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -120,6 +120,55 @@ def get_drink(identifier): raise NotFound +def set_drink(data): + allowedKeys = Drink().serialize().keys() + if "id" in data: + data.pop("id") + values = {key: value for key, value in data.items() if key in allowedKeys} + if "volumes" in values: + values.pop("volumes") + if "tags" in values: + values.pop("tags") + if "type" in values: + _type = values.pop("type") + if isinstance(_type, dict) and "id" in _type: + type = get_drink_type(_type.get("id")) + drink = Drink(**values) + if type: + drink.type = type + db.session.add(drink) + db.session.commit() + return drink + + +def update_drink(identifier, data): + allowedKeys = Drink().serialize().keys() + if "id" in data: + data.pop("id") + values = {key: value for key, value in data.items() if key in allowedKeys} + if "volumes" in values: + values.pop("volumes") + if "tags" in values: + values.pop("tags") + if "type" in values: + _type = values.pop("type") + if isinstance(_type, dict) and "id" in _type: + type = get_drink_type(_type.get("id")) + drink = get_drink(identifier) + for key, value in values.items(): + setattr(drink, key, value if value != "" else None) + if type: + drink.type = type + db.session.commit() + return drink + + +def delete_drink(identifier): + drink = get_drink(identifier) + db.session.delete(drink) + db.session.commit() + + def get_volume(identifier): return DrinkPriceVolume.query.get(identifier) From 26e82f02d688f32ef09f42705866dd254cf82a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 17 Mar 2021 22:49:54 +0100 Subject: [PATCH 214/446] [pricelist] add, modify and delete for extra ingredients --- flaschengeist/plugins/pricelist/__init__.py | 18 +++++++++++ .../plugins/pricelist/pricelist_controller.py | 32 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 89967b9..e5a15ac 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -202,3 +202,21 @@ def update_ingredient(identifier): def delete_ingredient(identifier): pricelist_controller.delete_ingredient(identifier) return "", NO_CONTENT + + +@pricelist_bp.route("/ingredients/extraIngredients", methods=["POST"]) +def set_extra_ingredient(): + data = request.get_json() + return jsonify(pricelist_controller.set_extra_ingredient(data)) + + +@pricelist_bp.route("/ingredients/extraIngredients/", methods=["PUT"]) +def update_extra_ingredient(identifier): + data = request.get_json() + return jsonify(pricelist_controller.update_extra_ingredient(identifier, data)) + + +@pricelist_bp.route("/ingredients/extraIngredients/", methods=["DELETE"]) +def delete_extra_ingredient(identifier): + pricelist_controller.delete_extra_ingredient(identifier) + return "", NO_CONTENT diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 03bf389..d688178 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -124,7 +124,7 @@ def set_drink(data): allowedKeys = Drink().serialize().keys() if "id" in data: data.pop("id") - values = {key: value for key, value in data.items() if key in allowedKeys} + values = {key: value if value != "" else None for key, value in data.items() if key in allowedKeys} if "volumes" in values: values.pop("volumes") if "tags" in values: @@ -341,3 +341,33 @@ def get_extra_ingredients(): def get_extra_ingredient(identifier): return ExtraIngredient.query.get(identifier) + + +def set_extra_ingredient(data): + allowedKeys = ExtraIngredient().serialize().keys() + if "id" in data: + data.pop("id") + values = {key: value for key, value in data.items() if key in allowedKeys} + extra_ingredient = ExtraIngredient(**values) + db.session.add(extra_ingredient) + db.session.commit() + return extra_ingredient + + +def update_extra_ingredient(identifier, data): + allowedKeys = ExtraIngredient().serialize().keys() + if "id" in data: + data.pop("id") + values = {key: value for key, value in data.items() if key in allowedKeys} + extra_ingredient = get_extra_ingredient(identifier) + if extra_ingredient: + for key, value in data.items: + setattr(extra_ingredient, key, value) + db.session.commit() + return extra_ingredient + + +def delete_extra_ingredient(identifier): + extra_ingredient = get_extra_ingredient(identifier) + db.session.delete(extra_ingredient) + db.session.commit() From 948c700e469c4ff220749e12ec58329185d24374 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Mar 2021 10:18:08 +0100 Subject: [PATCH 215/446] [setup] Fixed sqlalchemy requirement --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5a6ecaf..34c4e84 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,8 @@ setup( install_requires=[ "Flask >= 1.1", "toml", - "sqlalchemy>=1.3", + # < 1.4: https://github.com/pallets/flask-sqlalchemy/issues/885 + "sqlalchemy>=1.3,<1.4", "flask_sqlalchemy", "flask_cors", "werkzeug", From 466efcf9e708a7da74701825d83731452b5ae829 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Mar 2021 11:52:51 +0100 Subject: [PATCH 216/446] [System] New annotation format for future compatibility --- flaschengeist/models/session.py | 2 ++ flaschengeist/models/setting.py | 2 ++ flaschengeist/models/user.py | 11 +++++++---- flaschengeist/plugins/balance/models.py | 3 ++- flaschengeist/plugins/pricelist/models.py | 12 ++++++++---- flaschengeist/plugins/schedule/models.py | 6 ++++-- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index df95b85..c170ad6 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -1,3 +1,5 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + from datetime import datetime, timedelta, timezone from . import ModelSerializeMixin, UtcDateTime diff --git a/flaschengeist/models/setting.py b/flaschengeist/models/setting.py index 8b3acc4..6e8f74d 100644 --- a/flaschengeist/models/setting.py +++ b/flaschengeist/models/setting.py @@ -1,3 +1,5 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + from ..database import db diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index b8e8b83..6e23e4b 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,3 +1,5 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + from flask import url_for from typing import Optional from datetime import date, datetime @@ -6,6 +8,7 @@ from sqlalchemy.orm.collections import attribute_mapped_collection from ..database import db from . import ModelSerializeMixin, UtcDateTime + association_table = db.Table( "user_x_role", db.Column("user_id", db.Integer, db.ForeignKey("user.id")), @@ -30,7 +33,7 @@ class Role(db.Model, ModelSerializeMixin): __tablename__ = "role" id: int = db.Column(db.Integer, primary_key=True) name: str = db.Column(db.String(30), unique=True) - permissions: [Permission] = db.relationship("Permission", secondary=role_permission_association_table) + permissions: list[Permission] = db.relationship("Permission", secondary=role_permission_association_table) class User(db.Model, ModelSerializeMixin): @@ -55,11 +58,11 @@ class User(db.Model, ModelSerializeMixin): lastname: str = db.Column(db.String(50), nullable=False) mail: str = db.Column(db.String(60), nullable=False) birthday: Optional[date] = db.Column(db.Date) - roles: [str] = [] - permissions: Optional[type([str])] = None + roles: list[str] = [] + permissions: Optional[list[str]] = None avatar_url: Optional[str] = "" - roles_: [Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") + roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") _id = db.Column("id", db.Integer, primary_key=True) _sessions = db.relationship("Session", back_populates="_user") _attributes = db.relationship( diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 03ab890..41c3520 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -1,6 +1,7 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + from datetime import datetime from typing import Optional - from sqlalchemy.ext.hybrid import hybrid_property from flaschengeist.database import db diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 1308f29..5adab4d 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -1,7 +1,9 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + from flaschengeist.database import db from flaschengeist.models import ModelSerializeMixin -from typing import Optional +from typing import Optional, Union drink_tag_association = db.Table( "drink_x_tag", @@ -70,7 +72,7 @@ class DrinkIngredient(db.Model, ModelSerializeMixin): id: int = db.Column("id", db.Integer, primary_key=True) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) drink_ingredient_id: int = db.Column("drink_ingredient_id", db.Integer, db.ForeignKey("drink.id")) - drink_ingredient: "Drink" = db.relationship("Drink") + drink_ingredient = db.relationship("Drink") price: float = 0 @property @@ -104,7 +106,9 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): id: int = db.Column("id", db.Integer, primary_key=True) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) prices: [DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan") - ingredients: [Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) + ingredients: Union[DrinkIngredient, ExtraIngredient] = [] + # TODO: Really protected or just not exported (e.g. name_)? + _ingredients: [Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) drink_id = db.Column(db.Integer, db.ForeignKey("drink.id"), nullable=False) @@ -123,7 +127,7 @@ class Drink(db.Model, ModelSerializeMixin): cost_price_pro_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) cost_price_package_netto: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) - tags: [Optional[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") + tags: list[Tag] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") type_id_ = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) type: DrinkType = db.relationship("DrinkType") diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index 118c9b9..04c060f 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + from datetime import datetime from typing import Optional @@ -55,7 +57,7 @@ class Job(db.Model, ModelSerializeMixin): end: Optional[datetime] = db.Column(UtcDateTime) comment: str = db.Column(db.String(256)) type: JobType = db.relationship("JobType") - services: [Service] = db.relationship("Service", back_populates="job_") + services: list[Service] = db.relationship("Service", back_populates="job_") required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) event_ = db.relationship("Event", back_populates="jobs") @@ -81,6 +83,6 @@ class Event(db.Model, ModelSerializeMixin): end: datetime = db.Column(UtcDateTime) description: Optional[str] = db.Column(db.String(255)) type: EventType = db.relationship("EventType") - jobs: [Job] = db.relationship( + jobs: list[Job] = db.relationship( "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" ) From 900b5efff55e3e51f168415372dcb4f2a55fecfd Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Mar 2021 12:20:17 +0100 Subject: [PATCH 217/446] [chore] Minor cleanup --- flaschengeist/models/__init__.py | 7 +++++-- flaschengeist/plugins/__init__.py | 16 ++++++++++------ flaschengeist/plugins/auth_plain/__init__.py | 10 ++++++---- flaschengeist/utils/datetime.py | 7 +++++-- setup.py | 4 +--- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 59bb5b5..5f9484a 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -14,8 +14,11 @@ class ModelSerializeMixin: return False import typing - if typing.get_origin(self.__class__.__annotations__[param]) is typing.Union and \ - typing.get_args(self.__class__.__annotations__[param])[1] is None: + + if ( + typing.get_origin(self.__class__.__annotations__[param]) is typing.Union + and isinstance(typing.get_args(self.__class__.__annotations__[param])[1], type(None)) + ): return getattr(self, param) is None def serialize(self): diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index d4249a9..6665830 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -62,9 +62,11 @@ class Plugin: Value stored in database (native python) """ try: - setting = _PluginSetting.query\ - .filter(_PluginSetting.plugin == self._plugin_name)\ - .filter(_PluginSetting.name == name).one() + setting = ( + _PluginSetting.query.filter(_PluginSetting.plugin == self._plugin_name) + .filter(_PluginSetting.name == name) + .one() + ) return setting.value except sqlalchemy.orm.exc.NoResultFound: if "default" in kwargs: @@ -78,9 +80,11 @@ class Plugin: name: String identifying the setting value: Value to be stored """ - setting = _PluginSetting.query \ - .filter(_PluginSetting.plugin == self._plugin_name) \ - .filter(_PluginSetting.name == name).one_or_none() + setting = ( + _PluginSetting.query.filter(_PluginSetting.plugin == self._plugin_name) + .filter(_PluginSetting.name == name) + .one_or_none() + ) if setting is not None: setting.value = value else: diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 21f5254..c8de367 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -23,10 +23,12 @@ class AuthPlain(AuthPlugin): self.modify_user(admin, None, "admin") db.session.add(admin) db.session.commit() - logger.warning("New administrator user was added, please change the password or remove it before going into" - "production mode. Initial credentials:\n" - "name: admin\n" - "password: admin") + logger.warning( + "New administrator user was added, please change the password or remove it before going into" + "production mode. Initial credentials:\n" + "name: admin\n" + "password: admin" + ) def login(self, user: User, password: str): if user.has_attribute("password"): diff --git a/flaschengeist/utils/datetime.py b/flaschengeist/utils/datetime.py index cf97a00..9de34ad 100644 --- a/flaschengeist/utils/datetime.py +++ b/flaschengeist/utils/datetime.py @@ -1,7 +1,10 @@ import datetime -from backports.datetime_fromisoformat import MonkeyPatch +import sys -MonkeyPatch.patch_fromisoformat() +if sys.version_info < (3, 7): + from backports.datetime_fromisoformat import MonkeyPatch + + MonkeyPatch.patch_fromisoformat() def from_iso_format(date_str): diff --git a/setup.py b/setup.py index 34c4e84..2986a6e 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( packages=find_packages(), package_data={"": ["*.toml"]}, scripts=["run_flaschengeist"], - python_requires=">=3.6", + python_requires=">=3.7", install_requires=[ "Flask >= 1.1", "toml", @@ -19,8 +19,6 @@ setup( "flask_sqlalchemy", "flask_cors", "werkzeug", - # Needed for python < 3.7 - "backports-datetime-fromisoformat", ], extras_require={"ldap": ["flask_ldapconn", "ldap3"], "test": ["pytest", "coverage"]}, entry_points={ From 85f83f46d543a37d292ad07bd1508130a500598c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Mar 2021 13:02:16 +0100 Subject: [PATCH 218/446] [Script] Enhanced and added future compatibility for API export. * Python >= 3.9 required for API export. --- flaschengeist/plugins/pricelist/models.py | 20 +++--- run_flaschengeist | 79 ++++++++++++++--------- 2 files changed, 58 insertions(+), 41 deletions(-) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 5adab4d..2a7e182 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -104,13 +104,13 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): __tablename__ = "drink_price_volume" id: int = db.Column("id", db.Integer, primary_key=True) - volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) - prices: [DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan") - ingredients: Union[DrinkIngredient, ExtraIngredient] = [] - # TODO: Really protected or just not exported (e.g. name_)? - _ingredients: [Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) - drink_id = db.Column(db.Integer, db.ForeignKey("drink.id"), nullable=False) + volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + ingredients: list[DrinkIngredient, ExtraIngredient] = [] + + prices: list[DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan") + # TODO: Really protected or just not exported (e.g. name_)? + _ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) class Drink(db.Model, ModelSerializeMixin): @@ -127,8 +127,8 @@ class Drink(db.Model, ModelSerializeMixin): cost_price_pro_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) cost_price_package_netto: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) - tags: list[Tag] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") - type_id_ = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) - type: DrinkType = db.relationship("DrinkType") + _type_id = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) - volumes: [DrinkPriceVolume] = db.relationship(DrinkPriceVolume) + tags: list[Tag] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") + type: DrinkType = db.relationship("DrinkType", foreign_keys=[_type_id]) + volumes: list[DrinkPriceVolume] = db.relationship(DrinkPriceVolume) diff --git a/run_flaschengeist b/run_flaschengeist index 522fbd0..1e0e9c5 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -1,7 +1,9 @@ #!/usr/bin/python3 +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 import inspect import argparse import sys + import pkg_resources from flaschengeist.config import config @@ -27,7 +29,8 @@ class PrefixMiddleware(object): class InterfaceGenerator: known = [] classes = {} - mapper = {"str": "string", "int": "number", "float": "number", "date": "Date", "datetime": "Date", "NoneType": "null"} + mapper = {"str": "string", "int": "number", "float": "number", "date": "Date", "datetime": "Date", + "NoneType": "null"} def __init__(self, namespace, filename): self.basename = "" @@ -36,51 +39,65 @@ class InterfaceGenerator: self.this_type = None def pytype(self, cls): - if isinstance(cls, list): - return "", "Array<{}>".format(self.pytype(cls[0])[1]) - if sys.version_info >= (3, 8): - import typing + a = self._pytype(cls) + print(f"{cls} -> {a}") + return a - if isinstance(cls, typing.ForwardRef): - return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__ - if typing.get_origin(cls) == typing.Union: - types = typing.get_args(cls) - if len(types) == 2 and types[-1] is type(None): - return "?", self.pytype(types[0])[1] - else: - return "", "|".join([self.pytype(pt)[1] for pt in types]) - if hasattr(cls, "__name__"): - if cls.__name__ in self.mapper: - return "", self.mapper[cls.__name__] + def _pytype(self, cls): + import typing + origin = typing.get_origin(cls) + arguments = typing.get_args(cls) + + if origin is typing.ForwardRef: # isinstance(cls, typing.ForwardRef): + return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__ + if origin is typing.Union: + print(f"A1: {arguments[1]}") + if len(arguments) == 2 and arguments[1] is type(None): + return "?", self.pytype(arguments[0])[1] else: - return "", cls.__name__ + return "", "|".join([self.pytype(pt)[1] for pt in arguments]) + if origin is list: + return "", "Array<{}>".format("|".join([self.pytype(a_type)[1] for a_type in arguments])) + + name = cls.__name__ if hasattr(cls, "__name__") else cls if isinstance(cls, str) else None + if name is not None: + if name in self.mapper: + return "", self.mapper[name] + else: + return "", name print( - "WARNING: This python version might not detect all types (try >= 3.8). Could not identify >{}<".format(cls) + "WARNING: This python version might not detect all types (try >= 3.9). Could not identify >{}<".format(cls) ) return "?", "any" def walker(self, module): + if sys.version_info < (3, 9): + raise RuntimeError("Python >= 3.9 is required to export API") + import typing + if ( - inspect.ismodule(module[1]) - and module[1].__name__.startswith(self.basename) - and module[1].__name__ not in self.known + inspect.ismodule(module[1]) + and module[1].__name__.startswith(self.basename) + and module[1].__name__ not in self.known ): self.known.append(module[1].__name__) for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): self.walker(cls) elif ( - inspect.isclass(module[1]) - and module[1].__module__.startswith(self.basename) - and module[0] not in self.classes - and not module[0].startswith("_") - and hasattr(module[1], "__annotations__") + inspect.isclass(module[1]) + and module[1].__module__.startswith(self.basename) + and module[0] not in self.classes + and not module[0].startswith("_") + and hasattr(module[1], "__annotations__") ): self.this_type = module[0] - d = { - param: self.pytype(ptype) - for param, ptype in module[1].__annotations__.items() - if not param.startswith("_") and not param.endswith("_") - } + print("\n\n" + module[0] + "\n") + d = {} + for param, ptype in typing.get_type_hints(module[1], globalns=None, localns=None).items(): + if not param.startswith("_") and not param.endswith("_"): + print(f"{param} ::: {ptype}") + d[param] = self.pytype(ptype) + if len(d) == 1: key, value = d.popitem() self.classes[module[0]] = value[1] From fb8371afb9fc00b8ae04f9a7d9c11a481138f4ef Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Mar 2021 13:03:34 +0100 Subject: [PATCH 219/446] [chore] Minor cleanup --- flaschengeist/models/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 5f9484a..c7b94d1 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -15,10 +15,9 @@ class ModelSerializeMixin: import typing - if ( - typing.get_origin(self.__class__.__annotations__[param]) is typing.Union - and isinstance(typing.get_args(self.__class__.__annotations__[param])[1], type(None)) - ): + if typing.get_origin(self.__class__.__annotations__[param]) is typing.Union and typing.get_args( + self.__class__.__annotations__[param] + )[1] is type(None): return getattr(self, param) is None def serialize(self): From 87f9b0aa48967156c113af560968dbe16c3774a5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Mar 2021 14:05:28 +0100 Subject: [PATCH 220/446] [setup] Install correct mysql driver --- readme.md | 4 +++- setup.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 1041e1e..c10cf58 100644 --- a/readme.md +++ b/readme.md @@ -17,8 +17,10 @@ You will also need a MySQL driver, recommended drivers are - `mysqlclient` - `PyMySQL` +`setup.py` will try to install a matching driver. + #### Windows -Same as above, but for mysql you have to follow this guide: +Same as above, but if you want to use `mysqlclient` instead of `PyMySQL` (performance?) you have to follow this guide: https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/ diff --git a/setup.py b/setup.py index 2986a6e..cfab611 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,7 @@ from setuptools import setup, find_packages +import os + +mysql_driver = "PyMySQL" if os.name == "nt" else "mysqlclient" setup( name="flaschengeist", @@ -19,6 +22,7 @@ setup( "flask_sqlalchemy", "flask_cors", "werkzeug", + mysql_driver ], extras_require={"ldap": ["flask_ldapconn", "ldap3"], "test": ["pytest", "coverage"]}, entry_points={ From d5ba1f023e0f726aee72917b01e2c36e74ffc70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 18 Mar 2021 17:26:02 +0100 Subject: [PATCH 221/446] [pricelist] fixed circula issue --- flaschengeist/plugins/pricelist/models.py | 42 ++++++++++++------- .../plugins/pricelist/pricelist_controller.py | 27 ++++++------ 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 2a7e182..703a792 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -72,15 +72,16 @@ class DrinkIngredient(db.Model, ModelSerializeMixin): id: int = db.Column("id", db.Integer, primary_key=True) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) drink_ingredient_id: int = db.Column("drink_ingredient_id", db.Integer, db.ForeignKey("drink.id")) - drink_ingredient = db.relationship("Drink") - price: float = 0 + # drink_ingredient: Drink = db.relationship("Drink") + # price: float = 0 - @property - def price(self): - try: - return self.drink_ingredient.cost_price_pro_volume * self.volume - except AttributeError: - pass + +# @property +# def price(self): +# try: +# return self.drink_ingredient.cost_price_pro_volume * self.volume +# except AttributeError: +# pass class Ingredient(db.Model, ModelSerializeMixin): @@ -90,11 +91,20 @@ class Ingredient(db.Model, ModelSerializeMixin): __tablename__ = "ingredient_association" id: int = db.Column("id", db.Integer, primary_key=True) - volume_id: int = db.Column(db.Integer, db.ForeignKey("drink_price_volume.id")) + volume_id = db.Column(db.Integer, db.ForeignKey("drink_price_volume.id")) drink_ingredient_id = db.Column(db.Integer, db.ForeignKey("drink_ingredient.id")) - drink_ingredient: DrinkIngredient = db.relationship(DrinkIngredient) + drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient) extra_ingredient_id = db.Column(db.Integer, db.ForeignKey("extra_ingredient.id")) - extra_ingredient: ExtraIngredient = db.relationship(ExtraIngredient) + extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient) + + +class MinPrices(ModelSerializeMixin): + """ + MinPrices + """ + + percentage: float + price: float class DrinkPriceVolume(db.Model, ModelSerializeMixin): @@ -106,11 +116,11 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): id: int = db.Column("id", db.Integer, primary_key=True) drink_id = db.Column(db.Integer, db.ForeignKey("drink.id"), nullable=False) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) - ingredients: list[DrinkIngredient, ExtraIngredient] = [] + min_prices: list[MinPrices] = [] + # ingredients: list[Ingredient] = [] prices: list[DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan") - # TODO: Really protected or just not exported (e.g. name_)? - _ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) + ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) class Drink(db.Model, ModelSerializeMixin): @@ -129,6 +139,6 @@ class Drink(db.Model, ModelSerializeMixin): _type_id = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) - tags: list[Tag] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") - type: DrinkType = db.relationship("DrinkType", foreign_keys=[_type_id]) + tags: Optional[list[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") + type: Optional[DrinkType] = db.relationship("DrinkType", foreign_keys=[_type_id]) volumes: list[DrinkPriceVolume] = db.relationship(DrinkPriceVolume) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index d688178..8eceec9 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -269,12 +269,15 @@ def set_drink_ingredient(data): allowedKeys = DrinkIngredient().serialize().keys() if "id" in data: data.pop("id") + drink = None if "drink_ingredient" in data: drink_ingredient_ = data.pop("drink_ingredient") + if "id" in drink_ingredient_: + drink = get_drink(drink_ingredient_.get("id")) values = {key: value for key, value in data.items() if key in allowedKeys} + if "price" in values: + values.pop("price") drink_ingredient = DrinkIngredient(**values) - if "id" in drink_ingredient_: - drink = get_drink(drink_ingredient_.get("id")) if drink: drink_ingredient.drink_ingredient = drink db.session.add(drink_ingredient) @@ -291,15 +294,16 @@ def set_ingredient(data, volume_id): if "id" in data: data.pop("id") values = {key: value for key, value in data.items() if key in allowedKeys} + drink_ingredient_value = None + extra_ingredient_value = None if "drink_ingredient" in values: - drink_ingredient_values = values.get("drink_ingredient") + drink_ingredient_value = values.pop("drink_ingredient") if "extra_ingredient" in values: - extra_ingredient_value = values.get("extra_ingredient") - + extra_ingredient_value = values.pop("extra_ingredient") ingredient = Ingredient(**values) volume = get_volume(volume_id) - if drink_ingredient_values: - ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_values) + if drink_ingredient_value: + ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_value) if extra_ingredient_value: if "id" in extra_ingredient_value: ingredient.extra_ingredient = get_extra_ingredient(extra_ingredient_value.get("id")) @@ -315,12 +319,9 @@ def update_ingredient(identifier, data): if "extra_ingredient" in data and isinstance(data.get("extra_ingredient"), dict): if "id" in data.get("extra_ingredient"): ingredient.extra_ingredient = get_extra_ingredient(data.get("extra_ingredient").get("id")) - if "drink_ingredient" in data and ingredient.drink_ingredient and isinstance(data.get("drink_ingredient"), dict): - if "drink_ingredient" in data.get("drink_ingredient"): - if "id" in data.get("drink_ingredient").get("drink_ingredient"): - ingredient.drink_ingredient.drink_ingredient = get_drink( - data.get("drink_ingredient").get("drink_ingredient").get("id") - ) + if "drink_ingredient" in data and ingredient.drink_ingredient: + if data.get("drink_ingredient").get("drink_ingredient_id") > -1: + ingredient.drink_ingredient.drink_ingredient_id = data.get("drink_ingredient").get("drink_ingredient_id") if "volume" in data.get("drink_ingredient"): ingredient.drink_ingredient.volume = data.get("drink_ingredient").get("volume") db.session.commit() From 2b35eec0fca91d22048bc54e44b2eda5a57ca9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 18 Mar 2021 17:27:19 +0100 Subject: [PATCH 222/446] [run_flaschengeist] fixed bool definition --- run_flaschengeist | 39 +++++++++++++++++++++++---------------- setup.py | 2 +- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/run_flaschengeist b/run_flaschengeist index 1e0e9c5..ce06a1c 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -10,27 +10,33 @@ from flaschengeist.config import config class PrefixMiddleware(object): - - def __init__(self, app, prefix=''): + def __init__(self, app, prefix=""): self.app = app self.prefix = prefix def __call__(self, environ, start_response): - if environ['PATH_INFO'].startswith(self.prefix): - environ['PATH_INFO'] = environ['PATH_INFO'][len(self.prefix):] - environ['SCRIPT_NAME'] = self.prefix + if environ["PATH_INFO"].startswith(self.prefix): + environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :] + environ["SCRIPT_NAME"] = self.prefix return self.app(environ, start_response) else: - start_response('404', [('Content-Type', 'text/plain')]) + start_response("404", [("Content-Type", "text/plain")]) return ["This url does not belong to the app.".encode()] class InterfaceGenerator: known = [] classes = {} - mapper = {"str": "string", "int": "number", "float": "number", "date": "Date", "datetime": "Date", - "NoneType": "null"} + mapper = { + "str": "string", + "int": "number", + "float": "number", + "date": "Date", + "datetime": "Date", + "NoneType": "null", + "bool": "boolean", + } def __init__(self, namespace, filename): self.basename = "" @@ -45,6 +51,7 @@ class InterfaceGenerator: def _pytype(self, cls): import typing + origin = typing.get_origin(cls) arguments = typing.get_args(cls) @@ -76,19 +83,19 @@ class InterfaceGenerator: import typing if ( - inspect.ismodule(module[1]) - and module[1].__name__.startswith(self.basename) - and module[1].__name__ not in self.known + inspect.ismodule(module[1]) + and module[1].__name__.startswith(self.basename) + and module[1].__name__ not in self.known ): self.known.append(module[1].__name__) for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): self.walker(cls) elif ( - inspect.isclass(module[1]) - and module[1].__module__.startswith(self.basename) - and module[0] not in self.classes - and not module[0].startswith("_") - and hasattr(module[1], "__annotations__") + inspect.isclass(module[1]) + and module[1].__module__.startswith(self.basename) + and module[0] not in self.classes + and not module[0].startswith("_") + and hasattr(module[1], "__annotations__") ): self.this_type = module[0] print("\n\n" + module[0] + "\n") diff --git a/setup.py b/setup.py index cfab611..4be654d 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ setup( "flask_sqlalchemy", "flask_cors", "werkzeug", - mysql_driver + mysql_driver, ], extras_require={"ldap": ["flask_ldapconn", "ldap3"], "test": ["pytest", "coverage"]}, entry_points={ From 8dc2defe0213876d7c54d5ca589a29fe5db0f9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 18 Mar 2021 22:34:18 +0100 Subject: [PATCH 223/446] [pricelist] persistent save for visible columns in pricecalculation --- flaschengeist/plugins/pricelist/__init__.py | 37 ++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index e5a15ac..b015e85 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -5,10 +5,13 @@ from http.client import NO_CONTENT from flaschengeist.plugins import Plugin from flaschengeist.utils.decorators import login_required -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, Forbidden from . import models from . import pricelist_controller, permissions +from ...controller import userController +from ...models.session import Session +from ...utils.HTTP import no_content pricelist_bp = Blueprint("pricelist", __name__, url_prefix="/pricelist") @@ -220,3 +223,35 @@ def update_extra_ingredient(identifier): def delete_extra_ingredient(identifier): pricelist_controller.delete_extra_ingredient(identifier) return "", NO_CONTENT + + +@pricelist_bp.route("/users//pricecalc_columns", methods=["GET", "PUT"]) +@login_required() +def get_columns(userid, current_session: Session): + """Get pricecalc_columns of an user + + Route: ``/users//pricelist/pricecac_columns`` | Method: ``GET`` or ``PUT`` + POST-data: On ``PUT`` json encoded array of floats + + Args: + userid: Userid identifying the user + current_session: Session sent with Authorization Header + + Returns: + GET: JSON object containing the shortcuts as float array or HTTP error + PUT: HTTP-created or HTTP error + """ + if userid != current_session._user.userid: + raise Forbidden + + user = userController.get_user(userid) + if request.method == "GET": + return jsonify(user.get_attribute("pricecalc_columns", [])) + else: + data = request.get_json() + if not isinstance(data, list) or not all(isinstance(n, str) for n in data): + raise BadRequest + data.sort(reverse=True) + user.set_attribute("pricecalc_columns", data) + userController.persist() + return no_content() From cb342c07e859667cf41cccfedc8682769972f6ef Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 19 Mar 2021 18:17:59 +0100 Subject: [PATCH 224/446] [Models] Fixed optional detection --- flaschengeist/models/__init__.py | 6 ++---- flaschengeist/plugins/schedule/models.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index c7b94d1..27431a0 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -14,10 +14,8 @@ class ModelSerializeMixin: return False import typing - - if typing.get_origin(self.__class__.__annotations__[param]) is typing.Union and typing.get_args( - self.__class__.__annotations__[param] - )[1] is type(None): + hint = typing.get_type_hints(self.__class__)[param] + if typing.get_origin(hint) is typing.Union and len(typing.get_args(hint)) == 2 and typing.get_args(hint)[1] is type(None): return getattr(self, param) is None def serialize(self): diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index 04c060f..228ed0d 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -80,7 +80,7 @@ class Event(db.Model, ModelSerializeMixin): id: int = db.Column(db.Integer, primary_key=True) start: datetime = db.Column(UtcDateTime, nullable=False) - end: datetime = db.Column(UtcDateTime) + end: Optional[datetime] = db.Column(UtcDateTime) description: Optional[str] = db.Column(db.String(255)) type: EventType = db.relationship("EventType") jobs: list[Job] = db.relationship( From a476e4f5b11042f26f0716d022a748e347b19b07 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 19 Mar 2021 22:40:36 +0100 Subject: [PATCH 225/446] [cleanup] Fixed member names * Use _name only for protected members (should not be used outside the class * Use name_ instead for all members which should not be API exported --- flaschengeist/controller/sessionController.py | 10 +++---- flaschengeist/controller/userController.py | 4 +-- flaschengeist/models/session.py | 4 +-- flaschengeist/models/user.py | 5 ++-- flaschengeist/plugins/auth/__init__.py | 14 ++++----- flaschengeist/plugins/balance/__init__.py | 30 +++++++++---------- flaschengeist/plugins/balance/models.py | 14 ++++----- flaschengeist/plugins/pricelist/__init__.py | 2 +- flaschengeist/plugins/schedule/__init__.py | 6 ++-- flaschengeist/plugins/users/__init__.py | 8 ++--- 10 files changed, 49 insertions(+), 48 deletions(-) diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py index a3e08f8..4cae005 100644 --- a/flaschengeist/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -31,7 +31,7 @@ def validate_token(token, user_agent, permission): if session.expires >= datetime.now(timezone.utc) and ( session.browser == user_agent.browser and session.platform == user_agent.platform ): - if not permission or session._user.has_permission(permission): + if not permission or session.user_.has_permission(permission): session.refresh() db.session.commit() return session @@ -58,7 +58,7 @@ def create(user, user_agent=None) -> Session: token_str = secrets.token_hex(16) session = Session( token=token_str, - _user=user, + user_=user, lifetime=lifetime, browser=user_agent.browser, platform=user_agent.platform, @@ -83,13 +83,13 @@ def get_session(token, owner=None): Session: Token object identified by given token string """ session = Session.query.filter(Session.token == token).one_or_none() - if session and (owner and owner != session._user): + if session and (owner and owner != session.user_): raise Forbidden return session def get_users_sessions(user): - return Session.query.filter(Session._user == user) + return Session.query.filter(Session.user_ == user) def delete_sessions(user): @@ -98,7 +98,7 @@ def delete_sessions(user): Args: user (User): User to delete all sessions for """ - Session.query.filter(Session._user_id == user._id).delete() + Session.query.filter(Session.user_.id_ == user.id_).delete() db.session.commit() diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 111db65..a84b795 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -14,9 +14,9 @@ from flaschengeist.controller import messageController, sessionController def _generate_password_reset(user): """Generate a password reset link for the user""" - reset = _PasswordReset.query.get(user._id) + reset = _PasswordReset.query.get(user.id_) if not reset: - reset = _PasswordReset(_user_id=user._id) + reset = _PasswordReset(_user_id=user.id_) db.session.add(reset) expires = datetime.now(tz=timezone.utc) diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index c170ad6..7822804 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -27,12 +27,12 @@ class Session(db.Model, ModelSerializeMixin): userid: str = "" _id = db.Column("id", db.Integer, primary_key=True) - _user: User = db.relationship("User", back_populates="_sessions") _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id")) + user_: User = db.relationship("User", back_populates="sessions_") @property def userid(self): - return self._user.userid + return self.user_.userid def refresh(self): """Update the Timestamp diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 6e23e4b..4118120 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -62,9 +62,10 @@ class User(db.Model, ModelSerializeMixin): permissions: Optional[list[str]] = None avatar_url: Optional[str] = "" + id_ = db.Column("id", db.Integer, primary_key=True) roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") - _id = db.Column("id", db.Integer, primary_key=True) - _sessions = db.relationship("Session", back_populates="_user") + sessions_ = db.relationship("Session", back_populates="user_") + _attributes = db.relationship( "_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete" ) diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 07b00c4..f2289f0 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -62,7 +62,7 @@ def get_sessions(current_session, **kwargs): Returns: A JSON array of `flaschengeist.models.session.Session` or HTTP error """ - sessions = sessionController.get_users_sessions(current_session._user) + sessions = sessionController.get_users_sessions(current_session.user_) return jsonify(sessions) @@ -77,7 +77,7 @@ def delete_session(token, current_session, **kwargs): 200 Status (empty) or HTTP error """ logger.debug("Try to delete access token {{ {} }}".format(token)) - session = sessionController.get_session(token, current_session._user) + session = sessionController.get_session(token, current_session.user_) if not session: logger.debug("Token not found in database!") # Return 403 error, so that users can not bruteforce tokens @@ -103,7 +103,7 @@ def get_session(token, current_session, **kwargs): JSON encoded `flaschengeist.models.session.Session` or HTTP error """ logger.debug("get token {{ {} }}".format(token)) - session = sessionController.get_session(token, current_session._user) + session = sessionController.get_session(token, current_session.user_) if not session: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same @@ -127,7 +127,7 @@ def set_lifetime(token, current_session, **kwargs): Returns: HTTP-204 or HTTP error """ - session = sessionController.get_session(token, current_session._user) + session = sessionController.get_session(token, current_session.user_) if not session: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same @@ -136,7 +136,7 @@ def set_lifetime(token, current_session, **kwargs): lifetime = request.get_json()["value"] logger.debug(f"set lifetime >{lifetime}< to access token >{token}<") sessionController.set_lifetime(session, lifetime) - return jsonify(sessionController.get_session(token, current_session._user)) + return jsonify(sessionController.get_session(token, current_session.user_)) except (KeyError, TypeError): raise BadRequest @@ -156,12 +156,12 @@ def get_assocd_user(token, current_session, **kwargs): JSON encoded `flaschengeist.models.user.User` or HTTP error """ logger.debug("get token {{ {} }}".format(token)) - session = sessionController.get_session(token, current_session._user) + session = sessionController.get_session(token, current_session.user_) if not session: # Return 403 error, so that users can not bruteforce tokens # Valid tokens from other users and invalid tokens now are looking the same raise Forbidden - return jsonify(session._user) + return jsonify(session.user_) @auth_bp.route("/auth/reset", methods=["POST"]) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index edaa2a4..fc424bb 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -67,7 +67,7 @@ def get_shortcuts(userid, current_session: Session): GET: JSON object containing the shortcuts as float array or HTTP error PUT: HTTP-created or HTTP error """ - if userid != current_session._user.userid: + if userid != current_session.user_.userid: raise Forbidden user = userController.get_user(userid) @@ -98,8 +98,8 @@ def get_limit(userid, current_session: Session): JSON object containing the limit (or Null if no limit set) or HTTP error """ user = userController.get_user(userid) - if (user != current_session._user and not current_session._user.has_permission(permissions.SET_LIMIT)) or ( - user == current_session._user and not user.has_permission(permissions.SHOW) + if (user != current_session.user_ and not current_session.user_.has_permission(permissions.SET_LIMIT)) or ( + user == current_session.user_ and not user.has_permission(permissions.SHOW) ): raise Forbidden @@ -148,7 +148,7 @@ def get_balance(userid, current_session: Session): Returns: JSON object containing credit, debit and balance or HTTP error """ - if userid != current_session._user.userid and not current_session._user.has_permission(permissions.SHOW_OTHER): + if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER): raise Forbidden # Might raise NotFound @@ -187,7 +187,7 @@ def get_transactions(userid, current_session: Session): Returns: JSON Object {transactions: Transaction[], count?: number} or HTTP error """ - if userid != current_session._user.userid and not current_session._user.has_permission(permissions.SHOW_OTHER): + if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER): raise Forbidden # Might raise NotFound @@ -253,19 +253,19 @@ def change_balance(userid, current_session: Session): if sender == user: raise BadRequest - if (sender == current_session._user and sender.has_permission(permissions.SEND)) or ( - sender != current_session._user and current_session._user.has_permission(permissions.SEND_OTHER) + if (sender == current_session.user_ and sender.has_permission(permissions.SEND)) or ( + sender != current_session.user_ and current_session.user_.has_permission(permissions.SEND_OTHER) ): - return HTTP.created(balance_controller.send(sender, user, amount, current_session._user)) + return HTTP.created(balance_controller.send(sender, user, amount, current_session.user_)) elif ( amount < 0 and ( - (user == current_session._user and user.has_permission(permissions.DEBIT_OWN)) - or current_session._user.has_permission(permissions.DEBIT) + (user == current_session.user_ and user.has_permission(permissions.DEBIT_OWN)) + or current_session.user_.has_permission(permissions.DEBIT) ) - ) or (amount > 0 and current_session._user.has_permission(permissions.CREDIT)): - return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session._user)) + ) or (amount > 0 and current_session.user_.has_permission(permissions.CREDIT)): + return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session.user_)) raise Forbidden @@ -286,11 +286,11 @@ def reverse_transaction(transaction_id, current_session: Session): """ transaction = balance_controller.get_transaction(transaction_id) - if current_session._user.has_permission(permissions.REVERSAL) or ( - transaction.sender_ == current_session._user + if current_session.user_.has_permission(permissions.REVERSAL) or ( + transaction.sender_ == current_session.user_ and (datetime.now(tz=timezone.utc) - transaction.time).total_seconds() < 10 ): - reversal = balance_controller.reverse_transaction(transaction, current_session._user) + reversal = balance_controller.reverse_transaction(transaction, current_session.user_) return HTTP.created(reversal) raise Forbidden diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 41c3520..be504aa 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -20,13 +20,13 @@ class Transaction(db.Model, ModelSerializeMixin): id: int = db.Column("id", db.Integer, primary_key=True) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) - reversal_id: int = db.Column(db.Integer, db.ForeignKey("balance_transaction.id")) + reversal_id: Optional[int] = db.Column(db.Integer, db.ForeignKey("balance_transaction.id")) # Dummy properties used for JSON serialization (userid instead of full user) - sender_id: Optional[str] = "" - receiver_id: Optional[str] = "" - author_id: Optional[str] = "" - original_id: Optional[int] = -1 + author_id: Optional[str] = None + sender_id: Optional[str] = None + original_id: Optional[int] = None + receiver_id: Optional[str] = None # Not exported relationships just in backend only sender_: User = db.relationship("User", foreign_keys=[_sender_id]) @@ -40,7 +40,7 @@ class Transaction(db.Model, ModelSerializeMixin): @sender_id.expression def sender_id(cls): - return db.select([User.userid]).where(cls._sender_id == User._id).as_scalar() + return db.select([User.userid]).where(cls._sender_id == User.id_).as_scalar() @hybrid_property def receiver_id(self): @@ -48,7 +48,7 @@ class Transaction(db.Model, ModelSerializeMixin): @receiver_id.expression def receiver_id(cls): - return db.select([User.userid]).where(cls._receiver_id == User._id).as_scalar() + return db.select([User.userid]).where(cls._receiver_id == User.id_).as_scalar() @property def author_id(self): diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index b015e85..de9eb35 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -241,7 +241,7 @@ def get_columns(userid, current_session: Session): GET: JSON object containing the shortcuts as float array or HTTP error PUT: HTTP-created or HTTP error """ - if userid != current_session._user.userid: + if userid != current_session.user_.userid: raise Forbidden user = userController.get_user(userid) diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index d21b4f3..a2e3ec0 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -388,15 +388,15 @@ def update_job(event_id, job_id, current_session: Session): if not data: raise BadRequest - if ("user" not in data or len(data) > 1) and not current_session._user.has_permission(permissions.EDIT): + if ("user" not in data or len(data) > 1) and not current_session.user_.has_permission(permissions.EDIT): raise Forbidden if "user" in data: try: user = userController.get_user(data["user"]["userid"]) value = data["user"]["value"] - if (user == current_session._user and not user.has_permission(permissions.ASSIGN)) or ( - user != current_session._user and not current_session._user.has_permission(permissions.ASSIGN_OTHER) + if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or ( + user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) ): raise Forbidden event_controller.assign_to_job(job, user, value) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 02e565e..277aca1 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -92,7 +92,7 @@ def get_user(userid, current_session): logger.debug("Get information of user {{ {} }}".format(userid)) user: User = userController.get_user(userid) serial = user.serialize() - if userid == current_session._user.userid: + if userid == current_session.user_.userid: serial["permissions"] = user.get_permissions() return jsonify(serial) @@ -113,7 +113,7 @@ def get_avatar(userid): @login_required() def set_avatar(userid, current_session): user = userController.get_user(userid) - if userid != current_session._user.userid and not current_session._user.has_permission(permissions.EDIT): + if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.EDIT): raise Forbidden file = request.files.get("file") @@ -172,8 +172,8 @@ def edit_user(userid, current_session): new_password = data["new_password"] if "new_password" in data else None author = user - if userid != current_session._user.userid: - author = current_session._user + if userid != current_session.user_.userid: + author = current_session.user_ if not author.has_permission(permissions.EDIT): raise Forbidden else: From 02959587e188dd98dd1cbe84421d02439707716b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 20 Mar 2021 01:00:55 +0100 Subject: [PATCH 226/446] [plugin] schedule: Allow ID for types (e.g. creating new events) --- flaschengeist/plugins/schedule/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index 228ed0d..3b5993e 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -1,7 +1,7 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 from datetime import datetime -from typing import Optional +from typing import Optional, Union from sqlalchemy import UniqueConstraint @@ -55,8 +55,8 @@ class Job(db.Model, ModelSerializeMixin): id: int = db.Column(db.Integer, primary_key=True) start: datetime = db.Column(UtcDateTime, nullable=False) end: Optional[datetime] = db.Column(UtcDateTime) - comment: str = db.Column(db.String(256)) - type: JobType = db.relationship("JobType") + type: Union[JobType, int] = db.relationship("JobType") + comment: Optional[str] = db.Column(db.String(256)) services: list[Service] = db.relationship("Service", back_populates="job_") required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) @@ -82,7 +82,7 @@ class Event(db.Model, ModelSerializeMixin): start: datetime = db.Column(UtcDateTime, nullable=False) end: Optional[datetime] = db.Column(UtcDateTime) description: Optional[str] = db.Column(db.String(255)) - type: EventType = db.relationship("EventType") + type: Union[EventType, int] = db.relationship("EventType") jobs: list[Job] = db.relationship( "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" ) From e16f4704b5b732a2a4b9f3b669607b1d2ae09e2b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 20 Mar 2021 12:19:39 +0100 Subject: [PATCH 227/446] [setup] Flask-sqlalchemy now supports sqlalchemy 1.4 --- setup.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 4be654d..42bdd9a 100644 --- a/setup.py +++ b/setup.py @@ -17,9 +17,8 @@ setup( install_requires=[ "Flask >= 1.1", "toml", - # < 1.4: https://github.com/pallets/flask-sqlalchemy/issues/885 - "sqlalchemy>=1.3,<1.4", - "flask_sqlalchemy", + "sqlalchemy>=1.4", + "flask_sqlalchemy>=2.5", "flask_cors", "werkzeug", mysql_driver, From e31c5756a63d1e6192a5250a7b16b1518182cca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 20 Mar 2021 15:01:18 +0100 Subject: [PATCH 228/446] [pricelist][break] some cleanup, removed unneccessary api --- flaschengeist/models/__init__.py | 7 +- flaschengeist/plugins/pricelist/__init__.py | 46 ---- flaschengeist/plugins/pricelist/models.py | 2 +- .../plugins/pricelist/pricelist_controller.py | 215 +++++++++--------- 4 files changed, 112 insertions(+), 158 deletions(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 27431a0..5cee5bf 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -14,8 +14,13 @@ class ModelSerializeMixin: return False import typing + hint = typing.get_type_hints(self.__class__)[param] - if typing.get_origin(hint) is typing.Union and len(typing.get_args(hint)) == 2 and typing.get_args(hint)[1] is type(None): + if ( + typing.get_origin(hint) is typing.Union + and len(typing.get_args(hint)) == 2 + and typing.get_args(hint)[1] is type(None) + ): return getattr(self, param) is None def serialize(self): diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index de9eb35..cc3ec44 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -138,52 +138,12 @@ def delete_drink(identifier): return "", NO_CONTENT -@pricelist_bp.route("/prices", methods=["GET"]) -@pricelist_bp.route("/prices/", methods=["GET"]) -def get_prices(identifier=None): - if identifier: - result = pricelist_controller.get_price(identifier) - else: - result = pricelist_controller.get_prices() - return jsonify(result) - - -@pricelist_bp.route("/volumes//prices", methods=["POST"]) -def create_price(identifier): - data = request.get_json() - return jsonify(pricelist_controller.set_price(identifier, data)) - - -@pricelist_bp.route("/prices/", methods=["PUT"]) -def modify_price(identifier): - data = request.get_json() - return jsonify(pricelist_controller.update_price(identifier, data)) - - @pricelist_bp.route("/prices/", methods=["DELETE"]) def delete_price(identifier): pricelist_controller.delete_price(identifier) return "", NO_CONTENT -@pricelist_bp.route("/drinks//volumes", methods=["POST"]) -def set_volume(identifier): - data = request.get_json() - return jsonify(pricelist_controller.set_volume(identifier, data)) - - -@pricelist_bp.route("/volumes//ingredients", methods=["POST"]) -def set_ingredient(identifier): - data = request.get_json() - return jsonify(pricelist_controller.set_ingredient(data, identifier)) - - -@pricelist_bp.route("/volumes/", methods=["PUT"]) -def update_volume(identifier): - data = request.get_json() - return jsonify(pricelist_controller.update_volume(identifier, data)) - - @pricelist_bp.route("/volumes/", methods=["DELETE"]) def delete_volume(identifier): pricelist_controller.delete_volume(identifier) @@ -195,12 +155,6 @@ def get_extraIngredients(): return jsonify(pricelist_controller.get_extra_ingredients()) -@pricelist_bp.route("/ingredients/", methods=["PUT"]) -def update_ingredient(identifier): - data = request.get_json() - return jsonify(pricelist_controller.update_ingredient(identifier, data)) - - @pricelist_bp.route("/ingredients/", methods=["DELETE"]) def delete_ingredient(identifier): pricelist_controller.delete_ingredient(identifier) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 703a792..b98acba 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -114,7 +114,7 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): __tablename__ = "drink_price_volume" id: int = db.Column("id", db.Integer, primary_key=True) - drink_id = db.Column(db.Integer, db.ForeignKey("drink.id"), nullable=False) + drink_id = db.Column(db.Integer, db.ForeignKey("drink.id")) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) min_prices: list[MinPrices] = [] # ingredients: list[Ingredient] = [] diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 8eceec9..1544d97 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -121,24 +121,7 @@ def get_drink(identifier): def set_drink(data): - allowedKeys = Drink().serialize().keys() - if "id" in data: - data.pop("id") - values = {key: value if value != "" else None for key, value in data.items() if key in allowedKeys} - if "volumes" in values: - values.pop("volumes") - if "tags" in values: - values.pop("tags") - if "type" in values: - _type = values.pop("type") - if isinstance(_type, dict) and "id" in _type: - type = get_drink_type(_type.get("id")) - drink = Drink(**values) - if type: - drink.type = type - db.session.add(drink) - db.session.commit() - return drink + return update_drink(-1, data) def update_drink(identifier, data): @@ -146,23 +129,43 @@ def update_drink(identifier, data): if "id" in data: data.pop("id") values = {key: value for key, value in data.items() if key in allowedKeys} + volumes = None if "volumes" in values: - values.pop("volumes") + volumes = values.pop("volumes") if "tags" in values: values.pop("tags") + type = None if "type" in values: _type = values.pop("type") if isinstance(_type, dict) and "id" in _type: type = get_drink_type(_type.get("id")) - drink = get_drink(identifier) - for key, value in values.items(): - setattr(drink, key, value if value != "" else None) + if identifier == -1: + drink = Drink(**values) + db.session.add(drink) + else: + drink = get_drink(identifier) + if not drink: + raise NotFound + for key, value in values.items(): + setattr(drink, key, value if value != "" else None) + if type: drink.type = type + if volumes: + set_volumes(volumes, drink) db.session.commit() return drink +def set_volumes(volumes, drink): + if isinstance(volumes, list): + _volumes = [] + for _volume in volumes: + volume = set_volume(_volume) + _volumes.append(volume) + drink.volumes = _volumes + + def delete_drink(identifier): drink = get_drink(identifier) db.session.delete(drink) @@ -179,39 +182,52 @@ def get_volumes(drink_id=None): return DrinkPriceVolume.query.all() -def set_volume(identifier, data): +def set_volume(data): allowed_keys = DrinkPriceVolume().serialize().keys() - if "id" in data: - data.pop("id") values = {key: value for key, value in data.items() if key in allowed_keys} + prices = None + ingredients = None if "prices" in values: prices = values.pop("prices") if "ingredients" in values: ingredients = values.pop("ingredients") - volume = DrinkPriceVolume(**values) - drink = get_drink(identifier) - if not drink: - raise BadRequest - drink.volumes.append(volume) - db.session.add(volume) - db.session.commit() + id = None + if "id" in values: + id = values.pop("id") + volume = None + if id < 0: + volume = DrinkPriceVolume(**values) + db.session.add(volume) + else: + volume = get_volume(id) + if not volume: + raise NotFound + for key, value in values.items(): + setattr(volume, key, value if value != "" else None) + + if prices: + set_prices(prices, volume) + if ingredients: + set_ingredients(ingredients, volume) return volume -def update_volume(identifier, data): - allowed_keys = DrinkPriceVolume().serialize().keys() - if "id" in data: - data.pop("id") - values = {key: value for key, value in data.items() if key in allowed_keys} - if "prices" in values: - prices = values.pop("prices") - if "ingredients" in values: - ingredients = values.pop("ingredients") - volume = get_volume(identifier) - for key, value in values.items(): - setattr(volume, key, value if value != "" else None) - db.session.commit() - return volume +def set_prices(prices, volume): + if isinstance(prices, list): + _prices = [] + for _price in prices: + price = set_price(_price) + _prices.append(price) + volume.prices = _prices + + +def set_ingredients(ingredients, volume): + if isinstance(ingredients, list): + _ingredietns = [] + for _ingredient in ingredients: + ingredient = set_ingredient(_ingredient) + _ingredietns.append(ingredient) + volume.ingredients = _ingredietns def delete_volume(identifier): @@ -232,30 +248,23 @@ def get_prices(volume_id=None): return DrinkPrice.query.all() -def set_price(identifier, data): +def set_price(data): allowed_keys = DrinkPrice().serialize().keys() - if "id" in data: - data.pop("id") values = {key: value for key, value in data.items() if key in allowed_keys} - price = DrinkPrice(**values) - volume = get_volume(identifier) - if not volume: - raise BadRequest - volume.prices.append(price) - db.session.add(price) - db.session.commit() - return price + id = None + if "id" in values: + id = values.pop("id") + price = None + if id < 0: + price = DrinkPrice(**values) + db.session.add(price) + else: + price = get_price(id) + if not price: + raise NotFound + for key, value in values.items(): + setattr(price, key, value) - -def update_price(identifier, data): - allowed_keys = DrinkPrice().serialize().keys() - if "id" in data: - data.pop("id") - values = {key: value for key, value in data.items() if key in allowed_keys} - price = get_price(identifier) - for key, value in values.items(): - setattr(price, key, value) - db.session.commit() return price @@ -267,21 +276,20 @@ def delete_price(identifier): def set_drink_ingredient(data): allowedKeys = DrinkIngredient().serialize().keys() - if "id" in data: - data.pop("id") drink = None - if "drink_ingredient" in data: - drink_ingredient_ = data.pop("drink_ingredient") - if "id" in drink_ingredient_: - drink = get_drink(drink_ingredient_.get("id")) values = {key: value for key, value in data.items() if key in allowedKeys} - if "price" in values: - values.pop("price") - drink_ingredient = DrinkIngredient(**values) - if drink: - drink_ingredient.drink_ingredient = drink - db.session.add(drink_ingredient) - db.session.commit() + id = None + if "id" in values: + id = values.pop("id") + if id < 0: + drink_ingredient = DrinkIngredient(**values) + db.session.add(drink_ingredient) + else: + drink_ingredient = DrinkIngredient.query.get(id) + if not drink_ingredient: + raise NotFound + for key, value in values.items(): + setattr(drink_ingredient, key, value if value != "" else None) return drink_ingredient @@ -289,42 +297,29 @@ def get_ingredient(identifier): return Ingredient.query.get(identifier) -def set_ingredient(data, volume_id): - allowedKeys = Ingredient().serialize().keys() - if "id" in data: - data.pop("id") - values = {key: value for key, value in data.items() if key in allowedKeys} +def set_ingredient(data): drink_ingredient_value = None extra_ingredient_value = None - if "drink_ingredient" in values: - drink_ingredient_value = values.pop("drink_ingredient") - if "extra_ingredient" in values: - extra_ingredient_value = values.pop("extra_ingredient") - ingredient = Ingredient(**values) - volume = get_volume(volume_id) + if "drink_ingredient" in data: + drink_ingredient_value = data.pop("drink_ingredient") + if "extra_ingredient" in data: + extra_ingredient_value = data.pop("extra_ingredient") + id = None + if "id" in data: + id = data.pop("id") + ingredient = None + if id < 0: + ingredient = Ingredient(**data) + db.session.add(ingredient) + else: + ingredient = get_ingredient(id) + if not ingredient: + raise NotFound if drink_ingredient_value: ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_value) if extra_ingredient_value: if "id" in extra_ingredient_value: ingredient.extra_ingredient = get_extra_ingredient(extra_ingredient_value.get("id")) - - volume.ingredients.append(ingredient) - db.session.add(ingredient) - db.session.commit() - return ingredient - - -def update_ingredient(identifier, data): - ingredient = get_ingredient(identifier) - if "extra_ingredient" in data and isinstance(data.get("extra_ingredient"), dict): - if "id" in data.get("extra_ingredient"): - ingredient.extra_ingredient = get_extra_ingredient(data.get("extra_ingredient").get("id")) - if "drink_ingredient" in data and ingredient.drink_ingredient: - if data.get("drink_ingredient").get("drink_ingredient_id") > -1: - ingredient.drink_ingredient.drink_ingredient_id = data.get("drink_ingredient").get("drink_ingredient_id") - if "volume" in data.get("drink_ingredient"): - ingredient.drink_ingredient.volume = data.get("drink_ingredient").get("volume") - db.session.commit() return ingredient @@ -362,7 +357,7 @@ def update_extra_ingredient(identifier, data): values = {key: value for key, value in data.items() if key in allowedKeys} extra_ingredient = get_extra_ingredient(identifier) if extra_ingredient: - for key, value in data.items: + for key, value in values.items(): setattr(extra_ingredient, key, value) db.session.commit() return extra_ingredient From 4cd353cf4ec88f6996ac3db21128e50dc3e43d9c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 20 Mar 2021 17:18:17 +0100 Subject: [PATCH 229/446] Renamed schedule to events, added recurring events and invites --- .../plugins/{schedule => events}/__init__.py | 16 +- .../{schedule => events}/event_controller.py | 32 +++- flaschengeist/plugins/events/models.py | 142 ++++++++++++++++++ .../{schedule => events}/permissions.py | 0 flaschengeist/plugins/schedule/models.py | 88 ----------- setup.py | 2 +- tests/test_events.py | 17 +++ 7 files changed, 199 insertions(+), 98 deletions(-) rename flaschengeist/plugins/{schedule => events}/__init__.py (96%) rename flaschengeist/plugins/{schedule => events}/event_controller.py (76%) create mode 100644 flaschengeist/plugins/events/models.py rename flaschengeist/plugins/{schedule => events}/permissions.py (100%) delete mode 100644 flaschengeist/plugins/schedule/models.py create mode 100644 tests/test_events.py diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/events/__init__.py similarity index 96% rename from flaschengeist/plugins/schedule/__init__.py rename to flaschengeist/plugins/events/__init__.py index a2e3ec0..5b1b772 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/events/__init__.py @@ -17,10 +17,10 @@ from . import event_controller, permissions from . import models from ...utils.HTTP import no_content -schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") +schedule_bp = Blueprint("events", __name__, url_prefix="/schedule") -class SchedulePlugin(Plugin): +class EventPlugin(Plugin): models = models def __init__(self, config): @@ -268,12 +268,20 @@ def create_event(current_session): except (NotFound, ValueError): raise BadRequest("Invalid parameter") + recurrence_rule = None + if "recurrence_rule" in data: + recurrence_rule = event_controller.create_recurrence(data=data["recurrence_rule"]) event = event_controller.create_event( - start=start, end=end, event_type=event_type, description=data.get("description", None) + start=start, + end=end, + event_type=event_type, + description=data.get("description", None), + recurrence_rule=recurrence_rule, ) if "jobs" in data: for job in data["jobs"]: _add_job(event, job) + return jsonify(event) @@ -297,6 +305,8 @@ def modify_event(event_id, current_session): data = request.get_json() if "start" in data: event.start = from_iso_format(data["start"]) + if "end" in data: + event.end = from_iso_format(data["end"]) if "description" in data: event.description = data["description"] if "type" in data: diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/events/event_controller.py similarity index 76% rename from flaschengeist/plugins/schedule/event_controller.py rename to flaschengeist/plugins/events/event_controller.py index dbc829d..3432e29 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -3,7 +3,8 @@ from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.database import db -from flaschengeist.plugins.schedule.models import EventType, Event, Job, JobType, Service +from flaschengeist.plugins.events.models import EventType, Event, Job, JobType, Service, RecurrenceRule +from flaschengeist.utils.datetime import from_iso_format def update(): @@ -97,7 +98,7 @@ def delete_job_type(name): raise BadRequest("Type still in use") -def get_event(event_id): +def get_event(event_id) -> Event: event = Event.query.get(event_id) if event is None: raise NotFound @@ -128,11 +129,15 @@ def delete_event(event_id): db.session.commit() -def create_event(event_type, start, end=None, jobs=[], description=None): +def create_event(event_type, start, end=None, jobs=[], description=None, recurrence_rule=None): try: logger.debug(event_type) - event = Event(start=start, end=end, description=description, type=event_type, jobs=jobs) + event = Event( + start=start, end=end, description=description, type=event_type, jobs=jobs, recurrence_rule=recurrence_rule + ) db.session.add(event) + if recurrence_rule is not None: + event = Event(start=start, end=end, description=description, type=event_type, jobs=jobs, template_=event) db.session.commit() return event except IntegrityError: @@ -141,7 +146,7 @@ def create_event(event_type, start, end=None, jobs=[], description=None): def get_job(job_slot_id, event_id): - js = Job.query.filter(Job.id == job_slot_id).filter(Job._event_id == event_id).one_or_none() + js = Job.query.filter(Job.id == job_slot_id).filter(Job.event_id_ == event_id).one_or_none() if js is None: raise NotFound return js @@ -168,7 +173,7 @@ def delete_job(job: Job): def assign_to_job(job: Job, user, value): - service = Service.query.get((job.id, user._id)) + service = Service.query.get((job.id, user.id_)) if value < 0: if not service: raise BadRequest @@ -180,3 +185,18 @@ def assign_to_job(job: Job, user, value): service = Service(user_=user, value=value, job_=job) db.session.add(service) db.session.commit() + + +def create_recurrence(data=None, count=None, end_date=None, frequency=None, interval=None): + if data is not None: + if "frequency" not in data: + raise BadRequest("Missing POST parameter") + frequency = data["frequency"] + if "end_date" in data: + end_date = from_iso_format(data["end_date"]) + if "count" in data: + count = data["count"] + if "interval" in data: + interval = data["interval"] + recurrence = RecurrenceRule(frequency=frequency, end_date=end_date, count=count, interval=interval) + db.session.add(recurrence) diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py new file mode 100644 index 0000000..d645d7c --- /dev/null +++ b/flaschengeist/plugins/events/models.py @@ -0,0 +1,142 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + +import enum +from datetime import datetime +from typing import Optional, Union + +from sqlalchemy import UniqueConstraint + +from flaschengeist.models import ModelSerializeMixin, UtcDateTime +from flaschengeist.models.user import User +from flaschengeist.database import db + +######### +# Types # +######### + +_table_prefix_ = "events_" + + +class EventType(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "event_type" + id: int = db.Column(db.Integer, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +class JobType(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "job_type" + id: int = db.Column(db.Integer, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +######## +# Jobs # +######## + + +class Service(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "service" + userid: str = "" + value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) + + _job_id = db.Column( + "job_id", db.Integer, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True + ) + _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True) + + user_: User = db.relationship("User") + job_: Job = db.relationship("Job") + + @property + def userid(self): + return self.user_.userid + + +class Job(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "job" + _type_id = db.Column("type_id", db.Integer, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False) + + id: int = db.Column(db.Integer, primary_key=True) + start: datetime = db.Column(UtcDateTime, nullable=False) + end: Optional[datetime] = db.Column(UtcDateTime) + type: Union[JobType, int] = db.relationship("JobType") + comment: Optional[str] = db.Column(db.String(256)) + services: list[Service] = db.relationship("Service", back_populates="job_") + required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) + + event_ = db.relationship("Event", back_populates="jobs") + event_id_ = db.Column("event_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) + + __table_args__ = (UniqueConstraint("type_id", "start", name="_type_start_uc"),) + + +########## +# Events # +########## + + +class _Frequency(enum.Enum): + daily = 1 + weekly = 2 + monthly = 3 + yearly = 4 + + +class RecurrenceRule(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "recurrence_rule" + + frequency: str = db.Column(db.Enum(_Frequency)) + until: Optional[datetime] = db.Column(UtcDateTime) + count: Optional[int] = db.Column(db.Integer) + interval: int = db.Column(db.Integer, nullable=False, default=1) + + id_: int = db.Column("id", db.Integer, primary_key=True) + + +class Event(db.Model, ModelSerializeMixin): + """Model for an Event""" + + __tablename__ = _table_prefix_ + "event" + id: int = db.Column(db.Integer, primary_key=True) + start: datetime = db.Column(UtcDateTime, nullable=False) + end: Optional[datetime] = db.Column(UtcDateTime) + description: Optional[str] = db.Column(db.String(255)) + type: Union[EventType, int] = db.relationship("EventType") + jobs: list[Job] = db.relationship( + "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" + ) + recurrence_rule: Optional[RecurrenceRule] = db.relationship("RecurrenceRule") + template_id: Optional[int] = db.Column("template_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event.id")) + # Not exported properties for backend use + template_ = db.relationship("Event") + # Protected for internal use + _recurrence_rule_id = db.Column( + "recurrence_rule_id", db.Integer, db.ForeignKey(f"{_table_prefix_}recurrence_rule.id") + ) + _type_id = db.Column( + "type_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False + ) + + +class Invite(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "invite" + + id: int = db.Column(db.Integer, primary_key=True) + job_id: int = db.Column(db.Integer, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False) + # Dummy properties for API export + invitee_id: str = None + sender_id: str = None + # Not exported properties for backend use + invitee_: User = db.relationship("User", foreign_keys="Invite._invitee_id") + sender_: User = db.relationship("User", foreign_keys="Invite._sender_id") + # Protected properties needed for internal use + _invitee_id = db.Column("invitee_id", db.Integer, db.ForeignKey("user.id"), nullable=False) + _sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id"), nullable=False) + + @property + def invitee_id(self): + return self.invitee_.userid + + @property + def sender_id(self): + return self.sender_.userid diff --git a/flaschengeist/plugins/schedule/permissions.py b/flaschengeist/plugins/events/permissions.py similarity index 100% rename from flaschengeist/plugins/schedule/permissions.py rename to flaschengeist/plugins/events/permissions.py diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py deleted file mode 100644 index 3b5993e..0000000 --- a/flaschengeist/plugins/schedule/models.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 - -from datetime import datetime -from typing import Optional, Union - -from sqlalchemy import UniqueConstraint - -from flaschengeist.models import ModelSerializeMixin, UtcDateTime -from flaschengeist.models.user import User -from flaschengeist.database import db - -######### -# Types # -######### - - -class EventType(db.Model, ModelSerializeMixin): - __tablename__ = "schedule_event_type" - id: int = db.Column(db.Integer, primary_key=True) - name: str = db.Column(db.String(30), nullable=False, unique=True) - - -class JobType(db.Model, ModelSerializeMixin): - __tablename__ = "schedule_job_type" - id: int = db.Column(db.Integer, primary_key=True) - name: str = db.Column(db.String(30), nullable=False, unique=True) - - -######## -# Jobs # -######## - - -class Service(db.Model, ModelSerializeMixin): - __tablename__ = "schedule_service" - userid: str = "" - value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) - - _job_id = db.Column("job_id", db.Integer, db.ForeignKey("schedule_job.id"), nullable=False, primary_key=True) - _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True) - - user_: User = db.relationship("User") - job_ = db.relationship("Job") - - @property - def userid(self): - return self.user_.userid - - -class Job(db.Model, ModelSerializeMixin): - __tablename__ = "schedule_job" - _type_id = db.Column("type_id", db.Integer, db.ForeignKey("schedule_job_type.id"), nullable=False) - _event_id = db.Column("event_id", db.Integer, db.ForeignKey("schedule_event.id"), nullable=False) - - id: int = db.Column(db.Integer, primary_key=True) - start: datetime = db.Column(UtcDateTime, nullable=False) - end: Optional[datetime] = db.Column(UtcDateTime) - type: Union[JobType, int] = db.relationship("JobType") - comment: Optional[str] = db.Column(db.String(256)) - services: list[Service] = db.relationship("Service", back_populates="job_") - required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) - - event_ = db.relationship("Event", back_populates="jobs") - - __table_args__ = (UniqueConstraint("type_id", "start", name="_type_start_uc"),) - - -########## -# Events # -########## - - -class Event(db.Model, ModelSerializeMixin): - """Model for an Event""" - - __tablename__ = "schedule_event" - _type_id = db.Column( - "type_id", db.Integer, db.ForeignKey("schedule_event_type.id", ondelete="CASCADE"), nullable=False - ) - - id: int = db.Column(db.Integer, primary_key=True) - start: datetime = db.Column(UtcDateTime, nullable=False) - end: Optional[datetime] = db.Column(UtcDateTime) - description: Optional[str] = db.Column(db.String(255)) - type: Union[EventType, int] = db.relationship("EventType") - jobs: list[Job] = db.relationship( - "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" - ) diff --git a/setup.py b/setup.py index 42bdd9a..191d04d 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ setup( "users = flaschengeist.plugins.users:UsersPlugin", "roles = flaschengeist.plugins.roles:RolesPlugin", "balance = flaschengeist.plugins.balance:BalancePlugin", - "schedule = flaschengeist.plugins.schedule:SchedulePlugin", + "events = flaschengeist.plugins.events:EventPlugin", "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin", ], diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..a847ce1 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,17 @@ +import pytest +from werkzeug.exceptions import BadRequest + +import flaschengeist.plugins.events.event_controller as event_controller +from flaschengeist.plugins.events.models import EventType + +VALID_TOKEN = "f4ecbe14be3527ca998143a49200e294" +EVENT_TYPE_NAME = "Test Type" + + +def test_create_event_type(app): + with app.app_context(): + type = event_controller.create_event_type(EVENT_TYPE_NAME) + assert isinstance(type, EventType) + + with pytest.raises(BadRequest): + event_controller.create_event_type(EVENT_TYPE_NAME) From b60c405f76ea73ff6ff5e740bfa6665c9b5c1cd1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 21 Mar 2021 00:55:50 +0100 Subject: [PATCH 230/446] [events] Improved event plugin --- flaschengeist/plugins/events/__init__.py | 48 ++++++++++++------- .../plugins/events/event_controller.py | 40 +++++++--------- flaschengeist/plugins/events/models.py | 33 ++----------- 3 files changed, 54 insertions(+), 67 deletions(-) diff --git a/flaschengeist/plugins/events/__init__.py b/flaschengeist/plugins/events/__init__.py index 5b1b772..0194c7a 100644 --- a/flaschengeist/plugins/events/__init__.py +++ b/flaschengeist/plugins/events/__init__.py @@ -30,6 +30,12 @@ class EventPlugin(Plugin): ) +@schedule_bp.route("/templates", methods=["GET"]) +@login_required() +def get_templates(current_session): + return jsonify(event_controller.get_templates()) + + @schedule_bp.route("/event-types", methods=["GET"]) @schedule_bp.route("/event-types/", methods=["GET"]) @login_required() @@ -185,6 +191,19 @@ def get_event(event_id, current_session): @schedule_bp.route("/events", methods=["GET"]) +@login_required() +def get_filtered_events(current_session): + begin = request.args.get('from') + if begin is not None: + begin = from_iso_format(begin) + end = request.args.get('to') + if end is not None: + end = from_iso_format(end) + if begin is None and end is None: + begin = datetime.now() + return jsonify(event_controller.get_events(begin, end)) + + @schedule_bp.route("/events//", methods=["GET"]) @schedule_bp.route("/events///", methods=["GET"]) @login_required() @@ -221,21 +240,20 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m def _add_job(event, data): - end = None try: start = from_iso_format(data["start"]) + end = None + if "end" in data: + end = from_iso_format(data["end"]) + required_services = data["required_services"] + job_type = data["type"] + if isinstance(job_type, dict): + job_type = data["type"]["id"] except (KeyError, ValueError): - raise BadRequest("Missing POST parameter") - if "end" in data: - end = from_iso_format(data["end"]) + raise BadRequest("Missing or invalid POST parameter") - if "required_services" not in data: - raise BadRequest - job_type = data["type"] - if isinstance(job_type, dict): - job_type = data["type"]["id"] job_type = event_controller.get_job_type(job_type) - event_controller.add_job(event, job_type, data["required_services"], start, end, comment=data.get("comment")) + event_controller.add_job(event, job_type, required_services, start, end, comment=data.get("comment", None)) @schedule_bp.route("/events", methods=["POST"]) @@ -268,15 +286,13 @@ def create_event(current_session): except (NotFound, ValueError): raise BadRequest("Invalid parameter") - recurrence_rule = None - if "recurrence_rule" in data: - recurrence_rule = event_controller.create_recurrence(data=data["recurrence_rule"]) event = event_controller.create_event( start=start, end=end, + name=data.get("name", None), + is_template=data.get("is_template", None), event_type=event_type, description=data.get("description", None), - recurrence_rule=recurrence_rule, ) if "jobs" in data: for job in data["jobs"]: @@ -390,7 +406,7 @@ def update_job(event_id, job_id, current_session: Session): current_session: Session sent with Authorization Header Returns: - JSON encoded Event object or HTTP-error + JSON encoded Job object or HTTP-error """ job = event_controller.get_job(job_id, event_id) @@ -419,7 +435,7 @@ def update_job(event_id, job_id, current_session: Session): job.type = event_controller.get_job_type(data["type"]) event_controller.update() - return jsonify(job.event_) + return jsonify(job) # TODO: JobTransfer diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 3432e29..84e2887 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -1,9 +1,13 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import or_, and_ from werkzeug.exceptions import BadRequest, NotFound from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.database import db -from flaschengeist.plugins.events.models import EventType, Event, Job, JobType, Service, RecurrenceRule +from flaschengeist.plugins.events.models import EventType, Event, Job, JobType, Service from flaschengeist.utils.datetime import from_iso_format @@ -105,7 +109,11 @@ def get_event(event_id) -> Event: return event -def get_events(start, end): +def get_templates(): + return Event.query.filter(Event.is_template == True).all() + + +def get_events(start: Optional[datetime] = None, end=None): """Query events which start from begin until end Args: start (datetime): Earliest start @@ -113,7 +121,12 @@ def get_events(start, end): Returns: collection of Event objects """ - return Event.query.filter((start <= Event.start), (Event.start < end)).all() + query = Event.query.filter(Event.is_template.__eq__(False)) + if start is not None: + query = query.filter(start <= Event.start) + if end is not None: + query = query.filter(Event.start < end) + return query.all() def delete_event(event_id): @@ -129,15 +142,13 @@ def delete_event(event_id): db.session.commit() -def create_event(event_type, start, end=None, jobs=[], description=None, recurrence_rule=None): +def create_event(event_type, start, end=None, jobs=[], is_template=None, name=None, description=None): try: logger.debug(event_type) event = Event( - start=start, end=end, description=description, type=event_type, jobs=jobs, recurrence_rule=recurrence_rule + start=start, end=end, name=name, description=description, type=event_type, is_template=is_template, jobs=jobs ) db.session.add(event) - if recurrence_rule is not None: - event = Event(start=start, end=end, description=description, type=event_type, jobs=jobs, template_=event) db.session.commit() return event except IntegrityError: @@ -185,18 +196,3 @@ def assign_to_job(job: Job, user, value): service = Service(user_=user, value=value, job_=job) db.session.add(service) db.session.commit() - - -def create_recurrence(data=None, count=None, end_date=None, frequency=None, interval=None): - if data is not None: - if "frequency" not in data: - raise BadRequest("Missing POST parameter") - frequency = data["frequency"] - if "end_date" in data: - end_date = from_iso_format(data["end_date"]) - if "count" in data: - count = data["count"] - if "interval" in data: - interval = data["interval"] - recurrence = RecurrenceRule(frequency=frequency, end_date=end_date, count=count, interval=interval) - db.session.add(recurrence) diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py index d645d7c..7479df0 100644 --- a/flaschengeist/plugins/events/models.py +++ b/flaschengeist/plugins/events/models.py @@ -67,32 +67,12 @@ class Job(db.Model, ModelSerializeMixin): event_ = db.relationship("Event", back_populates="jobs") event_id_ = db.Column("event_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) - __table_args__ = (UniqueConstraint("type_id", "start", name="_type_start_uc"),) + __table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),) ########## # Events # ########## - - -class _Frequency(enum.Enum): - daily = 1 - weekly = 2 - monthly = 3 - yearly = 4 - - -class RecurrenceRule(db.Model, ModelSerializeMixin): - __tablename__ = _table_prefix_ + "recurrence_rule" - - frequency: str = db.Column(db.Enum(_Frequency)) - until: Optional[datetime] = db.Column(UtcDateTime) - count: Optional[int] = db.Column(db.Integer) - interval: int = db.Column(db.Integer, nullable=False, default=1) - - id_: int = db.Column("id", db.Integer, primary_key=True) - - class Event(db.Model, ModelSerializeMixin): """Model for an Event""" @@ -100,19 +80,14 @@ class Event(db.Model, ModelSerializeMixin): id: int = db.Column(db.Integer, primary_key=True) start: datetime = db.Column(UtcDateTime, nullable=False) end: Optional[datetime] = db.Column(UtcDateTime) - description: Optional[str] = db.Column(db.String(255)) + name: Optional[str] = db.Column(db.String(255)) + description: Optional[str] = db.Column(db.String(512)) type: Union[EventType, int] = db.relationship("EventType") + is_template: bool = db.Column(db.Boolean, default=False) jobs: list[Job] = db.relationship( "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" ) - recurrence_rule: Optional[RecurrenceRule] = db.relationship("RecurrenceRule") - template_id: Optional[int] = db.Column("template_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event.id")) - # Not exported properties for backend use - template_ = db.relationship("Event") # Protected for internal use - _recurrence_rule_id = db.Column( - "recurrence_rule_id", db.Integer, db.ForeignKey(f"{_table_prefix_}recurrence_rule.id") - ) _type_id = db.Column( "type_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False ) From b98bae337ddf602c5e1e176a455430ad7b8f09e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 21 Mar 2021 22:06:24 +0100 Subject: [PATCH 231/446] [pricelist] add modify settings, fixed update drinks --- flaschengeist/plugins/pricelist/__init__.py | 13 +++++++++++++ .../plugins/pricelist/pricelist_controller.py | 19 +++++++++---------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index cc3ec44..1d24715 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -179,6 +179,19 @@ def delete_extra_ingredient(identifier): return "", NO_CONTENT +@pricelist_bp.route("/settings/min_prices", methods=["POST", "GET"]) +def pricelist_settings_min_prices(): + if request.method == "GET": + return jsonify(PriceListPlugin.get_setting(PriceListPlugin, "min_prices")) + else: + data = request.get_json() + if not isinstance(data, list) or not all(isinstance(n, int) for n in data): + raise BadRequest + data.sort() + PriceListPlugin.set_setting(PriceListPlugin, "min_prices", data) + return no_content() + + @pricelist_bp.route("/users//pricecalc_columns", methods=["GET", "PUT"]) @login_required() def get_columns(userid, current_session: Session): diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 1544d97..e739db1 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -128,25 +128,24 @@ def update_drink(identifier, data): allowedKeys = Drink().serialize().keys() if "id" in data: data.pop("id") - values = {key: value for key, value in data.items() if key in allowedKeys} - volumes = None - if "volumes" in values: - volumes = values.pop("volumes") - if "tags" in values: - values.pop("tags") + if "volumes" in data: + volumes = data.pop("volumes") + if "tags" in data: + data.pop("tags") type = None - if "type" in values: - _type = values.pop("type") + if "type" in data: + _type = data.pop("type") if isinstance(_type, dict) and "id" in _type: type = get_drink_type(_type.get("id")) if identifier == -1: - drink = Drink(**values) + drink = Drink() db.session.add(drink) else: drink = get_drink(identifier) if not drink: raise NotFound - for key, value in values.items(): + for key, value in data.items(): + if hasattr(drink, key): setattr(drink, key, value if value != "" else None) if type: From eb1e146da92742757746b75c88ebb02aefa383b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 22 Mar 2021 23:17:26 +0100 Subject: [PATCH 232/446] [utils] picture creator --- flaschengeist/utils/picture.py | 38 ++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 flaschengeist/utils/picture.py diff --git a/flaschengeist/utils/picture.py b/flaschengeist/utils/picture.py new file mode 100644 index 0000000..968e764 --- /dev/null +++ b/flaschengeist/utils/picture.py @@ -0,0 +1,38 @@ +import os, sys +from PIL import Image +from flask import Response +from werkzeug.exceptions import BadRequest + +thumbnail_sizes = ((32, 32), (64, 64), (128, 128), (256, 256), (512, 512)) + + +def save_picture(picture, path): + + if not picture.mimetype.startswith("image/"): + raise BadRequest + os.makedirs(path, exist_ok=True) + file_type = picture.mimetype.replace("image/", "") + filename = f"{path}/drink" + with open(f"{filename}.{file_type}", "wb") as file: + file.write(picture.binary) + image = Image.open(f"{filename}.{file_type}") + if file_type != "png": + image.save(f"{filename}.png", "PNG") + os.remove(f"{filename}.{file_type}") + image.show() + for thumbnail_size in thumbnail_sizes: + work_image = image.copy() + work_image.thumbnail(thumbnail_size) + work_image.save(f"{filename}-{thumbnail_size[0]}.png", "PNG") + + +def get_picture(path, size=None): + if size: + with open(f"{path}/drink-{size}.png", "rb") as file: + image = file.read() + else: + with open(f"{path}/drink.png", "rb") as file: + image = file.read() + response = Response(image, mimetype="image/png") + response.add_etag() + return response From dca890dad96e3625542a92f136023927db561f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 22 Mar 2021 23:17:44 +0100 Subject: [PATCH 233/446] [pricelist] save and load pictures --- flaschengeist/plugins/pricelist/models.py | 9 ++++++++ .../plugins/pricelist/pricelist_controller.py | 22 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index b98acba..3d7c452 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -137,8 +137,17 @@ class Drink(db.Model, ModelSerializeMixin): cost_price_pro_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) cost_price_package_netto: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) + uuid = db.Column(db.String(36)) + _type_id = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) tags: Optional[list[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") type: Optional[DrinkType] = db.relationship("DrinkType", foreign_keys=[_type_id]) volumes: list[DrinkPriceVolume] = db.relationship(DrinkPriceVolume) + + +class _Picture: + """Wrapper class for pictures binaries""" + + mimetype = "" + binary = bytearray() diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index e739db1..d2a88be 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -2,10 +2,13 @@ from werkzeug.exceptions import BadRequest, NotFound from sqlalchemy.exc import IntegrityError from flaschengeist import logger +from flaschengeist.config import config from flaschengeist.database import db from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient -from math import ceil +from flaschengeist.utils.picture import save_picture, get_picture + +from uuid import uuid4 def update(): @@ -366,3 +369,20 @@ def delete_extra_ingredient(identifier): extra_ingredient = get_extra_ingredient(identifier) db.session.delete(extra_ingredient) db.session.commit() + + +def save_drink_picture(identifier, file): + drink = get_drink(identifier) + if not drink.uuid: + drink.uuid = str(uuid4()) + db.session.commit() + path = config["pricelist"]["path"] + save_picture(file, f"{path}/{drink.uuid}") + + +def get_drink_picture(identifier, size=None): + drink = get_drink(identifier) + if not drink.uuid: + raise BadRequest + path = config["pricelist"]["path"] + return get_picture(f"{path}/{drink.uuid}") From 1550be5da60fa747ab5db9381ad31279b50c9dd9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 24 Mar 2021 17:09:21 +0100 Subject: [PATCH 234/446] [app] Secure plugin loading --- flaschengeist/app.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 6ec40ed..1ac1dab 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,3 +1,5 @@ +import enum + import pkg_resources from flask import Flask, current_app from flask_cors import CORS @@ -22,6 +24,8 @@ class CustomJSONEncoder(JSONEncoder): if isinstance(o, datetime) or isinstance(o, date): return o.isoformat() + if isinstance(o, enum.Enum): + return o.value # Check if iterable try: @@ -40,12 +44,17 @@ def __load_plugins(app): logger.debug("Found plugin: >{}<".format(entry_point.name)) plugin = None if entry_point.name in config and config[entry_point.name].get("enabled", False): - plugin = entry_point.load() - setattr(plugin, "_plugin_name", entry_point.name) - plugin = plugin(config[entry_point.name]) - if plugin.blueprint: - app.register_blueprint(plugin.blueprint) - logger.info("Load plugin >{}<".format(entry_point.name)) + try: + logger.info(f"Load plugin {entry_point.name}") + plugin = entry_point.load() + setattr(plugin, "_plugin_name", entry_point.name) + plugin = plugin(config[entry_point.name]) + if plugin.blueprint: + app.register_blueprint(plugin.blueprint) + except: + logger.error( + f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True + ) if isinstance(plugin, AuthPlugin): logger.debug("Found authentication plugin: %s", entry_point.name) if entry_point.name == config["FLASCHENGEIST"]["auth"]: From 7d692c5f686486dc110ea97c91de771c9271db69 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 24 Mar 2021 18:37:53 +0100 Subject: [PATCH 235/446] [chore] cleanup --- flaschengeist/app.py | 12 +++++------- flaschengeist/plugins/events/__init__.py | 4 ++-- flaschengeist/plugins/pricelist/__init__.py | 5 ----- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 1ac1dab..2bdb2bb 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -16,19 +16,17 @@ from flaschengeist.controller import roleController class CustomJSONEncoder(JSONEncoder): def default(self, o): - # Check if custom model try: + # Check if custom model return o.serialize() except AttributeError: pass - if isinstance(o, datetime) or isinstance(o, date): return o.isoformat() if isinstance(o, enum.Enum): return o.value - - # Check if iterable try: + # Check if iterable iterable = iter(o) except TypeError: pass @@ -56,7 +54,7 @@ def __load_plugins(app): f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True ) if isinstance(plugin, AuthPlugin): - logger.debug("Found authentication plugin: %s", entry_point.name) + logger.debug(f"Found authentication plugin: {entry_point.name}") if entry_point.name == config["FLASCHENGEIST"]["auth"]: app.config["FG_AUTH_BACKEND"] = plugin else: @@ -76,9 +74,9 @@ def install_all(): installed = [] for name, plugin in current_app.config["FG_PLUGINS"].items(): if not plugin: - logger.debug("Skip disabled plugin {}".format(name)) + logger.debug(f"Skip disabled plugin: {name}") continue - logger.info("Install plugin {}".format(name)) + logger.info(f"Install plugin {name}") plugin.install() installed.append(plugin) if plugin.permissions: diff --git a/flaschengeist/plugins/events/__init__.py b/flaschengeist/plugins/events/__init__.py index 0194c7a..91deac0 100644 --- a/flaschengeist/plugins/events/__init__.py +++ b/flaschengeist/plugins/events/__init__.py @@ -193,10 +193,10 @@ def get_event(event_id, current_session): @schedule_bp.route("/events", methods=["GET"]) @login_required() def get_filtered_events(current_session): - begin = request.args.get('from') + begin = request.args.get("from") if begin is not None: begin = from_iso_format(begin) - end = request.args.get('to') + end = request.args.get("to") if end is not None: end = from_iso_format(end) if begin is None and end is None: diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 1d24715..5a8a831 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -24,11 +24,6 @@ class PriceListPlugin(Plugin): config = {"discount": 0} config.update(cfg) - def install(self): - from flaschengeist.database import db - - db.create_all() - @pricelist_bp.route("/drink-types", methods=["GET"]) @pricelist_bp.route("/drink-types/", methods=["GET"]) From 6cf33976b383f48b747df8e2fb720d3eac66c357 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 24 Mar 2021 20:47:04 +0100 Subject: [PATCH 236/446] [events] Changed routes --- flaschengeist/plugins/events/__init__.py | 44 +++++++++---------- .../plugins/events/event_controller.py | 8 +++- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/flaschengeist/plugins/events/__init__.py b/flaschengeist/plugins/events/__init__.py index 91deac0..3e955df 100644 --- a/flaschengeist/plugins/events/__init__.py +++ b/flaschengeist/plugins/events/__init__.py @@ -17,7 +17,7 @@ from . import event_controller, permissions from . import models from ...utils.HTTP import no_content -schedule_bp = Blueprint("events", __name__, url_prefix="/schedule") +schedule_bp = Blueprint("events", __name__, url_prefix="/events") class EventPlugin(Plugin): @@ -42,8 +42,8 @@ def get_templates(current_session): def get_event_types(current_session, identifier=None): """Get EventType(s) - Route: ``/schedule/event-types`` | Method: ``GET`` - Route: ``/schedule/event-types/`` | Method: ``GET`` + Route: ``/events/event-types`` | Method: ``GET`` + Route: ``/events/event-types/`` | Method: ``GET`` Args: current_session: Session sent with Authorization Header @@ -64,7 +64,7 @@ def get_event_types(current_session, identifier=None): def new_event_type(current_session): """Create a new EventType - Route: ``/schedule/event-types`` | Method: ``POST`` + Route: ``/events/event-types`` | Method: ``POST`` POST-data: ``{name: string}`` @@ -86,7 +86,7 @@ def new_event_type(current_session): def modify_event_type(identifier, current_session): """Rename or delete an event type - Route: ``/schedule/event-types/`` | Method: ``PUT`` or ``DELETE`` + Route: ``/events/event-types/`` | Method: ``PUT`` or ``DELETE`` POST-data: (if renaming) ``{name: string}`` @@ -112,7 +112,7 @@ def modify_event_type(identifier, current_session): def get_job_types(current_session): """Get all JobTypes - Route: ``/schedule/job-types`` | Method: ``GET`` + Route: ``/events/job-types`` | Method: ``GET`` Args: current_session: Session sent with Authorization Header @@ -129,7 +129,7 @@ def get_job_types(current_session): def new_job_type(current_session): """Create a new JobType - Route: ``/schedule/job-types`` | Method: ``POST`` + Route: ``/events/job-types`` | Method: ``POST`` POST-data: ``{name: string}`` @@ -151,7 +151,7 @@ def new_job_type(current_session): def modify_job_type(type_id, current_session): """Rename or delete a JobType - Route: ``/schedule/job-types/`` | Method: ``PUT`` or ``DELETE`` + Route: ``/events/job-types/`` | Method: ``PUT`` or ``DELETE`` POST-data: (if renaming) ``{name: string}`` @@ -172,12 +172,12 @@ def modify_job_type(type_id, current_session): return "", NO_CONTENT -@schedule_bp.route("/events/", methods=["GET"]) +@schedule_bp.route("/", methods=["GET"]) @login_required() def get_event(event_id, current_session): """Get event by id - Route: ``/schedule/events/`` | Method: ``GET`` + Route: ``/events/`` | Method: ``GET`` Args: event_id: ID identifying the event @@ -190,7 +190,7 @@ def get_event(event_id, current_session): return jsonify(event) -@schedule_bp.route("/events", methods=["GET"]) +@schedule_bp.route("/", methods=["GET"]) @login_required() def get_filtered_events(current_session): begin = request.args.get("from") @@ -204,14 +204,14 @@ def get_filtered_events(current_session): return jsonify(event_controller.get_events(begin, end)) -@schedule_bp.route("/events//", methods=["GET"]) +@schedule_bp.route("//", methods=["GET"]) @schedule_bp.route("/events///", methods=["GET"]) @login_required() def get_events(current_session, year=datetime.now().year, month=datetime.now().month, day=None): """Get Event objects for specified date (or month or year), if nothing set then events for current month are returned - Route: ``/schedule/events[//[/]]`` | Method: ``GET`` + Route: ``/events[//[/]]`` | Method: ``GET`` Args: year (int, optional): year to query, defaults to current year @@ -256,12 +256,12 @@ def _add_job(event, data): event_controller.add_job(event, job_type, required_services, start, end, comment=data.get("comment", None)) -@schedule_bp.route("/events", methods=["POST"]) +@schedule_bp.route("", methods=["POST"]) @login_required(permission=permissions.CREATE) def create_event(current_session): """Create an new event - Route: ``/schedule/events`` | Method: ``POST`` + Route: ``/events`` | Method: ``POST`` POST-data: See interfaces for Event, can already contain jobs @@ -306,7 +306,7 @@ def create_event(current_session): def modify_event(event_id, current_session): """Modify an event - Route: ``/schedule/events/`` | Method: ``PUT`` + Route: ``/events/`` | Method: ``PUT`` POST-data: See interfaces for Event, can already contain slots @@ -332,12 +332,12 @@ def modify_event(event_id, current_session): return jsonify(event) -@schedule_bp.route("/events/", methods=["DELETE"]) +@schedule_bp.route("/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) def delete_event(event_id, current_session): """Delete an event - Route: ``/schedule/events/`` | Method: ``DELETE`` + Route: ``/events/`` | Method: ``DELETE`` Args: event_id: Identifier of the event @@ -350,12 +350,12 @@ def delete_event(event_id, current_session): return "", NO_CONTENT -@schedule_bp.route("/events//jobs", methods=["POST"]) +@schedule_bp.route("//jobs", methods=["POST"]) @login_required(permission=permissions.EDIT) def add_job(event_id, current_session): """Add an new Job to an Event / EventSlot - Route: ``/schedule/events//jobs`` | Method: ``POST`` + Route: ``/events//jobs`` | Method: ``POST`` POST-data: See Job @@ -371,7 +371,7 @@ def add_job(event_id, current_session): return jsonify(event) -@schedule_bp.route("/events//jobs/", methods=["DELETE"]) +@schedule_bp.route("//jobs/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) def delete_job(event_id, job_id, current_session): """Delete a Job @@ -391,7 +391,7 @@ def delete_job(event_id, job_id, current_session): return no_content() -@schedule_bp.route("/events//jobs/", methods=["PUT"]) +@schedule_bp.route("//jobs/", methods=["PUT"]) @login_required() def update_job(event_id, job_id, current_session: Session): """Edit Job or assign user to the Job diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 84e2887..ee1f663 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -146,7 +146,13 @@ def create_event(event_type, start, end=None, jobs=[], is_template=None, name=No try: logger.debug(event_type) event = Event( - start=start, end=end, name=name, description=description, type=event_type, is_template=is_template, jobs=jobs + start=start, + end=end, + name=name, + description=description, + type=event_type, + is_template=is_template, + jobs=jobs, ) db.session.add(event) db.session.commit() From 05aba1161b6bd2b2ed19f9b35008618a9c19d9a3 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Mar 2021 01:19:37 +0100 Subject: [PATCH 237/446] [config] Connect with pymysql on Windows --- flaschengeist/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 089dab9..ed74470 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -66,7 +66,8 @@ def configure_app(app, test_config=None): app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] if test_config is None: - app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://{user}:{passwd}@{host}:{port}/{database}".format( + app.config["SQLALCHEMY_DATABASE_URI"] = "mysql{driver}://{user}:{passwd}@{host}:{port}/{database}".format( + driver="+pymysql" if os.name == "nt" else "", user=config["DATABASE"]["user"], passwd=config["DATABASE"]["password"], host=config["DATABASE"]["host"], From 4ffa5e0e6e16f27f9466f1ea612419f35e8a0fd1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Mar 2021 01:20:26 +0100 Subject: [PATCH 238/446] [pricelist] Install pillow --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 191d04d..d28760d 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ setup( "werkzeug", mysql_driver, ], - extras_require={"ldap": ["flask_ldapconn", "ldap3"], "test": ["pytest", "coverage"]}, + extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"],"test": ["pytest", "coverage"]}, entry_points={ "flaschengeist.plugin": [ # Authentication providers @@ -36,7 +36,7 @@ setup( "balance = flaschengeist.plugins.balance:BalancePlugin", "events = flaschengeist.plugins.events:EventPlugin", "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", - "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin", + "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin [pricelist]", ], }, ) From da94acf18c72fc1d63fa87ed420097ee6f72d2e8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Mar 2021 01:38:24 +0100 Subject: [PATCH 239/446] [events] More rename --- flaschengeist/plugins/events/__init__.py | 40 ++++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/flaschengeist/plugins/events/__init__.py b/flaschengeist/plugins/events/__init__.py index 3e955df..b0c7b98 100644 --- a/flaschengeist/plugins/events/__init__.py +++ b/flaschengeist/plugins/events/__init__.py @@ -17,7 +17,7 @@ from . import event_controller, permissions from . import models from ...utils.HTTP import no_content -schedule_bp = Blueprint("events", __name__, url_prefix="/events") +events_bp = Blueprint("events", __name__) class EventPlugin(Plugin): @@ -25,19 +25,19 @@ class EventPlugin(Plugin): def __init__(self, config): super().__init__( - blueprint=schedule_bp, + blueprint=events_bp, permissions=permissions.permissions, ) -@schedule_bp.route("/templates", methods=["GET"]) +@events_bp.route("/events/templates", methods=["GET"]) @login_required() def get_templates(current_session): return jsonify(event_controller.get_templates()) -@schedule_bp.route("/event-types", methods=["GET"]) -@schedule_bp.route("/event-types/", methods=["GET"]) +@events_bp.route("/events/event-types", methods=["GET"]) +@events_bp.route("/events/event-types/", methods=["GET"]) @login_required() def get_event_types(current_session, identifier=None): """Get EventType(s) @@ -59,7 +59,7 @@ def get_event_types(current_session, identifier=None): return jsonify(result) -@schedule_bp.route("/event-types", methods=["POST"]) +@events_bp.route("/events/event-types", methods=["POST"]) @login_required(permission=permissions.EVENT_TYPE) def new_event_type(current_session): """Create a new EventType @@ -81,7 +81,7 @@ def new_event_type(current_session): return jsonify(event_type) -@schedule_bp.route("/event-types/", methods=["PUT", "DELETE"]) +@events_bp.route("/events/event-types/", methods=["PUT", "DELETE"]) @login_required(permission=permissions.EVENT_TYPE) def modify_event_type(identifier, current_session): """Rename or delete an event type @@ -107,7 +107,7 @@ def modify_event_type(identifier, current_session): return "", NO_CONTENT -@schedule_bp.route("/job-types", methods=["GET"]) +@events_bp.route("/events/job-types", methods=["GET"]) @login_required() def get_job_types(current_session): """Get all JobTypes @@ -124,7 +124,7 @@ def get_job_types(current_session): return jsonify(types) -@schedule_bp.route("/job-types", methods=["POST"]) +@events_bp.route("/events/job-types", methods=["POST"]) @login_required(permission=permissions.JOB_TYPE) def new_job_type(current_session): """Create a new JobType @@ -146,7 +146,7 @@ def new_job_type(current_session): return jsonify(jt) -@schedule_bp.route("/job-types/", methods=["PUT", "DELETE"]) +@events_bp.route("/events/job-types/", methods=["PUT", "DELETE"]) @login_required(permission=permissions.JOB_TYPE) def modify_job_type(type_id, current_session): """Rename or delete a JobType @@ -172,7 +172,7 @@ def modify_job_type(type_id, current_session): return "", NO_CONTENT -@schedule_bp.route("/", methods=["GET"]) +@events_bp.route("/events/", methods=["GET"]) @login_required() def get_event(event_id, current_session): """Get event by id @@ -190,7 +190,7 @@ def get_event(event_id, current_session): return jsonify(event) -@schedule_bp.route("/", methods=["GET"]) +@events_bp.route("/events", methods=["GET"]) @login_required() def get_filtered_events(current_session): begin = request.args.get("from") @@ -204,8 +204,8 @@ def get_filtered_events(current_session): return jsonify(event_controller.get_events(begin, end)) -@schedule_bp.route("//", methods=["GET"]) -@schedule_bp.route("/events///", methods=["GET"]) +@events_bp.route("/events//", methods=["GET"]) +@events_bp.route("/events///", methods=["GET"]) @login_required() def get_events(current_session, year=datetime.now().year, month=datetime.now().month, day=None): """Get Event objects for specified date (or month or year), @@ -256,7 +256,7 @@ def _add_job(event, data): event_controller.add_job(event, job_type, required_services, start, end, comment=data.get("comment", None)) -@schedule_bp.route("", methods=["POST"]) +@events_bp.route("/events", methods=["POST"]) @login_required(permission=permissions.CREATE) def create_event(current_session): """Create an new event @@ -301,7 +301,7 @@ def create_event(current_session): return jsonify(event) -@schedule_bp.route("/events/", methods=["PUT"]) +@events_bp.route("/events/", methods=["PUT"]) @login_required(permission=permissions.EDIT) def modify_event(event_id, current_session): """Modify an event @@ -332,7 +332,7 @@ def modify_event(event_id, current_session): return jsonify(event) -@schedule_bp.route("/", methods=["DELETE"]) +@events_bp.route("/events/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) def delete_event(event_id, current_session): """Delete an event @@ -350,7 +350,7 @@ def delete_event(event_id, current_session): return "", NO_CONTENT -@schedule_bp.route("//jobs", methods=["POST"]) +@events_bp.route("/events//jobs", methods=["POST"]) @login_required(permission=permissions.EDIT) def add_job(event_id, current_session): """Add an new Job to an Event / EventSlot @@ -371,7 +371,7 @@ def add_job(event_id, current_session): return jsonify(event) -@schedule_bp.route("//jobs/", methods=["DELETE"]) +@events_bp.route("/events//jobs/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) def delete_job(event_id, job_id, current_session): """Delete a Job @@ -391,7 +391,7 @@ def delete_job(event_id, job_id, current_session): return no_content() -@schedule_bp.route("//jobs/", methods=["PUT"]) +@events_bp.route("/events//jobs/", methods=["PUT"]) @login_required() def update_job(event_id, job_id, current_session: Session): """Edit Job or assign user to the Job From 114e12f1510c8b7a86be88e8e94314ec702cbfd1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Mar 2021 13:12:28 +0100 Subject: [PATCH 240/446] [setup] Added build documentation target --- .gitignore | 1 + run_flaschengeist | 18 +++++++++--------- setup.py | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 7718450..886770a 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ instance/ # Sphinx documentation docs/_build/ +docs/ # PyBuilder target/ diff --git a/run_flaschengeist b/run_flaschengeist index ce06a1c..610b9eb 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -17,7 +17,7 @@ class PrefixMiddleware(object): def __call__(self, environ, start_response): if environ["PATH_INFO"].startswith(self.prefix): - environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :] + environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix):] environ["SCRIPT_NAME"] = self.prefix return self.app(environ, start_response) else: @@ -83,19 +83,19 @@ class InterfaceGenerator: import typing if ( - inspect.ismodule(module[1]) - and module[1].__name__.startswith(self.basename) - and module[1].__name__ not in self.known + inspect.ismodule(module[1]) + and module[1].__name__.startswith(self.basename) + and module[1].__name__ not in self.known ): self.known.append(module[1].__name__) for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): self.walker(cls) elif ( - inspect.isclass(module[1]) - and module[1].__module__.startswith(self.basename) - and module[0] not in self.classes - and not module[0].startswith("_") - and hasattr(module[1], "__annotations__") + inspect.isclass(module[1]) + and module[1].__module__.startswith(self.basename) + and module[0] not in self.classes + and not module[0].startswith("_") + and hasattr(module[1], "__annotations__") ): self.this_type = module[0] print("\n\n" + module[0] + "\n") diff --git a/setup.py b/setup.py index d28760d..c1c2847 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,35 @@ -from setuptools import setup, find_packages +from setuptools import setup, find_packages, Command +import subprocess import os mysql_driver = "PyMySQL" if os.name == "nt" else "mysqlclient" + +class DocsCommand(Command): + description = "Generate and export API documentation" + user_options = [ + # The format is (long option, short option, description). + ("output=", "o", "Documentation output path"), + ] + + def initialize_options(self): + self.output = "./docs" + + def finalize_options(self): + pass + + def run(self): + """Run command.""" + command = ["python", "-m", "pdoc", "--skip-errors", "--html", "--output-dir", self.output, "flaschengeist"] + self.announce( + "Running command: %s" % str(command), + ) + subprocess.check_call(command) + + setup( name="flaschengeist", - version="2.0.0-dev", + version="2.0.0.dev0", url="https://wu5.de/redmine/projects/geruecht", author="WU5 + Friends", author_email="tim@groeger-clan.de", @@ -23,7 +47,7 @@ setup( "werkzeug", mysql_driver, ], - extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"],"test": ["pytest", "coverage"]}, + extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"], "test": ["pytest", "coverage"]}, entry_points={ "flaschengeist.plugin": [ # Authentication providers @@ -39,4 +63,7 @@ setup( "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin [pricelist]", ], }, + cmdclass={ + "docs": DocsCommand, + }, ) From 6dbb135621ab69b4a6a6558fca3efb1ce415b249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 25 Mar 2021 20:32:21 +0100 Subject: [PATCH 241/446] [pricelist] delete picture (not ready yet) --- flaschengeist/plugins/pricelist/__init__.py | 28 +++++++++++++++- .../plugins/pricelist/pricelist_controller.py | 12 +++++-- flaschengeist/utils/picture.py | 32 +++++++++++++------ 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 5a8a831..d53dd30 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -11,7 +11,7 @@ from . import models from . import pricelist_controller, permissions from ...controller import userController from ...models.session import Session -from ...utils.HTTP import no_content +from ...utils.HTTP import no_content, created pricelist_bp = Blueprint("pricelist", __name__, url_prefix="/pricelist") @@ -217,3 +217,29 @@ def get_columns(userid, current_session: Session): user.set_attribute("pricecalc_columns", data) userController.persist() return no_content() + +@pricelist_bp.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) +def set_picture(identifier): + + if request.method == "GET": + try: + size = request.args.get("size") + response = pricelist_controller.get_drink_picture(identifier, size) + return response.make_conditional(request) + except FileNotFoundError: + return no_content() + + if request.method == "DELETE": + pricelist_controller.delete_drink_picture(identifier) + return no_content() + + file = request.files.get("file") + if file: + picture = models._Picture() + picture.mimetype = file.content_type + picture.binary = bytearray(file.stream.read()) + pricelist_controller.save_drink_picture(identifier, picture) + else: + raise BadRequest + + return created() \ No newline at end of file diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index d2a88be..e2c8469 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -6,7 +6,7 @@ from flaschengeist.config import config from flaschengeist.database import db from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient -from flaschengeist.utils.picture import save_picture, get_picture +from flaschengeist.utils.picture import save_picture, get_picture, delete_picture from uuid import uuid4 @@ -383,6 +383,14 @@ def save_drink_picture(identifier, file): def get_drink_picture(identifier, size=None): drink = get_drink(identifier) if not drink.uuid: - raise BadRequest + raise FileNotFoundError path = config["pricelist"]["path"] return get_picture(f"{path}/{drink.uuid}") + +def delete_drink_picture(identifier): + drink = get_drink(identifier) + if not drink.uuid: + delete_picture(f"{config['pricelist']['path']}/{drink.uuid}") + drink.uuid = None + db.session.commit() + diff --git a/flaschengeist/utils/picture.py b/flaschengeist/utils/picture.py index 968e764..529d322 100644 --- a/flaschengeist/utils/picture.py +++ b/flaschengeist/utils/picture.py @@ -2,6 +2,7 @@ import os, sys from PIL import Image from flask import Response from werkzeug.exceptions import BadRequest +from ..utils.HTTP import no_content thumbnail_sizes = ((32, 32), (64, 64), (128, 128), (256, 256), (512, 512)) @@ -27,12 +28,25 @@ def save_picture(picture, path): def get_picture(path, size=None): - if size: - with open(f"{path}/drink-{size}.png", "rb") as file: - image = file.read() - else: - with open(f"{path}/drink.png", "rb") as file: - image = file.read() - response = Response(image, mimetype="image/png") - response.add_etag() - return response + try: + if size: + if os.path.isfile(f"{path}/drink-{size}.png"): + with open(f"{path}/drink-{size}.png", "rb") as file: + image = file.read() + else: + _image = Image.open(f"{path}/drink.png") + _image.thumbnail((size, size)) + image = bytearray() + _image.save(bytearray, format='PNG') + else: + with open(f"{path}/drink.png", "rb") as file: + image = file.read() + response = Response(image, mimetype="image/png") + response.add_etag() + return response + except: + raise FileNotFoundError + +def delete_picture(path): + os.remove(path) + From 6fce88c120f5fcf0c974f36d51a252a95b4e77db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 25 Mar 2021 23:05:17 +0100 Subject: [PATCH 242/446] [pricelist] now can delete pictures, add no-image --- .../plugins/pricelist/pricelist_controller.py | 6 +++--- flaschengeist/utils/no-image.png | Bin 0 -> 47605 bytes flaschengeist/utils/picture.py | 18 +++++++++++++----- 3 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 flaschengeist/utils/no-image.png diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index e2c8469..71f9488 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -6,7 +6,7 @@ from flaschengeist.config import config from flaschengeist.database import db from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient -from flaschengeist.utils.picture import save_picture, get_picture, delete_picture +from flaschengeist.utils.picture import save_picture, get_picture, delete_picture, get_no_image from uuid import uuid4 @@ -383,13 +383,13 @@ def save_drink_picture(identifier, file): def get_drink_picture(identifier, size=None): drink = get_drink(identifier) if not drink.uuid: - raise FileNotFoundError + return get_no_image() path = config["pricelist"]["path"] return get_picture(f"{path}/{drink.uuid}") def delete_drink_picture(identifier): drink = get_drink(identifier) - if not drink.uuid: + if drink.uuid: delete_picture(f"{config['pricelist']['path']}/{drink.uuid}") drink.uuid = None db.session.commit() diff --git a/flaschengeist/utils/no-image.png b/flaschengeist/utils/no-image.png new file mode 100644 index 0000000000000000000000000000000000000000..240507b03e790f6871db20f36b4cc7f0bc2c55d5 GIT binary patch literal 47605 zcmZ^Kbx_q?^e+v9h!WDFbV)aYAdPf)2uODb(y2(dbf-uo-6)`RN+=*Dh;-L)aer^# z%=_crnLGE+8O}N1v-e(WeQLi`R+PrZB*8>LK){xjkx)fIKN^b>oW@w?BtxV}%E<%xcF_|^8=ezK$}(`9p_#AYZu^9fhlFSovsIz))#V$3&Ez5EiPD4WhtWT=BylDBuZhno$<#P1w6iT+7{5kt9sFLCp_k?#^S4lYZn@cNCOy6__l zabs5F$(d{=j1mGR(W^nWy)Zmq0!B3L4Aa}ir}=ZKNP_DhLAc@?DsqW=xO>Ld-tpq4 zsc96sko*2hzd^J6pRK*$U-NQv`Mb4(xQv;3d5`;Isf(1d>feZ4)tuA1tbNa}Tuhw% z={m0PqvjPXiO0_t&$E>jt^)bwwNw^(oNUHRY{l@#H1>??3FSvgtRFvnte0`^X25T9 zcACHYj4W1qSB7Tv#cQ8c;o!rQUw-J<_b5eLzOhM1Q$$8YL_|f2yxyhKe%JclNCFwP z+Pw^m**x9xDc?z^)}5e)tahSdRK547w6BKJL$C;+=+;_>U=oBAFk6IrilephfAl4& z4{uT>%KpLl4iAnBuKDbnBFmo9mUvc2S%2rRQRG~&hUVM-MA6e@TiGI!e7Ny?ji}-f zbhNd#3txQTM|j6Sl*mlTo=h!$&yw03FD3-nxvCP!+A0LGrNgw3MF!h#wG83j--rDn zt((&>>qBA=Ce<9iDu`x%Hw5(QOKSykiKVIqauN~}tzJh|HA_oN335LTD<-pukpBHw z2k+vH{I1+Cj<>%hv)yce{)M2BQh3+ozA}1;a*lWi0$l)Ama$rY9Ic5T?-urM{{XK; zp@1OAWT&(}>UAm`E{V<8$Gy2>VgvmO+~mWE`mbw@?vV>hEHQGu9j+2a3)IT&6T7}V zdr!z_SY_I~Ia#JvuAA6(fahTLnhC8uPRQJG*?6MA7ghJRY7?2YhF}Xj15xMd?bJ?V z6|fN#R^hpplB>t3_d+{J@2cJHHy4`$()Anqgn8vGbLQd?dPb4B3 zjmOS%3yTf>4O%>Qe!@Ab(5^E5QEeWELs@^v-j`MD6Pa0!^XdQ<-DwxcL$BFlzWd(m zLm3goHd7yeG`TT<9nmf>Gw`1@D$^{t7))L8J{bHw@BALMz`B}wQS%x5Up)rDweQ4d z%-&8M$nJkHk)e;|3+8;qoZeV+wfHg0&eBxHkh13aT}qSx9KN2pDG7V{eUO_I}bvSW1RDlJDJ zPqI4RJz3X`IfwK8*>ZF_O6VVhMrp#MwLjxUjpg1{KWX(8$sz=0Sxu|Nbn)>C2vA>k zIcIUaM3Z3}Z!_%;PsuuTY$zH!y%rjNTU^|T5%C<=^@5xC`7+P?_KYUFrFj2TrWawN z7qi*<;U9IL(3|V43o&iQwSmMk*FR%8BB#dCbQVh|DlI;&XdcgE{_mG*KU+tCO87+o z6b4Jzvy<&PdS!n%_l=Pp^KVJ!0|{03IJ2guIN`e*?Vd$M-Y}RFw*F#8Cvla zs@r;>Z<`}?s+Wl04H5Vr4VZ4C*|9E36GXnS~*T>+NYu(U-AX;RI9XR3nY z)yZG$)>%*fTWE$)|7rCR=J}(4Uk+9EHmTnTJP`V?B{B;L?C=ty60`s#&h<23-ZZ1Yt{B ze1hFnJiU_rZ0(=*EI*6y$l3zL4Ui+{~Z z)0V7-?_a!l`gr}F{;=Qm*}8pu%gN6-%$gsSjr|5wdA6o2el@vmp<$Es3Ecm+zxxF> z&1DKZ9{LYT&1lO)|N3w-x69g#v)PS!$9NJR>v`XcZG}|s0AF#FS{X(viBZ(4d|bq* zb-6FP)7-JOVaX$ixuC_>8FwP-VjA9wxJSZW3I!j-OfRxT-LMMJ@j=wb6C!fk_p4QTu=DfD zXRC2K1lNHCMi{{PSc$B9_4W%*!%t82p&=sI*WKUBH)3Q9eVJXNkWd}$&VhwoSkj07 z<9^379EtV8w3ELz!+|ZA!F8imFTWPbmRXwT^QdoES){r4q@YHzNK^7TGrF~nZ4B+lSOdtdHVo?ftUQoZnAW; zW>|!jf+Ca8!Ej+b?fc-_)A5#(d4@Zc4AX-F$moC@4hNVkUSx@69GIAxzzep}&P!Aa zSX_<)rf>AYNXC=<($hnOue51A(Yc{PwRViDow0qee!54OpB-lVqk0wawNbM>D|&i> z?>%D9eE9vLX|wS`6y_!~Lxf2b#_9uky@b5N6^5<1c$4Ao@XIZBPizxl>rP)R^8h!YV6WT9nPgtY$SB$1fP#k zj7w+LSj%Sb!e`fuoaYTJ;Y%cGQG_+XU+=$FUtxB+9skv-QhYLkX`}VwPkgxU{x|~S z<2oHiW5HKLWGRfs17~mFVx%xqSM`1{{rze%b+WTKXtYe*b{WGA?8E@U6#Hv&X_>@e`(Y4{$m^@4X%jp+5(pcMok3UxOe+W9TVD2!)1DG$ zRo-Rc>^4P>X^Ny7k7lHVBYT2RZ$U_wY%t+!gRv(Y8pN}ttZ#Va}Gb#5E?$wW#|`P z?tP_9%`j^9;;~h)A_KsHtnJnb?V{!UOK)mp+?ey2`n2p=Pt_<*ct{VkaPP554VnDL z;d{2WVp;z*hqklw&p(;8KBbF`wWi(hJ2?wH7LE3x4gzumMdx+4ze)!9?h069nQk2u zwcw^Tshw5*_+%#6rkY_bp4r;>57_t3O~fRPJXaI>etF{+m+Ln=!&@nQ4pnOPmA_Sx z9k3;kwzp54@#ku@Lq5U8Br;1zGAuqcL==(tH|M3O2110F1PIm zhHSmf^gYs2_{l$+?UEIo(9J&_{zKjA>0f2Y8I!PzOlPxSt!jAz&DGdH3kVeq>NGxw zdy?Bz{<&uMggRte@ zs4Bf;981xaX*zy+yg@}LDVLF`&%q$y(blxI{J}~;mswv7%$FIn*0SfYeEA$ae%Ici z9_j}$!@bY{6e(p0(mGCL_KWM))UOcnTw?FW3&L)9dv{w%=nywaB~79^D`WrKG4z7`0BL zAn_5}>%P(#+xwpI6gi_3gqqD+e| zEm|Bn_uo?H*kz>5>*z$I-Ah0e_$1Z931O780=@D0HxFZIr5i^yq90il^*P>bW#_y2 z2};H~&@!NmaELikc)oN5=0YP<*;BPgUhip=Ba1)_8@2h=NJmF!0q_7C_ysim&FPAp z{cmhqpY(r>EnAbf@+pDxQ=(~{7p`-BHciV+KuiK4YaftX<}e`FZd|l`WE-0UotAyZ z_}I@|uh;#q(L=W88xLT)!wK1jp4l;3B7P6|8%knLD|TB=-CbhNY?FtQJCS|Q=i|)iwPGE(7bD2d zQyEvMO}azx5PO~c^Vt}Ar1jE1{7=_%1_o#Om@Tf6-P%?2PFtBy4c&S0BT=Vi2`m+N zlLN)Ee3`N(6zHhT22I?lMu69V+)vgT^mO6wSstVXkNoJg)J>wIYH;`^GX9RO?KD^7 z9j+yA=w-ilA&6U8>{`Y+5BCx5K~|&jXR&4rF&R zb@hp}27+OCj@HnMKlMiW3598QcG`o?bLtn(ob&w zDNB;pspYZgAC*lclI;Rm0(u3?SLo`5tdqRTZ4KB*pPK&(er{K53Q~E2BQi>a+GEwt zZ)ZwIPQC!`+4Ep6=Q*YCsYx2I9e@83lK!XZm@xMp+TdE49{p%7_evBeBQv+2Rzz09W8p@0K|k-5C1{# zdok6|Wav;8d{6!@;E)Td7b#`{H_HZ2fcWBAxBYZ%YbfuJ)4M>l zxtp7Pr#Ca8b(}zt)^GK4laT0v$=Ru_!%fU-c0ZJduH-gPj@-_O z)URTnAT(?`rA++_!>3--St33US+xECteG3{yY_$gu;n(uc*zp_uH2ba)Yto0o6pOa zFVncK_F>K@GpeB};L5Ke5J|?LhQ~3sL?NU86mp6S?bUprI$95iz9`dc6HffvYbX*B ziEcS_Z^1M9VRxTY^E_Vm0do6Swc064RB;v6zdDs;4qH!tB0o1tjZvOJ;%WL*n5Im{ zu7%7K9>=Q38eOMx`b&`PD7`iwok{>pZ984YGVt7-V0^+)`T29o z37zim8{ONt5P#!|~a?)HCX*xEYpGKo%R1B)0Nuw0SR54SS7@6`R z$Y%t9dF|4J{uD-0Q&>M8>fD$N&yhPQ@O;E3l8c?PtE@76{o_@Z50rf4-G7YVCy#W8 zmW(H41qlTMF-m0Koj)Kia{B!RWXo$1*kI2(#DE)DTMT-EDx`em7rW}<7ay5Lu_2Qh zq7ti&wOhMlOs|yby^$04C4#tnZ+EHtR;EZP=@KV4W=)y+{x0WdnOkN7ak>BfUR;P7JnxPt?3pI!|`XJuQ%ovvhM?QBH#{eguJu>J8fowAMdgavkle!yBw)y5#oeRqL~ zj7BE>x)P`-w2LJLzJ>gie8VUE?N+P8`t){(ZDpCZ$Qg+80Ooi6NJC2pDc=0$V8_?H z&Wjd7dR|voG`*{Y^Po#HJy?07=9vn})iTc%Mz?8PWNEoq=n`gL{kYq%dU z?i%#sTpSXQ2l={ahqawj73Ut=tKVt9$|To(SsxZa3LPs(*ich?NO;>&_%=9)zzzhnsw$T3p;E z{56{L7a#;uhn6Z(?kIf@N8tnzP+DiIr9Gbb$MFR97HQ5d;2%hcj*St7@Qo%8r3)Y@ zE-fyFQC_daEAbO?m`XscEag88Azut}VNca1(Xc0+bpYnJJ@+{}GSX+ipWevp53K?< z6N4Nheyfw>&*C?L#b^wTGX`?jd4^lX!umP_kUK|!#!E^`S$sve`?mItyK$!ydHZQD{oCOyDw0Vxzb^r1(mgVUbOrez2cOO-Xi|6Hz)@?z-2e9d1DD_7s5HnH z`H$)p-llFE-JENY*znRTs+LEAg+&wS_=01per7}V$ZdH=Zk$9?gz+Z7y!K1S&Scp%*RU|&!-u@K zGnFN3Mdz0~hZ!naOlF`r?X(=+!{!CR5Bk8C862QQ)`{$?btRejdmi!b%QTXAC3bR^ z+QK^zL1qIyKz5g0&~&@z5l_3w+@`)yS<>z$we|15PX^7tCkr0%%gKb?Z9%>wCsmkw^bY_B0J&UaK*))7_=)qUF&i8^|Fsf_HC!+xs)Y)(_rwt>i>+FVKKjnteZB&; z>vMjn62r(}c4}`l-}DAOPfMbB9-yb$K!O#ZmD#}~m`vC3cgz~4hZ}j(n#Iau+JBBO z9hYec!uukTJ|Cb7%B5bpsVIV3ckLQmH z58nh71Um{uH5OQ-4b5WNT@(_3Iw$MR9@$b{uDMAQlRQxN$j}{H_Pziy{GP{Tu^DmB z{8+9|LEaI;)fA)FYJ9CA*$~MvFE4ME>!^2i$2mYjF+HDG<(eu>G%76!-gS>cIK{99 z6l~B+cVG@)=5LV9Ne*^ez765Pbo>q{6QBW6Gf2*SW_|Zd^|oUyX%WaJiwJGNjv^C4@-l4~J(+h& zWYREwkw;84Leyp(ZJtw(RRi;%fb-FBzRjFz1CIqbwjgAB++3ZECySnMP~Y{QlNs#X zFn=dbNfev#``I1LGqZ`=7EhPtCkCKeeT>vge6$9A^|Q80dA6hN1kfnDpN3XnXM3*$ z&?zj(3kSi!48@`R_dabYhK(RDza(nH_HU6C(O}FYe(4M%kRR}2m@OolJ)TQ+G@Y&~A7$iZY-+G2e_BpgaQf^D-Bo>`oE<{nMMNMXb z+s`>x4RW*o+?;IaRRTvEe7K8?UpEv41c{7lg?_!V)|q^Yk?+MKEdmpPdu>gW;GyHu zy|7$psxv_Dn=L$mgg~R0I)(${Fz3B;}Setdc|$zLRJfj4nPeS z(Aisk`Wst2i;Kvl;#3h)M0gkz(nNE=H5h)6si}W?u9HWh-z8EmRWAVmOD0Ji_}3_))U0^RItL`T=g&X~)pW@;TOFs`9xw#(8=eGrZ&M zuJ;`%(x;WeGEdL*|l@pi`Mj*Zv0s#O~v1qohDB5MSYCm(>9tufvVvVg~fQ8(L-8H2$$%-T%~4ZP;{B zlWrNpP;gk7jk5EZbULhA97F~_YM`!wUe{nbnhOlr>u6F-?Uj`pi~U5}56Pum=;;nW zs%d`@&o{cjBjWUdNb*kUfd=%geCE;F5m-Wh1gK5x=}+XAm1h08r++|5Eink`z%Iv9 z`r%lN|1z(oy?V-KIQkYS!ZNDQ125G3q%XiExluCf)SrI)AV_s%J$Ljm?=u*+39}FM zgNt|zic)oA4-&qq3g>gv`s9JBWG1ZgQ4UWBtN2%|JlI+`Tnr(-wvKz*{}kK*9ez%VHAIzSW9 zc%!q`IL_1yIwpD}Z;!8pfuU6hE43{2l3TC~wL;OeXal(5NDb7IamEN$F%S{S{?68I zft-DGw*DIvpCQ|*PWtU}kqgsmkE|D-V4Erra-MF_YrvUco57Pr|GtgzK$>7N2x(u< z8|zyF3ZwNpKm=IP`OgmEWo$#H#00};qeQG+tU4bi$T6W!2F;7{T5^ z)|s_Ft*pTI!;yXi?6>GC^u{~)D(+WEQ+NjdhJL}KSN|O=+uLv}wiY?B`s*ON?HlUn z?BIP~L4S0Gb%XtBafEtT6?W`|pHiZa5DqRvt^I-*Y|P{Q#r`vX*)%Di(7VJad6@Hxy#53RrHiRfS(D!oP$|(Uo5JRs?;uhIx2pM;^<$FXrrF}L zFo~A2@o%jipyrEaaL@*zwQFmq480F8BWGq$}LkJBKp0ifs&LBRIw+k322pa|H`Lp@yK1f`N>!og6Q*v)# z1w{-GP6#7edI?6?9yh#0+Ec6m$KrN0S zc%|~%l{Pt+twBo9S=Jd&0C>>hZ^Zh5V7Guprc+~ac6mJl-~st#zkpE|8GC8t>z|uo zcfbr@JROy$)Ub_j_{;{R(7ea__Lm3uXfg-n$Bcu69vu%py@>Z|<%fC%`D%eX6$+Ui z50wGp+QGPh$F>{IMNJa9oJbnLv?izjZ|e}r>c*>kd3oT>K%3BR61}8t{kI`b7@@cH0RkR($$!ey!^ZQ=<`Z#R%L`@s8Ip*50bT)= zjwoJAyLOq7elXdOm8dieYBSXLoI%L;cJu)&p{;ygdRmH zs#qc9bIyIkq>_sg5vtT0YZzSm8p)SQsY47}kytlCc8G2SR=8RW zA>WVaCv$NG;s_K+mlEwk_(RrnAY5|_&su4OQ~j_hgozgG?>1+jxePx=ruP_zbjl~CCAyH$@uhfA1Fe?|z!H)S z^B^I078?eaOXg|&@vM!k=34$cHc!I}D1r?Dooa4Ut>0wd&T%SIT@$t-yy)D`;Tmu4 z#PZ^g+@DZW`q5DsvqmEWAlgi?Kdjph3Leu@&xLtsNYWgLq!%i!V$1I+A$2B#7uvRW zyn>927nSnfitU21saa#85K3%#Bh@~09B;a}<}toPkNg}$fm$E6b2v9dimq}=!SDWj z8S7_${Qf@i5{ zX8T5>GTK?Kba8gm^pR4LpmTsxXRfFbkO{fhXoge;2rv;b0mfNjzEN{BI^k?-RXLfilwbC}eMhwElJR6kqnn~;A%Ip0}t zJp$qqyQmn4%#mkqfkx%}G41%9n;r+xj|#(4lcRz1~{}zYUtP)~Sr7m2l^oF|9I=d71Q^ zI%GieNmG%=(m*wL?2CEisERNoOr)MsxcG7GRETZ@G*e|59vhWI-#ALmsRa*Kla0i0 zN*s6hz&DsiMSLOh~@_0=0_?` zx(AZkDpEO=iVLSvKVC{mcAA3>(F&v?*a+0PPNjLg>=ic&R>n*O6_&}ruB{aUs@Y9m zP76j-KGrjM{0i=fO!eT~X`^|Y$H>=98#xj6GUOSVJn!ekWowuyCZT`FVT}@kBzImq zh5yaSDkdj0y_bzBrz;ukux;?^lJjeSKvYWD{Vx$t2v)Zv!2lsS`CvIdhOK-+Cn2+x zw+=*gLGb_J= z()W}X!mCR*gZ*bK0yu96959cWBPIdASo&Zr2AuUoq##`akK-n<#9D=u=>^-oFf;kZ zq)*;?&KYX`w;7dkVHi|pI8wb%uEshyZIIo#9fC5kk3sEB7Bge}OCqvj9gH-JvN9kEXW#8qXStg(^B2CVd??-H>i(5!}LdxMBB z6-5TkNP+FTlnKeZGfCvBKRc%P(YaKar`o0j>6tT20=}ew@aD6Vb$hhx@B~Nb)Ffiq z@_A5QNo+=KeSLjE7Yt_$4hmZzDe2(KX>Md7#*FQf6uSY`;8vEov3=_v7nu79+r5x6 z7Ls2GD4o;8cj#EC2?-q*n%qF5tEFR|1KK@=Kxr%y^THt4ZI($Iv%4K(#A>`y1>yTT zNI*6utw1Nk8E!KU5SZq#Kl1Ag>AI!!qFw;SG?0mqEEU%WW{U(;?q8OJbR|lbosO%SZ~(~B=W~8SAgYAiv9fNRx1S` zXK^ZY)p14JOTeTHrGWySRxY6s?@_MM+pPp?_R9#m#CxzO3&%I85sUiSO+@ zsA~~(0~$)kQSoPYI*FcoxxRQ2aaax*5`x3OMG&2|HdPey#!_VR7SU@@fdxVIe?^7k zsFLh!387S^1&{`J;9AQ_kP6p2d`A??o9MosQH^9giV5laPbzs*fTLJ>AYS;s{-dk< z#&bNb7vZ_QHdA-YC5;41j4y)!Q4=;kO06}$Jl(?&)iW9Gwl@Dk1u2K|V&ON(wnSys zFV2Nc^a#l)41JC!!L)Hq2PY1+apQ%pcf7t>&uHQC;sVSC4&{=cMCt_bD&DI9m1lWD zN^XVh5d-S$Am?^6dUOkoi|xeqLYFaDRS7R;7-x=IuzghX#HH zt&tshvJ;=o=DnbPd{Kj?;}q|-vsmCevA-jAjL<5dfZh!EE$o{ z8e7wscGap;rWsKP-i3hcMhx3f4JG^UgiT}h_##(?lD}OcSR4ePJWxJ(kVEketC(5b zkSwmS0pq?^-R|x#{7$BZD~LM25}yRAC$_kaTc(7DxWLvyj!P_e@q608%2H90&a5=;`4B>Wa8L7vxg7zfeJ>c+q;XQyI@!8CbGl}EvZtUOnwL@7xY z77_v=mme;&9Nst9a{o|prAfoWcL{QUNp_Q{MMf@GL@QIPzd2FNjP9E>*Jb9q93$-U zAfbu7KQy$OX3Ex|^sy25e8g6d9hRo_!S4-?9Qc&X>P8#rUV*(WB=aWeiNU}tI9e?d z9U$eqRPMejHM#DSyU#Zv8xVVzIMGncm{JGPvyk5lUR&jT1R<=-#0t8 zfDU5(Kv5U8CECBc*U8`bbpYP$ZTg<>nv{#gozbyOy~pq)zSHoctdzUBOs4Z)V@MCC zp@2W(n^t1pM#`R&0)`O(L)uiYf-ftQqqu0F-ziq#VUWnk`&6i+mCqOAPBv1twgHy( zhm-_iMO6v>v=R{9OL$#&nV;(N+h`&mNekpW zh#^0d|Fg3qClB5hB;*J&7_D7a^_OyjRZ4h#z&ItoJG6^G9bTd-QEkhY2BjPz1ZcnO z5|I|}kC+^)DQ~$6UqP%(x6&wICKgOI%!s?CPe|r$lctA7Z(3drQ&gsy-e$o=y-cwm zULbaLVdq_S8ExS3j?-F>GaH9i0GP})eLrvaWGfKqO$MI>MaNfa+pA_YWQ5o`usH7t z>VvGmBlIQtR^5WG;v9z&XfC>G^w6pI&C&MUH0^*qFA#vqk(}pN%OG_zOcR#pW&fGL z$dgI|Qxn;#82SbfIeq3rT@E|~i{KCXpa_F}gp}8T{zU>v%riW_rYGX1FF(DqC1ht5 z=|B1PC;3Tgz#UpmG|_T}<#QxdO!W%Gh1~^0!bkK8Y&z9u5IO@a9BzoXH**9?=tyb@ z%dt+=pi_^tEGR}AFVuVE(JuPiZU#GKX7fI8<==?E2!G^a=y=zj zi7-nCK`~DH@#Dt;nu)}~N*K4>2 z^1QQFM%_?SMP=~s?LY@MO%>&ixu-i=J;j!~Wten&i?=3uTrfkuJl>A+h(9C9mW`+L z>H*eRxfNoGI9t%?_b4OezF}>5hqCJ{TD{S+^m%}%(ho-8zq-Y`gf~Vwcngs zStM>QhhAnNA~%bylyIb@Jg9uD@6c6JQR<^?L*=Kb&^f5{^RsdEgFl*L4hhZ5r?Fg;Li~n>EC_kA8uoK z1ovF};_0KiQXrGWqQF&X)xSmMqszXTp@}Jy-mO-BCK*amm-9ZcgHm`f^J(+yIM(A^ zvH~)kaUDpdjx!)BTrT?g?ra1vvX9d>2mnO~xi^4fN+ z1L@Q2rxC)#^A!dA_eQKv<#^3rW!j9}%@VF5MzMnL@sC&IAd|$I9v^_9O~d z!QQkrtSBKSHDD?OjH|3<7uy7~an|hq6t|=Q;toEb3AN4_37uUjfjH|*Pmd-*D?}7| z7SnF|7z^=7NPcnc!a#2JG8T=&%Q&K{!auejRRtK)Y$vuQlhOn2vk5>$fuz+1cyN}< zRI(0gs1do2w}^GP{SL~xD{c4|jXKhk6swZr?-2F9P0diDUT#4kn1iK8q9CP!Y6v3F z1ho^bZ#?vx3Kw6QSUyv?@Ln>yIN75p zD`>JbW0;lV9Iw;G-_|aah|4v8zk9P-`RNWNw`N7R)|ocJXo1h(VSM>XG~2_uuZfGz z5An4Xc^yp0du(^tX{;1`Pahu?!vR*mNa1)%L}FDEQ<2-kTkF6HhRHqllka>hB>ZmS z$5e6UOl86lm*gh<&5IePAo%jjU3QfZI6h8|Svk3!>VRKLu|?yk5XM1Vj3yWQ^6Y(_ zf3&lffwJq<&525r=gKee_+dhEn&Efoh;P6}B1`6%a&6X=Ofz928$z;Kf$o!^3`jL@ zVAP;yKl+R&ekbof@i|2k7)&maP4?T~r395xi9GS)QVyo>-D>>1?O+9vR_8v(jtYRE08b{8jJvdytOMtL3uWjL~|SStuz*c z-e1ra6GjNBkE59}Y6+*0vyq;FIxVTB(qHEN(afKL+ipn7Q4~*`OuEsGtQ8Z&a+xRY zT-!Q58bVL7MnywMlkLZ+W)$9BXy$W&N)+4$<7YT`KA;6cj@D(gr!OI|5MLN2YzYDu zSRhRV&l){;4DVgPcxJ28k|gQv96HE|lzdMU^F5X8rFHUZf%(@+^m zt9V}vhx1aW%0-?7)3L%+W|MTo{ve3@dS9?&P!C1iM9Ai{`_su2nk-(sRJu;RI%{+J zS+=9bc~4MEF*VT_YF){nwTmDwJ-C!p0k?{_M=QnJ%xncT<&QJYl!%3ZRV#FA3ZoD$ z1-4qx;07e$dg2$A2#N_~uu%5ZLS{0tCL=5B7ukSq7HgKLve_@l@2*ZNs=6m=rsOxq zpkzRR?#rj?b1!yCny7)+S-ls)ddLRYGF)k&mhlPE^EZG?AE(S0LGF~ZsrAk#GTA+Z z$C{$6aASwm`WP(lZq>63u-U;I$c^LjWxhh!{b~m@fvH}|;(N+NR$T_^ck!;wmKb#S z)O$eP*tfg8yVV#Ym;wY0K7m>!a^o6*OzonOBDe}i-H>cTIKusCW2$@(LQRr3x7WQu zb?zXe2Sb^azdJp7N6$qf`FtK6FXb{x#5P!Otk}+xV2r${AvUw09opZV*c+?se1MBv zuZ$;>3K{&`hTB&dA=L<5*fIez7{L8=4NfnM!vCl#69^2*Comuias*QfIMaZHnxlXn zFOm22IBdVSmjHN3$iGo2whvmmbL+T)1-cJxt)%o+mUSsgr&_>aG_Q;%;t<~o%y#P_ z)}AU+qYp?;YNdDe!y2pnp2JzVHkd|#A0>9D;rG4X&*&UYV63mbNB*(~o~J^;$Odk9 zzGRKw*SBxq!bQ0}=c&DquQENQiX_xbn zCF4SYPevDnvk?4Z?%eDiNW+H zdGVcM@V+6|f)equS_ zL-;7${CIpy$)Cy+gY@X?B=a#7ZiL1Kla0pY?X57#kixYN)vbSt0g{)oC#D^z-E8%& zsjA9)skn1EJQx?6t;8P^SxfGHY~P~i(};P@UK$F3?!i$z1o>l>e2mh9O@A=3+Pn<{ z-VUKz24UeA@es_Zx_OaBtr7+{6-c}#?!Sba^_4Q^CSe7-rA3P~^hX5Jco*-ZqRlH_?Cuf- z2lIBAoYJ9&I*VNHc0*9BqcEOS%n&Q6(ZzRkZtoZ9^o>RMZKcWBNP(INhMIKwe5^?e zo$I_rO72yWq?Y(!97t|iFr&kS%{Ya;$8gbpDG<8h^*jCUQKMz*>%ok-dZ#!KNHtjh z{5?`g;FjEk?;%5nv`I2%417)It13H(pkF-+ayY0VgQ(H}tayK=jVs;2 zHT?k)U9bQVU8E7w@SUrxy zjWI@Y5_6m(UsJ$lKa>x#2VO^A>(St9;hBiYr1)FO5}8Z{i|?aht6=E+Y_++!Xuq2K zv!$CO^Ij)jRRe#u54P6i;_oT8X~0J0vb`*j=xF+f>$mEeXN8$CuKg)2GZiYF3av{fDj0t zawq6?w+Tln4?Q)iB;CRn#E!R0HqZ^-MrYbnjDpojq6-3yg2apYB&C8bn{#d#l|%(r-Q`Q_)HR_K53VBlMNr^`lV#6DvuOP@}IOu&Of6R8TRXI-&Eseucr zQTlC^!99@axWjDTGUBbtu{1fy#mubPV+Nq?upAUF2bl4V%&~^-!i4Wx_>NuCNF~4O z2_}nHnI?A6(hGPde_@m2v2R;)osm(94+e8Bm*)rxCp!Cq!s$1JuFZo{CE}M&%eP`* z|9f0ga;pSCP8bqW_64NYq2udW-}bxG$q%fF_A7e<2@#WEQBS$l$>SPzDqBnakng4S zxvp)5EFvWu)y^kE^_HBU@*--6V&)@H2Id1eElC-iUP(?nM{akSxwtCEiM2FS=6<;A z9AUy8JoV>;UHP$`vzdx&gjW{*uz+!s-5l?LR=&)G#Jm>mtwzZe39Mp&zX@@t}91qPj9`S1ykGf>HAg z+zsewZJ;HHiH0ENAzFrOWj{mw^mil&`Kx+tfyU&bS#L>?tE>6R*- zye7+nOLsNnGb@CR(O?WV0d{?Xu0BEP@1!&OVV%tY|0W;f$X>N*V#v3ll;;> z;v$?uo3K008}yrac<8;o*@>8?EYI@`EE$4|LJL*<->2nd-`gs zgWg9*PEr!QvJSi&Ld9ofVv@TV!*e^xFBurklXlCt%I%q#Hj#ZBUY?6dDap=QAD$3{ zBHZ;Riz-8M*zM}Eq=cc0+P;>dC+hqbv`%i`*Dmy`apEYp|5Otsu5}A0odR+h`NzTy zEQZo`GNr3%W@7BPcMcqaa#XZb#2#Yh7S@`_wlz#fI&xes(0RHVTa;jYtT$iZ*>`i! zj*Rfvnn+NRE8vmj?61>mQ1PcBY7|!7DZ9fFi7ux3ydNv4!ApX;M1%Lk(tfb2twVm1 zS2pS-sYX!;=L(yyZ(7~w>i#>6+`Ixld8OWe{9`c1NxFK)WcLi1I2D`TiZdiu1WL8i zy2z)*{oo@}-rlDf(^h^&(%1ZH)cqw6d$FCfY}E$RgYpHG)Kl@9q0i*@}xzDmdjQx;2hg`E6NC4Y80oIk7 z98Mx)RTBHJ4C^rAHySBEZur=eoCM#b;3i$qlMSW-1pWT@nLy_shagm4-{jD;NhThe zFMwFib2tg5nLq@ zugon(D>zXDErmq>7IIf~nLOMZhh8*a-{2Il4#d~lbit{1xcN2g%DROMKCN9YPcU#u zc0Tc`xD4HHN0KtX8em^@hGR!M4+BHrgajBl!2td&BWCh96REVp_!n3iLkfozf=75= zbi&fnC{X*LlXyVZdiUE&eTuKcC8$?9&)s}b7dtU{+>8o$t-kfR;*1l%d z>1&Sr{~ypR!`a0QM=i-zk)oIzZNIA?|L2ABcz^d>s#UPaIQ{!^g$IU)qV440s7LFJ z&EHkAP@QfMzlxY?BVhsqPJ0~6?2K;Up;6hH`N*geYW7`U1*}f#9D{crL2xang2Y6? z`QU+)B@fHZZEATYC;cX@_q{^o75nq+skP!s;J4<)QXVH$8{%r>?bqG27rLY-Nr6xP*05vMwOsCVwR??O zCMiD1(7xTdJl_fVrZ^bn+0*UzY!2ali&AF^`rz+-7UsH^Z6#vUgDr1lWpZ3xk@Fq- z>{C(~>P@xv))A@kO!M46Ht0tBi>t^8&(2T%Dk}^bZgpEk3THzPNPU|&|G?x_UwMSg zzxG>ei`hPyZW|;+OJ1t!xXV-;U`ciJEMi@Ce<%zbXz6J_Ls|N3G{0=&wiGq@!%~AR zSZzYaaGo`y@Njeg^guXkEBlQY`{9jZV{Oj03p(ms{T+v8wqYU(?@~PNkjjS9&}jn~ zG=imZM(3yHfnSN|(xJ58ss1BbrsMVlbu8R%@Adn~7FABjF3st*RXTbFWrmHPQ1^yF zv_0K9O`dd5f4}IWnZxX8+>dIelc8klz?; zy>6}xYZuc>PmrWmWapRJH7et%lE%(PSKkui7&PIx`0aPy9MB)58VJ*T7(XwVXMWG} zcf&7ZI9srqojCxbQb6m2L_jP4343)&Ty4Od@NL%vMC`$~@`|p#{A^3l2CDT8a!Cv~ z%;1m6IG@;KN9juIxmda9NVNF`Dl$!31oA4MH|^tuyQ%K6CHpD<5sQ8&(HY)qgN(pX zKpiB^ywf7Vin;Hep@dqMY_k9SFqY)SQVU0ZhKYJP&z!&)Jq_HZOjPCZv@Q$`r4CuGVaqZE2n3;1w2mUDykHu_>g=L?2|{#wwBMP-24BSRT_CHDr0k{wY0q#5+^#v zbt-47!~aRtu-Tatz%*;T)Rnm2tL9GKb8OKt?~~OPDEu)6zxRy?84Y8^zBF2~>g0=` zERAxAq%+rziIm5}gW~sD-dvkSBi0`F#TPrHA-0&Ltud+y>m^pwb|Nsf5w?GE*P;5S zQOkXws?%-hGsgQ}a$Ta27)9n5;jP7QMzH>tu!{vuKho~};PI2Awzfv$QI(v)b(Qo! z|MDA^2<@KF%Su-A7;M><6ym0UvB!O{577Ra@r~#7x+1n#`6BdNKwOs&2o@M&=(9EUh9{nxUv2=Ag$v- zP)hTSQyqOf?lx=uv4tp2S4mM3^W4o;o{nRv2A6K~YET@Z;z{JavTr9?o~6Oz46+!> zQv_AiE*^MNqy>_Un_B$498ul5RK+-*@7xu$0$zVKCwNWYEOeeRGrXFRJa`>%+>W!q zR@~(7q(=--G!@UyXtp57+eh!d4k7qDb1-!)DJ%a}bQ9O|_G9U#uU48)D#Sr%mq6FW zUly9%=fy}C;veRVb0N6}(?K9(>G>S_0Vjg=EQwOyri!v+X>4#M$ z7P%6g?B#qKcbO57ca3UFSHTo*ekbHFyCVCSIwxPj<$(D`Q#hK{^2G8jD^~3~{qq2B z<@EF|*!R1}R&k~F-1Hw#pCmp=)=4g^pnTB+jwttqI&0{#!xAG7xWUY%ul(6yIj#Wh z8cfyOBA^Ktsh7Q-O<$Y&={TKBgma%c2W;|SH3Kw84YU?qJBmTI0<0JCa@4>_R{0FF z>D+Y0$~jUYVyN<2Z5CKdkS>GA&9V%&9y}w^-5Jx7$?ClT@kZ}6wuOZKD9LC6Wi$j~ z{o9iR%Aj4VW*=8n^vmFefhvOni!D71+I)%OQb%Qwkksl!Q2l^_B@UeeuwsLF=xnlz zXP5~!3WH+Y)ScGL0|{S$e-T{SWTF;R!3Pt&o3UPcCk!8eum>h{=)qLtrpHlz&JpH0 zw17@|-eZuRF4a@4We~RBa?0uGjDdc_`Ts;W*knmZwxu993~?(5Pz$!-@9gcUwlNj< z`Fa3_2HZ7ZnQ>d`$f!b2W(@^zAggGHCr~@|p32@x8wLqEyaSsv*cHFyU_6g=Unn|0 zZ33FM%9B8|0+}%A4zl=!+|g2=n3V+!kp(bzvlJhLKeZMb|F^-C;9#c8=BV_m?um@# zYX(0}!z=JA6k+~SPX#Be)b(z$RcQy-95)xa_xhv3c&ORFfxZnn+i=}Ne{wEkn@AT* zaN|JuUy%Qp?RVs5I(rrtp!nFRGNvAB6u;B&;d@F#NJ!s&mR*|r>JPuf9S5rW_S&`Q z5x43KXR#>$?f_-ar^yH?mj3IgL*)ZLPw?(sM0dW^pN#NWq~D@EHDk*VqcUHEP;?1D zWd%U`_Wt|8DHOj-99`hsREtJW`v{RW`uZu60DGHa%bl4#qhb`&E3Vz6R}>}e zc8*yU7}PvnFliPQ4Uu`_9}D9<$ltBs9O|0}T(5I=`4&|Qel?)f6L z&V*7vLIRa}Kw8}rzSn#D;L4K3@3E?4?j{V}Jw z=ek#OtWNiAtIXmIgLX6u_BjtCHD!|v3}v5uskw|udDiu+ezA{bb{;1xf-&h?iqd~} z2!*s?byu-PT0T1!qVB%{h4`XnR*~>V*B-Q$CJd1W9U-DBf##{s?@+U&zWOWUle~#| zUTh)tZzuqQQHxOIati`oA$j(t@_e-s{)82Zyg9hlBbwt;>4k;z8@2`civ_Yj=t~op zS=k}V7<=5@d!K>j73$84{ZeRXh}?SJhI@^0ss>Dc*v#^R&S-V3G%gKw<0A~VvfSO7 znG{Wa&=;?eDtn#KwkD-OI%k%p%db!A^W_Eod|i8*95d?|@!y^DD^Q< z%}EFxK{$04{KQuu)(;AZ@*(=6bj(UG)WBVb@g*`BxvAlXX?F+|>^V`=pV4?$X0|2| zPYM?(E&+a^l+T89I7~Sk1f7K&^(%gD)86{vm*LdUXGkP`6^jd6?}Djd!4L2xNc(V1 z+^kl+E^DS?xzO&-()_HCr|K?HcYFq}2-CGOaa|?@O}Q@rWlRd!1nD9C)eag=kHH1~ zyeQ<+47W4~dVR&8mKNRG{FLWI2kGf3-j`|%zp3H~r_f6ks;As?&Vlnsfff0DIKr7m z55)znz!ZAMyB_GpiE;+DqFVp#Dg^H1qQgpa4cFW0@L^~KVk%elpJ+D=E7B{t#V!CD zsCOotuS9lWsR9PPUexGF=+0`JQ#shrvqXyps8$HzB!H!|2wG@XBFJw#7Sto1$>}x=V`!^@XSI!zu|+-2 z-i&>6ak;JkWtaa9y$jhfU9i7LRS?N;dV1PVm>o;<3LLwC&-V1ddkRKn^j6xoP}mUd zsbhOHI89D|hyMEj34pID1wZQ>n(L`9Vf%Yt75?wn_wMgl2c@lIU#U<=fO`~)YV!d$ z3NY^(Z4aJMy_7fx+uKk@xk=(b-BXLi=J?G<9rl=wLfY%!;i%Z$`@wBYLM{%G7TO64 z2H+&S7%AT}Zzq>a>BUK9TPqzJah$C#vbVfzrfgBTG!L;pGFT$$O^MIK(c%9v{`%PY zNkucUxjTd;z+#&CyMss>p$zenH&a+UxDq@e(;`Pv=>8q}O*K5aB^VRqKkp!oh*Px; zCRbrP`ZQc63k&bD{_ZX|mK(W7JPY!rczl6*9O+cC0AVa`86=lGe{Cd|Qh>N3o3`|- zEQ3BTd)O@S4waBo$N$hu2J+qpynlM{Xw-tBybwZ${~&{iOCzFPwd-4N?&F~w;zfLW zKEjf39LX6WU~JoED{AqP+wCkEP#K`NVtIKVT|m>x;3tT#7z$Q&7ghGbjM*$+iSy+n zB88+`+1CX5yx*?0av|Z4kK45N6*`!%o-NPl=3m;okAg5B8&%PVj!Q*yUuvVDRX9eh zfOP*ij>LfedC6%@@CJ;ld9 zeS_A20*Dhqw4&{^Bk1S=F#CH)nx}@iymi75+6yZgYV{TSdF^D#4Mv#vNNyABBdg~O z((S4r5$4Rg0I&y0RUf&JUQ{6{2_70ls|}>m;`=^a{xQdI=@MC(vHejKwA(SR2FkwZ zQy7rQQAF*WVoH`^^O0ZbH~d731l-uL^vIZsSH1&5htr6?FKDVfn<3#zT2B)T6H}eQNW-6 zUxryvgVC7?Pj-&!2^}LD-7KV^-IXE??xAC?JY2?jd<#~ECT)|^*sM~qdO3?J;n8yd zF=h|BStN!sI|MQw^X?y_PI*u8(BI_= z&S+U@T2s>RLuY^Pi%N>h3#z8sT^v`k1_Ba5`Abn04R0!oU2EXgOZICJ`eR?dERgU@ znECI_i6=N`7IgK-k3Fp8R0!L?Q&o_^!=U1>ins9DFskrbUm*GusRtGH9}vXA41C6z6hquJ_Ouqj45|GX1?6l}PGS{c9pp4*Ulsv&bZ z9P{8BsxuT3yH3<8)j`__KB1#`Ipy|c1ib|OS|#TvZO)a8=N-{HVO55zms{QJB@6ZR z#awf?H!UNr&|Cs6i}r;;BL63nxxO6Jdc=(nzdFVE*hT)E-WRupqH`DL_IZbYKvS6@0gwLy^wA!% zj7z-w4>fS~z9qO2KZD#VYpQk6IRI0D1#DLT8xk!WQ^83$QL2VAB+y?3U~ae-g@ zXk82hIX9#G28{ON{nqa4(qjs*^Zi84tL^aFOsRyV)SyfqOWa{yRzNtOl~!4gBgrb7AsGo#W=vo6Y-PCNmyW>jZrj z_l}4q&3Q+=yWnr;P*8mIl*{Uh-}-E6EUQv$+-5+XRPj6%uqcpZoMjSezUf83@9+K9 zc_KCn@iC7suaKpn`&f~VTjNHefXU=r9l`84S%1i(l2Nk!CS73qpuugFgD}m+j}ARw zSEwwt)hT(?@5*oKR>8}bCG)~t_S7}Z;T@iA9PdPSqpz1e} zcw9%*_rM=k&}oMkFM1E4S$#IREruQY4dN4V4`cEmX#EtTOo(INAIZmr%SlC6WA$x~up+@~D~Xx5A;<8nhR^+yh@ zCcyC37!w}0O#fM}U#UxAyvxoLkEfL_pvoZ;!1WooPOp7e& zzs+7&YN4Xt(U)M0#Ky;07F>Uny;~PazN&)v=v~ko9nAL?&)(1IR#c!yG;xLi#y$&MzC80IgHKu71z&>0&eHoGocihRkEXD z<%3fuVl}7IvTS+1Nz*ThiZ;D6RD)b!`O_>p36$fWt)^j*y5q5O@FqRhAzF~Eeb+aT zOwruO+7gSTa);HGqU4*MgsdWk!}xgXZa#nprWhm?ho(~K<=q}-JAYtTs4a$wddku! zAvB(e2#|Z>;DkGTPT4JOgSO{stg}JWvkNl30K&qLtN2m|+*N^Srq^_8n~MQjNJCR- zhNBNFrCi(j!HF^`pJwJMi;^y@qpj9X2$07waO^8FNE<8&NYgGT7*gZy@a4xSH+&b% z!!2I8;q|NYR8vYZY`PB?112wl2+!ey{+HOaB@Sw~f9rtQZjlrP2VxG*yrf6TsfYLb z9u)lOZ-Y{8n4>=y%m?zNdW3xC2ol_d3bKe>atQ3Kg%wjC77DTFkncx)hK&?VKRIM2 z)>B{Rlg{P^$^&_*n7?t-}t)FG-PbXbwbMbb*Id? z0I1@~UVX+DR8;D&YpDUID3Q>1>7z5c1{Z$sGCKAtl}>aGG2*I?TiB$YUxeYN=o=Xh6!O6?twvW&J)WKb@YL?_d?Fule27FiCVv6a#AI{dfa@q5*=r z5>?{u;0K@rmwHN~9X$>w9eb1VxL_o-(IGAVt6WFD!dO z6tSh18ZBps^d#+h@*4!|V${u@Vf3MrrWm{#jH(=hX*~gxL0;LELx;!lM*`r9gr!$w z+{13s5~Y}d4&r|VlNoGl8om@A*i-{i}!?!y82sLRV$ zW>E$<7BWU=7TjqWj2u1YQ$VCzvL9~(1T#eT!sn=~!SMU(2!=J)3^>QA<-&1lkmw$( z*zP{46$^FTk64X!;wl%x$t4^?F14u8m<<2hV zino2;HZB>LX1TR}x`eB0F_`W{VK8r?ZH@8$5Nb9V917e7GFI)imSy)-=G=!OZ5Kj4 z1jvCv7!7)qJC)dIKlUI4cVk5AeydiJj_DcxU^wGrVQBH#GEgG~VcpE-ZbB0#BC|S67ueE~zr~G9&IBy+~ zuvvb0{Nzsb9*HhQTQq$;QZ5&YfGQQ13huOMKf49(hJiI+7bYEZ8AK(duV4UpFf+C@ z!B&nu81dF*G1Laoeq4%1~WExaLAMWR=lc_I*YSa z45TrV2Vkm!Iu!W{`+M#1(XB;PMtz1*cNKphj0GnrYm(x}E19bX9X@*q{~Tt9EPR7x zSoryMT}GdfS~3{monZagJjq&?Xow~-rq9nVTj&n`rucO;`a>Cg=H6uw9~R zAzo$@6rm=BzF;hip3Ttgvi*c-`OO_Kcd^5_dIs4G5r^^eEYJfHmvVaIJE|Re7B#OW znP3E!G{_Xd;3}8hE4kY^W@TztQ$8Jz45Q<+kr@@u;;Hk$t0SD(Sj zj%vQ%aBwE*8BXos7&Z%}iJSP1 zt(V?C@#EB0uK$n9Iie0t5swOg&cQ?K-)!21B9p_sJr}}{wAIJ;Hj2FrejzsgU%-4K z|F7^SMdUYXg7A_IbEMM8a1Ye)1VsQk=0LTB#Y6?`Q1>%CC4;P0A2s3NZZkMsqWnC( zGt-|NES1>(6dKxi{{vnuYjD9Wd~Q%}N6>DA*bpEJ<`md79fCL=i;*IT@28sAq}e_R)r`Y2DW|b^jjE{)bY55g%ja46S<*~`W^3|_0rpPWUv%{uBE}=n6`+z zugaCiYi-q!DU5h5=tcZUGU zcxY*0USd3(hdQ_d5OXlp(NIyX|G{*EuMj!$Mjx@r63eDNKfz#NH}Tc`q0II($!;iG z{ddLoNVyx~ziu49s5e%(;wGDR8TnW1e8HD@FuEz{r@#!ZD6I^#co;QJYFcK2Mb6F1 zfwM<%`@K@XrXVCeI6PUQn1*AWSglH0{0|X>WC^+2+oRX?)K?$gkr$Me6~1-xdoZ7= zH>;X@9i&E&*<)6rT?p%ExKjN`aIbfir{@)afhHg7wJHnofZh5_uYKJ+uf7FZr07C39R7T>c z-d}pfe@tZ^qW5K8$p1FoZy%>KaihYr`xcHK(5lvq?ZF%Y78xemZ}6K_o~t!5JnVk` zililCQjT`LahRcL_IzTfJB@K9lJ-;ju2k+mYGkVGsk`pbgnkp!tXAi1XtWjyOfUCp zu$eS4nXBok{Y>4|(!H9YMs$&1ioz*d)ZtF~V8>+^sKuvxgBn~U-cY+rhL^zSMcZ8l-NWEqyH7o}T>FB5^YZeV& zY3XjCOiWS>+n_ZcGw{gL<2spz0`Jjl0r@`z=LVgVu?{VhQ~n44wXAplif zX3|&3{~-k%cc8n3YNk+>p-TP9C~89~Z8Fy#C{I;FOe!lTTbi$MB(nXC?6nY|$6VQ^ z<#|TT$NC8N4j$hGM%5sZ=XM`T7PlL$dvuk4C$gX1&G;ya-ISdPIBMc`6XgO9Mj}*M z<^7#4nXA!kIov+q;V?s?W(x@xx@S%I(f9&08`pVRu>*q1s<&(eq)EJ)!y0QWK4R6l z!N@8AmShpne)?1naXx~+z7T=u_dU5dU#8L@hzQ-GuAvY;J6KFp9j~?Q#%o822ZBpY z!b$wh`n%$`(O4wGA5og;|J|@)(mc;-f8?jg&vC+f+p=L#Kc=etEI*oAhP(=8bs2hr z^ccGHniF4Hm^<6NyvcqNmTvslQ|~+EH$p)rSgrF{ingChxA90+@9p2&U|WF(yDpWu zPq{m+r7}M=>e&eAayd|p+wT8l$L?0_jK51Bu&^bmM8%_cTcMrv z#+Rl06|$n?U9EHc`0qZ3_asni9r^#l*R$KE+;5n^22=2SuFsqw+`&@QtdFHsU8w}M zRL9y_Mz%o@+&V->LxU3}t*~v-bcz4g8XtHPIW$qVvrDL7nL2nzzD*CZW2Kzlsg{r5 zZs7K|5zG#&{OMlFEvoR+0_y_dF%dNop?Yi~wHnqL{g$4dUNZ2^YK_@jGk|ajXWXf# zzOwB;#*>-`)PnTEj!%a=!k#EsVThD&!dSsYw-28qeKvzUsD5r7BRdpfeU#eVb-6&| z$#c<7L-9K(dWY10{B~i!ko{`G32llsCuK#xsc>86fOVF%S|+WsSjWV2^4Hsp#|@k% zHSzu3bQ2;g$+UNr72VAz-*6_hq)7BhuW`ODKu6N=V1a(T4=eI3(sI_IS)m+i6RXQ< zQ}mhfxW9&rl}Wh(UZo04@C?sevwk#AMy{P>?fSj(XD8p8GOykeGxFCyipsz$$KLVY zd8=(;okMM5VFAc?NDGi#BC(Hy{!5>dwUzO?kCW~XLG%cHZ>NKRqD(0f!)t{`J4(MI zlB$vy`U%br!ZtoGdy%@<3p1F)+}yulS_eY@#`cDuA5DNc+J=az^Y?X~ z*nw<5fzi>hmc()!8FcZb^GS)*1f1BuB-><`xmVn3_-wzAE%V~YB-N-s)+GEF*12ok zPpG`#%*gYkh&}y=LptV+sB$nVbs9m{q*wjlWCY=$-PL4X(AdJyFpy z)5(>dAu$xp$eXj->8~5?x50Nigfe0zlHv`^w6pBY(Z~TJs$d{eOC)&H?$iK{fJCEL z!FFHR1LAzhOAA~4{lz!oO`3}Sbw-mbX zP+liiy{;Ym^WSjomcogtlN0ME_hzn{;AG=E2e$&+z~4{SJAHwde!Z(0MEKEo{#n@d zZMwx@qErC^&$}UR&)4G{$rWj1K$Vvo{&d#*Kdai6brP7u3;KJ?NjY zM{tO?@kKKAIah)GHJ!!O-_C{*3g9M$8A} zlYGFsf1GSTeJ_C!w3Y3sWF>yzA>ClU3Ff148+fT$*@^TD3o45R7T2@nW0wuB8!)i` zeGI7 zDe{n-TCe}XSpm8Lj` z(lO;1`HeB;7Z2`64?$mL*YA@p09778SUdgm2Q6*1uQ!2#Sk4;b$meJ$57<@XkIA~AbV?+4BJn9(ZU+6 zNgIoK_NjOowj^fh0c$;ovjI2c%e1t#ym}(D{p^U&<9Ip9YB(}^Gk(1zP2g22{;_im zQ4GyNlC+e2zi6J#)!Qm3HQkMS0oF$P3(N}IhK;cyIwDB{nd}lFO006D*G)EmlM=mx zvlv-+Qq1?%ig&3g@2w=o2I7ouG?H8R_o~E&!h}Y(YC>a<`(-gDT(pq)R@Ktd>Jry4 zhA))&<85xgVgVOG#nZmh_ z>DRjK8~=VHPohK0!y5oqfZ1cswXk>4e#xxU>6S4=Sdw>v`@_W-Uty2g;vgn%;FJBxE zqFZjZ=ZCC{ls#|EUj`_Mn>&+GQHPUE`fZPr{!N+yR4SIE!#fFq76^*wI7Z%o*!yQ8 z@fC^aV5ccsMsynT0%XSc8QBC25^Y`^TzM@?-(E&6Zc`lJ#a-}eZg!Zrcm+tK?}r`_ zl4qq7Qz@g}-gz}`2CuAn`8@>FEbQDQlxuz~;m-m);uqZP5lBGS-EYnx%RhcG^TV?u zX$YmI&~BR9@sl{Z++Ya}4eg}tG*%+qwASE@x9rt(B6lzi81|DLHU8lDp7RRcd}sC{6lMo3&F~RXNw3ONt~WvPFf#<)JK98{(pgJwKg_W z@@_p1jl~OS;0no^S_-(Q#YUJ^&xSLaUf6<^>C8Um-n$jE3p z&1er3C*372Hu1M%EVz z7JrbGMVT*1=JBT1bBnj$NkEXeAMu^K6COx9L2KUi&$BPwzIWXVA<_&F?VWXF1*1$iSIi)aHHXai{(WU8xSRdS}qZJ63 zXXcU$$1XjF$9Wd_gre+{jVLDF)edx7+=d~(wtx}5knRf&Wh<$RhX|e4{w-==?Sv)Y?VdPFpNn=aUhS$1UXXe+H%>4E`;B_YtP%oxPkWgPk4BE z9h8zupe6I3bMF&ts<(Fe3@`pCBmQQG$vp^8y8eC(3Irtrw)Kf8sc!K*%x~ zgx(}-_lWSmmB*At2xvAK&^2Ncf@Tly=jt!|mp1h6y zF-ldukMfB{)nQa2>Bin86Rgp>nPR@|oXTf91Wqu+^yuOON`GnBRk)OKLY0Za;`)4K zC87bniWDvyW|Vm3C$y#UuJ@#Z{XzFJw5E%!^AH+Il6i|7emB0VRH+Nb3e3tL(qGK*^p!i?4yN2Id$06WFZ$C84huS3^hX^4O%=(T!FetIc?fGr zMUzIe(C~2O!v%oZU5)!zETHTPh4IyPm*Ukdsda2;a@%*xdYCQhB__=%foL1+UmPmq z;D2YHuisc@<~F^Ph%`1AHR|{DrfCP`8YB_kQl)LY2}xCvrqr;Nw*KYCZLTKqcJ4E+*H;cSd?Foq+#D8^S6PZ{rRd%4c zHtec;%juJ^v{={l3q6-cUo$TR8jqCJ_vgsfvYy%dK9^woF8%41&dT5OpU=9}9{#Ny zpy*F-^qV&XWWKT2BrQ{jc+hbVL_@<;aal1K#ws>h+{91k9Mu8` z$wNvlN!GgF`r&Vi{OpfZV){#nZtDk4#*V*Cu)O4|C3$}yY2Pr?*Aq{;Iv2>ZN$Zg6 zaKmYSg9wuh_i5^~cEGy|E8TC)K=$+36ea!3CFI=B%035clyR}P1_3VYt&G3JLf5y) z4aDf^UVM7E^%LM7DA9uR#NRSHu`mO1Ik4tv(__$OCt@V;mc&f0v2NQOJxO5RwPrgF zKED|ycQ5AGGcH&v?zX$u?G)C>uN2)PaOhX?-qdUn^ugrhY>!Lie?(Y)B(dd*_lxp% zC(%sDwqhv~TbYiRXsh*es@GPE&kk{=*vBJbEK>wgEMXJfx4Yp-XG|yi$E#Ol#zHe& zz=0$sBXg)xy~!}3%-nfU_)y*g0ur#?`N&3^nyJ4I_lY1f6*YeJ@jb*`9PEKz4kqo4q zdhRykXoIm+^)W&;!t9>L;tPHaKKpU5iEoWdyZ9dXZCE~L&@wG;S5tr3S^=6tueJ>R zi~;uf7LbP0Yu;&!kWTj4z_f*uS6gC9USSpKV$RRGZ?Hd|#)12WiafBxm^b*+gLcUA zRma4<=IdUec#Q^QXoVrAeyu_s=|(WtaS~y*+$S<4oZqe;5vSNQYnOOqfr5|!4JYtf zCkn4%0=^k`Npg!!Idg1^)vq287Jq>XBYmb$mogQZA9u8%54@P447QtrG%#1?j}IQ& z>bX~Y3dK7=r{^%ui>?wEu!BwO#=(7<h`eu`F4V3G`{ES*87YXKa?UzCF0#oIEq{Pw z_w6@dfRTTgNT#dkZ~`56V}eb;kfmIo@S2rC)!(=G-B8OS3b~GF(%wAqA!giJ{Y_E* zhSL|JIrmp0jlo*nC7jw8zoG`S^4^WDb;3+7*Elu4~@mH%F){k zef9?ooM-@_0{=D5Z)fx%Q}s}A=nXc_Xga1{n8*r-J44ABE<>8Ndme*t>i&P z*C!$CS0i++auu}NEGEbqFRn=p(9h0besG_kmD>tn$P17Mo|7R5D&~b55R{iTGi>19 z6Z!ri`O}pS8*oZuPhY$QyIiD}0Ml3LS+vRqu=BL|-0C*IXGh1vA;ZNRT9G4KVs&tl zH}-486>t+^e;}1AB_rnHJt6aZ!i}b)SjdUwz51_wmXa5#Mq$e^U70tAaCh7)N9KzB zRm|PM8gFM0LUIwjj!kPUs@5>?u9H!Y*~ZP9c?^`21cI!oqxb%fR>#%u*NC_=lF{Lxy0#ULa%o zg}6%X-UTv-2sOq%UDF$eK}#e-Tu!%NVOuwHUs-KBu{voakoHU3uT5~(jKivEBx4dn z_=qSZE#7D2#ErWJcw7?wvuTaWgvqd&^79yRk=G(F?{%UrOl zf>B6C_yo;Ky@773!pXddWNN5Qj9?Hk@6oGimc;uPI8{}~^+mIkoit)k*W@lsQkcSK zuHm(alW3T#AR!Q<$cnYpsied4d~QOXGmqpKlsb}On=XPDBCX-Z&bZG18E>R6`N}JO z&Ta+SGRuIG+hLI zdLA)TdD_i=?)<_Nv4WpllJ863LUZQC-F{RtnsXvfgEc|yg$O{+Ae4Jd*G8bpA?vLA zU7xzH-pU(dy>X5yl>uL6x|FDtIt6%v?>8IVgru)RbDF});w6_!$R8CijN_cZ(a>is zNG3t&O8#6h=0JW=<&pZm7&X|3yMZ)_H1GXqEf{)hQOBL5@SR}MlSq>-?iWbc+b87Poz7z z8H#Bz~;TP4s+O$S*`zxB(0~vm-r`(rv z{_ptFh9+1|uy^`ih29*ayV+H6b!tc;BGpZMtdN5 zTOeyyL>Bgj{;8+e4VF;MJuo90Rf_JQqOOsoiVJVOKjAPsDPV8FE!IUvMO`NQ3t&>!&1X%pL2)WfovW9t&HW_e;aX?`hR)KGnKBI zf@#lk;Rx)yP*+%6%j2f|8YTZ!$7_(41S{&EtfN7oRzw?&^@>~#1Um~IH=k6As|L$O z5F=X`jRK0~yw$J=j-s5X@wTcVD@j~Jc!_>Ni){)VDFkEMRRk?dqm}F>F_k~j_thF( zHTR4SPhDnWMafxbHngU`>FetgsnUCA%xCgrV!chj7VFW3@rwj~s@H68JUl#gf1xHw zEKxKvGjkJH2$w6=E37s#oblam$nhPxS}t;NpZb9~+cq3{2{%>lA9+(J3rUM>_}@i*-_~5(+Zg zTVGFC4E*OfWyoC#80p9>soi|Qy-bi|DGHT<`v|PzMQha%=?cw=Jyyna$X25&MMgya z;$?mWUY7P>8J~vPNuuNmhdroDFC$`UsHm{~_&>F#4}aNHE^f;#@!h0#bacFbKfj`) z0y1!EC!*-*GRjWyKUDNGqs)VYZ@#{NoMkeWi<9wFHa9nSX0k%~hdAl|a8M+?b+Qfg zjqW~NMmsW9BkQveJ^ebknpzsy9-$^;Ii_(RkC>D}!aJMN@W&hY<|3w8z2|5|JP`f} zd_lM41;g5m!z-l=APD5_%%-iURF0BhDf$)bjJ(E5>SK{_ab!x&{!)jb4!G&N5}X_s z(ZF+d=UEjjk4n@^ZB~A)1~igrevEw>Y5eQgFX)&g6)D*dAO`{cithOWPOg0kAeEbm)dy-vpFhDE8wDED;>83QO^y=!-#Q_ zp)VdLq)fotKd7Xwozigkpb=;Xc|9MQoaEv97IUCF4pO3zudgJm(=arwQcLgcvO=LY z>p?h;sFzt+uqYGLz{JG=Mz0f%ut_Fd!@U%C|0cr6=Lzp$v{)iLyg+CVUxG6C-=D)} zxVVgOD@m@in~m_-U2Yv!)KtP1B)x!M?F-5LpkJapE-m7Mi+YJvs(huQCQmK6s z!|#H1U{eO0I6$GW0%AF$XJBUfeRj zPb|YR)7udd5tBgre`j0|BaPQy0NLup-^S%c_yEuEkFo{YeSeZU&DJUSd_{s6-Z>Fu zFDlho(cLF|TU!GL>0x1E0_l`&m<_YPj9PzaNdBUoJi0lZwEYX&f%*?#cWcDo5s`kP z@BO1(B(v>-=PqtO0Jt?3KYg@r!M;m^s5nudmi|B)4tS1AiD;YQBj zX$U`M3W()@$j5pPPEm-svI3k3tbB#?l&o1_F7T?9aMdM9jg01@nC=0o_4!Ay3V1Z` ztK16XCCmAShr%lsQ)^Ws5pcrm|L}Rkhsm@RClX!#$R}{HsQU%X_&hE0PVLwJ0u64x5}FD_wFihcHa0k8-?CnJ$Q9Nx%Dg(OF6Y8O zVS{(U$T9?;mG|JvNVVQmLrH4*+YtC+n$>ygHPxtyJNE7AsEL(geDt_jg#0z@FsI76SMGWM_h!gyjFK`tES7`>=hx z$S7{IDSIS4D|?o4s|d*^qLP(lWoJue6f(2QOv8vMl1(L*vLyf4L=)=8$77#QBl$!XJ8YvtY?{W>fc zz{@qWrL3$xQ3r8=x5NdaV*>$$hM7+udYO!eDi#T#voCck2`TB9Ye80NA z+>KTD>hH9(DDht4-n|>q@-j($4mJwJH=yyRt?cO2Lgb4Mc_nJSyEGQ`W@Zm#uhhXGHd(P(2Efwo|0EwNs zn7I9gqG^#p9fe7PCZESy+d`eLJdJ;-aNs{FnnRPrgwJ~_6~3L?`7btk2lAm(_ZNF^ z@4y*&&SjFaC%V3cWMVeBFDg2kH0aeIBBaOo;s*W@tnK8(9X~Md3Qi;hELS%+psnfl z`{Y5MAX>j2qoeg65J3&(q%J=qN_cCVT!jCBBqM{yfPDg$^DfP# zgFIgWtzP5chO-|y1u4gj4pbcI^|)5pgfY!|Uth{3cusfx+=!<@fDiOTrF$Ah2xfz+ z1wCgn&Q=Hw-_jr41KqUm);my4;FknVJ!d%ivIu9+a|A;hH5HY|wbZEvi0z*ndgf~C z114O^D&S~{sou}c&AFEuOfGbs*Sm_4^DaBhP$F!;`zlt1ly6Dl!qH>xO?`aeyM#Jg z|4`VCrHZAqP}0)U%KwmS=A0R6N=Vr8csr;`nVFeb0*yF;#g}tU+x5*AuCgh8rfws2&vE8)Lc90j21nZ!?Ul1d*4eSKoA zB8E&r_b!~m#MrnZZTj0>e?=IboCRky7(1tbw<+ zwK*6Mz8jr&sE&~|7bPfaPF>d}-=)uJjm3CF!?>!dbJ&5=dr&;577#SR_M58O1+dr^ zW+5dna_T-?0ogYl48`Wr+AEsd&xOliArWRc+FlpFyk?+xcF~pZTeEuBu_#*q5qleU zpyy#Y@(orX)q&T2p7eddrQ1ePL|bE&kn5<7ZKf8_H&PcaTo5jt3%znVPtW}rm9WlT zwe^vs@cHO?j>SpPl1>Xo6JG`Xq>G-s><}!>Udw+d82&~i7r@&uv9_g|$8jS|Xd+!w z;hCVs2_)nz$xr+H`(M5C$F;+7+ij4}o$Mek4c9Ftr#}O!si{aB9KmClC)(7za53h) zj~?x_eYgzygVCvp339Qoi;I<~kL43bTLCDdR4XVf z+|vhN3&;uV363W8jy4_=i4|EbG?rI@>n+&eqnXfAh?N_?RLW@~N?@Y8Q$k5W@!zJR zzo)5`>v_(Z2q9@Pm7Q5>e6lXvb?4!c%*d_Onv?z14xgfrCtgrcfWo(KoPyYi9#HPj zy`lCA+7!APG2fMeT!&X6o;L&C`}rH>f4N}VAol+Py;r2V1}4uW-Bl?6W>PJtt-lie zu;@6LZlqOq%K)rGmfts8TU${$`us5Y@C|pEp`)`iQWNG^&4Ac&?Al8eu0|xexpL0P zQeG81`8X~ripo!5fAbpi54g=hhI>~-m`Z<@7$Lrgdiez&2aK9{SykKY`b~Ah8<7Et)YOtwL;CaE$a-Yrl!~Mzf@v(EK zqtF4OtmflFi<=l1xAWtX^*t7%wT}ohG(AVlNvTlo-aO{BkZe#$E8uAH5)s%&xP5@b z{x*iSSkHZr%%=YDk1uUvKY!7v!+wOWS|%SG{`30mX@dOxpO8vWZOo6|muNv!zTT(t z`9{k4g!>wnoJ~zl>t7!{c(Bp$GsTHe3>1+8X1`Eo`B<7(T+>cFxROEbNBDp_BAB!_ zgK&eCj&bB*o_6F=pWg_iY2l|~NK8m*e}huCB0X(sGIVpJjI3&6PMVwY?u|`52s0e(1x>6gr6H zJolGVLPN`M#EXe867O3=ZZ@bbV{CYt15$J<*i!)m?C`=O8yB4hi_96)Tkx>SCuO4oQ@Y$ovaBQYbB>7xlGE1**wXuNQ)bDC&SWF9ww zy|S37s3wp|ZDW`!>~U@4y{J{xou-lwjBnqu11;iL?C#7|*U0lE>uY7htgNh};tL)P z1qH>j;v;DU4i~;wn%*oqk)B`Jg;!EmZiZ7)3}viffD&iYl`CcFWp!#y^VE^o!}>G! z=gl(UNU=H$>O?{Dgeh1B4mvn??b5waRB#@@+ioHw<1cP!jUtC+B?^%gu&n2G)3wut z93!{2<^m;vtTCy*JZWpIxr2lExcqRbdHbYq!iK{6NnNjV;6#92)SZu;^Arykx1T}c zFE)Y)De9r>i##<_c)Mn%g1dQO25Uj1Qz%5AH zw|1!XzWWoAf$!cnFWTFk0rjDVhG_Lis4%p9G{(hkZ=cSnNG8(hmC&3BfvPKy-Xpv7 z@D_pNC~A>eOt`MWyX4OTn$0eWr#z-fSNA?jRYa|Y93qJefmn>%lq}jF@+NS){S$w{ zQ%Tbhb;3&A(iU>Ti#!bt!qvJuxu`+XuyDT}{gQIv@+Tx8&KnQE#Wg{{2<`@$m8ENr`dE>t$%Qn;wR2DtZOX6Ga8*dm+uN8IQ{4E;ct%Jjfes zE&I&&e6D_dr+rY=H*R%{V*VO5dWUgLfm4$hYk_z8t)<0wD=Dv*)}eagcc_!&>y|g!uWRXh4;3;+%` zuv)u)tc0e6=Gx~XqjXI2z&*e&V$y-+jhE+H(EVH_pHPM<5ytV48ylZs5!Kd{QjRai z!`AkeB$@xTf>N@oB3=A+bzEY~wS6bpjN`ZPhk&QNtyB>kQHLnV-bI{vHF{i>dpLBC zJME#A%)$V;^d$am5@;z!kObF1awv%Li}GTi`|Rz(X)?V4bVxX=_wJG8jx?|$42zzX z=jP&QxV#cF?tM33u(# ztCd;y;M#eMa(wLVJ%hIZ5}VzV(5-EJ!Qm}CiX)Dj>9iX9$Q(tBPf2@$cA;n=lh$w8 z6-OS#+7ihc&|B0-O7gIGY0rMKq!QXp|3}bj!aTwf?$jiS9^6iy(%HV8cV3&GcP&J@ zlRYKb4rYs-_=0$){`aS|mof4n8cMYo?N^Rd@oqt!dS=vhIXWXdb3t0*ly)F%fCF|8 zQaI<0D{6nc6MJnuS?M~-O^oWDL`3f3Xx;7&#kL?Z2O=f4qC^D1kzJ-xX_8v zM(^Dun~XUl2dQ^tKkVyZZ{jJ8!1=ELcm3=PF=>5I+2LQ3ZrNGCH_1b6ki&^&dBLkM zFwF%<^P)`C^dr}eEUo94OI-8CE%FULC&XIDz5Lu0N$r4g%5&%}+<#*SkJ173x!?zV zt{mi*Yd(!NzXkB~mnJ4;kj${A5u;vYt0De@e_O--Pzizu!%iWSRZyQ0%Y0ALXA3I( z!HgDbDPnd_Ke2;>Wu#13d6SYmgLtNE-#-4s(5dqW#{Zx-UmhxmEqi<0A*1xjG2nvU z0T~g5-qe6SI`{L>MCk1CL)eck$$q=^Nz!$M>&BH!*VyRX@ZEEBbAjvlBE~YauqfX+ z&H4qvQ&Ab`bs71(tT5o#U<5%ps}K(x$-s5FK{C>~O26&uBgsq{x`K>=2S)N~{<-XU zL?r%b#;640^PQK*+?;UsOG0rCmc#8cif6{w5P;@J)TZjil2qurGCvIii$ZlFqNMZ_ z#DgZk&sVQWWF3n&V2zg-xz@>dN+)tp|EoMKo#bL03YdKMfpaYmZ4|teJBE6DlU`lhLGgeojSAt>87fvmcrO(`fh&gFMe) zMo;TORcGdCY}^Ag2iSzgoGBed{mgNrcnu}ht;d>?y?$TR>nhFrCr%T1IqI;QjAx_2 zrx{jw5HsR{@5@a;cDH{beOew%$3>6O8Jafm)C->-;1CuUkGIFPmx(dj;|VlleM%i) z>~ONWF~pwbz4T`*)q^c^jHa8()u<*5`2=sjH5k%9bEv==63&U0-q1_vOVue>%lu;S z8Kx*Y`GJ9q(MD43uC@o_%pE*6wS=Og6mt`)a6ccQ$;`BWy5_&HIDf) zt9C}X{$LC8RnW#)h7&F5{fN?D_STJ8Tz7F^_t|+~^5n@pPz#astu`P_MwK36 z^-E%I!*R1PvF0vYvvwNYX3A>1XO%}oq>e!%J9!XVyf1jy1GNJk1M{^F{Jm@P-TsY< zI|bNO=veIw3kn8=6yanlC3P0@*Wt*83mbs@mOo+PG936{#J*mw0%8%tML(v5YJl1C zC5n}o)n6~IFJPUTVn8CB{rhIQ`L}uLs-J>-J{om(1?doY`+-~pNC%65=KJ?op)v6; zAbK%a6w>L5Cu#_9>atMALafUjVG4*~jGhLzjTd$6v>RW_4zDzLANU?Q_1;o7U*^}v zt>6A$pX%VG7-;7`k0v9K{%`c8*kdz8DM;D0S-do8fyu;gp>s&LW(x)MbT`TNI*mVhDDHQ zt=#-BcfsC1U#Z;;+g|g=s)O$~T4k>>KLBPk`MWjx>xul_DlaFexU5I#Q+JukI-}Gx zNaB1|Xu#x`BD#(}x7#$~jw`Hsrg^~u)r-z`Mhs|+!$YH{b!l!<6+3aws!`E}X5+s@ zZL@qo?B8GX(t3&^CU4uwjEs#5FDYg|pt)$T&2~cdDZg|#+2B?F!2+U-FY!d?O-fzS z1N>U3$~%iAp+G%7Z~Y@GEjoekPiAT=zcF2^OwIS=3_GX&vO#O^6}v26vj-2cnsQy; zdy}$TXDrRyHxUaY!;c2?QH6}Qn-vdMR_f5k03I<7SRizTs z#DHg*YQzvvp`bjW3+O+VFSP4bc=dd~P7U9&n`c}N*sSSKQ&v*p(#^(+NbbTw=3Wbl zq4@p3>o!_ zZtP6}v z5SvaicQKL-KDlokMX8ofXq9x?0HsMt;c(Lx6{+FZEP59UGVkYoG(mq(b!!l?UAFM`eDlJ zP|>N8d_-Pp;|uV;Kp0q`PB&|T5fA)g3=DW!Sf=LYj>8&i=>n@Y7yN#J%jF_#Lg*2o z4#~#2JBry)bZoogfaaQ>tEWPDwE%^A#%y%^`&P4oJD`KhN!pc?7W+28-DIBYrM~9^AJ6SKaB;4F`FS9}9kX{~e@2b- zR^hNnp+NCWA!zuxDA&QycM{4Dq%9Z=zJo#amTtx7mnhu&NIU4mB~a=Bo3YTMcv|MI zVpGyyU#&{1y(%i45&WW}BqD9;#Q69)crFRHoDT?ie#g<8H{=cThpzy4zP_q>1AG>> zhdNYBGrz|?Q%D{ENyoz#ax8prTkfq6-8nTxk;jqsXWspQpv35l9=QwqhBG+xj!pdh zq^4S^l?yBi+|;57h$2KsM_Ff@P-g}g+iFVf*z=td)l4|##LkWkz&(9Om4_@w5mXb0pCrI+V<%JwU zr#^bV<9BU}?TGQlmoaFuxNF0@-T+5)aP62iSVqpM6PzE&bzHvC&F@6B|C*=34{)V{_x)# zv>$EvM9!W5SS6pGwsQw8hJrznLBp)avjsUpl5`H940}@#&XmMr6u-hVPio8H$$Agp*UcYp<}E#a|7w%a_j{ ze@^feSL~3CPfi|3Uc-pQ+>-}iVOTy2@)Bu~ z_0R(d6ZaQg3wfsS0^Wtic>(P${iC#&mVF~1YsggAAfM<=y@k6hYG4;GneP`!l0Al0Cg&Sj{bF>EpW4SxAnKP zm)qLk{tZxbphhlM&g4>sz1B61eqWT^Mc&gG*}0=pk~xZJY@oyYIK;|| zl7{}1=Jj9f(Uaz6OGbz10W4r>XM=4AK!or!CM}?Ar#fjcg!wI%-s)M^Jj@rq#H5u9 zMnMwwiYSrOq&fc1$^C9=#z8(zfoxpWze&q37sfBn!5EtI#SL+b(@-e+bTsCPh3Mk_ z4qwO+&(}FV!_7HxX3P(OVD*ZS8B+dmPtkYzxo8P_St!|@pzdRm=LVF{A6g-<4;Bmg}tX(WTK-( zH~$(~V)YZh&qEaSYBUeWfBjK zF9~1MZ0`c5*Ycv0ft@?%nT6dCL1E#i@)Hg)M37IsFmemyx!j}r7}r69))fQHt~y|L z`ll-EG7c45b71zB%Iu&sAS8JoxKGH>iCE101&1*q!C^x?X5*ulNmwGk8^J>Ho1*TD zUW%F5TvwV-%u8n{Cq_Q?KUm^vyLP$P4pS9CQ1^l9kRY!b=3Tokmuu${E7VWm$>@=p zkSjKwBF;f%mb5?y6gGNVchlo_$wD@mtm z@PpGkP%IE2FjKp0UKryoBthFEtMf4V58mty8->5v(QVJ4+(|^wEQjd7!Xj>+0rv4UMsDnwcpNj z4@&M>IW^gB&KQ(^p?2XLHYn1~)E!sNxqcGzo+VJaCD9kw&32jI6**M+c9-ASfjgHFf?y4|h~f)qThu|Mz{@m9 zzB;gL?EP9iD_1?^9idIHV_x;%*=knGZ*|W;Z!ltNMW zadL7JUD|AaUKqAg7sbpAbWu&3uier;BAO)r)pPOYsZ>A*j%_pjdH+y`h)iD+cJH6`#O-PXWdYWF5R9F7NFFMkY&&wTc-e}% z=LrZAXHh8ORlj@eFHuw)8*6|R2AstlDq`~5HJ^2hBUC=GQ)Ir)k{3T>s8II7F_&I` zBdyl&so|>2q-mBJX|w})*u1-1dzp2FcC#684dB=u`MmYB3s(8^_H8MfI~7{x+pCw>JAJM9eZmo00yll&8&zc#pJ^bscL)TkWPz(|)dY2=(UbPnE9hlvSB9>>yTIbAnpuFmcqP3(=ZTm_>?^YOj`Q`E>Ydjo;^EV*?L<4u%m%sipXBg zHm%Tt5z9)e^U#svr26~!FFH4>%kjfkmH%#^l3VdoQ7e}(547UEE_Sj(;&&G3zuZ5r z!m`~rfFp$B7D5_{gq~hwWGC%-f$ne)!2~7J;1AJ+>injxv#*=itT4m{07~Nlua$mM zhmJWIk^Ah~v)G1)Dyz)zCIq_fIB`YXnn3~C7qO1CKYNdsF{R_Ikl*dIrP|A-9PIYz z5-;Dr-li74SyAs+jCXLqc-Yyy2t`UIcFuAK1Tc`lyET1BnKLXHtIRYNf)t0o2Yz~$2?+^~bOCo>S$G_q@Ft^-_|~JyZv#%8 zWQV!8_cCNA>cM><-HZ`pN@;9V&8)|tPTFDz_1Q72Z?aru_LGo-;JGEgp9drh>zmk8 zcJs_tw`>W9l$*9sK6$}aG7c zdXHh$@mjuWDFn@WoO}$pQ&V>) zTdFE6Ul%o}sH#D3KtMY0@2_B=;cK8IM^C{qWho4(QAyOqiov~G_lfm2_z!9p|AlHR z!u4CoZ@Us67Gen!2b6ypwZP9`zb;<7#8pBo6*TTW^zl%h&64~bQ{QRwos72GlxMAZ zT4SF+$N63^P(1sdbnejiP~I{$`Z6(L4u{{cuzxs#Iv>90q=)I1zq}NfSwn&Mzc=z# zX_~x(u4D9#L`5%wi2b1vFT${bwq2rh1iGK;ELrdC!549e1zs-?Tr)-^_D7}ejf<{~ z`@8?CNXA}O>7@X4bo1C-L3Qrd%L%A0Cwsl#H8A8bM4qM$w2BL9VkZJUF_~?%L4;{B9b|5M(TpFU}kRA zYzfZ>5ELS`%5mMW5I0)glxoo-7OQalt&X8M`60L2iPnR8C}ojHI`@T_g@S2`5nZ1E zxtZDumGL_2wW+oh560aRE$%yi&R!pW68WQnUaK)n0f-~}h~ zi1-Ovp6f0e#VkL(|5Dvpg!nn#;n%_Y{?x=#V>n2%89({h4@s)2sYy^NoYJO>`2*#H z3&`mxm3dYC=1EiEjc=#^wNW!pTdTD^cyIyf2mLZ5#roQsi~UnxL*e186|2+AwjOND zw*s5j&J0v{A1aKOl=ArYC=6K=LdgKQ_`JfZ&0JgrKLk+$?7@_28n(R_u$9L^;_0Vo zr+ol)=1Q^}JfF;gA zmP^&-jM1ahZTyyM)TVJ#`CvSN)wsN}LVjsdY^3TavQ@QQl24+UWubQ7)1A*xIXLi- z(jB8dTOf2;ON344?+o}VA1O|qnsa&ke3_0T`IK{zE4go9>|TBRt@snp&$9 zcVR2*BYm>$T^WG!Xd2G@NGm8h1VwV>3$1XlvPN^Y)66qVrc@C!o6h{+mlk|pgyaT- zk`9g_3$O1cs9qlL!6dpXpnaMJ6v8C&MV&(C&VKGzg6{L;t@G#4-`+Ry9@S$g@(P$8 z2s)8JtI10!bBr2ZPVZD5TfEpkqhFx^sFQpDD{l2y^l{dIsf@nN&eG_|sL*(B{{9^y zfs&FTiJr50j!Y`{>gP9QysLDeB;|Nq)1`UT>2D(YxN|^cwyotRo96MMs8iBmT{`n7 z?na^pf8whq*c{9dy(j}d8G6ffWsqC+wzBTAIZv;yeE4|>7k>xPAqw3qW?s%I4;~ya z_UEQqh;mX$Z;>v%c-nBU_?1&F3q;cOyX!9hC?K+D+9}ve`pCl8Y)MiPD^V?%Wk&x9 zcW8ysA6Zs`+?#&ym?H{f7eBc0PY8$YwR>5q=~^;g&Ll0LdRrR___h3@FDF{Y&-oW- z>GQeO?}}d!P1u;BpPkWHED2XS^=z9_i~6hX;x^;^*GdoF#y8Z<9uT#99-yLT`sw{S z*XGLVg7(z#47s_)$9%&=Px=YJjtL11``o{7J+vxlDR_9BFzvSa&!Oj zl)-7kx|-mA{!1J5PXs657_v3?FSt>?NctVyaZrFtoOG=)be$b=5!wCw&f4}^Hex%#+*DRczZ1G{h16uRC RMKb(jq-UyoPun5l{{c1I8T0@E literal 0 HcmV?d00001 diff --git a/flaschengeist/utils/picture.py b/flaschengeist/utils/picture.py index 529d322..89de66f 100644 --- a/flaschengeist/utils/picture.py +++ b/flaschengeist/utils/picture.py @@ -1,4 +1,4 @@ -import os, sys +import os, sys, shutil from PIL import Image from flask import Response from werkzeug.exceptions import BadRequest @@ -20,12 +20,18 @@ def save_picture(picture, path): if file_type != "png": image.save(f"{filename}.png", "PNG") os.remove(f"{filename}.{file_type}") - image.show() for thumbnail_size in thumbnail_sizes: work_image = image.copy() work_image.thumbnail(thumbnail_size) work_image.save(f"{filename}-{thumbnail_size[0]}.png", "PNG") +def get_no_image(): + path = os.path.dirname(__file__) + with open(f"{path}/no-image.png", "rb") as file: + image = file.read() + response = Response(image, mimetype="image/png") + response.add_etag() + return response def get_picture(path, size=None): try: @@ -45,8 +51,10 @@ def get_picture(path, size=None): response.add_etag() return response except: - raise FileNotFoundError + get_no_image() + + + def delete_picture(path): - os.remove(path) - + shutil.rmtree(path) From 3ad1cfa9bed563fbc04ec49c4d069680ab2b1948 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 28 Mar 2021 04:08:35 +0200 Subject: [PATCH 243/446] [setup] We are using pdoc3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c1c2847..db12c1a 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ mysql_driver = "PyMySQL" if os.name == "nt" else "mysqlclient" class DocsCommand(Command): - description = "Generate and export API documentation" + description = "Generate and export API documentation using pdoc3" user_options = [ # The format is (long option, short option, description). ("output=", "o", "Documentation output path"), From 2ae8bc7e0c9e06d055c98b24cf427187543f853f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 28 Mar 2021 12:47:02 +0200 Subject: [PATCH 244/446] [pricelist][picture] delete picture, if not found --- flaschengeist/plugins/pricelist/__init__.py | 21 +++++++------- flaschengeist/plugins/pricelist/models.py | 2 +- .../plugins/pricelist/pricelist_controller.py | 29 ++++++++++++++----- flaschengeist/utils/picture.py | 15 ++++------ 4 files changed, 37 insertions(+), 30 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index d53dd30..c20e564 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -6,12 +6,13 @@ from http.client import NO_CONTENT from flaschengeist.plugins import Plugin from flaschengeist.utils.decorators import login_required from werkzeug.exceptions import BadRequest, Forbidden +from flaschengeist.config import config from . import models from . import pricelist_controller, permissions from ...controller import userController from ...models.session import Session -from ...utils.HTTP import no_content, created +from ...utils.HTTP import no_content pricelist_bp = Blueprint("pricelist", __name__, url_prefix="/pricelist") @@ -221,14 +222,6 @@ def get_columns(userid, current_session: Session): @pricelist_bp.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) def set_picture(identifier): - if request.method == "GET": - try: - size = request.args.get("size") - response = pricelist_controller.get_drink_picture(identifier, size) - return response.make_conditional(request) - except FileNotFoundError: - return no_content() - if request.method == "DELETE": pricelist_controller.delete_drink_picture(identifier) return no_content() @@ -238,8 +231,14 @@ def set_picture(identifier): picture = models._Picture() picture.mimetype = file.content_type picture.binary = bytearray(file.stream.read()) - pricelist_controller.save_drink_picture(identifier, picture) + return jsonify(pricelist_controller.save_drink_picture(identifier, picture)) else: raise BadRequest - return created() \ No newline at end of file +@pricelist_bp.route("/picture/", methods=["GET"]) +def _get_picture(identifier): + if request.method == "GET": + size = request.args.get("size") + path = config["pricelist"]["path"] + response = pricelist_controller.get_drink_picture(identifier, size) + return response.make_conditional(request) \ No newline at end of file diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 3d7c452..ff3fb72 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -137,7 +137,7 @@ class Drink(db.Model, ModelSerializeMixin): cost_price_pro_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) cost_price_package_netto: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) - uuid = db.Column(db.String(36)) + uuid: str = db.Column(db.String(36)) _type_id = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 71f9488..0ce42dd 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -6,7 +6,7 @@ from flaschengeist.config import config from flaschengeist.database import db from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient -from flaschengeist.utils.picture import save_picture, get_picture, delete_picture, get_no_image +from flaschengeist.utils.picture import save_picture, get_picture, delete_picture from uuid import uuid4 @@ -373,19 +373,32 @@ def delete_extra_ingredient(identifier): def save_drink_picture(identifier, file): drink = get_drink(identifier) - if not drink.uuid: - drink.uuid = str(uuid4()) - db.session.commit() + old_uuid = None + if drink.uuid: + old_uuid = drink.uuid + drink.uuid = str(uuid4()) + db.session.commit() path = config["pricelist"]["path"] save_picture(file, f"{path}/{drink.uuid}") + if old_uuid: + delete_picture(f"{path}/{old_uuid}") + return drink def get_drink_picture(identifier, size=None): - drink = get_drink(identifier) - if not drink.uuid: - return get_no_image() path = config["pricelist"]["path"] - return get_picture(f"{path}/{drink.uuid}") + drink = None + if isinstance(identifier, int): + drink = get_drink(identifier) + if isinstance(identifier, str): + drink = Drink.query.filter(Drink.uuid==identifier).one_or_none() + try: + if drink: + return get_picture(f"{path}/{drink.uuid}", size) + except FileNotFoundError: + drink.uuid = None + db.session.commit() + raise FileNotFoundError def delete_drink_picture(identifier): drink = get_drink(identifier) diff --git a/flaschengeist/utils/picture.py b/flaschengeist/utils/picture.py index 89de66f..dc896f5 100644 --- a/flaschengeist/utils/picture.py +++ b/flaschengeist/utils/picture.py @@ -25,14 +25,6 @@ def save_picture(picture, path): work_image.thumbnail(thumbnail_size) work_image.save(f"{filename}-{thumbnail_size[0]}.png", "PNG") -def get_no_image(): - path = os.path.dirname(__file__) - with open(f"{path}/no-image.png", "rb") as file: - image = file.read() - response = Response(image, mimetype="image/png") - response.add_etag() - return response - def get_picture(path, size=None): try: if size: @@ -51,10 +43,13 @@ def get_picture(path, size=None): response.add_etag() return response except: - get_no_image() + raise FileNotFoundError def delete_picture(path): - shutil.rmtree(path) + try: + shutil.rmtree(path) + except FileNotFoundError: + pass From e8c9c6e66ca73b96b227a78224e33c3e1abcaee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 28 Mar 2021 16:41:20 +0200 Subject: [PATCH 245/446] [pricelist][drinks] return only public drinkprices if not logged in --- flaschengeist/plugins/pricelist/__init__.py | 20 +++++++---- .../plugins/pricelist/pricelist_controller.py | 35 +++++++++++++++---- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index c20e564..58c1ba8 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -4,8 +4,8 @@ from flask import Blueprint, jsonify, request from http.client import NO_CONTENT from flaschengeist.plugins import Plugin -from flaschengeist.utils.decorators import login_required -from werkzeug.exceptions import BadRequest, Forbidden +from flaschengeist.utils.decorators import login_required,extract_session +from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized from flaschengeist.config import config from . import models @@ -103,12 +103,18 @@ def delete_tag(identifier, current_session): @pricelist_bp.route("/drinks", methods=["GET"]) @pricelist_bp.route("/drinks/", methods=["GET"]) def get_drinks(identifier=None): - if identifier: - result = pricelist_controller.get_drink(identifier) - else: - result = pricelist_controller.get_drinks() - return jsonify(result) + public = True + try: + extract_session() + public = False + except Unauthorized: + public = True + if identifier: + result = pricelist_controller.get_drink(identifier, public=public) + else: + result = pricelist_controller.get_drinks(public=public) + return jsonify(result) @pricelist_bp.route("/drinks/search/", methods=["GET"]) def search_drinks(name): diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 0ce42dd..925a1b2 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -105,21 +105,44 @@ def delete_drink_type(identifier): except IntegrityError: raise BadRequest("DrinkType still in use") +def _create_public_drink(drink): + _volumes = [] + for volume in drink.volumes: + _prices = [] + for price in volume.prices: + price: DrinkPrice + if price.public: + _prices.append(price) + volume.prices = _prices + if len(volume.prices) > 0: + _volumes.append(volume) + drink.volumes = _volumes + if len(drink.volumes) > 0: + return drink + return None -def get_drinks(name=None): +def get_drinks(name=None, public=False): if name: - return Drink.query.filter(Drink.name.contains(name)).all() - return Drink.query.all() + drinks = Drink.query.filter(Drink.name.contains(name)).all() + drinks = Drink.query.all() + if public: + return [_create_public_drink(drink) for drink in drinks if _create_public_drink(drink)] + return drinks -def get_drink(identifier): +def get_drink(identifier, public=False): + drink = None if isinstance(identifier, int): - return Drink.query.get(identifier) + drink = Drink.query.get(identifier) elif isinstance(identifier, str): - return Drink.query.filter(Tag.name == identifier).one_or_none() + drink = Drink.query.filter(Tag.name == identifier).one_or_none() else: logger.debug("Invalid identifier type for Drink") raise BadRequest + if drink: + if public: + return _create_public_drink(drink) + return drink raise NotFound From 544ae6a3fe184a0859173191b0e9ece5bf70b273 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 28 Mar 2021 23:14:03 +0200 Subject: [PATCH 246/446] [pricelist] Fixed warnings --- flaschengeist/plugins/pricelist/__init__.py | 48 +++--- flaschengeist/plugins/pricelist/models.py | 13 +- .../plugins/pricelist/pricelist_controller.py | 154 ++++++++---------- 3 files changed, 101 insertions(+), 114 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 5a8a831..45f26c8 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -1,19 +1,21 @@ """Pricelist plugin""" -from flask import Blueprint, jsonify, request -from http.client import NO_CONTENT +from flask import Blueprint, jsonify, request, current_app +from werkzeug.local import LocalProxy +from werkzeug.exceptions import BadRequest, Forbidden from flaschengeist.plugins import Plugin from flaschengeist.utils.decorators import login_required -from werkzeug.exceptions import BadRequest, Forbidden +from flaschengeist.utils.HTTP import no_content +from flaschengeist.models.session import Session +from flaschengeist.controller import userController from . import models from . import pricelist_controller, permissions -from ...controller import userController -from ...models.session import Session -from ...utils.HTTP import no_content -pricelist_bp = Blueprint("pricelist", __name__, url_prefix="/pricelist") +plugin_name = "pricelist" +pricelist_bp = Blueprint(plugin_name, __name__, url_prefix="/pricelist") +plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][plugin_name]) class PriceListPlugin(Plugin): @@ -28,10 +30,10 @@ class PriceListPlugin(Plugin): @pricelist_bp.route("/drink-types", methods=["GET"]) @pricelist_bp.route("/drink-types/", methods=["GET"]) def get_drink_types(identifier=None): - if identifier: - result = pricelist_controller.get_drink_type(identifier) - else: + if identifier is None: result = pricelist_controller.get_drink_types() + else: + result = pricelist_controller.get_drink_type(identifier) return jsonify(result) @@ -51,7 +53,7 @@ def update_drink_type(identifier, current_session): data = request.get_json() if "name" not in data: raise BadRequest - drink_type = pricelist_controller.rename_drink_type(data["id"], data["name"]) + drink_type = pricelist_controller.rename_drink_type(identifier, data["name"]) return jsonify(drink_type) @@ -59,7 +61,7 @@ def update_drink_type(identifier, current_session): @login_required(permission=permissions.DELETE_TYPE) def delete_drink_type(identifier, current_session): pricelist_controller.delete_drink_type(identifier) - return "", NO_CONTENT + return no_content() @pricelist_bp.route("/tags", methods=["GET"]) @@ -88,15 +90,15 @@ def update_tag(identifier, current_session): data = request.get_json() if "name" not in data: raise BadRequest - drink_type = pricelist_controller.rename_tag(data["name"]) - return jsonify(drink_type) + tag = pricelist_controller.rename_tag(identifier, data["name"]) + return jsonify(tag) @pricelist_bp.route("/tags/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_TAG) def delete_tag(identifier, current_session): pricelist_controller.delete_tag(identifier) - return "", NO_CONTENT + return no_content() @pricelist_bp.route("/drinks", methods=["GET"]) @@ -130,30 +132,30 @@ def update_drink(identifier): @pricelist_bp.route("/drinks/", methods=["DELETE"]) def delete_drink(identifier): pricelist_controller.delete_drink(identifier) - return "", NO_CONTENT + return no_content() @pricelist_bp.route("/prices/", methods=["DELETE"]) def delete_price(identifier): pricelist_controller.delete_price(identifier) - return "", NO_CONTENT + return no_content() @pricelist_bp.route("/volumes/", methods=["DELETE"]) def delete_volume(identifier): pricelist_controller.delete_volume(identifier) - return "", NO_CONTENT + return no_content() @pricelist_bp.route("/ingredients/extraIngredients", methods=["GET"]) -def get_extraIngredients(): +def get_extra_ingredients(): return jsonify(pricelist_controller.get_extra_ingredients()) @pricelist_bp.route("/ingredients/", methods=["DELETE"]) def delete_ingredient(identifier): pricelist_controller.delete_ingredient(identifier) - return "", NO_CONTENT + return no_content() @pricelist_bp.route("/ingredients/extraIngredients", methods=["POST"]) @@ -171,19 +173,19 @@ def update_extra_ingredient(identifier): @pricelist_bp.route("/ingredients/extraIngredients/", methods=["DELETE"]) def delete_extra_ingredient(identifier): pricelist_controller.delete_extra_ingredient(identifier) - return "", NO_CONTENT + return no_content() @pricelist_bp.route("/settings/min_prices", methods=["POST", "GET"]) def pricelist_settings_min_prices(): if request.method == "GET": - return jsonify(PriceListPlugin.get_setting(PriceListPlugin, "min_prices")) + return jsonify(plugin.get_setting("min_prices")) else: data = request.get_json() if not isinstance(data, list) or not all(isinstance(n, int) for n in data): raise BadRequest data.sort() - PriceListPlugin.set_setting(PriceListPlugin, "min_prices", data) + plugin.set_setting("min_prices", data) return no_content() diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 3d7c452..75eaf45 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -3,7 +3,7 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3 from flaschengeist.database import db from flaschengeist.models import ModelSerializeMixin -from typing import Optional, Union +from typing import Optional drink_tag_association = db.Table( "drink_x_tag", @@ -71,7 +71,7 @@ class DrinkIngredient(db.Model, ModelSerializeMixin): __tablename__ = "drink_ingredient" id: int = db.Column("id", db.Integer, primary_key=True) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) - drink_ingredient_id: int = db.Column("drink_ingredient_id", db.Integer, db.ForeignKey("drink.id")) + ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id")) # drink_ingredient: Drink = db.relationship("Drink") # price: float = 0 @@ -92,11 +92,12 @@ class Ingredient(db.Model, ModelSerializeMixin): __tablename__ = "ingredient_association" id: int = db.Column("id", db.Integer, primary_key=True) volume_id = db.Column(db.Integer, db.ForeignKey("drink_price_volume.id")) - drink_ingredient_id = db.Column(db.Integer, db.ForeignKey("drink_ingredient.id")) drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient) - extra_ingredient_id = db.Column(db.Integer, db.ForeignKey("extra_ingredient.id")) extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient) + _drink_ingredient_id = db.Column(db.Integer, db.ForeignKey("drink_ingredient.id")) + _extra_ingredient_id = db.Column(db.Integer, db.ForeignKey("extra_ingredient.id")) + class MinPrices(ModelSerializeMixin): """ @@ -134,8 +135,8 @@ class Drink(db.Model, ModelSerializeMixin): package_size: Optional[int] = db.Column(db.Integer) name: str = db.Column(db.String(60), nullable=False) volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) - cost_price_pro_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) - cost_price_package_netto: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) + cost_per_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) + cost_per_package: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) uuid = db.Column(db.String(36)) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index d2a88be..7de5d66 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -1,14 +1,13 @@ from werkzeug.exceptions import BadRequest, NotFound from sqlalchemy.exc import IntegrityError +from uuid import uuid4 from flaschengeist import logger from flaschengeist.config import config from flaschengeist.database import db -from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient - from flaschengeist.utils.picture import save_picture, get_picture -from uuid import uuid4 +from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient def update(): @@ -21,15 +20,15 @@ def get_tags(): def get_tag(identifier): if isinstance(identifier, int): - retVal = Tag.query.get(identifier) + ret = Tag.query.get(identifier) elif isinstance(identifier, str): - retVal = Tag.query.filter(Tag.name == identifier).one_or_none() + ret = Tag.query.filter(Tag.name == identifier).one_or_none() else: logger.debug("Invalid identifier type for Tag") raise BadRequest - if not retVal: + if ret is None: raise NotFound - return retVal + return ret def create_tag(name): @@ -66,23 +65,23 @@ def get_drink_types(): def get_drink_type(identifier): if isinstance(identifier, int): - retVal = DrinkType.query.get(identifier) + ret = DrinkType.query.get(identifier) elif isinstance(identifier, str): - retVal = DrinkType.query.filter(Tag.name == identifier).one_or_none() + ret = DrinkType.query.filter(Tag.name == identifier).one_or_none() else: logger.debug("Invalid identifier type for DrinkType") raise BadRequest - if not retVal: + if ret is None: raise NotFound - return retVal + return ret def create_drink_type(name): try: - drinkType = DrinkType(name=name) - db.session.add(drinkType) + drink_type = DrinkType(name=name) + db.session.add(drink_type) update() - return drinkType + return drink_type except IntegrityError: raise BadRequest("Name already exists") @@ -98,8 +97,8 @@ def rename_drink_type(identifier, new_name): def delete_drink_type(identifier): - drinkType = get_drink_type(identifier) - db.session.delete(drinkType) + drink_type = get_drink_type(identifier) + db.session.delete(drink_type) try: update() except IntegrityError: @@ -114,13 +113,14 @@ def get_drinks(name=None): def get_drink(identifier): if isinstance(identifier, int): - return Drink.query.get(identifier) + drink = Drink.query.get(identifier) elif isinstance(identifier, str): - return Drink.query.filter(Tag.name == identifier).one_or_none() + drink = Drink.query.filter(Tag.name == identifier).one_or_none() else: - logger.debug("Invalid identifier type for Drink") - raise BadRequest - raise NotFound + raise BadRequest("Invalid identifier type for Drink") + if drink is None: + raise NotFound + return drink def set_drink(data): @@ -128,44 +128,40 @@ def set_drink(data): def update_drink(identifier, data): - allowedKeys = Drink().serialize().keys() - if "id" in data: - data.pop("id") - if "volumes" in data: - volumes = data.pop("volumes") - if "tags" in data: - data.pop("tags") - type = None - if "type" in data: - _type = data.pop("type") - if isinstance(_type, dict) and "id" in _type: - type = get_drink_type(_type.get("id")) - if identifier == -1: - drink = Drink() - db.session.add(drink) - else: - drink = get_drink(identifier) - if not drink: - raise NotFound - for key, value in data.items(): - if hasattr(drink, key): - setattr(drink, key, value if value != "" else None) + try: + if "id" in data: + data.pop("id") + volumes = data.pop("volumes") if "volumes" in data else None + if "tags" in data: + data.pop("tags") + drink_type = data.pop("type") + if isinstance(drink_type, dict) and "id" in drink_type: + drink_type = drink_type["id"] + drink_type = get_drink_type(drink_type) + if identifier == -1: + drink = Drink() + db.session.add(drink) + else: + drink = get_drink(identifier) + for key, value in data.items(): + if hasattr(drink, key): + setattr(drink, key, value if value != "" else None) - if type: - drink.type = type - if volumes: - set_volumes(volumes, drink) - db.session.commit() - return drink + if drink_type: + drink.type = drink_type + if volumes is not None: + set_volumes(volumes, drink) + db.session.commit() + return drink + except (NotFound, KeyError): + raise BadRequest def set_volumes(volumes, drink): - if isinstance(volumes, list): - _volumes = [] - for _volume in volumes: - volume = set_volume(_volume) - _volumes.append(volume) - drink.volumes = _volumes + if not isinstance(volumes, list): + raise BadRequest + for volume in volumes: + drink.volumes.append(set_volume(volume)) def delete_drink(identifier): @@ -193,15 +189,12 @@ def set_volume(data): prices = values.pop("prices") if "ingredients" in values: ingredients = values.pop("ingredients") - id = None - if "id" in values: - id = values.pop("id") - volume = None - if id < 0: + vol_id = values.pop("id", None) + if vol_id < 0: volume = DrinkPriceVolume(**values) db.session.add(volume) else: - volume = get_volume(id) + volume = get_volume(vol_id) if not volume: raise NotFound for key, value in values.items(): @@ -253,15 +246,12 @@ def get_prices(volume_id=None): def set_price(data): allowed_keys = DrinkPrice().serialize().keys() values = {key: value for key, value in data.items() if key in allowed_keys} - id = None - if "id" in values: - id = values.pop("id") - price = None - if id < 0: + price_id = values.pop("id", -1) + if price_id < 0: price = DrinkPrice(**values) db.session.add(price) else: - price = get_price(id) + price = get_price(price_id) if not price: raise NotFound for key, value in values.items(): @@ -277,17 +267,14 @@ def delete_price(identifier): def set_drink_ingredient(data): - allowedKeys = DrinkIngredient().serialize().keys() - drink = None - values = {key: value for key, value in data.items() if key in allowedKeys} - id = None - if "id" in values: - id = values.pop("id") - if id < 0: + allowed_keys = DrinkIngredient().serialize().keys() + values = {key: value for key, value in data.items() if key in allowed_keys} + ingredient_id = values.pop("id", -1) + if ingredient_id < 0: drink_ingredient = DrinkIngredient(**values) db.session.add(drink_ingredient) else: - drink_ingredient = DrinkIngredient.query.get(id) + drink_ingredient = DrinkIngredient.query.get(ingredient_id) if not drink_ingredient: raise NotFound for key, value in values.items(): @@ -306,15 +293,12 @@ def set_ingredient(data): drink_ingredient_value = data.pop("drink_ingredient") if "extra_ingredient" in data: extra_ingredient_value = data.pop("extra_ingredient") - id = None - if "id" in data: - id = data.pop("id") - ingredient = None - if id < 0: + ingredient_id = data.pop("id", -1) + if ingredient_id < 0: ingredient = Ingredient(**data) db.session.add(ingredient) else: - ingredient = get_ingredient(id) + ingredient = get_ingredient(ingredient_id) if not ingredient: raise NotFound if drink_ingredient_value: @@ -342,10 +326,10 @@ def get_extra_ingredient(identifier): def set_extra_ingredient(data): - allowedKeys = ExtraIngredient().serialize().keys() + allowed_keys = ExtraIngredient().serialize().keys() if "id" in data: data.pop("id") - values = {key: value for key, value in data.items() if key in allowedKeys} + values = {key: value for key, value in data.items() if key in allowed_keys} extra_ingredient = ExtraIngredient(**values) db.session.add(extra_ingredient) db.session.commit() @@ -353,10 +337,10 @@ def set_extra_ingredient(data): def update_extra_ingredient(identifier, data): - allowedKeys = ExtraIngredient().serialize().keys() + allowed_keys = ExtraIngredient().serialize().keys() if "id" in data: data.pop("id") - values = {key: value for key, value in data.items() if key in allowedKeys} + values = {key: value for key, value in data.items() if key in allowed_keys} extra_ingredient = get_extra_ingredient(identifier) if extra_ingredient: for key, value in values.items(): From 775e775e89bc84885f5a2a7d3eaf1704eec724f0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 29 Mar 2021 07:30:56 +0200 Subject: [PATCH 247/446] [core][plugin] Added Notifications, restructure plugins --- flaschengeist/app.py | 5 +- flaschengeist/controller/userController.py | 13 + flaschengeist/models/__init__.py | 8 + flaschengeist/models/notification.py | 19 + flaschengeist/models/session.py | 6 +- flaschengeist/models/setting.py | 6 +- flaschengeist/models/user.py | 22 +- flaschengeist/plugins/__init__.py | 23 +- flaschengeist/plugins/auth/__init__.py | 20 +- flaschengeist/plugins/balance/__init__.py | 297 +----------- .../plugins/balance/balance_controller.py | 6 +- flaschengeist/plugins/balance/models.py | 12 +- flaschengeist/plugins/balance/routes.py | 279 +++++++++++ flaschengeist/plugins/events/__init__.py | 442 +----------------- .../plugins/events/event_controller.py | 51 +- flaschengeist/plugins/events/models.py | 32 +- flaschengeist/plugins/events/routes.py | 431 +++++++++++++++++ flaschengeist/plugins/pricelist/__init__.py | 64 +-- flaschengeist/plugins/roles/__init__.py | 18 +- flaschengeist/plugins/users/__init__.py | 57 ++- flaschengeist/utils/scheduler.py | 17 + run_flaschengeist | 2 +- 22 files changed, 990 insertions(+), 840 deletions(-) create mode 100644 flaschengeist/models/notification.py create mode 100644 flaschengeist/plugins/balance/routes.py create mode 100644 flaschengeist/plugins/events/routes.py create mode 100644 flaschengeist/utils/scheduler.py diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 2bdb2bb..e91f000 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -45,9 +45,10 @@ def __load_plugins(app): try: logger.info(f"Load plugin {entry_point.name}") plugin = entry_point.load() - setattr(plugin, "_plugin_name", entry_point.name) + if not hasattr(plugin, "name"): + setattr(plugin, "name", entry_point.name) plugin = plugin(config[entry_point.name]) - if plugin.blueprint: + if hasattr(plugin, "blueprint") and plugin.blueprint is not None: app.register_blueprint(plugin.blueprint) except: logger.error( diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index a84b795..2305f9a 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -6,6 +6,7 @@ from werkzeug.exceptions import NotFound, BadRequest, Forbidden from flaschengeist import logger from flaschengeist.config import config from flaschengeist.database import db +from flaschengeist.models.notification import Notification from flaschengeist.utils.hook import Hook from flaschengeist.utils.datetime import from_iso_format from flaschengeist.models.user import User, Role, _PasswordReset @@ -210,3 +211,15 @@ def persist(user=None): if user: db.session.add(user) db.session.commit() + + +def get_notifications(user, start=None): + query = Notification.query.filter(Notification.user_id_ == user.id_) + if start is not None: + query = query.filter(Notification.time > start) + return query.order_by(Notification.time).all() + + +def delete_notification(nid, user): + Notification.query.filter(Notification.id == nid).filter(Notification.user_ == user).delete() + db.session.commit() diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 5cee5bf..f63d173 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -1,6 +1,8 @@ import sys import datetime +from sqlalchemy import BigInteger +from sqlalchemy.dialects import mysql from sqlalchemy.types import DateTime, TypeDecorator @@ -39,6 +41,12 @@ class ModelSerializeMixin: return d +class Serial(TypeDecorator): + """Same as MariaDB Serial used for IDs""" + + impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql") + + class UtcDateTime(TypeDecorator): """Almost equivalent to `sqlalchemy.types.DateTime` with ``timezone=True`` option, but it differs from that by: diff --git a/flaschengeist/models/notification.py b/flaschengeist/models/notification.py new file mode 100644 index 0000000..574b089 --- /dev/null +++ b/flaschengeist/models/notification.py @@ -0,0 +1,19 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 +from datetime import datetime +from typing import Any + +from . import Serial, UtcDateTime, ModelSerializeMixin +from ..database import db +from .user import User + + +class Notification(db.Model, ModelSerializeMixin): + __tablename__ = "notification" + id: int = db.Column("id", Serial, primary_key=True) + plugin: str = db.Column(db.String(30), nullable=False) + text: str = db.Column(db.Text) + data: Any = db.Column(db.PickleType(protocol=4)) + time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) + + user_id_: int = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False) + user_: User = db.relationship("User") diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index 7822804..9acf27c 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -2,7 +2,7 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3 from datetime import datetime, timedelta, timezone -from . import ModelSerializeMixin, UtcDateTime +from . import ModelSerializeMixin, UtcDateTime, Serial from .user import User from flaschengeist.database import db from secrets import compare_digest @@ -26,8 +26,8 @@ class Session(db.Model, ModelSerializeMixin): platform: str = db.Column(db.String(30)) userid: str = "" - _id = db.Column("id", db.Integer, primary_key=True) - _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id")) + _id = db.Column("id", Serial, primary_key=True) + _user_id = db.Column("user_id", Serial, db.ForeignKey("user.id")) user_: User = db.relationship("User", back_populates="sessions_") @property diff --git a/flaschengeist/models/setting.py b/flaschengeist/models/setting.py index 6e8f74d..277f36c 100644 --- a/flaschengeist/models/setting.py +++ b/flaschengeist/models/setting.py @@ -1,11 +1,13 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 +from typing import Any +from . import Serial from ..database import db class _PluginSetting(db.Model): __tablename__ = "plugin_setting" - id = db.Column("id", db.Integer, primary_key=True) + id = db.Column("id", Serial, primary_key=True) plugin: str = db.Column(db.String(30)) name: str = db.Column(db.String(30), nullable=False) - value: any = db.Column(db.PickleType(protocol=4)) + value: Any = db.Column(db.PickleType(protocol=4)) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 4118120..43ef91e 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -6,19 +6,19 @@ from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection from ..database import db -from . import ModelSerializeMixin, UtcDateTime +from . import ModelSerializeMixin, UtcDateTime, Serial association_table = db.Table( "user_x_role", - db.Column("user_id", db.Integer, db.ForeignKey("user.id")), - db.Column("role_id", db.Integer, db.ForeignKey("role.id")), + db.Column("user_id", Serial, db.ForeignKey("user.id")), + db.Column("role_id", Serial, db.ForeignKey("role.id")), ) role_permission_association_table = db.Table( "role_x_permission", - db.Column("role_id", db.Integer, db.ForeignKey("role.id")), - db.Column("permission_id", db.Integer, db.ForeignKey("permission.id")), + db.Column("role_id", Serial, db.ForeignKey("role.id")), + db.Column("permission_id", Serial, db.ForeignKey("permission.id")), ) @@ -26,12 +26,12 @@ class Permission(db.Model, ModelSerializeMixin): __tablename__ = "permission" name: str = db.Column(db.String(30), unique=True) - _id = db.Column("id", db.Integer, primary_key=True) + _id = db.Column("id", Serial, primary_key=True) class Role(db.Model, ModelSerializeMixin): __tablename__ = "role" - id: int = db.Column(db.Integer, primary_key=True) + id: int = db.Column(Serial, primary_key=True) name: str = db.Column(db.String(30), unique=True) permissions: list[Permission] = db.relationship("Permission", secondary=role_permission_association_table) @@ -62,7 +62,7 @@ class User(db.Model, ModelSerializeMixin): permissions: Optional[list[str]] = None avatar_url: Optional[str] = "" - id_ = db.Column("id", db.Integer, primary_key=True) + id_ = db.Column("id", Serial, primary_key=True) roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") sessions_ = db.relationship("Session", back_populates="user_") @@ -101,8 +101,8 @@ class User(db.Model, ModelSerializeMixin): class _UserAttribute(db.Model, ModelSerializeMixin): __tablename__ = "user_attribute" - id = db.Column("id", db.Integer, primary_key=True) - user: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), nullable=False) + id = db.Column("id", Serial, primary_key=True) + user: User = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False) name: str = db.Column(db.String(30)) value: any = db.Column(db.PickleType(protocol=4)) @@ -111,7 +111,7 @@ class _PasswordReset(db.Model): """Table containing password reset requests""" __tablename__ = "password_reset" - _user_id: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), primary_key=True) + _user_id: User = db.Column("user", Serial, db.ForeignKey("user.id"), primary_key=True) user: User = db.relationship("User", foreign_keys=[_user_id]) token: str = db.Column(db.String(32)) expires: datetime = db.Column(UtcDateTime) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 6665830..094eae7 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -3,6 +3,7 @@ import pkg_resources from werkzeug.exceptions import MethodNotAllowed, NotFound from flaschengeist.database import db +from flaschengeist.models.notification import Notification from flaschengeist.models.user import _Avatar from flaschengeist.models.setting import _PluginSetting from flaschengeist.utils.hook import HookBefore, HookAfter @@ -30,15 +31,16 @@ class Plugin: """Base class for all Plugins If your class uses custom models add a static property called ``models``""" - def __init__(self, config=None, blueprint=None, permissions=[]): + blueprint = None # You have to override + permissions = [] # You have to override + name = "plugin" # You have to override + models = None # You have to override + + def __init__(self, config=None): """Constructor called by create_app Args: config: Dict configuration containing the plugin section - blueprint: A flask blueprint containing all plugin routes - permissions: List of permissions of this Plugin """ - self.blueprint = blueprint - self.permissions = permissions self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version def install(self): @@ -63,7 +65,7 @@ class Plugin: """ try: setting = ( - _PluginSetting.query.filter(_PluginSetting.plugin == self._plugin_name) + _PluginSetting.query.filter(_PluginSetting.plugin == self.name) .filter(_PluginSetting.name == name) .one() ) @@ -81,14 +83,19 @@ class Plugin: value: Value to be stored """ setting = ( - _PluginSetting.query.filter(_PluginSetting.plugin == self._plugin_name) + _PluginSetting.query.filter(_PluginSetting.plugin == self.name) .filter(_PluginSetting.name == name) .one_or_none() ) if setting is not None: setting.value = value else: - db.session.add(_PluginSetting(plugin=self._plugin_name, name=name, value=value)) + db.session.add(_PluginSetting(plugin=self.name, name=name, value=value)) + db.session.commit() + + def notify(self, user, text: str, data=None): + n = Notification(text=text, data=data, plugin=self.name, user_=user) + db.session.add(n) db.session.commit() def serialize(self): diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index f2289f0..3874f79 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -11,15 +11,13 @@ from flaschengeist.utils.HTTP import no_content, created from flaschengeist.utils.decorators import login_required from flaschengeist.controller import sessionController, userController -auth_bp = Blueprint("auth", __name__) - class AuthRoutePlugin(Plugin): - def __init__(self, conf): - super().__init__(blueprint=auth_bp) + name = "auth" + blueprint = Blueprint(name, __name__) -@auth_bp.route("/auth", methods=["POST"]) +@AuthRoutePlugin.blueprint.route("/auth", methods=["POST"]) def login(): """Login in an user and create a session @@ -52,7 +50,7 @@ def login(): return created(session) -@auth_bp.route("/auth", methods=["GET"]) +@AuthRoutePlugin.blueprint.route("/auth", methods=["GET"]) @login_required() def get_sessions(current_session, **kwargs): """Get all valid sessions of current user @@ -66,7 +64,7 @@ def get_sessions(current_session, **kwargs): return jsonify(sessions) -@auth_bp.route("/auth/", methods=["DELETE"]) +@AuthRoutePlugin.blueprint.route("/auth/", methods=["DELETE"]) @login_required() def delete_session(token, current_session, **kwargs): """Delete a session aka "logout" @@ -88,7 +86,7 @@ def delete_session(token, current_session, **kwargs): return "" -@auth_bp.route("/auth/", methods=["GET"]) +@AuthRoutePlugin.blueprint.route("/auth/", methods=["GET"]) @login_required() def get_session(token, current_session, **kwargs): """Retrieve information about a session @@ -111,7 +109,7 @@ def get_session(token, current_session, **kwargs): return jsonify(session) -@auth_bp.route("/auth/", methods=["PUT"]) +@AuthRoutePlugin.blueprint.route("/auth/", methods=["PUT"]) @login_required() def set_lifetime(token, current_session, **kwargs): """Set lifetime of a session @@ -141,7 +139,7 @@ def set_lifetime(token, current_session, **kwargs): raise BadRequest -@auth_bp.route("/auth//user", methods=["GET"]) +@AuthRoutePlugin.blueprint.route("/auth//user", methods=["GET"]) @login_required() def get_assocd_user(token, current_session, **kwargs): """Retrieve user owning a session @@ -164,7 +162,7 @@ def get_assocd_user(token, current_session, **kwargs): return jsonify(session.user_) -@auth_bp.route("/auth/reset", methods=["POST"]) +@AuthRoutePlugin.blueprint.route("/auth/reset", methods=["POST"]) def reset_password(): data = request.get_json() if "userid" in data: diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index fc424bb..a7558f9 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -3,30 +3,25 @@ Extends users plugin with balance functions """ -from datetime import datetime, timezone - -from flaschengeist.utils.HTTP import no_content -from flask import Blueprint, request, jsonify -from werkzeug.exceptions import Forbidden, BadRequest +from werkzeug.local import LocalProxy +from flask import Blueprint, current_app from flaschengeist import logger -from flaschengeist.utils import HTTP -from flaschengeist.models.session import Session -from flaschengeist.utils.datetime import from_iso_format -from flaschengeist.utils.decorators import login_required -from flaschengeist.controller import userController from flaschengeist.plugins import Plugin, before_update_user -from . import balance_controller, permissions, models - -balance_bp = Blueprint("balance", __name__) +from . import permissions, models class BalancePlugin(Plugin): + name = "balance" + blueprint = Blueprint(name, __name__) + permissions = permissions.permissions + plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name]) models = models def __init__(self, config): - super().__init__(blueprint=balance_bp, permissions=permissions.permissions) + super(BalancePlugin, self).__init__(config) + from . import routes, balance_controller @before_update_user def set_default_limit(user): @@ -36,277 +31,3 @@ class BalancePlugin(Plugin): balance_controller.set_limit(user, limit, override=False) except KeyError: pass - - def install(self): - from flaschengeist.database import db - - db.create_all() - - -def str2bool(string: str): - if string.lower() in ["true", "yes", "1"]: - return True - elif string.lower() in ["false", "no", "0"]: - return False - raise ValueError - - -@balance_bp.route("/users//balance/shortcuts", methods=["GET", "PUT"]) -@login_required() -def get_shortcuts(userid, current_session: Session): - """Get balance shortcuts of an user - - Route: ``/users//balance/shortcuts`` | Method: ``GET`` or ``PUT`` - POST-data: On ``PUT`` json encoded array of floats - - Args: - userid: Userid identifying the user - current_session: Session sent with Authorization Header - - Returns: - GET: JSON object containing the shortcuts as float array or HTTP error - PUT: HTTP-created or HTTP error - """ - if userid != current_session.user_.userid: - raise Forbidden - - user = userController.get_user(userid) - if request.method == "GET": - return jsonify(user.get_attribute("balance_shortcuts", [])) - else: - data = request.get_json() - if not isinstance(data, list) or not all(isinstance(n, (int, float)) for n in data): - raise BadRequest - data.sort(reverse=True) - user.set_attribute("balance_shortcuts", data) - userController.persist() - return no_content() - - -@balance_bp.route("/users//balance/limit", methods=["GET"]) -@login_required() -def get_limit(userid, current_session: Session): - """Get limit of an user - - Route: ``/users//balance/limit`` | Method: ``GET`` - - Args: - userid: Userid identifying the user - current_session: Session sent with Authorization Header - - Returns: - JSON object containing the limit (or Null if no limit set) or HTTP error - """ - user = userController.get_user(userid) - if (user != current_session.user_ and not current_session.user_.has_permission(permissions.SET_LIMIT)) or ( - user == current_session.user_ and not user.has_permission(permissions.SHOW) - ): - raise Forbidden - - return {"limit": balance_controller.get_limit(user)} - - -@balance_bp.route("/users//balance/limit", methods=["PUT"]) -@login_required(permissions.SET_LIMIT) -def set_limit(userid, current_session: Session): - """Set the limit of an user - - Route: ``/users//balance/limit`` | Method: ``PUT`` - - POST-data: ``{limit: float}`` - - Args: - userid: Userid identifying the user - current_session: Session sent with Authorization Header - - Returns: - HTTP-200 or HTTP error - """ - user = userController.get_user(userid) - data = request.get_json() - try: - limit = data["limit"] - except (TypeError, KeyError): - raise BadRequest - balance_controller.set_limit(user, limit) - return HTTP.no_content() - - -@balance_bp.route("/users//balance", methods=["GET"]) -@login_required(permission=permissions.SHOW) -def get_balance(userid, current_session: Session): - """Get balance of user, optionally filtered - - Route: ``/users//balance`` | Method: ``GET`` - - GET-parameters: ```{from?: string, to?: string}``` - - Args: - userid: Userid of user to get balance from - current_session: Session sent with Authorization Header - - Returns: - JSON object containing credit, debit and balance or HTTP error - """ - if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER): - raise Forbidden - - # Might raise NotFound - user = userController.get_user(userid) - - start = request.args.get("from") - if start: - start = from_iso_format(start) - else: - start = datetime.fromtimestamp(0, tz=timezone.utc) - - end = request.args.get("to") - if end: - end = from_iso_format(end) - else: - end = datetime.now(tz=timezone.utc) - - balance = balance_controller.get_balance(user, start, end) - return {"credit": balance[0], "debit": balance[1], "balance": balance[2]} - - -@balance_bp.route("/users//balance/transactions", methods=["GET"]) -@login_required(permission=permissions.SHOW) -def get_transactions(userid, current_session: Session): - """Get transactions of user, optionally filtered - Returns also count of transactions if limit is set (e.g. just count with limit = 0) - - Route: ``/users//balance/transactions`` | Method: ``GET`` - - GET-parameters: ```{from?: string, to?: string, limit?: int, offset?: int}``` - - Args: - userid: Userid of user to get transactions from - current_session: Session sent with Authorization Header - - Returns: - JSON Object {transactions: Transaction[], count?: number} or HTTP error - """ - if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER): - raise Forbidden - - # Might raise NotFound - user = userController.get_user(userid) - - start = request.args.get("from") - if start: - start = from_iso_format(start) - end = request.args.get("to") - if end: - end = from_iso_format(end) - show_reversals = request.args.get("showReversals", False) - show_cancelled = request.args.get("showCancelled", True) - limit = request.args.get("limit") - offset = request.args.get("offset") - try: - if limit is not None: - limit = int(limit) - if offset is not None: - offset = int(offset) - if not isinstance(show_reversals, bool): - show_reversals = str2bool(show_reversals) - if not isinstance(show_cancelled, bool): - show_cancelled = str2bool(show_cancelled) - except ValueError: - raise BadRequest - - transactions, count = balance_controller.get_transactions( - user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled - ) - return {"transactions": transactions, "count": count} - - -@balance_bp.route("/users//balance", methods=["PUT"]) -@login_required() -def change_balance(userid, current_session: Session): - """Change balance of an user - If ``sender`` is preset in POST-data, the action is handled as a transfer from ``sender`` to user. - - Route: ``/users//balance`` | Method: ``PUT`` - - POST-data: ``{amount: float, sender: string}`` - - Args: - userid: userid identifying user to change balance - current_session: Session sent with Authorization Header - - Returns: - JSON encoded transaction (201) or HTTP error - """ - - data = request.get_json() - try: - amount = data["amount"] - except (TypeError, KeyError): - raise BadRequest - - sender = data.get("sender", None) - user = userController.get_user(userid) - - if sender: - sender = userController.get_user(sender) - if sender == user: - raise BadRequest - - if (sender == current_session.user_ and sender.has_permission(permissions.SEND)) or ( - sender != current_session.user_ and current_session.user_.has_permission(permissions.SEND_OTHER) - ): - return HTTP.created(balance_controller.send(sender, user, amount, current_session.user_)) - - elif ( - amount < 0 - and ( - (user == current_session.user_ and user.has_permission(permissions.DEBIT_OWN)) - or current_session.user_.has_permission(permissions.DEBIT) - ) - ) or (amount > 0 and current_session.user_.has_permission(permissions.CREDIT)): - return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session.user_)) - - raise Forbidden - - -@balance_bp.route("/balance/", methods=["DELETE"]) -@login_required() -def reverse_transaction(transaction_id, current_session: Session): - """Reverse a transaction - - Route: ``/balance/`` | Method: ``DELETE`` - - Args: - transaction_id: Identifier of the transaction - current_session: Session sent with Authorization Header - - Returns: - JSON encoded reversal (transaction) (201) or HTTP error - """ - - transaction = balance_controller.get_transaction(transaction_id) - if current_session.user_.has_permission(permissions.REVERSAL) or ( - transaction.sender_ == current_session.user_ - and (datetime.now(tz=timezone.utc) - transaction.time).total_seconds() < 10 - ): - reversal = balance_controller.reverse_transaction(transaction, current_session.user_) - return HTTP.created(reversal) - raise Forbidden - - -@balance_bp.route("/balance", methods=["GET"]) -@login_required(permission=permissions.SHOW_OTHER) -def get_balances(current_session: Session): - """Get all balances - - Route: ``/balance`` | Method: ``GET`` - - Args: - current_session: Session sent with Authorization Header - - Returns: - JSON Array containing credit, debit and userid for each user or HTTP error - """ - balances = balance_controller.get_balances() - return jsonify([{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()]) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index ea4d499..0ab282f 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -11,7 +11,7 @@ from flaschengeist.database import db from flaschengeist.models.user import User from .models import Transaction -from . import permissions +from . import permissions, BalancePlugin __attribute_limit = "balance_limit" @@ -86,6 +86,10 @@ def send(sender: User, receiver, amount: float, author: User): transaction = Transaction(sender_=sender, receiver_=receiver, amount=amount, author_=author) db.session.add(transaction) db.session.commit() + if sender is not None and sender.id_ != author.id_: + BalancePlugin.plugin.notify(sender, "Neue Transaktion") + if receiver is not None and receiver.id_ != author.id_: + BalancePlugin.plugin.notify(receiver, "Neue Transaktion") return transaction diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index be504aa..23bb32c 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -6,21 +6,21 @@ from sqlalchemy.ext.hybrid import hybrid_property from flaschengeist.database import db from flaschengeist.models.user import User -from flaschengeist.models import ModelSerializeMixin, UtcDateTime +from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial class Transaction(db.Model, ModelSerializeMixin): __tablename__ = "balance_transaction" # Protected foreign key properties - _receiver_id = db.Column("receiver_id", db.Integer, db.ForeignKey("user.id")) - _sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id")) - _author_id = db.Column("author_id", db.Integer, db.ForeignKey("user.id"), nullable=False) + _receiver_id = db.Column("receiver_id", Serial, db.ForeignKey("user.id")) + _sender_id = db.Column("sender_id", Serial, db.ForeignKey("user.id")) + _author_id = db.Column("author_id", Serial, db.ForeignKey("user.id"), nullable=False) # Public and exported member - id: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column("id", Serial, primary_key=True) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) - reversal_id: Optional[int] = db.Column(db.Integer, db.ForeignKey("balance_transaction.id")) + reversal_id: Optional[int] = db.Column(Serial, db.ForeignKey("balance_transaction.id")) # Dummy properties used for JSON serialization (userid instead of full user) author_id: Optional[str] = None diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py new file mode 100644 index 0000000..d28f913 --- /dev/null +++ b/flaschengeist/plugins/balance/routes.py @@ -0,0 +1,279 @@ +from datetime import datetime, timezone +from werkzeug.exceptions import Forbidden, BadRequest +from flask import request, jsonify + +from flaschengeist.utils import HTTP +from flaschengeist.models.session import Session +from flaschengeist.utils.datetime import from_iso_format +from flaschengeist.utils.decorators import login_required +from flaschengeist.controller import userController +from . import BalancePlugin, balance_controller, permissions + + +def str2bool(string: str): + if string.lower() in ["true", "yes", "1"]: + return True + elif string.lower() in ["false", "no", "0"]: + return False + raise ValueError + + +@BalancePlugin.blueprint.route("/users//balance/shortcuts", methods=["GET", "PUT"]) +@login_required() +def get_shortcuts(userid, current_session: Session): + """Get balance shortcuts of an user + + Route: ``/users//balance/shortcuts`` | Method: ``GET`` or ``PUT`` + POST-data: On ``PUT`` json encoded array of floats + + Args: + userid: Userid identifying the user + current_session: Session sent with Authorization Header + + Returns: + GET: JSON object containing the shortcuts as float array or HTTP error + PUT: HTTP-created or HTTP error + """ + if userid != current_session.user_.userid: + raise Forbidden + + user = userController.get_user(userid) + if request.method == "GET": + return jsonify(user.get_attribute("balance_shortcuts", [])) + else: + data = request.get_json() + if not isinstance(data, list) or not all(isinstance(n, (int, float)) for n in data): + raise BadRequest + data.sort(reverse=True) + user.set_attribute("balance_shortcuts", data) + userController.persist() + return HTTP.no_content() + + +@BalancePlugin.blueprint.route("/users//balance/limit", methods=["GET"]) +@login_required() +def get_limit(userid, current_session: Session): + """Get limit of an user + + Route: ``/users//balance/limit`` | Method: ``GET`` + + Args: + userid: Userid identifying the user + current_session: Session sent with Authorization Header + + Returns: + JSON object containing the limit (or Null if no limit set) or HTTP error + """ + user = userController.get_user(userid) + if (user != current_session.user_ and not current_session.user_.has_permission(permissions.SET_LIMIT)) or ( + user == current_session.user_ and not user.has_permission(permissions.SHOW) + ): + raise Forbidden + + return {"limit": balance_controller.get_limit(user)} + + +@BalancePlugin.blueprint.route("/users//balance/limit", methods=["PUT"]) +@login_required(permissions.SET_LIMIT) +def set_limit(userid, current_session: Session): + """Set the limit of an user + + Route: ``/users//balance/limit`` | Method: ``PUT`` + + POST-data: ``{limit: float}`` + + Args: + userid: Userid identifying the user + current_session: Session sent with Authorization Header + + Returns: + HTTP-200 or HTTP error + """ + user = userController.get_user(userid) + data = request.get_json() + try: + limit = data["limit"] + except (TypeError, KeyError): + raise BadRequest + balance_controller.set_limit(user, limit) + return HTTP.no_content() + + +@BalancePlugin.blueprint.route("/users//balance", methods=["GET"]) +@login_required(permission=permissions.SHOW) +def get_balance(userid, current_session: Session): + """Get balance of user, optionally filtered + + Route: ``/users//balance`` | Method: ``GET`` + + GET-parameters: ```{from?: string, to?: string}``` + + Args: + userid: Userid of user to get balance from + current_session: Session sent with Authorization Header + + Returns: + JSON object containing credit, debit and balance or HTTP error + """ + if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER): + raise Forbidden + + # Might raise NotFound + user = userController.get_user(userid) + + start = request.args.get("from") + if start: + start = from_iso_format(start) + else: + start = datetime.fromtimestamp(0, tz=timezone.utc) + + end = request.args.get("to") + if end: + end = from_iso_format(end) + else: + end = datetime.now(tz=timezone.utc) + + balance = balance_controller.get_balance(user, start, end) + return {"credit": balance[0], "debit": balance[1], "balance": balance[2]} + + +@BalancePlugin.blueprint.route("/users//balance/transactions", methods=["GET"]) +@login_required(permission=permissions.SHOW) +def get_transactions(userid, current_session: Session): + """Get transactions of user, optionally filtered + Returns also count of transactions if limit is set (e.g. just count with limit = 0) + + Route: ``/users//balance/transactions`` | Method: ``GET`` + + GET-parameters: ```{from?: string, to?: string, limit?: int, offset?: int}``` + + Args: + userid: Userid of user to get transactions from + current_session: Session sent with Authorization Header + + Returns: + JSON Object {transactions: Transaction[], count?: number} or HTTP error + """ + if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER): + raise Forbidden + + # Might raise NotFound + user = userController.get_user(userid) + + start = request.args.get("from") + if start: + start = from_iso_format(start) + end = request.args.get("to") + if end: + end = from_iso_format(end) + show_reversals = request.args.get("showReversals", False) + show_cancelled = request.args.get("showCancelled", True) + limit = request.args.get("limit") + offset = request.args.get("offset") + try: + if limit is not None: + limit = int(limit) + if offset is not None: + offset = int(offset) + if not isinstance(show_reversals, bool): + show_reversals = str2bool(show_reversals) + if not isinstance(show_cancelled, bool): + show_cancelled = str2bool(show_cancelled) + except ValueError: + raise BadRequest + + transactions, count = balance_controller.get_transactions( + user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled + ) + return {"transactions": transactions, "count": count} + + +@BalancePlugin.blueprint.route("/users//balance", methods=["PUT"]) +@login_required() +def change_balance(userid, current_session: Session): + """Change balance of an user + If ``sender`` is preset in POST-data, the action is handled as a transfer from ``sender`` to user. + + Route: ``/users//balance`` | Method: ``PUT`` + + POST-data: ``{amount: float, sender: string}`` + + Args: + userid: userid identifying user to change balance + current_session: Session sent with Authorization Header + + Returns: + JSON encoded transaction (201) or HTTP error + """ + + data = request.get_json() + try: + amount = data["amount"] + except (TypeError, KeyError): + raise BadRequest + + sender = data.get("sender", None) + user = userController.get_user(userid) + + if sender: + sender = userController.get_user(sender) + if sender == user: + raise BadRequest + + if (sender == current_session.user_ and sender.has_permission(permissions.SEND)) or ( + sender != current_session.user_ and current_session.user_.has_permission(permissions.SEND_OTHER) + ): + return HTTP.created(balance_controller.send(sender, user, amount, current_session.user_)) + + elif ( + amount < 0 + and ( + (user == current_session.user_ and user.has_permission(permissions.DEBIT_OWN)) + or current_session.user_.has_permission(permissions.DEBIT) + ) + ) or (amount > 0 and current_session.user_.has_permission(permissions.CREDIT)): + return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session.user_)) + + raise Forbidden + + +@BalancePlugin.blueprint.route("/balance/", methods=["DELETE"]) +@login_required() +def reverse_transaction(transaction_id, current_session: Session): + """Reverse a transaction + + Route: ``/balance/`` | Method: ``DELETE`` + + Args: + transaction_id: Identifier of the transaction + current_session: Session sent with Authorization Header + + Returns: + JSON encoded reversal (transaction) (201) or HTTP error + """ + + transaction = balance_controller.get_transaction(transaction_id) + if current_session.user_.has_permission(permissions.REVERSAL) or ( + transaction.sender_ == current_session.user_ + and (datetime.now(tz=timezone.utc) - transaction.time).total_seconds() < 10 + ): + reversal = balance_controller.reverse_transaction(transaction, current_session.user_) + return HTTP.created(reversal) + raise Forbidden + + +@BalancePlugin.blueprint.route("/balance", methods=["GET"]) +@login_required(permission=permissions.SHOW_OTHER) +def get_balances(current_session: Session): + """Get all balances + + Route: ``/balance`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON Array containing credit, debit and userid for each user or HTTP error + """ + balances = balance_controller.get_balances() + return jsonify([{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()]) diff --git a/flaschengeist/plugins/events/__init__.py b/flaschengeist/plugins/events/__init__.py index b0c7b98..e4a0af0 100644 --- a/flaschengeist/plugins/events/__init__.py +++ b/flaschengeist/plugins/events/__init__.py @@ -1,441 +1,21 @@ -"""Schedule plugin +"""Events plugin Provides duty schedule / duty roster functions """ -from datetime import datetime, timedelta, timezone -from http.client import NO_CONTENT -from flask import Blueprint, request, jsonify -from werkzeug.exceptions import BadRequest, NotFound, Forbidden +from flask import Blueprint, current_app +from werkzeug.local import LocalProxy from flaschengeist.plugins import Plugin -from flaschengeist.models.session import Session -from flaschengeist.utils.decorators import login_required -from flaschengeist.utils.datetime import from_iso_format -from flaschengeist.controller import userController - -from . import event_controller, permissions -from . import models -from ...utils.HTTP import no_content - -events_bp = Blueprint("events", __name__) +from . import permissions, models class EventPlugin(Plugin): + name = "events" + plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name]) + permissions = permissions.permissions + blueprint = Blueprint(name, __name__) models = models - def __init__(self, config): - super().__init__( - blueprint=events_bp, - permissions=permissions.permissions, - ) - - -@events_bp.route("/events/templates", methods=["GET"]) -@login_required() -def get_templates(current_session): - return jsonify(event_controller.get_templates()) - - -@events_bp.route("/events/event-types", methods=["GET"]) -@events_bp.route("/events/event-types/", methods=["GET"]) -@login_required() -def get_event_types(current_session, identifier=None): - """Get EventType(s) - - Route: ``/events/event-types`` | Method: ``GET`` - Route: ``/events/event-types/`` | Method: ``GET`` - - Args: - current_session: Session sent with Authorization Header - identifier: If querying a specific EventType - - Returns: - JSON encoded (list of) EventType(s) or HTTP-error - """ - if identifier: - result = event_controller.get_event_type(identifier) - else: - result = event_controller.get_event_types() - return jsonify(result) - - -@events_bp.route("/events/event-types", methods=["POST"]) -@login_required(permission=permissions.EVENT_TYPE) -def new_event_type(current_session): - """Create a new EventType - - Route: ``/events/event-types`` | Method: ``POST`` - - POST-data: ``{name: string}`` - - Args: - current_session: Session sent with Authorization Header - - Returns: - HTTP-Created or HTTP-error - """ - data = request.get_json() - if "name" not in data: - raise BadRequest - event_type = event_controller.create_event_type(data["name"]) - return jsonify(event_type) - - -@events_bp.route("/events/event-types/", methods=["PUT", "DELETE"]) -@login_required(permission=permissions.EVENT_TYPE) -def modify_event_type(identifier, current_session): - """Rename or delete an event type - - Route: ``/events/event-types/`` | Method: ``PUT`` or ``DELETE`` - - POST-data: (if renaming) ``{name: string}`` - - Args: - identifier: Identifier of the EventType - current_session: Session sent with Authorization Header - - Returns: - HTTP-NoContent or HTTP-error - """ - if request.method == "DELETE": - event_controller.delete_event_type(identifier) - else: - data = request.get_json() - if "name" not in data: - raise BadRequest("Parameter missing in data") - event_controller.rename_event_type(identifier, data["name"]) - return "", NO_CONTENT - - -@events_bp.route("/events/job-types", methods=["GET"]) -@login_required() -def get_job_types(current_session): - """Get all JobTypes - - Route: ``/events/job-types`` | Method: ``GET`` - - Args: - current_session: Session sent with Authorization Header - - Returns: - JSON encoded list of JobType HTTP-error - """ - types = event_controller.get_job_types() - return jsonify(types) - - -@events_bp.route("/events/job-types", methods=["POST"]) -@login_required(permission=permissions.JOB_TYPE) -def new_job_type(current_session): - """Create a new JobType - - Route: ``/events/job-types`` | Method: ``POST`` - - POST-data: ``{name: string}`` - - Args: - current_session: Session sent with Authorization Header - - Returns: - JSON encoded JobType or HTTP-error - """ - data = request.get_json() - if "name" not in data: - raise BadRequest - jt = event_controller.create_job_type(data["name"]) - return jsonify(jt) - - -@events_bp.route("/events/job-types/", methods=["PUT", "DELETE"]) -@login_required(permission=permissions.JOB_TYPE) -def modify_job_type(type_id, current_session): - """Rename or delete a JobType - - Route: ``/events/job-types/`` | Method: ``PUT`` or ``DELETE`` - - POST-data: (if renaming) ``{name: string}`` - - Args: - type_id: Identifier of the JobType - current_session: Session sent with Authorization Header - - Returns: - HTTP-NoContent or HTTP-error - """ - if request.method == "DELETE": - event_controller.delete_job_type(type_id) - else: - data = request.get_json() - if "name" not in data: - raise BadRequest("Parameter missing in data") - event_controller.rename_job_type(type_id, data["name"]) - return "", NO_CONTENT - - -@events_bp.route("/events/", methods=["GET"]) -@login_required() -def get_event(event_id, current_session): - """Get event by id - - Route: ``/events/`` | Method: ``GET`` - - Args: - event_id: ID identifying the event - current_session: Session sent with Authorization Header - - Returns: - JSON encoded event object - """ - event = event_controller.get_event(event_id) - return jsonify(event) - - -@events_bp.route("/events", methods=["GET"]) -@login_required() -def get_filtered_events(current_session): - begin = request.args.get("from") - if begin is not None: - begin = from_iso_format(begin) - end = request.args.get("to") - if end is not None: - end = from_iso_format(end) - if begin is None and end is None: - begin = datetime.now() - return jsonify(event_controller.get_events(begin, end)) - - -@events_bp.route("/events//", methods=["GET"]) -@events_bp.route("/events///", methods=["GET"]) -@login_required() -def get_events(current_session, year=datetime.now().year, month=datetime.now().month, day=None): - """Get Event objects for specified date (or month or year), - if nothing set then events for current month are returned - - Route: ``/events[//[/]]`` | Method: ``GET`` - - Args: - year (int, optional): year to query, defaults to current year - month (int, optional): month to query (if set), defaults to current month - day (int, optional): day to query events for (if set) - current_session: Session sent with Authorization Header - - Returns: - JSON encoded list containing events found or HTTP-error - """ - try: - begin = datetime(year=year, month=month, day=1, tzinfo=timezone.utc) - if day: - begin += timedelta(days=day - 1) - end = begin + timedelta(days=1) - else: - if month == 12: - end = datetime(year=year + 1, month=1, day=1, tzinfo=timezone.utc) - else: - end = datetime(year=year, month=month + 1, day=1, tzinfo=timezone.utc) - - events = event_controller.get_events(begin, end) - return jsonify(events) - except ValueError: - raise BadRequest("Invalid date given") - - -def _add_job(event, data): - try: - start = from_iso_format(data["start"]) - end = None - if "end" in data: - end = from_iso_format(data["end"]) - required_services = data["required_services"] - job_type = data["type"] - if isinstance(job_type, dict): - job_type = data["type"]["id"] - except (KeyError, ValueError): - raise BadRequest("Missing or invalid POST parameter") - - job_type = event_controller.get_job_type(job_type) - event_controller.add_job(event, job_type, required_services, start, end, comment=data.get("comment", None)) - - -@events_bp.route("/events", methods=["POST"]) -@login_required(permission=permissions.CREATE) -def create_event(current_session): - """Create an new event - - Route: ``/events`` | Method: ``POST`` - - POST-data: See interfaces for Event, can already contain jobs - - Args: - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Event object or HTTP-error - """ - data = request.get_json() - end = data.get("end", None) - try: - start = from_iso_format(data["start"]) - if end is not None: - end = from_iso_format(end) - data_type = data["type"] - if isinstance(data_type, dict): - data_type = data["type"]["id"] - event_type = event_controller.get_event_type(data_type) - except KeyError: - raise BadRequest("Missing POST parameter") - except (NotFound, ValueError): - raise BadRequest("Invalid parameter") - - event = event_controller.create_event( - start=start, - end=end, - name=data.get("name", None), - is_template=data.get("is_template", None), - event_type=event_type, - description=data.get("description", None), - ) - if "jobs" in data: - for job in data["jobs"]: - _add_job(event, job) - - return jsonify(event) - - -@events_bp.route("/events/", methods=["PUT"]) -@login_required(permission=permissions.EDIT) -def modify_event(event_id, current_session): - """Modify an event - - Route: ``/events/`` | Method: ``PUT`` - - POST-data: See interfaces for Event, can already contain slots - - Args: - event_id: Identifier of the event - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Event object or HTTP-error - """ - event = event_controller.get_event(event_id) - data = request.get_json() - if "start" in data: - event.start = from_iso_format(data["start"]) - if "end" in data: - event.end = from_iso_format(data["end"]) - if "description" in data: - event.description = data["description"] - if "type" in data: - event_type = event_controller.get_event_type(data["type"]) - event.type = event_type - event_controller.update() - return jsonify(event) - - -@events_bp.route("/events/", methods=["DELETE"]) -@login_required(permission=permissions.DELETE) -def delete_event(event_id, current_session): - """Delete an event - - Route: ``/events/`` | Method: ``DELETE`` - - Args: - event_id: Identifier of the event - current_session: Session sent with Authorization Header - - Returns: - HTTP-NoContent or HTTP-error - """ - event_controller.delete_event(event_id) - return "", NO_CONTENT - - -@events_bp.route("/events//jobs", methods=["POST"]) -@login_required(permission=permissions.EDIT) -def add_job(event_id, current_session): - """Add an new Job to an Event / EventSlot - - Route: ``/events//jobs`` | Method: ``POST`` - - POST-data: See Job - - Args: - event_id: Identifier of the event - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Event object or HTTP-error - """ - event = event_controller.get_event(event_id) - _add_job(event, request.get_json()) - return jsonify(event) - - -@events_bp.route("/events//jobs/", methods=["DELETE"]) -@login_required(permission=permissions.DELETE) -def delete_job(event_id, job_id, current_session): - """Delete a Job - - Route: ``/events//jobs/`` | Method: ``DELETE`` - - Args: - event_id: Identifier of the event - job_id: Identifier of the Job - current_session: Session sent with Authorization Header - - Returns: - HTTP-no-content or HTTP error - """ - job_slot = event_controller.get_job(job_id, event_id) - event_controller.delete_job(job_slot) - return no_content() - - -@events_bp.route("/events//jobs/", methods=["PUT"]) -@login_required() -def update_job(event_id, job_id, current_session: Session): - """Edit Job or assign user to the Job - - Route: ``/events//jobs/`` | Method: ``PUT`` - - POST-data: See TS interface for Job or ``{user: {userid: string, value: number}}`` - - Args: - event_id: Identifier of the event - job_id: Identifier of the Job - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Job object or HTTP-error - """ - job = event_controller.get_job(job_id, event_id) - - data = request.get_json() - if not data: - raise BadRequest - - if ("user" not in data or len(data) > 1) and not current_session.user_.has_permission(permissions.EDIT): - raise Forbidden - - if "user" in data: - try: - user = userController.get_user(data["user"]["userid"]) - value = data["user"]["value"] - if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or ( - user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) - ): - raise Forbidden - event_controller.assign_to_job(job, user, value) - except (KeyError, ValueError): - raise BadRequest - - if "required_services" in data: - job.required_services = data["required_services"] - if "type" in data: - job.type = event_controller.get_job_type(data["type"]) - event_controller.update() - - return jsonify(job) - - -# TODO: JobTransfer + def __init__(self, cfg): + super(EventPlugin, self).__init__(cfg) + from . import routes diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index ee1f663..31a3b7b 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -1,14 +1,14 @@ -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import Optional -from sqlalchemy import or_, and_ from werkzeug.exceptions import BadRequest, NotFound from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.database import db +from flaschengeist.plugins.events import EventPlugin from flaschengeist.plugins.events.models import EventType, Event, Job, JobType, Service -from flaschengeist.utils.datetime import from_iso_format +from flaschengeist.utils.scheduler import scheduled def update(): @@ -102,10 +102,21 @@ def delete_job_type(name): raise BadRequest("Type still in use") -def get_event(event_id) -> Event: +def clear_backup(event: Event): + for job in event.jobs: + services = [] + for service in job.services: + if not service.is_backup: + services.append(service) + job.services = services + + +def get_event(event_id, with_backup=False) -> Event: event = Event.query.get(event_id) if event is None: raise NotFound + if not with_backup: + return clear_backup(event) return event @@ -113,11 +124,12 @@ def get_templates(): return Event.query.filter(Event.is_template == True).all() -def get_events(start: Optional[datetime] = None, end=None): +def get_events(start: Optional[datetime] = None, end=None, with_backup=False): """Query events which start from begin until end Args: start (datetime): Earliest start end (datetime): Latest start + with_backup (bool): Export also backup services Returns: collection of Event objects """ @@ -126,7 +138,11 @@ def get_events(start: Optional[datetime] = None, end=None): query = query.filter(start <= Event.start) if end is not None: query = query.filter(Event.start < end) - return query.all() + events = query.all() + if not with_backup: + for event in events: + clear_backup(event) + return events def delete_event(event_id): @@ -202,3 +218,26 @@ def assign_to_job(job: Job, user, value): service = Service(user_=user, value=value, job_=job) db.session.add(service) db.session.commit() + + +@scheduled +def assign_backups(): + logger.debug("Notifications") + now = datetime.now(tz=timezone.utc) + # now + backup_time + next cron tick + start = now + timedelta(hours=16) + timedelta(minutes=30) + services = Service.query.filter(Service.is_backup == True).join(Job).filter(Job.start <= start).all() + for service in services: + if service.job_.start <= now or service.job_.is_full(): + EventPlugin.plugin.notify( + service.user_, "Your backup assignment was cancelled.", {"event_id": service.job_.event_id_} + ) + logger.debug(f"Service is outdated or full, removing. {service.serialize()}") + db.session.delete(service) + else: + service.is_backup = False + logger.debug(f"Service not full, assigning backup. {service.serialize()}") + EventPlugin.plugin.notify( + service.user_, "Your backup assignment was accepted.", {"event_id": service.job_.event_id_} + ) + db.session.commit() diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py index 7479df0..daf308a 100644 --- a/flaschengeist/plugins/events/models.py +++ b/flaschengeist/plugins/events/models.py @@ -1,12 +1,11 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 -import enum from datetime import datetime from typing import Optional, Union from sqlalchemy import UniqueConstraint -from flaschengeist.models import ModelSerializeMixin, UtcDateTime +from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial from flaschengeist.models.user import User from flaschengeist.database import db @@ -19,13 +18,13 @@ _table_prefix_ = "events_" class EventType(db.Model, ModelSerializeMixin): __tablename__ = _table_prefix_ + "event_type" - id: int = db.Column(db.Integer, primary_key=True) + id: int = db.Column(Serial, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) class JobType(db.Model, ModelSerializeMixin): __tablename__ = _table_prefix_ + "job_type" - id: int = db.Column(db.Integer, primary_key=True) + id: int = db.Column(Serial, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) @@ -37,12 +36,11 @@ class JobType(db.Model, ModelSerializeMixin): class Service(db.Model, ModelSerializeMixin): __tablename__ = _table_prefix_ + "service" userid: str = "" + is_backup: bool = db.Column(db.Boolean, default=False) value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) - _job_id = db.Column( - "job_id", db.Integer, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True - ) - _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True) + _job_id = db.Column("job_id", Serial, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True) + _user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False, primary_key=True) user_: User = db.relationship("User") job_: Job = db.relationship("Job") @@ -54,9 +52,9 @@ class Service(db.Model, ModelSerializeMixin): class Job(db.Model, ModelSerializeMixin): __tablename__ = _table_prefix_ + "job" - _type_id = db.Column("type_id", db.Integer, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False) + _type_id = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False) - id: int = db.Column(db.Integer, primary_key=True) + id: int = db.Column(Serial, primary_key=True) start: datetime = db.Column(UtcDateTime, nullable=False) end: Optional[datetime] = db.Column(UtcDateTime) type: Union[JobType, int] = db.relationship("JobType") @@ -65,7 +63,7 @@ class Job(db.Model, ModelSerializeMixin): required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) event_ = db.relationship("Event", back_populates="jobs") - event_id_ = db.Column("event_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) + event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) __table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),) @@ -77,7 +75,7 @@ class Event(db.Model, ModelSerializeMixin): """Model for an Event""" __tablename__ = _table_prefix_ + "event" - id: int = db.Column(db.Integer, primary_key=True) + id: int = db.Column(Serial, primary_key=True) start: datetime = db.Column(UtcDateTime, nullable=False) end: Optional[datetime] = db.Column(UtcDateTime) name: Optional[str] = db.Column(db.String(255)) @@ -89,15 +87,15 @@ class Event(db.Model, ModelSerializeMixin): ) # Protected for internal use _type_id = db.Column( - "type_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False + "type_id", Serial, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False ) class Invite(db.Model, ModelSerializeMixin): __tablename__ = _table_prefix_ + "invite" - id: int = db.Column(db.Integer, primary_key=True) - job_id: int = db.Column(db.Integer, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False) + id: int = db.Column(Serial, primary_key=True) + job_id: int = db.Column(Serial, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False) # Dummy properties for API export invitee_id: str = None sender_id: str = None @@ -105,8 +103,8 @@ class Invite(db.Model, ModelSerializeMixin): invitee_: User = db.relationship("User", foreign_keys="Invite._invitee_id") sender_: User = db.relationship("User", foreign_keys="Invite._sender_id") # Protected properties needed for internal use - _invitee_id = db.Column("invitee_id", db.Integer, db.ForeignKey("user.id"), nullable=False) - _sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id"), nullable=False) + _invitee_id = db.Column("invitee_id", Serial, db.ForeignKey("user.id"), nullable=False) + _sender_id = db.Column("sender_id", Serial, db.ForeignKey("user.id"), nullable=False) @property def invitee_id(self): diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py new file mode 100644 index 0000000..7fccccf --- /dev/null +++ b/flaschengeist/plugins/events/routes.py @@ -0,0 +1,431 @@ +from datetime import datetime, timedelta, timezone +from http.client import NO_CONTENT +from flask import request, jsonify +from werkzeug.exceptions import BadRequest, NotFound, Forbidden + +from flaschengeist.models.session import Session +from flaschengeist.utils.decorators import login_required +from flaschengeist.utils.datetime import from_iso_format +from flaschengeist.controller import userController + +from . import event_controller, permissions, EventPlugin +from ...utils.HTTP import no_content + + +@EventPlugin.blueprint.route("/events/templates", methods=["GET"]) +@login_required() +def get_templates(current_session): + return jsonify(event_controller.get_templates()) + + +@EventPlugin.blueprint.route("/events/event-types", methods=["GET"]) +@EventPlugin.blueprint.route("/events/event-types/", methods=["GET"]) +@login_required() +def get_event_types(current_session, identifier=None): + """Get EventType(s) + + Route: ``/events/event-types`` | Method: ``GET`` + Route: ``/events/event-types/`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + identifier: If querying a specific EventType + + Returns: + JSON encoded (list of) EventType(s) or HTTP-error + """ + if identifier: + result = event_controller.get_event_type(identifier) + else: + result = event_controller.get_event_types() + return jsonify(result) + + +@EventPlugin.blueprint.route("/events/event-types", methods=["POST"]) +@login_required(permission=permissions.EVENT_TYPE) +def new_event_type(current_session): + """Create a new EventType + + Route: ``/events/event-types`` | Method: ``POST`` + + POST-data: ``{name: string}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + HTTP-Created or HTTP-error + """ + data = request.get_json() + if "name" not in data: + raise BadRequest + event_type = event_controller.create_event_type(data["name"]) + return jsonify(event_type) + + +@EventPlugin.blueprint.route("/events/event-types/", methods=["PUT", "DELETE"]) +@login_required(permission=permissions.EVENT_TYPE) +def modify_event_type(identifier, current_session): + """Rename or delete an event type + + Route: ``/events/event-types/`` | Method: ``PUT`` or ``DELETE`` + + POST-data: (if renaming) ``{name: string}`` + + Args: + identifier: Identifier of the EventType + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ + if request.method == "DELETE": + event_controller.delete_event_type(identifier) + else: + data = request.get_json() + if "name" not in data: + raise BadRequest("Parameter missing in data") + event_controller.rename_event_type(identifier, data["name"]) + return "", NO_CONTENT + + +@EventPlugin.blueprint.route("/events/job-types", methods=["GET"]) +@login_required() +def get_job_types(current_session): + """Get all JobTypes + + Route: ``/events/job-types`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded list of JobType HTTP-error + """ + types = event_controller.get_job_types() + return jsonify(types) + + +@EventPlugin.blueprint.route("/events/job-types", methods=["POST"]) +@login_required(permission=permissions.JOB_TYPE) +def new_job_type(current_session): + """Create a new JobType + + Route: ``/events/job-types`` | Method: ``POST`` + + POST-data: ``{name: string}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded JobType or HTTP-error + """ + data = request.get_json() + if "name" not in data: + raise BadRequest + jt = event_controller.create_job_type(data["name"]) + return jsonify(jt) + + +@EventPlugin.blueprint.route("/events/job-types/", methods=["PUT", "DELETE"]) +@login_required(permission=permissions.JOB_TYPE) +def modify_job_type(type_id, current_session): + """Rename or delete a JobType + + Route: ``/events/job-types/`` | Method: ``PUT`` or ``DELETE`` + + POST-data: (if renaming) ``{name: string}`` + + Args: + type_id: Identifier of the JobType + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ + if request.method == "DELETE": + event_controller.delete_job_type(type_id) + else: + data = request.get_json() + if "name" not in data: + raise BadRequest("Parameter missing in data") + event_controller.rename_job_type(type_id, data["name"]) + return "", NO_CONTENT + + +@EventPlugin.blueprint.route("/events/", methods=["GET"]) +@login_required() +def get_event(event_id, current_session): + """Get event by id + + Route: ``/events/`` | Method: ``GET`` + + Args: + event_id: ID identifying the event + current_session: Session sent with Authorization Header + + Returns: + JSON encoded event object + """ + event = event_controller.get_event( + event_id, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP) + ) + return jsonify(event) + + +@EventPlugin.blueprint.route("/events", methods=["GET"]) +@login_required() +def get_filtered_events(current_session): + begin = request.args.get("from") + if begin is not None: + begin = from_iso_format(begin) + end = request.args.get("to") + if end is not None: + end = from_iso_format(end) + if begin is None and end is None: + begin = datetime.now() + return jsonify( + event_controller.get_events( + begin, end, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP) + ) + ) + + +@EventPlugin.blueprint.route("/events//", methods=["GET"]) +@EventPlugin.blueprint.route("/events///", methods=["GET"]) +@login_required() +def get_events(current_session, year=datetime.now().year, month=datetime.now().month, day=None): + """Get Event objects for specified date (or month or year), + if nothing set then events for current month are returned + + Route: ``/events[//[/]]`` | Method: ``GET`` + + Args: + year (int, optional): year to query, defaults to current year + month (int, optional): month to query (if set), defaults to current month + day (int, optional): day to query events for (if set) + current_session: Session sent with Authorization Header + + Returns: + JSON encoded list containing events found or HTTP-error + """ + try: + begin = datetime(year=year, month=month, day=1, tzinfo=timezone.utc) + if day: + begin += timedelta(days=day - 1) + end = begin + timedelta(days=1) + else: + if month == 12: + end = datetime(year=year + 1, month=1, day=1, tzinfo=timezone.utc) + else: + end = datetime(year=year, month=month + 1, day=1, tzinfo=timezone.utc) + + events = event_controller.get_events( + begin, end, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP) + ) + return jsonify(events) + except ValueError: + raise BadRequest("Invalid date given") + + +def _add_job(event, data): + try: + start = from_iso_format(data["start"]) + end = None + if "end" in data: + end = from_iso_format(data["end"]) + required_services = data["required_services"] + job_type = data["type"] + if isinstance(job_type, dict): + job_type = data["type"]["id"] + except (KeyError, ValueError): + raise BadRequest("Missing or invalid POST parameter") + + job_type = event_controller.get_job_type(job_type) + event_controller.add_job(event, job_type, required_services, start, end, comment=data.get("comment", None)) + + +@EventPlugin.blueprint.route("/events", methods=["POST"]) +@login_required(permission=permissions.CREATE) +def create_event(current_session): + """Create an new event + + Route: ``/events`` | Method: ``POST`` + + POST-data: See interfaces for Event, can already contain jobs + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + data = request.get_json() + end = data.get("end", None) + try: + start = from_iso_format(data["start"]) + if end is not None: + end = from_iso_format(end) + data_type = data["type"] + if isinstance(data_type, dict): + data_type = data["type"]["id"] + event_type = event_controller.get_event_type(data_type) + except KeyError: + raise BadRequest("Missing POST parameter") + except (NotFound, ValueError): + raise BadRequest("Invalid parameter") + + event = event_controller.create_event( + start=start, + end=end, + name=data.get("name", None), + is_template=data.get("is_template", None), + event_type=event_type, + description=data.get("description", None), + ) + if "jobs" in data: + for job in data["jobs"]: + _add_job(event, job) + + return jsonify(event) + + +@EventPlugin.blueprint.route("/events/", methods=["PUT"]) +@login_required(permission=permissions.EDIT) +def modify_event(event_id, current_session): + """Modify an event + + Route: ``/events/`` | Method: ``PUT`` + + POST-data: See interfaces for Event, can already contain slots + + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + data = request.get_json() + if "start" in data: + event.start = from_iso_format(data["start"]) + if "end" in data: + event.end = from_iso_format(data["end"]) + if "description" in data: + event.description = data["description"] + if "type" in data: + event_type = event_controller.get_event_type(data["type"]) + event.type = event_type + event_controller.update() + return jsonify(event) + + +@EventPlugin.blueprint.route("/events/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE) +def delete_event(event_id, current_session): + """Delete an event + + Route: ``/events/`` | Method: ``DELETE`` + + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ + event_controller.delete_event(event_id) + return "", NO_CONTENT + + +@EventPlugin.blueprint.route("/events//jobs", methods=["POST"]) +@login_required(permission=permissions.EDIT) +def add_job(event_id, current_session): + """Add an new Job to an Event / EventSlot + + Route: ``/events//jobs`` | Method: ``POST`` + + POST-data: See Job + + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + _add_job(event, request.get_json()) + return jsonify(event) + + +@EventPlugin.blueprint.route("/events//jobs/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE) +def delete_job(event_id, job_id, current_session): + """Delete a Job + + Route: ``/events//jobs/`` | Method: ``DELETE`` + + Args: + event_id: Identifier of the event + job_id: Identifier of the Job + current_session: Session sent with Authorization Header + + Returns: + HTTP-no-content or HTTP error + """ + job_slot = event_controller.get_job(job_id, event_id) + event_controller.delete_job(job_slot) + return no_content() + + +@EventPlugin.blueprint.route("/events//jobs/", methods=["PUT"]) +@login_required() +def update_job(event_id, job_id, current_session: Session): + """Edit Job or assign user to the Job + + Route: ``/events//jobs/`` | Method: ``PUT`` + + POST-data: See TS interface for Job or ``{user: {userid: string, value: number}}`` + + Args: + event_id: Identifier of the event + job_id: Identifier of the Job + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Job object or HTTP-error + """ + job = event_controller.get_job(job_id, event_id) + + data = request.get_json() + if not data: + raise BadRequest + + if ("user" not in data or len(data) > 1) and not current_session.user_.has_permission(permissions.EDIT): + raise Forbidden + + if "user" in data: + try: + user = userController.get_user(data["user"]["userid"]) + value = data["user"]["value"] + if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or ( + user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) + ): + raise Forbidden + event_controller.assign_to_job(job, user, value) + except (KeyError, ValueError): + raise BadRequest + + if "required_services" in data: + job.required_services = data["required_services"] + if "type" in data: + job.type = event_controller.get_job_type(data["type"]) + event_controller.update() + + return jsonify(job) + + +# TODO: JobTransfer diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 45f26c8..3eb7dd2 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -13,22 +13,21 @@ from flaschengeist.controller import userController from . import models from . import pricelist_controller, permissions -plugin_name = "pricelist" -pricelist_bp = Blueprint(plugin_name, __name__, url_prefix="/pricelist") -plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][plugin_name]) - class PriceListPlugin(Plugin): + name = "pricelist" + blueprint = Blueprint(name, __name__, url_prefix="/pricelist") + plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name]) models = models def __init__(self, cfg): - super().__init__(blueprint=pricelist_bp, permissions=permissions.permissions) + super().__init__(cfg) config = {"discount": 0} config.update(cfg) -@pricelist_bp.route("/drink-types", methods=["GET"]) -@pricelist_bp.route("/drink-types/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drink-types", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drink-types/", methods=["GET"]) def get_drink_types(identifier=None): if identifier is None: result = pricelist_controller.get_drink_types() @@ -37,7 +36,7 @@ def get_drink_types(identifier=None): return jsonify(result) -@pricelist_bp.route("/drink-types", methods=["POST"]) +@PriceListPlugin.blueprint.route("/drink-types", methods=["POST"]) @login_required(permission=permissions.CREATE_TYPE) def new_drink_type(current_session): data = request.get_json() @@ -47,7 +46,7 @@ def new_drink_type(current_session): return jsonify(drink_type) -@pricelist_bp.route("/drink-types/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/drink-types/", methods=["PUT"]) @login_required(permission=permissions.EDIT_TYPE) def update_drink_type(identifier, current_session): data = request.get_json() @@ -57,15 +56,15 @@ def update_drink_type(identifier, current_session): return jsonify(drink_type) -@pricelist_bp.route("/drink-types/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/drink-types/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_TYPE) def delete_drink_type(identifier, current_session): pricelist_controller.delete_drink_type(identifier) return no_content() -@pricelist_bp.route("/tags", methods=["GET"]) -@pricelist_bp.route("/tags/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/tags", methods=["GET"]) +@PriceListPlugin.blueprint.route("/tags/", methods=["GET"]) def get_tags(identifier=None): if identifier: result = pricelist_controller.get_tag(identifier) @@ -74,7 +73,7 @@ def get_tags(identifier=None): return jsonify(result) -@pricelist_bp.route("/tags", methods=["POST"]) +@PriceListPlugin.blueprint.route("/tags", methods=["POST"]) @login_required(permission=permissions.CREATE_TAG) def new_tag(current_session): data = request.get_json() @@ -84,7 +83,7 @@ def new_tag(current_session): return jsonify(drink_type) -@pricelist_bp.route("/tags/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/tags/", methods=["PUT"]) @login_required(permission=permissions.EDIT_TAG) def update_tag(identifier, current_session): data = request.get_json() @@ -94,15 +93,15 @@ def update_tag(identifier, current_session): return jsonify(tag) -@pricelist_bp.route("/tags/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/tags/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_TAG) def delete_tag(identifier, current_session): pricelist_controller.delete_tag(identifier) return no_content() -@pricelist_bp.route("/drinks", methods=["GET"]) -@pricelist_bp.route("/drinks/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks/", methods=["GET"]) def get_drinks(identifier=None): if identifier: result = pricelist_controller.get_drink(identifier) @@ -111,85 +110,86 @@ def get_drinks(identifier=None): return jsonify(result) -@pricelist_bp.route("/drinks/search/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks/search/", methods=["GET"]) def search_drinks(name): return jsonify(pricelist_controller.get_drinks(name)) -@pricelist_bp.route("/drinks", methods=["POST"]) +@PriceListPlugin.blueprint.route("/drinks", methods=["POST"]) @login_required(permission=permissions.CREATE) def create_drink(current_session): data = request.get_json() return jsonify(pricelist_controller.set_drink(data)) -@pricelist_bp.route("/drinks/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/drinks/", methods=["PUT"]) def update_drink(identifier): data = request.get_json() return jsonify(pricelist_controller.update_drink(identifier, data)) -@pricelist_bp.route("/drinks/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/drinks/", methods=["DELETE"]) def delete_drink(identifier): pricelist_controller.delete_drink(identifier) return no_content() -@pricelist_bp.route("/prices/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/prices/", methods=["DELETE"]) def delete_price(identifier): pricelist_controller.delete_price(identifier) return no_content() -@pricelist_bp.route("/volumes/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/volumes/", methods=["DELETE"]) def delete_volume(identifier): pricelist_controller.delete_volume(identifier) return no_content() -@pricelist_bp.route("/ingredients/extraIngredients", methods=["GET"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"]) def get_extra_ingredients(): return jsonify(pricelist_controller.get_extra_ingredients()) -@pricelist_bp.route("/ingredients/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/ingredients/", methods=["DELETE"]) def delete_ingredient(identifier): pricelist_controller.delete_ingredient(identifier) return no_content() -@pricelist_bp.route("/ingredients/extraIngredients", methods=["POST"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"]) def set_extra_ingredient(): data = request.get_json() return jsonify(pricelist_controller.set_extra_ingredient(data)) -@pricelist_bp.route("/ingredients/extraIngredients/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", methods=["PUT"]) def update_extra_ingredient(identifier): data = request.get_json() return jsonify(pricelist_controller.update_extra_ingredient(identifier, data)) -@pricelist_bp.route("/ingredients/extraIngredients/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", methods=["DELETE"]) def delete_extra_ingredient(identifier): pricelist_controller.delete_extra_ingredient(identifier) return no_content() -@pricelist_bp.route("/settings/min_prices", methods=["POST", "GET"]) +@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST", "GET"]) def pricelist_settings_min_prices(): if request.method == "GET": - return jsonify(plugin.get_setting("min_prices")) + # TODO: Handle if no prices are set! + return jsonify(PriceListPlugin.plugin.get_setting("min_prices")) else: data = request.get_json() if not isinstance(data, list) or not all(isinstance(n, int) for n in data): raise BadRequest data.sort() - plugin.set_setting("min_prices", data) + PriceListPlugin.plugin.set_setting("min_prices", data) return no_content() -@pricelist_bp.route("/users//pricecalc_columns", methods=["GET", "PUT"]) +@PriceListPlugin.blueprint.route("/users//pricecalc_columns", methods=["GET", "PUT"]) @login_required() def get_columns(userid, current_session: Session): """Get pricecalc_columns of an user diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index 8ad1a35..54b0547 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -12,17 +12,17 @@ from flaschengeist.utils.decorators import login_required from flaschengeist.controller import roleController from flaschengeist.utils.HTTP import created -roles_bp = Blueprint("roles", __name__) _permission_edit = "roles_edit" _permission_delete = "roles_delete" class RolesPlugin(Plugin): - def __init__(self, config): - super().__init__(config, roles_bp, permissions=[_permission_edit, _permission_delete]) + name = "roles" + blueprint = Blueprint(name, __name__) + permissions = [_permission_edit, _permission_delete] -@roles_bp.route("/roles", methods=["GET"]) +@RolesPlugin.blueprint.route("/roles", methods=["GET"]) @login_required() def list_roles(current_session): """List all existing roles @@ -39,7 +39,7 @@ def list_roles(current_session): return jsonify(roles) -@roles_bp.route("/roles", methods=["POST"]) +@RolesPlugin.blueprint.route("/roles", methods=["POST"]) @login_required(permission=_permission_edit) def create_role(current_session): """Create new role @@ -62,7 +62,7 @@ def create_role(current_session): return created(roleController.create_role(data["name"], permissions)) -@roles_bp.route("/roles/permissions", methods=["GET"]) +@RolesPlugin.blueprint.route("/roles/permissions", methods=["GET"]) @login_required() def list_permissions(current_session): """List all existing permissions @@ -79,7 +79,7 @@ def list_permissions(current_session): return jsonify(permissions) -@roles_bp.route("/roles/", methods=["GET"]) +@RolesPlugin.blueprint.route("/roles/", methods=["GET"]) @login_required() def get_role(role_name, current_session): """Get role by name @@ -97,7 +97,7 @@ def get_role(role_name, current_session): return jsonify(role) -@roles_bp.route("/roles/", methods=["PUT"]) +@RolesPlugin.blueprint.route("/roles/", methods=["PUT"]) @login_required(permission=_permission_edit) def edit_role(role_id, current_session): """Edit role, rename and / or set permissions @@ -123,7 +123,7 @@ def edit_role(role_id, current_session): return "", NO_CONTENT -@roles_bp.route("/roles/", methods=["DELETE"]) +@RolesPlugin.blueprint.route("/roles/", methods=["DELETE"]) @login_required(permission=_permission_delete) def delete_role(role_id, current_session): """Delete role diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 277aca1..e5b08a6 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -13,18 +13,17 @@ from flaschengeist.plugins import Plugin from flaschengeist.models.user import User, _Avatar from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.controller import userController -from flaschengeist.utils.HTTP import created +from flaschengeist.utils.HTTP import created, no_content from flaschengeist.utils.datetime import from_iso_format -users_bp = Blueprint("users", __name__) - class UsersPlugin(Plugin): - def __init__(self, cfg): - super().__init__(blueprint=users_bp, permissions=permissions.permissions) + name = "users" + blueprint = Blueprint(name, __name__) + permissions = permissions.permissions -@users_bp.route("/users", methods=["POST"]) +@UsersPlugin.blueprint.route("/users", methods=["POST"]) def register(): """Register a new user The password will be set to a random string of at lease 16byte entropy. @@ -55,7 +54,7 @@ def register(): return make_response(jsonify(userController.register(data)), CREATED) -@users_bp.route("/users", methods=["GET"]) +@UsersPlugin.blueprint.route("/users", methods=["GET"]) @login_required() @headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) def list_users(current_session): @@ -74,7 +73,7 @@ def list_users(current_session): return jsonify(users) -@users_bp.route("/users/", methods=["GET"]) +@UsersPlugin.blueprint.route("/users/", methods=["GET"]) @login_required() @headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) def get_user(userid, current_session): @@ -97,7 +96,25 @@ def get_user(userid, current_session): return jsonify(serial) -@users_bp.route("/users//avatar", methods=["GET"]) +@UsersPlugin.blueprint.route("/users//frontend", methods=["POST", "GET"]) +@login_required() +def frontend(userid, current_session): + if current_session.user_.userid != userid: + raise Forbidden + + if request.method == "POST": + if request.content_length > 1024 ** 2: + raise BadRequest + current_session.user_.set_attribute("frontend", request.get_json()) + return no_content() + else: + content = current_session.user_.get_attribute("frontend", None) + if content is None: + return no_content() + return jsonify(content) + + +@UsersPlugin.blueprint.route("/users//avatar", methods=["GET"]) @headers({"Cache-Control": "public, max-age=604800"}) def get_avatar(userid): user = userController.get_user(userid) @@ -109,7 +126,7 @@ def get_avatar(userid): raise NotFound -@users_bp.route("/users//avatar", methods=["POST"]) +@UsersPlugin.blueprint.route("/users//avatar", methods=["POST"]) @login_required() def set_avatar(userid, current_session): user = userController.get_user(userid) @@ -127,7 +144,7 @@ def set_avatar(userid, current_session): raise BadRequest -@users_bp.route("/users/", methods=["DELETE"]) +@UsersPlugin.blueprint.route("/users/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) def delete_user(userid, current_session): """Delete user by userid @@ -147,7 +164,7 @@ def delete_user(userid, current_session): return "", NO_CONTENT -@users_bp.route("/users/", methods=["PUT"]) +@UsersPlugin.blueprint.route("/users/", methods=["PUT"]) @login_required() def edit_user(userid, current_session): """Modify user by userid @@ -198,3 +215,19 @@ def edit_user(userid, current_session): userController.modify_user(user, password, new_password) userController.update_user(user) return "", NO_CONTENT + + +@UsersPlugin.blueprint.route("/notifications", methods=["GET"]) +@login_required() +def notifications(current_session): + f = request.args.get("from", None) + if f is not None: + f = from_iso_format(f) + return jsonify(userController.get_notifications(current_session.user_, f)) + + +@UsersPlugin.blueprint.route("/notifications/", methods=["DELETE"]) +@login_required() +def remove_notifications(nid, current_session): + userController.delete_notification(nid, current_session.user_) + return no_content() diff --git a/flaschengeist/utils/scheduler.py b/flaschengeist/utils/scheduler.py new file mode 100644 index 0000000..aefbddd --- /dev/null +++ b/flaschengeist/utils/scheduler.py @@ -0,0 +1,17 @@ +from flask import current_app + +from flaschengeist.utils.HTTP import no_content + +_scheduled = set() + + +def scheduled(func): + _scheduled.add(func) + return func + + +@current_app.route("/cron") +def __run_scheduled(): + for function in _scheduled: + function() + return no_content() diff --git a/run_flaschengeist b/run_flaschengeist index 610b9eb..26c10ec 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -160,7 +160,7 @@ def export(arguments): if arguments.plugins: for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"): plg = entry_point.load() - if hasattr(plg, "models"): + if hasattr(plg, "models") and plg.models is not None: gen.run(plg.models) gen.write() From c3aeeea2ce8a00fc7d4e8ea4c29a98fefaaddb6c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 29 Mar 2021 07:33:20 +0200 Subject: [PATCH 248/446] [events] Fixed permissions names --- flaschengeist/plugins/events/permissions.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/flaschengeist/plugins/events/permissions.py b/flaschengeist/plugins/events/permissions.py index f889793..459967c 100644 --- a/flaschengeist/plugins/events/permissions.py +++ b/flaschengeist/plugins/events/permissions.py @@ -1,22 +1,25 @@ -CREATE = "schedule_create" +CREATE = "events_create" """Can create events""" -EDIT = "schedule_edit" +EDIT = "events_edit" """Can edit events""" -DELETE = "schedule_delete" +DELETE = "events_delete" """Can delete events""" -EVENT_TYPE = "schedule_event_type" +EVENT_TYPE = "events_event_type" """Can create and edit EventTypes""" -JOB_TYPE = "schedule_job_type" +JOB_TYPE = "events_job_type" """Can create and edit JobTypes""" -ASSIGN = "schedule_assign" +ASSIGN = "events_assign" """Can self assign to jobs""" -ASSIGN_OTHER = "schedule_assign_other" +ASSIGN_OTHER = "events_assign_other" """Can assign other users to jobs""" +SEE_BACKUP = "events_see_backup" +"""Can see users assigned as backup""" + permissions = [value for key, value in globals().items() if not key.startswith("_")] From faf5b0b8d0ba494b8b68d1d703ded7fda6eb9d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 29 Mar 2021 12:41:29 +0200 Subject: [PATCH 249/446] [pricelist] add receipts --- flaschengeist/plugins/pricelist/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index ff3fb72..b547f34 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -138,6 +138,7 @@ class Drink(db.Model, ModelSerializeMixin): cost_price_package_netto: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) uuid: str = db.Column(db.String(36)) + receipt: Optional[str] = db.Column(db.String) _type_id = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) From b2d843169797fb1359aba5d86f787cb94d108bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 29 Mar 2021 20:26:15 +0200 Subject: [PATCH 250/446] [pricelist][fix] add permission to plugin --- flaschengeist/plugins/pricelist/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 35c3ce1..9d5151a 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -16,6 +16,7 @@ from . import pricelist_controller, permissions class PriceListPlugin(Plugin): name = "pricelist" + permissions = permissions.permissions blueprint = Blueprint(name, __name__, url_prefix="/pricelist") plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name]) models = models @@ -186,7 +187,11 @@ def delete_extra_ingredient(identifier): def pricelist_settings_min_prices(): if request.method == "GET": # TODO: Handle if no prices are set! - return jsonify(PriceListPlugin.plugin.get_setting("min_prices")) + try: + min_prices = PriceListPlugin.plugin.get_setting("min_prices") + except KeyError: + min_prices = [] + return jsonify(min_prices) else: data = request.get_json() if not isinstance(data, list) or not all(isinstance(n, int) for n in data): @@ -227,7 +232,7 @@ def get_columns(userid, current_session: Session): userController.persist() return no_content() -@PriceListPlugin.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) +@PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) def set_picture(identifier): if request.method == "DELETE": @@ -243,7 +248,7 @@ def set_picture(identifier): else: raise BadRequest -@PriceListPlugin.route("/picture/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/picture/", methods=["GET"]) def _get_picture(identifier): if request.method == "GET": size = request.args.get("size") From bcf1941a810545fd7ae73e9a88140da99f899f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 29 Mar 2021 21:28:48 +0200 Subject: [PATCH 251/446] [pricelist] fix some merge issues --- flaschengeist/plugins/pricelist/__init__.py | 1 - flaschengeist/plugins/pricelist/pricelist_controller.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 9d5151a..dff3bb6 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -252,6 +252,5 @@ def set_picture(identifier): def _get_picture(identifier): if request.method == "GET": size = request.args.get("size") - path = PriceListPlugin.plugin["path"] response = pricelist_controller.get_drink_picture(identifier, size) return response.make_conditional(request) \ No newline at end of file diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 6a115f5..6713b67 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -5,7 +5,7 @@ from uuid import uuid4 from flaschengeist import logger from flaschengeist.config import config from flaschengeist.database import db -from flaschengeist.utils.picture import save_picture, get_picture +from flaschengeist.utils.picture import save_picture, get_picture, delete_picture from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient From 15c7a56d564721723cfae2b6e2f64cc7cbd8582d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 29 Mar 2021 22:34:05 +0200 Subject: [PATCH 252/446] [pricelist] receipt as list of strings --- flaschengeist/plugins/pricelist/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index d392168..99ef37c 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -139,7 +139,7 @@ class Drink(db.Model, ModelSerializeMixin): cost_per_package: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) uuid: str = db.Column(db.String(36)) - receipt: Optional[str] = db.Column(db.String) + receipt: Optional[list[str]] = db.Column(db.PickleType(protocol=4)) _type_id = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) From 5c688df39291aef12782426d2057a95dc27ad4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 1 Apr 2021 22:45:28 +0200 Subject: [PATCH 253/446] [pricelist][tags] change model of tags, fixed tags on updateDrink --- flaschengeist/plugins/pricelist/__init__.py | 15 ++++---- flaschengeist/plugins/pricelist/models.py | 10 ++++++ .../plugins/pricelist/pricelist_controller.py | 34 ++++++++++++++----- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index dff3bb6..117aa3f 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -4,6 +4,7 @@ from flask import Blueprint, jsonify, request, current_app from werkzeug.local import LocalProxy from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized +from flaschengeist import logger from flaschengeist.plugins import Plugin from flaschengeist.utils.decorators import login_required, extract_session from flaschengeist.utils.HTTP import no_content @@ -78,9 +79,7 @@ def get_tags(identifier=None): @login_required(permission=permissions.CREATE_TAG) def new_tag(current_session): data = request.get_json() - if "name" not in data: - raise BadRequest - drink_type = pricelist_controller.create_tag(data["name"]) + drink_type = pricelist_controller.create_tag(data) return jsonify(drink_type) @@ -88,9 +87,7 @@ def new_tag(current_session): @login_required(permission=permissions.EDIT_TAG) def update_tag(identifier, current_session): data = request.get_json() - if "name" not in data: - raise BadRequest - tag = pricelist_controller.rename_tag(identifier, data["name"]) + tag = pricelist_controller.update_tag(identifier, data) return jsonify(tag) @@ -115,6 +112,7 @@ def get_drinks(identifier=None): result = pricelist_controller.get_drink(identifier, public=public) else: result = pricelist_controller.get_drinks(public=public) + logger.debug(f"GET drink {result}") return jsonify(result) @@ -133,6 +131,7 @@ def create_drink(current_session): @PriceListPlugin.blueprint.route("/drinks/", methods=["PUT"]) def update_drink(identifier): data = request.get_json() + logger.debug(f"update drink {data}") return jsonify(pricelist_controller.update_drink(identifier, data)) @@ -232,6 +231,7 @@ def get_columns(userid, current_session: Session): userController.persist() return no_content() + @PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) def set_picture(identifier): @@ -248,9 +248,10 @@ def set_picture(identifier): else: raise BadRequest + @PriceListPlugin.blueprint.route("/picture/", methods=["GET"]) def _get_picture(identifier): if request.method == "GET": size = request.args.get("size") response = pricelist_controller.get_drink_picture(identifier, size) - return response.make_conditional(request) \ No newline at end of file + return response.make_conditional(request) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 99ef37c..ffde797 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -26,6 +26,7 @@ class Tag(db.Model, ModelSerializeMixin): __tablename__ = "drink_tag" id: int = db.Column("id", db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) + color: str = db.Column(db.String(7), nullable=False) class DrinkType(db.Model, ModelSerializeMixin): @@ -51,6 +52,9 @@ class DrinkPrice(db.Model, ModelSerializeMixin): public: bool = db.Column(db.Boolean, default=True) description: Optional[str] = db.Column(db.String(30)) + def __repr__(self): + return f"DrinkPric({self.id},{self.price},{self.public},{self.description})" + class ExtraIngredient(db.Model, ModelSerializeMixin): """ @@ -123,6 +127,9 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): prices: list[DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan") ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) + def __repr__(self): + return f"DrinkPriceVolume({self.id},{self.drink_id},{self.prices})" + class Drink(db.Model, ModelSerializeMixin): """ @@ -147,6 +154,9 @@ class Drink(db.Model, ModelSerializeMixin): type: Optional[DrinkType] = db.relationship("DrinkType", foreign_keys=[_type_id]) volumes: list[DrinkPriceVolume] = db.relationship(DrinkPriceVolume) + def __repr__(self): + return f"Drink({self.id},{self.name},{self.volumes})" + class _Picture: """Wrapper class for pictures binaries""" diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 6713b67..fbc6965 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -31,9 +31,13 @@ def get_tag(identifier): return ret -def create_tag(name): +def create_tag(data): try: - tag = Tag(name=name) + if "id" in data: + data.pop("id") + allowed_keys = Tag().serialize().keys() + values = {key: value for key, value in data.items() if key in allowed_keys} + tag = Tag(**values) db.session.add(tag) update() return tag @@ -41,9 +45,12 @@ def create_tag(name): raise BadRequest("Name already exists") -def rename_tag(identifier, new_name): +def update_tag(identifier, data): tag = get_tag(identifier) - tag.name = new_name + allowed_keys = Tag().serialize().keys() + values = {key: value for key, value in data.items() if key in allowed_keys} + for key, value in values.items(): + setattr(tag, key, value) try: update() except IntegrityError: @@ -104,6 +111,7 @@ def delete_drink_type(identifier): except IntegrityError: raise BadRequest("DrinkType still in use") + def _create_public_drink(drink): _volumes = [] for volume in drink.volumes: @@ -120,6 +128,7 @@ def _create_public_drink(drink): return drink return None + def get_drinks(name=None, public=False): if name: drinks = Drink.query.filter(Drink.name.contains(name)).all() @@ -153,8 +162,13 @@ def update_drink(identifier, data): if "id" in data: data.pop("id") volumes = data.pop("volumes") if "volumes" in data else None + tags = [] if "tags" in data: - data.pop("tags") + _tags = data.pop("tags") + if isinstance(_tags, list): + for _tag in _tags: + if isinstance(_tag, dict) and "id" in _tag: + tags.append(get_tag(_tag["id"])) drink_type = data.pop("type") if isinstance(drink_type, dict) and "id" in drink_type: drink_type = drink_type["id"] @@ -172,6 +186,8 @@ def update_drink(identifier, data): drink.type = drink_type if volumes is not None: set_volumes(volumes, drink) + if len(tags) > 0: + drink.tags = tags db.session.commit() return drink except (NotFound, KeyError): @@ -265,7 +281,9 @@ def get_prices(volume_id=None): def set_price(data): - allowed_keys = DrinkPrice().serialize().keys() + allowed_keys = list(DrinkPrice().serialize().keys()) + allowed_keys.append("description") + logger.debug(f"allowed_key {allowed_keys}") values = {key: value for key, value in data.items() if key in allowed_keys} price_id = values.pop("id", -1) if price_id < 0: @@ -396,7 +414,7 @@ def get_drink_picture(identifier, size=None): if isinstance(identifier, int): drink = get_drink(identifier) if isinstance(identifier, str): - drink = Drink.query.filter(Drink.uuid==identifier).one_or_none() + drink = Drink.query.filter(Drink.uuid == identifier).one_or_none() try: if drink: return get_picture(f"{path}/{drink.uuid}", size) @@ -405,10 +423,10 @@ def get_drink_picture(identifier, size=None): db.session.commit() raise FileNotFoundError + def delete_drink_picture(identifier): drink = get_drink(identifier) if drink.uuid: delete_picture(f"{config['pricelist']['path']}/{drink.uuid}") drink.uuid = None db.session.commit() - From 064177542eb62cb940baacabc2158fc3969a1237 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 2 Apr 2021 06:58:47 +0200 Subject: [PATCH 254/446] [core] Added license and added setup information --- LICENSE | 21 +++++++++++++++++++++ setup.cfg | 19 +++++++++++++++++++ setup.py | 6 ------ 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bbcaf76 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright 2021 Tim Gröger | Flaschengeist Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/setup.cfg b/setup.cfg index 6218c19..957dff3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,22 @@ +[metadata] +license = MIT +version = 2.0.0.dev0 +name = flaschengeist +author = Tim Gröger +author_email = flaschengeist@wu5.de +url = https://flaschengeist.dev +long_description = file: README.md +long_description_content_type = text/markdown +description = Modular student club administration system +project_urls = + Documentation = https://docs.flaschengeist.dev + Source = https://flaschengeist.dev/Flaschengeist/flaschengeist + Tracker = https://flaschengeist.dev/Flaschengeist/flaschengeist/issues +classifiers = + Programming Language :: Python :: 3 + License :: OSI Approved :: MIT License + Operating System :: OS Independent + [bdist_wheel] universal = True diff --git a/setup.py b/setup.py index db12c1a..d7bfd18 100644 --- a/setup.py +++ b/setup.py @@ -28,12 +28,6 @@ class DocsCommand(Command): setup( - name="flaschengeist", - version="2.0.0.dev0", - url="https://wu5.de/redmine/projects/geruecht", - author="WU5 + Friends", - author_email="tim@groeger-clan.de", - description="Does things", packages=find_packages(), package_data={"": ["*.toml"]}, scripts=["run_flaschengeist"], From 03aa7a32314ae5334277583775fccc9fb44a18e9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 4 Apr 2021 21:46:51 +0200 Subject: [PATCH 255/446] [roles] controller: Fixed setting permissions --- flaschengeist/controller/roleController.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 501da56..a280388 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -44,12 +44,11 @@ def update_role(role, new_name): def set_permissions(role, permissions): - role.permissions.clear() for name in permissions: p = Permission.query.filter(Permission.name.in_(permissions)).all() if not p or len(p) < len(permissions): raise BadRequest("Invalid permission name >{}<".format(name)) - role.permissions.extend(p) + role.permissions = list(p) db.session.commit() From 62948cd59178e98d52c9653a850d93ea35f9dcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 14 Apr 2021 20:03:44 +0200 Subject: [PATCH 256/446] [picture][fix] any size for thumbnail --- .../plugins/pricelist/pricelist_controller.py | 10 +++------- flaschengeist/utils/picture.py | 12 ++++++------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index fbc6965..32d2c33 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -415,13 +415,9 @@ def get_drink_picture(identifier, size=None): drink = get_drink(identifier) if isinstance(identifier, str): drink = Drink.query.filter(Drink.uuid == identifier).one_or_none() - try: - if drink: - return get_picture(f"{path}/{drink.uuid}", size) - except FileNotFoundError: - drink.uuid = None - db.session.commit() - raise FileNotFoundError + if drink: + return get_picture(f"{path}/{drink.uuid}", size) + raise FileNotFoundError def delete_drink_picture(identifier): diff --git a/flaschengeist/utils/picture.py b/flaschengeist/utils/picture.py index dc896f5..3f2dc40 100644 --- a/flaschengeist/utils/picture.py +++ b/flaschengeist/utils/picture.py @@ -1,4 +1,4 @@ -import os, sys, shutil +import os, sys, shutil, io from PIL import Image from flask import Response from werkzeug.exceptions import BadRequest @@ -25,6 +25,7 @@ def save_picture(picture, path): work_image.thumbnail(thumbnail_size) work_image.save(f"{filename}-{thumbnail_size[0]}.png", "PNG") + def get_picture(path, size=None): try: if size: @@ -33,9 +34,10 @@ def get_picture(path, size=None): image = file.read() else: _image = Image.open(f"{path}/drink.png") - _image.thumbnail((size, size)) - image = bytearray() - _image.save(bytearray, format='PNG') + _image.thumbnail((int(size), int(size))) + with io.BytesIO() as file: + _image.save(file, format="PNG") + image = file.getvalue() else: with open(f"{path}/drink.png", "rb") as file: image = file.read() @@ -46,8 +48,6 @@ def get_picture(path, size=None): raise FileNotFoundError - - def delete_picture(path): try: shutil.rmtree(path) From 2d45c0dab9a46648744c3c590ea8bb6fe7e75e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 14 Apr 2021 20:11:23 +0200 Subject: [PATCH 257/446] [pricelist] delete visibleColums --- flaschengeist/plugins/pricelist/__init__.py | 34 +-------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 117aa3f..0f56111 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -198,39 +198,7 @@ def pricelist_settings_min_prices(): data.sort() PriceListPlugin.plugin.set_setting("min_prices", data) return no_content() - - -@PriceListPlugin.blueprint.route("/users//pricecalc_columns", methods=["GET", "PUT"]) -@login_required() -def get_columns(userid, current_session: Session): - """Get pricecalc_columns of an user - - Route: ``/users//pricelist/pricecac_columns`` | Method: ``GET`` or ``PUT`` - POST-data: On ``PUT`` json encoded array of floats - - Args: - userid: Userid identifying the user - current_session: Session sent with Authorization Header - - Returns: - GET: JSON object containing the shortcuts as float array or HTTP error - PUT: HTTP-created or HTTP error - """ - if userid != current_session.user_.userid: - raise Forbidden - - user = userController.get_user(userid) - if request.method == "GET": - return jsonify(user.get_attribute("pricecalc_columns", [])) - else: - data = request.get_json() - if not isinstance(data, list) or not all(isinstance(n, str) for n in data): - raise BadRequest - data.sort(reverse=True) - user.set_attribute("pricecalc_columns", data) - userController.persist() - return no_content() - + @PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) def set_picture(identifier): From 1d36c3ef6c069f50d52f8dc5ec19133bd6e646d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 14 Apr 2021 22:42:57 +0200 Subject: [PATCH 258/446] [pricelist] add more permissions --- flaschengeist/plugins/pricelist/__init__.py | 415 ++++++++++++++++-- .../plugins/pricelist/permissions.py | 14 + .../plugins/pricelist/pricelist_controller.py | 10 +- 3 files changed, 406 insertions(+), 33 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 0f56111..998e046 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -8,8 +8,6 @@ from flaschengeist import logger from flaschengeist.plugins import Plugin from flaschengeist.utils.decorators import login_required, extract_session from flaschengeist.utils.HTTP import no_content -from flaschengeist.models.session import Session -from flaschengeist.controller import userController from . import models from . import pricelist_controller, permissions @@ -31,6 +29,17 @@ class PriceListPlugin(Plugin): @PriceListPlugin.blueprint.route("/drink-types", methods=["GET"]) @PriceListPlugin.blueprint.route("/drink-types/", methods=["GET"]) def get_drink_types(identifier=None): + """Get DrinkType(s) + + Route: ``/pricelist/drink-types`` | Method: ``GET`` + Route: ``/pricelist/drink-types/`` | Method: ``GET`` + + Args: + identifier: If querying a spicific DrinkType + + Returns: + JSON encoded (list of) DrinkType(s) or HTTP-error + """ if identifier is None: result = pricelist_controller.get_drink_types() else: @@ -41,6 +50,18 @@ def get_drink_types(identifier=None): @PriceListPlugin.blueprint.route("/drink-types", methods=["POST"]) @login_required(permission=permissions.CREATE_TYPE) def new_drink_type(current_session): + """Create new DrinkType + + Route ``/pricelist/drink-types`` | Method: ``POST`` + + POST-data: ``{name: string}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded DrinkType or HTTP-error + """ data = request.get_json() if "name" not in data: raise BadRequest @@ -51,6 +72,19 @@ def new_drink_type(current_session): @PriceListPlugin.blueprint.route("/drink-types/", methods=["PUT"]) @login_required(permission=permissions.EDIT_TYPE) def update_drink_type(identifier, current_session): + """Modify DrinkType + + Route ``/pricelist/drink-types/`` | METHOD ``PUT`` + + POST-data: ``{name: string}`` + + Args: + identifier: Identifier of DrinkType + current_session: Session sent with Authorization Header + + Returns: + JSON encoded DrinkType or HTTP-error + """ data = request.get_json() if "name" not in data: raise BadRequest @@ -61,6 +95,17 @@ def update_drink_type(identifier, current_session): @PriceListPlugin.blueprint.route("/drink-types/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_TYPE) def delete_drink_type(identifier, current_session): + """Delete DrinkType + + Route: ``/pricelist/drink-types/`` | Method: ``DELETE`` + + Args: + identifier: Identifier of DrinkType + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ pricelist_controller.delete_drink_type(identifier) return no_content() @@ -68,6 +113,17 @@ def delete_drink_type(identifier, current_session): @PriceListPlugin.blueprint.route("/tags", methods=["GET"]) @PriceListPlugin.blueprint.route("/tags/", methods=["GET"]) def get_tags(identifier=None): + """Get Tag(s) + + Route: ``/pricelist/tags`` | Method: ``GET`` + Route: ``/pricelist/tags/`` | Method: ``GET`` + + Args: + identifier: Identifier of Tag + + Returns: + JSON encoded (list of) Tag(s) or HTTP-error + """ if identifier: result = pricelist_controller.get_tag(identifier) else: @@ -78,6 +134,18 @@ def get_tags(identifier=None): @PriceListPlugin.blueprint.route("/tags", methods=["POST"]) @login_required(permission=permissions.CREATE_TAG) def new_tag(current_session): + """Create Tag + + Route: ``/pricelist/tags`` | Method: ``POST`` + + POST-data: ``{name: string, color: string}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Tag or HTTP-error + """ data = request.get_json() drink_type = pricelist_controller.create_tag(data) return jsonify(drink_type) @@ -86,6 +154,19 @@ def new_tag(current_session): @PriceListPlugin.blueprint.route("/tags/", methods=["PUT"]) @login_required(permission=permissions.EDIT_TAG) def update_tag(identifier, current_session): + """Modify Tag + + Route: ``/pricelist/tags/`` | Methods: ``PUT`` + + POST-data: ``{name: string, color: string}`` + + Args: + identifier: Identifier of Tag + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Tag or HTTP-error + """ data = request.get_json() tag = pricelist_controller.update_tag(identifier, data) return jsonify(tag) @@ -94,6 +175,17 @@ def update_tag(identifier, current_session): @PriceListPlugin.blueprint.route("/tags/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_TAG) def delete_tag(identifier, current_session): + """Delete Tag + + Route: ``/pricelist/tags/`` | Methods: ``DELETE`` + + Args: + identifier: Identifier of Tag + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ pricelist_controller.delete_tag(identifier) return no_content() @@ -101,6 +193,17 @@ def delete_tag(identifier, current_session): @PriceListPlugin.blueprint.route("/drinks", methods=["GET"]) @PriceListPlugin.blueprint.route("/drinks/", methods=["GET"]) def get_drinks(identifier=None): + """Get Drink(s) + + Route: ``/pricelist/drinks`` | Method: ``GET`` + Route: ``/pricelist/drinks/`` | Method: ``GET`` + + Args: + identifier: Identifier of Drink + + Returns: + JSON encoded (list of) Drink(s) or HTTP-error + """ public = True try: extract_session() @@ -118,91 +221,335 @@ def get_drinks(identifier=None): @PriceListPlugin.blueprint.route("/drinks/search/", methods=["GET"]) def search_drinks(name): - return jsonify(pricelist_controller.get_drinks(name)) + """Search Drink + + Route: ``/pricelist/drinks/search/`` | Method: ``GET`` + + Args: + name: Name to search + + Returns: + JSON encoded list of Drinks or HTTP-error + """ + public = True + try: + extract_session() + public = False + except Unauthorized: + public = True + return jsonify(pricelist_controller.get_drinks(name, public=public)) @PriceListPlugin.blueprint.route("/drinks", methods=["POST"]) @login_required(permission=permissions.CREATE) def create_drink(current_session): + """Create Drink + + Route: ``/pricelist/drinks`` | Method: ``POST`` + + POST-data : +``{ + article_id?: string + cost_per_package?: float, + cost_per_volume?: float, + name: string, + package_size?: number, + receipt?: list[string], + tags?: list[Tag], + type: DrinkType, + uuid?: string, + volume?: float, + volumes?: list[ + { + ingredients?: list[{ + id: int + drink_ingredient?: { + ingredient_id: int, + volume: float + }, + extra_ingredient?: { + id: number, + } + }], + prices?: list[ + { + price: float + public: boolean + } + ], + volume: float + } + ] +}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Drink or HTTP-error + """ data = request.get_json() return jsonify(pricelist_controller.set_drink(data)) @PriceListPlugin.blueprint.route("/drinks/", methods=["PUT"]) -def update_drink(identifier): +@login_required(permission=permissions.EDIT) +def update_drink(identifier, current_session): + """Modify Drink + + Route: ``/pricelist/drinks/`` | Method: ``PUT`` + + POST-data : +``{ + article_id?: string + cost_per_package?: float, + cost_per_volume?: float, + name: string, + package_size?: number, + receipt?: list[string], + tags?: list[Tag], + type: DrinkType, + uuid?: string, + volume?: float, + volumes?: list[ + { + ingredients?: list[{ + id: int + drink_ingredient?: { + ingredient_id: int, + volume: float + }, + extra_ingredient?: { + id: number, + } + }], + prices?: list[ + { + price: float + public: boolean + } + ], + volume: float + } + ] +}`` + + Args: + identifier: Identifier of Drink + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Drink or HTTP-error + """ data = request.get_json() logger.debug(f"update drink {data}") return jsonify(pricelist_controller.update_drink(identifier, data)) @PriceListPlugin.blueprint.route("/drinks/", methods=["DELETE"]) -def delete_drink(identifier): +@login_required(permission=permissions.DELETE) +def delete_drink(identifier, current_session): + """Delete Drink + + Route: ``/pricelist/drinks/`` | Method: ``DELETE`` + + Args: + identifier: Identifier of Drink + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ pricelist_controller.delete_drink(identifier) return no_content() @PriceListPlugin.blueprint.route("/prices/", methods=["DELETE"]) -def delete_price(identifier): +@login_required(permission=permissions.DELETE_PRICE) +def delete_price(identifier, current_session): + """Delete Price + + Route: ``/pricelist/prices/`` | Methods: ``DELETE`` + + Args: + identifier: Identiefer of Price + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ pricelist_controller.delete_price(identifier) return no_content() @PriceListPlugin.blueprint.route("/volumes/", methods=["DELETE"]) -def delete_volume(identifier): +@login_required(permission=permissions.DELETE_VOLUME) +def delete_volume(identifier, current_session): + """Delete DrinkPriceVolume + + Route: ``/pricelist/volumes/`` | Method: ``DELETE`` + + Args: + identifier: Identifier of DrinkPriceVolume + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ pricelist_controller.delete_volume(identifier) return no_content() @PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"]) -def get_extra_ingredients(): +@login_required() +def get_extra_ingredients(current_session): + """Get ExtraIngredients + + Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded list of ExtraIngredients or HTTP-error + """ return jsonify(pricelist_controller.get_extra_ingredients()) @PriceListPlugin.blueprint.route("/ingredients/", methods=["DELETE"]) -def delete_ingredient(identifier): +@login_required(permission=permissions.DELETE_INGREDIENTS_DRINK) +def delete_ingredient(identifier, current_session): + """Delete Ingredient + + Route: ``/pricelist/ingredients/`` | Method: ``DELETE`` + + Args: + identifier: Identifier of Ingredient + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ pricelist_controller.delete_ingredient(identifier) return no_content() @PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"]) -def set_extra_ingredient(): +@login_required(permission=permissions.EDIT_INGREDIENTS) +def set_extra_ingredient(current_session): + """Create ExtraIngredient + + Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``POST`` + + POST-data: ``{ name: string, price: float }`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded ExtraIngredient or HTTP-error + """ data = request.get_json() return jsonify(pricelist_controller.set_extra_ingredient(data)) @PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", methods=["PUT"]) -def update_extra_ingredient(identifier): +@login_required(permission=permissions.EDIT_INGREDIENTS) +def update_extra_ingredient(identifier, current_session): + """Modify ExtraIngredient + + Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``PUT`` + + POST-data: ``{ name: string, price: float }`` + + Args: + identifier: Identifier of ExtraIngredient + current_session: Session sent with Authorization Header + + Returns: + JSON encoded ExtraIngredient or HTTP-error + """ data = request.get_json() return jsonify(pricelist_controller.update_extra_ingredient(identifier, data)) @PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", methods=["DELETE"]) -def delete_extra_ingredient(identifier): +@login_required(permission=permissions.DELETE_INGREDIENTS) +def delete_extra_ingredient(identifier, current_session): + """Delete ExtraIngredient + + Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``DELETE`` + + Args: + identifier: Identifier of ExtraIngredient + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ pricelist_controller.delete_extra_ingredient(identifier) return no_content() -@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST", "GET"]) -def pricelist_settings_min_prices(): - if request.method == "GET": - # TODO: Handle if no prices are set! - try: - min_prices = PriceListPlugin.plugin.get_setting("min_prices") - except KeyError: - min_prices = [] - return jsonify(min_prices) - else: - data = request.get_json() - if not isinstance(data, list) or not all(isinstance(n, int) for n in data): - raise BadRequest - data.sort() - PriceListPlugin.plugin.set_setting("min_prices", data) - return no_content() - +@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["GET"]) +@login_required() +def get_pricelist_settings_min_prices(current_session): + """Get MinPrices + + Route: ``/pricelist/settings/min_prices`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded list of MinPrices + """ + # TODO: Handle if no prices are set! + try: + min_prices = PriceListPlugin.plugin.get_setting("min_prices") + except KeyError: + min_prices = [] + return jsonify(min_prices) + +@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST"]) +@login_required(permission=permissions.EDIT_MIN_PRICES) +def post_pricelist_settings_min_prices(current_session): + """Create MinPrices + + Route: ``/pricelist/settings/min_prices`` | Method: ``POST`` + + POST-data: ``list[int]`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ + data = request.get_json() + if not isinstance(data, list) or not all(isinstance(n, int) for n in data): + raise BadRequest + data.sort() + PriceListPlugin.plugin.set_setting("min_prices", data) + return no_content() + @PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) -def set_picture(identifier): +@login_required(permission=permissions.EDIT) +def set_picture(identifier, current_session): + """Get, Create, Delete Drink Picture + Route: ``/pricelist//picture`` | Method: ``GET,POST,DELETE`` + + POST-data: (if remaining) ``Form-Data: mime: 'image/*'`` + + Args: + identifier: Identifier of Drink + current_session: Session sent with Authorization + + Returns: + Picture or HTTP-error + """ if request.method == "DELETE": pricelist_controller.delete_drink_picture(identifier) return no_content() @@ -219,6 +566,14 @@ def set_picture(identifier): @PriceListPlugin.blueprint.route("/picture/", methods=["GET"]) def _get_picture(identifier): + """Get Picture + + Args: + identifier: Identifier of Picture + + Returns: + Picture or HTTP-error + """ if request.method == "GET": size = request.args.get("size") response = pricelist_controller.get_drink_picture(identifier, size) diff --git a/flaschengeist/plugins/pricelist/permissions.py b/flaschengeist/plugins/pricelist/permissions.py index b92ab9a..a94b62b 100644 --- a/flaschengeist/plugins/pricelist/permissions.py +++ b/flaschengeist/plugins/pricelist/permissions.py @@ -10,6 +10,18 @@ DELETE = "drink_delete" CREATE_TAG = "drink_tag_create" """Can create and edit Tags""" +EDIT_PRICE = "edit_price" +DELETE_PRICE = "delete_price" + +EDIT_VOLUME = "edit_volume" +DELETE_VOLUME = "delete_volume" + +EDIT_INGREDIENTS_DRINK = "edit_ingredients_drink" +DELETE_INGREDIENTS_DRINK = "delete_ingredients_drink" + +EDIT_INGREDIENTS = "edit_ingredients" +DELETE_INGREDIENTS = "delete_ingredients" + EDIT_TAG = "drink_tag_edit" DELETE_TAG = "drink_tag_delete" @@ -20,4 +32,6 @@ EDIT_TYPE = "drink_type_edit" DELETE_TYPE = "drink_type_delete" +EDIT_MIN_PRICES = "edit_min_prices" + permissions = [value for key, value in globals().items() if not key.startswith("_")] diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 32d2c33..ca18aaf 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -6,8 +6,10 @@ from flaschengeist import logger from flaschengeist.config import config from flaschengeist.database import db from flaschengeist.utils.picture import save_picture, get_picture, delete_picture +from flaschengeist.utils.decorators import extract_session from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient +from .permissions import EDIT_VOLUME, EDIT_PRICE, EDIT_INGREDIENTS_DRINK def update(): @@ -159,6 +161,7 @@ def set_drink(data): def update_drink(identifier, data): try: + session = extract_session() if "id" in data: data.pop("id") volumes = data.pop("volumes") if "volumes" in data else None @@ -184,7 +187,7 @@ def update_drink(identifier, data): if drink_type: drink.type = drink_type - if volumes is not None: + if volumes is not None and session.user_.has_permission(EDIT_VOLUME): set_volumes(volumes, drink) if len(tags) > 0: drink.tags = tags @@ -218,6 +221,7 @@ def get_volumes(drink_id=None): def set_volume(data): + session = extract_session() allowed_keys = DrinkPriceVolume().serialize().keys() values = {key: value for key, value in data.items() if key in allowed_keys} prices = None @@ -237,9 +241,9 @@ def set_volume(data): for key, value in values.items(): setattr(volume, key, value if value != "" else None) - if prices: + if prices and session.user_.has_permission(EDIT_PRICE): set_prices(prices, volume) - if ingredients: + if ingredients and session.user_.has_permission(EDIT_INGREDIENTS_DRINK): set_ingredients(ingredients, volume) return volume From 32ad4471c6553387202765f48e8774a120fe5075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 14 Apr 2021 22:43:28 +0200 Subject: [PATCH 259/446] [pricelist] black code --- flaschengeist/plugins/pricelist/__init__.py | 159 ++++++++++---------- 1 file changed, 80 insertions(+), 79 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 998e046..fce9659 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -245,48 +245,48 @@ def search_drinks(name): def create_drink(current_session): """Create Drink - Route: ``/pricelist/drinks`` | Method: ``POST`` + Route: ``/pricelist/drinks`` | Method: ``POST`` - POST-data : -``{ - article_id?: string - cost_per_package?: float, - cost_per_volume?: float, - name: string, - package_size?: number, - receipt?: list[string], - tags?: list[Tag], - type: DrinkType, - uuid?: string, - volume?: float, - volumes?: list[ - { - ingredients?: list[{ - id: int - drink_ingredient?: { - ingredient_id: int, - volume: float - }, - extra_ingredient?: { - id: number, - } - }], - prices?: list[ - { - price: float - public: boolean - } - ], - volume: float - } - ] -}`` + POST-data : + ``{ + article_id?: string + cost_per_package?: float, + cost_per_volume?: float, + name: string, + package_size?: number, + receipt?: list[string], + tags?: list[Tag], + type: DrinkType, + uuid?: string, + volume?: float, + volumes?: list[ + { + ingredients?: list[{ + id: int + drink_ingredient?: { + ingredient_id: int, + volume: float + }, + extra_ingredient?: { + id: number, + } + }], + prices?: list[ + { + price: float + public: boolean + } + ], + volume: float + } + ] + }`` - Args: - current_session: Session sent with Authorization Header + Args: + current_session: Session sent with Authorization Header - Returns: - JSON encoded Drink or HTTP-error + Returns: + JSON encoded Drink or HTTP-error """ data = request.get_json() return jsonify(pricelist_controller.set_drink(data)) @@ -297,49 +297,49 @@ def create_drink(current_session): def update_drink(identifier, current_session): """Modify Drink - Route: ``/pricelist/drinks/`` | Method: ``PUT`` + Route: ``/pricelist/drinks/`` | Method: ``PUT`` - POST-data : -``{ - article_id?: string - cost_per_package?: float, - cost_per_volume?: float, - name: string, - package_size?: number, - receipt?: list[string], - tags?: list[Tag], - type: DrinkType, - uuid?: string, - volume?: float, - volumes?: list[ - { - ingredients?: list[{ - id: int - drink_ingredient?: { - ingredient_id: int, - volume: float - }, - extra_ingredient?: { - id: number, - } - }], - prices?: list[ - { - price: float - public: boolean - } - ], - volume: float - } - ] -}`` + POST-data : + ``{ + article_id?: string + cost_per_package?: float, + cost_per_volume?: float, + name: string, + package_size?: number, + receipt?: list[string], + tags?: list[Tag], + type: DrinkType, + uuid?: string, + volume?: float, + volumes?: list[ + { + ingredients?: list[{ + id: int + drink_ingredient?: { + ingredient_id: int, + volume: float + }, + extra_ingredient?: { + id: number, + } + }], + prices?: list[ + { + price: float + public: boolean + } + ], + volume: float + } + ] + }`` - Args: - identifier: Identifier of Drink - current_session: Session sent with Authorization Header + Args: + identifier: Identifier of Drink + current_session: Session sent with Authorization Header - Returns: - JSON encoded Drink or HTTP-error + Returns: + JSON encoded Drink or HTTP-error """ data = request.get_json() logger.debug(f"update drink {data}") @@ -511,6 +511,7 @@ def get_pricelist_settings_min_prices(current_session): min_prices = [] return jsonify(min_prices) + @PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST"]) @login_required(permission=permissions.EDIT_MIN_PRICES) def post_pricelist_settings_min_prices(current_session): From 0630b5183d9a0a4908580049515628cd4cebfec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 15 Apr 2021 15:23:37 +0200 Subject: [PATCH 260/446] [pricelist] fix bug set no volumes are set --- flaschengeist/plugins/pricelist/pricelist_controller.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index ca18aaf..1c1491e 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -188,7 +188,8 @@ def update_drink(identifier, data): if drink_type: drink.type = drink_type if volumes is not None and session.user_.has_permission(EDIT_VOLUME): - set_volumes(volumes, drink) + drink.volumes = [] + drink.volumes = set_volumes(volumes) if len(tags) > 0: drink.tags = tags db.session.commit() @@ -197,11 +198,13 @@ def update_drink(identifier, data): raise BadRequest -def set_volumes(volumes, drink): +def set_volumes(volumes): + retVal = [] if not isinstance(volumes, list): raise BadRequest for volume in volumes: - drink.volumes.append(set_volume(volume)) + retVal.append(set_volume(volume)) + return retVal def delete_drink(identifier): From 2d31cda66570dbcd33916a6b8d7e90bb6cc6f362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 15 Apr 2021 22:05:34 +0200 Subject: [PATCH 261/446] Revert "[pricelist] delete visibleColums" This reverts commit 2d45c0dab9a46648744c3c590ea8bb6fe7e75e12. Conflicts: flaschengeist/plugins/pricelist/__init__.py --- flaschengeist/plugins/pricelist/__init__.py | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index fce9659..e7a25e3 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -5,6 +5,7 @@ from werkzeug.local import LocalProxy from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized from flaschengeist import logger +from flaschengeist.controller import userController from flaschengeist.plugins import Plugin from flaschengeist.utils.decorators import login_required, extract_session from flaschengeist.utils.HTTP import no_content @@ -535,6 +536,39 @@ def post_pricelist_settings_min_prices(current_session): return no_content() + +@PriceListPlugin.blueprint.route("/users//pricecalc_columns", methods=["GET", "PUT"]) +@login_required() +def get_columns(userid, current_session): + """Get pricecalc_columns of an user + + Route: ``/users//pricelist/pricecac_columns`` | Method: ``GET`` or ``PUT`` + POST-data: On ``PUT`` json encoded array of floats + + Args: + userid: Userid identifying the user + current_session: Session sent with Authorization Header + + Returns: + GET: JSON object containing the shortcuts as float array or HTTP error + PUT: HTTP-created or HTTP error + """ + if userid != current_session.user_.userid: + raise Forbidden + + user = userController.get_user(userid) + if request.method == "GET": + return jsonify(user.get_attribute("pricecalc_columns", [])) + else: + data = request.get_json() + if not isinstance(data, list) or not all(isinstance(n, str) for n in data): + raise BadRequest + data.sort(reverse=True) + user.set_attribute("pricecalc_columns", data) + userController.persist() + return no_content() + + @PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) @login_required(permission=permissions.EDIT) def set_picture(identifier, current_session): From f5624e9a7d4a5f81f5d5f13c85cb614d20a2841b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 15 Apr 2021 22:55:41 +0200 Subject: [PATCH 262/446] [pricelist] add user options pricelist_view --- flaschengeist/plugins/pricelist/__init__.py | 34 ++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index e7a25e3..68205ba 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -536,7 +536,6 @@ def post_pricelist_settings_min_prices(current_session): return no_content() - @PriceListPlugin.blueprint.route("/users//pricecalc_columns", methods=["GET", "PUT"]) @login_required() def get_columns(userid, current_session): @@ -569,6 +568,39 @@ def get_columns(userid, current_session): return no_content() +@PriceListPlugin.blueprint.route("/users//pricelist", methods=["GET", "PUT"]) +@login_required() +def get_priclist_setting(userid, current_session): + """Get pricelistsetting of an user + + Route: ``/pricelist/user//pricelist`` | Method: ``GET`` or ``PUT`` + + POST-data: on ``PUT`` ``{value: boolean}`` + + Args: + userid: Userid identifying the user + current_session: Session sent wth Authorization Header + + Returns: + GET: JSON object containing the value as boolean or HTTP-error + PUT: HTTP-NoContent or HTTP-error + """ + + if userid != current_session.user_.userid: + raise Forbidden + + user = userController.get_user(userid) + if request.method == "GET": + return jsonify(user.get_attribute("pricelist_view", {"value": False})) + else: + data = request.get_json() + if not isinstance(data, dict) or not "value" in data or not isinstance(data["value"], bool): + raise BadRequest + user.set_attribute("pricelist_view", data) + userController.persist() + return no_content() + + @PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) @login_required(permission=permissions.EDIT) def set_picture(identifier, current_session): From 3da2ed53d5676dd6f4079c3a06ed5715480005e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 17 Apr 2021 18:27:14 +0200 Subject: [PATCH 263/446] [pricelist][#6] save order of pricelist columns for user --- flaschengeist/plugins/pricelist/__init__.py | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 68205ba..ef19807 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -567,6 +567,36 @@ def get_columns(userid, current_session): userController.persist() return no_content() +@PriceListPlugin.blueprint.route("/users//pricecalc_columns_order", methods=["GET", "PUT"]) +@login_required() +def get_columns_order(userid, current_session): + """Get pricecalc_columns_order of an user + + Route: ``/users//pricelist/pricecac_columns_order`` | Method: ``GET`` or ``PUT`` + POST-data: On ``PUT`` json encoded array of floats + + Args: + userid: Userid identifying the user + current_session: Session sent with Authorization Header + + Returns: + GET: JSON object containing the shortcuts as object array or HTTP error + PUT: HTTP-created or HTTP error + """ + if userid != current_session.user_.userid: + raise Forbidden + + user = userController.get_user(userid) + if request.method == "GET": + return jsonify(user.get_attribute("pricecalc_columns_order", [])) + else: + data = request.get_json() + if not isinstance(data, list) or not all(isinstance(n, str) for mop in data for n in mop.values()): + raise BadRequest + user.set_attribute("pricecalc_columns_order", data) + userController.persist() + return no_content() + @PriceListPlugin.blueprint.route("/users//pricelist", methods=["GET", "PUT"]) @login_required() From 96e4e73f4becd6aa475e87377a3a265054e5d5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 18 Apr 2021 23:28:05 +0200 Subject: [PATCH 264/446] [users] add dynamic shortcuts for users --- flaschengeist/plugins/users/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index e5b08a6..8a0db58 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -231,3 +231,21 @@ def notifications(current_session): def remove_notifications(nid, current_session): userController.delete_notification(nid, current_session.user_) return no_content() + + +@UsersPlugin.blueprint.route("/users//shortcuts", methods=["GET", "PUT"]) +@login_required() +def shortcuts(userid, current_session): + if userid != current_session.user_.userid: + raise Forbidden + + user = userController.get_user(userid) + if request.method == "GET": + return jsonify(user.get_attribute("users_link_shortcuts", [])) + else: + data = request.get_json() + if not isinstance(data, list) or not all(isinstance(n, dict) for n in data): + raise BadRequest + user.set_attribute("users_link_shortcuts", data) + userController.persist() + return no_content() From 776332d5fe6a5c2fdc1c400047db39833a9f52fe Mon Sep 17 00:00:00 2001 From: groegert Date: Thu, 20 May 2021 15:37:17 +0000 Subject: [PATCH 265/446] added some hints to ease the installation --- readme.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index c10cf58..5cef43d 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,10 @@ # Flaschengeist +This is the backend of the Flaschengeist. ## Installation ### Requirements - mysql or mariadb + - including development files libmariadb-dev - python 3.6+ ### Install python files pip3 install --user . @@ -30,9 +32,21 @@ Configuration is done within the a `flaschengeist.toml`file, you can copy the on 1. `~/.config/` 2. A custom path and set environment variable `FLASCHENGEIST_CONF` -Change at least the database parameters! +Uncomment and change at least all the database parameters! ### Database installation +The user needs to have full permissions to the database. +If not you need to create user and database manually do (or similar on Windows): + + ( + echo "CREATE DATABASE flaschengeist;" + echo "CREATE USER 'flaschengeist'@'localhost' IDENTIFIED BY 'flaschengeist';" + echo "GRANT ALL PRIVILEGES ON 'flaschengeist'.* TO 'flaschengeist'@'localhost';" + echo "FLUSH PRIVILEGES;" + ) | sudo mysql + +Then you can install the database tables and initial entries: + run_flaschengeist install ### Run @@ -41,6 +55,8 @@ or with debug messages: run_flaschengeist run --debug +This will run the backend on http://localhost:5000 + ## Tests $ pip install '.[test]' $ pytest @@ -55,7 +71,7 @@ Or with html output (open `htmlcov/index.html` in a browser): ## Development ### Code Style We enforce you to use PEP 8 code style with a line length of 120 as used by Black. -See also [Black Code Style](https://github.com/psf/black/blob/master/docs/the_black_code_style.md). +See also [Black Code Style](https://github.com/psf/black/blob/main/docs/the_black_code_style/current_style.md). #### Code formatting We use [Black](https://github.com/psf/black) as the code formatter. From 8696699ecb8e90dc3b4b95d7d3d3c6e8a9ba3a75 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 26 May 2021 16:47:03 +0200 Subject: [PATCH 266/446] [run_flaschengeist] Improved export command Export now supports --no-core flag, if set no core models will get exported. Also --plugins was changed to support a list of plugins, if no list is given the old behavior is taken (export all plugins). If a list of plugins is given, only those plugins are exported. --- flaschengeist/plugins/pricelist/__init__.py | 1 + run_flaschengeist | 37 ++++++++++++--------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index ef19807..b4df1ac 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -567,6 +567,7 @@ def get_columns(userid, current_session): userController.persist() return no_content() + @PriceListPlugin.blueprint.route("/users//pricecalc_columns_order", methods=["GET", "PUT"]) @login_required() def get_columns_order(userid, current_session): diff --git a/run_flaschengeist b/run_flaschengeist index 26c10ec..e11271e 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -17,7 +17,7 @@ class PrefixMiddleware(object): def __call__(self, environ, start_response): if environ["PATH_INFO"].startswith(self.prefix): - environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix):] + environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :] environ["SCRIPT_NAME"] = self.prefix return self.app(environ, start_response) else: @@ -83,19 +83,19 @@ class InterfaceGenerator: import typing if ( - inspect.ismodule(module[1]) - and module[1].__name__.startswith(self.basename) - and module[1].__name__ not in self.known + inspect.ismodule(module[1]) + and module[1].__name__.startswith(self.basename) + and module[1].__name__ not in self.known ): self.known.append(module[1].__name__) for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): self.walker(cls) elif ( - inspect.isclass(module[1]) - and module[1].__module__.startswith(self.basename) - and module[0] not in self.classes - and not module[0].startswith("_") - and hasattr(module[1], "__annotations__") + inspect.isclass(module[1]) + and module[1].__module__.startswith(self.basename) + and module[0] not in self.classes + and not module[0].startswith("_") + and hasattr(module[1], "__annotations__") ): self.this_type = module[0] print("\n\n" + module[0] + "\n") @@ -156,12 +156,14 @@ def export(arguments): app = create_app() with app.app_context(): gen = InterfaceGenerator(arguments.namespace, arguments.file) - gen.run(models) - if arguments.plugins: + if not arguments.no_core: + gen.run(models) + if arguments.plugins is not None: for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"): - plg = entry_point.load() - if hasattr(plg, "models") and plg.models is not None: - gen.run(plg.models) + if len(arguments.plugins) == 0 or entry_point.name in arguments.plugins: + plg = entry_point.load() + if hasattr(plg, "models") and plg.models is not None: + gen.run(plg.models) gen.write() @@ -183,7 +185,12 @@ if __name__ == "__main__": parser_export.set_defaults(func=export) parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts") parser_export.add_argument("--namespace", help="Namespace of declarations", default="FG") - parser_export.add_argument("--plugins", help="Also export plugins", action="store_true") + parser_export.add_argument( + "--no-core", + help="Do not export core declarations (only useful in conjunction with --plugins)", + action="store_true", + ) + parser_export.add_argument("--plugins", help="Also export plugins (none means all)", nargs="*") args = parser.parse_args() args.func(args) From 7b5f854d510c65c8b9932f48cf255a5dc257a47c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 27 May 2021 01:27:53 +0200 Subject: [PATCH 267/446] [db] Support sqlite and postgresql as engine, fixes #5 mysql / mariadb still is the only tested configuration. This will break existing databases, as UTF8MB4 is enforced for mysql (real UTF8). --- flaschengeist/config.py | 29 +++++++++++++++++++++-------- flaschengeist/flaschengeist.toml | 1 + flaschengeist/models/__init__.py | 4 ++-- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index ed74470..9820e9e 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -9,7 +9,7 @@ from flaschengeist import _module_path, logger # Default config: -config = {"DATABASE": {"port": 3306}} +config = {"DATABASE": {"engine": "mysql", "port": 3306}} def update_dict(d, u): @@ -65,17 +65,30 @@ def configure_app(app, test_config=None): else: app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] - if test_config is None: - app.config["SQLALCHEMY_DATABASE_URI"] = "mysql{driver}://{user}:{passwd}@{host}:{port}/{database}".format( - driver="+pymysql" if os.name == "nt" else "", + if test_config is not None: + config["DATABASE"]["engine"] = "sqlite" + + if config["DATABASE"]["engine"] == "mysql": + engine = "mysql" + ("+pymysql" if os.name == "nt" else "") + options = "?charset=utf8mb4" + elif config["DATABASE"]["engine"] == "postgres": + engine = "postgresql+psycopg2" + options = "?client_encoding=utf8" + elif config["DATABASE"]["engine"] == "sqlite": + engine = "sqlite" + options = "" + host = "" + else: + logger.error(f"Invalid database engine configured. >{config['DATABASE']['engine']}< is unknown") + raise Exception + if config["DATABASE"]["engine"] in ["mysql", "postgresql"]: + host = "{user}:{password}@{host}:{port}".format( user=config["DATABASE"]["user"], - passwd=config["DATABASE"]["password"], + password=config["DATABASE"]["password"], host=config["DATABASE"]["host"], - database=config["DATABASE"]["database"], port=config["DATABASE"]["port"], ) - else: - app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite+pysqlite://{config['DATABASE']['file_path']}" + app.config["SQLALCHEMY_DATABASE_URI"] = f"{engine}://{host}/{config['DATABASE']['database']}{options}" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False if "root" in config["FLASCHENGEIST"]: diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index 0ec50a0..ffbb51d 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -20,6 +20,7 @@ secret_key = "V3ryS3cr3t" level = "WARNING" [DATABASE] +# engine = "mysql" (default) # user = "user" # host = "127.0.0.1" # password = "password" diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index f63d173..1b503dd 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -2,7 +2,7 @@ import sys import datetime from sqlalchemy import BigInteger -from sqlalchemy.dialects import mysql +from sqlalchemy.dialects import mysql, sqlite from sqlalchemy.types import DateTime, TypeDecorator @@ -44,7 +44,7 @@ class ModelSerializeMixin: class Serial(TypeDecorator): """Same as MariaDB Serial used for IDs""" - impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql") + impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite") class UtcDateTime(TypeDecorator): From 7928c16c07c72c0d6ec4833ec4beafd54160a44a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 27 May 2021 01:52:30 +0200 Subject: [PATCH 268/446] [db] Try mysqlclient first, maybe the user managed to get it working on Windows --- flaschengeist/config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 9820e9e..e06fcae 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -69,7 +69,12 @@ def configure_app(app, test_config=None): config["DATABASE"]["engine"] = "sqlite" if config["DATABASE"]["engine"] == "mysql": - engine = "mysql" + ("+pymysql" if os.name == "nt" else "") + engine = "mysql" + try: + # Try mysqlclient first + from MySQLdb import _mysql + except ModuleNotFoundError: + engine += "+pymysql" options = "?charset=utf8mb4" elif config["DATABASE"]["engine"] == "postgres": engine = "postgresql+psycopg2" From 3fc04c4143564d3b1c678e23d73ec3a6b2a4f4a5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 27 May 2021 01:52:45 +0200 Subject: [PATCH 269/446] [docs] Moved some devel docs to the wiki --- readme.md | 98 +++++-------------------------------------------------- 1 file changed, 9 insertions(+), 89 deletions(-) diff --git a/readme.md b/readme.md index 5cef43d..133a570 100644 --- a/readme.md +++ b/readme.md @@ -3,9 +3,14 @@ This is the backend of the Flaschengeist. ## Installation ### Requirements -- mysql or mariadb - - including development files libmariadb-dev -- python 3.6+ +- `mysql` or `mariadb` + - maybe `libmariadb` development files[1] +- python 3.7+ + +[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on +Linux / Mac the faster `mysqlclient` is used, if it is not already installed installing from pypi requires the +development files for `libmariadb` to be present on your system. + ### Install python files pip3 install --user . or with ldap support @@ -69,89 +74,4 @@ Or with html output (open `htmlcov/index.html` in a browser): $ coverage html ## Development -### Code Style -We enforce you to use PEP 8 code style with a line length of 120 as used by Black. -See also [Black Code Style](https://github.com/psf/black/blob/main/docs/the_black_code_style/current_style.md). - -#### Code formatting -We use [Black](https://github.com/psf/black) as the code formatter. - -Installation: - - pip install black -Usage: - - black -l 120 DIRECTORY_OR_FILE - -### Misc -#### Git blame -When using `git blame` use this to ignore the code formatting commits: - - $ git blame FILE.py --ignore-revs-file .git-blame-ignore-revs -Or if you just want to use `git blame`, configure git like this: - - $ git config blame.ignoreRevsFile .git-blame-ignore-revs - -#### Ignore changes on config - git update-index --assume-unchanged flaschengeist/flaschengeist.toml - -## Plugin Development -### File Structure - flaschengeist-example-plugin - |> __init__.py - |> model.py - |> setup.py - -### Files -#### \_\_init\_\_.py - from flask import Blueprint - from flaschengeist.modules import Plugin - - - example_bp = Blueprint("example", __name__, url_prefix="/example") - permissions = ["example_hello"] - - class PluginExample(Plugin): - def __init__(self, conf): - super().__init__(blueprint=example_bp, permissions=permissions) - - def install(self): - from flaschengeist.system.database import db - import .model - db.create_all() - db.session.commit() - - - @example_bp.route("/hello", methods=['GET']) - @login_required(roles=['example_hello']) - def __hello(id, **kwargs): - return "Hello" - -#### model.py -Optional, only needed if you need your own models (database) - - from flaschengeist.system.database import db - - - class ExampleModel(db.Model): - """Example Model""" - __tablename__ = 'example' - id = db.Column(db.Integer, primary_key=True) - description = db.Column(db.String(240)) - -#### setup.py - from setuptools import setup, find_packages - - setup( - name="flaschengeist-example-plugin", - version="0.0.0-dev", - packages=find_packages(), - install_requires=[ - "flaschengeist >= 2", - ], - entry_points={ - "flaschengeist.plugin": [ - "example = flaschengeist-example-plugin:ExampleModel" - ] - }, - ) +Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development). \ No newline at end of file From e7b978ae3c6fc57288e21204b7fb1a1f8b042401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 30 Jun 2021 10:45:41 +0200 Subject: [PATCH 270/446] better drink dependency drink_ingredient has name and cost_per_volume https://flaschengeist.dev/Flaschengeist/flaschengeist-pricelist/issues/2 --- flaschengeist/plugins/pricelist/models.py | 17 +++++++++-------- .../plugins/pricelist/pricelist_controller.py | 4 ++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index ffde797..f6d68f9 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -76,16 +76,17 @@ class DrinkIngredient(db.Model, ModelSerializeMixin): id: int = db.Column("id", db.Integer, primary_key=True) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id")) - # drink_ingredient: Drink = db.relationship("Drink") - # price: float = 0 + cost_per_volume: float + name: str + _drink_ingredient: Drink = db.relationship("Drink") + @property + def cost_per_volume(self): + return self._drink_ingredient.cost_per_volume if self._drink_ingredient else None -# @property -# def price(self): -# try: -# return self.drink_ingredient.cost_price_pro_volume * self.volume -# except AttributeError: -# pass + @property + def name(self): + return self._drink_ingredient.name if self._drink_ingredient else None class Ingredient(db.Model, ModelSerializeMixin): diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 1c1491e..c89dc2d 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -315,6 +315,10 @@ def delete_price(identifier): def set_drink_ingredient(data): allowed_keys = DrinkIngredient().serialize().keys() values = {key: value for key, value in data.items() if key in allowed_keys} + if "cost_per_volume" in values: + values.pop("cost_per_volume") + if "name" in values: + values.pop("name") ingredient_id = values.pop("id", -1) if ingredient_id < 0: drink_ingredient = DrinkIngredient(**values) From 712946983588ecb0f54a9ff4cbf647cf24db7047 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Jul 2021 17:18:01 +0200 Subject: [PATCH 271/446] [roles] MySQL is caseinsensitive for strings so workaround it for renaming roles --- flaschengeist/controller/roleController.py | 6 +++--- flaschengeist/database.py | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index a280388..1e4e6e1 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -2,7 +2,7 @@ from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import BadRequest, NotFound from flaschengeist.models.user import Role, Permission -from flaschengeist.database import db +from flaschengeist.database import db, case_sensitive from flaschengeist import logger from flaschengeist.utils.hook import Hook @@ -36,8 +36,8 @@ def update_role(role, new_name): except IntegrityError: logger.debug("IntegrityError: Role might still be in use", exc_info=True) raise BadRequest("Role still in use") - elif role.name != new_name: - if db.session.query(db.exists().where(Role.name == new_name)).scalar(): + else: + if role.name == new_name or db.session.query(db.exists().where(Role.name == case_sensitive(new_name))).scalar(): raise BadRequest("Name already used") role.name = new_name db.session.commit() diff --git a/flaschengeist/database.py b/flaschengeist/database.py index f0b13d6..85fc3e1 100644 --- a/flaschengeist/database.py +++ b/flaschengeist/database.py @@ -1,3 +1,10 @@ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() + + +def case_sensitive(s): + if db.session.bind.dialect.name == "mysql": + from sqlalchemy import func + return func.binary(s) + return s From 0d1a39f217428258feeac1f643443c535306f0d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 17 Apr 2021 20:37:53 +0200 Subject: [PATCH 272/446] [balance] add sorting of transaction --- flaschengeist/plugins/balance/balance_controller.py | 9 +++++++-- flaschengeist/plugins/balance/routes.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 0ab282f..58b1152 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -113,7 +113,9 @@ def get_transaction(transaction_id) -> Transaction: return transaction -def get_transactions(user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True): +def get_transactions( + user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True, descending=False +): count = None query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user)) if start: @@ -125,7 +127,10 @@ def get_transactions(user, start=None, end=None, limit=None, offset=None, show_r query = query.filter(Transaction.original_ == None) if not show_cancelled: query = query.filter(Transaction.reversal_id.is_(None)) - query = query.order_by(Transaction.time) + if descending: + query = query.order_by(Transaction.time.desc()) + else: + query = query.order_by(Transaction.time) if limit is not None: count = query.count() query = query.limit(limit) diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py index d28f913..1341e6e 100644 --- a/flaschengeist/plugins/balance/routes.py +++ b/flaschengeist/plugins/balance/routes.py @@ -170,6 +170,7 @@ def get_transactions(userid, current_session: Session): show_cancelled = request.args.get("showCancelled", True) limit = request.args.get("limit") offset = request.args.get("offset") + descending = request.args.get("descending", False) try: if limit is not None: limit = int(limit) @@ -179,11 +180,20 @@ def get_transactions(userid, current_session: Session): show_reversals = str2bool(show_reversals) if not isinstance(show_cancelled, bool): show_cancelled = str2bool(show_cancelled) + if not isinstance(descending, bool): + descending = str2bool(descending) except ValueError: raise BadRequest transactions, count = balance_controller.get_transactions( - user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled + user, + start, + end, + limit, + offset, + show_reversal=show_reversals, + show_cancelled=show_cancelled, + descending=descending, ) return {"transactions": transactions, "count": count} From 4e46ea1ca3b0743375b6adab05bf127b8e940ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 17 Apr 2021 22:05:49 +0200 Subject: [PATCH 273/446] [balance] add get and modify limits for all users --- flaschengeist/plugins/balance/routes.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py index 1341e6e..b0ce3c9 100644 --- a/flaschengeist/plugins/balance/routes.py +++ b/flaschengeist/plugins/balance/routes.py @@ -99,6 +99,32 @@ def set_limit(userid, current_session: Session): return HTTP.no_content() +@BalancePlugin.blueprint.route("/users/balance/limit", methods=["GET", "PUT"]) +@login_required(permission=permissions.SET_LIMIT) +def limits(current_session: Session): + """Get, Modify limit of all users + + Args: + current_ession: Session sent with Authorization Header + + Returns: + JSON encoded array of userid with limit or HTTP-error + """ + + users = userController.get_users() + if request.method == "GET": + return jsonify([{"userid": user.userid, "limit": user.get_attribute("balance_limit")} for user in users]) + + data = request.get_json() + try: + limit = data["limit"] + except (TypeError, KeyError): + raise BadRequest + for user in users: + balance_controller.set_limit(user, limit) + return HTTP.no_content() + + @BalancePlugin.blueprint.route("/users//balance", methods=["GET"]) @login_required(permission=permissions.SHOW) def get_balance(userid, current_session: Session): From cadde543f214a94d709ce3bf5a84fdbc6336b153 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 30 Aug 2021 14:39:54 +0200 Subject: [PATCH 274/446] [plugins] Improved handling of plugin loading errors --- flaschengeist/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index e91f000..0012c6f 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -54,6 +54,8 @@ def __load_plugins(app): logger.error( f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True ) + del plugin + continue if isinstance(plugin, AuthPlugin): logger.debug(f"Found authentication plugin: {entry_point.name}") if entry_point.name == config["FLASCHENGEIST"]["auth"]: @@ -65,6 +67,7 @@ def __load_plugins(app): app.config["FG_PLUGINS"][entry_point.name] = plugin if "FG_AUTH_BACKEND" not in app.config: logger.error("No authentication plugin configured or authentication plugin not found") + raise RuntimeError("No authentication plugin configured or authentication plugin not found") def install_all(): From 2dabd1dd34de907c801809eedc1216817604249c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 11 Nov 2021 12:23:45 +0100 Subject: [PATCH 275/446] [events] Fix deleteing an event --- flaschengeist/plugins/events/event_controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 31a3b7b..3211363 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -116,7 +116,7 @@ def get_event(event_id, with_backup=False) -> Event: if event is None: raise NotFound if not with_backup: - return clear_backup(event) + clear_backup(event) return event @@ -154,6 +154,7 @@ def delete_event(event_id): NotFound if not found """ event = get_event(event_id) + logger.info(f"{type(event)} {event.__str__}") db.session.delete(event) db.session.commit() From 3d833fb6af4b8ac3f4eef696d924c43f56650cc7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 11 Nov 2021 15:22:15 +0100 Subject: [PATCH 276/446] [plugin] Plugins should have an unique ID --- flaschengeist/plugins/__init__.py | 3 ++- flaschengeist/plugins/events/__init__.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 094eae7..fabc4e2 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -33,6 +33,7 @@ class Plugin: blueprint = None # You have to override permissions = [] # You have to override + id = "dev.flaschengeist.plugin" # You have to override name = "plugin" # You have to override models = None # You have to override @@ -94,7 +95,7 @@ class Plugin: db.session.commit() def notify(self, user, text: str, data=None): - n = Notification(text=text, data=data, plugin=self.name, user_=user) + n = Notification(text=text, data=data, plugin=self.id, user_=user) db.session.add(n) db.session.commit() diff --git a/flaschengeist/plugins/events/__init__.py b/flaschengeist/plugins/events/__init__.py index e4a0af0..c86a214 100644 --- a/flaschengeist/plugins/events/__init__.py +++ b/flaschengeist/plugins/events/__init__.py @@ -11,6 +11,7 @@ from . import permissions, models class EventPlugin(Plugin): name = "events" + id = "dev.flaschengeist.events" plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name]) permissions = permissions.permissions blueprint = Blueprint(name, __name__) From f7e07fdadec4ffdd0490ff08a5dfd033e7e1badc Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 11 Nov 2021 15:22:55 +0100 Subject: [PATCH 277/446] [events] Hotfix: delete an event with registered jobs --- .../plugins/events/event_controller.py | 42 +++++++++++++------ flaschengeist/plugins/events/routes.py | 5 ++- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 3211363..1bc8b43 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -153,8 +153,9 @@ def delete_event(event_id): Raises: NotFound if not found """ - event = get_event(event_id) - logger.info(f"{type(event)} {event.__str__}") + event = get_event(event_id, True) + for job in event.jobs: + delete_job(job) db.session.delete(event) db.session.commit() @@ -202,25 +203,42 @@ def update(): def delete_job(job: Job): + for service in job.services: + unassign_job(service=service, notify=True) db.session.delete(job) db.session.commit() -def assign_to_job(job: Job, user, value): +def assign_job(job: Job, user, value): + assert value > 0 service = Service.query.get((job.id, user.id_)) - if value < 0: - if not service: - raise BadRequest - db.session.delete(service) + if service: + service.value = value else: - if service: - service.value = value - else: - service = Service(user_=user, value=value, job_=job) - db.session.add(service) + service = Service(user_=user, value=value, job_=job) + db.session.add(service) db.session.commit() +def unassign_job(job: Job = None, user=None, service=None, notify=False): + if service is None: + assert(job is not None and user is not None) + service = Service.query.get((job.id, user.id_)) + else: + user = service.user_ + if not service: + raise BadRequest + + event_id = service.job_.event_id_ + + db.session.delete(service) + db.session.commit() + if notify: + EventPlugin.plugin.notify( + user, "Your assignmet was cancelled", {"event_id": event_id} + ) + + @scheduled def assign_backups(): logger.debug("Notifications") diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py index 7fccccf..fce9416 100644 --- a/flaschengeist/plugins/events/routes.py +++ b/flaschengeist/plugins/events/routes.py @@ -415,7 +415,10 @@ def update_job(event_id, job_id, current_session: Session): user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) ): raise Forbidden - event_controller.assign_to_job(job, user, value) + if value > 0: + event_controller.assign_job(job, user, value) + else: + event_controller.unassign_job(job, user, notify=user != current_session.user_) except (KeyError, ValueError): raise BadRequest From 4e1799e29733f30fc33e1df5c5e1ed4e4dbc24fa Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Jul 2021 15:50:03 +0200 Subject: [PATCH 278/446] [auth_ldap] Allow more configuration * Allow configuring the password hash (SSHA, PBKDF2 or Argon2) * Allow setting custom dn templates for users and groups to e.g. allow "ou=people" or "ou=user" * Allow setting custom object class for entries * Stop using deprecated openssl constants --- flaschengeist/flaschengeist.toml | 21 ++- flaschengeist/models/user.py | 2 +- flaschengeist/plugins/auth_ldap/__init__.py | 163 +++++++++++--------- setup.py | 7 +- 4 files changed, 107 insertions(+), 86 deletions(-) diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index ffbb51d..a920eb6 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -29,17 +29,16 @@ level = "WARNING" [auth_plain] enabled = true -#[auth_ldap] -# enabled = true -# host = -# port = -# bind_dn = -# base_dn = -# secret = -# use_ssl = -# admin_dn = -# admin_dn = -# default_gid = +[auth_ldap] +# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap +enabled = false +# host = "localhost" +# port = 389 +# base_dn = "dc=example,dc=com" +# root_dn = "cn=Manager,dc=example,dc=com" +# root_secret = "SuperS3cret" +# Uncomment to use secured LDAP (ldaps) +# use_ssl = true [MESSAGES] welcome_subject = "Welcome to Flaschengeist {name}" diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 43ef91e..84116a6 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -56,7 +56,7 @@ class User(db.Model, ModelSerializeMixin): display_name: str = db.Column(db.String(30)) firstname: str = db.Column(db.String(50), nullable=False) lastname: str = db.Column(db.String(50), nullable=False) - mail: str = db.Column(db.String(60), nullable=False) + mail: str = db.Column(db.String(60)) birthday: Optional[date] = db.Column(db.Date) roles: list[str] = [] permissions: Optional[list[str]] = None diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index c3ae7c6..f8c6d00 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -1,12 +1,12 @@ """LDAP Authentication Provider Plugin""" import io +import os import ssl from typing import Optional from flask_ldapconn import LDAPConn from flask import current_app as app -from ldap3.utils.hashed import hashed from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError -from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5 +from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from flaschengeist import logger @@ -16,32 +16,36 @@ import flaschengeist.controller.userController as userController class AuthLDAP(AuthPlugin): - def __init__(self, cfg): + def __init__(self, config): super().__init__() - config = {"port": 389, "use_ssl": False} - config.update(cfg) app.config.update( - LDAP_SERVER=config["host"], - LDAP_PORT=config["port"], - LDAP_BINDDN=config["bind_dn"], + LDAP_SERVER=config.get("host", "localhost"), + LDAP_PORT=config.get("port", 389), + LDAP_BINDDN=config.get("bind_dn", None), + LDAP_SECRET=config.get("secret", None), + LDAP_USE_SSL=config.get("use_ssl", False), + # That's not TLS, its dirty StartTLS on unencrypted LDAP LDAP_USE_TLS=False, - LDAP_USE_SSL=config["use_ssl"], - LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2, - LDAP_REQUIRE_CERT=ssl.CERT_NONE, + LDAP_TLS_VERSION=ssl.PROTOCOL_TLS, FORCE_ATTRIBUTE_VALUE_AS_LIST=True, ) - if "secret" in config: - app.config["LDAP_SECRET"] = config["secret"] + logger.warning(app.config.get("LDAP_USE_SSL")) + if "ca_cert" in config: + app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"] + else: + # Default is CERT_REQUIRED + app.config["LDAP_REQUIRE_CERT"] = ssl.CERT_OPTIONAL self.ldap = LDAPConn(app) - self.dn = config["base_dn"] - self.default_gid = config["default_gid"] + self.base_dn = config["base_dn"] + self.search_dn = config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn) + self.group_dn = config.get("group_dn", "ou=group,{base_dn}").format(base_dn=self.base_dn) + self.password_hash = config.get("password_hash", "SSHA").upper() + self.object_classes = config.get("object_classes", ["inetOrgPerson"]) + self.user_attributes: dict = config.get("user_attributes", {}) # TODO: might not be set if modify is called - if "admin_dn" in config: - self.admin_dn = config["admin_dn"] - self.admin_secret = config["admin_secret"] - else: - self.admin_dn = None + self.root_dn = config.get("root_dn", None) + self.root_secret = self.root_dn = config.get("root_secret", None) @after_role_updated def _role_updated(role, new_name): @@ -50,7 +54,7 @@ class AuthLDAP(AuthPlugin): def login(self, user, password): if not user: return False - return self.ldap.authenticate(user.userid, password, "uid", self.dn) + return self.ldap.authenticate(user.userid, password, "uid", self.base_dn) def find_user(self, userid, mail=None): attr = self.__find(userid, mail) @@ -64,42 +68,41 @@ class AuthLDAP(AuthPlugin): self.__update(user, attr) def create_user(self, user, password): - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") raise InternalServerError try: - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - self.ldap.connection.search( - "ou=user,{}".format(self.dn), - "(uidNumber=*)", - SUBTREE, - attributes=["uidNumber"], + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) + attributes = self.user_attributes.copy() + if "uidNumber" in attributes: + self.ldap.connection.search( + self.search_dn, + "(uidNumber=*)", + SUBTREE, + attributes=["uidNumber"], + ) + resp = sorted( + self.ldap.response(), + key=lambda i: i["attributes"]["uidNumber"], + reverse=True, + ) + attributes = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"] + dn = self.dn_template.format( + firstname=user.firstname, + lastname=user.lastname, + userid=user.userid, + mail=user.mail, + display_name=user.display_name, + base_dn=self.base_dn, ) - uid_number = ( - sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][ - "attributes" - ]["uidNumber"] - + 1 - ) - dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}" - object_class = [ - "inetOrgPerson", - "posixAccount", - "person", - "organizationalPerson", - ] - attributes = { - "sn": user.firstname, - "givenName": user.lastname, - "gidNumber": self.default_gid, - "homeDirectory": f"/home/{user.userid}", - "loginShell": "/bin/bash", + attributes.update({ + "sn": user.lastname, + "givenName": user.firstname, "uid": user.userid, - "userPassword": hashed(HASHED_SALTED_MD5, password), - "uidNumber": uid_number, - } - ldap_conn.add(dn, object_class, attributes) + "userPassword": self.__hash(password), + }) + ldap_conn.add(dn, self.object_classes, attributes) self._set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest @@ -110,10 +113,10 @@ class AuthLDAP(AuthPlugin): if password: ldap_conn = self.ldap.connect(dn, password) else: - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") raise InternalServerError - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) modifier = {} for name, ldap_name in [ ("firstname", "givenName"), @@ -124,9 +127,7 @@ class AuthLDAP(AuthPlugin): if hasattr(user, name): modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] if new_password: - # TODO: Use secure hash! - salted_password = hashed(HASHED_SALTED_MD5, new_password) - modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])] + modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])] ldap_conn.modify(dn, modifier) self._set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): @@ -134,7 +135,7 @@ class AuthLDAP(AuthPlugin): def get_avatar(self, user): self.ldap.connection.search( - "ou=user,{}".format(self.dn), + self.search_dn, "(uid={})".format(user.userid), SUBTREE, attributes=["jpegPhoto"], @@ -150,8 +151,8 @@ class AuthLDAP(AuthPlugin): raise NotFound def set_avatar(self, user, avatar: _Avatar): - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") raise InternalServerError if avatar.mimetype != "image/jpeg": @@ -172,16 +173,16 @@ class AuthLDAP(AuthPlugin): raise BadRequest("Unsupported image format") dn = user.get_attribute("DN") - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) def __find(self, userid, mail=None): """Find attributes of an user by uid or mail in LDAP""" con = self.ldap.connection if not con: - con = self.ldap.connect(self.admin_dn, self.admin_secret) + con = self.ldap.connect(self.root_dn, self.root_secret) con.search( - f"ou=user,{self.dn}", + self.search_dn, f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})", SUBTREE, attributes=["uid", "givenName", "sn", "mail"], @@ -205,12 +206,12 @@ class AuthLDAP(AuthPlugin): role: Role, new_name: Optional[str], ): - if self.admin_dn is None: - logger.error("admin_dn missing in ldap config!") + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") raise InternalServerError try: - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"]) + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) + ldap_conn.search(self.group_dn, f"(cn={role.name})", SUBTREE, attributes=["cn"]) if len(ldap_conn.response) > 0: dn = ldap_conn.response[0]["dn"] if new_name: @@ -221,10 +222,27 @@ class AuthLDAP(AuthPlugin): except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest + def __hash(self, password): + if self.password_hash == "ARGON2": + from argon2 import PasswordHasher + + return f"{{ARGON2}}{PasswordHasher().hash(password)}" + else: + from hashlib import pbkdf2_hmac, sha1 + import base64 + + salt = os.urandom(16) + if self.password_hash == "PBKDF2": + rounds = 200000 + password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode() + return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}" + else: + return f"{{SSHA}}{base64.b64encode(sha1(password + salt) + salt)}" + def _get_groups(self, uid): groups = [] self.ldap.connection.search( - "ou=group,{}".format(self.dn), + self.group_dn, "(memberUID={})".format(uid), SUBTREE, attributes=["cn"], @@ -236,7 +254,7 @@ class AuthLDAP(AuthPlugin): def _get_all_roles(self): self.ldap.connection.search( - f"ou=group,{self.dn}", + self.group_dn, "(cn=*)", SUBTREE, attributes=["cn", "gidNumber", "memberUid"], @@ -245,8 +263,7 @@ class AuthLDAP(AuthPlugin): def _set_roles(self, user: User): try: - ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) - + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_roles = self._get_all_roles() gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True) @@ -255,7 +272,7 @@ class AuthLDAP(AuthPlugin): for user_role in user.roles: if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: ldap_conn.add( - f"cn={user_role},ou=group,{self.dn}", + f"cn={user_role},{self.group_dn}", ["posixGroup"], attributes={"gidNumber": gid_number}, ) diff --git a/setup.py b/setup.py index d7bfd18..7860856 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,12 @@ setup( "werkzeug", mysql_driver, ], - extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"], "test": ["pytest", "coverage"]}, + extras_require={ + "ldap": ["flask_ldapconn", "ldap3"], + "argon": ["argon2-cffi"], + "pricelist": ["pillow"], + "test": ["pytest", "coverage"], + }, entry_points={ "flaschengeist.plugin": [ # Authentication providers From f80ad5c420a9c41f558359a39188993a78ac49b4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Jul 2021 16:45:26 +0200 Subject: [PATCH 279/446] [auth_ldap] Fix typo in __init__ --- flaschengeist/plugins/auth_ldap/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index f8c6d00..32799e4 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -45,7 +45,7 @@ class AuthLDAP(AuthPlugin): # TODO: might not be set if modify is called self.root_dn = config.get("root_dn", None) - self.root_secret = self.root_dn = config.get("root_secret", None) + self.root_secret = config.get("root_secret", None) @after_role_updated def _role_updated(role, new_name): @@ -219,8 +219,11 @@ class AuthLDAP(AuthPlugin): else: ldap_conn.delete(dn) - except (LDAPPasswordIsMandatoryError, LDAPBindError): + except LDAPPasswordIsMandatoryError: raise BadRequest + except LDAPBindError: + logger.debug(f"Could not bind to LDAP server", exc_info=True) + raise InternalServerError def __hash(self, password): if self.password_hash == "ARGON2": From 80f06e483b5418c8bf29670e80222ed58f85f946 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 29 Jul 2021 17:10:52 +0200 Subject: [PATCH 280/446] [auth_ldap] modify_role has to be called before the update to change it on the backend --- flaschengeist/plugins/auth_ldap/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 32799e4..627325a 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -10,7 +10,7 @@ from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from flaschengeist import logger -from flaschengeist.plugins import AuthPlugin, after_role_updated +from flaschengeist.plugins import AuthPlugin, before_role_updated from flaschengeist.models.user import User, Role, _Avatar import flaschengeist.controller.userController as userController @@ -47,8 +47,9 @@ class AuthLDAP(AuthPlugin): self.root_dn = config.get("root_dn", None) self.root_secret = config.get("root_secret", None) - @after_role_updated + @before_role_updated def _role_updated(role, new_name): + logger.debug(f"LDAP: before_role_updated called with ({role}, {new_name})") self.__modify_role(role, new_name) def login(self, user, password): From 974af80a9b5fb325a8a0f4a17ecdc816b1dade35 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 12 Nov 2021 11:34:42 +0100 Subject: [PATCH 281/446] [db] Fix warnings and fix readme --- flaschengeist/models/__init__.py | 1 + readme.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 1b503dd..4eca8e3 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -44,6 +44,7 @@ class ModelSerializeMixin: class Serial(TypeDecorator): """Same as MariaDB Serial used for IDs""" + cache_ok=True impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite") diff --git a/readme.md b/readme.md index 133a570..7b64951 100644 --- a/readme.md +++ b/readme.md @@ -46,7 +46,7 @@ If not you need to create user and database manually do (or similar on Windows): ( echo "CREATE DATABASE flaschengeist;" echo "CREATE USER 'flaschengeist'@'localhost' IDENTIFIED BY 'flaschengeist';" - echo "GRANT ALL PRIVILEGES ON 'flaschengeist'.* TO 'flaschengeist'@'localhost';" + echo "GRANT ALL PRIVILEGES ON flaschengeist.* TO 'flaschengeist'@'localhost';" echo "FLUSH PRIVILEGES;" ) | sudo mysql From 26a00ed6a6b5a709960fe0d6c78eb4f4de6829cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 12 Nov 2021 22:09:16 +0100 Subject: [PATCH 282/446] [pricelist] add serverside pagination of drinks --- flaschengeist/plugins/pricelist/__init__.py | 17 +++++++++++--- .../plugins/pricelist/pricelist_controller.py | 22 ++++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index b4df1ac..67c92db 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -214,10 +214,21 @@ def get_drinks(identifier=None): if identifier: result = pricelist_controller.get_drink(identifier, public=public) + return jsonify(result) else: - result = pricelist_controller.get_drinks(public=public) - logger.debug(f"GET drink {result}") - return jsonify(result) + limit = request.args.get("limit") + offset = request.args.get("offset") + try: + if limit is not None: + limit = int(limit) + if offset is not None: + offset = int(offset) + except ValueError: + raise BadRequest + drinks, count = pricelist_controller.get_drinks(public=public, limit=limit, offset=offset) + logger.debug(f"GET drink {drinks}, {count}") + # return jsonify({"drinks": drinks, "count": count}) + return jsonify({"drinks": drinks, "count": count}) @PriceListPlugin.blueprint.route("/drinks/search/", methods=["GET"]) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index c89dc2d..6ea9b87 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -131,13 +131,22 @@ def _create_public_drink(drink): return None -def get_drinks(name=None, public=False): +def get_drinks(name=None, public=False, limit=None, offset=None): + count = None if name: - drinks = Drink.query.filter(Drink.name.contains(name)).all() - drinks = Drink.query.all() + query = Drink.query.filter(Drink.name.contains(name)) + else: + query = Drink.query + + if limit is not None: + count = query.count() + query = query.limit(limit) + if offset is not None: + query = query.offset(offset) + drinks = query.all() if public: - return [_create_public_drink(drink) for drink in drinks if _create_public_drink(drink)] - return drinks + return [_create_public_drink(drink) for drink in drinks if _create_public_drink(drink)], count + return drinks, count def get_drink(identifier, public=False): @@ -209,6 +218,9 @@ def set_volumes(volumes): def delete_drink(identifier): drink = get_drink(identifier) + if drink.uuid: + path = config["pricelist"]["path"] + delete_picture(f"{path}/{drink.uuid}") db.session.delete(drink) db.session.commit() From 8fb74358e7d69048e2e95bd15ab25b1d5cc7e19f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 13 Nov 2021 13:23:04 +0100 Subject: [PATCH 283/446] [pricelist] add serverside filtering for getDrinks --- flaschengeist/plugins/pricelist/__init__.py | 6 +++++- .../plugins/pricelist/pricelist_controller.py | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 67c92db..3e4c71b 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -218,6 +218,8 @@ def get_drinks(identifier=None): else: limit = request.args.get("limit") offset = request.args.get("offset") + search_name = request.args.get("search_name") + search_key = request.args.get("search_key") try: if limit is not None: limit = int(limit) @@ -225,7 +227,9 @@ def get_drinks(identifier=None): offset = int(offset) except ValueError: raise BadRequest - drinks, count = pricelist_controller.get_drinks(public=public, limit=limit, offset=offset) + drinks, count = pricelist_controller.get_drinks( + public=public, limit=limit, offset=offset, search_name=search_name, search_key=search_key + ) logger.debug(f"GET drink {drinks}, {count}") # return jsonify({"drinks": drinks, "count": count}) return jsonify({"drinks": drinks, "count": count}) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 6ea9b87..5f8154c 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -131,12 +131,28 @@ def _create_public_drink(drink): return None -def get_drinks(name=None, public=False, limit=None, offset=None): +def get_drinks(name=None, public=False, limit=None, offset=None, search_name=None, search_key=None): count = None if name: query = Drink.query.filter(Drink.name.contains(name)) else: query = Drink.query + if search_name: + if search_key == "name": + query = query.filter(Drink.name.contains(search_name)) + elif search_key == "article_id": + query = query.filter(Drink.article_id.contains(search_name)) + elif search_key == "drink_type": + query = query.filter(Drink.type.has(DrinkType.name.contains(search_name))) + elif search_key == "tags": + query = query.filter(Drink.tags.any(Tag.name.contains(search_name))) + else: + query = query.filter( + (Drink.name.contains(search_name)) + | (Drink.article_id.contains(search_name)) + | (Drink.type.has(DrinkType.name.contains(search_name))) + | (Drink.tags.any(Tag.name.contains(search_name))) + ) if limit is not None: count = query.count() From 1b371763eee5cb1490a647574cdcd818c8b6d95f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 13 Nov 2021 14:49:28 +0100 Subject: [PATCH 284/446] [events] Fix conflic return code --- flaschengeist/plugins/events/event_controller.py | 6 +++--- flaschengeist/plugins/users/__init__.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 1bc8b43..fb50655 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta, timezone from typing import Optional -from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.exceptions import BadRequest, Conflict, NotFound from sqlalchemy.exc import IntegrityError from flaschengeist import logger @@ -41,7 +41,7 @@ def create_event_type(name): db.session.commit() return event except IntegrityError: - raise BadRequest("Name already exists") + raise Conflict("Name already exists") def rename_event_type(identifier, new_name): @@ -50,7 +50,7 @@ def rename_event_type(identifier, new_name): try: db.session.commit() except IntegrityError: - raise BadRequest("Name already exists") + raise Conflict("Name already exists") def delete_event_type(name): diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 8a0db58..9b41070 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -75,7 +75,7 @@ def list_users(current_session): @UsersPlugin.blueprint.route("/users/", methods=["GET"]) @login_required() -@headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) +@headers({"Cache-Control": "private, must-revalidate, max-age=300"}) def get_user(userid, current_session): """Retrieve user by userid From 526433afba948776550cde73ef61aacf366da75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 13 Nov 2021 15:03:21 +0100 Subject: [PATCH 285/446] [pricelist] serviceside filtering for ingredients --- flaschengeist/plugins/pricelist/__init__.py | 10 +++++++++- .../plugins/pricelist/pricelist_controller.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 3e4c71b..2239727 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -220,15 +220,23 @@ def get_drinks(identifier=None): offset = request.args.get("offset") search_name = request.args.get("search_name") search_key = request.args.get("search_key") + ingredient = request.args.get("ingredient", type=bool) try: if limit is not None: limit = int(limit) if offset is not None: offset = int(offset) + if ingredient is not None: + ingredient = bool(ingredient) except ValueError: raise BadRequest drinks, count = pricelist_controller.get_drinks( - public=public, limit=limit, offset=offset, search_name=search_name, search_key=search_key + public=public, + limit=limit, + offset=offset, + search_name=search_name, + search_key=search_key, + ingredient=ingredient, ) logger.debug(f"GET drink {drinks}, {count}") # return jsonify({"drinks": drinks, "count": count}) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 5f8154c..b32a26d 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -131,12 +131,14 @@ def _create_public_drink(drink): return None -def get_drinks(name=None, public=False, limit=None, offset=None, search_name=None, search_key=None): +def get_drinks(name=None, public=False, limit=None, offset=None, search_name=None, search_key=None, ingredient=False): count = None if name: query = Drink.query.filter(Drink.name.contains(name)) else: query = Drink.query + if ingredient: + query = query.filter(Drink.cost_per_volume >= 0) if search_name: if search_key == "name": query = query.filter(Drink.name.contains(search_name)) From e4b937991be4b363c9dd0cc34096bc6cc08b2842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 13 Nov 2021 15:44:06 +0100 Subject: [PATCH 286/446] [pricelist] add serverside pagination and filter for receipts --- flaschengeist/plugins/pricelist/__init__.py | 4 ++++ flaschengeist/plugins/pricelist/pricelist_controller.py | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 2239727..5ad57af 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -221,6 +221,7 @@ def get_drinks(identifier=None): search_name = request.args.get("search_name") search_key = request.args.get("search_key") ingredient = request.args.get("ingredient", type=bool) + receipt = request.args.get("receipt", type=bool) try: if limit is not None: limit = int(limit) @@ -228,6 +229,8 @@ def get_drinks(identifier=None): offset = int(offset) if ingredient is not None: ingredient = bool(ingredient) + if receipt is not None: + receipt = bool(receipt) except ValueError: raise BadRequest drinks, count = pricelist_controller.get_drinks( @@ -237,6 +240,7 @@ def get_drinks(identifier=None): search_name=search_name, search_key=search_key, ingredient=ingredient, + receipt=receipt, ) logger.debug(f"GET drink {drinks}, {count}") # return jsonify({"drinks": drinks, "count": count}) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index b32a26d..0b786a9 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -131,7 +131,9 @@ def _create_public_drink(drink): return None -def get_drinks(name=None, public=False, limit=None, offset=None, search_name=None, search_key=None, ingredient=False): +def get_drinks( + name=None, public=False, limit=None, offset=None, search_name=None, search_key=None, ingredient=False, receipt=None +): count = None if name: query = Drink.query.filter(Drink.name.contains(name)) @@ -139,6 +141,8 @@ def get_drinks(name=None, public=False, limit=None, offset=None, search_name=Non query = Drink.query if ingredient: query = query.filter(Drink.cost_per_volume >= 0) + if receipt: + query = query.filter(Drink.volumes.any(DrinkPriceVolume.ingredients != None)) if search_name: if search_key == "name": query = query.filter(Drink.name.contains(search_name)) From 1bb7bafa2a69793da05b29f7aa384253e0b5c2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 13 Nov 2021 15:57:49 +0100 Subject: [PATCH 287/446] [pricelist][fix] better query to send drinks with public prices --- flaschengeist/plugins/pricelist/pricelist_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 0b786a9..3a441f3 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -143,6 +143,8 @@ def get_drinks( query = query.filter(Drink.cost_per_volume >= 0) if receipt: query = query.filter(Drink.volumes.any(DrinkPriceVolume.ingredients != None)) + if public: + query = query.filter(Drink.volumes.any(DrinkPriceVolume.prices.any(DrinkPrice.public))) if search_name: if search_key == "name": query = query.filter(Drink.name.contains(search_name)) @@ -166,8 +168,6 @@ def get_drinks( if offset is not None: query = query.offset(offset) drinks = query.all() - if public: - return [_create_public_drink(drink) for drink in drinks if _create_public_drink(drink)], count return drinks, count From dba60fdab8ce40c6449d50f274d19026a5a2e8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 14 Nov 2021 19:35:11 +0100 Subject: [PATCH 288/446] [pricelist] serverside pagination and filtering for pricelist --- flaschengeist/plugins/pricelist/__init__.py | 40 ++++++++++ flaschengeist/plugins/pricelist/models.py | 16 +++- .../plugins/pricelist/pricelist_controller.py | 80 +++++++++++++++++-- 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 5ad57af..68693e8 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -242,11 +242,51 @@ def get_drinks(identifier=None): ingredient=ingredient, receipt=receipt, ) + mop = drinks.copy() logger.debug(f"GET drink {drinks}, {count}") # return jsonify({"drinks": drinks, "count": count}) return jsonify({"drinks": drinks, "count": count}) +@PriceListPlugin.blueprint.route("/list", methods=["GET"]) +def get_pricelist(): + """Get Priclist + Route: ``/pricelist/list`` | Method: ``GET`` + + Returns: + JSON encoded list of DrinkPrices or HTTP-KeyError + """ + public = True + try: + extract_session() + public = False + except Unauthorized: + public = True + + limit = request.args.get("limit") + offset = request.args.get("offset") + search_name = request.args.get("search_name") + search_key = request.args.get("search_key") + ingredient = request.args.get("ingredient", type=bool) + receipt = request.args.get("receipt", type=bool) + try: + if limit is not None: + limit = int(limit) + if offset is not None: + offset = int(offset) + if ingredient is not None: + ingredient = bool(ingredient) + if receipt is not None: + receipt = bool(receipt) + except ValueError: + raise BadRequest + pricelist, count = pricelist_controller.get_pricelist( + public=public, limit=limit, offset=offset, search_name=search_name, search_key=search_key + ) + logger.debug(f"GET pricelist {pricelist}, {count}") + return jsonify({"pricelist": pricelist, "count": count}) + + @PriceListPlugin.blueprint.route("/drinks/search/", methods=["GET"]) def search_drinks(name): """Search Drink diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index f6d68f9..43cf792 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -48,7 +48,8 @@ class DrinkPrice(db.Model, ModelSerializeMixin): id: int = db.Column("id", db.Integer, primary_key=True) price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) volume_id_ = db.Column("volume_id", db.Integer, db.ForeignKey("drink_price_volume.id")) - volume = db.relationship("DrinkPriceVolume", back_populates="prices") + volume: "DrinkPriceVolume" = None + _volume: "DrinkPriceVolume" = db.relationship("DrinkPriceVolume", back_populates="_prices", join_depth=1) public: bool = db.Column(db.Boolean, default=True) description: Optional[str] = db.Column(db.String(30)) @@ -121,11 +122,15 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): __tablename__ = "drink_price_volume" id: int = db.Column("id", db.Integer, primary_key=True) drink_id = db.Column(db.Integer, db.ForeignKey("drink.id")) + drink: "Drink" = None + _drink: "Drink" = db.relationship("Drink", back_populates="_volumes") volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) min_prices: list[MinPrices] = [] # ingredients: list[Ingredient] = [] - - prices: list[DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan") + prices: list[DrinkPrice] = [] + _prices: list[DrinkPrice] = db.relationship( + DrinkPrice, back_populates="_volume", cascade="all,delete,delete-orphan" + ) ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) def __repr__(self): @@ -153,7 +158,10 @@ class Drink(db.Model, ModelSerializeMixin): tags: Optional[list[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") type: Optional[DrinkType] = db.relationship("DrinkType", foreign_keys=[_type_id]) - volumes: list[DrinkPriceVolume] = db.relationship(DrinkPriceVolume) + volumes: list[DrinkPriceVolume] = [] + _volumes: list[DrinkPriceVolume] = db.relationship( + DrinkPriceVolume, back_populates="_drink", cascade="all,delete,delete-orphan" + ) def __repr__(self): return f"Drink({self.id},{self.name},{self.volumes})" diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 3a441f3..1c5ac35 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -142,9 +142,9 @@ def get_drinks( if ingredient: query = query.filter(Drink.cost_per_volume >= 0) if receipt: - query = query.filter(Drink.volumes.any(DrinkPriceVolume.ingredients != None)) + query = query.filter(Drink._volumes.any(DrinkPriceVolume.ingredients != None)) if public: - query = query.filter(Drink.volumes.any(DrinkPriceVolume.prices.any(DrinkPrice.public))) + query = query.filter(Drink._volumes.any(DrinkPriceVolume._prices.any(DrinkPrice.public))) if search_name: if search_key == "name": query = query.filter(Drink.name.contains(search_name)) @@ -168,9 +168,72 @@ def get_drinks( if offset is not None: query = query.offset(offset) drinks = query.all() + for drink in drinks: + for volume in drink._volumes: + volume.prices = volume._prices + drink.volumes = drink._volumes + return drinks, count +def get_pricelist(public=False, limit=None, offset=None, search_name=None, search_key=None): + count = None + query = DrinkPrice.query + if public: + query.filter(DrinkPrice.public) + if search_name: + if search_key == "name": + query = query.filter(DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.name.contains(search_name)))) + if search_key == "type": + query = query.filter( + DrinkPrice._volume.has( + DrinkPriceVolume._drink.has(Drink.type.has(DrinkType.name.contains(search_name))) + ) + ) + if search_key == "tags": + query = query.filter( + DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.conaitns(search_name)))) + ) + if search_key == "volume": + query = query.filter(DrinkPrice._volume.has(DrinkPriceVolume.volume == float(search_name))) + if search_key == "price": + query = query.filter(DrinkPrice.price == float(search_name)) + if search_key == "description": + query = query.filter(DrinkPrice.description.contains(search_name)) + else: + try: + search_name = float(search_name) + query = query.filter( + (DrinkPrice._volume.has(DrinkPriceVolume.volume == float(search_name))) + | (DrinkPrice.price == float(search_name)) + ) + except: + query = query.filter( + (DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.name.contains(search_name)))) + | ( + DrinkPrice._volume.has( + DrinkPriceVolume._drink.has(Drink.type.has(DrinkType.name.contains(search_name))) + ) + ) + | ( + DrinkPrice._volume.has( + DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.conaitns(search_name))) + ) + ) + | (DrinkPrice.description.contains(search_name)) + ) + if limit is not None: + count = query.count() + query = query.limit(limit) + if offset is not None: + query = query.offset(offset) + prices = query.all() + for price in prices: + price._volume.drink = price._volume._drink + price.volume = price._volume + return prices, count + + def get_drink(identifier, public=False): drink = None if isinstance(identifier, int): @@ -183,6 +246,9 @@ def get_drink(identifier, public=False): raise NotFound if public: return _create_public_drink(drink) + for volume in drink._volumes: + volume.prices = volume._prices + drink.volumes = drink._volumes return drink @@ -219,11 +285,15 @@ def update_drink(identifier, data): if drink_type: drink.type = drink_type if volumes is not None and session.user_.has_permission(EDIT_VOLUME): - drink.volumes = [] - drink.volumes = set_volumes(volumes) + drink._volumes = [] + drink._volumes = set_volumes(volumes) if len(tags) > 0: drink.tags = tags db.session.commit() + for volume in drink._volumes: + volume.prices = volume._prices + drink.volumes = drink._volumes + return drink except (NotFound, KeyError): raise BadRequest @@ -291,7 +361,7 @@ def set_prices(prices, volume): for _price in prices: price = set_price(_price) _prices.append(price) - volume.prices = _prices + volume._prices = _prices def set_ingredients(ingredients, volume): From 57a03a80cc0e2f2b503840972aac6645c4813fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 15 Nov 2021 09:19:50 +0100 Subject: [PATCH 289/446] [pricelist] add serverside sorting for pricelist, sorting by name for drinks --- flaschengeist/plugins/pricelist/__init__.py | 12 +++++++- .../plugins/pricelist/pricelist_controller.py | 29 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 68693e8..afe21e4 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -269,6 +269,8 @@ def get_pricelist(): search_key = request.args.get("search_key") ingredient = request.args.get("ingredient", type=bool) receipt = request.args.get("receipt", type=bool) + descending = request.args.get("descending", type=bool) + sortBy = request.args.get("sortBy") try: if limit is not None: limit = int(limit) @@ -278,10 +280,18 @@ def get_pricelist(): ingredient = bool(ingredient) if receipt is not None: receipt = bool(receipt) + if descending is not None: + descending = bool(descending) except ValueError: raise BadRequest pricelist, count = pricelist_controller.get_pricelist( - public=public, limit=limit, offset=offset, search_name=search_name, search_key=search_key + public=public, + limit=limit, + offset=offset, + search_name=search_name, + search_key=search_key, + descending=descending, + sortBy=sortBy, ) logger.debug(f"GET pricelist {pricelist}, {count}") return jsonify({"pricelist": pricelist, "count": count}) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 1c5ac35..136711c 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -161,6 +161,7 @@ def get_drinks( | (Drink.type.has(DrinkType.name.contains(search_name))) | (Drink.tags.any(Tag.name.contains(search_name))) ) + query = query.order_by(Drink.name.asc()) if limit is not None: count = query.count() @@ -176,11 +177,13 @@ def get_drinks( return drinks, count -def get_pricelist(public=False, limit=None, offset=None, search_name=None, search_key=None): +def get_pricelist( + public=False, limit=None, offset=None, search_name=None, search_key=None, sortBy=None, descending=False +): count = None query = DrinkPrice.query if public: - query.filter(DrinkPrice.public) + query = query.filter(DrinkPrice.public) if search_name: if search_key == "name": query = query.filter(DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.name.contains(search_name)))) @@ -217,16 +220,36 @@ def get_pricelist(public=False, limit=None, offset=None, search_name=None, searc ) | ( DrinkPrice._volume.has( - DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.conaitns(search_name))) + DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.contains(search_name))) ) ) | (DrinkPrice.description.contains(search_name)) ) + if sortBy == "type": + query = ( + query.join(DrinkPrice._volume) + .join(DrinkPriceVolume._drink) + .join(Drink.type) + .order_by(DrinkType.name.desc() if descending else DrinkType.name.asc()) + ) + elif sortBy == "volume": + query = query.join(DrinkPrice._volume).order_by( + DrinkPriceVolume.volume.desc() if descending else DrinkPriceVolume.volume.asc() + ) + elif sortBy == "price": + query = query.order_by(DrinkPrice.price.desc() if descending else DrinkPrice.price.asc()) + else: + query = ( + query.join(DrinkPrice._volume) + .join(DrinkPriceVolume._drink) + .order_by(Drink.name.desc() if descending else Drink.name.asc()) + ) if limit is not None: count = query.count() query = query.limit(limit) if offset is not None: query = query.offset(offset) + prices = query.all() for price in prices: price._volume.drink = price._volume._drink From a6fe921920c4d0e17938f62509f5d1fa9e905af3 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 15 Nov 2021 16:32:24 +0100 Subject: [PATCH 290/446] [controller] Add controller for handling uploading images --- flaschengeist/controller/imageController.py | 65 +++++++++++++++++++++ flaschengeist/flaschengeist.toml | 19 ++++-- flaschengeist/models/image.py | 30 ++++++++++ setup.py | 4 +- 4 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 flaschengeist/controller/imageController.py create mode 100644 flaschengeist/models/image.py diff --git a/flaschengeist/controller/imageController.py b/flaschengeist/controller/imageController.py new file mode 100644 index 0000000..5139d56 --- /dev/null +++ b/flaschengeist/controller/imageController.py @@ -0,0 +1,65 @@ +from datetime import date +from flask import send_file +from pathlib import Path +from PIL import Image as PImage + +from werkzeug.exceptions import NotFound, UnprocessableEntity +from werkzeug.datastructures import FileStorage +from werkzeug.utils import secure_filename + +from flaschengeist.models.image import Image +from flaschengeist.database import db +from flaschengeist.config import config + + +def check_mimetype(mime: str): + return mime in config["FILES"].get('allowed_mimetypes', []) + + +def send_image(id: int = None, image: Image = None): + if image is None: + image = Image.query.get(id) + if not image: + raise NotFound + return send_file(image.path_, mimetype=image.mimetype_, download_name=image.filename_) + + +def send_thumbnail(id: int = None, image: Image = None): + if image is None: + image = Image.query.get(id) + if not image: + raise NotFound + if not image.thumbnail_: + with PImage.open(image.open()) as im: + im.thumbnail(tuple(config["FILES"].get("thumbnail_size"))) + s = image.path_.split('.') + s.insert(len(s)-1, 'thumbnail') + im.save('.'.join(s)) + image.thumbnail_ = '.'.join(s) + db.session.commit() + return send_file(image.thumbnail_, mimetype=image.mimetype_, download_name=image.filename_) + + +def upload_image(file: FileStorage): + if not check_mimetype(file.mimetype): + raise UnprocessableEntity + + path = Path(config["FILES"].get("data_path")) / str(date.today().year) + path.mkdir(mode=int('0700', 8), parents=True, exist_ok=True) + + if file.filename.count('.') < 1: + name = secure_filename(file.filename + '.' + file.mimetype.split('/')[-1]) + else: + name = secure_filename(file.filename) + img = Image(mimetype_=file.mimetype, filename_=name) + db.session.add(img) + db.session.flush() + try: + img.path_ = str((path / f"{img.id}.{img.filename_.split('.')[-1]}").resolve()) + file.save(img.path_) + except: + db.session.delete(img) + raise + finally: + db.session.commit() + return img diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index a920eb6..0898e2c 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -7,7 +7,7 @@ auth = "auth_plain" # Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn #proxy = false # Set root path, prefixes all routes -#root = /api +root = "/api" # Set secret key secret_key = "V3ryS3cr3t" # Domain used by frontend @@ -21,10 +21,19 @@ level = "WARNING" [DATABASE] # engine = "mysql" (default) -# user = "user" -# host = "127.0.0.1" -# password = "password" -# database = "database" + +[FILES] +# Path for file / image uploads +data_path = "./data" +# Thumbnail size +thumbnail_size = [192, 192] +# Accepted mimetypes +allowed_mimetypes = [ + "image/avif", + "image/jpeg", + "image/png", + "image/webp" +] [auth_plain] enabled = true diff --git a/flaschengeist/models/image.py b/flaschengeist/models/image.py new file mode 100644 index 0000000..1242c2e --- /dev/null +++ b/flaschengeist/models/image.py @@ -0,0 +1,30 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + +from sqlalchemy import event +from pathlib import Path + +from . import ModelSerializeMixin, Serial +from ..database import db + +class Image(db.Model, ModelSerializeMixin): + __tablename__ = "image" + id: int = db.Column("id", Serial, primary_key=True) + filename_: str = db.Column(db.String(30), nullable=False) + mimetype_: str = db.Column(db.String(30), nullable=False) + thumbnail_: str = db.Column(db.String(127)) + path_: str = db.Column(db.String(127)) + + def open(self): + return open(self.path_, "rb") + + +@event.listens_for(Image, 'before_delete') +def clear_file(mapper, connection, target: Image): + if target.path_: + p = Path(target.path_) + if p.exists(): + p.unlink() + if target.thumbnail_: + p = Path(target.thumbnail_) + if p.exists(): + p.unlink() diff --git a/setup.py b/setup.py index 7860856..adef712 100644 --- a/setup.py +++ b/setup.py @@ -38,13 +38,13 @@ setup( "sqlalchemy>=1.4", "flask_sqlalchemy>=2.5", "flask_cors", + "Pillow>=8.4.0", "werkzeug", mysql_driver, ], extras_require={ "ldap": ["flask_ldapconn", "ldap3"], "argon": ["argon2-cffi"], - "pricelist": ["pillow"], "test": ["pytest", "coverage"], }, entry_points={ @@ -59,7 +59,7 @@ setup( "balance = flaschengeist.plugins.balance:BalancePlugin", "events = flaschengeist.plugins.events:EventPlugin", "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", - "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin [pricelist]", + "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin", ], }, cmdclass={ From a43441e0c520352cb492b1ec27d79399f69c1c41 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 15 Nov 2021 16:34:35 +0100 Subject: [PATCH 291/446] [pricelist] Use new image controller --- flaschengeist/plugins/pricelist/__init__.py | 24 ++++---- flaschengeist/plugins/pricelist/models.py | 16 ++--- .../plugins/pricelist/pricelist_controller.py | 33 ++--------- flaschengeist/utils/no-image.png | Bin 47605 -> 0 bytes flaschengeist/utils/picture.py | 55 ------------------ 5 files changed, 25 insertions(+), 103 deletions(-) delete mode 100644 flaschengeist/utils/no-image.png delete mode 100644 flaschengeist/utils/picture.py diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index afe21e4..7423d1e 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -2,10 +2,11 @@ from flask import Blueprint, jsonify, request, current_app from werkzeug.local import LocalProxy -from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized +from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized from flaschengeist import logger from flaschengeist.controller import userController +from flaschengeist.controller.imageController import send_image, send_thumbnail from flaschengeist.plugins import Plugin from flaschengeist.utils.decorators import login_required, extract_session from flaschengeist.utils.HTTP import no_content @@ -709,7 +710,7 @@ def get_priclist_setting(userid, current_session): return no_content() -@PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "GET", "DELETE"]) +@PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST" "DELETE"]) @login_required(permission=permissions.EDIT) def set_picture(identifier, current_session): """Get, Create, Delete Drink Picture @@ -731,25 +732,24 @@ def set_picture(identifier, current_session): file = request.files.get("file") if file: - picture = models._Picture() - picture.mimetype = file.content_type - picture.binary = bytearray(file.stream.read()) - return jsonify(pricelist_controller.save_drink_picture(identifier, picture)) + return jsonify(pricelist_controller.save_drink_picture(identifier, file)) else: raise BadRequest -@PriceListPlugin.blueprint.route("/picture/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks//picture", methods=["GET"]) def _get_picture(identifier): """Get Picture Args: - identifier: Identifier of Picture + identifier: Identifier of Drink Returns: Picture or HTTP-error """ - if request.method == "GET": - size = request.args.get("size") - response = pricelist_controller.get_drink_picture(identifier, size) - return response.make_conditional(request) + drink = pricelist_controller.get_drink(identifier) + if drink.has_image: + if request.args.get("thumbnail"): + return send_thumbnail(image=drink.image_) + return send_image(image=drink.image_) + raise NotFound diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 43cf792..be36247 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -1,7 +1,7 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 from flaschengeist.database import db -from flaschengeist.models import ModelSerializeMixin +from flaschengeist.models.image import Image from typing import Optional @@ -150,11 +150,14 @@ class Drink(db.Model, ModelSerializeMixin): volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) cost_per_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) cost_per_package: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) + has_image: bool = False uuid: str = db.Column(db.String(36)) receipt: Optional[list[str]] = db.Column(db.PickleType(protocol=4)) - _type_id = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) + _image_id = db.Column("image_id", Serial, db.ForeignKey("image.id")) + + image_: Image = db.relationship("Image", cascade="all, delete", foreign_keys=[_image_id]) tags: Optional[list[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge") type: Optional[DrinkType] = db.relationship("DrinkType", foreign_keys=[_type_id]) @@ -166,9 +169,6 @@ class Drink(db.Model, ModelSerializeMixin): def __repr__(self): return f"Drink({self.id},{self.name},{self.volumes})" - -class _Picture: - """Wrapper class for pictures binaries""" - - mimetype = "" - binary = bytearray() + @property + def has_image(self): + return self.image_ is not None diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 136711c..69b011c 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -5,12 +5,12 @@ from uuid import uuid4 from flaschengeist import logger from flaschengeist.config import config from flaschengeist.database import db -from flaschengeist.utils.picture import save_picture, get_picture, delete_picture from flaschengeist.utils.decorators import extract_session from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient from .permissions import EDIT_VOLUME, EDIT_PRICE, EDIT_INGREDIENTS_DRINK +import flaschengeist.controller.imageController as image_controller def update(): db.session.commit() @@ -333,9 +333,6 @@ def set_volumes(volumes): def delete_drink(identifier): drink = get_drink(identifier) - if drink.uuid: - path = config["pricelist"]["path"] - delete_picture(f"{path}/{drink.uuid}") db.session.delete(drink) db.session.commit() @@ -534,33 +531,13 @@ def delete_extra_ingredient(identifier): def save_drink_picture(identifier, file): drink = get_drink(identifier) - old_uuid = None - if drink.uuid: - old_uuid = drink.uuid - drink.uuid = str(uuid4()) + drink.image = image_controller.upload_image(file) db.session.commit() - path = config["pricelist"]["path"] - save_picture(file, f"{path}/{drink.uuid}") - if old_uuid: - delete_picture(f"{path}/{old_uuid}") return drink -def get_drink_picture(identifier, size=None): - path = config["pricelist"]["path"] - drink = None - if isinstance(identifier, int): - drink = get_drink(identifier) - if isinstance(identifier, str): - drink = Drink.query.filter(Drink.uuid == identifier).one_or_none() - if drink: - return get_picture(f"{path}/{drink.uuid}", size) - raise FileNotFoundError - - def delete_drink_picture(identifier): drink = get_drink(identifier) - if drink.uuid: - delete_picture(f"{config['pricelist']['path']}/{drink.uuid}") - drink.uuid = None - db.session.commit() + drink.image = None + db.session.commit() + return drink diff --git a/flaschengeist/utils/no-image.png b/flaschengeist/utils/no-image.png deleted file mode 100644 index 240507b03e790f6871db20f36b4cc7f0bc2c55d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47605 zcmZ^Kbx_q?^e+v9h!WDFbV)aYAdPf)2uODb(y2(dbf-uo-6)`RN+=*Dh;-L)aer^# z%=_crnLGE+8O}N1v-e(WeQLi`R+PrZB*8>LK){xjkx)fIKN^b>oW@w?BtxV}%E<%xcF_|^8=ezK$}(`9p_#AYZu^9fhlFSovsIz))#V$3&Ez5EiPD4WhtWT=BylDBuZhno$<#P1w6iT+7{5kt9sFLCp_k?#^S4lYZn@cNCOy6__l zabs5F$(d{=j1mGR(W^nWy)Zmq0!B3L4Aa}ir}=ZKNP_DhLAc@?DsqW=xO>Ld-tpq4 zsc96sko*2hzd^J6pRK*$U-NQv`Mb4(xQv;3d5`;Isf(1d>feZ4)tuA1tbNa}Tuhw% z={m0PqvjPXiO0_t&$E>jt^)bwwNw^(oNUHRY{l@#H1>??3FSvgtRFvnte0`^X25T9 zcACHYj4W1qSB7Tv#cQ8c;o!rQUw-J<_b5eLzOhM1Q$$8YL_|f2yxyhKe%JclNCFwP z+Pw^m**x9xDc?z^)}5e)tahSdRK547w6BKJL$C;+=+;_>U=oBAFk6IrilephfAl4& z4{uT>%KpLl4iAnBuKDbnBFmo9mUvc2S%2rRQRG~&hUVM-MA6e@TiGI!e7Ny?ji}-f zbhNd#3txQTM|j6Sl*mlTo=h!$&yw03FD3-nxvCP!+A0LGrNgw3MF!h#wG83j--rDn zt((&>>qBA=Ce<9iDu`x%Hw5(QOKSykiKVIqauN~}tzJh|HA_oN335LTD<-pukpBHw z2k+vH{I1+Cj<>%hv)yce{)M2BQh3+ozA}1;a*lWi0$l)Ama$rY9Ic5T?-urM{{XK; zp@1OAWT&(}>UAm`E{V<8$Gy2>VgvmO+~mWE`mbw@?vV>hEHQGu9j+2a3)IT&6T7}V zdr!z_SY_I~Ia#JvuAA6(fahTLnhC8uPRQJG*?6MA7ghJRY7?2YhF}Xj15xMd?bJ?V z6|fN#R^hpplB>t3_d+{J@2cJHHy4`$()Anqgn8vGbLQd?dPb4B3 zjmOS%3yTf>4O%>Qe!@Ab(5^E5QEeWELs@^v-j`MD6Pa0!^XdQ<-DwxcL$BFlzWd(m zLm3goHd7yeG`TT<9nmf>Gw`1@D$^{t7))L8J{bHw@BALMz`B}wQS%x5Up)rDweQ4d z%-&8M$nJkHk)e;|3+8;qoZeV+wfHg0&eBxHkh13aT}qSx9KN2pDG7V{eUO_I}bvSW1RDlJDJ zPqI4RJz3X`IfwK8*>ZF_O6VVhMrp#MwLjxUjpg1{KWX(8$sz=0Sxu|Nbn)>C2vA>k zIcIUaM3Z3}Z!_%;PsuuTY$zH!y%rjNTU^|T5%C<=^@5xC`7+P?_KYUFrFj2TrWawN z7qi*<;U9IL(3|V43o&iQwSmMk*FR%8BB#dCbQVh|DlI;&XdcgE{_mG*KU+tCO87+o z6b4Jzvy<&PdS!n%_l=Pp^KVJ!0|{03IJ2guIN`e*?Vd$M-Y}RFw*F#8Cvla zs@r;>Z<`}?s+Wl04H5Vr4VZ4C*|9E36GXnS~*T>+NYu(U-AX;RI9XR3nY z)yZG$)>%*fTWE$)|7rCR=J}(4Uk+9EHmTnTJP`V?B{B;L?C=ty60`s#&h<23-ZZ1Yt{B ze1hFnJiU_rZ0(=*EI*6y$l3zL4Ui+{~Z z)0V7-?_a!l`gr}F{;=Qm*}8pu%gN6-%$gsSjr|5wdA6o2el@vmp<$Es3Ecm+zxxF> z&1DKZ9{LYT&1lO)|N3w-x69g#v)PS!$9NJR>v`XcZG}|s0AF#FS{X(viBZ(4d|bq* zb-6FP)7-JOVaX$ixuC_>8FwP-VjA9wxJSZW3I!j-OfRxT-LMMJ@j=wb6C!fk_p4QTu=DfD zXRC2K1lNHCMi{{PSc$B9_4W%*!%t82p&=sI*WKUBH)3Q9eVJXNkWd}$&VhwoSkj07 z<9^379EtV8w3ELz!+|ZA!F8imFTWPbmRXwT^QdoES){r4q@YHzNK^7TGrF~nZ4B+lSOdtdHVo?ftUQoZnAW; zW>|!jf+Ca8!Ej+b?fc-_)A5#(d4@Zc4AX-F$moC@4hNVkUSx@69GIAxzzep}&P!Aa zSX_<)rf>AYNXC=<($hnOue51A(Yc{PwRViDow0qee!54OpB-lVqk0wawNbM>D|&i> z?>%D9eE9vLX|wS`6y_!~Lxf2b#_9uky@b5N6^5<1c$4Ao@XIZBPizxl>rP)R^8h!YV6WT9nPgtY$SB$1fP#k zj7w+LSj%Sb!e`fuoaYTJ;Y%cGQG_+XU+=$FUtxB+9skv-QhYLkX`}VwPkgxU{x|~S z<2oHiW5HKLWGRfs17~mFVx%xqSM`1{{rze%b+WTKXtYe*b{WGA?8E@U6#Hv&X_>@e`(Y4{$m^@4X%jp+5(pcMok3UxOe+W9TVD2!)1DG$ zRo-Rc>^4P>X^Ny7k7lHVBYT2RZ$U_wY%t+!gRv(Y8pN}ttZ#Va}Gb#5E?$wW#|`P z?tP_9%`j^9;;~h)A_KsHtnJnb?V{!UOK)mp+?ey2`n2p=Pt_<*ct{VkaPP554VnDL z;d{2WVp;z*hqklw&p(;8KBbF`wWi(hJ2?wH7LE3x4gzumMdx+4ze)!9?h069nQk2u zwcw^Tshw5*_+%#6rkY_bp4r;>57_t3O~fRPJXaI>etF{+m+Ln=!&@nQ4pnOPmA_Sx z9k3;kwzp54@#ku@Lq5U8Br;1zGAuqcL==(tH|M3O2110F1PIm zhHSmf^gYs2_{l$+?UEIo(9J&_{zKjA>0f2Y8I!PzOlPxSt!jAz&DGdH3kVeq>NGxw zdy?Bz{<&uMggRte@ zs4Bf;981xaX*zy+yg@}LDVLF`&%q$y(blxI{J}~;mswv7%$FIn*0SfYeEA$ae%Ici z9_j}$!@bY{6e(p0(mGCL_KWM))UOcnTw?FW3&L)9dv{w%=nywaB~79^D`WrKG4z7`0BL zAn_5}>%P(#+xwpI6gi_3gqqD+e| zEm|Bn_uo?H*kz>5>*z$I-Ah0e_$1Z931O780=@D0HxFZIr5i^yq90il^*P>bW#_y2 z2};H~&@!NmaELikc)oN5=0YP<*;BPgUhip=Ba1)_8@2h=NJmF!0q_7C_ysim&FPAp z{cmhqpY(r>EnAbf@+pDxQ=(~{7p`-BHciV+KuiK4YaftX<}e`FZd|l`WE-0UotAyZ z_}I@|uh;#q(L=W88xLT)!wK1jp4l;3B7P6|8%knLD|TB=-CbhNY?FtQJCS|Q=i|)iwPGE(7bD2d zQyEvMO}azx5PO~c^Vt}Ar1jE1{7=_%1_o#Om@Tf6-P%?2PFtBy4c&S0BT=Vi2`m+N zlLN)Ee3`N(6zHhT22I?lMu69V+)vgT^mO6wSstVXkNoJg)J>wIYH;`^GX9RO?KD^7 z9j+yA=w-ilA&6U8>{`Y+5BCx5K~|&jXR&4rF&R zb@hp}27+OCj@HnMKlMiW3598QcG`o?bLtn(ob&w zDNB;pspYZgAC*lclI;Rm0(u3?SLo`5tdqRTZ4KB*pPK&(er{K53Q~E2BQi>a+GEwt zZ)ZwIPQC!`+4Ep6=Q*YCsYx2I9e@83lK!XZm@xMp+TdE49{p%7_evBeBQv+2Rzz09W8p@0K|k-5C1{# zdok6|Wav;8d{6!@;E)Td7b#`{H_HZ2fcWBAxBYZ%YbfuJ)4M>l zxtp7Pr#Ca8b(}zt)^GK4laT0v$=Ru_!%fU-c0ZJduH-gPj@-_O z)URTnAT(?`rA++_!>3--St33US+xECteG3{yY_$gu;n(uc*zp_uH2ba)Yto0o6pOa zFVncK_F>K@GpeB};L5Ke5J|?LhQ~3sL?NU86mp6S?bUprI$95iz9`dc6HffvYbX*B ziEcS_Z^1M9VRxTY^E_Vm0do6Swc064RB;v6zdDs;4qH!tB0o1tjZvOJ;%WL*n5Im{ zu7%7K9>=Q38eOMx`b&`PD7`iwok{>pZ984YGVt7-V0^+)`T29o z37zim8{ONt5P#!|~a?)HCX*xEYpGKo%R1B)0Nuw0SR54SS7@6`R z$Y%t9dF|4J{uD-0Q&>M8>fD$N&yhPQ@O;E3l8c?PtE@76{o_@Z50rf4-G7YVCy#W8 zmW(H41qlTMF-m0Koj)Kia{B!RWXo$1*kI2(#DE)DTMT-EDx`em7rW}<7ay5Lu_2Qh zq7ti&wOhMlOs|yby^$04C4#tnZ+EHtR;EZP=@KV4W=)y+{x0WdnOkN7ak>BfUR;P7JnxPt?3pI!|`XJuQ%ovvhM?QBH#{eguJu>J8fowAMdgavkle!yBw)y5#oeRqL~ zj7BE>x)P`-w2LJLzJ>gie8VUE?N+P8`t){(ZDpCZ$Qg+80Ooi6NJC2pDc=0$V8_?H z&Wjd7dR|voG`*{Y^Po#HJy?07=9vn})iTc%Mz?8PWNEoq=n`gL{kYq%dU z?i%#sTpSXQ2l={ahqawj73Ut=tKVt9$|To(SsxZa3LPs(*ich?NO;>&_%=9)zzzhnsw$T3p;E z{56{L7a#;uhn6Z(?kIf@N8tnzP+DiIr9Gbb$MFR97HQ5d;2%hcj*St7@Qo%8r3)Y@ zE-fyFQC_daEAbO?m`XscEag88Azut}VNca1(Xc0+bpYnJJ@+{}GSX+ipWevp53K?< z6N4Nheyfw>&*C?L#b^wTGX`?jd4^lX!umP_kUK|!#!E^`S$sve`?mItyK$!ydHZQD{oCOyDw0Vxzb^r1(mgVUbOrez2cOO-Xi|6Hz)@?z-2e9d1DD_7s5HnH z`H$)p-llFE-JENY*znRTs+LEAg+&wS_=01per7}V$ZdH=Zk$9?gz+Z7y!K1S&Scp%*RU|&!-u@K zGnFN3Mdz0~hZ!naOlF`r?X(=+!{!CR5Bk8C862QQ)`{$?btRejdmi!b%QTXAC3bR^ z+QK^zL1qIyKz5g0&~&@z5l_3w+@`)yS<>z$we|15PX^7tCkr0%%gKb?Z9%>wCsmkw^bY_B0J&UaK*))7_=)qUF&i8^|Fsf_HC!+xs)Y)(_rwt>i>+FVKKjnteZB&; z>vMjn62r(}c4}`l-}DAOPfMbB9-yb$K!O#ZmD#}~m`vC3cgz~4hZ}j(n#Iau+JBBO z9hYec!uukTJ|Cb7%B5bpsVIV3ckLQmH z58nh71Um{uH5OQ-4b5WNT@(_3Iw$MR9@$b{uDMAQlRQxN$j}{H_Pziy{GP{Tu^DmB z{8+9|LEaI;)fA)FYJ9CA*$~MvFE4ME>!^2i$2mYjF+HDG<(eu>G%76!-gS>cIK{99 z6l~B+cVG@)=5LV9Ne*^ez765Pbo>q{6QBW6Gf2*SW_|Zd^|oUyX%WaJiwJGNjv^C4@-l4~J(+h& zWYREwkw;84Leyp(ZJtw(RRi;%fb-FBzRjFz1CIqbwjgAB++3ZECySnMP~Y{QlNs#X zFn=dbNfev#``I1LGqZ`=7EhPtCkCKeeT>vge6$9A^|Q80dA6hN1kfnDpN3XnXM3*$ z&?zj(3kSi!48@`R_dabYhK(RDza(nH_HU6C(O}FYe(4M%kRR}2m@OolJ)TQ+G@Y&~A7$iZY-+G2e_BpgaQf^D-Bo>`oE<{nMMNMXb z+s`>x4RW*o+?;IaRRTvEe7K8?UpEv41c{7lg?_!V)|q^Yk?+MKEdmpPdu>gW;GyHu zy|7$psxv_Dn=L$mgg~R0I)(${Fz3B;}Setdc|$zLRJfj4nPeS z(Aisk`Wst2i;Kvl;#3h)M0gkz(nNE=H5h)6si}W?u9HWh-z8EmRWAVmOD0Ji_}3_))U0^RItL`T=g&X~)pW@;TOFs`9xw#(8=eGrZ&M zuJ;`%(x;WeGEdL*|l@pi`Mj*Zv0s#O~v1qohDB5MSYCm(>9tufvVvVg~fQ8(L-8H2$$%-T%~4ZP;{B zlWrNpP;gk7jk5EZbULhA97F~_YM`!wUe{nbnhOlr>u6F-?Uj`pi~U5}56Pum=;;nW zs%d`@&o{cjBjWUdNb*kUfd=%geCE;F5m-Wh1gK5x=}+XAm1h08r++|5Eink`z%Iv9 z`r%lN|1z(oy?V-KIQkYS!ZNDQ125G3q%XiExluCf)SrI)AV_s%J$Ljm?=u*+39}FM zgNt|zic)oA4-&qq3g>gv`s9JBWG1ZgQ4UWBtN2%|JlI+`Tnr(-wvKz*{}kK*9ez%VHAIzSW9 zc%!q`IL_1yIwpD}Z;!8pfuU6hE43{2l3TC~wL;OeXal(5NDb7IamEN$F%S{S{?68I zft-DGw*DIvpCQ|*PWtU}kqgsmkE|D-V4Erra-MF_YrvUco57Pr|GtgzK$>7N2x(u< z8|zyF3ZwNpKm=IP`OgmEWo$#H#00};qeQG+tU4bi$T6W!2F;7{T5^ z)|s_Ft*pTI!;yXi?6>GC^u{~)D(+WEQ+NjdhJL}KSN|O=+uLv}wiY?B`s*ON?HlUn z?BIP~L4S0Gb%XtBafEtT6?W`|pHiZa5DqRvt^I-*Y|P{Q#r`vX*)%Di(7VJad6@Hxy#53RrHiRfS(D!oP$|(Uo5JRs?;uhIx2pM;^<$FXrrF}L zFo~A2@o%jipyrEaaL@*zwQFmq480F8BWGq$}LkJBKp0ifs&LBRIw+k322pa|H`Lp@yK1f`N>!og6Q*v)# z1w{-GP6#7edI?6?9yh#0+Ec6m$KrN0S zc%|~%l{Pt+twBo9S=Jd&0C>>hZ^Zh5V7Guprc+~ac6mJl-~st#zkpE|8GC8t>z|uo zcfbr@JROy$)Ub_j_{;{R(7ea__Lm3uXfg-n$Bcu69vu%py@>Z|<%fC%`D%eX6$+Ui z50wGp+QGPh$F>{IMNJa9oJbnLv?izjZ|e}r>c*>kd3oT>K%3BR61}8t{kI`b7@@cH0RkR($$!ey!^ZQ=<`Z#R%L`@s8Ip*50bT)= zjwoJAyLOq7elXdOm8dieYBSXLoI%L;cJu)&p{;ygdRmH zs#qc9bIyIkq>_sg5vtT0YZzSm8p)SQsY47}kytlCc8G2SR=8RW zA>WVaCv$NG;s_K+mlEwk_(RrnAY5|_&su4OQ~j_hgozgG?>1+jxePx=ruP_zbjl~CCAyH$@uhfA1Fe?|z!H)S z^B^I078?eaOXg|&@vM!k=34$cHc!I}D1r?Dooa4Ut>0wd&T%SIT@$t-yy)D`;Tmu4 z#PZ^g+@DZW`q5DsvqmEWAlgi?Kdjph3Leu@&xLtsNYWgLq!%i!V$1I+A$2B#7uvRW zyn>927nSnfitU21saa#85K3%#Bh@~09B;a}<}toPkNg}$fm$E6b2v9dimq}=!SDWj z8S7_${Qf@i5{ zX8T5>GTK?Kba8gm^pR4LpmTsxXRfFbkO{fhXoge;2rv;b0mfNjzEN{BI^k?-RXLfilwbC}eMhwElJR6kqnn~;A%Ip0}t zJp$qqyQmn4%#mkqfkx%}G41%9n;r+xj|#(4lcRz1~{}zYUtP)~Sr7m2l^oF|9I=d71Q^ zI%GieNmG%=(m*wL?2CEisERNoOr)MsxcG7GRETZ@G*e|59vhWI-#ALmsRa*Kla0i0 zN*s6hz&DsiMSLOh~@_0=0_?` zx(AZkDpEO=iVLSvKVC{mcAA3>(F&v?*a+0PPNjLg>=ic&R>n*O6_&}ruB{aUs@Y9m zP76j-KGrjM{0i=fO!eT~X`^|Y$H>=98#xj6GUOSVJn!ekWowuyCZT`FVT}@kBzImq zh5yaSDkdj0y_bzBrz;ukux;?^lJjeSKvYWD{Vx$t2v)Zv!2lsS`CvIdhOK-+Cn2+x zw+=*gLGb_J= z()W}X!mCR*gZ*bK0yu96959cWBPIdASo&Zr2AuUoq##`akK-n<#9D=u=>^-oFf;kZ zq)*;?&KYX`w;7dkVHi|pI8wb%uEshyZIIo#9fC5kk3sEB7Bge}OCqvj9gH-JvN9kEXW#8qXStg(^B2CVd??-H>i(5!}LdxMBB z6-5TkNP+FTlnKeZGfCvBKRc%P(YaKar`o0j>6tT20=}ew@aD6Vb$hhx@B~Nb)Ffiq z@_A5QNo+=KeSLjE7Yt_$4hmZzDe2(KX>Md7#*FQf6uSY`;8vEov3=_v7nu79+r5x6 z7Ls2GD4o;8cj#EC2?-q*n%qF5tEFR|1KK@=Kxr%y^THt4ZI($Iv%4K(#A>`y1>yTT zNI*6utw1Nk8E!KU5SZq#Kl1Ag>AI!!qFw;SG?0mqEEU%WW{U(;?q8OJbR|lbosO%SZ~(~B=W~8SAgYAiv9fNRx1S` zXK^ZY)p14JOTeTHrGWySRxY6s?@_MM+pPp?_R9#m#CxzO3&%I85sUiSO+@ zsA~~(0~$)kQSoPYI*FcoxxRQ2aaax*5`x3OMG&2|HdPey#!_VR7SU@@fdxVIe?^7k zsFLh!387S^1&{`J;9AQ_kP6p2d`A??o9MosQH^9giV5laPbzs*fTLJ>AYS;s{-dk< z#&bNb7vZ_QHdA-YC5;41j4y)!Q4=;kO06}$Jl(?&)iW9Gwl@Dk1u2K|V&ON(wnSys zFV2Nc^a#l)41JC!!L)Hq2PY1+apQ%pcf7t>&uHQC;sVSC4&{=cMCt_bD&DI9m1lWD zN^XVh5d-S$Am?^6dUOkoi|xeqLYFaDRS7R;7-x=IuzghX#HH zt&tshvJ;=o=DnbPd{Kj?;}q|-vsmCevA-jAjL<5dfZh!EE$o{ z8e7wscGap;rWsKP-i3hcMhx3f4JG^UgiT}h_##(?lD}OcSR4ePJWxJ(kVEketC(5b zkSwmS0pq?^-R|x#{7$BZD~LM25}yRAC$_kaTc(7DxWLvyj!P_e@q608%2H90&a5=;`4B>Wa8L7vxg7zfeJ>c+q;XQyI@!8CbGl}EvZtUOnwL@7xY z77_v=mme;&9Nst9a{o|prAfoWcL{QUNp_Q{MMf@GL@QIPzd2FNjP9E>*Jb9q93$-U zAfbu7KQy$OX3Ex|^sy25e8g6d9hRo_!S4-?9Qc&X>P8#rUV*(WB=aWeiNU}tI9e?d z9U$eqRPMejHM#DSyU#Zv8xVVzIMGncm{JGPvyk5lUR&jT1R<=-#0t8 zfDU5(Kv5U8CECBc*U8`bbpYP$ZTg<>nv{#gozbyOy~pq)zSHoctdzUBOs4Z)V@MCC zp@2W(n^t1pM#`R&0)`O(L)uiYf-ftQqqu0F-ziq#VUWnk`&6i+mCqOAPBv1twgHy( zhm-_iMO6v>v=R{9OL$#&nV;(N+h`&mNekpW zh#^0d|Fg3qClB5hB;*J&7_D7a^_OyjRZ4h#z&ItoJG6^G9bTd-QEkhY2BjPz1ZcnO z5|I|}kC+^)DQ~$6UqP%(x6&wICKgOI%!s?CPe|r$lctA7Z(3drQ&gsy-e$o=y-cwm zULbaLVdq_S8ExS3j?-F>GaH9i0GP})eLrvaWGfKqO$MI>MaNfa+pA_YWQ5o`usH7t z>VvGmBlIQtR^5WG;v9z&XfC>G^w6pI&C&MUH0^*qFA#vqk(}pN%OG_zOcR#pW&fGL z$dgI|Qxn;#82SbfIeq3rT@E|~i{KCXpa_F}gp}8T{zU>v%riW_rYGX1FF(DqC1ht5 z=|B1PC;3Tgz#UpmG|_T}<#QxdO!W%Gh1~^0!bkK8Y&z9u5IO@a9BzoXH**9?=tyb@ z%dt+=pi_^tEGR}AFVuVE(JuPiZU#GKX7fI8<==?E2!G^a=y=zj zi7-nCK`~DH@#Dt;nu)}~N*K4>2 z^1QQFM%_?SMP=~s?LY@MO%>&ixu-i=J;j!~Wten&i?=3uTrfkuJl>A+h(9C9mW`+L z>H*eRxfNoGI9t%?_b4OezF}>5hqCJ{TD{S+^m%}%(ho-8zq-Y`gf~Vwcngs zStM>QhhAnNA~%bylyIb@Jg9uD@6c6JQR<^?L*=Kb&^f5{^RsdEgFl*L4hhZ5r?Fg;Li~n>EC_kA8uoK z1ovF};_0KiQXrGWqQF&X)xSmMqszXTp@}Jy-mO-BCK*amm-9ZcgHm`f^J(+yIM(A^ zvH~)kaUDpdjx!)BTrT?g?ra1vvX9d>2mnO~xi^4fN+ z1L@Q2rxC)#^A!dA_eQKv<#^3rW!j9}%@VF5MzMnL@sC&IAd|$I9v^_9O~d z!QQkrtSBKSHDD?OjH|3<7uy7~an|hq6t|=Q;toEb3AN4_37uUjfjH|*Pmd-*D?}7| z7SnF|7z^=7NPcnc!a#2JG8T=&%Q&K{!auejRRtK)Y$vuQlhOn2vk5>$fuz+1cyN}< zRI(0gs1do2w}^GP{SL~xD{c4|jXKhk6swZr?-2F9P0diDUT#4kn1iK8q9CP!Y6v3F z1ho^bZ#?vx3Kw6QSUyv?@Ln>yIN75p zD`>JbW0;lV9Iw;G-_|aah|4v8zk9P-`RNWNw`N7R)|ocJXo1h(VSM>XG~2_uuZfGz z5An4Xc^yp0du(^tX{;1`Pahu?!vR*mNa1)%L}FDEQ<2-kTkF6HhRHqllka>hB>ZmS z$5e6UOl86lm*gh<&5IePAo%jjU3QfZI6h8|Svk3!>VRKLu|?yk5XM1Vj3yWQ^6Y(_ zf3&lffwJq<&525r=gKee_+dhEn&Efoh;P6}B1`6%a&6X=Ofz928$z;Kf$o!^3`jL@ zVAP;yKl+R&ekbof@i|2k7)&maP4?T~r395xi9GS)QVyo>-D>>1?O+9vR_8v(jtYRE08b{8jJvdytOMtL3uWjL~|SStuz*c z-e1ra6GjNBkE59}Y6+*0vyq;FIxVTB(qHEN(afKL+ipn7Q4~*`OuEsGtQ8Z&a+xRY zT-!Q58bVL7MnywMlkLZ+W)$9BXy$W&N)+4$<7YT`KA;6cj@D(gr!OI|5MLN2YzYDu zSRhRV&l){;4DVgPcxJ28k|gQv96HE|lzdMU^F5X8rFHUZf%(@+^m zt9V}vhx1aW%0-?7)3L%+W|MTo{ve3@dS9?&P!C1iM9Ai{`_su2nk-(sRJu;RI%{+J zS+=9bc~4MEF*VT_YF){nwTmDwJ-C!p0k?{_M=QnJ%xncT<&QJYl!%3ZRV#FA3ZoD$ z1-4qx;07e$dg2$A2#N_~uu%5ZLS{0tCL=5B7ukSq7HgKLve_@l@2*ZNs=6m=rsOxq zpkzRR?#rj?b1!yCny7)+S-ls)ddLRYGF)k&mhlPE^EZG?AE(S0LGF~ZsrAk#GTA+Z z$C{$6aASwm`WP(lZq>63u-U;I$c^LjWxhh!{b~m@fvH}|;(N+NR$T_^ck!;wmKb#S z)O$eP*tfg8yVV#Ym;wY0K7m>!a^o6*OzonOBDe}i-H>cTIKusCW2$@(LQRr3x7WQu zb?zXe2Sb^azdJp7N6$qf`FtK6FXb{x#5P!Otk}+xV2r${AvUw09opZV*c+?se1MBv zuZ$;>3K{&`hTB&dA=L<5*fIez7{L8=4NfnM!vCl#69^2*Comuias*QfIMaZHnxlXn zFOm22IBdVSmjHN3$iGo2whvmmbL+T)1-cJxt)%o+mUSsgr&_>aG_Q;%;t<~o%y#P_ z)}AU+qYp?;YNdDe!y2pnp2JzVHkd|#A0>9D;rG4X&*&UYV63mbNB*(~o~J^;$Odk9 zzGRKw*SBxq!bQ0}=c&DquQENQiX_xbn zCF4SYPevDnvk?4Z?%eDiNW+H zdGVcM@V+6|f)equS_ zL-;7${CIpy$)Cy+gY@X?B=a#7ZiL1Kla0pY?X57#kixYN)vbSt0g{)oC#D^z-E8%& zsjA9)skn1EJQx?6t;8P^SxfGHY~P~i(};P@UK$F3?!i$z1o>l>e2mh9O@A=3+Pn<{ z-VUKz24UeA@es_Zx_OaBtr7+{6-c}#?!Sba^_4Q^CSe7-rA3P~^hX5Jco*-ZqRlH_?Cuf- z2lIBAoYJ9&I*VNHc0*9BqcEOS%n&Q6(ZzRkZtoZ9^o>RMZKcWBNP(INhMIKwe5^?e zo$I_rO72yWq?Y(!97t|iFr&kS%{Ya;$8gbpDG<8h^*jCUQKMz*>%ok-dZ#!KNHtjh z{5?`g;FjEk?;%5nv`I2%417)It13H(pkF-+ayY0VgQ(H}tayK=jVs;2 zHT?k)U9bQVU8E7w@SUrxy zjWI@Y5_6m(UsJ$lKa>x#2VO^A>(St9;hBiYr1)FO5}8Z{i|?aht6=E+Y_++!Xuq2K zv!$CO^Ij)jRRe#u54P6i;_oT8X~0J0vb`*j=xF+f>$mEeXN8$CuKg)2GZiYF3av{fDj0t zawq6?w+Tln4?Q)iB;CRn#E!R0HqZ^-MrYbnjDpojq6-3yg2apYB&C8bn{#d#l|%(r-Q`Q_)HR_K53VBlMNr^`lV#6DvuOP@}IOu&Of6R8TRXI-&Eseucr zQTlC^!99@axWjDTGUBbtu{1fy#mubPV+Nq?upAUF2bl4V%&~^-!i4Wx_>NuCNF~4O z2_}nHnI?A6(hGPde_@m2v2R;)osm(94+e8Bm*)rxCp!Cq!s$1JuFZo{CE}M&%eP`* z|9f0ga;pSCP8bqW_64NYq2udW-}bxG$q%fF_A7e<2@#WEQBS$l$>SPzDqBnakng4S zxvp)5EFvWu)y^kE^_HBU@*--6V&)@H2Id1eElC-iUP(?nM{akSxwtCEiM2FS=6<;A z9AUy8JoV>;UHP$`vzdx&gjW{*uz+!s-5l?LR=&)G#Jm>mtwzZe39Mp&zX@@t}91qPj9`S1ykGf>HAg z+zsewZJ;HHiH0ENAzFrOWj{mw^mil&`Kx+tfyU&bS#L>?tE>6R*- zye7+nOLsNnGb@CR(O?WV0d{?Xu0BEP@1!&OVV%tY|0W;f$X>N*V#v3ll;;> z;v$?uo3K008}yrac<8;o*@>8?EYI@`EE$4|LJL*<->2nd-`gs zgWg9*PEr!QvJSi&Ld9ofVv@TV!*e^xFBurklXlCt%I%q#Hj#ZBUY?6dDap=QAD$3{ zBHZ;Riz-8M*zM}Eq=cc0+P;>dC+hqbv`%i`*Dmy`apEYp|5Otsu5}A0odR+h`NzTy zEQZo`GNr3%W@7BPcMcqaa#XZb#2#Yh7S@`_wlz#fI&xes(0RHVTa;jYtT$iZ*>`i! zj*Rfvnn+NRE8vmj?61>mQ1PcBY7|!7DZ9fFi7ux3ydNv4!ApX;M1%Lk(tfb2twVm1 zS2pS-sYX!;=L(yyZ(7~w>i#>6+`Ixld8OWe{9`c1NxFK)WcLi1I2D`TiZdiu1WL8i zy2z)*{oo@}-rlDf(^h^&(%1ZH)cqw6d$FCfY}E$RgYpHG)Kl@9q0i*@}xzDmdjQx;2hg`E6NC4Y80oIk7 z98Mx)RTBHJ4C^rAHySBEZur=eoCM#b;3i$qlMSW-1pWT@nLy_shagm4-{jD;NhThe zFMwFib2tg5nLq@ zugon(D>zXDErmq>7IIf~nLOMZhh8*a-{2Il4#d~lbit{1xcN2g%DROMKCN9YPcU#u zc0Tc`xD4HHN0KtX8em^@hGR!M4+BHrgajBl!2td&BWCh96REVp_!n3iLkfozf=75= zbi&fnC{X*LlXyVZdiUE&eTuKcC8$?9&)s}b7dtU{+>8o$t-kfR;*1l%d z>1&Sr{~ypR!`a0QM=i-zk)oIzZNIA?|L2ABcz^d>s#UPaIQ{!^g$IU)qV440s7LFJ z&EHkAP@QfMzlxY?BVhsqPJ0~6?2K;Up;6hH`N*geYW7`U1*}f#9D{crL2xang2Y6? z`QU+)B@fHZZEATYC;cX@_q{^o75nq+skP!s;J4<)QXVH$8{%r>?bqG27rLY-Nr6xP*05vMwOsCVwR??O zCMiD1(7xTdJl_fVrZ^bn+0*UzY!2ali&AF^`rz+-7UsH^Z6#vUgDr1lWpZ3xk@Fq- z>{C(~>P@xv))A@kO!M46Ht0tBi>t^8&(2T%Dk}^bZgpEk3THzPNPU|&|G?x_UwMSg zzxG>ei`hPyZW|;+OJ1t!xXV-;U`ciJEMi@Ce<%zbXz6J_Ls|N3G{0=&wiGq@!%~AR zSZzYaaGo`y@Njeg^guXkEBlQY`{9jZV{Oj03p(ms{T+v8wqYU(?@~PNkjjS9&}jn~ zG=imZM(3yHfnSN|(xJ58ss1BbrsMVlbu8R%@Adn~7FABjF3st*RXTbFWrmHPQ1^yF zv_0K9O`dd5f4}IWnZxX8+>dIelc8klz?; zy>6}xYZuc>PmrWmWapRJH7et%lE%(PSKkui7&PIx`0aPy9MB)58VJ*T7(XwVXMWG} zcf&7ZI9srqojCxbQb6m2L_jP4343)&Ty4Od@NL%vMC`$~@`|p#{A^3l2CDT8a!Cv~ z%;1m6IG@;KN9juIxmda9NVNF`Dl$!31oA4MH|^tuyQ%K6CHpD<5sQ8&(HY)qgN(pX zKpiB^ywf7Vin;Hep@dqMY_k9SFqY)SQVU0ZhKYJP&z!&)Jq_HZOjPCZv@Q$`r4CuGVaqZE2n3;1w2mUDykHu_>g=L?2|{#wwBMP-24BSRT_CHDr0k{wY0q#5+^#v zbt-47!~aRtu-Tatz%*;T)Rnm2tL9GKb8OKt?~~OPDEu)6zxRy?84Y8^zBF2~>g0=` zERAxAq%+rziIm5}gW~sD-dvkSBi0`F#TPrHA-0&Ltud+y>m^pwb|Nsf5w?GE*P;5S zQOkXws?%-hGsgQ}a$Ta27)9n5;jP7QMzH>tu!{vuKho~};PI2Awzfv$QI(v)b(Qo! z|MDA^2<@KF%Su-A7;M><6ym0UvB!O{577Ra@r~#7x+1n#`6BdNKwOs&2o@M&=(9EUh9{nxUv2=Ag$v- zP)hTSQyqOf?lx=uv4tp2S4mM3^W4o;o{nRv2A6K~YET@Z;z{JavTr9?o~6Oz46+!> zQv_AiE*^MNqy>_Un_B$498ul5RK+-*@7xu$0$zVKCwNWYEOeeRGrXFRJa`>%+>W!q zR@~(7q(=--G!@UyXtp57+eh!d4k7qDb1-!)DJ%a}bQ9O|_G9U#uU48)D#Sr%mq6FW zUly9%=fy}C;veRVb0N6}(?K9(>G>S_0Vjg=EQwOyri!v+X>4#M$ z7P%6g?B#qKcbO57ca3UFSHTo*ekbHFyCVCSIwxPj<$(D`Q#hK{^2G8jD^~3~{qq2B z<@EF|*!R1}R&k~F-1Hw#pCmp=)=4g^pnTB+jwttqI&0{#!xAG7xWUY%ul(6yIj#Wh z8cfyOBA^Ktsh7Q-O<$Y&={TKBgma%c2W;|SH3Kw84YU?qJBmTI0<0JCa@4>_R{0FF z>D+Y0$~jUYVyN<2Z5CKdkS>GA&9V%&9y}w^-5Jx7$?ClT@kZ}6wuOZKD9LC6Wi$j~ z{o9iR%Aj4VW*=8n^vmFefhvOni!D71+I)%OQb%Qwkksl!Q2l^_B@UeeuwsLF=xnlz zXP5~!3WH+Y)ScGL0|{S$e-T{SWTF;R!3Pt&o3UPcCk!8eum>h{=)qLtrpHlz&JpH0 zw17@|-eZuRF4a@4We~RBa?0uGjDdc_`Ts;W*knmZwxu993~?(5Pz$!-@9gcUwlNj< z`Fa3_2HZ7ZnQ>d`$f!b2W(@^zAggGHCr~@|p32@x8wLqEyaSsv*cHFyU_6g=Unn|0 zZ33FM%9B8|0+}%A4zl=!+|g2=n3V+!kp(bzvlJhLKeZMb|F^-C;9#c8=BV_m?um@# zYX(0}!z=JA6k+~SPX#Be)b(z$RcQy-95)xa_xhv3c&ORFfxZnn+i=}Ne{wEkn@AT* zaN|JuUy%Qp?RVs5I(rrtp!nFRGNvAB6u;B&;d@F#NJ!s&mR*|r>JPuf9S5rW_S&`Q z5x43KXR#>$?f_-ar^yH?mj3IgL*)ZLPw?(sM0dW^pN#NWq~D@EHDk*VqcUHEP;?1D zWd%U`_Wt|8DHOj-99`hsREtJW`v{RW`uZu60DGHa%bl4#qhb`&E3Vz6R}>}e zc8*yU7}PvnFliPQ4Uu`_9}D9<$ltBs9O|0}T(5I=`4&|Qel?)f6L z&V*7vLIRa}Kw8}rzSn#D;L4K3@3E?4?j{V}Jw z=ek#OtWNiAtIXmIgLX6u_BjtCHD!|v3}v5uskw|udDiu+ezA{bb{;1xf-&h?iqd~} z2!*s?byu-PT0T1!qVB%{h4`XnR*~>V*B-Q$CJd1W9U-DBf##{s?@+U&zWOWUle~#| zUTh)tZzuqQQHxOIati`oA$j(t@_e-s{)82Zyg9hlBbwt;>4k;z8@2`civ_Yj=t~op zS=k}V7<=5@d!K>j73$84{ZeRXh}?SJhI@^0ss>Dc*v#^R&S-V3G%gKw<0A~VvfSO7 znG{Wa&=;?eDtn#KwkD-OI%k%p%db!A^W_Eod|i8*95d?|@!y^DD^Q< z%}EFxK{$04{KQuu)(;AZ@*(=6bj(UG)WBVb@g*`BxvAlXX?F+|>^V`=pV4?$X0|2| zPYM?(E&+a^l+T89I7~Sk1f7K&^(%gD)86{vm*LdUXGkP`6^jd6?}Djd!4L2xNc(V1 z+^kl+E^DS?xzO&-()_HCr|K?HcYFq}2-CGOaa|?@O}Q@rWlRd!1nD9C)eag=kHH1~ zyeQ<+47W4~dVR&8mKNRG{FLWI2kGf3-j`|%zp3H~r_f6ks;As?&Vlnsfff0DIKr7m z55)znz!ZAMyB_GpiE;+DqFVp#Dg^H1qQgpa4cFW0@L^~KVk%elpJ+D=E7B{t#V!CD zsCOotuS9lWsR9PPUexGF=+0`JQ#shrvqXyps8$HzB!H!|2wG@XBFJw#7Sto1$>}x=V`!^@XSI!zu|+-2 z-i&>6ak;JkWtaa9y$jhfU9i7LRS?N;dV1PVm>o;<3LLwC&-V1ddkRKn^j6xoP}mUd zsbhOHI89D|hyMEj34pID1wZQ>n(L`9Vf%Yt75?wn_wMgl2c@lIU#U<=fO`~)YV!d$ z3NY^(Z4aJMy_7fx+uKk@xk=(b-BXLi=J?G<9rl=wLfY%!;i%Z$`@wBYLM{%G7TO64 z2H+&S7%AT}Zzq>a>BUK9TPqzJah$C#vbVfzrfgBTG!L;pGFT$$O^MIK(c%9v{`%PY zNkucUxjTd;z+#&CyMss>p$zenH&a+UxDq@e(;`Pv=>8q}O*K5aB^VRqKkp!oh*Px; zCRbrP`ZQc63k&bD{_ZX|mK(W7JPY!rczl6*9O+cC0AVa`86=lGe{Cd|Qh>N3o3`|- zEQ3BTd)O@S4waBo$N$hu2J+qpynlM{Xw-tBybwZ${~&{iOCzFPwd-4N?&F~w;zfLW zKEjf39LX6WU~JoED{AqP+wCkEP#K`NVtIKVT|m>x;3tT#7z$Q&7ghGbjM*$+iSy+n zB88+`+1CX5yx*?0av|Z4kK45N6*`!%o-NPl=3m;okAg5B8&%PVj!Q*yUuvVDRX9eh zfOP*ij>LfedC6%@@CJ;ld9 zeS_A20*Dhqw4&{^Bk1S=F#CH)nx}@iymi75+6yZgYV{TSdF^D#4Mv#vNNyABBdg~O z((S4r5$4Rg0I&y0RUf&JUQ{6{2_70ls|}>m;`=^a{xQdI=@MC(vHejKwA(SR2FkwZ zQy7rQQAF*WVoH`^^O0ZbH~d731l-uL^vIZsSH1&5htr6?FKDVfn<3#zT2B)T6H}eQNW-6 zUxryvgVC7?Pj-&!2^}LD-7KV^-IXE??xAC?JY2?jd<#~ECT)|^*sM~qdO3?J;n8yd zF=h|BStN!sI|MQw^X?y_PI*u8(BI_= z&S+U@T2s>RLuY^Pi%N>h3#z8sT^v`k1_Ba5`Abn04R0!oU2EXgOZICJ`eR?dERgU@ znECI_i6=N`7IgK-k3Fp8R0!L?Q&o_^!=U1>ins9DFskrbUm*GusRtGH9}vXA41C6z6hquJ_Ouqj45|GX1?6l}PGS{c9pp4*Ulsv&bZ z9P{8BsxuT3yH3<8)j`__KB1#`Ipy|c1ib|OS|#TvZO)a8=N-{HVO55zms{QJB@6ZR z#awf?H!UNr&|Cs6i}r;;BL63nxxO6Jdc=(nzdFVE*hT)E-WRupqH`DL_IZbYKvS6@0gwLy^wA!% zj7z-w4>fS~z9qO2KZD#VYpQk6IRI0D1#DLT8xk!WQ^83$QL2VAB+y?3U~ae-g@ zXk82hIX9#G28{ON{nqa4(qjs*^Zi84tL^aFOsRyV)SyfqOWa{yRzNtOl~!4gBgrb7AsGo#W=vo6Y-PCNmyW>jZrj z_l}4q&3Q+=yWnr;P*8mIl*{Uh-}-E6EUQv$+-5+XRPj6%uqcpZoMjSezUf83@9+K9 zc_KCn@iC7suaKpn`&f~VTjNHefXU=r9l`84S%1i(l2Nk!CS73qpuugFgD}m+j}ARw zSEwwt)hT(?@5*oKR>8}bCG)~t_S7}Z;T@iA9PdPSqpz1e} zcw9%*_rM=k&}oMkFM1E4S$#IREruQY4dN4V4`cEmX#EtTOo(INAIZmr%SlC6WA$x~up+@~D~Xx5A;<8nhR^+yh@ zCcyC37!w}0O#fM}U#UxAyvxoLkEfL_pvoZ;!1WooPOp7e& zzs+7&YN4Xt(U)M0#Ky;07F>Uny;~PazN&)v=v~ko9nAL?&)(1IR#c!yG;xLi#y$&MzC80IgHKu71z&>0&eHoGocihRkEXD z<%3fuVl}7IvTS+1Nz*ThiZ;D6RD)b!`O_>p36$fWt)^j*y5q5O@FqRhAzF~Eeb+aT zOwruO+7gSTa);HGqU4*MgsdWk!}xgXZa#nprWhm?ho(~K<=q}-JAYtTs4a$wddku! zAvB(e2#|Z>;DkGTPT4JOgSO{stg}JWvkNl30K&qLtN2m|+*N^Srq^_8n~MQjNJCR- zhNBNFrCi(j!HF^`pJwJMi;^y@qpj9X2$07waO^8FNE<8&NYgGT7*gZy@a4xSH+&b% z!!2I8;q|NYR8vYZY`PB?112wl2+!ey{+HOaB@Sw~f9rtQZjlrP2VxG*yrf6TsfYLb z9u)lOZ-Y{8n4>=y%m?zNdW3xC2ol_d3bKe>atQ3Kg%wjC77DTFkncx)hK&?VKRIM2 z)>B{Rlg{P^$^&_*n7?t-}t)FG-PbXbwbMbb*Id? z0I1@~UVX+DR8;D&YpDUID3Q>1>7z5c1{Z$sGCKAtl}>aGG2*I?TiB$YUxeYN=o=Xh6!O6?twvW&J)WKb@YL?_d?Fule27FiCVv6a#AI{dfa@q5*=r z5>?{u;0K@rmwHN~9X$>w9eb1VxL_o-(IGAVt6WFD!dO z6tSh18ZBps^d#+h@*4!|V${u@Vf3MrrWm{#jH(=hX*~gxL0;LELx;!lM*`r9gr!$w z+{13s5~Y}d4&r|VlNoGl8om@A*i-{i}!?!y82sLRV$ zW>E$<7BWU=7TjqWj2u1YQ$VCzvL9~(1T#eT!sn=~!SMU(2!=J)3^>QA<-&1lkmw$( z*zP{46$^FTk64X!;wl%x$t4^?F14u8m<<2hV zino2;HZB>LX1TR}x`eB0F_`W{VK8r?ZH@8$5Nb9V917e7GFI)imSy)-=G=!OZ5Kj4 z1jvCv7!7)qJC)dIKlUI4cVk5AeydiJj_DcxU^wGrVQBH#GEgG~VcpE-ZbB0#BC|S67ueE~zr~G9&IBy+~ zuvvb0{Nzsb9*HhQTQq$;QZ5&YfGQQ13huOMKf49(hJiI+7bYEZ8AK(duV4UpFf+C@ z!B&nu81dF*G1Laoeq4%1~WExaLAMWR=lc_I*YSa z45TrV2Vkm!Iu!W{`+M#1(XB;PMtz1*cNKphj0GnrYm(x}E19bX9X@*q{~Tt9EPR7x zSoryMT}GdfS~3{monZagJjq&?Xow~-rq9nVTj&n`rucO;`a>Cg=H6uw9~R zAzo$@6rm=BzF;hip3Ttgvi*c-`OO_Kcd^5_dIs4G5r^^eEYJfHmvVaIJE|Re7B#OW znP3E!G{_Xd;3}8hE4kY^W@TztQ$8Jz45Q<+kr@@u;;Hk$t0SD(Sj zj%vQ%aBwE*8BXos7&Z%}iJSP1 zt(V?C@#EB0uK$n9Iie0t5swOg&cQ?K-)!21B9p_sJr}}{wAIJ;Hj2FrejzsgU%-4K z|F7^SMdUYXg7A_IbEMM8a1Ye)1VsQk=0LTB#Y6?`Q1>%CC4;P0A2s3NZZkMsqWnC( zGt-|NES1>(6dKxi{{vnuYjD9Wd~Q%}N6>DA*bpEJ<`md79fCL=i;*IT@28sAq}e_R)r`Y2DW|b^jjE{)bY55g%ja46S<*~`W^3|_0rpPWUv%{uBE}=n6`+z zugaCiYi-q!DU5h5=tcZUGU zcxY*0USd3(hdQ_d5OXlp(NIyX|G{*EuMj!$Mjx@r63eDNKfz#NH}Tc`q0II($!;iG z{ddLoNVyx~ziu49s5e%(;wGDR8TnW1e8HD@FuEz{r@#!ZD6I^#co;QJYFcK2Mb6F1 zfwM<%`@K@XrXVCeI6PUQn1*AWSglH0{0|X>WC^+2+oRX?)K?$gkr$Me6~1-xdoZ7= zH>;X@9i&E&*<)6rT?p%ExKjN`aIbfir{@)afhHg7wJHnofZh5_uYKJ+uf7FZr07C39R7T>c z-d}pfe@tZ^qW5K8$p1FoZy%>KaihYr`xcHK(5lvq?ZF%Y78xemZ}6K_o~t!5JnVk` zililCQjT`LahRcL_IzTfJB@K9lJ-;ju2k+mYGkVGsk`pbgnkp!tXAi1XtWjyOfUCp zu$eS4nXBok{Y>4|(!H9YMs$&1ioz*d)ZtF~V8>+^sKuvxgBn~U-cY+rhL^zSMcZ8l-NWEqyH7o}T>FB5^YZeV& zY3XjCOiWS>+n_ZcGw{gL<2spz0`Jjl0r@`z=LVgVu?{VhQ~n44wXAplif zX3|&3{~-k%cc8n3YNk+>p-TP9C~89~Z8Fy#C{I;FOe!lTTbi$MB(nXC?6nY|$6VQ^ z<#|TT$NC8N4j$hGM%5sZ=XM`T7PlL$dvuk4C$gX1&G;ya-ISdPIBMc`6XgO9Mj}*M z<^7#4nXA!kIov+q;V?s?W(x@xx@S%I(f9&08`pVRu>*q1s<&(eq)EJ)!y0QWK4R6l z!N@8AmShpne)?1naXx~+z7T=u_dU5dU#8L@hzQ-GuAvY;J6KFp9j~?Q#%o822ZBpY z!b$wh`n%$`(O4wGA5og;|J|@)(mc;-f8?jg&vC+f+p=L#Kc=etEI*oAhP(=8bs2hr z^ccGHniF4Hm^<6NyvcqNmTvslQ|~+EH$p)rSgrF{ingChxA90+@9p2&U|WF(yDpWu zPq{m+r7}M=>e&eAayd|p+wT8l$L?0_jK51Bu&^bmM8%_cTcMrv z#+Rl06|$n?U9EHc`0qZ3_asni9r^#l*R$KE+;5n^22=2SuFsqw+`&@QtdFHsU8w}M zRL9y_Mz%o@+&V->LxU3}t*~v-bcz4g8XtHPIW$qVvrDL7nL2nzzD*CZW2Kzlsg{r5 zZs7K|5zG#&{OMlFEvoR+0_y_dF%dNop?Yi~wHnqL{g$4dUNZ2^YK_@jGk|ajXWXf# zzOwB;#*>-`)PnTEj!%a=!k#EsVThD&!dSsYw-28qeKvzUsD5r7BRdpfeU#eVb-6&| z$#c<7L-9K(dWY10{B~i!ko{`G32llsCuK#xsc>86fOVF%S|+WsSjWV2^4Hsp#|@k% zHSzu3bQ2;g$+UNr72VAz-*6_hq)7BhuW`ODKu6N=V1a(T4=eI3(sI_IS)m+i6RXQ< zQ}mhfxW9&rl}Wh(UZo04@C?sevwk#AMy{P>?fSj(XD8p8GOykeGxFCyipsz$$KLVY zd8=(;okMM5VFAc?NDGi#BC(Hy{!5>dwUzO?kCW~XLG%cHZ>NKRqD(0f!)t{`J4(MI zlB$vy`U%br!ZtoGdy%@<3p1F)+}yulS_eY@#`cDuA5DNc+J=az^Y?X~ z*nw<5fzi>hmc()!8FcZb^GS)*1f1BuB-><`xmVn3_-wzAE%V~YB-N-s)+GEF*12ok zPpG`#%*gYkh&}y=LptV+sB$nVbs9m{q*wjlWCY=$-PL4X(AdJyFpy z)5(>dAu$xp$eXj->8~5?x50Nigfe0zlHv`^w6pBY(Z~TJs$d{eOC)&H?$iK{fJCEL z!FFHR1LAzhOAA~4{lz!oO`3}Sbw-mbX zP+liiy{;Ym^WSjomcogtlN0ME_hzn{;AG=E2e$&+z~4{SJAHwde!Z(0MEKEo{#n@d zZMwx@qErC^&$}UR&)4G{$rWj1K$Vvo{&d#*Kdai6brP7u3;KJ?NjY zM{tO?@kKKAIah)GHJ!!O-_C{*3g9M$8A} zlYGFsf1GSTeJ_C!w3Y3sWF>yzA>ClU3Ff148+fT$*@^TD3o45R7T2@nW0wuB8!)i` zeGI7 zDe{n-TCe}XSpm8Lj` z(lO;1`HeB;7Z2`64?$mL*YA@p09778SUdgm2Q6*1uQ!2#Sk4;b$meJ$57<@XkIA~AbV?+4BJn9(ZU+6 zNgIoK_NjOowj^fh0c$;ovjI2c%e1t#ym}(D{p^U&<9Ip9YB(}^Gk(1zP2g22{;_im zQ4GyNlC+e2zi6J#)!Qm3HQkMS0oF$P3(N}IhK;cyIwDB{nd}lFO006D*G)EmlM=mx zvlv-+Qq1?%ig&3g@2w=o2I7ouG?H8R_o~E&!h}Y(YC>a<`(-gDT(pq)R@Ktd>Jry4 zhA))&<85xgVgVOG#nZmh_ z>DRjK8~=VHPohK0!y5oqfZ1cswXk>4e#xxU>6S4=Sdw>v`@_W-Uty2g;vgn%;FJBxE zqFZjZ=ZCC{ls#|EUj`_Mn>&+GQHPUE`fZPr{!N+yR4SIE!#fFq76^*wI7Z%o*!yQ8 z@fC^aV5ccsMsynT0%XSc8QBC25^Y`^TzM@?-(E&6Zc`lJ#a-}eZg!Zrcm+tK?}r`_ zl4qq7Qz@g}-gz}`2CuAn`8@>FEbQDQlxuz~;m-m);uqZP5lBGS-EYnx%RhcG^TV?u zX$YmI&~BR9@sl{Z++Ya}4eg}tG*%+qwASE@x9rt(B6lzi81|DLHU8lDp7RRcd}sC{6lMo3&F~RXNw3ONt~WvPFf#<)JK98{(pgJwKg_W z@@_p1jl~OS;0no^S_-(Q#YUJ^&xSLaUf6<^>C8Um-n$jE3p z&1er3C*372Hu1M%EVz z7JrbGMVT*1=JBT1bBnj$NkEXeAMu^K6COx9L2KUi&$BPwzIWXVA<_&F?VWXF1*1$iSIi)aHHXai{(WU8xSRdS}qZJ63 zXXcU$$1XjF$9Wd_gre+{jVLDF)edx7+=d~(wtx}5knRf&Wh<$RhX|e4{w-==?Sv)Y?VdPFpNn=aUhS$1UXXe+H%>4E`;B_YtP%oxPkWgPk4BE z9h8zupe6I3bMF&ts<(Fe3@`pCBmQQG$vp^8y8eC(3Irtrw)Kf8sc!K*%x~ zgx(}-_lWSmmB*At2xvAK&^2Ncf@Tly=jt!|mp1h6y zF-ldukMfB{)nQa2>Bin86Rgp>nPR@|oXTf91Wqu+^yuOON`GnBRk)OKLY0Za;`)4K zC87bniWDvyW|Vm3C$y#UuJ@#Z{XzFJw5E%!^AH+Il6i|7emB0VRH+Nb3e3tL(qGK*^p!i?4yN2Id$06WFZ$C84huS3^hX^4O%=(T!FetIc?fGr zMUzIe(C~2O!v%oZU5)!zETHTPh4IyPm*Ukdsda2;a@%*xdYCQhB__=%foL1+UmPmq z;D2YHuisc@<~F^Ph%`1AHR|{DrfCP`8YB_kQl)LY2}xCvrqr;Nw*KYCZLTKqcJ4E+*H;cSd?Foq+#D8^S6PZ{rRd%4c zHtec;%juJ^v{={l3q6-cUo$TR8jqCJ_vgsfvYy%dK9^woF8%41&dT5OpU=9}9{#Ny zpy*F-^qV&XWWKT2BrQ{jc+hbVL_@<;aal1K#ws>h+{91k9Mu8` z$wNvlN!GgF`r&Vi{OpfZV){#nZtDk4#*V*Cu)O4|C3$}yY2Pr?*Aq{;Iv2>ZN$Zg6 zaKmYSg9wuh_i5^~cEGy|E8TC)K=$+36ea!3CFI=B%035clyR}P1_3VYt&G3JLf5y) z4aDf^UVM7E^%LM7DA9uR#NRSHu`mO1Ik4tv(__$OCt@V;mc&f0v2NQOJxO5RwPrgF zKED|ycQ5AGGcH&v?zX$u?G)C>uN2)PaOhX?-qdUn^ugrhY>!Lie?(Y)B(dd*_lxp% zC(%sDwqhv~TbYiRXsh*es@GPE&kk{=*vBJbEK>wgEMXJfx4Yp-XG|yi$E#Ol#zHe& zz=0$sBXg)xy~!}3%-nfU_)y*g0ur#?`N&3^nyJ4I_lY1f6*YeJ@jb*`9PEKz4kqo4q zdhRykXoIm+^)W&;!t9>L;tPHaKKpU5iEoWdyZ9dXZCE~L&@wG;S5tr3S^=6tueJ>R zi~;uf7LbP0Yu;&!kWTj4z_f*uS6gC9USSpKV$RRGZ?Hd|#)12WiafBxm^b*+gLcUA zRma4<=IdUec#Q^QXoVrAeyu_s=|(WtaS~y*+$S<4oZqe;5vSNQYnOOqfr5|!4JYtf zCkn4%0=^k`Npg!!Idg1^)vq287Jq>XBYmb$mogQZA9u8%54@P447QtrG%#1?j}IQ& z>bX~Y3dK7=r{^%ui>?wEu!BwO#=(7<h`eu`F4V3G`{ES*87YXKa?UzCF0#oIEq{Pw z_w6@dfRTTgNT#dkZ~`56V}eb;kfmIo@S2rC)!(=G-B8OS3b~GF(%wAqA!giJ{Y_E* zhSL|JIrmp0jlo*nC7jw8zoG`S^4^WDb;3+7*Elu4~@mH%F){k zef9?ooM-@_0{=D5Z)fx%Q}s}A=nXc_Xga1{n8*r-J44ABE<>8Ndme*t>i&P z*C!$CS0i++auu}NEGEbqFRn=p(9h0besG_kmD>tn$P17Mo|7R5D&~b55R{iTGi>19 z6Z!ri`O}pS8*oZuPhY$QyIiD}0Ml3LS+vRqu=BL|-0C*IXGh1vA;ZNRT9G4KVs&tl zH}-486>t+^e;}1AB_rnHJt6aZ!i}b)SjdUwz51_wmXa5#Mq$e^U70tAaCh7)N9KzB zRm|PM8gFM0LUIwjj!kPUs@5>?u9H!Y*~ZP9c?^`21cI!oqxb%fR>#%u*NC_=lF{Lxy0#ULa%o zg}6%X-UTv-2sOq%UDF$eK}#e-Tu!%NVOuwHUs-KBu{voakoHU3uT5~(jKivEBx4dn z_=qSZE#7D2#ErWJcw7?wvuTaWgvqd&^79yRk=G(F?{%UrOl zf>B6C_yo;Ky@773!pXddWNN5Qj9?Hk@6oGimc;uPI8{}~^+mIkoit)k*W@lsQkcSK zuHm(alW3T#AR!Q<$cnYpsied4d~QOXGmqpKlsb}On=XPDBCX-Z&bZG18E>R6`N}JO z&Ta+SGRuIG+hLI zdLA)TdD_i=?)<_Nv4WpllJ863LUZQC-F{RtnsXvfgEc|yg$O{+Ae4Jd*G8bpA?vLA zU7xzH-pU(dy>X5yl>uL6x|FDtIt6%v?>8IVgru)RbDF});w6_!$R8CijN_cZ(a>is zNG3t&O8#6h=0JW=<&pZm7&X|3yMZ)_H1GXqEf{)hQOBL5@SR}MlSq>-?iWbc+b87Poz7z z8H#Bz~;TP4s+O$S*`zxB(0~vm-r`(rv z{_ptFh9+1|uy^`ih29*ayV+H6b!tc;BGpZMtdN5 zTOeyyL>Bgj{;8+e4VF;MJuo90Rf_JQqOOsoiVJVOKjAPsDPV8FE!IUvMO`NQ3t&>!&1X%pL2)WfovW9t&HW_e;aX?`hR)KGnKBI zf@#lk;Rx)yP*+%6%j2f|8YTZ!$7_(41S{&EtfN7oRzw?&^@>~#1Um~IH=k6As|L$O z5F=X`jRK0~yw$J=j-s5X@wTcVD@j~Jc!_>Ni){)VDFkEMRRk?dqm}F>F_k~j_thF( zHTR4SPhDnWMafxbHngU`>FetgsnUCA%xCgrV!chj7VFW3@rwj~s@H68JUl#gf1xHw zEKxKvGjkJH2$w6=E37s#oblam$nhPxS}t;NpZb9~+cq3{2{%>lA9+(J3rUM>_}@i*-_~5(+Zg zTVGFC4E*OfWyoC#80p9>soi|Qy-bi|DGHT<`v|PzMQha%=?cw=Jyyna$X25&MMgya z;$?mWUY7P>8J~vPNuuNmhdroDFC$`UsHm{~_&>F#4}aNHE^f;#@!h0#bacFbKfj`) z0y1!EC!*-*GRjWyKUDNGqs)VYZ@#{NoMkeWi<9wFHa9nSX0k%~hdAl|a8M+?b+Qfg zjqW~NMmsW9BkQveJ^ebknpzsy9-$^;Ii_(RkC>D}!aJMN@W&hY<|3w8z2|5|JP`f} zd_lM41;g5m!z-l=APD5_%%-iURF0BhDf$)bjJ(E5>SK{_ab!x&{!)jb4!G&N5}X_s z(ZF+d=UEjjk4n@^ZB~A)1~igrevEw>Y5eQgFX)&g6)D*dAO`{cithOWPOg0kAeEbm)dy-vpFhDE8wDED;>83QO^y=!-#Q_ zp)VdLq)fotKd7Xwozigkpb=;Xc|9MQoaEv97IUCF4pO3zudgJm(=arwQcLgcvO=LY z>p?h;sFzt+uqYGLz{JG=Mz0f%ut_Fd!@U%C|0cr6=Lzp$v{)iLyg+CVUxG6C-=D)} zxVVgOD@m@in~m_-U2Yv!)KtP1B)x!M?F-5LpkJapE-m7Mi+YJvs(huQCQmK6s z!|#H1U{eO0I6$GW0%AF$XJBUfeRj zPb|YR)7udd5tBgre`j0|BaPQy0NLup-^S%c_yEuEkFo{YeSeZU&DJUSd_{s6-Z>Fu zFDlho(cLF|TU!GL>0x1E0_l`&m<_YPj9PzaNdBUoJi0lZwEYX&f%*?#cWcDo5s`kP z@BO1(B(v>-=PqtO0Jt?3KYg@r!M;m^s5nudmi|B)4tS1AiD;YQBj zX$U`M3W()@$j5pPPEm-svI3k3tbB#?l&o1_F7T?9aMdM9jg01@nC=0o_4!Ay3V1Z` ztK16XCCmAShr%lsQ)^Ws5pcrm|L}Rkhsm@RClX!#$R}{HsQU%X_&hE0PVLwJ0u64x5}FD_wFihcHa0k8-?CnJ$Q9Nx%Dg(OF6Y8O zVS{(U$T9?;mG|JvNVVQmLrH4*+YtC+n$>ygHPxtyJNE7AsEL(geDt_jg#0z@FsI76SMGWM_h!gyjFK`tES7`>=hx z$S7{IDSIS4D|?o4s|d*^qLP(lWoJue6f(2QOv8vMl1(L*vLyf4L=)=8$77#QBl$!XJ8YvtY?{W>fc zz{@qWrL3$xQ3r8=x5NdaV*>$$hM7+udYO!eDi#T#voCck2`TB9Ye80NA z+>KTD>hH9(DDht4-n|>q@-j($4mJwJH=yyRt?cO2Lgb4Mc_nJSyEGQ`W@Zm#uhhXGHd(P(2Efwo|0EwNs zn7I9gqG^#p9fe7PCZESy+d`eLJdJ;-aNs{FnnRPrgwJ~_6~3L?`7btk2lAm(_ZNF^ z@4y*&&SjFaC%V3cWMVeBFDg2kH0aeIBBaOo;s*W@tnK8(9X~Md3Qi;hELS%+psnfl z`{Y5MAX>j2qoeg65J3&(q%J=qN_cCVT!jCBBqM{yfPDg$^DfP# zgFIgWtzP5chO-|y1u4gj4pbcI^|)5pgfY!|Uth{3cusfx+=!<@fDiOTrF$Ah2xfz+ z1wCgn&Q=Hw-_jr41KqUm);my4;FknVJ!d%ivIu9+a|A;hH5HY|wbZEvi0z*ndgf~C z114O^D&S~{sou}c&AFEuOfGbs*Sm_4^DaBhP$F!;`zlt1ly6Dl!qH>xO?`aeyM#Jg z|4`VCrHZAqP}0)U%KwmS=A0R6N=Vr8csr;`nVFeb0*yF;#g}tU+x5*AuCgh8rfws2&vE8)Lc90j21nZ!?Ul1d*4eSKoA zB8E&r_b!~m#MrnZZTj0>e?=IboCRky7(1tbw<+ zwK*6Mz8jr&sE&~|7bPfaPF>d}-=)uJjm3CF!?>!dbJ&5=dr&;577#SR_M58O1+dr^ zW+5dna_T-?0ogYl48`Wr+AEsd&xOliArWRc+FlpFyk?+xcF~pZTeEuBu_#*q5qleU zpyy#Y@(orX)q&T2p7eddrQ1ePL|bE&kn5<7ZKf8_H&PcaTo5jt3%znVPtW}rm9WlT zwe^vs@cHO?j>SpPl1>Xo6JG`Xq>G-s><}!>Udw+d82&~i7r@&uv9_g|$8jS|Xd+!w z;hCVs2_)nz$xr+H`(M5C$F;+7+ij4}o$Mek4c9Ftr#}O!si{aB9KmClC)(7za53h) zj~?x_eYgzygVCvp339Qoi;I<~kL43bTLCDdR4XVf z+|vhN3&;uV363W8jy4_=i4|EbG?rI@>n+&eqnXfAh?N_?RLW@~N?@Y8Q$k5W@!zJR zzo)5`>v_(Z2q9@Pm7Q5>e6lXvb?4!c%*d_Onv?z14xgfrCtgrcfWo(KoPyYi9#HPj zy`lCA+7!APG2fMeT!&X6o;L&C`}rH>f4N}VAol+Py;r2V1}4uW-Bl?6W>PJtt-lie zu;@6LZlqOq%K)rGmfts8TU${$`us5Y@C|pEp`)`iQWNG^&4Ac&?Al8eu0|xexpL0P zQeG81`8X~ripo!5fAbpi54g=hhI>~-m`Z<@7$Lrgdiez&2aK9{SykKY`b~Ah8<7Et)YOtwL;CaE$a-Yrl!~Mzf@v(EK zqtF4OtmflFi<=l1xAWtX^*t7%wT}ohG(AVlNvTlo-aO{BkZe#$E8uAH5)s%&xP5@b z{x*iSSkHZr%%=YDk1uUvKY!7v!+wOWS|%SG{`30mX@dOxpO8vWZOo6|muNv!zTT(t z`9{k4g!>wnoJ~zl>t7!{c(Bp$GsTHe3>1+8X1`Eo`B<7(T+>cFxROEbNBDp_BAB!_ zgK&eCj&bB*o_6F=pWg_iY2l|~NK8m*e}huCB0X(sGIVpJjI3&6PMVwY?u|`52s0e(1x>6gr6H zJolGVLPN`M#EXe867O3=ZZ@bbV{CYt15$J<*i!)m?C`=O8yB4hi_96)Tkx>SCuO4oQ@Y$ovaBQYbB>7xlGE1**wXuNQ)bDC&SWF9ww zy|S37s3wp|ZDW`!>~U@4y{J{xou-lwjBnqu11;iL?C#7|*U0lE>uY7htgNh};tL)P z1qH>j;v;DU4i~;wn%*oqk)B`Jg;!EmZiZ7)3}viffD&iYl`CcFWp!#y^VE^o!}>G! z=gl(UNU=H$>O?{Dgeh1B4mvn??b5waRB#@@+ioHw<1cP!jUtC+B?^%gu&n2G)3wut z93!{2<^m;vtTCy*JZWpIxr2lExcqRbdHbYq!iK{6NnNjV;6#92)SZu;^Arykx1T}c zFE)Y)De9r>i##<_c)Mn%g1dQO25Uj1Qz%5AH zw|1!XzWWoAf$!cnFWTFk0rjDVhG_Lis4%p9G{(hkZ=cSnNG8(hmC&3BfvPKy-Xpv7 z@D_pNC~A>eOt`MWyX4OTn$0eWr#z-fSNA?jRYa|Y93qJefmn>%lq}jF@+NS){S$w{ zQ%Tbhb;3&A(iU>Ti#!bt!qvJuxu`+XuyDT}{gQIv@+Tx8&KnQE#Wg{{2<`@$m8ENr`dE>t$%Qn;wR2DtZOX6Ga8*dm+uN8IQ{4E;ct%Jjfes zE&I&&e6D_dr+rY=H*R%{V*VO5dWUgLfm4$hYk_z8t)<0wD=Dv*)}eagcc_!&>y|g!uWRXh4;3;+%` zuv)u)tc0e6=Gx~XqjXI2z&*e&V$y-+jhE+H(EVH_pHPM<5ytV48ylZs5!Kd{QjRai z!`AkeB$@xTf>N@oB3=A+bzEY~wS6bpjN`ZPhk&QNtyB>kQHLnV-bI{vHF{i>dpLBC zJME#A%)$V;^d$am5@;z!kObF1awv%Li}GTi`|Rz(X)?V4bVxX=_wJG8jx?|$42zzX z=jP&QxV#cF?tM33u(# ztCd;y;M#eMa(wLVJ%hIZ5}VzV(5-EJ!Qm}CiX)Dj>9iX9$Q(tBPf2@$cA;n=lh$w8 z6-OS#+7ihc&|B0-O7gIGY0rMKq!QXp|3}bj!aTwf?$jiS9^6iy(%HV8cV3&GcP&J@ zlRYKb4rYs-_=0$){`aS|mof4n8cMYo?N^Rd@oqt!dS=vhIXWXdb3t0*ly)F%fCF|8 zQaI<0D{6nc6MJnuS?M~-O^oWDL`3f3Xx;7&#kL?Z2O=f4qC^D1kzJ-xX_8v zM(^Dun~XUl2dQ^tKkVyZZ{jJ8!1=ELcm3=PF=>5I+2LQ3ZrNGCH_1b6ki&^&dBLkM zFwF%<^P)`C^dr}eEUo94OI-8CE%FULC&XIDz5Lu0N$r4g%5&%}+<#*SkJ173x!?zV zt{mi*Yd(!NzXkB~mnJ4;kj${A5u;vYt0De@e_O--Pzizu!%iWSRZyQ0%Y0ALXA3I( z!HgDbDPnd_Ke2;>Wu#13d6SYmgLtNE-#-4s(5dqW#{Zx-UmhxmEqi<0A*1xjG2nvU z0T~g5-qe6SI`{L>MCk1CL)eck$$q=^Nz!$M>&BH!*VyRX@ZEEBbAjvlBE~YauqfX+ z&H4qvQ&Ab`bs71(tT5o#U<5%ps}K(x$-s5FK{C>~O26&uBgsq{x`K>=2S)N~{<-XU zL?r%b#;640^PQK*+?;UsOG0rCmc#8cif6{w5P;@J)TZjil2qurGCvIii$ZlFqNMZ_ z#DgZk&sVQWWF3n&V2zg-xz@>dN+)tp|EoMKo#bL03YdKMfpaYmZ4|teJBE6DlU`lhLGgeojSAt>87fvmcrO(`fh&gFMe) zMo;TORcGdCY}^Ag2iSzgoGBed{mgNrcnu}ht;d>?y?$TR>nhFrCr%T1IqI;QjAx_2 zrx{jw5HsR{@5@a;cDH{beOew%$3>6O8Jafm)C->-;1CuUkGIFPmx(dj;|VlleM%i) z>~ONWF~pwbz4T`*)q^c^jHa8()u<*5`2=sjH5k%9bEv==63&U0-q1_vOVue>%lu;S z8Kx*Y`GJ9q(MD43uC@o_%pE*6wS=Og6mt`)a6ccQ$;`BWy5_&HIDf) zt9C}X{$LC8RnW#)h7&F5{fN?D_STJ8Tz7F^_t|+~^5n@pPz#astu`P_MwK36 z^-E%I!*R1PvF0vYvvwNYX3A>1XO%}oq>e!%J9!XVyf1jy1GNJk1M{^F{Jm@P-TsY< zI|bNO=veIw3kn8=6yanlC3P0@*Wt*83mbs@mOo+PG936{#J*mw0%8%tML(v5YJl1C zC5n}o)n6~IFJPUTVn8CB{rhIQ`L}uLs-J>-J{om(1?doY`+-~pNC%65=KJ?op)v6; zAbK%a6w>L5Cu#_9>atMALafUjVG4*~jGhLzjTd$6v>RW_4zDzLANU?Q_1;o7U*^}v zt>6A$pX%VG7-;7`k0v9K{%`c8*kdz8DM;D0S-do8fyu;gp>s&LW(x)MbT`TNI*mVhDDHQ zt=#-BcfsC1U#Z;;+g|g=s)O$~T4k>>KLBPk`MWjx>xul_DlaFexU5I#Q+JukI-}Gx zNaB1|Xu#x`BD#(}x7#$~jw`Hsrg^~u)r-z`Mhs|+!$YH{b!l!<6+3aws!`E}X5+s@ zZL@qo?B8GX(t3&^CU4uwjEs#5FDYg|pt)$T&2~cdDZg|#+2B?F!2+U-FY!d?O-fzS z1N>U3$~%iAp+G%7Z~Y@GEjoekPiAT=zcF2^OwIS=3_GX&vO#O^6}v26vj-2cnsQy; zdy}$TXDrRyHxUaY!;c2?QH6}Qn-vdMR_f5k03I<7SRizTs z#DHg*YQzvvp`bjW3+O+VFSP4bc=dd~P7U9&n`c}N*sSSKQ&v*p(#^(+NbbTw=3Wbl zq4@p3>o!_ zZtP6}v z5SvaicQKL-KDlokMX8ofXq9x?0HsMt;c(Lx6{+FZEP59UGVkYoG(mq(b!!l?UAFM`eDlJ zP|>N8d_-Pp;|uV;Kp0q`PB&|T5fA)g3=DW!Sf=LYj>8&i=>n@Y7yN#J%jF_#Lg*2o z4#~#2JBry)bZoogfaaQ>tEWPDwE%^A#%y%^`&P4oJD`KhN!pc?7W+28-DIBYrM~9^AJ6SKaB;4F`FS9}9kX{~e@2b- zR^hNnp+NCWA!zuxDA&QycM{4Dq%9Z=zJo#amTtx7mnhu&NIU4mB~a=Bo3YTMcv|MI zVpGyyU#&{1y(%i45&WW}BqD9;#Q69)crFRHoDT?ie#g<8H{=cThpzy4zP_q>1AG>> zhdNYBGrz|?Q%D{ENyoz#ax8prTkfq6-8nTxk;jqsXWspQpv35l9=QwqhBG+xj!pdh zq^4S^l?yBi+|;57h$2KsM_Ff@P-g}g+iFVf*z=td)l4|##LkWkz&(9Om4_@w5mXb0pCrI+V<%JwU zr#^bV<9BU}?TGQlmoaFuxNF0@-T+5)aP62iSVqpM6PzE&bzHvC&F@6B|C*=34{)V{_x)# zv>$EvM9!W5SS6pGwsQw8hJrznLBp)avjsUpl5`H940}@#&XmMr6u-hVPio8H$$Agp*UcYp<}E#a|7w%a_j{ ze@^feSL~3CPfi|3Uc-pQ+>-}iVOTy2@)Bu~ z_0R(d6ZaQg3wfsS0^Wtic>(P${iC#&mVF~1YsggAAfM<=y@k6hYG4;GneP`!l0Al0Cg&Sj{bF>EpW4SxAnKP zm)qLk{tZxbphhlM&g4>sz1B61eqWT^Mc&gG*}0=pk~xZJY@oyYIK;|| zl7{}1=Jj9f(Uaz6OGbz10W4r>XM=4AK!or!CM}?Ar#fjcg!wI%-s)M^Jj@rq#H5u9 zMnMwwiYSrOq&fc1$^C9=#z8(zfoxpWze&q37sfBn!5EtI#SL+b(@-e+bTsCPh3Mk_ z4qwO+&(}FV!_7HxX3P(OVD*ZS8B+dmPtkYzxo8P_St!|@pzdRm=LVF{A6g-<4;Bmg}tX(WTK-( zH~$(~V)YZh&qEaSYBUeWfBjK zF9~1MZ0`c5*Ycv0ft@?%nT6dCL1E#i@)Hg)M37IsFmemyx!j}r7}r69))fQHt~y|L z`ll-EG7c45b71zB%Iu&sAS8JoxKGH>iCE101&1*q!C^x?X5*ulNmwGk8^J>Ho1*TD zUW%F5TvwV-%u8n{Cq_Q?KUm^vyLP$P4pS9CQ1^l9kRY!b=3Tokmuu${E7VWm$>@=p zkSjKwBF;f%mb5?y6gGNVchlo_$wD@mtm z@PpGkP%IE2FjKp0UKryoBthFEtMf4V58mty8->5v(QVJ4+(|^wEQjd7!Xj>+0rv4UMsDnwcpNj z4@&M>IW^gB&KQ(^p?2XLHYn1~)E!sNxqcGzo+VJaCD9kw&32jI6**M+c9-ASfjgHFf?y4|h~f)qThu|Mz{@m9 zzB;gL?EP9iD_1?^9idIHV_x;%*=knGZ*|W;Z!ltNMW zadL7JUD|AaUKqAg7sbpAbWu&3uier;BAO)r)pPOYsZ>A*j%_pjdH+y`h)iD+cJH6`#O-PXWdYWF5R9F7NFFMkY&&wTc-e}% z=LrZAXHh8ORlj@eFHuw)8*6|R2AstlDq`~5HJ^2hBUC=GQ)Ir)k{3T>s8II7F_&I` zBdyl&so|>2q-mBJX|w})*u1-1dzp2FcC#684dB=u`MmYB3s(8^_H8MfI~7{x+pCw>JAJM9eZmo00yll&8&zc#pJ^bscL)TkWPz(|)dY2=(UbPnE9hlvSB9>>yTIbAnpuFmcqP3(=ZTm_>?^YOj`Q`E>Ydjo;^EV*?L<4u%m%sipXBg zHm%Tt5z9)e^U#svr26~!FFH4>%kjfkmH%#^l3VdoQ7e}(547UEE_Sj(;&&G3zuZ5r z!m`~rfFp$B7D5_{gq~hwWGC%-f$ne)!2~7J;1AJ+>injxv#*=itT4m{07~Nlua$mM zhmJWIk^Ah~v)G1)Dyz)zCIq_fIB`YXnn3~C7qO1CKYNdsF{R_Ikl*dIrP|A-9PIYz z5-;Dr-li74SyAs+jCXLqc-Yyy2t`UIcFuAK1Tc`lyET1BnKLXHtIRYNf)t0o2Yz~$2?+^~bOCo>S$G_q@Ft^-_|~JyZv#%8 zWQV!8_cCNA>cM><-HZ`pN@;9V&8)|tPTFDz_1Q72Z?aru_LGo-;JGEgp9drh>zmk8 zcJs_tw`>W9l$*9sK6$}aG7c zdXHh$@mjuWDFn@WoO}$pQ&V>) zTdFE6Ul%o}sH#D3KtMY0@2_B=;cK8IM^C{qWho4(QAyOqiov~G_lfm2_z!9p|AlHR z!u4CoZ@Us67Gen!2b6ypwZP9`zb;<7#8pBo6*TTW^zl%h&64~bQ{QRwos72GlxMAZ zT4SF+$N63^P(1sdbnejiP~I{$`Z6(L4u{{cuzxs#Iv>90q=)I1zq}NfSwn&Mzc=z# zX_~x(u4D9#L`5%wi2b1vFT${bwq2rh1iGK;ELrdC!549e1zs-?Tr)-^_D7}ejf<{~ z`@8?CNXA}O>7@X4bo1C-L3Qrd%L%A0Cwsl#H8A8bM4qM$w2BL9VkZJUF_~?%L4;{B9b|5M(TpFU}kRA zYzfZ>5ELS`%5mMW5I0)glxoo-7OQalt&X8M`60L2iPnR8C}ojHI`@T_g@S2`5nZ1E zxtZDumGL_2wW+oh560aRE$%yi&R!pW68WQnUaK)n0f-~}h~ zi1-Ovp6f0e#VkL(|5Dvpg!nn#;n%_Y{?x=#V>n2%89({h4@s)2sYy^NoYJO>`2*#H z3&`mxm3dYC=1EiEjc=#^wNW!pTdTD^cyIyf2mLZ5#roQsi~UnxL*e186|2+AwjOND zw*s5j&J0v{A1aKOl=ArYC=6K=LdgKQ_`JfZ&0JgrKLk+$?7@_28n(R_u$9L^;_0Vo zr+ol)=1Q^}JfF;gA zmP^&-jM1ahZTyyM)TVJ#`CvSN)wsN}LVjsdY^3TavQ@QQl24+UWubQ7)1A*xIXLi- z(jB8dTOf2;ON344?+o}VA1O|qnsa&ke3_0T`IK{zE4go9>|TBRt@snp&$9 zcVR2*BYm>$T^WG!Xd2G@NGm8h1VwV>3$1XlvPN^Y)66qVrc@C!o6h{+mlk|pgyaT- zk`9g_3$O1cs9qlL!6dpXpnaMJ6v8C&MV&(C&VKGzg6{L;t@G#4-`+Ry9@S$g@(P$8 z2s)8JtI10!bBr2ZPVZD5TfEpkqhFx^sFQpDD{l2y^l{dIsf@nN&eG_|sL*(B{{9^y zfs&FTiJr50j!Y`{>gP9QysLDeB;|Nq)1`UT>2D(YxN|^cwyotRo96MMs8iBmT{`n7 z?na^pf8whq*c{9dy(j}d8G6ffWsqC+wzBTAIZv;yeE4|>7k>xPAqw3qW?s%I4;~ya z_UEQqh;mX$Z;>v%c-nBU_?1&F3q;cOyX!9hC?K+D+9}ve`pCl8Y)MiPD^V?%Wk&x9 zcW8ysA6Zs`+?#&ym?H{f7eBc0PY8$YwR>5q=~^;g&Ll0LdRrR___h3@FDF{Y&-oW- z>GQeO?}}d!P1u;BpPkWHED2XS^=z9_i~6hX;x^;^*GdoF#y8Z<9uT#99-yLT`sw{S z*XGLVg7(z#47s_)$9%&=Px=YJjtL11``o{7J+vxlDR_9BFzvSa&!Oj zl)-7kx|-mA{!1J5PXs657_v3?FSt>?NctVyaZrFtoOG=)be$b=5!wCw&f4}^Hex%#+*DRczZ1G{h16uRC RMKb(jq-UyoPun5l{{c1I8T0@E diff --git a/flaschengeist/utils/picture.py b/flaschengeist/utils/picture.py deleted file mode 100644 index 3f2dc40..0000000 --- a/flaschengeist/utils/picture.py +++ /dev/null @@ -1,55 +0,0 @@ -import os, sys, shutil, io -from PIL import Image -from flask import Response -from werkzeug.exceptions import BadRequest -from ..utils.HTTP import no_content - -thumbnail_sizes = ((32, 32), (64, 64), (128, 128), (256, 256), (512, 512)) - - -def save_picture(picture, path): - - if not picture.mimetype.startswith("image/"): - raise BadRequest - os.makedirs(path, exist_ok=True) - file_type = picture.mimetype.replace("image/", "") - filename = f"{path}/drink" - with open(f"{filename}.{file_type}", "wb") as file: - file.write(picture.binary) - image = Image.open(f"{filename}.{file_type}") - if file_type != "png": - image.save(f"{filename}.png", "PNG") - os.remove(f"{filename}.{file_type}") - for thumbnail_size in thumbnail_sizes: - work_image = image.copy() - work_image.thumbnail(thumbnail_size) - work_image.save(f"{filename}-{thumbnail_size[0]}.png", "PNG") - - -def get_picture(path, size=None): - try: - if size: - if os.path.isfile(f"{path}/drink-{size}.png"): - with open(f"{path}/drink-{size}.png", "rb") as file: - image = file.read() - else: - _image = Image.open(f"{path}/drink.png") - _image.thumbnail((int(size), int(size))) - with io.BytesIO() as file: - _image.save(file, format="PNG") - image = file.getvalue() - else: - with open(f"{path}/drink.png", "rb") as file: - image = file.read() - response = Response(image, mimetype="image/png") - response.add_etag() - return response - except: - raise FileNotFoundError - - -def delete_picture(path): - try: - shutil.rmtree(path) - except FileNotFoundError: - pass From e3d0014e62e2b24871299b77c417cec3160a426a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 15 Nov 2021 16:34:58 +0100 Subject: [PATCH 292/446] [pricelist] Use Serial database type instead of int for IDs --- flaschengeist/plugins/pricelist/models.py | 43 ++++++++++++----------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index be36247..48298dc 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -1,20 +1,21 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 from flaschengeist.database import db +from flaschengeist.models import ModelSerializeMixin, Serial from flaschengeist.models.image import Image from typing import Optional drink_tag_association = db.Table( "drink_x_tag", - db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")), - db.Column("tag_id", db.Integer, db.ForeignKey("drink_tag.id")), + db.Column("drink_id", Serial, db.ForeignKey("drink.id")), + db.Column("tag_id", Serial, db.ForeignKey("drink_tag.id")), ) drink_type_association = db.Table( "drink_x_type", - db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")), - db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")), + db.Column("drink_id", Serial, db.ForeignKey("drink.id")), + db.Column("type_id", Serial, db.ForeignKey("drink_type.id")), ) @@ -24,7 +25,7 @@ class Tag(db.Model, ModelSerializeMixin): """ __tablename__ = "drink_tag" - id: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column("id", Serial, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) color: str = db.Column(db.String(7), nullable=False) @@ -35,7 +36,7 @@ class DrinkType(db.Model, ModelSerializeMixin): """ __tablename__ = "drink_type" - id: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column("id", Serial, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) @@ -45,9 +46,9 @@ class DrinkPrice(db.Model, ModelSerializeMixin): """ __tablename__ = "drink_price" - id: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column("id", Serial, primary_key=True) price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) - volume_id_ = db.Column("volume_id", db.Integer, db.ForeignKey("drink_price_volume.id")) + volume_id_ = db.Column("volume_id", Serial, db.ForeignKey("drink_price_volume.id")) volume: "DrinkPriceVolume" = None _volume: "DrinkPriceVolume" = db.relationship("DrinkPriceVolume", back_populates="_prices", join_depth=1) public: bool = db.Column(db.Boolean, default=True) @@ -62,8 +63,8 @@ class ExtraIngredient(db.Model, ModelSerializeMixin): ExtraIngredient """ - __tablename__ = "extra_ingredient" - id: int = db.Column("id", db.Integer, primary_key=True) + __tablename__ = "drink_extra_ingredient" + id: int = db.Column("id", Serial, primary_key=True) name: str = db.Column(db.String(30), unique=True, nullable=False) price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) @@ -74,9 +75,9 @@ class DrinkIngredient(db.Model, ModelSerializeMixin): """ __tablename__ = "drink_ingredient" - id: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column("id", Serial, primary_key=True) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) - ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id")) + ingredient_id: int = db.Column(Serial, db.ForeignKey("drink.id")) cost_per_volume: float name: str _drink_ingredient: Drink = db.relationship("Drink") @@ -95,14 +96,14 @@ class Ingredient(db.Model, ModelSerializeMixin): Ingredient Associationtable """ - __tablename__ = "ingredient_association" - id: int = db.Column("id", db.Integer, primary_key=True) - volume_id = db.Column(db.Integer, db.ForeignKey("drink_price_volume.id")) + __tablename__ = "drink_ingredient_association" + id: int = db.Column("id", Serial, primary_key=True) + volume_id = db.Column(Serial, db.ForeignKey("drink_price_volume.id")) drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient) extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient) - _drink_ingredient_id = db.Column(db.Integer, db.ForeignKey("drink_ingredient.id")) - _extra_ingredient_id = db.Column(db.Integer, db.ForeignKey("extra_ingredient.id")) + _drink_ingredient_id = db.Column(Serial, db.ForeignKey("drink_ingredient.id")) + _extra_ingredient_id = db.Column(Serial, db.ForeignKey("drink_extra_ingredient.id")) class MinPrices(ModelSerializeMixin): @@ -120,8 +121,8 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): """ __tablename__ = "drink_price_volume" - id: int = db.Column("id", db.Integer, primary_key=True) - drink_id = db.Column(db.Integer, db.ForeignKey("drink.id")) + id: int = db.Column("id", Serial, primary_key=True) + drink_id = db.Column(Serial, db.ForeignKey("drink.id")) drink: "Drink" = None _drink: "Drink" = db.relationship("Drink", back_populates="_volumes") volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) @@ -143,7 +144,7 @@ class Drink(db.Model, ModelSerializeMixin): """ __tablename__ = "drink" - id: int = db.Column("id", db.Integer, primary_key=True) + id: int = db.Column("id", Serial, primary_key=True) article_id: Optional[str] = db.Column(db.String(64)) package_size: Optional[int] = db.Column(db.Integer) name: str = db.Column(db.String(60), nullable=False) @@ -152,9 +153,9 @@ class Drink(db.Model, ModelSerializeMixin): cost_per_package: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False)) has_image: bool = False - uuid: str = db.Column(db.String(36)) receipt: Optional[list[str]] = db.Column(db.PickleType(protocol=4)) + _type_id = db.Column("type_id", Serial, db.ForeignKey("drink_type.id")) _image_id = db.Column("image_id", Serial, db.ForeignKey("image.id")) image_: Image = db.relationship("Image", cascade="all, delete", foreign_keys=[_image_id]) From 6a9db1b36a646393fffcedfdcbba4e34f2e7585b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 15 Nov 2021 17:05:18 +0100 Subject: [PATCH 293/446] [pricelist] Fix minor issues --- flaschengeist/plugins/pricelist/__init__.py | 2 +- flaschengeist/plugins/pricelist/pricelist_controller.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 7423d1e..9adace7 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -710,7 +710,7 @@ def get_priclist_setting(userid, current_session): return no_content() -@PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST" "DELETE"]) +@PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "DELETE"]) @login_required(permission=permissions.EDIT) def set_picture(identifier, current_session): """Get, Create, Delete Drink Picture diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 69b011c..0d9567a 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -302,7 +302,7 @@ def update_drink(identifier, data): else: drink = get_drink(identifier) for key, value in data.items(): - if hasattr(drink, key): + if hasattr(drink, key) and key != 'has_image': setattr(drink, key, value if value != "" else None) if drink_type: @@ -531,7 +531,7 @@ def delete_extra_ingredient(identifier): def save_drink_picture(identifier, file): drink = get_drink(identifier) - drink.image = image_controller.upload_image(file) + drink.image_ = image_controller.upload_image(file) db.session.commit() return drink From f205291d6d62df6b97aed4929353348b1f094596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 15 Nov 2021 20:47:14 +0100 Subject: [PATCH 294/446] [pricelist][fix] autodeletion of relationship. drinks can be modified --- flaschengeist/plugins/pricelist/models.py | 6 +-- .../plugins/pricelist/pricelist_controller.py | 52 +++++-------------- 2 files changed, 15 insertions(+), 43 deletions(-) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 48298dc..55b1152 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -99,7 +99,7 @@ class Ingredient(db.Model, ModelSerializeMixin): __tablename__ = "drink_ingredient_association" id: int = db.Column("id", Serial, primary_key=True) volume_id = db.Column(Serial, db.ForeignKey("drink_price_volume.id")) - drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient) + drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient, cascade="all,delete") extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient) _drink_ingredient_id = db.Column(Serial, db.ForeignKey("drink_ingredient.id")) @@ -132,10 +132,10 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): _prices: list[DrinkPrice] = db.relationship( DrinkPrice, back_populates="_volume", cascade="all,delete,delete-orphan" ) - ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) + ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id, cascade="all,delete,delete-orphan") def __repr__(self): - return f"DrinkPriceVolume({self.id},{self.drink_id},{self.prices})" + return f"DrinkPriceVolume({self.id},{self.drink_id},{self.volume},{self.prices})" class Drink(db.Model, ModelSerializeMixin): diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 0d9567a..d42085a 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -1,9 +1,7 @@ from werkzeug.exceptions import BadRequest, NotFound from sqlalchemy.exc import IntegrityError -from uuid import uuid4 from flaschengeist import logger -from flaschengeist.config import config from flaschengeist.database import db from flaschengeist.utils.decorators import extract_session @@ -357,16 +355,9 @@ def set_volume(data): prices = values.pop("prices") if "ingredients" in values: ingredients = values.pop("ingredients") - vol_id = values.pop("id", None) - if vol_id < 0: - volume = DrinkPriceVolume(**values) - db.session.add(volume) - else: - volume = get_volume(vol_id) - if not volume: - raise NotFound - for key, value in values.items(): - setattr(volume, key, value if value != "" else None) + values.pop("id", None) + volume = DrinkPriceVolume(**values) + db.session.add(volume) if prices and session.user_.has_permission(EDIT_PRICE): set_prices(prices, volume) @@ -416,16 +407,9 @@ def set_price(data): allowed_keys.append("description") logger.debug(f"allowed_key {allowed_keys}") values = {key: value for key, value in data.items() if key in allowed_keys} - price_id = values.pop("id", -1) - if price_id < 0: - price = DrinkPrice(**values) - db.session.add(price) - else: - price = get_price(price_id) - if not price: - raise NotFound - for key, value in values.items(): - setattr(price, key, value) + values.pop("id", -1) + price = DrinkPrice(**values) + db.session.add(price) return price @@ -443,16 +427,9 @@ def set_drink_ingredient(data): values.pop("cost_per_volume") if "name" in values: values.pop("name") - ingredient_id = values.pop("id", -1) - if ingredient_id < 0: - drink_ingredient = DrinkIngredient(**values) - db.session.add(drink_ingredient) - else: - drink_ingredient = DrinkIngredient.query.get(ingredient_id) - if not drink_ingredient: - raise NotFound - for key, value in values.items(): - setattr(drink_ingredient, key, value if value != "" else None) + values.pop("id", -1) + drink_ingredient = DrinkIngredient(**values) + db.session.add(drink_ingredient) return drink_ingredient @@ -467,14 +444,9 @@ def set_ingredient(data): drink_ingredient_value = data.pop("drink_ingredient") if "extra_ingredient" in data: extra_ingredient_value = data.pop("extra_ingredient") - ingredient_id = data.pop("id", -1) - if ingredient_id < 0: - ingredient = Ingredient(**data) - db.session.add(ingredient) - else: - ingredient = get_ingredient(ingredient_id) - if not ingredient: - raise NotFound + data.pop("id", -1) + ingredient = Ingredient(**data) + db.session.add(ingredient) if drink_ingredient_value: ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_value) if extra_ingredient_value: From ae1bf6c54bf2e797ca5f95b698c3683f24c71bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 15 Nov 2021 22:38:49 +0100 Subject: [PATCH 295/446] [auth_ldap][fix] hash ssha from ldap3 --- flaschengeist/plugins/auth_ldap/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 627325a..f017815 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -6,7 +6,8 @@ from typing import Optional from flask_ldapconn import LDAPConn from flask import current_app as app from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError -from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE +from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_SHA +from ldap3.utils.hashed import hashed from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from flaschengeist import logger @@ -241,7 +242,7 @@ class AuthLDAP(AuthPlugin): password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode() return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}" else: - return f"{{SSHA}}{base64.b64encode(sha1(password + salt) + salt)}" + return hashed(HASHED_SALTED_SHA, password) def _get_groups(self, uid): groups = [] From 0ef9d18aceab08fc77a48a1ef962642e0e116023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 16 Nov 2021 11:18:00 +0100 Subject: [PATCH 296/446] [auth_ldap][fix] fix loade correct picture --- flaschengeist/plugins/auth_ldap/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index f017815..ab001a2 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -147,7 +147,7 @@ class AuthLDAP(AuthPlugin): if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0: avatar = _Avatar() avatar.mimetype = "image/jpeg" - avatar.binary.extend(r["jpegPhoto"][0]) + avatar.binary = bytearray(r['jpegPhoto'][0]) return avatar else: raise NotFound From 45ed9219a455aea167fc98ada8e6aa33c733bcb4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 16 Nov 2021 13:43:19 +0100 Subject: [PATCH 297/446] [logging] Some cleanup and improved configuring using user config --- flaschengeist/flaschengeist.toml | 11 ++++++++--- flaschengeist/logging.toml | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index 0898e2c..d9d670b 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -14,10 +14,15 @@ secret_key = "V3ryS3cr3t" #domain = "flaschengeist.local" [LOGGING] -# Uncomment to enable logging to a file -#file = "/tmp/flaschengeist-debug.log" +# You can override all settings from the logging.toml here +# E.g. override the formatters etc +# # Logging level, possible: DEBUG INFO WARNING ERROR -level = "WARNING" +level = "DEBUG" +# Uncomment to enable logging to a file +# file = "/tmp/flaschengeist-debug.log" +# Uncomment to disable console logging +# console = False [DATABASE] # engine = "mysql" (default) diff --git a/flaschengeist/logging.toml b/flaschengeist/logging.toml index 7ff7c08..3af9f99 100644 --- a/flaschengeist/logging.toml +++ b/flaschengeist/logging.toml @@ -1,9 +1,12 @@ +# This is the default flaschengeist logger configuration +# If you want to customize it, use the flaschengeist.toml + version = 1 disable_existing_loggers = false [formatters] [formatters.simple] - format = "%(asctime)s - %(name)s - %(message)s" + format = "%(asctime)s - %(name)s (%(levelname)s) - %(message)s" [formatters.extended] format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s" From f1d973b44626fe672f20f54f1954d7e87e761096 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 16 Nov 2021 14:06:31 +0100 Subject: [PATCH 298/446] [deps] Updated flask requirement --- flaschengeist/plugins/auth_ldap/__init__.py | 6 ++---- setup.py | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index ab001a2..dffbc3c 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -6,8 +6,7 @@ from typing import Optional from flask_ldapconn import LDAPConn from flask import current_app as app from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError -from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_SHA -from ldap3.utils.hashed import hashed +from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from flaschengeist import logger @@ -30,7 +29,6 @@ class AuthLDAP(AuthPlugin): LDAP_TLS_VERSION=ssl.PROTOCOL_TLS, FORCE_ATTRIBUTE_VALUE_AS_LIST=True, ) - logger.warning(app.config.get("LDAP_USE_SSL")) if "ca_cert" in config: app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"] else: @@ -242,7 +240,7 @@ class AuthLDAP(AuthPlugin): password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode() return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}" else: - return hashed(HASHED_SALTED_SHA, password) + return f"{{SSHA}}{base64.b64encode(sha1(password.encode() + salt).digest() + salt)}" def _get_groups(self, uid): groups = [] diff --git a/setup.py b/setup.py index adef712..d8ccc0f 100644 --- a/setup.py +++ b/setup.py @@ -33,9 +33,9 @@ setup( scripts=["run_flaschengeist"], python_requires=">=3.7", install_requires=[ - "Flask >= 1.1", + "Flask >= 2.0", "toml", - "sqlalchemy>=1.4", + "sqlalchemy>=1.4.26", "flask_sqlalchemy>=2.5", "flask_cors", "Pillow>=8.4.0", From c6c41adb02199b45b8cee2e5ffc7303d0ef4a979 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 16 Nov 2021 14:07:05 +0100 Subject: [PATCH 299/446] [logging] Enabled overriding logger config by user config --- flaschengeist/config.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index e06fcae..47af03f 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -41,17 +41,21 @@ def read_configuration(test_config): update_dict(config, test_config) -def configure_app(app, test_config=None): +def configure_logger(): global config - read_configuration(test_config) - - # Always enable this builtin plugins! - update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) - + # Read default config logger_config = toml.load(_module_path / "logging.toml") + if "LOGGING" in config: + # Override with user config + update_dict(logger_config, config.get("LOGGING")) + # Check for shortcuts if "level" in config["LOGGING"]: logger_config["loggers"]["flaschengeist"] = {"level": config["LOGGING"]["level"]} + logger_config["handlers"]["console"]["level"] = config["LOGGING"]["level"] + logger_config["handlers"]["file"]["level"] = config["LOGGING"]["level"] + if not config["LOGGING"].get("console", True): + logger_config["handlers"]["console"]["level"] = "CRITICAL" if "file" in config["LOGGING"]: logger_config["root"]["handlers"].append("file") logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"] @@ -59,6 +63,14 @@ def configure_app(app, test_config=None): path.parent.mkdir(parents=True, exist_ok=True) logging.config.dictConfig(logger_config) + +def configure_app(app, test_config=None): + global config + read_configuration(test_config) + + # Always enable this builtin plugins! + update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) + if "secret_key" not in config["FLASCHENGEIST"]: logger.warning("No secret key was configured, please configure one for production systems!") app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29" From 92183a423549cf1163caac24cc1380c656ba7827 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 16 Nov 2021 14:07:05 +0100 Subject: [PATCH 300/446] [logging] Enabled overriding logger config by user config --- flaschengeist/config.py | 24 +++++++++++++++------ flaschengeist/plugins/auth_ldap/__init__.py | 2 +- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index e06fcae..47af03f 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -41,17 +41,21 @@ def read_configuration(test_config): update_dict(config, test_config) -def configure_app(app, test_config=None): +def configure_logger(): global config - read_configuration(test_config) - - # Always enable this builtin plugins! - update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) - + # Read default config logger_config = toml.load(_module_path / "logging.toml") + if "LOGGING" in config: + # Override with user config + update_dict(logger_config, config.get("LOGGING")) + # Check for shortcuts if "level" in config["LOGGING"]: logger_config["loggers"]["flaschengeist"] = {"level": config["LOGGING"]["level"]} + logger_config["handlers"]["console"]["level"] = config["LOGGING"]["level"] + logger_config["handlers"]["file"]["level"] = config["LOGGING"]["level"] + if not config["LOGGING"].get("console", True): + logger_config["handlers"]["console"]["level"] = "CRITICAL" if "file" in config["LOGGING"]: logger_config["root"]["handlers"].append("file") logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"] @@ -59,6 +63,14 @@ def configure_app(app, test_config=None): path.parent.mkdir(parents=True, exist_ok=True) logging.config.dictConfig(logger_config) + +def configure_app(app, test_config=None): + global config + read_configuration(test_config) + + # Always enable this builtin plugins! + update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) + if "secret_key" not in config["FLASCHENGEIST"]: logger.warning("No secret key was configured, please configure one for production systems!") app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29" diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index dffbc3c..3ceb6e0 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -240,7 +240,7 @@ class AuthLDAP(AuthPlugin): password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode() return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}" else: - return f"{{SSHA}}{base64.b64encode(sha1(password.encode() + salt).digest() + salt)}" + return f"{{SSHA}}{base64.b64encode(sha1(password.encode() + salt).digest() + salt).decode()}" def _get_groups(self, uid): groups = [] From 05dc158719366a1a6e0c62b50ee3aefe45bf57ee Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Nov 2021 12:54:37 +0100 Subject: [PATCH 301/446] [cleanup] PEP8 cleanup --- flaschengeist/app.py | 3 +- flaschengeist/config.py | 9 +++++- flaschengeist/controller/imageController.py | 18 +++++------ flaschengeist/database.py | 1 + flaschengeist/models/__init__.py | 2 +- flaschengeist/models/image.py | 3 +- flaschengeist/models/user.py | 4 ++- flaschengeist/plugins/__init__.py | 2 +- flaschengeist/plugins/auth_ldap/__init__.py | 16 +++++----- flaschengeist/plugins/auth_plain/__init__.py | 8 ++++- .../plugins/balance/balance_controller.py | 9 +++++- flaschengeist/plugins/events/models.py | 18 +++++++++-- flaschengeist/plugins/pricelist/models.py | 6 +++- .../plugins/pricelist/pricelist_controller.py | 31 ++++++++++++++++--- setup.py | 11 ++++++- tests/conftest.py | 8 ++++- 16 files changed, 115 insertions(+), 34 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 0012c6f..8d15c11 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -52,7 +52,8 @@ def __load_plugins(app): app.register_blueprint(plugin.blueprint) except: logger.error( - f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True + f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", + exc_info=True, ) del plugin continue diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 47af03f..48c71b2 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -69,7 +69,14 @@ def configure_app(app, test_config=None): read_configuration(test_config) # Always enable this builtin plugins! - update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}}) + update_dict( + config, + { + "auth": {"enabled": True}, + "roles": {"enabled": True}, + "users": {"enabled": True}, + }, + ) if "secret_key" not in config["FLASCHENGEIST"]: logger.warning("No secret key was configured, please configure one for production systems!") diff --git a/flaschengeist/controller/imageController.py b/flaschengeist/controller/imageController.py index 5139d56..26bfd2d 100644 --- a/flaschengeist/controller/imageController.py +++ b/flaschengeist/controller/imageController.py @@ -13,7 +13,7 @@ from flaschengeist.config import config def check_mimetype(mime: str): - return mime in config["FILES"].get('allowed_mimetypes', []) + return mime in config["FILES"].get("allowed_mimetypes", []) def send_image(id: int = None, image: Image = None): @@ -32,10 +32,10 @@ def send_thumbnail(id: int = None, image: Image = None): if not image.thumbnail_: with PImage.open(image.open()) as im: im.thumbnail(tuple(config["FILES"].get("thumbnail_size"))) - s = image.path_.split('.') - s.insert(len(s)-1, 'thumbnail') - im.save('.'.join(s)) - image.thumbnail_ = '.'.join(s) + s = image.path_.split(".") + s.insert(len(s) - 1, "thumbnail") + im.save(".".join(s)) + image.thumbnail_ = ".".join(s) db.session.commit() return send_file(image.thumbnail_, mimetype=image.mimetype_, download_name=image.filename_) @@ -45,10 +45,10 @@ def upload_image(file: FileStorage): raise UnprocessableEntity path = Path(config["FILES"].get("data_path")) / str(date.today().year) - path.mkdir(mode=int('0700', 8), parents=True, exist_ok=True) - - if file.filename.count('.') < 1: - name = secure_filename(file.filename + '.' + file.mimetype.split('/')[-1]) + path.mkdir(mode=int("0700", 8), parents=True, exist_ok=True) + + if file.filename.count(".") < 1: + name = secure_filename(file.filename + "." + file.mimetype.split("/")[-1]) else: name = secure_filename(file.filename) img = Image(mimetype_=file.mimetype, filename_=name) diff --git a/flaschengeist/database.py b/flaschengeist/database.py index 85fc3e1..410e0cf 100644 --- a/flaschengeist/database.py +++ b/flaschengeist/database.py @@ -6,5 +6,6 @@ db = SQLAlchemy() def case_sensitive(s): if db.session.bind.dialect.name == "mysql": from sqlalchemy import func + return func.binary(s) return s diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 4eca8e3..acffc1a 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -44,7 +44,7 @@ class ModelSerializeMixin: class Serial(TypeDecorator): """Same as MariaDB Serial used for IDs""" - cache_ok=True + cache_ok = True impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite") diff --git a/flaschengeist/models/image.py b/flaschengeist/models/image.py index 1242c2e..7cf9967 100644 --- a/flaschengeist/models/image.py +++ b/flaschengeist/models/image.py @@ -6,6 +6,7 @@ from pathlib import Path from . import ModelSerializeMixin, Serial from ..database import db + class Image(db.Model, ModelSerializeMixin): __tablename__ = "image" id: int = db.Column("id", Serial, primary_key=True) @@ -18,7 +19,7 @@ class Image(db.Model, ModelSerializeMixin): return open(self.path_, "rb") -@event.listens_for(Image, 'before_delete') +@event.listens_for(Image, "before_delete") def clear_file(mapper, connection, target: Image): if target.path_: p = Path(target.path_) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 84116a6..16b901c 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -67,7 +67,9 @@ class User(db.Model, ModelSerializeMixin): sessions_ = db.relationship("Session", back_populates="user_") _attributes = db.relationship( - "_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete" + "_UserAttribute", + collection_class=attribute_mapped_collection("name"), + cascade="all, delete", ) @property diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index fabc4e2..da5bc84 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -33,7 +33,7 @@ class Plugin: blueprint = None # You have to override permissions = [] # You have to override - id = "dev.flaschengeist.plugin" # You have to override + id = "dev.flaschengeist.plugin" # You have to override name = "plugin" # You have to override models = None # You have to override diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 3ceb6e0..846dc5d 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -96,12 +96,14 @@ class AuthLDAP(AuthPlugin): display_name=user.display_name, base_dn=self.base_dn, ) - attributes.update({ - "sn": user.lastname, - "givenName": user.firstname, - "uid": user.userid, - "userPassword": self.__hash(password), - }) + attributes.update( + { + "sn": user.lastname, + "givenName": user.firstname, + "uid": user.userid, + "userPassword": self.__hash(password), + } + ) ldap_conn.add(dn, self.object_classes, attributes) self._set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): @@ -145,7 +147,7 @@ class AuthLDAP(AuthPlugin): if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0: avatar = _Avatar() avatar.mimetype = "image/jpeg" - avatar.binary = bytearray(r['jpegPhoto'][0]) + avatar.binary = bytearray(r["jpegPhoto"][0]) return avatar else: raise NotFound diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index c8de367..3b2b8f7 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -19,7 +19,13 @@ class AuthPlain(AuthPlugin): if User.query.first() is None: logger.info("Installing admin user") role = Role(name="Superuser", permissions=Permission.query.all()) - admin = User(userid="admin", firstname="Admin", lastname="Admin", mail="", roles_=[role]) + admin = User( + userid="admin", + firstname="Admin", + lastname="Admin", + mail="", + roles_=[role], + ) self.modify_user(admin, None, "admin") db.session.add(admin) db.session.commit() diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 58b1152..1d6174f 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -114,7 +114,14 @@ def get_transaction(transaction_id) -> Transaction: def get_transactions( - user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True, descending=False + user, + start=None, + end=None, + limit=None, + offset=None, + show_reversal=False, + show_cancelled=True, + descending=False, ): count = None query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user)) diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py index daf308a..51fd4a7 100644 --- a/flaschengeist/plugins/events/models.py +++ b/flaschengeist/plugins/events/models.py @@ -39,7 +39,13 @@ class Service(db.Model, ModelSerializeMixin): is_backup: bool = db.Column(db.Boolean, default=False) value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) - _job_id = db.Column("job_id", Serial, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True) + _job_id = db.Column( + "job_id", + Serial, + db.ForeignKey(f"{_table_prefix_}job.id"), + nullable=False, + primary_key=True, + ) _user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False, primary_key=True) user_: User = db.relationship("User") @@ -83,11 +89,17 @@ class Event(db.Model, ModelSerializeMixin): type: Union[EventType, int] = db.relationship("EventType") is_template: bool = db.Column(db.Boolean, default=False) jobs: list[Job] = db.relationship( - "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" + "Job", + back_populates="event_", + cascade="all,delete,delete-orphan", + order_by="[Job.start, Job.end]", ) # Protected for internal use _type_id = db.Column( - "type_id", Serial, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False + "type_id", + Serial, + db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), + nullable=False, ) diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 55b1152..630766d 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -132,7 +132,11 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin): _prices: list[DrinkPrice] = db.relationship( DrinkPrice, back_populates="_volume", cascade="all,delete,delete-orphan" ) - ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id, cascade="all,delete,delete-orphan") + ingredients: list[Ingredient] = db.relationship( + "Ingredient", + foreign_keys=Ingredient.volume_id, + cascade="all,delete,delete-orphan", + ) def __repr__(self): return f"DrinkPriceVolume({self.id},{self.drink_id},{self.volume},{self.prices})" diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index d42085a..bd89518 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -5,11 +5,21 @@ from flaschengeist import logger from flaschengeist.database import db from flaschengeist.utils.decorators import extract_session -from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient +from .models import ( + Drink, + DrinkPrice, + Ingredient, + Tag, + DrinkType, + DrinkPriceVolume, + DrinkIngredient, + ExtraIngredient, +) from .permissions import EDIT_VOLUME, EDIT_PRICE, EDIT_INGREDIENTS_DRINK import flaschengeist.controller.imageController as image_controller + def update(): db.session.commit() @@ -130,7 +140,14 @@ def _create_public_drink(drink): def get_drinks( - name=None, public=False, limit=None, offset=None, search_name=None, search_key=None, ingredient=False, receipt=None + name=None, + public=False, + limit=None, + offset=None, + search_name=None, + search_key=None, + ingredient=False, + receipt=None, ): count = None if name: @@ -176,7 +193,13 @@ def get_drinks( def get_pricelist( - public=False, limit=None, offset=None, search_name=None, search_key=None, sortBy=None, descending=False + public=False, + limit=None, + offset=None, + search_name=None, + search_key=None, + sortBy=None, + descending=False, ): count = None query = DrinkPrice.query @@ -300,7 +323,7 @@ def update_drink(identifier, data): else: drink = get_drink(identifier) for key, value in data.items(): - if hasattr(drink, key) and key != 'has_image': + if hasattr(drink, key) and key != "has_image": setattr(drink, key, value if value != "" else None) if drink_type: diff --git a/setup.py b/setup.py index d8ccc0f..6d80097 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,16 @@ class DocsCommand(Command): def run(self): """Run command.""" - command = ["python", "-m", "pdoc", "--skip-errors", "--html", "--output-dir", self.output, "flaschengeist"] + command = [ + "python", + "-m", + "pdoc", + "--skip-errors", + "--html", + "--output-dir", + self.output, + "flaschengeist", + ] self.announce( "Running command: %s" % str(command), ) diff --git a/tests/conftest.py b/tests/conftest.py index b99d8d6..5290fd8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,13 @@ with open(os.path.join(os.path.dirname(__file__), "data.sql"), "r") as f: @pytest.fixture def app(): db_fd, db_path = tempfile.mkstemp() - app = create_app({"TESTING": True, "DATABASE": {"file_path": f"/{db_path}"}, "LOGGING": {"level": "DEBUG"}}) + app = create_app( + { + "TESTING": True, + "DATABASE": {"file_path": f"/{db_path}"}, + "LOGGING": {"level": "DEBUG"}, + } + ) with app.app_context(): install_all() engine = database.db.engine From 7cb31bf60e8dea7f0e3ae1925ed9f181372b3b0e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Nov 2021 12:57:18 +0100 Subject: [PATCH 302/446] gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 886770a..aef08d3 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,8 @@ dmypy.json .vscode/ *.log +data/ + # config flaschengeist/flaschengeist.toml From 48933cdf5f81887aab9187c90ed12b31008ecc0a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Nov 2021 23:02:03 +0100 Subject: [PATCH 303/446] [core] Minor fixes --- flaschengeist/config.py | 2 ++ flaschengeist/logging.toml | 2 +- flaschengeist/models/__init__.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 48c71b2..524ed6e 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -68,6 +68,8 @@ def configure_app(app, test_config=None): global config read_configuration(test_config) + configure_logger() + # Always enable this builtin plugins! update_dict( config, diff --git a/flaschengeist/logging.toml b/flaschengeist/logging.toml index 3af9f99..c9a5647 100644 --- a/flaschengeist/logging.toml +++ b/flaschengeist/logging.toml @@ -6,7 +6,7 @@ disable_existing_loggers = false [formatters] [formatters.simple] - format = "%(asctime)s - %(name)s (%(levelname)s) - %(message)s" + format = "%(asctime)s - %(levelname)s - %(message)s" [formatters.extended] format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s" diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index acffc1a..339a598 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -61,6 +61,7 @@ class UtcDateTime(TypeDecorator): aware value, even with SQLite or MySQL. """ + cache_ok = True impl = DateTime(timezone=True) @staticmethod From d00c603697896c9aa3f74c9c95f8bc5b86cbbc76 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Nov 2021 23:06:03 +0100 Subject: [PATCH 304/446] [events] Allow server side pageination --- .../plugins/events/event_controller.py | 48 ++++++++++++++----- flaschengeist/plugins/events/routes.py | 34 +++++++++---- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index fb50655..b5f7410 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -124,7 +124,14 @@ def get_templates(): return Event.query.filter(Event.is_template == True).all() -def get_events(start: Optional[datetime] = None, end=None, with_backup=False): +def get_events( + start: Optional[datetime] = None, + end: Optional[datetime] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + descending: Optional[bool] = False, + with_backup=False, +): """Query events which start from begin until end Args: start (datetime): Earliest start @@ -138,6 +145,14 @@ def get_events(start: Optional[datetime] = None, end=None, with_backup=False): query = query.filter(start <= Event.start) if end is not None: query = query.filter(Event.start < end) + if descending: + query = query.order_by(Event.start.desc()) + else: + query = query.order_by(Event.start) + if limit is not None: + query = query.limit(limit) + if offset is not None and offset > 0: + query = query.offset(offset) events = query.all() if not with_backup: for event in events: @@ -188,7 +203,13 @@ def get_job(job_slot_id, event_id): def add_job(event, job_type, required_services, start, end=None, comment=None): - job = Job(required_services=required_services, type=job_type, start=start, end=end, comment=comment) + job = Job( + required_services=required_services, + type=job_type, + start=start, + end=end, + comment=comment, + ) event.jobs.append(job) update() return job @@ -198,7 +219,10 @@ def update(): try: db.session.commit() except IntegrityError: - logger.debug("Error, looks like a Job with that type already exists on an event", exc_info=True) + logger.debug( + "Error, looks like a Job with that type already exists on an event", + exc_info=True, + ) raise BadRequest() @@ -222,21 +246,19 @@ def assign_job(job: Job, user, value): def unassign_job(job: Job = None, user=None, service=None, notify=False): if service is None: - assert(job is not None and user is not None) + assert job is not None and user is not None service = Service.query.get((job.id, user.id_)) else: user = service.user_ if not service: raise BadRequest - + event_id = service.job_.event_id_ - + db.session.delete(service) db.session.commit() if notify: - EventPlugin.plugin.notify( - user, "Your assignmet was cancelled", {"event_id": event_id} - ) + EventPlugin.plugin.notify(user, "Your assignmet was cancelled", {"event_id": event_id}) @scheduled @@ -249,7 +271,9 @@ def assign_backups(): for service in services: if service.job_.start <= now or service.job_.is_full(): EventPlugin.plugin.notify( - service.user_, "Your backup assignment was cancelled.", {"event_id": service.job_.event_id_} + service.user_, + "Your backup assignment was cancelled.", + {"event_id": service.job_.event_id_}, ) logger.debug(f"Service is outdated or full, removing. {service.serialize()}") db.session.delete(service) @@ -257,6 +281,8 @@ def assign_backups(): service.is_backup = False logger.debug(f"Service not full, assigning backup. {service.serialize()}") EventPlugin.plugin.notify( - service.user_, "Your backup assignment was accepted.", {"event_id": service.job_.event_id_} + service.user_, + "Your backup assignment was accepted.", + {"event_id": service.job_.event_id_}, ) db.session.commit() diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py index fce9416..6e0ec96 100644 --- a/flaschengeist/plugins/events/routes.py +++ b/flaschengeist/plugins/events/routes.py @@ -169,7 +169,8 @@ def get_event(event_id, current_session): JSON encoded event object """ event = event_controller.get_event( - event_id, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP) + event_id, + with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), ) return jsonify(event) @@ -177,17 +178,21 @@ def get_event(event_id, current_session): @EventPlugin.blueprint.route("/events", methods=["GET"]) @login_required() def get_filtered_events(current_session): - begin = request.args.get("from") - if begin is not None: - begin = from_iso_format(begin) - end = request.args.get("to") - if end is not None: - end = from_iso_format(end) + begin = request.args.get("from", type=from_iso_format) + end = request.args.get("to", type=from_iso_format) + limit = request.args.get("limit", type=int) + offset = request.args.get("offset", type=int) + descending = "descending" in request.args if begin is None and end is None: begin = datetime.now() return jsonify( event_controller.get_events( - begin, end, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP) + start=begin, + end=end, + limit=limit, + offset=offset, + descending=descending, + with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), ) ) @@ -222,7 +227,9 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m end = datetime(year=year, month=month + 1, day=1, tzinfo=timezone.utc) events = event_controller.get_events( - begin, end, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP) + begin, + end, + with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), ) return jsonify(events) except ValueError: @@ -243,7 +250,14 @@ def _add_job(event, data): raise BadRequest("Missing or invalid POST parameter") job_type = event_controller.get_job_type(job_type) - event_controller.add_job(event, job_type, required_services, start, end, comment=data.get("comment", None)) + event_controller.add_job( + event, + job_type, + required_services, + start, + end, + comment=data.get("comment", None), + ) @EventPlugin.blueprint.route("/events", methods=["POST"]) From 795475fe155ca511bcf51d48365a136f4e838c63 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 19 Nov 2021 13:32:54 +0100 Subject: [PATCH 305/446] [pricelist] Delete old images --- flaschengeist/plugins/pricelist/pricelist_controller.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index bd89518..872b412 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -525,7 +525,7 @@ def delete_extra_ingredient(identifier): def save_drink_picture(identifier, file): - drink = get_drink(identifier) + drink = delete_drink_picture(identifier) drink.image_ = image_controller.upload_image(file) db.session.commit() return drink @@ -533,6 +533,8 @@ def save_drink_picture(identifier, file): def delete_drink_picture(identifier): drink = get_drink(identifier) - drink.image = None + if drink.image_: + db.session.delete(drink.image_) + drink.image_ = None db.session.commit() return drink From f7f27311dba52ad50f3011fa1c6149fbbc2701de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 19 Nov 2021 22:04:33 +0100 Subject: [PATCH 306/446] [image] bigger filename size --- flaschengeist/models/image.py | 2 +- flaschengeist/plugins/pricelist/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/flaschengeist/models/image.py b/flaschengeist/models/image.py index 7cf9967..4c963e7 100644 --- a/flaschengeist/models/image.py +++ b/flaschengeist/models/image.py @@ -10,7 +10,7 @@ from ..database import db class Image(db.Model, ModelSerializeMixin): __tablename__ = "image" id: int = db.Column("id", Serial, primary_key=True) - filename_: str = db.Column(db.String(30), nullable=False) + filename_: str = db.Column(db.String(127), nullable=False) mimetype_: str = db.Column(db.String(30), nullable=False) thumbnail_: str = db.Column(db.String(127)) path_: str = db.Column(db.String(127)) diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 9adace7..20bb30a 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -8,7 +8,7 @@ from flaschengeist import logger from flaschengeist.controller import userController from flaschengeist.controller.imageController import send_image, send_thumbnail from flaschengeist.plugins import Plugin -from flaschengeist.utils.decorators import login_required, extract_session +from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.utils.HTTP import no_content from . import models @@ -738,6 +738,7 @@ def set_picture(identifier, current_session): @PriceListPlugin.blueprint.route("/drinks//picture", methods=["GET"]) +#@headers({"Cache-Control": "private, must-revalidate"}) def _get_picture(identifier): """Get Picture From 26d63b7c7d2dcff099c008c6c97041cdc8913361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 20 Nov 2021 22:58:05 +0100 Subject: [PATCH 307/446] [users][auth_ldap][auth_plain] delete avatar --- flaschengeist/controller/userController.py | 5 +++++ flaschengeist/models/user.py | 4 ++++ flaschengeist/plugins/__init__.py | 11 +++++++++++ flaschengeist/plugins/auth_ldap/__init__.py | 7 +++++++ flaschengeist/plugins/auth_plain/__init__.py | 3 +++ flaschengeist/plugins/users/__init__.py | 10 ++++++++++ 6 files changed, 40 insertions(+) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 2305f9a..7427ac1 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -207,6 +207,11 @@ def save_avatar(user, avatar): db.session.commit() +def delete_avatar(user): + current_app.config["FG_AUTH_BACKEND"].delete_avatar(user) + db.session.commit() + + def persist(user=None): if user: db.session.add(user) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 16b901c..2f3ac67 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -94,6 +94,10 @@ class User(db.Model, ModelSerializeMixin): return self._attributes[name].value return default + def delete_attribute(self, name): + if name in self._attributes: + self._attributes.pop(name) + def get_permissions(self): return ["user"] + [permission.name for role in self.roles_ for permission in role.permissions] diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index da5bc84..1c42a71 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -191,3 +191,14 @@ class AuthPlugin(Plugin): MethodNotAllowed: If not supported by Backend """ raise MethodNotAllowed + + def delete_avatar(self, user): + """Delete the avatar for given user (if supported by auth backend) + + Args: + user: Uset to delete the avatar for + + Raises: + MethodNotAllowed: If not supported by Backend + """ + raise MethodNotAllowed diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 846dc5d..1cfd5aa 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -178,6 +178,13 @@ class AuthLDAP(AuthPlugin): ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) + def delete_avatar(self, user): + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") + dn = user.get_attribute("DN") + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) + ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [])]}) + def __find(self, userid, mail=None): """Find attributes of an user by uid or mail in LDAP""" con = self.ldap.connection diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 3b2b8f7..a9f24a5 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -64,6 +64,9 @@ class AuthPlain(AuthPlugin): def set_avatar(self, user, avatar): user.set_attribute("avatar", avatar) + def delete_avatar(self, user): + user.delete_attribute("avatar") + @staticmethod def _hash_password(password): salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii") diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 9b41070..d0f929e 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -144,6 +144,16 @@ def set_avatar(userid, current_session): raise BadRequest +@UsersPlugin.blueprint.route("/users//avatar", methods=["DELETE"]) +@login_required() +def delete_avatar(userid, current_session): + user = userController.get_user(userid) + if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.EDIT): + raise Forbidden + userController.delete_avatar(user) + return "", NO_CONTENT + + @UsersPlugin.blueprint.route("/users/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) def delete_user(userid, current_session): From 0be31d0bfed1d65f1e5e6b4f02fa2c2009ac1fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 21 Nov 2021 15:11:37 +0100 Subject: [PATCH 308/446] [auth_ldap] sync ldap_users to Database --- run_flaschengeist | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/run_flaschengeist b/run_flaschengeist index e11271e..55fabad 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -167,6 +167,28 @@ def export(arguments): gen.write() +def ldap_sync(arguments): + from flaschengeist.app import create_app + from flaschengeist.controller import userController + from flaschengeist.plugins.auth_ldap import AuthLDAP + from ldap3 import SUBTREE + + app = create_app() + with app.app_context(): + auth_ldap: AuthLDAP = app.config.get("FG_PLUGINS").get("auth_ldap") + if auth_ldap: + conn = auth_ldap.ldap.connection + if not conn: + conn = auth_ldap.ldap.connect(auth_ldap.root_dn, auth_ldap.root_secret) + conn.search(auth_ldap.search_dn, "(uid=*)", SUBTREE, attributes=["uid", "givenName", "sn", "mail"]) + ldap_users_response = conn.response + for ldap_user in ldap_users_response: + uid = ldap_user["attributes"]["uid"][0] + userController.find_user(uid) + exit() + raise Exception("auth_ldap not found") + + if __name__ == "__main__": # create the top-level parser parser = argparse.ArgumentParser() @@ -192,5 +214,8 @@ if __name__ == "__main__": ) parser_export.add_argument("--plugins", help="Also export plugins (none means all)", nargs="*") + parser_ldap_sync = subparsers.add_parser("ldap_sync", help="synch ldap-users with database") + parser_ldap_sync.set_defaults(func=ldap_sync) + args = parser.parse_args() args.func(args) From d75574e07827f68fb8c89ac4905e09fe88bee70c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 21 Nov 2021 15:30:49 +0100 Subject: [PATCH 309/446] [auth_ldap] fix create Users --- flaschengeist/plugins/auth_ldap/__init__.py | 22 +++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 1cfd5aa..a872ce8 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -41,6 +41,7 @@ class AuthLDAP(AuthPlugin): self.password_hash = config.get("password_hash", "SSHA").upper() self.object_classes = config.get("object_classes", ["inetOrgPerson"]) self.user_attributes: dict = config.get("user_attributes", {}) + self.dn_template = config.get("dn_template") # TODO: might not be set if modify is called self.root_dn = config.get("root_dn", None) @@ -87,25 +88,34 @@ class AuthLDAP(AuthPlugin): key=lambda i: i["attributes"]["uidNumber"], reverse=True, ) - attributes = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"] + attributes["uidNumber"] = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"] dn = self.dn_template.format( - firstname=user.firstname, - lastname=user.lastname, - userid=user.userid, - mail=user.mail, - display_name=user.display_name, + user=user, base_dn=self.base_dn, ) + if "default_gid" in attributes: + default_gid = attributes.pop("default_gid") + attributes["gidNumber"] = default_gid + if "homeDirectory" in attributes: + attributes["homeDirectory"] = attributes.get("homeDirectory").format( + firstname=user.firstname, + lastname=user.lastname, + userid=user.userid, + mail=user.mail, + display_name=user.display_name, + ) attributes.update( { "sn": user.lastname, "givenName": user.firstname, "uid": user.userid, "userPassword": self.__hash(password), + "mail": user.mail, } ) ldap_conn.add(dn, self.object_classes, attributes) self._set_roles(user) + self.update_user(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest From 51a3a8dfc84da3f25b31afdf53fa8a1739778546 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 21 Nov 2021 17:52:06 +0100 Subject: [PATCH 310/446] [events] Respect backup assignment --- flaschengeist/plugins/events/event_controller.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index b5f7410..6a3f9f2 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -195,11 +195,14 @@ def create_event(event_type, start, end=None, jobs=[], is_template=None, name=No raise BadRequest -def get_job(job_slot_id, event_id): - js = Job.query.filter(Job.id == job_slot_id).filter(Job.event_id_ == event_id).one_or_none() - if js is None: +def get_job(job_id, event_id=None) -> Job: + query = Job.query.filter(Job.id == job_id) + if event_id is not None: + query = query.filter(Job.event_id_ == event_id) + job = query.one_or_none() + if job is None: raise NotFound - return js + return job def add_job(event, job_type, required_services, start, end=None, comment=None): @@ -233,13 +236,13 @@ def delete_job(job: Job): db.session.commit() -def assign_job(job: Job, user, value): +def assign_job(job: Job, user, value, is_backup=False): assert value > 0 service = Service.query.get((job.id, user.id_)) if service: service.value = value else: - service = Service(user_=user, value=value, job_=job) + service = Service(user_=user, value=value, is_backup=is_backup, job_=job) db.session.add(service) db.session.commit() From 04d5b1e83a76ebab08a7b1a9b5299abc732e4da0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 21 Nov 2021 17:58:28 +0100 Subject: [PATCH 311/446] [events] Allow locking events --- flaschengeist/plugins/events/models.py | 1 + flaschengeist/plugins/events/permissions.py | 3 + flaschengeist/plugins/events/routes.py | 155 ++++++++++++++------ 3 files changed, 114 insertions(+), 45 deletions(-) diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py index 51fd4a7..d1dee02 100644 --- a/flaschengeist/plugins/events/models.py +++ b/flaschengeist/plugins/events/models.py @@ -65,6 +65,7 @@ class Job(db.Model, ModelSerializeMixin): end: Optional[datetime] = db.Column(UtcDateTime) type: Union[JobType, int] = db.relationship("JobType") comment: Optional[str] = db.Column(db.String(256)) + locked: bool = db.Column(db.Boolean()) services: list[Service] = db.relationship("Service", back_populates="job_") required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) diff --git a/flaschengeist/plugins/events/permissions.py b/flaschengeist/plugins/events/permissions.py index 459967c..3eb81b6 100644 --- a/flaschengeist/plugins/events/permissions.py +++ b/flaschengeist/plugins/events/permissions.py @@ -22,4 +22,7 @@ ASSIGN_OTHER = "events_assign_other" SEE_BACKUP = "events_see_backup" """Can see users assigned as backup""" +LOCK_JOBS = "events_lock_jobs" +"""Can lock jobs, no further services can be assigned or unassigned""" + permissions = [value for key, value in globals().items() if not key.startswith("_")] diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py index 6e0ec96..780edcb 100644 --- a/flaschengeist/plugins/events/routes.py +++ b/flaschengeist/plugins/events/routes.py @@ -1,9 +1,12 @@ from datetime import datetime, timedelta, timezone from http.client import NO_CONTENT +from re import template from flask import request, jsonify +from sqlalchemy import exc from werkzeug.exceptions import BadRequest, NotFound, Forbidden from flaschengeist.models.session import Session +from flaschengeist.plugins.events.models import Job from flaschengeist.utils.decorators import login_required from flaschengeist.utils.datetime import from_iso_format from flaschengeist.controller import userController @@ -12,6 +15,21 @@ from . import event_controller, permissions, EventPlugin from ...utils.HTTP import no_content + +def dict_get(self, key, default=None, type=None): + """Same as .get from MultiDict""" + try: + rv = self[key] + except KeyError: + return default + if type is not None: + try: + rv = type(rv) + except ValueError: + rv = default + return rv + + @EventPlugin.blueprint.route("/events/templates", methods=["GET"]) @login_required() def get_templates(current_session): @@ -239,9 +257,7 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m def _add_job(event, data): try: start = from_iso_format(data["start"]) - end = None - if "end" in data: - end = from_iso_format(data["end"]) + end = dict_get(data, "end", None, type=from_iso_format) required_services = data["required_services"] job_type = data["type"] if isinstance(job_type, dict): @@ -256,7 +272,7 @@ def _add_job(event, data): required_services, start, end, - comment=data.get("comment", None), + comment=dict_get(data, "comment", None, str), ) @@ -276,11 +292,9 @@ def create_event(current_session): JSON encoded Event object or HTTP-error """ data = request.get_json() - end = data.get("end", None) try: start = from_iso_format(data["start"]) - if end is not None: - end = from_iso_format(end) + end = dict_get(data, "end", None, type=from_iso_format) data_type = data["type"] if isinstance(data_type, dict): data_type = data["type"]["id"] @@ -293,10 +307,10 @@ def create_event(current_session): event = event_controller.create_event( start=start, end=end, - name=data.get("name", None), - is_template=data.get("is_template", None), + name=dict_get(data, "name", None), + is_template=dict_get(data, "is_template", None), event_type=event_type, - description=data.get("description", None), + description=dict_get(data, "description", None), ) if "jobs" in data: for job in data["jobs"]: @@ -323,15 +337,14 @@ def modify_event(event_id, current_session): """ event = event_controller.get_event(event_id) data = request.get_json() - if "start" in data: - event.start = from_iso_format(data["start"]) - if "end" in data: - event.end = from_iso_format(data["end"]) - if "description" in data: - event.description = data["description"] + event.start = dict_get(data, "start", event.start, type=from_iso_format) + event.end = dict_get(data, "end", event.end, type=from_iso_format) + event.name = dict_get(data, "name", event.name, type=str) + event.description = dict_get(data, "description", event.description, type=str) if "type" in data: event_type = event_controller.get_event_type(data["type"]) event.type = event_type + event_controller.update() return jsonify(event) @@ -390,19 +403,19 @@ def delete_job(event_id, job_id, current_session): Returns: HTTP-no-content or HTTP error """ - job_slot = event_controller.get_job(job_id, event_id) - event_controller.delete_job(job_slot) + job = event_controller.get_job(job_id, event_id) + event_controller.delete_job(job) return no_content() @EventPlugin.blueprint.route("/events//jobs/", methods=["PUT"]) @login_required() def update_job(event_id, job_id, current_session: Session): - """Edit Job or assign user to the Job + """Edit Job Route: ``/events//jobs/`` | Method: ``PUT`` - POST-data: See TS interface for Job or ``{user: {userid: string, value: number}}`` + POST-data: See TS interface for Job Args: event_id: Identifier of the event @@ -412,37 +425,89 @@ def update_job(event_id, job_id, current_session: Session): Returns: JSON encoded Job object or HTTP-error """ - job = event_controller.get_job(job_id, event_id) - + if not current_session.user_.has_permission(permissions.EDIT): + raise Forbidden + data = request.get_json() if not data: raise BadRequest - if ("user" not in data or len(data) > 1) and not current_session.user_.has_permission(permissions.EDIT): - raise Forbidden - - if "user" in data: - try: - user = userController.get_user(data["user"]["userid"]) - value = data["user"]["value"] - if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or ( - user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) - ): - raise Forbidden - if value > 0: - event_controller.assign_job(job, user, value) - else: - event_controller.unassign_job(job, user, notify=user != current_session.user_) - except (KeyError, ValueError): - raise BadRequest - - if "required_services" in data: - job.required_services = data["required_services"] - if "type" in data: - job.type = event_controller.get_job_type(data["type"]) - event_controller.update() + job = event_controller.get_job(job_id, event_id) + try: + if "type" in data: + job.type = event_controller.get_job_type(data["type"]) + job.start = from_iso_format(data.get("start", job.start)) + job.end = from_iso_format(data.get("end", job.end)) + job.comment = str(data.get("comment", job.comment)) + job.locked = bool(data.get("locked", job.locked)) + job.required_services = float(data.get("required_services", job.required_services)) + event_controller.update() + except NotFound: + raise BadRequest("Invalid JobType") + except ValueError: + raise BadRequest("Invalid POST data") return jsonify(job) +@EventPlugin.blueprint.route("/events/jobs//assign", methods=["POST"]) +@login_required() +def assign_job(job_id, current_session: Session): + """Assign / unassign user to the Job + + Route: ``/events/jobs//assign`` | Method: ``POST`` + + POST-data: a Service object, see TS interface for Service + + Args: + job_id: Identifier of the Job + current_session: Session sent with Authorization Header + + Returns: + HTTP-No-Content or HTTP-error + """ + data = request.get_json() + job = event_controller.get_job(job_id) + try: + user = userController.get_user(data["userid"]) + value = data["value"] + if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or ( + user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) + ): + raise Forbidden + if value > 0: + event_controller.assign_job(job, user, value, data.get("is_backup", False)) + else: + event_controller.unassign_job(job, user, notify=user != current_session.user_) + except (TypeError, KeyError, ValueError): + raise BadRequest + return no_content() + + +@EventPlugin.blueprint.route("/events/jobs//lock", methods=["POST"]) +@login_required(permissions.LOCK_JOBS) +def lock_job(job_id, current_session: Session): + """Lock / unlock the Job + + Route: ``/events/jobs//lock`` | Method: ``POST`` + + POST-data: ``{locked: boolean}`` + + Args: + job_id: Identifier of the Job + current_session: Session sent with Authorization Header + + Returns: + HTTP-No-Content or HTTP-error + """ + data = request.get_json() + job = event_controller.get_job(job_id) + try: + locked = bool(userController.get_user(data["locked"])) + job.locked = locked + event_controller.update() + except (TypeError, KeyError, ValueError): + raise BadRequest + return no_content() + # TODO: JobTransfer From 7cac7083095bad1af86f6847f90738e3ddbdaba1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 22 Nov 2021 15:31:53 +0100 Subject: [PATCH 314/446] [clean] PEP8 cleanup --- flaschengeist/plugins/events/routes.py | 8 ++++---- flaschengeist/plugins/pricelist/__init__.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py index 780edcb..2771c72 100644 --- a/flaschengeist/plugins/events/routes.py +++ b/flaschengeist/plugins/events/routes.py @@ -15,7 +15,6 @@ from . import event_controller, permissions, EventPlugin from ...utils.HTTP import no_content - def dict_get(self, key, default=None, type=None): """Same as .get from MultiDict""" try: @@ -340,11 +339,11 @@ def modify_event(event_id, current_session): event.start = dict_get(data, "start", event.start, type=from_iso_format) event.end = dict_get(data, "end", event.end, type=from_iso_format) event.name = dict_get(data, "name", event.name, type=str) - event.description = dict_get(data, "description", event.description, type=str) + event.description = dict_get(data, "description", event.description, type=str) if "type" in data: event_type = event_controller.get_event_type(data["type"]) event.type = event_type - + event_controller.update() return jsonify(event) @@ -427,7 +426,7 @@ def update_job(event_id, job_id, current_session: Session): """ if not current_session.user_.has_permission(permissions.EDIT): raise Forbidden - + data = request.get_json() if not data: raise BadRequest @@ -510,4 +509,5 @@ def lock_job(job_id, current_session: Session): raise BadRequest return no_content() + # TODO: JobTransfer diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 20bb30a..168136e 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -738,7 +738,7 @@ def set_picture(identifier, current_session): @PriceListPlugin.blueprint.route("/drinks//picture", methods=["GET"]) -#@headers({"Cache-Control": "private, must-revalidate"}) +# @headers({"Cache-Control": "private, must-revalidate"}) def _get_picture(identifier): """Get Picture From 471258c88693898a103e32c94c5fabf63650cbf3 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 22 Nov 2021 15:33:18 +0100 Subject: [PATCH 315/446] [events] Default jobs to unlocked state --- flaschengeist/plugins/events/models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py index d1dee02..2cbfd6e 100644 --- a/flaschengeist/plugins/events/models.py +++ b/flaschengeist/plugins/events/models.py @@ -65,8 +65,10 @@ class Job(db.Model, ModelSerializeMixin): end: Optional[datetime] = db.Column(UtcDateTime) type: Union[JobType, int] = db.relationship("JobType") comment: Optional[str] = db.Column(db.String(256)) - locked: bool = db.Column(db.Boolean()) - services: list[Service] = db.relationship("Service", back_populates="job_") + locked: bool = db.Column(db.Boolean(), default=False, nullable=False) + services: list[Service] = db.relationship( + "Service", back_populates="job_", cascade="save-update, merge, delete, delete-orphan" + ) required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) event_ = db.relationship("Event", back_populates="jobs") From eb04d305ab9806d0c96b3e5594bc4fe184658d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 21 Nov 2021 15:34:19 +0100 Subject: [PATCH 316/446] [auth_ldap] fix add displayName when create --- flaschengeist/plugins/auth_ldap/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index a872ce8..ebe84c3 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -113,6 +113,8 @@ class AuthLDAP(AuthPlugin): "mail": user.mail, } ) + if user.display_name: + attributes.update( {"displayName": user.display_name}) ldap_conn.add(dn, self.object_classes, attributes) self._set_roles(user) self.update_user(user) From b4086108e45bfe051ba8b6af22e67e1c0bc5d016 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 24 Nov 2021 21:49:14 +0100 Subject: [PATCH 317/446] [events] Can invite, accept and reject invitations --- .../plugins/events/event_controller.py | 69 ++++++++++++++++++- flaschengeist/plugins/events/models.py | 27 +++++--- flaschengeist/plugins/events/routes.py | 69 ++++++++++++++++++- 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 6a3f9f2..4cd2d03 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -1,16 +1,32 @@ from datetime import datetime, timedelta, timezone +from enum import IntEnum from typing import Optional from werkzeug.exceptions import BadRequest, Conflict, NotFound from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.util import was_deleted from flaschengeist import logger from flaschengeist.database import db from flaschengeist.plugins.events import EventPlugin -from flaschengeist.plugins.events.models import EventType, Event, Job, JobType, Service +from flaschengeist.plugins.events.models import EventType, Event, Invitation, Job, JobType, Service from flaschengeist.utils.scheduler import scheduled +# STUB +def _(x): + return x + + +class NotifyType(IntEnum): + # Invitations 0x00..0x0F + INVITE = 0x01 + TRANSFER = 0x02 + # Invitation responsed 0x10..0x1F + INVITATION_ACCEPTED = 0x10 + INVITATION_REJECTED = 0x11 + + def update(): db.session.commit() @@ -232,6 +248,8 @@ def update(): def delete_job(job: Job): for service in job.services: unassign_job(service=service, notify=True) + for invitation in job.invitations_: + respond_invitation(invitation, False) db.session.delete(job) db.session.commit() @@ -242,8 +260,7 @@ def assign_job(job: Job, user, value, is_backup=False): if service: service.value = value else: - service = Service(user_=user, value=value, is_backup=is_backup, job_=job) - db.session.add(service) + job.services.append(Service(user_=user, value=value, is_backup=is_backup, job_=job)) db.session.commit() @@ -264,6 +281,52 @@ def unassign_job(job: Job = None, user=None, service=None, notify=False): EventPlugin.plugin.notify(user, "Your assignmet was cancelled", {"event_id": event_id}) +def invite(job: Job, invitee, inviter, transferee=None): + inv = Invitation(job_=job, inviter_=inviter, invitee_=invitee, transferee_=transferee) + db.session.add(inv) + update() + if transferee is None: + EventPlugin.plugin.notify(invitee, _("Job invitation"), {"type": NotifyType.INVITE, "invitation": inv.id}) + else: + EventPlugin.plugin.notify(invitee, _("Job transfer"), {"type": NotifyType.TRANSFER, "invitation": inv.id}) + return inv + + +def get_invitation(id: int): + inv: Invitation = Invitation.query.get(id) + if inv is None: + raise NotFound + return inv + + +def cancel_invitation(inv: Invitation): + db.session.delete(inv) + db.session.commit() + + +def respond_invitation(invite: Invitation, accepted=True): + inviter = invite.inviter_ + job = invite.job_ + + db.session.delete(invite) + db.session.commit() + if not was_deleted(invite): + raise Conflict + + if not accepted: + EventPlugin.plugin.notify(inviter, _("Invitation rejected"), {"type": NotifyType.INVITATION_REJECTED, "event": job.event_id_, "job": invite.job_id, "invitee": invite.invitee_id}) + else: + if invite.transferee_id is None: + assign_job(job, invite.invitee_, 1) + else: + service = filter(lambda s: s.userid == invite.transferee_id, job.services) + if not service: + raise Conflict + unassign_job(job, invite.transferee_, service[0], True) + assign_job(job, invite.invitee_, service[0].value) + EventPlugin.plugin.notify(inviter, _("Invitation accepted"), {"type": NotifyType.INVITATION_ACCEPTED, "event": job.event_id_, "job": invite.job_id, "invitee": invite.invitee_id}) + + @scheduled def assign_backups(): logger.debug("Notifications") diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py index 2cbfd6e..36fd3f9 100644 --- a/flaschengeist/plugins/events/models.py +++ b/flaschengeist/plugins/events/models.py @@ -73,6 +73,7 @@ class Job(db.Model, ModelSerializeMixin): event_ = db.relationship("Event", back_populates="jobs") event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) + invitations_ = db.relationship("Invitation", cascade="all,delete,delete-orphan", back_populates="job_") __table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),) @@ -106,25 +107,33 @@ class Event(db.Model, ModelSerializeMixin): ) -class Invite(db.Model, ModelSerializeMixin): - __tablename__ = _table_prefix_ + "invite" +class Invitation(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "invitation" id: int = db.Column(Serial, primary_key=True) job_id: int = db.Column(Serial, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False) # Dummy properties for API export - invitee_id: str = None - sender_id: str = None + invitee_id: str = None # User who was invited to take over + inviter_id: str = None # User who invited the invitee + transferee_id: Optional[str] = None # In case of a transfer: The user who is transfered out of the job # Not exported properties for backend use - invitee_: User = db.relationship("User", foreign_keys="Invite._invitee_id") - sender_: User = db.relationship("User", foreign_keys="Invite._sender_id") + job_: Job = db.relationship(Job, foreign_keys="Invitation.job_id") + invitee_: User = db.relationship("User", foreign_keys="Invitation._invitee_id") + inviter_: User = db.relationship("User", foreign_keys="Invitation._inviter_id") + transferee_: User = db.relationship("User", foreign_keys="Invitation._transferee_id") # Protected properties needed for internal use _invitee_id = db.Column("invitee_id", Serial, db.ForeignKey("user.id"), nullable=False) - _sender_id = db.Column("sender_id", Serial, db.ForeignKey("user.id"), nullable=False) + _inviter_id = db.Column("inviter_id", Serial, db.ForeignKey("user.id"), nullable=False) + _transferee_id = db.Column("transferee_id", Serial, db.ForeignKey("user.id")) @property def invitee_id(self): return self.invitee_.userid @property - def sender_id(self): - return self.sender_.userid + def inviter_id(self): + return self.inviter_.userid + + @property + def transferee_id(self): + return self.transferee_.userid if self.transferee_ else None diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py index 2771c72..2fb9b60 100644 --- a/flaschengeist/plugins/events/routes.py +++ b/flaschengeist/plugins/events/routes.py @@ -463,7 +463,7 @@ def assign_job(job_id, current_session: Session): current_session: Session sent with Authorization Header Returns: - HTTP-No-Content or HTTP-error + JSON encoded Job or HTTP-error """ data = request.get_json() job = event_controller.get_job(job_id) @@ -480,7 +480,7 @@ def assign_job(job_id, current_session: Session): event_controller.unassign_job(job, user, notify=user != current_session.user_) except (TypeError, KeyError, ValueError): raise BadRequest - return no_content() + return jsonify(job) @EventPlugin.blueprint.route("/events/jobs//lock", methods=["POST"]) @@ -510,4 +510,67 @@ def lock_job(job_id, current_session: Session): return no_content() -# TODO: JobTransfer +@EventPlugin.blueprint.route("/events/invitations", methods=["POST"]) +@login_required() +def invite(current_session: Session): + """Invite an user to a job or transfer job + + Route: ``/events/invites`` | Method: ``POST`` + + POST-data: ``{job: number, invitees: string[], is_transfer?: boolean}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + List of Invitation objects or HTTP-error + """ + data = request.get_json() + transferee = data.get("transferee", None) + if ( + transferee is not None + and transferee != current_session.userid + and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) + ): + raise Forbidden + + try: + job = event_controller.get_job(data["job"]) + if not isinstance(data["invitees"], list): + raise BadRequest + return jsonify( + [ + event_controller.invite(job, invitee, current_session.user_, transferee) + for invitee in [userController.get_user(uid) for uid in data["invitees"]] + ] + ) + except (TypeError, KeyError, ValueError): + raise BadRequest + + +@EventPlugin.blueprint.route("/events/invitations/", methods=["GET"]) +@login_required() +def get_invitation(invitation_id: int, current_session: Session): + inv = event_controller.get_invitation(invitation_id) + if current_session.userid not in [inv.invitee_id, inv.inviter_id, inv.transferee_id]: + raise Forbidden + return jsonify(inv) + + +@EventPlugin.blueprint.route("/events/invitations/", methods=["DELETE", "PUT"]) +@login_required() +def respond_invitation(invitation_id: int, current_session: Session): + inv = event_controller.get_invitation(invitation_id) + if request.method == "DELETE": + if current_session.userid == inv.invitee_id: + event_controller.respond_invitation(inv, False) + elif current_session.userid == inv.inviter_id: + event_controller.cancel_invitation(inv) + else: + raise Forbidden + else: + # maybe validate data is something like ({accepted: true}) + if current_session.userid != inv.invitee_id: + raise Forbidden + event_controller.respond_invitation(inv) + return no_content() From 2634181d5ef301c736cda58ff0c72230635967bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 22 Nov 2021 15:07:16 +0100 Subject: [PATCH 319/446] [balance] add serverside pagination --- flaschengeist/controller/userController.py | 14 +- flaschengeist/models/user.py | 5 + .../plugins/balance/balance_controller.py | 130 +++++++++++++++--- flaschengeist/plugins/balance/routes.py | 16 ++- flaschengeist/plugins/users/__init__.py | 5 +- 5 files changed, 140 insertions(+), 30 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 7427ac1..7c7c237 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -118,8 +118,12 @@ def modify_user(user, password, new_password=None): messageController.send_message(messageController.Message(user, text, subject)) -def get_users(): - return User.query.all() +def get_users(userids=None): + query = User.query + if userids: + query.filter(User.userid in userids) + query = query.order_by(User.lastname.asc(), User.firstname.asc()) + return query.all() def get_user_by_role(role: Role): @@ -175,8 +179,8 @@ def register(data): allowed_keys = User().serialize().keys() values = {key: value for key, value in data.items() if key in allowed_keys} roles = values.pop("roles", []) - if "birthday" in values: - values["birthday"] = from_iso_format(values["birthday"]).date() + if "birthday" in data: + values["birthday"] = from_iso_format(data["birthday"]).date() user = User(**values) set_roles(user, roles) @@ -195,6 +199,8 @@ def register(data): ) messageController.send_message(messageController.Message(user, text, subject)) + find_user(user.userid) + return user diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 2f3ac67..095ba12 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -104,6 +104,11 @@ class User(db.Model, ModelSerializeMixin): def has_permission(self, permission): return permission in self.get_permissions() + def __repr__(self): + return ( + f"User({self.userid}, {self.firstname}, {self.lastname}, {self.mail}, {self.display_name}, {self.birthday})" + ) + class _UserAttribute(db.Model, ModelSerializeMixin): __tablename__ = "user_attribute" diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 1d6174f..9925b44 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -3,12 +3,13 @@ # English: Debit -> from account # Credit -> to account -from sqlalchemy import func +from sqlalchemy import func, case, and_ +from sqlalchemy.ext.hybrid import hybrid_property from datetime import datetime from werkzeug.exceptions import BadRequest, NotFound, Conflict from flaschengeist.database import db -from flaschengeist.models.user import User +from flaschengeist.models.user import User, _UserAttribute from .models import Transaction from . import permissions, BalancePlugin @@ -38,27 +39,114 @@ def get_balance(user, start: datetime = None, end: datetime = None): return credit, debit, credit - debit -def get_balances(start: datetime = None, end: datetime = None): - debit = db.session.query(Transaction.sender_id, func.sum(Transaction.amount)).filter(Transaction.sender_ != None) - credit = db.session.query(Transaction.receiver_id, func.sum(Transaction.amount)).filter( - Transaction.receiver_ != None - ) - if start: - debit = debit.filter(start <= Transaction.time) - credit = credit.filter(start <= Transaction.time) - if end: - debit = debit.filter(Transaction.time <= end) - credit = credit.filter(Transaction.time <= end) +def get_balances(start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None): + class _User(User): + _debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id]) + _credit = db.relationship(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id]) - debit = debit.group_by(Transaction._sender_id).all() - credit = credit.group_by(Transaction._receiver_id).all() + @hybrid_property + def debit(self): + return sum([cred.amount for cred in self._debit]) + + @debit.expression + def debit(cls): + a = ( + db.select(func.sum(Transaction.amount)) + .where(cls.id_ == Transaction._sender_id, Transaction.amount) + .scalar_subquery() + ) + return case([(a, a)], else_=0) + + @hybrid_property + def credit(self): + return sum([cred.amount for cred in self._credit]) + + @credit.expression + def credit(cls): + b = ( + db.select(func.sum(Transaction.amount)) + .where(cls.id_ == Transaction._receiver_id, Transaction.amount) + .scalar_subquery() + ) + return case([(b, b)], else_=0) + + @hybrid_property + def limit(self): + return self.get_attribute("balance_limit", None) + + @limit.expression + def limit(cls): + return ( + db.select(_UserAttribute.value) + .where(and_(cls.id_ == _UserAttribute.user, _UserAttribute.name == "balance_limit")) + .scalar_subquery() + ) + + def get_debit(self, start: datetime = None, end: datetime = None): + if start and end: + return sum([deb.amount for deb in self._debit if start <= deb.time and deb.time <= end]) + if start: + return sum([deb.amount for deb in self._dedit if start <= deb.time]) + if end: + return sum([deb.amount for deb in self._dedit if deb.time <= end]) + return self.debit + + def get_credit(self, start: datetime = None, end: datetime = None): + if start and end: + return sum([cred.amount for cred in self._credit if start <= cred.time and cred.time <= end]) + if start: + return sum([cred.amount for cred in self._credit if start <= cred.time]) + if end: + return sum([cred.amount for cred in self._credit if cred.time <= end]) + return self.credit + + query = _User.query + + if start: + q1 = query.join(_User._credit).filter(start <= Transaction.time) + q2 = query.join(_User._debit).filter(start <= Transaction.time) + query = q1.union(q2) + if end: + q1 = query.join(_User._credit).filter(Transaction.time <= end) + q2 = query.join(_User._debit).filter(Transaction.time <= end) + query = q1.union(q2) + + if sortBy == "balance": + if descending: + query = query.order_by((_User.credit - _User.debit).desc(), _User.lastname.asc(), _User.firstname.asc()) + else: + query = query.order_by((_User.credit - _User.debit).asc(), _User.lastname.asc(), _User.firstname.asc()) + elif sortBy == "limit": + if descending: + query = query.order_by(_User.limit.desc(), User.lastname.asc(), User.firstname.asc()) + else: + query = query.order_by(_User.limit.asc(), User.lastname.asc(), User.firstname.asc()) + elif sortBy == "firstname": + if descending: + query = query.order_by(User.firstname.desc(), User.lastname.desc()) + else: + query = query.order_by(User.firstname.asc(), User.lastname.asc()) + elif sortBy == "lastname": + if descending: + query = query.order_by(User.lastname.desc(), User.firstname.desc()) + else: + query = query.order_by(User.lastname.asc(), User.firstname.asc()) + + count = None + if limit: + count = query.count() + query = query.limit(limit) + if offset: + query = query.offset(offset) + users = query all = {} - for uid, cred in credit: - all[uid] = [cred, 0] - for uid, deb in debit: - all.setdefault(uid, [0, 0]) - all[uid][1] = deb - return all + + for user in users: + + all[user.userid] = [user.get_credit(start, end), 0] + all[user.userid][1] = user.get_debit(start, end) + + return all, count def send(sender: User, receiver, amount: float, author: User): diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py index b0ce3c9..11c7e33 100644 --- a/flaschengeist/plugins/balance/routes.py +++ b/flaschengeist/plugins/balance/routes.py @@ -110,8 +110,10 @@ def limits(current_session: Session): Returns: JSON encoded array of userid with limit or HTTP-error """ - - users = userController.get_users() + userids = None + if "userids" in request.args: + [x for x in request.args.get("userids").split(",") if x] + users = userController.get_users(userids=userids) if request.method == "GET": return jsonify([{"userid": user.userid, "limit": user.get_attribute("balance_limit")} for user in users]) @@ -311,5 +313,11 @@ def get_balances(current_session: Session): Returns: JSON Array containing credit, debit and userid for each user or HTTP error """ - balances = balance_controller.get_balances() - return jsonify([{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()]) + limit = request.args.get("limit", type=int) + offset = request.args.get("offset", type=int) + descending = request.args.get("descending", False, type=bool) + sortBy = request.args.get("sortBy", type=str) + balances, count = balance_controller.get_balances(limit=limit, offset=offset, descending=descending, sortBy=sortBy) + return jsonify( + {"balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()], "count": count} + ) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index d0f929e..a8c87c8 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -69,7 +69,10 @@ def list_users(current_session): JSON encoded array of `flaschengeist.models.user.User` or HTTP error """ logger.debug("Retrieve list of all users") - users = userController.get_users() + userids = None + if "userids" in request.args: + userids = [x for x in request.args.get("userids").split(",") if x] + users = userController.get_users(userids=userids) return jsonify(users) From c3468eea03466d66b42e3bb41d27dba39ad02b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Thu, 25 Nov 2021 10:11:41 +0100 Subject: [PATCH 320/446] [balance] revert user ssp for pull-request --- flaschengeist/controller/userController.py | 8 ++------ flaschengeist/models/user.py | 5 ----- flaschengeist/plugins/balance/routes.py | 10 +++++----- flaschengeist/plugins/users/__init__.py | 5 +---- 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 7c7c237..0722d66 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -118,12 +118,8 @@ def modify_user(user, password, new_password=None): messageController.send_message(messageController.Message(user, text, subject)) -def get_users(userids=None): - query = User.query - if userids: - query.filter(User.userid in userids) - query = query.order_by(User.lastname.asc(), User.firstname.asc()) - return query.all() +def get_users(): + return User.query.all() def get_user_by_role(role: Role): diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 095ba12..2f3ac67 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -104,11 +104,6 @@ class User(db.Model, ModelSerializeMixin): def has_permission(self, permission): return permission in self.get_permissions() - def __repr__(self): - return ( - f"User({self.userid}, {self.firstname}, {self.lastname}, {self.mail}, {self.display_name}, {self.birthday})" - ) - class _UserAttribute(db.Model, ModelSerializeMixin): __tablename__ = "user_attribute" diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py index 11c7e33..b135c52 100644 --- a/flaschengeist/plugins/balance/routes.py +++ b/flaschengeist/plugins/balance/routes.py @@ -110,10 +110,7 @@ def limits(current_session: Session): Returns: JSON encoded array of userid with limit or HTTP-error """ - userids = None - if "userids" in request.args: - [x for x in request.args.get("userids").split(",") if x] - users = userController.get_users(userids=userids) + users = userController.get_users() if request.method == "GET": return jsonify([{"userid": user.userid, "limit": user.get_attribute("balance_limit")} for user in users]) @@ -319,5 +316,8 @@ def get_balances(current_session: Session): sortBy = request.args.get("sortBy", type=str) balances, count = balance_controller.get_balances(limit=limit, offset=offset, descending=descending, sortBy=sortBy) return jsonify( - {"balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()], "count": count} + { + "balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()], + "count": count, + } ) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index a8c87c8..d0f929e 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -69,10 +69,7 @@ def list_users(current_session): JSON encoded array of `flaschengeist.models.user.User` or HTTP error """ logger.debug("Retrieve list of all users") - userids = None - if "userids" in request.args: - userids = [x for x in request.args.get("userids").split(",") if x] - users = userController.get_users(userids=userids) + users = userController.get_users() return jsonify(users) From 1609d8ae29b71e6e05c7f02a7e8d394c63787e05 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Nov 2021 15:40:15 +0100 Subject: [PATCH 321/446] [utils] Add util to get pagination filter args from request --- flaschengeist/utils/HTTP.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/flaschengeist/utils/HTTP.py b/flaschengeist/utils/HTTP.py index 8fe57a9..e81316b 100644 --- a/flaschengeist/utils/HTTP.py +++ b/flaschengeist/utils/HTTP.py @@ -2,6 +2,24 @@ from http.client import NO_CONTENT, CREATED from flask import make_response, jsonify +from flaschengeist.utils.datetime import from_iso_format + + +def get_filter_args(): + """ + Get filter parameter from request + returns: FROM, TO, LIMIT, OFFSET, DESCENDING + """ + from flask import request + + return ( + request.args.get("from", type=from_iso_format), + request.args.get("to", type=from_iso_format), + request.args.get("limit", type=int), + request.args.get("offset", type=int), + "descending" in request.args, + ) + def no_content(): return make_response(jsonify(""), NO_CONTENT) From 1c091311de7d3250b596d8b2ed9e6053c53d07a0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Nov 2021 15:43:02 +0100 Subject: [PATCH 322/446] [events] Use new pagination responses, drop unused api endpoint --- .../plugins/events/event_controller.py | 8 ++- flaschengeist/plugins/events/routes.py | 63 ++----------------- 2 files changed, 11 insertions(+), 60 deletions(-) diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 4cd2d03..8de2ed7 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -147,7 +147,7 @@ def get_events( offset: Optional[int] = None, descending: Optional[bool] = False, with_backup=False, -): +) -> Tuple[int, list[Event]]: """Query events which start from begin until end Args: start (datetime): Earliest start @@ -161,10 +161,14 @@ def get_events( query = query.filter(start <= Event.start) if end is not None: query = query.filter(Event.start < end) + elif start is None: + # Neither start nor end was given + query = query.filter(datetime.now() <= Event.start) if descending: query = query.order_by(Event.start.desc()) else: query = query.order_by(Event.start) + count = query.count() if limit is not None: query = query.limit(limit) if offset is not None and offset > 0: @@ -173,7 +177,7 @@ def get_events( if not with_backup: for event in events: clear_backup(event) - return events + return count, events def delete_event(event_id): diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py index 2fb9b60..71c18d9 100644 --- a/flaschengeist/plugins/events/routes.py +++ b/flaschengeist/plugins/events/routes.py @@ -1,8 +1,6 @@ from datetime import datetime, timedelta, timezone from http.client import NO_CONTENT -from re import template from flask import request, jsonify -from sqlalchemy import exc from werkzeug.exceptions import BadRequest, NotFound, Forbidden from flaschengeist.models.session import Session @@ -12,7 +10,7 @@ from flaschengeist.utils.datetime import from_iso_format from flaschengeist.controller import userController from . import event_controller, permissions, EventPlugin -from ...utils.HTTP import no_content +from ...utils.HTTP import get_filter_args, no_content def dict_get(self, key, default=None, type=None): @@ -194,63 +192,12 @@ def get_event(event_id, current_session): @EventPlugin.blueprint.route("/events", methods=["GET"]) @login_required() -def get_filtered_events(current_session): - begin = request.args.get("from", type=from_iso_format) - end = request.args.get("to", type=from_iso_format) - limit = request.args.get("limit", type=int) - offset = request.args.get("offset", type=int) - descending = "descending" in request.args - if begin is None and end is None: - begin = datetime.now() - return jsonify( - event_controller.get_events( - start=begin, - end=end, - limit=limit, - offset=offset, - descending=descending, +def get_events(current_session): + count, result = event_controller.get_events( + *get_filter_args(), with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), - ) ) - - -@EventPlugin.blueprint.route("/events//", methods=["GET"]) -@EventPlugin.blueprint.route("/events///", methods=["GET"]) -@login_required() -def get_events(current_session, year=datetime.now().year, month=datetime.now().month, day=None): - """Get Event objects for specified date (or month or year), - if nothing set then events for current month are returned - - Route: ``/events[//[/]]`` | Method: ``GET`` - - Args: - year (int, optional): year to query, defaults to current year - month (int, optional): month to query (if set), defaults to current month - day (int, optional): day to query events for (if set) - current_session: Session sent with Authorization Header - - Returns: - JSON encoded list containing events found or HTTP-error - """ - try: - begin = datetime(year=year, month=month, day=1, tzinfo=timezone.utc) - if day: - begin += timedelta(days=day - 1) - end = begin + timedelta(days=1) - else: - if month == 12: - end = datetime(year=year + 1, month=1, day=1, tzinfo=timezone.utc) - else: - end = datetime(year=year, month=month + 1, day=1, tzinfo=timezone.utc) - - events = event_controller.get_events( - begin, - end, - with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), - ) - return jsonify(events) - except ValueError: - raise BadRequest("Invalid date given") + return jsonify({"count": count, "result": result}) def _add_job(event, data): From aa64c769ef2bdfd10b5fadb829edd4aac617835d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Nov 2021 15:44:10 +0100 Subject: [PATCH 323/446] [events] Implemented API endpoint for jobs of the current user --- .../plugins/events/event_controller.py | 20 ++++++++++++++++++- flaschengeist/plugins/events/models.py | 3 ++- flaschengeist/plugins/events/routes.py | 11 +++++++--- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 8de2ed7..46dcb33 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta, timezone from enum import IntEnum -from typing import Optional +from typing import Optional, Tuple from werkzeug.exceptions import BadRequest, Conflict, NotFound from sqlalchemy.exc import IntegrityError @@ -225,6 +225,24 @@ def get_job(job_id, event_id=None) -> Job: return job +def get_jobs(user, start=None, end=None, limit=None, offset=None, descending=None) -> Tuple[int, list[Job]]: + query = Job.query.join(Service).filter(Service.user_ == user) + if start is not None: + query = query.filter(start <= Job.end) + if end is not None: + query = query.filter(end >= Job.start) + if descending is not None: + query = query.order_by(Job.start.desc(), Job.type_id_) + else: + query = query.order_by(Job.start, Job.type_id_) + count = query.count() + if limit is not None: + query = query.limit(limit) + if offset is not None: + query = query.offset(offset) + return count, query.all() + + def add_job(event, job_type, required_services, start, end=None, comment=None): job = Job( required_services=required_services, diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py index 36fd3f9..97c06ca 100644 --- a/flaschengeist/plugins/events/models.py +++ b/flaschengeist/plugins/events/models.py @@ -58,7 +58,6 @@ class Service(db.Model, ModelSerializeMixin): class Job(db.Model, ModelSerializeMixin): __tablename__ = _table_prefix_ + "job" - _type_id = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False) id: int = db.Column(Serial, primary_key=True) start: datetime = db.Column(UtcDateTime, nullable=False) @@ -73,6 +72,8 @@ class Job(db.Model, ModelSerializeMixin): event_ = db.relationship("Event", back_populates="jobs") event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) + type_id_ = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False) + invitations_ = db.relationship("Invitation", cascade="all,delete,delete-orphan", back_populates="job_") __table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),) diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py index 71c18d9..fe7b2f9 100644 --- a/flaschengeist/plugins/events/routes.py +++ b/flaschengeist/plugins/events/routes.py @@ -1,10 +1,8 @@ -from datetime import datetime, timedelta, timezone from http.client import NO_CONTENT from flask import request, jsonify from werkzeug.exceptions import BadRequest, NotFound, Forbidden from flaschengeist.models.session import Session -from flaschengeist.plugins.events.models import Job from flaschengeist.utils.decorators import login_required from flaschengeist.utils.datetime import from_iso_format from flaschengeist.controller import userController @@ -195,7 +193,7 @@ def get_event(event_id, current_session): def get_events(current_session): count, result = event_controller.get_events( *get_filter_args(), - with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), + with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), ) return jsonify({"count": count, "result": result}) @@ -396,6 +394,13 @@ def update_job(event_id, job_id, current_session: Session): return jsonify(job) +@EventPlugin.blueprint.route("/events/jobs", methods=["GET"]) +@login_required() +def get_jobs(current_session: Session): + count, result = event_controller.get_jobs(current_session.user_, *get_filter_args()) + return jsonify({"count": count, "result": result}) + + @EventPlugin.blueprint.route("/events/jobs//assign", methods=["POST"]) @login_required() def assign_job(job_id, current_session: Session): From e626239d8484eecd9d2b9a76199ca800e357f26e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Nov 2021 15:44:37 +0100 Subject: [PATCH 324/446] [cleanup] Minor pep8 cleanup --- flaschengeist/plugins/auth_ldap/__init__.py | 2 +- .../plugins/events/event_controller.py | 22 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index ebe84c3..29659bf 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -114,7 +114,7 @@ class AuthLDAP(AuthPlugin): } ) if user.display_name: - attributes.update( {"displayName": user.display_name}) + attributes.update({"displayName": user.display_name}) ldap_conn.add(dn, self.object_classes, attributes) self._set_roles(user) self.update_user(user) diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 46dcb33..432a5a8 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -336,7 +336,16 @@ def respond_invitation(invite: Invitation, accepted=True): raise Conflict if not accepted: - EventPlugin.plugin.notify(inviter, _("Invitation rejected"), {"type": NotifyType.INVITATION_REJECTED, "event": job.event_id_, "job": invite.job_id, "invitee": invite.invitee_id}) + EventPlugin.plugin.notify( + inviter, + _("Invitation rejected"), + { + "type": NotifyType.INVITATION_REJECTED, + "event": job.event_id_, + "job": invite.job_id, + "invitee": invite.invitee_id, + }, + ) else: if invite.transferee_id is None: assign_job(job, invite.invitee_, 1) @@ -346,7 +355,16 @@ def respond_invitation(invite: Invitation, accepted=True): raise Conflict unassign_job(job, invite.transferee_, service[0], True) assign_job(job, invite.invitee_, service[0].value) - EventPlugin.plugin.notify(inviter, _("Invitation accepted"), {"type": NotifyType.INVITATION_ACCEPTED, "event": job.event_id_, "job": invite.job_id, "invitee": invite.invitee_id}) + EventPlugin.plugin.notify( + inviter, + _("Invitation accepted"), + { + "type": NotifyType.INVITATION_ACCEPTED, + "event": job.event_id_, + "job": invite.job_id, + "invitee": invite.invitee_id, + }, + ) @scheduled From d2ef02c2aff5369000eecad737d96ffa7649ba64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 27 Nov 2021 00:36:28 +0100 Subject: [PATCH 325/446] [balance] add correct notification --- flaschengeist/plugins/balance/__init__.py | 1 + .../plugins/balance/balance_controller.py | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index a7558f9..cb4d929 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -14,6 +14,7 @@ from . import permissions, models class BalancePlugin(Plugin): name = "balance" + id = "dev.flaschengeist.balance" blueprint = Blueprint(name, __name__) permissions = permissions.permissions plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name]) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 9925b44..e11b6b7 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -2,7 +2,7 @@ # Haben -> Zugang aufs Konto # English: Debit -> from account # Credit -> to account - +from enum import IntEnum from sqlalchemy import func, case, and_ from sqlalchemy.ext.hybrid import hybrid_property from datetime import datetime @@ -17,6 +17,11 @@ from . import permissions, BalancePlugin __attribute_limit = "balance_limit" +class NotifyType(IntEnum): + SEND_TO = 0x01 + SEND_FROM = 0x02 + + def set_limit(user: User, limit: float, override=True): if override or not user.has_attribute(__attribute_limit): user.set_attribute(__attribute_limit, limit) @@ -175,9 +180,20 @@ def send(sender: User, receiver, amount: float, author: User): db.session.add(transaction) db.session.commit() if sender is not None and sender.id_ != author.id_: - BalancePlugin.plugin.notify(sender, "Neue Transaktion") + BalancePlugin.plugin.notify( + sender, + "Neue Transaktion", + { + "type": NotifyType.SEND_FROM, + "receiver_id": receiver.userid, + "author_id": author.userid, + "amount": amount, + }, + ) if receiver is not None and receiver.id_ != author.id_: - BalancePlugin.plugin.notify(receiver, "Neue Transaktion") + BalancePlugin.plugin.notify( + receiver, "Neue Transaktion", {"type": NotifyType.SEND_TO, "sender_id": sender.userid, "amount": amount} + ) return transaction From a479d0c0ee2048cd108dcf9962c897364d177338 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 27 Nov 2021 03:05:05 +0100 Subject: [PATCH 326/446] [models] Add __str__ function for all serialized models (for debug) --- flaschengeist/models/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 339a598..d739b88 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -39,6 +39,9 @@ class ModelSerializeMixin: key, value = d.popitem() return value return d + + def __str__(self) -> str: + return self.serialize().__str__() class Serial(TypeDecorator): From 2b93404dc0d11114a00427f1ea54a856c88cfef4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 28 Nov 2021 14:23:08 +0100 Subject: [PATCH 327/446] [core] Add CORS headers --- flaschengeist/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 8d15c11..fbbdf60 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -108,6 +108,14 @@ def create_app(test_config=None): return jsonify({"plugins": app.config["FG_PLUGINS"], "version": version}) + @app.after_request + def after_request(response): + header = response.headers + header['Access-Control-Allow-Origin'] = '*' + header["Access-Control-Allow-Methods"] = "GET,HEAD,OPTIONS,POST,PUT" + header["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept, Authorization" + return response + @app.errorhandler(Exception) def handle_exception(e): if isinstance(e, HTTPException): From 50632eb3336373b64b593f24ecae70327c7e27c6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 28 Nov 2021 22:23:34 +0100 Subject: [PATCH 328/446] feat(cli) Allow assigning all permissions to one group from cli --- run_flaschengeist | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/run_flaschengeist b/run_flaschengeist index 55fabad..03d3222 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -5,7 +5,9 @@ import argparse import sys import pkg_resources +from sqlalchemy import exc +from flaschengeist.app import create_app, install_all from flaschengeist.config import config @@ -130,16 +132,12 @@ class InterfaceGenerator: def install(arguments): - from flaschengeist.app import create_app, install_all - app = create_app() with app.app_context(): install_all() def run(arguments): - from flaschengeist.app import create_app - app = create_app() with app.app_context(): app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", "")) @@ -151,7 +149,6 @@ def run(arguments): def export(arguments): import flaschengeist.models as models - from flaschengeist.app import create_app app = create_app() with app.app_context(): @@ -167,16 +164,23 @@ def export(arguments): gen.write() -def ldap_sync(arguments): - from flaschengeist.app import create_app - from flaschengeist.controller import userController - from flaschengeist.plugins.auth_ldap import AuthLDAP - from ldap3 import SUBTREE - +def ldap(arguments): app = create_app() with app.app_context(): - auth_ldap: AuthLDAP = app.config.get("FG_PLUGINS").get("auth_ldap") - if auth_ldap: + if arguments.set_admin: + from flaschengeist.controller import roleController + from flaschengeist.database import db + role = roleController.get(arguments.set_admin) + role.permissions = roleController.get_permissions() + db.session.commit() + if arguments.sync: + from flaschengeist.controller import userController + from flaschengeist.plugins.auth_ldap import AuthLDAP + from ldap3 import SUBTREE + + auth_ldap: AuthLDAP = app.config.get("FG_PLUGINS").get("auth_ldap") + if auth_ldap is None: + raise Exception("Plugin >auth_ldap< not found") conn = auth_ldap.ldap.connection if not conn: conn = auth_ldap.ldap.connect(auth_ldap.root_dn, auth_ldap.root_secret) @@ -185,8 +189,6 @@ def ldap_sync(arguments): for ldap_user in ldap_users_response: uid = ldap_user["attributes"]["uid"][0] userController.find_user(uid) - exit() - raise Exception("auth_ldap not found") if __name__ == "__main__": @@ -214,8 +216,10 @@ if __name__ == "__main__": ) parser_export.add_argument("--plugins", help="Also export plugins (none means all)", nargs="*") - parser_ldap_sync = subparsers.add_parser("ldap_sync", help="synch ldap-users with database") - parser_ldap_sync.set_defaults(func=ldap_sync) + parser_ldap = subparsers.add_parser("ldap", help="LDAP helper utils") + parser_ldap.set_defaults(func=ldap) + parser_ldap.add_argument('--sync', action="store_true", help="Sync ldap-users with database") + parser_ldap.add_argument('--set-admin', type=str, help="Assign all permissions this to group") args = parser.parse_args() args.func(args) From 60ba8d479993086d183521f6e645dfd98cc961a3 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 28 Nov 2021 22:24:49 +0100 Subject: [PATCH 329/446] fix(core) Fix entry point name --- flaschengeist/app.py | 4 ++-- flaschengeist/controller/roleController.py | 2 +- flaschengeist/models/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index fbbdf60..b886f78 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -38,7 +38,7 @@ class CustomJSONEncoder(JSONEncoder): def __load_plugins(app): logger.info("Search for plugins") app.config["FG_PLUGINS"] = {} - for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"): + for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"): logger.debug("Found plugin: >{}<".format(entry_point.name)) plugin = None if entry_point.name in config and config[entry_point.name].get("enabled", False): @@ -111,7 +111,7 @@ def create_app(test_config=None): @app.after_request def after_request(response): header = response.headers - header['Access-Control-Allow-Origin'] = '*' + header["Access-Control-Allow-Origin"] = "*" header["Access-Control-Allow-Methods"] = "GET,HEAD,OPTIONS,POST,PUT" header["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept, Authorization" return response diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 1e4e6e1..a81c1bb 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -11,7 +11,7 @@ def get_all(): return Role.query.all() -def get(role_name): +def get(role_name) -> Role: if type(role_name) is int: role = Role.query.get(role_name) else: diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index d739b88..f4ab8ec 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -39,7 +39,7 @@ class ModelSerializeMixin: key, value = d.popitem() return value return d - + def __str__(self) -> str: return self.serialize().__str__() diff --git a/setup.py b/setup.py index 6d80097..6bf3a67 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ setup( "test": ["pytest", "coverage"], }, entry_points={ - "flaschengeist.plugin": [ + "flaschengeist.plugins": [ # Authentication providers "auth_plain = flaschengeist.plugins.auth_plain:AuthPlain", "auth_ldap = flaschengeist.plugins.auth_ldap:AuthLDAP [ldap]", From b94319c38f1163743ec21f7cc656113ef4910b60 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 28 Nov 2021 22:29:12 +0100 Subject: [PATCH 330/446] chore(plugins) Split of events plugin --- flaschengeist/plugins/events/__init__.py | 22 - .../plugins/events/event_controller.py | 394 ------------- flaschengeist/plugins/events/models.py | 140 ----- flaschengeist/plugins/events/permissions.py | 28 - flaschengeist/plugins/events/routes.py | 528 ------------------ setup.py | 1 - 6 files changed, 1113 deletions(-) delete mode 100644 flaschengeist/plugins/events/__init__.py delete mode 100644 flaschengeist/plugins/events/event_controller.py delete mode 100644 flaschengeist/plugins/events/models.py delete mode 100644 flaschengeist/plugins/events/permissions.py delete mode 100644 flaschengeist/plugins/events/routes.py diff --git a/flaschengeist/plugins/events/__init__.py b/flaschengeist/plugins/events/__init__.py deleted file mode 100644 index c86a214..0000000 --- a/flaschengeist/plugins/events/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Events plugin - -Provides duty schedule / duty roster functions -""" -from flask import Blueprint, current_app -from werkzeug.local import LocalProxy - -from flaschengeist.plugins import Plugin -from . import permissions, models - - -class EventPlugin(Plugin): - name = "events" - id = "dev.flaschengeist.events" - plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name]) - permissions = permissions.permissions - blueprint = Blueprint(name, __name__) - models = models - - def __init__(self, cfg): - super(EventPlugin, self).__init__(cfg) - from . import routes diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py deleted file mode 100644 index 432a5a8..0000000 --- a/flaschengeist/plugins/events/event_controller.py +++ /dev/null @@ -1,394 +0,0 @@ -from datetime import datetime, timedelta, timezone -from enum import IntEnum -from typing import Optional, Tuple - -from werkzeug.exceptions import BadRequest, Conflict, NotFound -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm.util import was_deleted - -from flaschengeist import logger -from flaschengeist.database import db -from flaschengeist.plugins.events import EventPlugin -from flaschengeist.plugins.events.models import EventType, Event, Invitation, Job, JobType, Service -from flaschengeist.utils.scheduler import scheduled - - -# STUB -def _(x): - return x - - -class NotifyType(IntEnum): - # Invitations 0x00..0x0F - INVITE = 0x01 - TRANSFER = 0x02 - # Invitation responsed 0x10..0x1F - INVITATION_ACCEPTED = 0x10 - INVITATION_REJECTED = 0x11 - - -def update(): - db.session.commit() - - -def get_event_types(): - return EventType.query.all() - - -def get_event_type(identifier): - """Get EventType by ID (int) or name (string)""" - - if isinstance(identifier, int): - et = EventType.query.get(identifier) - elif isinstance(identifier, str): - et = EventType.query.filter(EventType.name == identifier).one_or_none() - else: - logger.debug("Invalid identifier type for EventType") - raise BadRequest - if not et: - raise NotFound - return et - - -def create_event_type(name): - try: - event = EventType(name=name) - db.session.add(event) - db.session.commit() - return event - except IntegrityError: - raise Conflict("Name already exists") - - -def rename_event_type(identifier, new_name): - event_type = get_event_type(identifier) - event_type.name = new_name - try: - db.session.commit() - except IntegrityError: - raise Conflict("Name already exists") - - -def delete_event_type(name): - event_type = get_event_type(name) - db.session.delete(event_type) - try: - db.session.commit() - except IntegrityError: - raise BadRequest("Type still in use") - - -def get_job_types(): - return JobType.query.all() - - -def get_job_type(type_id): - job_type = JobType.query.get(type_id) - print(job_type) - if not job_type: - raise NotFound - return job_type - - -def create_job_type(name): - try: - job_type = JobType(name=name) - db.session.add(job_type) - db.session.commit() - return job_type - except IntegrityError: - raise BadRequest("Name already exists") - - -def rename_job_type(name, new_name): - job_type = get_job_type(name) - job_type.name = new_name - try: - db.session.commit() - except IntegrityError: - raise BadRequest("Name already exists") - - -def delete_job_type(name): - job_type = get_job_type(name) - db.session.delete(job_type) - try: - db.session.commit() - except IntegrityError: - raise BadRequest("Type still in use") - - -def clear_backup(event: Event): - for job in event.jobs: - services = [] - for service in job.services: - if not service.is_backup: - services.append(service) - job.services = services - - -def get_event(event_id, with_backup=False) -> Event: - event = Event.query.get(event_id) - if event is None: - raise NotFound - if not with_backup: - clear_backup(event) - return event - - -def get_templates(): - return Event.query.filter(Event.is_template == True).all() - - -def get_events( - start: Optional[datetime] = None, - end: Optional[datetime] = None, - limit: Optional[int] = None, - offset: Optional[int] = None, - descending: Optional[bool] = False, - with_backup=False, -) -> Tuple[int, list[Event]]: - """Query events which start from begin until end - Args: - start (datetime): Earliest start - end (datetime): Latest start - with_backup (bool): Export also backup services - - Returns: collection of Event objects - """ - query = Event.query.filter(Event.is_template.__eq__(False)) - if start is not None: - query = query.filter(start <= Event.start) - if end is not None: - query = query.filter(Event.start < end) - elif start is None: - # Neither start nor end was given - query = query.filter(datetime.now() <= Event.start) - if descending: - query = query.order_by(Event.start.desc()) - else: - query = query.order_by(Event.start) - count = query.count() - if limit is not None: - query = query.limit(limit) - if offset is not None and offset > 0: - query = query.offset(offset) - events = query.all() - if not with_backup: - for event in events: - clear_backup(event) - return count, events - - -def delete_event(event_id): - """Delete event with given ID - Args: - event_id: id of Event to delete - - Raises: - NotFound if not found - """ - event = get_event(event_id, True) - for job in event.jobs: - delete_job(job) - db.session.delete(event) - db.session.commit() - - -def create_event(event_type, start, end=None, jobs=[], is_template=None, name=None, description=None): - try: - logger.debug(event_type) - event = Event( - start=start, - end=end, - name=name, - description=description, - type=event_type, - is_template=is_template, - jobs=jobs, - ) - db.session.add(event) - db.session.commit() - return event - except IntegrityError: - logger.debug("Database error when creating new event", exc_info=True) - raise BadRequest - - -def get_job(job_id, event_id=None) -> Job: - query = Job.query.filter(Job.id == job_id) - if event_id is not None: - query = query.filter(Job.event_id_ == event_id) - job = query.one_or_none() - if job is None: - raise NotFound - return job - - -def get_jobs(user, start=None, end=None, limit=None, offset=None, descending=None) -> Tuple[int, list[Job]]: - query = Job.query.join(Service).filter(Service.user_ == user) - if start is not None: - query = query.filter(start <= Job.end) - if end is not None: - query = query.filter(end >= Job.start) - if descending is not None: - query = query.order_by(Job.start.desc(), Job.type_id_) - else: - query = query.order_by(Job.start, Job.type_id_) - count = query.count() - if limit is not None: - query = query.limit(limit) - if offset is not None: - query = query.offset(offset) - return count, query.all() - - -def add_job(event, job_type, required_services, start, end=None, comment=None): - job = Job( - required_services=required_services, - type=job_type, - start=start, - end=end, - comment=comment, - ) - event.jobs.append(job) - update() - return job - - -def update(): - try: - db.session.commit() - except IntegrityError: - logger.debug( - "Error, looks like a Job with that type already exists on an event", - exc_info=True, - ) - raise BadRequest() - - -def delete_job(job: Job): - for service in job.services: - unassign_job(service=service, notify=True) - for invitation in job.invitations_: - respond_invitation(invitation, False) - db.session.delete(job) - db.session.commit() - - -def assign_job(job: Job, user, value, is_backup=False): - assert value > 0 - service = Service.query.get((job.id, user.id_)) - if service: - service.value = value - else: - job.services.append(Service(user_=user, value=value, is_backup=is_backup, job_=job)) - db.session.commit() - - -def unassign_job(job: Job = None, user=None, service=None, notify=False): - if service is None: - assert job is not None and user is not None - service = Service.query.get((job.id, user.id_)) - else: - user = service.user_ - if not service: - raise BadRequest - - event_id = service.job_.event_id_ - - db.session.delete(service) - db.session.commit() - if notify: - EventPlugin.plugin.notify(user, "Your assignmet was cancelled", {"event_id": event_id}) - - -def invite(job: Job, invitee, inviter, transferee=None): - inv = Invitation(job_=job, inviter_=inviter, invitee_=invitee, transferee_=transferee) - db.session.add(inv) - update() - if transferee is None: - EventPlugin.plugin.notify(invitee, _("Job invitation"), {"type": NotifyType.INVITE, "invitation": inv.id}) - else: - EventPlugin.plugin.notify(invitee, _("Job transfer"), {"type": NotifyType.TRANSFER, "invitation": inv.id}) - return inv - - -def get_invitation(id: int): - inv: Invitation = Invitation.query.get(id) - if inv is None: - raise NotFound - return inv - - -def cancel_invitation(inv: Invitation): - db.session.delete(inv) - db.session.commit() - - -def respond_invitation(invite: Invitation, accepted=True): - inviter = invite.inviter_ - job = invite.job_ - - db.session.delete(invite) - db.session.commit() - if not was_deleted(invite): - raise Conflict - - if not accepted: - EventPlugin.plugin.notify( - inviter, - _("Invitation rejected"), - { - "type": NotifyType.INVITATION_REJECTED, - "event": job.event_id_, - "job": invite.job_id, - "invitee": invite.invitee_id, - }, - ) - else: - if invite.transferee_id is None: - assign_job(job, invite.invitee_, 1) - else: - service = filter(lambda s: s.userid == invite.transferee_id, job.services) - if not service: - raise Conflict - unassign_job(job, invite.transferee_, service[0], True) - assign_job(job, invite.invitee_, service[0].value) - EventPlugin.plugin.notify( - inviter, - _("Invitation accepted"), - { - "type": NotifyType.INVITATION_ACCEPTED, - "event": job.event_id_, - "job": invite.job_id, - "invitee": invite.invitee_id, - }, - ) - - -@scheduled -def assign_backups(): - logger.debug("Notifications") - now = datetime.now(tz=timezone.utc) - # now + backup_time + next cron tick - start = now + timedelta(hours=16) + timedelta(minutes=30) - services = Service.query.filter(Service.is_backup == True).join(Job).filter(Job.start <= start).all() - for service in services: - if service.job_.start <= now or service.job_.is_full(): - EventPlugin.plugin.notify( - service.user_, - "Your backup assignment was cancelled.", - {"event_id": service.job_.event_id_}, - ) - logger.debug(f"Service is outdated or full, removing. {service.serialize()}") - db.session.delete(service) - else: - service.is_backup = False - logger.debug(f"Service not full, assigning backup. {service.serialize()}") - EventPlugin.plugin.notify( - service.user_, - "Your backup assignment was accepted.", - {"event_id": service.job_.event_id_}, - ) - db.session.commit() diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py deleted file mode 100644 index 97c06ca..0000000 --- a/flaschengeist/plugins/events/models.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 - -from datetime import datetime -from typing import Optional, Union - -from sqlalchemy import UniqueConstraint - -from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial -from flaschengeist.models.user import User -from flaschengeist.database import db - -######### -# Types # -######### - -_table_prefix_ = "events_" - - -class EventType(db.Model, ModelSerializeMixin): - __tablename__ = _table_prefix_ + "event_type" - id: int = db.Column(Serial, primary_key=True) - name: str = db.Column(db.String(30), nullable=False, unique=True) - - -class JobType(db.Model, ModelSerializeMixin): - __tablename__ = _table_prefix_ + "job_type" - id: int = db.Column(Serial, primary_key=True) - name: str = db.Column(db.String(30), nullable=False, unique=True) - - -######## -# Jobs # -######## - - -class Service(db.Model, ModelSerializeMixin): - __tablename__ = _table_prefix_ + "service" - userid: str = "" - is_backup: bool = db.Column(db.Boolean, default=False) - value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) - - _job_id = db.Column( - "job_id", - Serial, - db.ForeignKey(f"{_table_prefix_}job.id"), - nullable=False, - primary_key=True, - ) - _user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False, primary_key=True) - - user_: User = db.relationship("User") - job_: Job = db.relationship("Job") - - @property - def userid(self): - return self.user_.userid - - -class Job(db.Model, ModelSerializeMixin): - __tablename__ = _table_prefix_ + "job" - - id: int = db.Column(Serial, primary_key=True) - start: datetime = db.Column(UtcDateTime, nullable=False) - end: Optional[datetime] = db.Column(UtcDateTime) - type: Union[JobType, int] = db.relationship("JobType") - comment: Optional[str] = db.Column(db.String(256)) - locked: bool = db.Column(db.Boolean(), default=False, nullable=False) - services: list[Service] = db.relationship( - "Service", back_populates="job_", cascade="save-update, merge, delete, delete-orphan" - ) - required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) - - event_ = db.relationship("Event", back_populates="jobs") - event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) - type_id_ = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False) - - invitations_ = db.relationship("Invitation", cascade="all,delete,delete-orphan", back_populates="job_") - - __table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),) - - -########## -# Events # -########## -class Event(db.Model, ModelSerializeMixin): - """Model for an Event""" - - __tablename__ = _table_prefix_ + "event" - id: int = db.Column(Serial, primary_key=True) - start: datetime = db.Column(UtcDateTime, nullable=False) - end: Optional[datetime] = db.Column(UtcDateTime) - name: Optional[str] = db.Column(db.String(255)) - description: Optional[str] = db.Column(db.String(512)) - type: Union[EventType, int] = db.relationship("EventType") - is_template: bool = db.Column(db.Boolean, default=False) - jobs: list[Job] = db.relationship( - "Job", - back_populates="event_", - cascade="all,delete,delete-orphan", - order_by="[Job.start, Job.end]", - ) - # Protected for internal use - _type_id = db.Column( - "type_id", - Serial, - db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), - nullable=False, - ) - - -class Invitation(db.Model, ModelSerializeMixin): - __tablename__ = _table_prefix_ + "invitation" - - id: int = db.Column(Serial, primary_key=True) - job_id: int = db.Column(Serial, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False) - # Dummy properties for API export - invitee_id: str = None # User who was invited to take over - inviter_id: str = None # User who invited the invitee - transferee_id: Optional[str] = None # In case of a transfer: The user who is transfered out of the job - # Not exported properties for backend use - job_: Job = db.relationship(Job, foreign_keys="Invitation.job_id") - invitee_: User = db.relationship("User", foreign_keys="Invitation._invitee_id") - inviter_: User = db.relationship("User", foreign_keys="Invitation._inviter_id") - transferee_: User = db.relationship("User", foreign_keys="Invitation._transferee_id") - # Protected properties needed for internal use - _invitee_id = db.Column("invitee_id", Serial, db.ForeignKey("user.id"), nullable=False) - _inviter_id = db.Column("inviter_id", Serial, db.ForeignKey("user.id"), nullable=False) - _transferee_id = db.Column("transferee_id", Serial, db.ForeignKey("user.id")) - - @property - def invitee_id(self): - return self.invitee_.userid - - @property - def inviter_id(self): - return self.inviter_.userid - - @property - def transferee_id(self): - return self.transferee_.userid if self.transferee_ else None diff --git a/flaschengeist/plugins/events/permissions.py b/flaschengeist/plugins/events/permissions.py deleted file mode 100644 index 3eb81b6..0000000 --- a/flaschengeist/plugins/events/permissions.py +++ /dev/null @@ -1,28 +0,0 @@ -CREATE = "events_create" -"""Can create events""" - -EDIT = "events_edit" -"""Can edit events""" - -DELETE = "events_delete" -"""Can delete events""" - -EVENT_TYPE = "events_event_type" -"""Can create and edit EventTypes""" - -JOB_TYPE = "events_job_type" -"""Can create and edit JobTypes""" - -ASSIGN = "events_assign" -"""Can self assign to jobs""" - -ASSIGN_OTHER = "events_assign_other" -"""Can assign other users to jobs""" - -SEE_BACKUP = "events_see_backup" -"""Can see users assigned as backup""" - -LOCK_JOBS = "events_lock_jobs" -"""Can lock jobs, no further services can be assigned or unassigned""" - -permissions = [value for key, value in globals().items() if not key.startswith("_")] diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py deleted file mode 100644 index fe7b2f9..0000000 --- a/flaschengeist/plugins/events/routes.py +++ /dev/null @@ -1,528 +0,0 @@ -from http.client import NO_CONTENT -from flask import request, jsonify -from werkzeug.exceptions import BadRequest, NotFound, Forbidden - -from flaschengeist.models.session import Session -from flaschengeist.utils.decorators import login_required -from flaschengeist.utils.datetime import from_iso_format -from flaschengeist.controller import userController - -from . import event_controller, permissions, EventPlugin -from ...utils.HTTP import get_filter_args, no_content - - -def dict_get(self, key, default=None, type=None): - """Same as .get from MultiDict""" - try: - rv = self[key] - except KeyError: - return default - if type is not None: - try: - rv = type(rv) - except ValueError: - rv = default - return rv - - -@EventPlugin.blueprint.route("/events/templates", methods=["GET"]) -@login_required() -def get_templates(current_session): - return jsonify(event_controller.get_templates()) - - -@EventPlugin.blueprint.route("/events/event-types", methods=["GET"]) -@EventPlugin.blueprint.route("/events/event-types/", methods=["GET"]) -@login_required() -def get_event_types(current_session, identifier=None): - """Get EventType(s) - - Route: ``/events/event-types`` | Method: ``GET`` - Route: ``/events/event-types/`` | Method: ``GET`` - - Args: - current_session: Session sent with Authorization Header - identifier: If querying a specific EventType - - Returns: - JSON encoded (list of) EventType(s) or HTTP-error - """ - if identifier: - result = event_controller.get_event_type(identifier) - else: - result = event_controller.get_event_types() - return jsonify(result) - - -@EventPlugin.blueprint.route("/events/event-types", methods=["POST"]) -@login_required(permission=permissions.EVENT_TYPE) -def new_event_type(current_session): - """Create a new EventType - - Route: ``/events/event-types`` | Method: ``POST`` - - POST-data: ``{name: string}`` - - Args: - current_session: Session sent with Authorization Header - - Returns: - HTTP-Created or HTTP-error - """ - data = request.get_json() - if "name" not in data: - raise BadRequest - event_type = event_controller.create_event_type(data["name"]) - return jsonify(event_type) - - -@EventPlugin.blueprint.route("/events/event-types/", methods=["PUT", "DELETE"]) -@login_required(permission=permissions.EVENT_TYPE) -def modify_event_type(identifier, current_session): - """Rename or delete an event type - - Route: ``/events/event-types/`` | Method: ``PUT`` or ``DELETE`` - - POST-data: (if renaming) ``{name: string}`` - - Args: - identifier: Identifier of the EventType - current_session: Session sent with Authorization Header - - Returns: - HTTP-NoContent or HTTP-error - """ - if request.method == "DELETE": - event_controller.delete_event_type(identifier) - else: - data = request.get_json() - if "name" not in data: - raise BadRequest("Parameter missing in data") - event_controller.rename_event_type(identifier, data["name"]) - return "", NO_CONTENT - - -@EventPlugin.blueprint.route("/events/job-types", methods=["GET"]) -@login_required() -def get_job_types(current_session): - """Get all JobTypes - - Route: ``/events/job-types`` | Method: ``GET`` - - Args: - current_session: Session sent with Authorization Header - - Returns: - JSON encoded list of JobType HTTP-error - """ - types = event_controller.get_job_types() - return jsonify(types) - - -@EventPlugin.blueprint.route("/events/job-types", methods=["POST"]) -@login_required(permission=permissions.JOB_TYPE) -def new_job_type(current_session): - """Create a new JobType - - Route: ``/events/job-types`` | Method: ``POST`` - - POST-data: ``{name: string}`` - - Args: - current_session: Session sent with Authorization Header - - Returns: - JSON encoded JobType or HTTP-error - """ - data = request.get_json() - if "name" not in data: - raise BadRequest - jt = event_controller.create_job_type(data["name"]) - return jsonify(jt) - - -@EventPlugin.blueprint.route("/events/job-types/", methods=["PUT", "DELETE"]) -@login_required(permission=permissions.JOB_TYPE) -def modify_job_type(type_id, current_session): - """Rename or delete a JobType - - Route: ``/events/job-types/`` | Method: ``PUT`` or ``DELETE`` - - POST-data: (if renaming) ``{name: string}`` - - Args: - type_id: Identifier of the JobType - current_session: Session sent with Authorization Header - - Returns: - HTTP-NoContent or HTTP-error - """ - if request.method == "DELETE": - event_controller.delete_job_type(type_id) - else: - data = request.get_json() - if "name" not in data: - raise BadRequest("Parameter missing in data") - event_controller.rename_job_type(type_id, data["name"]) - return "", NO_CONTENT - - -@EventPlugin.blueprint.route("/events/", methods=["GET"]) -@login_required() -def get_event(event_id, current_session): - """Get event by id - - Route: ``/events/`` | Method: ``GET`` - - Args: - event_id: ID identifying the event - current_session: Session sent with Authorization Header - - Returns: - JSON encoded event object - """ - event = event_controller.get_event( - event_id, - with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), - ) - return jsonify(event) - - -@EventPlugin.blueprint.route("/events", methods=["GET"]) -@login_required() -def get_events(current_session): - count, result = event_controller.get_events( - *get_filter_args(), - with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), - ) - return jsonify({"count": count, "result": result}) - - -def _add_job(event, data): - try: - start = from_iso_format(data["start"]) - end = dict_get(data, "end", None, type=from_iso_format) - required_services = data["required_services"] - job_type = data["type"] - if isinstance(job_type, dict): - job_type = data["type"]["id"] - except (KeyError, ValueError): - raise BadRequest("Missing or invalid POST parameter") - - job_type = event_controller.get_job_type(job_type) - event_controller.add_job( - event, - job_type, - required_services, - start, - end, - comment=dict_get(data, "comment", None, str), - ) - - -@EventPlugin.blueprint.route("/events", methods=["POST"]) -@login_required(permission=permissions.CREATE) -def create_event(current_session): - """Create an new event - - Route: ``/events`` | Method: ``POST`` - - POST-data: See interfaces for Event, can already contain jobs - - Args: - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Event object or HTTP-error - """ - data = request.get_json() - try: - start = from_iso_format(data["start"]) - end = dict_get(data, "end", None, type=from_iso_format) - data_type = data["type"] - if isinstance(data_type, dict): - data_type = data["type"]["id"] - event_type = event_controller.get_event_type(data_type) - except KeyError: - raise BadRequest("Missing POST parameter") - except (NotFound, ValueError): - raise BadRequest("Invalid parameter") - - event = event_controller.create_event( - start=start, - end=end, - name=dict_get(data, "name", None), - is_template=dict_get(data, "is_template", None), - event_type=event_type, - description=dict_get(data, "description", None), - ) - if "jobs" in data: - for job in data["jobs"]: - _add_job(event, job) - - return jsonify(event) - - -@EventPlugin.blueprint.route("/events/", methods=["PUT"]) -@login_required(permission=permissions.EDIT) -def modify_event(event_id, current_session): - """Modify an event - - Route: ``/events/`` | Method: ``PUT`` - - POST-data: See interfaces for Event, can already contain slots - - Args: - event_id: Identifier of the event - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Event object or HTTP-error - """ - event = event_controller.get_event(event_id) - data = request.get_json() - event.start = dict_get(data, "start", event.start, type=from_iso_format) - event.end = dict_get(data, "end", event.end, type=from_iso_format) - event.name = dict_get(data, "name", event.name, type=str) - event.description = dict_get(data, "description", event.description, type=str) - if "type" in data: - event_type = event_controller.get_event_type(data["type"]) - event.type = event_type - - event_controller.update() - return jsonify(event) - - -@EventPlugin.blueprint.route("/events/", methods=["DELETE"]) -@login_required(permission=permissions.DELETE) -def delete_event(event_id, current_session): - """Delete an event - - Route: ``/events/`` | Method: ``DELETE`` - - Args: - event_id: Identifier of the event - current_session: Session sent with Authorization Header - - Returns: - HTTP-NoContent or HTTP-error - """ - event_controller.delete_event(event_id) - return "", NO_CONTENT - - -@EventPlugin.blueprint.route("/events//jobs", methods=["POST"]) -@login_required(permission=permissions.EDIT) -def add_job(event_id, current_session): - """Add an new Job to an Event / EventSlot - - Route: ``/events//jobs`` | Method: ``POST`` - - POST-data: See Job - - Args: - event_id: Identifier of the event - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Event object or HTTP-error - """ - event = event_controller.get_event(event_id) - _add_job(event, request.get_json()) - return jsonify(event) - - -@EventPlugin.blueprint.route("/events//jobs/", methods=["DELETE"]) -@login_required(permission=permissions.DELETE) -def delete_job(event_id, job_id, current_session): - """Delete a Job - - Route: ``/events//jobs/`` | Method: ``DELETE`` - - Args: - event_id: Identifier of the event - job_id: Identifier of the Job - current_session: Session sent with Authorization Header - - Returns: - HTTP-no-content or HTTP error - """ - job = event_controller.get_job(job_id, event_id) - event_controller.delete_job(job) - return no_content() - - -@EventPlugin.blueprint.route("/events//jobs/", methods=["PUT"]) -@login_required() -def update_job(event_id, job_id, current_session: Session): - """Edit Job - - Route: ``/events//jobs/`` | Method: ``PUT`` - - POST-data: See TS interface for Job - - Args: - event_id: Identifier of the event - job_id: Identifier of the Job - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Job object or HTTP-error - """ - if not current_session.user_.has_permission(permissions.EDIT): - raise Forbidden - - data = request.get_json() - if not data: - raise BadRequest - - job = event_controller.get_job(job_id, event_id) - try: - if "type" in data: - job.type = event_controller.get_job_type(data["type"]) - job.start = from_iso_format(data.get("start", job.start)) - job.end = from_iso_format(data.get("end", job.end)) - job.comment = str(data.get("comment", job.comment)) - job.locked = bool(data.get("locked", job.locked)) - job.required_services = float(data.get("required_services", job.required_services)) - event_controller.update() - except NotFound: - raise BadRequest("Invalid JobType") - except ValueError: - raise BadRequest("Invalid POST data") - - return jsonify(job) - - -@EventPlugin.blueprint.route("/events/jobs", methods=["GET"]) -@login_required() -def get_jobs(current_session: Session): - count, result = event_controller.get_jobs(current_session.user_, *get_filter_args()) - return jsonify({"count": count, "result": result}) - - -@EventPlugin.blueprint.route("/events/jobs//assign", methods=["POST"]) -@login_required() -def assign_job(job_id, current_session: Session): - """Assign / unassign user to the Job - - Route: ``/events/jobs//assign`` | Method: ``POST`` - - POST-data: a Service object, see TS interface for Service - - Args: - job_id: Identifier of the Job - current_session: Session sent with Authorization Header - - Returns: - JSON encoded Job or HTTP-error - """ - data = request.get_json() - job = event_controller.get_job(job_id) - try: - user = userController.get_user(data["userid"]) - value = data["value"] - if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or ( - user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) - ): - raise Forbidden - if value > 0: - event_controller.assign_job(job, user, value, data.get("is_backup", False)) - else: - event_controller.unassign_job(job, user, notify=user != current_session.user_) - except (TypeError, KeyError, ValueError): - raise BadRequest - return jsonify(job) - - -@EventPlugin.blueprint.route("/events/jobs//lock", methods=["POST"]) -@login_required(permissions.LOCK_JOBS) -def lock_job(job_id, current_session: Session): - """Lock / unlock the Job - - Route: ``/events/jobs//lock`` | Method: ``POST`` - - POST-data: ``{locked: boolean}`` - - Args: - job_id: Identifier of the Job - current_session: Session sent with Authorization Header - - Returns: - HTTP-No-Content or HTTP-error - """ - data = request.get_json() - job = event_controller.get_job(job_id) - try: - locked = bool(userController.get_user(data["locked"])) - job.locked = locked - event_controller.update() - except (TypeError, KeyError, ValueError): - raise BadRequest - return no_content() - - -@EventPlugin.blueprint.route("/events/invitations", methods=["POST"]) -@login_required() -def invite(current_session: Session): - """Invite an user to a job or transfer job - - Route: ``/events/invites`` | Method: ``POST`` - - POST-data: ``{job: number, invitees: string[], is_transfer?: boolean}`` - - Args: - current_session: Session sent with Authorization Header - - Returns: - List of Invitation objects or HTTP-error - """ - data = request.get_json() - transferee = data.get("transferee", None) - if ( - transferee is not None - and transferee != current_session.userid - and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) - ): - raise Forbidden - - try: - job = event_controller.get_job(data["job"]) - if not isinstance(data["invitees"], list): - raise BadRequest - return jsonify( - [ - event_controller.invite(job, invitee, current_session.user_, transferee) - for invitee in [userController.get_user(uid) for uid in data["invitees"]] - ] - ) - except (TypeError, KeyError, ValueError): - raise BadRequest - - -@EventPlugin.blueprint.route("/events/invitations/", methods=["GET"]) -@login_required() -def get_invitation(invitation_id: int, current_session: Session): - inv = event_controller.get_invitation(invitation_id) - if current_session.userid not in [inv.invitee_id, inv.inviter_id, inv.transferee_id]: - raise Forbidden - return jsonify(inv) - - -@EventPlugin.blueprint.route("/events/invitations/", methods=["DELETE", "PUT"]) -@login_required() -def respond_invitation(invitation_id: int, current_session: Session): - inv = event_controller.get_invitation(invitation_id) - if request.method == "DELETE": - if current_session.userid == inv.invitee_id: - event_controller.respond_invitation(inv, False) - elif current_session.userid == inv.inviter_id: - event_controller.cancel_invitation(inv) - else: - raise Forbidden - else: - # maybe validate data is something like ({accepted: true}) - if current_session.userid != inv.invitee_id: - raise Forbidden - event_controller.respond_invitation(inv) - return no_content() diff --git a/setup.py b/setup.py index 6bf3a67..4728772 100644 --- a/setup.py +++ b/setup.py @@ -66,7 +66,6 @@ setup( "users = flaschengeist.plugins.users:UsersPlugin", "roles = flaschengeist.plugins.roles:RolesPlugin", "balance = flaschengeist.plugins.balance:BalancePlugin", - "events = flaschengeist.plugins.events:EventPlugin", "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin", ], From 06caec86e74ecef989c7beee1aa5ee1f978f928f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 29 Nov 2021 11:33:23 +0100 Subject: [PATCH 331/446] fix(users) Display name should be created when user is created --- flaschengeist/controller/userController.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 0722d66..fff534c 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -76,8 +76,6 @@ def reset_password(token: str, password: str): @Hook def update_user(user): current_app.config["FG_AUTH_BACKEND"].update_user(user) - if not user.display_name: - user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) db.session.commit() @@ -159,6 +157,8 @@ def find_user(uid_mail): else: user = current_app.config["FG_AUTH_BACKEND"].find_user(uid_mail, uid_mail if mail else None) if user: + if not user.display_name: + user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) db.session.add(user) db.session.commit() return user From 0ce52de8cd8e835f24a8b334fd455b0d2d78e1c0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 29 Nov 2021 18:15:21 +0100 Subject: [PATCH 332/446] feat(plugins) Plugins use native Image objects as default avatar, but can still implement their own stuff. --- flaschengeist/controller/userController.py | 18 ++++++--- flaschengeist/models/user.py | 9 ++--- flaschengeist/plugins/__init__.py | 25 ++++++++---- flaschengeist/plugins/auth/__init__.py | 6 +-- flaschengeist/plugins/auth_ldap/__init__.py | 41 ++++++++------------ flaschengeist/plugins/auth_plain/__init__.py | 11 ------ flaschengeist/plugins/users/__init__.py | 19 +++------ 7 files changed, 61 insertions(+), 68 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index fff534c..4374db8 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,6 +1,8 @@ import secrets +from io import BytesIO from flask import current_app from datetime import datetime, timedelta, timezone +from flask.helpers import send_file from werkzeug.exceptions import NotFound, BadRequest, Forbidden from flaschengeist import logger @@ -10,7 +12,7 @@ from flaschengeist.models.notification import Notification from flaschengeist.utils.hook import Hook from flaschengeist.utils.datetime import from_iso_format from flaschengeist.models.user import User, Role, _PasswordReset -from flaschengeist.controller import messageController, sessionController +from flaschengeist.controller import imageController, messageController, sessionController def _generate_password_reset(user): @@ -79,7 +81,7 @@ def update_user(user): db.session.commit() -def set_roles(user: User, roles: [str], create=False): +def set_roles(user: User, roles: list[str], create=False): user.roles_.clear() for role_name in roles: role = Role.query.filter(Role.name == role_name).one_or_none() @@ -201,11 +203,17 @@ def register(data): def load_avatar(user: User): - return current_app.config["FG_AUTH_BACKEND"].get_avatar(user) + if user.avatar_ is not None: + return imageController.send_image(image=user.avatar_) + else: + avatar = current_app.config["FG_AUTH_BACKEND"].get_avatar(user) + if len(avatar.binary) > 0: + return send_file(BytesIO(avatar.binary), avatar.mimetype) + raise NotFound -def save_avatar(user, avatar): - current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) +def save_avatar(user, file): + current_app.config["FG_AUTH_BACKEND"].set_avatar(user, file) db.session.commit() diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 2f3ac67..87fdb91 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -5,6 +5,8 @@ from typing import Optional from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection +from flaschengeist.models.image import Image + from ..database import db from . import ModelSerializeMixin, UtcDateTime, Serial @@ -60,22 +62,19 @@ class User(db.Model, ModelSerializeMixin): birthday: Optional[date] = db.Column(db.Date) roles: list[str] = [] permissions: Optional[list[str]] = None - avatar_url: Optional[str] = "" id_ = db.Column("id", Serial, primary_key=True) roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") sessions_ = db.relationship("Session", back_populates="user_") + avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) + _avatar_id = db.Column("avatar", Serial, db.ForeignKey("image.id")) _attributes = db.relationship( "_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete", ) - @property - def avatar_url(self): - return url_for("users.get_avatar", userid=self.userid) if self.userid else None - @property def roles(self): return [role.name for role in self.roles_] diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 1c42a71..089c5d5 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -1,10 +1,12 @@ import sqlalchemy import pkg_resources +from werkzeug.datastructures import FileStorage from werkzeug.exceptions import MethodNotAllowed, NotFound +from flaschengeist.controller import imageController from flaschengeist.database import db from flaschengeist.models.notification import Notification -from flaschengeist.models.user import _Avatar +from flaschengeist.models.user import _Avatar, User from flaschengeist.models.setting import _PluginSetting from flaschengeist.utils.hook import HookBefore, HookAfter @@ -171,9 +173,13 @@ class AuthPlugin(Plugin): """ raise MethodNotAllowed - def get_avatar(self, user) -> _Avatar: + def get_avatar(self, user: User) -> _Avatar: """Retrieve avatar for given user (if supported by auth backend) + Default behavior is to use native Image objects, + so by default this function is never called, as the userController checks + native Image objects first. + Args: user: User to retrieve the avatar for Raises: @@ -181,24 +187,29 @@ class AuthPlugin(Plugin): """ raise NotFound - def set_avatar(self, user, avatar: _Avatar): + def set_avatar(self, user: User, file: FileStorage): """Set the avatar for given user (if supported by auth backend) + Default behavior is to use native Image objects stored on the Flaschengeist server + Args: user: User to set the avatar for - avatar: Avatar to set + file: FileStorage object uploaded by the user Raises: MethodNotAllowed: If not supported by Backend + Any valid HTTP exception """ - raise MethodNotAllowed + user.avatar_ = imageController.upload_image(file) - def delete_avatar(self, user): + def delete_avatar(self, user: User): """Delete the avatar for given user (if supported by auth backend) + Default behavior is to use the imageController and native Image objects. + Args: user: Uset to delete the avatar for Raises: MethodNotAllowed: If not supported by Backend """ - raise MethodNotAllowed + user.avatar_ = None diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 3874f79..9c08463 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -37,13 +37,13 @@ def login(): except (KeyError, ValueError, TypeError): raise BadRequest("Missing parameter(s)") - logger.debug("search user {{ {} }} in database".format(userid)) + logger.debug(f"search user {userid} in database") user = userController.login_user(userid, password) if not user: raise Unauthorized session = sessionController.create(user, user_agent=request.user_agent) - logger.debug("token is {{ {} }}".format(session.token)) - logger.info("User {{ {} }} success login.".format(userid)) + logger.debug(f"token is {session.token}") + logger.info(f"User {userid} logged in.") # Lets cleanup the DB sessionController.clear_expired() diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 29659bf..697e972 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -1,18 +1,19 @@ """LDAP Authentication Provider Plugin""" -import io import os import ssl -from typing import Optional +from PIL import Image +from io import BytesIO from flask_ldapconn import LDAPConn from flask import current_app as app -from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE +from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from werkzeug.exceptions import BadRequest, InternalServerError, NotFound +from werkzeug.datastructures import FileStorage from flaschengeist import logger -from flaschengeist.plugins import AuthPlugin, before_role_updated +from flaschengeist.controller import userController from flaschengeist.models.user import User, Role, _Avatar -import flaschengeist.controller.userController as userController +from flaschengeist.plugins import AuthPlugin, before_role_updated class AuthLDAP(AuthPlugin): @@ -164,31 +165,23 @@ class AuthLDAP(AuthPlugin): else: raise NotFound - def set_avatar(self, user, avatar: _Avatar): + def set_avatar(self, user: User, file: FileStorage): if self.root_dn is None: logger.error("root_dn missing in ldap config!") raise InternalServerError - if avatar.mimetype != "image/jpeg": - # Try converting using Pillow (if installed) - try: - from PIL import Image - - image = Image.open(io.BytesIO(avatar.binary)) - image_bytes = io.BytesIO() - image.save(image_bytes, format="JPEG") - avatar.binary = image_bytes.getvalue() - avatar.mimetype = "image/jpeg" - except ImportError: - logger.debug("Pillow not installed for image conversion") - raise BadRequest("Unsupported image format") - except IOError: - logger.debug(f"Could not convert avatar from '{avatar.mimetype}' to JPEG") - raise BadRequest("Unsupported image format") + image_bytes = BytesIO() + try: + # Make sure converted to RGB, e.g. png support RGBA but jpeg does not + image = Image.open(file).convert("RGB") + image.save(image_bytes, format="JPEG") + except IOError: + logger.debug(f"Could not convert avatar from '{file.mimetype}' to JPEG") + raise BadRequest("Unsupported image format") dn = user.get_attribute("DN") ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) - ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) + ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [image_bytes.getvalue()])]}) def delete_avatar(self, user): if self.root_dn is None: @@ -225,7 +218,7 @@ class AuthLDAP(AuthPlugin): def __modify_role( self, role: Role, - new_name: Optional[str], + new_name, ): if self.root_dn is None: logger.error("root_dn missing in ldap config!") diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index a9f24a5..4db87d8 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -56,17 +56,6 @@ class AuthPlain(AuthPlugin): def delete_user(self, user): pass - def get_avatar(self, user): - if not user.has_attribute("avatar"): - raise NotFound - return user.get_attribute("avatar") - - def set_avatar(self, user, avatar): - user.set_attribute("avatar", avatar) - - def delete_avatar(self, user): - user.delete_attribute("avatar") - @staticmethod def _hash_password(password): salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii") diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index d0f929e..75f2f56 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -2,8 +2,9 @@ Provides routes used to manage users """ +from io import BytesIO from http.client import NO_CONTENT, CREATED -from flask import Blueprint, request, jsonify, make_response, Response +from flask import Blueprint, request, jsonify, make_response, Response, send_file from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound from . import permissions @@ -12,7 +13,7 @@ from flaschengeist.config import config from flaschengeist.plugins import Plugin from flaschengeist.models.user import User, _Avatar from flaschengeist.utils.decorators import login_required, extract_session, headers -from flaschengeist.controller import userController +from flaschengeist.controller import userController, imageController as image_controller from flaschengeist.utils.HTTP import created, no_content from flaschengeist.utils.datetime import from_iso_format @@ -118,12 +119,7 @@ def frontend(userid, current_session): @headers({"Cache-Control": "public, max-age=604800"}) def get_avatar(userid): user = userController.get_user(userid) - avatar = userController.load_avatar(user) - if len(avatar.binary) > 0: - response = Response(avatar.binary, mimetype=avatar.mimetype) - response.add_etag() - return response.make_conditional(request) - raise NotFound + return userController.load_avatar(user) @UsersPlugin.blueprint.route("/users//avatar", methods=["POST"]) @@ -135,10 +131,7 @@ def set_avatar(userid, current_session): file = request.files.get("file") if file: - avatar = _Avatar() - avatar.mimetype = file.content_type - avatar.binary = bytearray(file.stream.read()) - userController.save_avatar(user, avatar) + userController.save_avatar(user, file) return created() else: raise BadRequest @@ -238,7 +231,7 @@ def notifications(current_session): @UsersPlugin.blueprint.route("/notifications/", methods=["DELETE"]) @login_required() -def remove_notifications(nid, current_session): +def remove_notification(nid, current_session): userController.delete_notification(nid, current_session.user_) return no_content() From 45d15b4f88eebbe9ee1474b266dec89cac5d08a2 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 1 Dec 2021 15:18:36 +0100 Subject: [PATCH 333/446] docs(config): Add some database default values --- flaschengeist/flaschengeist.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index d9d670b..335a3c3 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -26,6 +26,10 @@ level = "DEBUG" [DATABASE] # engine = "mysql" (default) +host = "localhost" +user = "flaschengeist" +password = "flaschengeist" +database = "flaschengeist" [FILES] # Path for file / image uploads From e4a10028b7e65a0370e224bb900917b59dd77734 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 1 Dec 2021 15:19:29 +0100 Subject: [PATCH 334/446] fix(users): Update hook needs to check existence of display_name as well --- flaschengeist/controller/userController.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 4374db8..944565e 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -78,6 +78,8 @@ def reset_password(token: str, password: str): @Hook def update_user(user): current_app.config["FG_AUTH_BACKEND"].update_user(user) + if not user.display_name: + user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) db.session.commit() From 593b8546a2905f6b67e59e8812dd7b09f598d0f1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 1 Dec 2021 15:31:48 +0100 Subject: [PATCH 335/446] fix(roles): Ignore name if it did not change --- flaschengeist/controller/roleController.py | 18 ++++++++---------- flaschengeist/plugins/roles/__init__.py | 19 +++++++++---------- flaschengeist/plugins/roles/permissions.py | 7 +++++++ 3 files changed, 24 insertions(+), 20 deletions(-) create mode 100644 flaschengeist/plugins/roles/permissions.py diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index a81c1bb..9d214b0 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -27,15 +27,8 @@ def get_permissions(): @Hook def update_role(role, new_name): - if new_name is None: - try: - logger.debug(f"Hallo, dies ist die {role.serialize()}") - db.session.delete(role) - logger.debug(f"Hallo, dies ist die {role.serialize()}") - db.session.commit() - except IntegrityError: - logger.debug("IntegrityError: Role might still be in use", exc_info=True) - raise BadRequest("Role still in use") + if new_name is None or not isinstance(new_name, str): + raise BadRequest("Invalid new name") else: if role.name == new_name or db.session.query(db.exists().where(Role.name == case_sensitive(new_name))).scalar(): raise BadRequest("Name already used") @@ -73,4 +66,9 @@ def create_role(name: str, permissions=[]): def delete(role): role.permissions.clear() - update_role(role, None) + try: + db.session.delete(role) + db.session.commit() + except IntegrityError: + logger.debug("IntegrityError: Role might still be in use", exc_info=True) + raise BadRequest("Role still in use") diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index 54b0547..80deca7 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -10,16 +10,15 @@ from http.client import NO_CONTENT from flaschengeist.plugins import Plugin from flaschengeist.utils.decorators import login_required from flaschengeist.controller import roleController -from flaschengeist.utils.HTTP import created +from flaschengeist.utils.HTTP import created, no_content -_permission_edit = "roles_edit" -_permission_delete = "roles_delete" +from . import permissions class RolesPlugin(Plugin): name = "roles" blueprint = Blueprint(name, __name__) - permissions = [_permission_edit, _permission_delete] + permissions = permissions.permissions @RolesPlugin.blueprint.route("/roles", methods=["GET"]) @@ -40,7 +39,7 @@ def list_roles(current_session): @RolesPlugin.blueprint.route("/roles", methods=["POST"]) -@login_required(permission=_permission_edit) +@login_required(permission=permissions.EDIT) def create_role(current_session): """Create new role @@ -98,7 +97,7 @@ def get_role(role_name, current_session): @RolesPlugin.blueprint.route("/roles/", methods=["PUT"]) -@login_required(permission=_permission_edit) +@login_required(permission=permissions.EDIT) def edit_role(role_id, current_session): """Edit role, rename and / or set permissions @@ -118,13 +117,13 @@ def edit_role(role_id, current_session): data = request.get_json() if "permissions" in data: roleController.set_permissions(role, data["permissions"]) - if "name" in data: + if "name" in data and data["name"] != role.name: roleController.update_role(role, data["name"]) - return "", NO_CONTENT + return no_content() @RolesPlugin.blueprint.route("/roles/", methods=["DELETE"]) -@login_required(permission=_permission_delete) +@login_required(permission=permissions.DELETE) def delete_role(role_id, current_session): """Delete role @@ -139,4 +138,4 @@ def delete_role(role_id, current_session): """ role = roleController.get(role_id) roleController.delete(role) - return "", NO_CONTENT + return no_content() diff --git a/flaschengeist/plugins/roles/permissions.py b/flaschengeist/plugins/roles/permissions.py new file mode 100644 index 0000000..1bb3347 --- /dev/null +++ b/flaschengeist/plugins/roles/permissions.py @@ -0,0 +1,7 @@ +EDIT = "roles_edit" +"""Can edit roles, assign permissions to roles and change names""" + +DELETE = "roles_delete" +"""Can delete roles""" + +permissions = [value for key, value in globals().items() if not key.startswith("_")] From 50fa39be4f1ad7484f34e5ab3c95317b48ceb73a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 2 Dec 2021 18:28:32 +0100 Subject: [PATCH 336/446] feat(users): Add some more relationships to model --- flaschengeist/models/user.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 87fdb91..67c4142 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -63,16 +63,21 @@ class User(db.Model, ModelSerializeMixin): roles: list[str] = [] permissions: Optional[list[str]] = None + # Protected stuff for backend use only id_ = db.Column("id", Serial, primary_key=True) roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") - sessions_ = db.relationship("Session", back_populates="user_") + sessions_: list["Session"] = db.relationship( + "Session", back_populates="user_", cascade="all, delete, delete-orphan" + ) avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) + reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan") + # Private stuff for internal use _avatar_id = db.Column("avatar", Serial, db.ForeignKey("image.id")) _attributes = db.relationship( "_UserAttribute", collection_class=attribute_mapped_collection("name"), - cascade="all, delete", + cascade="all, delete, delete-orphan", ) @property @@ -117,7 +122,7 @@ class _PasswordReset(db.Model): __tablename__ = "password_reset" _user_id: User = db.Column("user", Serial, db.ForeignKey("user.id"), primary_key=True) - user: User = db.relationship("User", foreign_keys=[_user_id]) + user: User = db.relationship("User", back_populates="reset_requests_", foreign_keys=[_user_id]) token: str = db.Column(db.String(32)) expires: datetime = db.Column(UtcDateTime) From d0674e8876c21a53ab97e7aebfaf622097eae54a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 2 Dec 2021 21:27:59 +0100 Subject: [PATCH 337/446] fix(users): Fix deleting users Remove all internal references, e.g. sessions, attributes, password reset requests. Add hook for plugins. If not deletable remove at least all personal data --- flaschengeist/controller/userController.py | 26 +++++++++++++++++++- flaschengeist/models/user.py | 3 +-- flaschengeist/plugins/__init__.py | 5 ++++ flaschengeist/plugins/auth_plain/__init__.py | 5 ++-- flaschengeist/plugins/users/__init__.py | 19 ++++++++------ 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 944565e..2c81e64 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,5 +1,6 @@ import secrets from io import BytesIO +from sqlalchemy import exc from flask import current_app from datetime import datetime, timedelta, timezone from flask.helpers import send_file @@ -168,11 +169,34 @@ def find_user(uid_mail): return user -def delete(user): +@Hook +def delete_user(user: User): """Delete given user""" + # First let the backend delete the user, as this might fail current_app.config["FG_AUTH_BACKEND"].delete_user(user) + # Clear all easy relationships + user.avatar_ = None + user._attributes.clear() + user.roles_.clear() + user.sessions_.clear() + user.reset_requests_.clear() + db.session.commit() + try: + # Delete the user db.session.delete(user) db.session.commit() + except exc.IntegrityError: + logger.error("Delete of user failed, there might be ForeignKey contraits from disabled plugins", exec_info=True) + # Remove at least all personal data + user.userid = f"__deleted_user__{user.id_}" + user.display_name = "DELETED USER" + user.firstname = "" + user.lastname = "" + user.birthday = None + user.mail = None + db.session.commit() + + def register(data): diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 67c4142..31da202 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -5,10 +5,9 @@ from typing import Optional from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection -from flaschengeist.models.image import Image - from ..database import db from . import ModelSerializeMixin, UtcDateTime, Serial +from .image import Image association_table = db.Table( diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 089c5d5..4f253ab 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -27,6 +27,11 @@ before_update_user = HookBefore("update_user") Args: user: User object """ +before_delete_user = HookBefore("delete_user") +"""Hook decorator,this is called before an user gets deleted. +Args: + user: User object +""" class Plugin: diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 4db87d8..7a95679 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -6,8 +6,7 @@ Flaschengeist database (as User attribute) import os import hashlib import binascii -from werkzeug.exceptions import BadRequest, NotFound - +from werkzeug.exceptions import BadRequest from flaschengeist.plugins import AuthPlugin from flaschengeist.models.user import User, Role, Permission from flaschengeist.database import db @@ -18,6 +17,8 @@ class AuthPlain(AuthPlugin): def post_install(self): if User.query.first() is None: logger.info("Installing admin user") + role = Role.query.filter(Role.name == "Superuser").first() + if role is None: role = Role(name="Superuser", permissions=Permission.query.all()) admin = User( userid="admin", diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 75f2f56..b368753 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -2,18 +2,17 @@ Provides routes used to manage users """ -from io import BytesIO from http.client import NO_CONTENT, CREATED -from flask import Blueprint, request, jsonify, make_response, Response, send_file +from flask import Blueprint, request, jsonify, make_response from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound from . import permissions from flaschengeist import logger from flaschengeist.config import config from flaschengeist.plugins import Plugin -from flaschengeist.models.user import User, _Avatar +from flaschengeist.models.user import User from flaschengeist.utils.decorators import login_required, extract_session, headers -from flaschengeist.controller import userController, imageController as image_controller +from flaschengeist.controller import userController from flaschengeist.utils.HTTP import created, no_content from flaschengeist.utils.datetime import from_iso_format @@ -23,6 +22,10 @@ class UsersPlugin(Plugin): blueprint = Blueprint(name, __name__) permissions = permissions.permissions + def install(self): + userController.install() + return super().install() + @UsersPlugin.blueprint.route("/users", methods=["POST"]) def register(): @@ -144,7 +147,7 @@ def delete_avatar(userid, current_session): if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.EDIT): raise Forbidden userController.delete_avatar(user) - return "", NO_CONTENT + return no_content() @UsersPlugin.blueprint.route("/users/", methods=["DELETE"]) @@ -163,8 +166,8 @@ def delete_user(userid, current_session): """ logger.debug("Delete user {{ {} }}".format(userid)) user = userController.get_user(userid) - userController.delete(user) - return "", NO_CONTENT + userController.delete_user(user) + return no_content() @UsersPlugin.blueprint.route("/users/", methods=["PUT"]) @@ -217,7 +220,7 @@ def edit_user(userid, current_session): userController.modify_user(user, password, new_password) userController.update_user(user) - return "", NO_CONTENT + return no_content() @UsersPlugin.blueprint.route("/notifications", methods=["GET"]) From f9d9494a3634d373c97dc1754906757759429bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 3 Dec 2021 09:49:10 +0100 Subject: [PATCH 338/446] [fix] add empty install function for userController, fix wrong indention --- flaschengeist/controller/userController.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 2c81e64..d0f85bf 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -33,6 +33,10 @@ def _generate_password_reset(user): return reset +def install(): + pass + + def login_user(username, password): logger.info("login user {{ {} }}".format(username)) @@ -183,8 +187,8 @@ def delete_user(user: User): db.session.commit() try: # Delete the user - db.session.delete(user) - db.session.commit() + db.session.delete(user) + db.session.commit() except exc.IntegrityError: logger.error("Delete of user failed, there might be ForeignKey contraits from disabled plugins", exec_info=True) # Remove at least all personal data @@ -197,8 +201,6 @@ def delete_user(user: User): db.session.commit() - - def register(data): allowed_keys = User().serialize().keys() values = {key: value for key, value in data.items() if key in allowed_keys} From 47400f02e9acc70ad271527776998763065ed408 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 3 Dec 2021 12:51:40 +0100 Subject: [PATCH 339/446] feat(users): Add deleted attribute to users. This allows us to filter out deleted users which could not be deleted and had to be soft-deleted. Meaning: users which still had foreign keys on the database, from e.g. disabled plugins. --- flaschengeist/controller/userController.py | 29 +++++++---- flaschengeist/models/user.py | 3 +- flaschengeist/plugins/auth_plain/__init__.py | 4 +- flaschengeist/plugins/users/__init__.py | 8 ++- flaschengeist/utils/foreign_keys.py | 51 ++++++++++++++++++++ 5 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 flaschengeist/utils/foreign_keys.py diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index d0f85bf..93bd771 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -12,6 +12,7 @@ from flaschengeist.database import db from flaschengeist.models.notification import Notification from flaschengeist.utils.hook import Hook from flaschengeist.utils.datetime import from_iso_format +from flaschengeist.utils.foreign_keys import merge_references from flaschengeist.models.user import User, Role, _PasswordReset from flaschengeist.controller import imageController, messageController, sessionController @@ -33,10 +34,6 @@ def _generate_password_reset(user): return reset -def install(): - pass - - def login_user(username, password): logger.info("login user {{ {} }}".format(username)) @@ -125,23 +122,24 @@ def modify_user(user, password, new_password=None): messageController.send_message(messageController.Message(user, text, subject)) -def get_users(): - return User.query.all() +def get_users(deleted=False): + return User.query.filter(User.deleted == deleted).all() def get_user_by_role(role: Role): return User.query.join(User.roles_).filter_by(role_id=role.id).all() -def get_user(uid): +def get_user(uid, deleted=False): """Get an user by userid from database Args: uid: Userid to search for + deleted: Set to true to also search deleted users Returns: User fround Raises: NotFound if not found""" - user = User.query.filter(User.userid == uid).one_or_none() + user = User.query.filter(User.userid == uid, User.deleted == deleted).one_or_none() if not user: raise NotFound return user @@ -184,9 +182,19 @@ def delete_user(user: User): user.roles_.clear() user.sessions_.clear() user.reset_requests_.clear() - db.session.commit() + # Now move all other references to the DELETED_USER + try: + deleted_user = get_user("__deleted_user__", True) + except NotFound: + deleted_user = User( + userid="__deleted_user__", firstname="USER", lastname="DELETED", display_name="DELETED USER", deleted=True + ) + db.session.add(user) + db.session.flush() + merge_references(user, deleted_user) + db.session.commit() + # Now try to delete the user for real try: - # Delete the user db.session.delete(user) db.session.commit() except exc.IntegrityError: @@ -196,6 +204,7 @@ def delete_user(user: User): user.display_name = "DELETED USER" user.firstname = "" user.lastname = "" + user.deleted = True user.birthday = None user.mail = None db.session.commit() diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 31da202..d2eea8f 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -57,8 +57,9 @@ class User(db.Model, ModelSerializeMixin): display_name: str = db.Column(db.String(30)) firstname: str = db.Column(db.String(50), nullable=False) lastname: str = db.Column(db.String(50), nullable=False) - mail: str = db.Column(db.String(60)) + deleted: bool = db.Column(db.Boolean(), default=False) birthday: Optional[date] = db.Column(db.Date) + mail: str = db.Column(db.String(60)) roles: list[str] = [] permissions: Optional[list[str]] = None diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 7a95679..4fce6c6 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -15,11 +15,11 @@ from flaschengeist import logger class AuthPlain(AuthPlugin): def post_install(self): - if User.query.first() is None: + if User.query.filter(User.deleted == False).count() == 0: logger.info("Installing admin user") role = Role.query.filter(Role.name == "Superuser").first() if role is None: - role = Role(name="Superuser", permissions=Permission.query.all()) + role = Role(name="Superuser", permissions=Permission.query.all()) admin = User( userid="admin", firstname="Admin", diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index b368753..b485f97 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -22,10 +22,6 @@ class UsersPlugin(Plugin): blueprint = Blueprint(name, __name__) permissions = permissions.permissions - def install(self): - userController.install() - return super().install() - @UsersPlugin.blueprint.route("/users", methods=["POST"]) def register(): @@ -93,7 +89,9 @@ def get_user(userid, current_session): JSON encoded `flaschengeist.models.user.User` or if userid is current user also containing permissions or HTTP error """ logger.debug("Get information of user {{ {} }}".format(userid)) - user: User = userController.get_user(userid) + user: User = userController.get_user( + userid, True + ) # This is the only API point that should return data for deleted users serial = user.serialize() if userid == current_session.user_.userid: serial["permissions"] = user.get_permissions() diff --git a/flaschengeist/utils/foreign_keys.py b/flaschengeist/utils/foreign_keys.py new file mode 100644 index 0000000..ac2bedc --- /dev/null +++ b/flaschengeist/utils/foreign_keys.py @@ -0,0 +1,51 @@ +# Borrowed from https://github.com/kvesteri/sqlalchemy-utils +# Modifications see: https://github.com/kvesteri/sqlalchemy-utils/issues/561 +# LICENSED under the BSD license, see upstream https://github.com/kvesteri/sqlalchemy-utils/blob/master/LICENSE + +import sqlalchemy as sa +from sqlalchemy.orm import object_session + + +def get_foreign_key_values(fk, obj): + mapper = sa.inspect(obj.__class__) + return dict( + ( + fk.constraint.columns.values()[index], + getattr(obj, element.column.key) + if hasattr(obj, element.column.key) + else getattr(obj, mapper.get_property_by_column(element.column).key), + ) + for index, element in enumerate(fk.constraint.elements) + ) + + +def get_referencing_foreign_keys(mixed): + tables = [mixed] + referencing_foreign_keys = set() + + for table in mixed.metadata.tables.values(): + if table not in tables: + for constraint in table.constraints: + if isinstance(constraint, sa.sql.schema.ForeignKeyConstraint): + for fk in constraint.elements: + if any(fk.references(t) for t in tables): + referencing_foreign_keys.add(fk) + return referencing_foreign_keys + + +def merge_references(from_, to, foreign_keys=None): + """ + Merge the references of an entity into another entity. + """ + if from_.__tablename__ != to.__tablename__: + raise TypeError("The tables of given arguments do not match.") + + session = object_session(from_) + foreign_keys = get_referencing_foreign_keys(from_.__table__) + + for fk in foreign_keys: + old_values = get_foreign_key_values(fk, from_) + new_values = get_foreign_key_values(fk, to) + session.query(from_.__mapper__).filter(*[k == old_values[k] for k in old_values]).update( + new_values, synchronize_session=False + ) From bac75ca582b25784125e8c651120d787473fb0ff Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 3 Dec 2021 13:13:48 +0100 Subject: [PATCH 340/446] fix(users): Fix query for active users --- flaschengeist/controller/userController.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 93bd771..e5514cf 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -17,6 +17,11 @@ from flaschengeist.models.user import User, Role, _PasswordReset from flaschengeist.controller import imageController, messageController, sessionController +def __active_users(): + """Return query for not deleted users""" + return User.query.filter(User.deleted == False) + + def _generate_password_reset(user): """Generate a password reset link for the user""" reset = _PasswordReset.query.get(user.id_) @@ -123,7 +128,8 @@ def modify_user(user, password, new_password=None): def get_users(deleted=False): - return User.query.filter(User.deleted == deleted).all() + query = __active_users() if not deleted else User.query + return query.all() def get_user_by_role(role: Role): @@ -139,7 +145,7 @@ def get_user(uid, deleted=False): User fround Raises: NotFound if not found""" - user = User.query.filter(User.userid == uid, User.deleted == deleted).one_or_none() + user = (__active_users() if not deleted else User.query).filter(User.userid == uid).one_or_none() if not user: raise NotFound return user From 5b3f63cd0ad9f980a51ae011d714bae559170035 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 5 Dec 2021 20:50:57 +0100 Subject: [PATCH 341/446] fix(roles): Return conflict if role should be deleted but is still in use --- flaschengeist/controller/roleController.py | 4 ++-- flaschengeist/plugins/roles/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 9d214b0..c8b0cb2 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -1,5 +1,5 @@ from sqlalchemy.exc import IntegrityError -from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.exceptions import BadRequest, Conflict, NotFound from flaschengeist.models.user import Role, Permission from flaschengeist.database import db, case_sensitive @@ -71,4 +71,4 @@ def delete(role): db.session.commit() except IntegrityError: logger.debug("IntegrityError: Role might still be in use", exc_info=True) - raise BadRequest("Role still in use") + raise Conflict("Role still in use") diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index 80deca7..954ba21 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -134,7 +134,7 @@ def delete_role(role_id, current_session): current_session: Session sent with Authorization Header Returns: - HTTP-204 or HTTP error + HTTP-204 or HTTP error (HTTP-409 Conflict if role still in use) """ role = roleController.get(role_id) roleController.delete(role) From 5819a0637f64b65de231a59def0b770ed9ae1270 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 5 Dec 2021 22:56:05 +0100 Subject: [PATCH 342/446] fix(models): Notification.plugin should be bigger to support FQN as value --- flaschengeist/models/notification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/models/notification.py b/flaschengeist/models/notification.py index 574b089..9431c17 100644 --- a/flaschengeist/models/notification.py +++ b/flaschengeist/models/notification.py @@ -10,7 +10,7 @@ from .user import User class Notification(db.Model, ModelSerializeMixin): __tablename__ = "notification" id: int = db.Column("id", Serial, primary_key=True) - plugin: str = db.Column(db.String(30), nullable=False) + plugin: str = db.Column(db.String(127), nullable=False) text: str = db.Column(db.Text) data: Any = db.Column(db.PickleType(protocol=4)) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) From 239faac7dd9de9bb073ba817c47616c621ab7132 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 5 Dec 2021 22:56:34 +0100 Subject: [PATCH 343/446] fix(plugin): Only active users can and should be notified --- flaschengeist/plugins/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 4f253ab..bf32013 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -102,9 +102,10 @@ class Plugin: db.session.commit() def notify(self, user, text: str, data=None): - n = Notification(text=text, data=data, plugin=self.id, user_=user) - db.session.add(n) - db.session.commit() + if not user.deleted: + n = Notification(text=text, data=data, plugin=self.id, user_=user) + db.session.add(n) + db.session.commit() def serialize(self): """Serialize a plugin into a dict From d8192679e5e982681c8dc4109eab69c98757e828 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 6 Dec 2021 15:30:39 +0100 Subject: [PATCH 344/446] chore(cleanup): Drop stuff for unsupported python versions --- flaschengeist/utils/datetime.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/flaschengeist/utils/datetime.py b/flaschengeist/utils/datetime.py index 9de34ad..87ec300 100644 --- a/flaschengeist/utils/datetime.py +++ b/flaschengeist/utils/datetime.py @@ -1,10 +1,4 @@ import datetime -import sys - -if sys.version_info < (3, 7): - from backports.datetime_fromisoformat import MonkeyPatch - - MonkeyPatch.patch_fromisoformat() def from_iso_format(date_str): From 653c1c584cfd69f651355b83c2c3b8d3273e316f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 6 Dec 2021 15:32:11 +0100 Subject: [PATCH 345/446] fix(cli): Set env variable for debug --- run_flaschengeist | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/run_flaschengeist b/run_flaschengeist index 03d3222..1aca1be 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -5,7 +5,7 @@ import argparse import sys import pkg_resources -from sqlalchemy import exc +from os import environ from flaschengeist.app import create_app, install_all from flaschengeist.config import config @@ -142,6 +142,7 @@ def run(arguments): with app.app_context(): app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", "")) if arguments.debug: + environ["FLASK_DEBUG"] = "1" app.run(arguments.host, arguments.port, debug=True) else: app.run(arguments.host, arguments.port, debug=False) From f6c229d2ef07e0c01049da740a3bcec15378bd32 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 6 Dec 2021 23:44:07 +0100 Subject: [PATCH 346/446] feat(core): Selected authentification plugin is always enabled --- flaschengeist/app.py | 34 ++++++++++++++++++-------------- flaschengeist/flaschengeist.toml | 4 ---- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index b886f78..a2933fe 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -36,18 +36,21 @@ class CustomJSONEncoder(JSONEncoder): def __load_plugins(app): - logger.info("Search for plugins") + logger.debug("Search for plugins") + app.config["FG_PLUGINS"] = {} for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"): - logger.debug("Found plugin: >{}<".format(entry_point.name)) - plugin = None - if entry_point.name in config and config[entry_point.name].get("enabled", False): + logger.debug(f"Found plugin: >{entry_point.name}<") + + if entry_point.name == config["FLASCHENGEIST"]["auth"] or ( + entry_point.name in config and config[entry_point.name].get("enabled", False) + ): + logger.debug(f"Load plugin {entry_point.name}") try: - logger.info(f"Load plugin {entry_point.name}") plugin = entry_point.load() if not hasattr(plugin, "name"): setattr(plugin, "name", entry_point.name) - plugin = plugin(config[entry_point.name]) + plugin = plugin(config.get(entry_point.name, {})) if hasattr(plugin, "blueprint") and plugin.blueprint is not None: app.register_blueprint(plugin.blueprint) except: @@ -55,17 +58,18 @@ def __load_plugins(app): f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True, ) - del plugin continue - if isinstance(plugin, AuthPlugin): - logger.debug(f"Found authentication plugin: {entry_point.name}") - if entry_point.name == config["FLASCHENGEIST"]["auth"]: - app.config["FG_AUTH_BACKEND"] = plugin + if isinstance(plugin, AuthPlugin): + if entry_point.name != config["FLASCHENGEIST"]["auth"]: + logger.debug(f"Unload not configured AuthPlugin {entry_point.name}") + del plugin + continue + else: + logger.info(f"Using authentication plugin: {entry_point.name}") + app.config["FG_AUTH_BACKEND"] = plugin else: - del plugin - continue - if plugin: - app.config["FG_PLUGINS"][entry_point.name] = plugin + logger.info(f"Using plugin: {entry_point.name}") + app.config["FG_PLUGINS"][entry_point.name] = plugin if "FG_AUTH_BACKEND" not in app.config: logger.error("No authentication plugin configured or authentication plugin not found") raise RuntimeError("No authentication plugin configured or authentication plugin not found") diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index 335a3c3..2ef5c84 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -44,12 +44,8 @@ allowed_mimetypes = [ "image/webp" ] -[auth_plain] -enabled = true - [auth_ldap] # Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap -enabled = false # host = "localhost" # port = 389 # base_dn = "dc=example,dc=com" From dca52b764cc625293d805a9256aa44fda3e1c9cd Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 6 Dec 2021 23:44:41 +0100 Subject: [PATCH 347/446] fix(plugins): Setting a plugin setting to None removes that setting --- flaschengeist/plugins/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index bf32013..0c33ea9 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -96,7 +96,10 @@ class Plugin: .one_or_none() ) if setting is not None: - setting.value = value + if value is None: + db.session.delete(setting) + else: + setting.value = value else: db.session.add(_PluginSetting(plugin=self.name, name=name, value=value)) db.session.commit() From 348adefb7c5375f403501cae60ac2005d9629a86 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 6 Dec 2021 23:48:05 +0100 Subject: [PATCH 348/446] feat(scheduler): Scheduler is now a plugin Scheduler allows to schedule tasks, like cron does, but requires special configuration. --- flaschengeist/config.py | 1 + flaschengeist/flaschengeist.toml | 6 ++- flaschengeist/plugins/scheduler.py | 83 ++++++++++++++++++++++++++++++ flaschengeist/utils/scheduler.py | 17 ------ readme.md | 13 +++++ setup.py | 1 + 6 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 flaschengeist/plugins/scheduler.py delete mode 100644 flaschengeist/utils/scheduler.py diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 524ed6e..8b0b6ed 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -77,6 +77,7 @@ def configure_app(app, test_config=None): "auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}, + "scheduler": {"enabled": True}, }, ) diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index 2ef5c84..d8a32f0 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -11,7 +11,11 @@ root = "/api" # Set secret key secret_key = "V3ryS3cr3t" # Domain used by frontend -#domain = "flaschengeist.local" + +[scheduler] +# Possible values are: "passive_web" (default), "active_web" and "system" +# See documentation +# cron = "passive_web" [LOGGING] # You can override all settings from the logging.toml here diff --git a/flaschengeist/plugins/scheduler.py b/flaschengeist/plugins/scheduler.py new file mode 100644 index 0000000..2a4af6a --- /dev/null +++ b/flaschengeist/plugins/scheduler.py @@ -0,0 +1,83 @@ +import pkg_resources +from datetime import datetime, timedelta +from flask import Blueprint + +from flaschengeist import logger +from flaschengeist.utils.HTTP import no_content + +from . import Plugin + + +class __Task: + def __init__(self, function, **kwags): + self.function = function + self.interval = timedelta(**kwags) + + +_scheduled_tasks: dict[__Task] = dict() + + +def scheduled(id: str, replace=False, **kwargs): + """ + kwargs: days, hours, minutes + """ + + def real_decorator(function): + if id not in _scheduled_tasks or replace: + logger.info(f"Registered task: {id}") + _scheduled_tasks[id] = __Task(function, **kwargs) + else: + logger.debug(f"Skipping already registered task: {id}") + return function + + return real_decorator + + +class SchedulerPlugin(Plugin): + id = "dev.flaschengeist.scheduler" + name = "scheduler" + blueprint = Blueprint(name, __name__) + + def __init__(self, config=None): + """Constructor called by create_app + Args: + config: Dict configuration containing the plugin section + """ + + def __view_func(): + self.run_tasks() + return no_content() + + def __passiv_func(v): + try: + self.run_tasks() + except: + logger.error("Error while executing scheduled tasks!", exc_info=True) + + self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version + cron = None if config is None else config.get("cron", "passive_web").lower() + + if cron is None or cron == "passive_web": + self.blueprint.teardown_app_request(__passiv_func) + elif cron == "active_web": + self.blueprint.add_url_rule("/cron", view_func=__view_func) + + def run_tasks(self): + changed = False + now = datetime.now() + status = self.get_setting("status", default=dict()) + + for id, task in _scheduled_tasks.items(): + last_run = status.setdefault(id, now) + if last_run + task.interval <= now: + logger.debug( + f"Run task {id}, was scheduled for {last_run + task.interval}, next iteration: {now + task.interval}" + ) + task.function() + changed = True + if changed: + # Remove not registered tasks + for id in status.keys(): + if id not in _scheduled_tasks.keys(): + del status[id] + self.set_setting("status", status) diff --git a/flaschengeist/utils/scheduler.py b/flaschengeist/utils/scheduler.py deleted file mode 100644 index aefbddd..0000000 --- a/flaschengeist/utils/scheduler.py +++ /dev/null @@ -1,17 +0,0 @@ -from flask import current_app - -from flaschengeist.utils.HTTP import no_content - -_scheduled = set() - - -def scheduled(func): - _scheduled.add(func) - return func - - -@current_app.route("/cron") -def __run_scheduled(): - for function in _scheduled: - function() - return no_content() diff --git a/readme.md b/readme.md index 7b64951..9e32c8e 100644 --- a/readme.md +++ b/readme.md @@ -39,6 +39,19 @@ Configuration is done within the a `flaschengeist.toml`file, you can copy the on Uncomment and change at least all the database parameters! +#### CRON +Some functionality used by some plugins rely on regular updates, +but as flaschengeists works as an WSGI app it can not controll when it gets called. + +So you have to configure one of the following options to call flaschengeists CRON tasks: + +1. Passive Web-CRON: Every time an users calls flaschengeist a task is scheduled (**NOT RECOMMENDED**) + - Pros: No external configuration needed + - Cons: Slower user experience, no guaranteed execution time of tasks +2. Active Web-CRON: You configure a webworker to call `/cron` + - Pros: Guaranteed execution interval, no impact on user experience (at least if you do not limit wsgi worker threads) + - Cons: Uses one of the webserver threads while executing + ### Database installation The user needs to have full permissions to the database. If not you need to create user and database manually do (or similar on Windows): diff --git a/setup.py b/setup.py index 4728772..f57a18b 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ setup( "balance = flaschengeist.plugins.balance:BalancePlugin", "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin", + "scheduler = flaschengeist.plugins.scheduler:SchedulerPlugin", ], }, cmdclass={ From d8db0aae3a6005ace4751d4daf1668c94a393ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 7 Dec 2021 18:35:05 +0100 Subject: [PATCH 349/446] fix sync with ldap --- run_flaschengeist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_flaschengeist b/run_flaschengeist index 1aca1be..661f173 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -179,8 +179,8 @@ def ldap(arguments): from flaschengeist.plugins.auth_ldap import AuthLDAP from ldap3 import SUBTREE - auth_ldap: AuthLDAP = app.config.get("FG_PLUGINS").get("auth_ldap") - if auth_ldap is None: + auth_ldap: AuthLDAP = app.config.get("FG_AUTH_BACKEND") + if auth_ldap is None and isinstance(auth_ldap, AuthLDAP): raise Exception("Plugin >auth_ldap< not found") conn = auth_ldap.ldap.connection if not conn: From dc9f70983b526f956227cf0ae301b9d5e4b01015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 7 Dec 2021 20:48:33 +0100 Subject: [PATCH 350/446] fix sync ldap --- run_flaschengeist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run_flaschengeist b/run_flaschengeist index 661f173..6ba88c2 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -180,7 +180,7 @@ def ldap(arguments): from ldap3 import SUBTREE auth_ldap: AuthLDAP = app.config.get("FG_AUTH_BACKEND") - if auth_ldap is None and isinstance(auth_ldap, AuthLDAP): + if auth_ldap is None or not isinstance(auth_ldap, AuthLDAP): raise Exception("Plugin >auth_ldap< not found") conn = auth_ldap.ldap.connection if not conn: From 1b80e396482aa54eb842a977e35f66bb45b8dc63 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 14 Dec 2021 15:36:08 +0100 Subject: [PATCH 351/446] feat(ci): Added woodpecker CI --- .woodpecker/lint.yml | 6 ++++++ readme.md | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .woodpecker/lint.yml diff --git a/.woodpecker/lint.yml b/.woodpecker/lint.yml new file mode 100644 index 0000000..0be3525 --- /dev/null +++ b/.woodpecker/lint.yml @@ -0,0 +1,6 @@ +pipeline: + lint: + image: python:alpine + commands: + - pip install black + - black --check --line-length 120 --target-version=py37 . diff --git a/readme.md b/readme.md index 9e32c8e..af8bcec 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,6 @@ # Flaschengeist +![status-badge](https://ci.os-sc.org/api/badges/Flaschengeist/flaschengeist/status.svg) + This is the backend of the Flaschengeist. ## Installation @@ -87,4 +89,4 @@ Or with html output (open `htmlcov/index.html` in a browser): $ coverage html ## Development -Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development). \ No newline at end of file +Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development). From cfac55efe0465dcbb5e712b9ac22631cf694a34d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 17 Dec 2021 12:21:53 +0100 Subject: [PATCH 352/446] fix(app): Add some more debugging --- flaschengeist/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index a2933fe..8259b68 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -70,6 +70,8 @@ def __load_plugins(app): else: logger.info(f"Using plugin: {entry_point.name}") app.config["FG_PLUGINS"][entry_point.name] = plugin + else: + logger.debug(f"Skip disabled plugin {entry_point.name}") if "FG_AUTH_BACKEND" not in app.config: logger.error("No authentication plugin configured or authentication plugin not found") raise RuntimeError("No authentication plugin configured or authentication plugin not found") From 38ebaf0e79a76b3c172c98e7fa3824e97a69e5fe Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 17 Dec 2021 14:27:27 +0100 Subject: [PATCH 353/446] feat(hooks): Some more work on the hooks functions --- flaschengeist/app.py | 7 +- flaschengeist/plugins/__init__.py | 21 +++-- flaschengeist/plugins/auth_plain/__init__.py | 5 +- flaschengeist/utils/hook.py | 98 +++++++++++++------- 4 files changed, 84 insertions(+), 47 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 8259b68..a367fe8 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -12,6 +12,7 @@ from . import logger from .plugins import AuthPlugin from flaschengeist.config import config, configure_app from flaschengeist.controller import roleController +from flaschengeist.utils.hook import Hook class CustomJSONEncoder(JSONEncoder): @@ -35,6 +36,7 @@ class CustomJSONEncoder(JSONEncoder): return JSONEncoder.default(self, o) +@Hook("plugins.loaded") def __load_plugins(app): logger.debug("Search for plugins") @@ -77,23 +79,20 @@ def __load_plugins(app): raise RuntimeError("No authentication plugin configured or authentication plugin not found") +@Hook("plugins.installed") def install_all(): from flaschengeist.database import db db.create_all() db.session.commit() - installed = [] for name, plugin in current_app.config["FG_PLUGINS"].items(): if not plugin: logger.debug(f"Skip disabled plugin: {name}") continue logger.info(f"Install plugin {name}") plugin.install() - installed.append(plugin) if plugin.permissions: roleController.create_permissions(plugin.permissions) - for plugin in installed: - plugin.post_install() def create_app(test_config=None): diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 0c33ea9..1b6f701 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -10,6 +10,21 @@ from flaschengeist.models.user import _Avatar, User from flaschengeist.models.setting import _PluginSetting from flaschengeist.utils.hook import HookBefore, HookAfter +plugins_installed = HookAfter("plugins.installed") +"""Hook decorator for when all plugins are installed + Possible use case would be to populate the database with some presets. + + Args: + hook_result: void (kwargs) +""" +plugins_loaded = HookAfter("plugins.loaded") +"""Hook decorator for when all plugins are loaded + Possible use case would be to check if a specific other plugin is loaded and change own behavior + + Args: + app: Current flask app instance (args) + hook_result: void (kwargs) +""" before_role_updated = HookBefore("update_role") """Hook decorator for when roles are modified Args: @@ -57,12 +72,6 @@ class Plugin: """ pass - def post_install(self): - """Fill database or do other stuff - Called after all plugins are installed - """ - pass - def get_setting(self, name: str, **kwargs): """Get plugin setting from database Args: diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 4fce6c6..a92e5c6 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -7,13 +7,16 @@ import os import hashlib import binascii from werkzeug.exceptions import BadRequest -from flaschengeist.plugins import AuthPlugin +from flaschengeist.plugins import AuthPlugin, plugins_installed from flaschengeist.models.user import User, Role, Permission from flaschengeist.database import db from flaschengeist import logger class AuthPlain(AuthPlugin): + def install(self): + plugins_installed(self.post_install) + def post_install(self): if User.query.filter(User.deleted == False).count() == 0: logger.info("Installing admin user") diff --git a/flaschengeist/utils/hook.py b/flaschengeist/utils/hook.py index 02db156..b028f30 100644 --- a/flaschengeist/utils/hook.py +++ b/flaschengeist/utils/hook.py @@ -1,43 +1,69 @@ -_hook_dict = ({}, {}) +from functools import wraps -class Hook(object): - """Decorator for Hooks - Use to decorate system hooks where plugins should be able to hook in +_hooks_before = {} +_hooks_after = {} + + +def Hook(function=None, id=None): + """Hook decorator + Use to decorate functions as hooks, so plugins can hook up their custom functions. + """ + # `id` passed as `arg` not `kwarg` + if isinstance(function, str): + return Hook(id=function) + + def decorator(function): + @wraps(function) + def wrapped(*args, **kwargs): + _id = id if id is not None else function.__qualname__ + # Hooks before + for f in _hooks_before.get(_id, []): + f(*args, **kwargs) + # Main function + result = function(*args, **kwargs) + # Hooks after + for f in _hooks_after.get(_id, []): + f(*args, hook_result=result, **kwargs) + return result + + return wrapped + + # Called @Hook or we are in the second step + if callable(function): + return decorator(function) + else: + return decorator + + +def HookBefore(id: str): + """Decorator for functions to be called before a Hook-Function is called + The hooked up function must accept the same arguments as the function hooked onto, + as the functions are called with the same arguments. + Hint: This enables you to modify the arguments! + """ + if not id or not isinstance(id, str): + raise TypeError("HookBefore requires the ID of the function to hook up") + + def wrapped(function): + _hooks_before.setdefault(id, []).append(function) + return function + + return wrapped + + +def HookAfter(id: str): + """Decorator for functions to be called after a Hook-Function is called + As with the HookBefore, the hooked up function must accept the same + arguments as the function hooked onto, but also receives a + `hook_result` kwarg containing the result of the function. """ - def __init__(self, function): - self.function = function + if not id or not isinstance(id, str): + raise TypeError("HookAfter requires the ID of the function to hook up") - def __call__(self, *args, **kwargs): - # Hooks before - for function in _hook_dict[0].get(self.function.__name__, []): - function(*args, **kwargs) - # Main function - ret = self.function(*args, **kwargs) - # Hooks after - for function in _hook_dict[1].get(self.function.__name__, []): - function(*args, **kwargs) - return ret - - -class HookBefore(object): - """Decorator for functions to be called before a Hook-Function is called""" - - def __init__(self, name): - self.name = name - - def __call__(self, function): - _hook_dict[0].setdefault(self.name, []).append(function) + def wrapped(function): + _hooks_after.setdefault(id, []).append(function) return function - -class HookAfter(object): - """Decorator for functions to be called after a Hook-Function is called""" - - def __init__(self, name): - self.name = name - - def __call__(self, function): - _hook_dict[1].setdefault(self.name, []).append(function) - return function + return wrapped From ece68936754af9ef3827b29fcea5aa9f7570b194 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 01:39:04 +0100 Subject: [PATCH 354/446] feat(cli): Ported CLI to use native click / flask cli --- flaschengeist/app.py | 12 +- flaschengeist/cli/InterfaceGenerator.py | 107 +++++++++++ flaschengeist/cli/__init__.py | 160 +++++++++++++++++ flaschengeist/config.py | 26 +-- flaschengeist/plugins/auth_ldap/cli.py | 27 +++ flaschengeist/plugins/users/cli.py | 50 ++++++ run_flaschengeist | 226 ------------------------ 7 files changed, 360 insertions(+), 248 deletions(-) create mode 100644 flaschengeist/cli/InterfaceGenerator.py create mode 100644 flaschengeist/cli/__init__.py create mode 100644 flaschengeist/plugins/auth_ldap/cli.py create mode 100644 flaschengeist/plugins/users/cli.py delete mode 100644 run_flaschengeist diff --git a/flaschengeist/app.py b/flaschengeist/app.py index a367fe8..25c317f 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -95,7 +95,7 @@ def install_all(): roleController.create_permissions(plugin.permissions) -def create_app(test_config=None): +def create_app(test_config=None, cli=False): app = Flask(__name__) app.json_encoder = CustomJSONEncoder CORS(app) @@ -103,7 +103,7 @@ def create_app(test_config=None): with app.app_context(): from flaschengeist.database import db - configure_app(app, test_config) + configure_app(app, test_config, cli) db.init_app(app) __load_plugins(app) @@ -113,14 +113,6 @@ def create_app(test_config=None): return jsonify({"plugins": app.config["FG_PLUGINS"], "version": version}) - @app.after_request - def after_request(response): - header = response.headers - header["Access-Control-Allow-Origin"] = "*" - header["Access-Control-Allow-Methods"] = "GET,HEAD,OPTIONS,POST,PUT" - header["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept, Authorization" - return response - @app.errorhandler(Exception) def handle_exception(e): if isinstance(e, HTTPException): diff --git a/flaschengeist/cli/InterfaceGenerator.py b/flaschengeist/cli/InterfaceGenerator.py new file mode 100644 index 0000000..177a1a9 --- /dev/null +++ b/flaschengeist/cli/InterfaceGenerator.py @@ -0,0 +1,107 @@ +import sys +import inspect +import logging + + +class InterfaceGenerator: + known = [] + classes = {} + mapper = { + "str": "string", + "int": "number", + "float": "number", + "date": "Date", + "datetime": "Date", + "NoneType": "null", + "bool": "boolean", + } + + def __init__(self, namespace, filename, logger=logging.getLogger()): + self.basename = "" + self.namespace = namespace + self.filename = filename + self.this_type = None + self.logger = logger + + def pytype(self, cls): + a = self._pytype(cls) + return a + + def _pytype(self, cls): + import typing + + origin = typing.get_origin(cls) + arguments = typing.get_args(cls) + + if origin is typing.ForwardRef: # isinstance(cls, typing.ForwardRef): + return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__ + if origin is typing.Union: + + if len(arguments) == 2 and arguments[1] is type(None): + return "?", self.pytype(arguments[0])[1] + else: + return "", "|".join([self.pytype(pt)[1] for pt in arguments]) + if origin is list: + return "", "Array<{}>".format("|".join([self.pytype(a_type)[1] for a_type in arguments])) + if cls is typing.Any: + return "", "any" + + name = cls.__name__ if hasattr(cls, "__name__") else cls if isinstance(cls, str) else None + if name is not None: + if name in self.mapper: + return "", self.mapper[name] + else: + return "", name + self.logger.warning(f"This python version might not detect all types (try >= 3.9). Could not identify >{cls}<") + return "?", "any" + + def walker(self, module): + if sys.version_info < (3, 9): + raise RuntimeError("Python >= 3.9 is required to export API") + import typing + + if ( + inspect.ismodule(module[1]) + and module[1].__name__.startswith(self.basename) + and module[1].__name__ not in self.known + ): + self.known.append(module[1].__name__) + for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): + self.walker(cls) + elif ( + inspect.isclass(module[1]) + and module[1].__module__.startswith(self.basename) + and module[0] not in self.classes + and not module[0].startswith("_") + and hasattr(module[1], "__annotations__") + ): + self.this_type = module[0] + + d = {} + for param, ptype in typing.get_type_hints(module[1], globalns=None, localns=None).items(): + if not param.startswith("_") and not param.endswith("_"): + + d[param] = self.pytype(ptype) + + if len(d) == 1: + key, value = d.popitem() + self.classes[module[0]] = value[1] + else: + self.classes[module[0]] = d + + def run(self, models): + self.basename = models.__name__ + self.walker(("models", models)) + + def write(self): + with (open(self.filename, "w") if self.filename else sys.stdout) as file: + file.write("declare namespace {} {{\n".format(self.namespace)) + for cls, params in self.classes.items(): + if isinstance(params, str): + file.write("\ttype {} = {};\n".format(cls, params)) + else: + file.write("\tinterface {} {{\n".format(cls)) + for name in params: + file.write("\t\t{}{}: {};\n".format(name, *params[name])) + file.write("\t}\n") + file.write("}\n") diff --git a/flaschengeist/cli/__init__.py b/flaschengeist/cli/__init__.py new file mode 100644 index 0000000..3927e88 --- /dev/null +++ b/flaschengeist/cli/__init__.py @@ -0,0 +1,160 @@ +import pathlib +import subprocess +import click +from os import environ +from flask import current_app +from flask.cli import FlaskGroup, run_command, with_appcontext +import pkg_resources +from flaschengeist.app import create_app +from flaschengeist.config import configure_logger + + +def get_version(ctx, param, value): + if not value or ctx.resilient_parsing: + return + + import platform + from werkzeug import __version__ as werkzeug_version + from flask import __version__ as flask_version + from flaschengeist import __version__ + + click.echo( + f"Python {platform.python_version()}\n" + f"Flask {flask_version}\n" + f"Werkzeug {werkzeug_version}\n" + f"Flaschengeist {__version__}", + color=ctx.color, + ) + ctx.exit() + + +@with_appcontext +def verbosity(ctx, param, value): + """Toggle verbosity between WARNING <-> DEBUG""" + if not value or ctx.resilient_parsing: + return + configure_logger(cli=40 - max(0, min(value * 10, 30))) + + +@click.group( + cls=FlaskGroup, + add_version_option=False, + add_default_commands=False, + create_app=lambda: create_app(cli=True), +) +@click.option( + "--version", + help="Show the flask version", + expose_value=False, + callback=get_version, + is_flag=True, + is_eager=True, +) +@click.option( + "--verbose", + "-v", + help="Increase logging level", + callback=verbosity, + count=True, + expose_value=False, +) +def cli(): + """Management script for the Flaschengeist application.""" + pass + + +@cli.command() +@with_appcontext +def install(): + """Install and initialize enabled plugins. + + Most plugins need to install custom tables into the database + running this command will lookup all enabled plugins and run + their database initalization routines. + """ + from flaschengeist.app import install_all + + install_all() + + +@cli.command() +@click.option("--output", "-o", help="Output file, default is stdout", type=click.Path()) +@click.option("--namespace", "-n", help="TS namespace for the interfaces", type=str, show_default=True) +@click.option("--plugin", "-p", help="Also export types for a plugin (even if disabled)", multiple=True, type=str) +@click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True) +def export(namespace, output, no_core, plugin): + from flaschengeist import models + from flaschengeist import logger + from .InterfaceGenerator import InterfaceGenerator + + gen = InterfaceGenerator(namespace, output, logger) + if not no_core: + gen.run(models) + if plugin: + for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"): + if len(plugin) == 0 or entry_point.name in plugin: + plg = entry_point.load() + if hasattr(plg, "models") and plg.models is not None: + gen.run(plg.models) + gen.write() + + +@cli.command() +@click.option("--host", help="set hostname to listen on", default="127.0.0.1", show_default=True) +@click.option("--port", help="set port to listen on", type=int, default=5000, show_default=True) +@click.option("--debug", help="run in debug mode", is_flag=True) +@with_appcontext +@click.pass_context +def run(ctx, host, port, debug): + """Run Flaschengeist using a development server.""" + + class PrefixMiddleware(object): + def __init__(self, app, prefix=""): + self.app = app + self.prefix = prefix + + def __call__(self, environ, start_response): + + if environ["PATH_INFO"].startswith(self.prefix): + environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :] + environ["SCRIPT_NAME"] = self.prefix + return self.app(environ, start_response) + else: + start_response("404", [("Content-Type", "text/plain")]) + return ["This url does not belong to the app.".encode()] + + from flaschengeist.config import config + + # re configure logger, as we are no logger in CLI mode + configure_logger() + current_app.wsgi_app = PrefixMiddleware(current_app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", "")) + if debug: + environ["FLASK_DEBUG"] = "1" + environ["FLASK_ENV"] = "development" + + ctx.invoke(run_command, host=host, port=port, debugger=debug) + + +@cli.command() +@click.option( + "--output", + "-o", + help="Documentation output path", + default="./docs", + type=click.Path(file_okay=False, path_type=pathlib.Path), +) +def docs(output: pathlib.Path): + """Generate and export API documentation using pdoc3""" + output.mkdir(parents=True, exist_ok=True) + command = [ + "python", + "-m", + "pdoc", + "--skip-errors", + "--html", + "--output-dir", + str(output), + "flaschengeist", + ] + click.echo(f"Running command: {command}") + subprocess.check_call(command) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 8b0b6ed..4282712 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -33,7 +33,7 @@ def read_configuration(test_config): for loc in paths: try: with (loc / "flaschengeist.toml").open() as source: - print("Reading config file from >{}<".format(loc)) + logger.warning(f"Reading config file from >{loc}<") # default root logger, goes to stderr update_dict(config, toml.load(source)) except IOError: pass @@ -41,7 +41,7 @@ def read_configuration(test_config): update_dict(config, test_config) -def configure_logger(): +def configure_logger(cli=False): global config # Read default config logger_config = toml.load(_module_path / "logging.toml") @@ -50,25 +50,27 @@ def configure_logger(): # Override with user config update_dict(logger_config, config.get("LOGGING")) # Check for shortcuts - if "level" in config["LOGGING"]: - logger_config["loggers"]["flaschengeist"] = {"level": config["LOGGING"]["level"]} - logger_config["handlers"]["console"]["level"] = config["LOGGING"]["level"] - logger_config["handlers"]["file"]["level"] = config["LOGGING"]["level"] - if not config["LOGGING"].get("console", True): + if "level" in config["LOGGING"] or isinstance(cli, int): + level = cli if isinstance(cli, int) else config["LOGGING"]["level"] + logger_config["loggers"]["flaschengeist"] = {"level": level} + logger_config["handlers"]["console"]["level"] = level + logger_config["handlers"]["file"]["level"] = level + if cli is True or not config["LOGGING"].get("console", True): logger_config["handlers"]["console"]["level"] = "CRITICAL" - if "file" in config["LOGGING"]: + if not cli and isinstance(config["LOGGING"].get("file", False), str): logger_config["root"]["handlers"].append("file") logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"] - path = Path(config["LOGGING"]["file"]) - path.parent.mkdir(parents=True, exist_ok=True) + Path(config["LOGGING"]["file"]).parent.mkdir(parents=True, exist_ok=True) + else: + del logger_config["handlers"]["file"] logging.config.dictConfig(logger_config) -def configure_app(app, test_config=None): +def configure_app(app, test_config=None, cli=False): global config read_configuration(test_config) - configure_logger() + configure_logger(cli) # Always enable this builtin plugins! update_dict( diff --git a/flaschengeist/plugins/auth_ldap/cli.py b/flaschengeist/plugins/auth_ldap/cli.py new file mode 100644 index 0000000..d306d21 --- /dev/null +++ b/flaschengeist/plugins/auth_ldap/cli.py @@ -0,0 +1,27 @@ +import click +from flask import current_app +from flask.cli import with_appcontext + + +@click.command(no_args_is_help=True) +@click.option("--sync", is_flag=True, default=False, help="Synchronize users from LDAP -> database") +@with_appcontext +@click.pass_context +def ldap(ctx, sync): + """Tools for the LDAP authentification""" + if sync: + from flaschengeist.controller import userController + from flaschengeist.plugins.auth_ldap import AuthLDAP + from ldap3 import SUBTREE + + auth_ldap: AuthLDAP = current_app.config.get("FG_AUTH_BACKEND") + if auth_ldap is None or not isinstance(auth_ldap, AuthLDAP): + ctx.fail("auth_ldap plugin not found or not enabled!") + conn = auth_ldap.ldap.connection + if not conn: + conn = auth_ldap.ldap.connect(auth_ldap.root_dn, auth_ldap.root_secret) + conn.search(auth_ldap.search_dn, "(uid=*)", SUBTREE, attributes=["uid", "givenName", "sn", "mail"]) + ldap_users_response = conn.response + for ldap_user in ldap_users_response: + uid = ldap_user["attributes"]["uid"][0] + userController.find_user(uid) diff --git a/flaschengeist/plugins/users/cli.py b/flaschengeist/plugins/users/cli.py new file mode 100644 index 0000000..4c9dab2 --- /dev/null +++ b/flaschengeist/plugins/users/cli.py @@ -0,0 +1,50 @@ +import click +from flask.cli import with_appcontext +from werkzeug.exceptions import BadRequest, Conflict, NotFound +from flaschengeist.controller import roleController, userController + + +USER_KEY = f"{__name__}.user" + + +def user(ctx, param, value): + if not value or ctx.resilient_parsing: + return + + click.echo("Adding new user") + ctx.meta[USER_KEY] = {} + try: + ctx.meta[USER_KEY]["userid"] = click.prompt("userid", type=str) + ctx.meta[USER_KEY]["firstname"] = click.prompt("firstname", type=str) + ctx.meta[USER_KEY]["lastname"] = click.prompt("lastname", type=str) + ctx.meta[USER_KEY]["display_name"] = click.prompt("displayed name", type=str, default="") + ctx.meta[USER_KEY]["mail"] = click.prompt("mail", type=str, default="") + ctx.meta[USER_KEY]["password"] = click.prompt("password", type=str, confirmation_prompt=True, hide_input=True) + ctx.meta[USER_KEY] = {k: v for k, v in ctx.meta[USER_KEY].items() if v != ""} + + except click.Abort: + click.echo("\n!!! User was not added, aborted.") + del ctx.meta[USER_KEY] + + +@click.command() +@click.option("--add-role", help="Add new role", type=str) +@click.option("--set-admin", help="Make a role an admin role, adding all permissions", type=str) +@click.option("--add-user", help="Add new user interactivly", callback=user, is_flag=True, expose_value=False) +@with_appcontext +def users(add_role, set_admin): + from flaschengeist.database import db + + ctx = click.get_current_context() + + try: + if add_role: + roleController.create_role(add_role) + if set_admin: + role = roleController.get(set_admin) + role.permissions = roleController.get_permissions() + db.session.commit() + if USER_KEY in ctx.meta: + userController.register(ctx.meta[USER_KEY], ctx.meta[USER_KEY]["password"]) + except (BadRequest, NotFound) as e: + ctx.fail(e.description) diff --git a/run_flaschengeist b/run_flaschengeist deleted file mode 100644 index 6ba88c2..0000000 --- a/run_flaschengeist +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/python3 -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 -import inspect -import argparse -import sys - -import pkg_resources -from os import environ - -from flaschengeist.app import create_app, install_all -from flaschengeist.config import config - - -class PrefixMiddleware(object): - def __init__(self, app, prefix=""): - self.app = app - self.prefix = prefix - - def __call__(self, environ, start_response): - - if environ["PATH_INFO"].startswith(self.prefix): - environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :] - environ["SCRIPT_NAME"] = self.prefix - return self.app(environ, start_response) - else: - start_response("404", [("Content-Type", "text/plain")]) - return ["This url does not belong to the app.".encode()] - - -class InterfaceGenerator: - known = [] - classes = {} - mapper = { - "str": "string", - "int": "number", - "float": "number", - "date": "Date", - "datetime": "Date", - "NoneType": "null", - "bool": "boolean", - } - - def __init__(self, namespace, filename): - self.basename = "" - self.namespace = namespace - self.filename = filename - self.this_type = None - - def pytype(self, cls): - a = self._pytype(cls) - print(f"{cls} -> {a}") - return a - - def _pytype(self, cls): - import typing - - origin = typing.get_origin(cls) - arguments = typing.get_args(cls) - - if origin is typing.ForwardRef: # isinstance(cls, typing.ForwardRef): - return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__ - if origin is typing.Union: - print(f"A1: {arguments[1]}") - if len(arguments) == 2 and arguments[1] is type(None): - return "?", self.pytype(arguments[0])[1] - else: - return "", "|".join([self.pytype(pt)[1] for pt in arguments]) - if origin is list: - return "", "Array<{}>".format("|".join([self.pytype(a_type)[1] for a_type in arguments])) - - name = cls.__name__ if hasattr(cls, "__name__") else cls if isinstance(cls, str) else None - if name is not None: - if name in self.mapper: - return "", self.mapper[name] - else: - return "", name - print( - "WARNING: This python version might not detect all types (try >= 3.9). Could not identify >{}<".format(cls) - ) - return "?", "any" - - def walker(self, module): - if sys.version_info < (3, 9): - raise RuntimeError("Python >= 3.9 is required to export API") - import typing - - if ( - inspect.ismodule(module[1]) - and module[1].__name__.startswith(self.basename) - and module[1].__name__ not in self.known - ): - self.known.append(module[1].__name__) - for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): - self.walker(cls) - elif ( - inspect.isclass(module[1]) - and module[1].__module__.startswith(self.basename) - and module[0] not in self.classes - and not module[0].startswith("_") - and hasattr(module[1], "__annotations__") - ): - self.this_type = module[0] - print("\n\n" + module[0] + "\n") - d = {} - for param, ptype in typing.get_type_hints(module[1], globalns=None, localns=None).items(): - if not param.startswith("_") and not param.endswith("_"): - print(f"{param} ::: {ptype}") - d[param] = self.pytype(ptype) - - if len(d) == 1: - key, value = d.popitem() - self.classes[module[0]] = value[1] - else: - self.classes[module[0]] = d - - def run(self, models): - self.basename = models.__name__ - self.walker(("models", models)) - - def write(self): - with open(self.filename, "w") as file: - file.write("declare namespace {} {{\n".format(self.namespace)) - for cls, params in self.classes.items(): - if isinstance(params, str): - file.write("\ttype {} = {};\n".format(cls, params)) - else: - file.write("\tinterface {} {{\n".format(cls)) - for name in params: - file.write("\t\t{}{}: {};\n".format(name, *params[name])) - file.write("\t}\n") - file.write("}\n") - - -def install(arguments): - app = create_app() - with app.app_context(): - install_all() - - -def run(arguments): - app = create_app() - with app.app_context(): - app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", "")) - if arguments.debug: - environ["FLASK_DEBUG"] = "1" - app.run(arguments.host, arguments.port, debug=True) - else: - app.run(arguments.host, arguments.port, debug=False) - - -def export(arguments): - import flaschengeist.models as models - - app = create_app() - with app.app_context(): - gen = InterfaceGenerator(arguments.namespace, arguments.file) - if not arguments.no_core: - gen.run(models) - if arguments.plugins is not None: - for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"): - if len(arguments.plugins) == 0 or entry_point.name in arguments.plugins: - plg = entry_point.load() - if hasattr(plg, "models") and plg.models is not None: - gen.run(plg.models) - gen.write() - - -def ldap(arguments): - app = create_app() - with app.app_context(): - if arguments.set_admin: - from flaschengeist.controller import roleController - from flaschengeist.database import db - role = roleController.get(arguments.set_admin) - role.permissions = roleController.get_permissions() - db.session.commit() - if arguments.sync: - from flaschengeist.controller import userController - from flaschengeist.plugins.auth_ldap import AuthLDAP - from ldap3 import SUBTREE - - auth_ldap: AuthLDAP = app.config.get("FG_AUTH_BACKEND") - if auth_ldap is None or not isinstance(auth_ldap, AuthLDAP): - raise Exception("Plugin >auth_ldap< not found") - conn = auth_ldap.ldap.connection - if not conn: - conn = auth_ldap.ldap.connect(auth_ldap.root_dn, auth_ldap.root_secret) - conn.search(auth_ldap.search_dn, "(uid=*)", SUBTREE, attributes=["uid", "givenName", "sn", "mail"]) - ldap_users_response = conn.response - for ldap_user in ldap_users_response: - uid = ldap_user["attributes"]["uid"][0] - userController.find_user(uid) - - -if __name__ == "__main__": - # create the top-level parser - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(help="sub-command help", dest="sub_command") - subparsers.required = True - parser_run = subparsers.add_parser("run", help="run flaschengeist") - parser_run.set_defaults(func=run) - parser_run.add_argument("--host", help="set hostname to listen on", default="127.0.0.1") - parser_run.add_argument("--port", help="set port to listen on", type=int, default=5000) - parser_run.add_argument("--debug", help="run in debug mode", action="store_true") - parser_install = subparsers.add_parser( - "install", help="run database setup for flaschengeist and all installed plugins" - ) - parser_install.set_defaults(func=install) - parser_export = subparsers.add_parser("export", help="export models to typescript interfaces") - parser_export.set_defaults(func=export) - parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts") - parser_export.add_argument("--namespace", help="Namespace of declarations", default="FG") - parser_export.add_argument( - "--no-core", - help="Do not export core declarations (only useful in conjunction with --plugins)", - action="store_true", - ) - parser_export.add_argument("--plugins", help="Also export plugins (none means all)", nargs="*") - - parser_ldap = subparsers.add_parser("ldap", help="LDAP helper utils") - parser_ldap.set_defaults(func=ldap) - parser_ldap.add_argument('--sync', action="store_true", help="Sync ldap-users with database") - parser_ldap.add_argument('--set-admin', type=str, help="Assign all permissions this to group") - - args = parser.parse_args() - args.func(args) From 53ed2d9d1a984794a51432c858ead49402e778ac Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 01:46:35 +0100 Subject: [PATCH 355/446] chore(package): Only use setup.cfg, drop setup.py --- flaschengeist/flaschengeist.toml | 5 ++- pyproject.toml | 3 ++ setup.cfg | 42 +++++++++++++++++ setup.py | 77 -------------------------------- 4 files changed, 48 insertions(+), 79 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index d8a32f0..77af5b4 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -23,10 +23,11 @@ secret_key = "V3ryS3cr3t" # # Logging level, possible: DEBUG INFO WARNING ERROR level = "DEBUG" -# Uncomment to enable logging to a file +# Logging to a file is simple, just add the path # file = "/tmp/flaschengeist-debug.log" +file = false # Uncomment to disable console logging -# console = False +# console = false [DATABASE] # engine = "mysql" (default) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9787c3b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg index 957dff3..b5347c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,6 +17,48 @@ classifiers = License :: OSI Approved :: MIT License Operating System :: OS Independent +[options] +include_package_data = True +python_requires = >=3.7 +packages = find: +install_requires = + Flask >= 2.0 + Pillow>=8.4.0 + flask_cors + flask_sqlalchemy>=2.5 + sqlalchemy>=1.4.26 + toml + werkzeug + PyMySQL;platform_system=='Windows' + mysqlclient;platform_system!='Windows' + +[options.extras_require] +argon = argon2-cffi +ldap = flask_ldapconn; ldap3 +test = pytest; coverage + +[options.package_data] +* = *.toml + +[options.entry_points] +console_scripts = + flaschengeist = flaschengeist.cli:cli +flask.commands = + ldap = flaschengeist.plugins.auth_ldap.cli:ldap + users = flaschengeist.plugins.users.cli:users +flaschengeist.plugins = + # Authentication providers + auth_plain = flaschengeist.plugins.auth_plain:AuthPlain + auth_ldap = flaschengeist.plugins.auth_ldap:AuthLDAP [ldap] + # Route providers (and misc) + auth = flaschengeist.plugins.auth:AuthRoutePlugin + users = flaschengeist.plugins.users:UsersPlugin + roles = flaschengeist.plugins.roles:RolesPlugin + balance = flaschengeist.plugins.balance:BalancePlugin + mail = flaschengeist.plugins.message_mail:MailMessagePlugin + pricelist = flaschengeist.plugins.pricelist:PriceListPlugin + scheduler = flaschengeist.plugins.scheduler:SchedulerPlugin + [bdist_wheel] universal = True diff --git a/setup.py b/setup.py deleted file mode 100644 index f57a18b..0000000 --- a/setup.py +++ /dev/null @@ -1,77 +0,0 @@ -from setuptools import setup, find_packages, Command -import subprocess -import os - -mysql_driver = "PyMySQL" if os.name == "nt" else "mysqlclient" - - -class DocsCommand(Command): - description = "Generate and export API documentation using pdoc3" - user_options = [ - # The format is (long option, short option, description). - ("output=", "o", "Documentation output path"), - ] - - def initialize_options(self): - self.output = "./docs" - - def finalize_options(self): - pass - - def run(self): - """Run command.""" - command = [ - "python", - "-m", - "pdoc", - "--skip-errors", - "--html", - "--output-dir", - self.output, - "flaschengeist", - ] - self.announce( - "Running command: %s" % str(command), - ) - subprocess.check_call(command) - - -setup( - packages=find_packages(), - package_data={"": ["*.toml"]}, - scripts=["run_flaschengeist"], - python_requires=">=3.7", - install_requires=[ - "Flask >= 2.0", - "toml", - "sqlalchemy>=1.4.26", - "flask_sqlalchemy>=2.5", - "flask_cors", - "Pillow>=8.4.0", - "werkzeug", - mysql_driver, - ], - extras_require={ - "ldap": ["flask_ldapconn", "ldap3"], - "argon": ["argon2-cffi"], - "test": ["pytest", "coverage"], - }, - entry_points={ - "flaschengeist.plugins": [ - # Authentication providers - "auth_plain = flaschengeist.plugins.auth_plain:AuthPlain", - "auth_ldap = flaschengeist.plugins.auth_ldap:AuthLDAP [ldap]", - # Route providers (and misc) - "auth = flaschengeist.plugins.auth:AuthRoutePlugin", - "users = flaschengeist.plugins.users:UsersPlugin", - "roles = flaschengeist.plugins.roles:RolesPlugin", - "balance = flaschengeist.plugins.balance:BalancePlugin", - "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", - "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin", - "scheduler = flaschengeist.plugins.scheduler:SchedulerPlugin", - ], - }, - cmdclass={ - "docs": DocsCommand, - }, -) From a3ccd6cea1968899b6184c9d5d6e1b3d364a242c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 01:48:12 +0100 Subject: [PATCH 356/446] feat(db): Use named database constraints --- flaschengeist/database.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/flaschengeist/database.py b/flaschengeist/database.py index 410e0cf..ebda993 100644 --- a/flaschengeist/database.py +++ b/flaschengeist/database.py @@ -1,9 +1,31 @@ from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import MetaData -db = SQLAlchemy() +# https://alembic.sqlalchemy.org/en/latest/naming.html +metadata = MetaData( + naming_convention={ + "pk": "pk_%(table_name)s", + "ix": "ix_%(table_name)s_%(column_0_name)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", + } +) + + +db = SQLAlchemy(metadata=metadata) def case_sensitive(s): + """ + Compare string as case sensitive on the database + + Args: + s: string to compare + + Example: + User.query.filter(User.name == case_sensitive(some_string)) + """ if db.session.bind.dialect.name == "mysql": from sqlalchemy import func From ceba819ca753712f9a99fa85740c8b675d163f44 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 01:49:17 +0100 Subject: [PATCH 357/446] fix(db): User.userid should be unique --- flaschengeist/models/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index d2eea8f..2ce1716 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -53,7 +53,7 @@ class User(db.Model, ModelSerializeMixin): """ __tablename__ = "user" - userid: str = db.Column(db.String(30), nullable=False) + userid: str = db.Column(db.String(30), unique=True, nullable=False) display_name: str = db.Column(db.String(30)) firstname: str = db.Column(db.String(50), nullable=False) lastname: str = db.Column(db.String(50), nullable=False) From 0a3da51b92270a1f33dd59ecc885fdea829d31f0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 01:50:03 +0100 Subject: [PATCH 358/446] fix(logging): console logger should default to stderr --- flaschengeist/logging.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/logging.toml b/flaschengeist/logging.toml index c9a5647..3891623 100644 --- a/flaschengeist/logging.toml +++ b/flaschengeist/logging.toml @@ -15,7 +15,7 @@ disable_existing_loggers = false class = "logging.StreamHandler" level = "DEBUG" formatter = "simple" - stream = "ext://sys.stdout" + stream = "ext://sys.stderr" [handlers.file] class = "logging.handlers.WatchedFileHandler" level = "WARNING" From ec7bf396664d4ba57bcbd7be28422e0b7a3a1585 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 01:51:38 +0100 Subject: [PATCH 359/446] fix(roles): Map IntegrityError to BadRequest as this is an client error, no server error. --- flaschengeist/controller/roleController.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index c8b0cb2..204adad 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -17,7 +17,7 @@ def get(role_name) -> Role: else: role = Role.query.filter(Role.name == role_name).one_or_none() if not role: - raise NotFound + raise NotFound("no such role") return role @@ -56,11 +56,14 @@ def create_permissions(permissions): def create_role(name: str, permissions=[]): logger.debug(f"Create new role with name: {name}") - role = Role(name=name) - db.session.add(role) - set_permissions(role, permissions) - db.session.commit() - logger.debug(f"Created role: {role.serialize()}") + try: + role = Role(name=name) + db.session.add(role) + set_permissions(role, permissions) + db.session.commit() + logger.debug(f"Created role: {role.serialize()}") + except IntegrityError: + raise BadRequest("role already exists") return role From 9f6aa38925ea2b484843e2157952f266d721d4fa Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 01:52:35 +0100 Subject: [PATCH 360/446] fix(users): Reduce amount of SELECT queries in set_roles --- flaschengeist/controller/userController.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index e5514cf..c2ca7f2 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -90,15 +90,13 @@ def update_user(user): db.session.commit() -def set_roles(user: User, roles: list[str], create=False): - user.roles_.clear() - for role_name in roles: - role = Role.query.filter(Role.name == role_name).one_or_none() - if not role: - if not create: - raise BadRequest("Role not found >{}<".format(role_name)) - role = Role(name=role_name) - user.roles_.append(role) +def set_roles(user: User, roles: list[str]): + if not isinstance(roles, list) and any([not isinstance(r, str) for r in roles]): + raise BadRequest("Invalid role name") + fetched = Role.query.filter(Role.name.in_(roles)).all() + if len(fetched) < len(roles): + raise BadRequest("Invalid role name, role not found") + user.roles_ = fetched def modify_user(user, password, new_password=None): From bd371dfcf27d00e79e0a1cd0002469817d15be4b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 01:56:52 +0100 Subject: [PATCH 361/446] fix(users): Register: validate `mail`, handle duplicated `userid`, only send password mail if `mail` was set --- flaschengeist/controller/userController.py | 36 ++++++++++++++-------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index c2ca7f2..059edc2 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,4 +1,5 @@ import secrets +import re from io import BytesIO from sqlalchemy import exc from flask import current_app @@ -214,29 +215,40 @@ def delete_user(user: User): db.session.commit() -def register(data): +def register(data, passwd=None): + """Register a new user + Args: + data: dictionary containing valid user properties + passwd: optional a password, default: 16byte random + """ allowed_keys = User().serialize().keys() values = {key: value for key, value in data.items() if key in allowed_keys} roles = values.pop("roles", []) if "birthday" in data: values["birthday"] = from_iso_format(data["birthday"]).date() + if "mail" in data and not re.match(r"[^@]+@[^@]+\.[^@]+", data["mail"]): + raise BadRequest("Invalid mail given") user = User(**values) set_roles(user, roles) - password = secrets.token_urlsafe(16) + password = passwd if passwd else secrets.token_urlsafe(16) current_app.config["FG_AUTH_BACKEND"].create_user(user, password) - db.session.add(user) - db.session.commit() + try: + db.session.add(user) + db.session.commit() + except exc.IntegrityError: + raise BadRequest("userid already in use") - reset = _generate_password_reset(user) + if user.mail: + reset = _generate_password_reset(user) - subject = str(config["MESSAGES"]["welcome_subject"]).format(name=user.display_name, username=user.userid) - text = str(config["MESSAGES"]["welcome_text"]).format( - name=user.display_name, - username=user.userid, - password_link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}', - ) - messageController.send_message(messageController.Message(user, text, subject)) + subject = str(config["MESSAGES"]["welcome_subject"]).format(name=user.display_name, username=user.userid) + text = str(config["MESSAGES"]["welcome_text"]).format( + name=user.display_name, + username=user.userid, + password_link=f'https://{config["FLASCHENGEIST"]["domain"]}/reset?token={reset.token}', + ) + messageController.send_message(messageController.Message(user, text, subject)) find_user(user.userid) From f507cd483db176e421d252e180a89065f7cb07c4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 02:00:46 +0100 Subject: [PATCH 362/446] fix(docs): Fix script name in documentation --- readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index af8bcec..672f7d9 100644 --- a/readme.md +++ b/readme.md @@ -67,13 +67,13 @@ If not you need to create user and database manually do (or similar on Windows): Then you can install the database tables and initial entries: - run_flaschengeist install + $ flaschengeist install ### Run - run_flaschengeist run + $ flaschengeist run or with debug messages: - run_flaschengeist run --debug + $ flaschengeist run --debug This will run the backend on http://localhost:5000 From ee839ce6a30fc9cb1a1e49cffd22239c680106f8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 02:12:00 +0100 Subject: [PATCH 363/446] fix(auth_plain): Fix post_install hook --- flaschengeist/plugins/auth_plain/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index a92e5c6..10d72d3 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -17,7 +17,7 @@ class AuthPlain(AuthPlugin): def install(self): plugins_installed(self.post_install) - def post_install(self): + def post_install(self, **kwargs): if User.query.filter(User.deleted == False).count() == 0: logger.info("Installing admin user") role = Role.query.filter(Role.name == "Superuser").first() From ab093c04bd547bd48754b8496c2732f90029dbc0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 02:12:19 +0100 Subject: [PATCH 364/446] fix(cli): Defaul logging level should be WARNING --- flaschengeist/cli/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/cli/__init__.py b/flaschengeist/cli/__init__.py index 3927e88..493dcfd 100644 --- a/flaschengeist/cli/__init__.py +++ b/flaschengeist/cli/__init__.py @@ -33,14 +33,14 @@ def verbosity(ctx, param, value): """Toggle verbosity between WARNING <-> DEBUG""" if not value or ctx.resilient_parsing: return - configure_logger(cli=40 - max(0, min(value * 10, 30))) + configure_logger(cli=30 - max(0, min(value * 10, 20))) @click.group( cls=FlaskGroup, add_version_option=False, add_default_commands=False, - create_app=lambda: create_app(cli=True), + create_app=lambda: create_app(cli=30), ) @click.option( "--version", From b8f086b4ddc6e5716921c61f5c164094414ab363 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 02:15:53 +0100 Subject: [PATCH 365/446] fix(app): Add AuthPlugin to FG_PLUGINS --- flaschengeist/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 25c317f..6cd99c5 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -71,7 +71,7 @@ def __load_plugins(app): app.config["FG_AUTH_BACKEND"] = plugin else: logger.info(f"Using plugin: {entry_point.name}") - app.config["FG_PLUGINS"][entry_point.name] = plugin + app.config["FG_PLUGINS"][entry_point.name] = plugin else: logger.debug(f"Skip disabled plugin {entry_point.name}") if "FG_AUTH_BACKEND" not in app.config: From 2df5a61ff3045576c6bbf7640bf34d447707e6e6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 03:03:41 +0100 Subject: [PATCH 366/446] fix(cli): Fix logging level not set on run --- flaschengeist/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 4282712..5b7031c 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -51,7 +51,7 @@ def configure_logger(cli=False): update_dict(logger_config, config.get("LOGGING")) # Check for shortcuts if "level" in config["LOGGING"] or isinstance(cli, int): - level = cli if isinstance(cli, int) else config["LOGGING"]["level"] + level = cli if cli and isinstance(cli, int) else config["LOGGING"]["level"] logger_config["loggers"]["flaschengeist"] = {"level": level} logger_config["handlers"]["console"]["level"] = level logger_config["handlers"]["file"]["level"] = level From 1db33918263558f134b3017ea51315f7e0469ccb Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 03:04:37 +0100 Subject: [PATCH 367/446] fix(roles): Minor documentation + typings --- flaschengeist/controller/roleController.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 204adad..86f74ab 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -1,9 +1,10 @@ +from typing import Union from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import BadRequest, Conflict, NotFound +from flaschengeist import logger from flaschengeist.models.user import Role, Permission from flaschengeist.database import db, case_sensitive -from flaschengeist import logger from flaschengeist.utils.hook import Hook @@ -11,7 +12,15 @@ def get_all(): return Role.query.all() -def get(role_name) -> Role: +def get(role_name: Union[int,str]) -> Role: + """Get role by ID or name + Args: + role_name: Name or ID of the role + Returns: + Matching role + Raises: + NotFound + """ if type(role_name) is int: role = Role.query.get(role_name) else: From 691345cf404a9a7ab222ca574e4438471d0291a7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 04:19:07 +0100 Subject: [PATCH 368/446] fix(roles): Minor fix in set_permissions --- flaschengeist/controller/roleController.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 86f74ab..23528ad 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -12,7 +12,7 @@ def get_all(): return Role.query.all() -def get(role_name: Union[int,str]) -> Role: +def get(role_name: Union[int, str]) -> Role: """Get role by ID or name Args: role_name: Name or ID of the role @@ -46,11 +46,10 @@ def update_role(role, new_name): def set_permissions(role, permissions): - for name in permissions: - p = Permission.query.filter(Permission.name.in_(permissions)).all() - if not p or len(p) < len(permissions): - raise BadRequest("Invalid permission name >{}<".format(name)) - role.permissions = list(p) + perms = Permission.query.filter(Permission.name.in_(permissions)).all() + if len(perms) < len(permissions): + raise BadRequest("Invalid permission name") + role.permissions = list(perms) db.session.commit() From e1f919bd20e55ac8efcc8016d102ef9e8d413d82 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 04:19:57 +0100 Subject: [PATCH 369/446] feat(scheduler): Add function to add scheduled tasks programmatically --- flaschengeist/plugins/scheduler.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/flaschengeist/plugins/scheduler.py b/flaschengeist/plugins/scheduler.py index 2a4af6a..7f52db7 100644 --- a/flaschengeist/plugins/scheduler.py +++ b/flaschengeist/plugins/scheduler.py @@ -17,19 +17,25 @@ class __Task: _scheduled_tasks: dict[__Task] = dict() +def add_scheduled(id: str, function, replace=False, **kwargs): + if id not in _scheduled_tasks or replace: + _scheduled_tasks[id] = __Task(function, **kwargs) + logger.info(f"Registered task: {id}") + else: + logger.debug(f"Skipping already registered task: {id}") + + def scheduled(id: str, replace=False, **kwargs): """ kwargs: days, hours, minutes """ def real_decorator(function): - if id not in _scheduled_tasks or replace: - logger.info(f"Registered task: {id}") - _scheduled_tasks[id] = __Task(function, **kwargs) - else: - logger.debug(f"Skipping already registered task: {id}") + add_scheduled(id, function, replace, **kwargs) return function + if not isinstance(id, str): + raise TypeError return real_decorator @@ -75,9 +81,11 @@ class SchedulerPlugin(Plugin): ) task.function() changed = True + else: + logger.debug(f"Skip task {id}, is scheduled for {last_run + task.interval}") if changed: # Remove not registered tasks for id in status.keys(): if id not in _scheduled_tasks.keys(): del status[id] - self.set_setting("status", status) + self.set_setting("status", status) From 25ba4d21aa77918d44db18c8ca84e76ac177d97b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 18 Dec 2021 04:23:53 +0100 Subject: [PATCH 370/446] feat(balance): Add option to allow active services to debit other users --- flaschengeist/flaschengeist.toml | 2 + flaschengeist/plugins/balance/__init__.py | 58 +++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index 77af5b4..d1351a7 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -119,3 +119,5 @@ If this was not you, please contact the support. # enabled = true # Enable a default limit, will be set if no other limit is set # limit = -10.00 +# Uncomment to allow active services to debit other users (requires events plugin) +# allow_service_debit = true diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index cb4d929..ca61a5f 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -3,29 +3,79 @@ Extends users plugin with balance functions """ -from werkzeug.local import LocalProxy from flask import Blueprint, current_app +from werkzeug.local import LocalProxy +from werkzeug.exceptions import NotFound from flaschengeist import logger -from flaschengeist.plugins import Plugin, before_update_user +from flaschengeist.plugins import Plugin, plugins_loaded, before_update_user +from flaschengeist.plugins.scheduler import add_scheduled from . import permissions, models +def service_debit(): + from flaschengeist.database import db + from flaschengeist.models import UtcDateTime + from flaschengeist.models.user import User + from flaschengeist.controller import roleController + from flaschengeist_events.models import Service, Job + + role = BalancePlugin.plugin.get_setting("service_role", default=None) + if role is None: + try: + role = roleController.get("__has_service") + except NotFound: + role = roleController.create_role("__has_service", [permissions.DEBIT]) + BalancePlugin.plugin.set_setting("service_role", role.id) + else: + role = roleController.get(role) + + active_services = ( + User.query.join(Service, User.id_ == Service._user_id) + .join(Job, Service.job_) + .filter(Job.start <= UtcDateTime.current_utc(), Job.end.is_(None) | (Job.end >= UtcDateTime.current_utc())) + .distinct(User.id_) + .all() + ) + previous_services = BalancePlugin.plugin.get_setting("service_debit", default=[]) + + logger.debug(f"Found {len(active_services)} users doing their service.") + + if len(previous_services) > 0: + previous_services = User.query.filter(User.userid.in_(previous_services)).all() + # Remove not active users + for user in [u for u in previous_services if u not in active_services]: + user.roles_ = [r for r in user.roles_ if r.id != role.id] + # Add active + for user in [u for u in active_services if u not in previous_services]: + if role not in user.roles_: + user.roles_.append(role) + db.session.commit() + BalancePlugin.plugin.set_setting("service_debit", [u.userid for u in active_services]) + + class BalancePlugin(Plugin): name = "balance" id = "dev.flaschengeist.balance" blueprint = Blueprint(name, __name__) permissions = permissions.permissions - plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name]) + plugin: "BalancePlugin" = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name]) models = models def __init__(self, config): super(BalancePlugin, self).__init__(config) - from . import routes, balance_controller + from . import routes + + @plugins_loaded + def post_loaded(*args, **kwargs): + if config.get("allow_service_debit", False) and "events" in current_app.config["FG_PLUGINS"]: + add_scheduled(f"{id}.service_debit", service_debit, minutes=1) @before_update_user def set_default_limit(user): + from . import balance_controller + try: limit = self.get_setting("limit") logger.debug("Setting default limit of {} to user {}".format(limit, user.userid)) From b8ac6eb462366074a4dbffa50c7e21c56eb8d053 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Dec 2021 13:06:36 +0100 Subject: [PATCH 371/446] fix(users): Readd `create` flag on set_roles --- flaschengeist/controller/userController.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 059edc2..c1de6d8 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -91,12 +91,26 @@ def update_user(user): db.session.commit() -def set_roles(user: User, roles: list[str]): +def set_roles(user: User, roles: list[str], create=False): + """Set roles of user + + Args: + user: User to set roles of + roles: List of role names + create: If set to true, create not existing roles + Raises: + BadRequest if invalid arguments given or not all roles found while `create` is set to false + """ + from roleController import create_role + if not isinstance(roles, list) and any([not isinstance(r, str) for r in roles]): raise BadRequest("Invalid role name") fetched = Role.query.filter(Role.name.in_(roles)).all() if len(fetched) < len(roles): - raise BadRequest("Invalid role name, role not found") + if not create: + raise BadRequest("Invalid role name, role not found") + # Create all new roles + fetched += [create_role(role_name) for role_name in roles if not any([role_name == r.name for r in fetched])] user.roles_ = fetched From 4df7f1cc01638963c46ca9d0adfd129aa227ad73 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Dec 2021 18:58:57 +0100 Subject: [PATCH 372/446] docs(plugins): Some more documentation on the plugin class --- flaschengeist/plugins/__init__.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 1b6f701..6932110 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -54,10 +54,21 @@ class Plugin: If your class uses custom models add a static property called ``models``""" blueprint = None # You have to override + """Override with a `flask.blueprint` if the plugin uses custom routes""" permissions = [] # You have to override + """Override to add custom permissions used by the plugin + + A good style is to name the permissions with a prefix related to the plugin name, + to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*. + """ id = "dev.flaschengeist.plugin" # You have to override + """Override with the unique ID of the plugin (Hint: FQN)""" name = "plugin" # You have to override + """Override with human readable name of the plugin""" models = None # You have to override + """Override with models module""" + migrations_path = None # Override this with the location of your db migrations directory + """Override with path to migration files, if custome db tables are used""" def __init__(self, config=None): """Constructor called by create_app @@ -68,17 +79,21 @@ class Plugin: def install(self): """Installation routine + Is always called with Flask application context """ pass def get_setting(self, name: str, **kwargs): """Get plugin setting from database + Args: name: string identifying the setting default: Default value Returns: Value stored in database (native python) + Raises: + `KeyError` if no such setting exists in the database """ try: setting = ( @@ -95,6 +110,7 @@ class Plugin: def set_setting(self, name: str, value): """Save setting in database + Args: name: String identifying the setting value: Value to be stored @@ -114,10 +130,22 @@ class Plugin: db.session.commit() def notify(self, user, text: str, data=None): + """Create a new notification for an user + + Args: + user: `flaschengeist.models.user.User` to notify + text: Visibile notification text + data: Optional data passed to the notificaton + Returns: + ID of the created `flaschengeist.models.notification.Notification` + + Hint: use the data for frontend actions. + """ if not user.deleted: n = Notification(text=text, data=data, plugin=self.id, user_=user) db.session.add(n) db.session.commit() + return n.id def serialize(self): """Serialize a plugin into a dict From d9be9430db8c0f1c7bda89aada5826afcb48f56a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Dec 2021 19:01:27 +0100 Subject: [PATCH 373/446] docs(misc): Some more documentation fixes --- flaschengeist/__init__.py | 8 +++----- flaschengeist/controller/__init__.py | 1 + flaschengeist/controller/userController.py | 2 +- flaschengeist/utils/__init__.py | 1 + 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 8980c61..8a5e19e 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -1,8 +1,4 @@ -""" Server-package - - Initialize app, CORS, database and add it to the application. - -""" +"""Flaschengeist""" import logging import pkg_resources from pathlib import Path @@ -10,5 +6,7 @@ from werkzeug.local import LocalProxy __version__ = pkg_resources.get_distribution("flaschengeist").version _module_path = Path(__file__).parent +__pdoc__ = {} logger: logging.Logger = LocalProxy(lambda: logging.getLogger(__name__)) +__pdoc__["logger"] = "Flaschengeist's logger instance (`werkzeug.local.LocalProxy`)" diff --git a/flaschengeist/controller/__init__.py b/flaschengeist/controller/__init__.py index e69de29..9967031 100644 --- a/flaschengeist/controller/__init__.py +++ b/flaschengeist/controller/__init__.py @@ -0,0 +1 @@ +"""Basic controllers for interaction with the Flaschengeist core""" diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index c1de6d8..2ab781a 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -99,7 +99,7 @@ def set_roles(user: User, roles: list[str], create=False): roles: List of role names create: If set to true, create not existing roles Raises: - BadRequest if invalid arguments given or not all roles found while `create` is set to false + BadRequest if invalid arguments given or not all roles found while *create* is set to false """ from roleController import create_role diff --git a/flaschengeist/utils/__init__.py b/flaschengeist/utils/__init__.py index e69de29..d85bd72 100644 --- a/flaschengeist/utils/__init__.py +++ b/flaschengeist/utils/__init__.py @@ -0,0 +1 @@ +"""Common utilities""" From 22fbb526bb2f2bf5912dbd31dc8f1029b1732c9b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Dec 2021 19:01:48 +0100 Subject: [PATCH 374/446] fix(balance): Replace deprecated sqlalchemy functions --- flaschengeist/plugins/balance/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 23bb32c..1ba206b 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -40,7 +40,7 @@ class Transaction(db.Model, ModelSerializeMixin): @sender_id.expression def sender_id(cls): - return db.select([User.userid]).where(cls._sender_id == User.id_).as_scalar() + return db.select([User.userid]).where(cls._sender_id == User.id_).scalar_subquery() @hybrid_property def receiver_id(self): @@ -48,7 +48,7 @@ class Transaction(db.Model, ModelSerializeMixin): @receiver_id.expression def receiver_id(cls): - return db.select([User.userid]).where(cls._receiver_id == User.id_).as_scalar() + return db.select([User.userid]).where(cls._receiver_id == User.id_).scalar_subquery() @property def author_id(self): From 9b42d2b5b74c112dc22c60292c78feeb9ff4a21c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 20 Dec 2021 00:56:16 +0100 Subject: [PATCH 375/446] fix(docs): Rename README --- readme.md => README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename readme.md => README.md (100%) diff --git a/readme.md b/README.md similarity index 100% rename from readme.md rename to README.md From aaec6b43ae697b96f766002dff4440793ec20269 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 21 Dec 2021 22:56:03 +0100 Subject: [PATCH 376/446] tests: Fix tests for current backend --- setup.cfg | 2 +- tests/conftest.py | 10 +++++++--- tests/data.sql | 8 ++++++-- tests/test_auth.py | 4 ++-- tests/test_events.py | 17 ----------------- 5 files changed, 16 insertions(+), 25 deletions(-) delete mode 100644 tests/test_events.py diff --git a/setup.cfg b/setup.cfg index b5347c8..982d0ab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,7 +35,7 @@ install_requires = [options.extras_require] argon = argon2-cffi ldap = flask_ldapconn; ldap3 -test = pytest; coverage +tests = pytest; pytest-depends; coverage [options.package_data] * = *.toml diff --git a/tests/conftest.py b/tests/conftest.py index 5290fd8..f1dbb31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ import pytest from flaschengeist import database from flaschengeist.app import create_app, install_all - +from flask_migrate import upgrade # read in SQL for populating test data with open(os.path.join(os.path.dirname(__file__), "data.sql"), "r") as f: @@ -25,12 +25,16 @@ def app(): app = create_app( { "TESTING": True, - "DATABASE": {"file_path": f"/{db_path}"}, + "DATABASE": { + "engine": "sqlite", + "database": f"/{db_path}" + }, "LOGGING": {"level": "DEBUG"}, } ) with app.app_context(): - install_all() + upgrade(directory='migrations', revision='heads') + # install_all() engine = database.db.engine with engine.connect() as connection: for statement in _data_sql: diff --git a/tests/data.sql b/tests/data.sql index 6c6a989..98bb489 100644 --- a/tests/data.sql +++ b/tests/data.sql @@ -1,4 +1,8 @@ -INSERT INTO user ('userid', 'firstname', 'lastname', 'mail', 'id') VALUES ('user', 'Max', 'Mustermann', 'abc@def.gh', 1); +INSERT INTO "user" ('userid', 'firstname', 'lastname', 'mail', 'deleted', 'id') VALUES ('user', 'Max', 'Mustermann', 'abc@def.gh', 0, 1); +INSERT INTO "user" ('userid', 'firstname', 'lastname', 'mail', 'deleted', 'id') VALUES ('deleted_user', 'John', 'Doe', 'doe@example.com', 1, 2); -- Password = 1234 INSERT INTO user_attribute VALUES(1,1,'password',X'800495c4000000000000008cc0373731346161336536623932613830366664353038656631323932623134393936393561386463353536623037363761323037623238346264623833313265323333373066376233663462643332666332653766303537333564366335393133366463366234356539633865613835643661643435343931376636626663343163653333643635646530386634396231323061316236386162613164373663663333306564306463303737303733336136353363393538396536343266393865942e'); -INSERT INTO session ('expires', 'token', 'lifetime', 'id', 'user_id') VALUES ('2999-01-01 00:00:00', 'f4ecbe14be3527ca998143a49200e294', 600, 1, 1); \ No newline at end of file +INSERT INTO session ('expires', 'token', 'lifetime', 'id', 'user_id') VALUES ('2999-01-01 00:00:00', 'f4ecbe14be3527ca998143a49200e294', 600, 1, 1); +-- ROLES +INSERT INTO role ('name', 'id') VALUES ('role_1', 1); +INSERT INTO permission ('name', 'id') VALUES ('permission_1', 1); \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py index 5e7cadb..b4c60a5 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -15,9 +15,9 @@ def test_login(client): # Login successful assert result.status_code == 201 # User set correctly - assert json["user"]["userid"] == USERID + assert json["userid"] == USERID # Token works - assert client.get("/auth", headers={"Authorization": f"Bearer {json['session']['token']}"}).status_code == 200 + assert client.get("/auth", headers={"Authorization": f"Bearer {json['token']}"}).status_code == 200 def test_login_decorator(client): diff --git a/tests/test_events.py b/tests/test_events.py deleted file mode 100644 index a847ce1..0000000 --- a/tests/test_events.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest -from werkzeug.exceptions import BadRequest - -import flaschengeist.plugins.events.event_controller as event_controller -from flaschengeist.plugins.events.models import EventType - -VALID_TOKEN = "f4ecbe14be3527ca998143a49200e294" -EVENT_TYPE_NAME = "Test Type" - - -def test_create_event_type(app): - with app.app_context(): - type = event_controller.create_event_type(EVENT_TYPE_NAME) - assert isinstance(type, EventType) - - with pytest.raises(BadRequest): - event_controller.create_event_type(EVENT_TYPE_NAME) From 519eac8f258feac34e993cacd5ae6949d4db5a49 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 21 Dec 2021 22:58:04 +0100 Subject: [PATCH 377/446] feat(ci): Added pipeline for tests Add all supported, meaning by flaschengeist, python versions. Use slim image of python instead of alpine, because Pillow does not provide any wheels for musllibc --- .woodpecker/lint.yml | 2 +- .woodpecker/test.yml | 21 +++++++++++++++++++++ setup.cfg | 6 ++++-- 3 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 .woodpecker/test.yml diff --git a/.woodpecker/lint.yml b/.woodpecker/lint.yml index 0be3525..3116a12 100644 --- a/.woodpecker/lint.yml +++ b/.woodpecker/lint.yml @@ -1,6 +1,6 @@ pipeline: lint: - image: python:alpine + image: python:slim commands: - pip install black - black --check --line-length 120 --target-version=py37 . diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..a496f9c --- /dev/null +++ b/.woodpecker/test.yml @@ -0,0 +1,21 @@ +pipeline: + install: + image: python:${PYTHON}-slim + commands: + - python -m venv --clear venv + - export PATH=venv/bin:$PATH + - python -m pip install --upgrade pip + - pip install -v ".[tests]" + test: + image: python:${PYTHON}-slim + commands: + - export PATH=venv/bin:$PATH + - python -m pytest + + +matrix: + PYTHON: + - 3.10 + - 3.9 + - 3.8 + - 3.7 diff --git a/setup.cfg b/setup.cfg index 982d0ab..6d533e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,13 +29,15 @@ install_requires = sqlalchemy>=1.4.26 toml werkzeug - PyMySQL;platform_system=='Windows' - mysqlclient;platform_system!='Windows' + [options.extras_require] argon = argon2-cffi ldap = flask_ldapconn; ldap3 tests = pytest; pytest-depends; coverage +mysql = + PyMySQL;platform_system=='Windows' + mysqlclient;platform_system!='Windows' [options.package_data] * = *.toml From 702b894f757780c1827c0e591717c73607f32d52 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 22 Dec 2021 00:34:32 +0100 Subject: [PATCH 378/446] feat(tests): Added first unit test for the user controller --- flaschengeist/plugins/__init__.py | 2 +- tests/conftest.py | 13 +++----- tests/test_users.py | 52 +++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 tests/test_users.py diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 6932110..bcd96b3 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -131,7 +131,7 @@ class Plugin: def notify(self, user, text: str, data=None): """Create a new notification for an user - + Args: user: `flaschengeist.models.user.User` to notify text: Visibile notification text diff --git a/tests/conftest.py b/tests/conftest.py index f1dbb31..fb02e05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,7 @@ import tempfile import pytest from flaschengeist import database -from flaschengeist.app import create_app, install_all -from flask_migrate import upgrade +from flaschengeist.app import create_app # read in SQL for populating test data with open(os.path.join(os.path.dirname(__file__), "data.sql"), "r") as f: @@ -25,16 +24,14 @@ def app(): app = create_app( { "TESTING": True, - "DATABASE": { - "engine": "sqlite", - "database": f"/{db_path}" - }, + "DATABASE": {"engine": "sqlite", "database": f"/{db_path}"}, "LOGGING": {"level": "DEBUG"}, } ) with app.app_context(): - upgrade(directory='migrations', revision='heads') - # install_all() + database.db.create_all() + database.db.session.commit() + engine = database.db.engine with engine.connect() as connection: for statement in _data_sql: diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..4db8d07 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,52 @@ +import pytest +from werkzeug.exceptions import BadRequest, NotFound +from flaschengeist.controller import roleController, userController +from flaschengeist.models.user import User + +VALID_TOKEN = "f4ecbe14be3527ca998143a49200e294" + + +def test_get_user(app): + with app.app_context(): + user = userController.get_user("user") + assert user is not None and isinstance(user, User) + assert user.userid == "user" + + user = userController.get_user("deleted_user", deleted=True) + assert user is not None and isinstance(user, User) + assert user.userid == "deleted_user" + + with pytest.raises(NotFound): + user = userController.get_user("__does_not_exist__") + with pytest.raises(NotFound): + user = userController.get_user("__does_not_exist__", deleted=True) + with pytest.raises(NotFound): + user = userController.get_user("deleted_user") + + +def test_set_roles(app): + with app.app_context(): + user = userController.get_user("user") + userController.set_roles(user, []) + assert user.roles_ == [] + + userController.set_roles(user, ["role_1"]) + assert len(user.roles_) == 1 and user.roles_[0].id == 1 + + # Test unknown role + no create flag -> raise no changes + with pytest.raises(BadRequest): + userController.set_roles(user, ["__custom__"]) + assert len(user.roles_) == 1 + + userController.set_roles(user, ["__custom__"], create=True) + assert len(user.roles_) == 1 and user.roles_[0].name == "__custom__" + assert roleController.get("__custom__").id == user.roles_[0].id + + userController.set_roles(user, ["__custom__"], create=True) + assert len(user.roles_) == 1 + + userController.set_roles(user, ["__custom__", "role_1"]) + assert len(user.roles_) == 2 + + userController.set_roles(user, []) + assert len(user.roles_) == 0 From 016ed7739a77d4c0457449d206b15606a93d51f9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 22 Dec 2021 00:36:41 +0100 Subject: [PATCH 379/446] fix(db): Fix Serial column type for SQLite --- flaschengeist/models/__init__.py | 2 +- tests/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index f4ab8ec..8cf3850 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -48,7 +48,7 @@ class Serial(TypeDecorator): """Same as MariaDB Serial used for IDs""" cache_ok = True - impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite") + impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER(), "sqlite") class UtcDateTime(TypeDecorator): diff --git a/tests/conftest.py b/tests/conftest.py index fb02e05..e5f798f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,7 +31,7 @@ def app(): with app.app_context(): database.db.create_all() database.db.session.commit() - + engine = database.db.engine with engine.connect() as connection: for statement in _data_sql: From 9bcba9c7f9eae70813bd8255904ba5fccaf1318d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 22 Dec 2021 00:37:52 +0100 Subject: [PATCH 380/446] fix(users): Fix import error inside `set_roles` --- flaschengeist/controller/userController.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 2ab781a..bd6e4b8 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -101,7 +101,7 @@ def set_roles(user: User, roles: list[str], create=False): Raises: BadRequest if invalid arguments given or not all roles found while *create* is set to false """ - from roleController import create_role + from .roleController import create_role if not isinstance(roles, list) and any([not isinstance(r, str) for r in roles]): raise BadRequest("Invalid role name") @@ -149,7 +149,7 @@ def get_user_by_role(role: Role): return User.query.join(User.roles_).filter_by(role_id=role.id).all() -def get_user(uid, deleted=False): +def get_user(uid, deleted=False) -> User: """Get an user by userid from database Args: uid: Userid to search for From 34ee95c66a645aff5f15834192952a66c69444c8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 23 Dec 2021 02:48:02 +0100 Subject: [PATCH 381/446] feat(cli): Split CLI commands into seperate files --- flaschengeist/cli/__init__.py | 111 ++++---------------------------- flaschengeist/cli/docs_cmd.py | 38 +++++++++++ flaschengeist/cli/export_cmd.py | 21 ++++++ flaschengeist/cli/run_cmd.py | 40 ++++++++++++ setup.cfg | 2 +- 5 files changed, 111 insertions(+), 101 deletions(-) create mode 100644 flaschengeist/cli/docs_cmd.py create mode 100644 flaschengeist/cli/export_cmd.py create mode 100644 flaschengeist/cli/run_cmd.py diff --git a/flaschengeist/cli/__init__.py b/flaschengeist/cli/__init__.py index 493dcfd..945a80b 100644 --- a/flaschengeist/cli/__init__.py +++ b/flaschengeist/cli/__init__.py @@ -1,10 +1,5 @@ -import pathlib -import subprocess import click -from os import environ -from flask import current_app -from flask.cli import FlaskGroup, run_command, with_appcontext -import pkg_resources +from flask.cli import FlaskGroup, with_appcontext from flaschengeist.app import create_app from flaschengeist.config import configure_logger @@ -63,98 +58,14 @@ def cli(): pass -@cli.command() -@with_appcontext -def install(): - """Install and initialize enabled plugins. +def main(*args, **kwargs): + from .plugin_cmd import plugin + from .export_cmd import export + from .docs_cmd import docs + from .run_cmd import run - Most plugins need to install custom tables into the database - running this command will lookup all enabled plugins and run - their database initalization routines. - """ - from flaschengeist.app import install_all - - install_all() - - -@cli.command() -@click.option("--output", "-o", help="Output file, default is stdout", type=click.Path()) -@click.option("--namespace", "-n", help="TS namespace for the interfaces", type=str, show_default=True) -@click.option("--plugin", "-p", help="Also export types for a plugin (even if disabled)", multiple=True, type=str) -@click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True) -def export(namespace, output, no_core, plugin): - from flaschengeist import models - from flaschengeist import logger - from .InterfaceGenerator import InterfaceGenerator - - gen = InterfaceGenerator(namespace, output, logger) - if not no_core: - gen.run(models) - if plugin: - for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"): - if len(plugin) == 0 or entry_point.name in plugin: - plg = entry_point.load() - if hasattr(plg, "models") and plg.models is not None: - gen.run(plg.models) - gen.write() - - -@cli.command() -@click.option("--host", help="set hostname to listen on", default="127.0.0.1", show_default=True) -@click.option("--port", help="set port to listen on", type=int, default=5000, show_default=True) -@click.option("--debug", help="run in debug mode", is_flag=True) -@with_appcontext -@click.pass_context -def run(ctx, host, port, debug): - """Run Flaschengeist using a development server.""" - - class PrefixMiddleware(object): - def __init__(self, app, prefix=""): - self.app = app - self.prefix = prefix - - def __call__(self, environ, start_response): - - if environ["PATH_INFO"].startswith(self.prefix): - environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :] - environ["SCRIPT_NAME"] = self.prefix - return self.app(environ, start_response) - else: - start_response("404", [("Content-Type", "text/plain")]) - return ["This url does not belong to the app.".encode()] - - from flaschengeist.config import config - - # re configure logger, as we are no logger in CLI mode - configure_logger() - current_app.wsgi_app = PrefixMiddleware(current_app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", "")) - if debug: - environ["FLASK_DEBUG"] = "1" - environ["FLASK_ENV"] = "development" - - ctx.invoke(run_command, host=host, port=port, debugger=debug) - - -@cli.command() -@click.option( - "--output", - "-o", - help="Documentation output path", - default="./docs", - type=click.Path(file_okay=False, path_type=pathlib.Path), -) -def docs(output: pathlib.Path): - """Generate and export API documentation using pdoc3""" - output.mkdir(parents=True, exist_ok=True) - command = [ - "python", - "-m", - "pdoc", - "--skip-errors", - "--html", - "--output-dir", - str(output), - "flaschengeist", - ] - click.echo(f"Running command: {command}") - subprocess.check_call(command) + cli.add_command(plugin) + cli.add_command(export) + cli.add_command(docs) + cli.add_command(run) + cli(*args, **kwargs) diff --git a/flaschengeist/cli/docs_cmd.py b/flaschengeist/cli/docs_cmd.py new file mode 100644 index 0000000..54faf02 --- /dev/null +++ b/flaschengeist/cli/docs_cmd.py @@ -0,0 +1,38 @@ +import click +import pathlib +import subprocess + + +@click.command() +@click.option( + "--output", + "-o", + help="Documentation output path", + default="./docs", + type=click.Path(file_okay=False, path_type=pathlib.Path), +) +@click.pass_context +def docs(ctx: click.Context, output: pathlib.Path): + """Generate and export API documentation using pdoc""" + import pkg_resources + + try: + pkg_resources.get_distribution("pdoc>=8.0.1") + except pkg_resources.DistributionNotFound: + click.echo( + f"Error: pdoc was not found, maybe you need to install it. Try:\n" "\n" '$ pip install "pdoc>=8.0.1"\n' + ) + ctx.exit(1) + output.mkdir(parents=True, exist_ok=True) + command = [ + "python", + "-m", + "pdoc", + "--docformat", + "google", + "--output-directory", + str(output), + "flaschengeist", + ] + click.echo(f"Running command: {command}") + subprocess.check_call(command) diff --git a/flaschengeist/cli/export_cmd.py b/flaschengeist/cli/export_cmd.py new file mode 100644 index 0000000..6f93c70 --- /dev/null +++ b/flaschengeist/cli/export_cmd.py @@ -0,0 +1,21 @@ +import click + + +@click.command() +@click.option("--output", "-o", help="Output file, default is stdout", type=click.Path()) +@click.option("--namespace", "-n", help="TS namespace for the interfaces", type=str, show_default=True) +@click.option("--plugin", "-p", help="Also export types for a plugin (even if disabled)", multiple=True, type=str) +@click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True) +def export(namespace, output, no_core, plugin): + from flaschengeist import logger, models + from flaschengeist.app import get_plugins + from .InterfaceGenerator import InterfaceGenerator + + gen = InterfaceGenerator(namespace, output, logger) + if not no_core: + gen.run(models) + if plugin: + for plugin_class in get_plugins(): + if (len(plugin) == 0 or plugin_class.id in plugin) and plugin_class.models is not None: + gen.run(plugin_class.models) + gen.write() diff --git a/flaschengeist/cli/run_cmd.py b/flaschengeist/cli/run_cmd.py new file mode 100644 index 0000000..5f0eec3 --- /dev/null +++ b/flaschengeist/cli/run_cmd.py @@ -0,0 +1,40 @@ +import click +from os import environ +from flask import current_app +from flask.cli import with_appcontext, run_command + + +class PrefixMiddleware(object): + def __init__(self, app, prefix=""): + self.app = app + self.prefix = prefix + + def __call__(self, environ, start_response): + + if environ["PATH_INFO"].startswith(self.prefix): + environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :] + environ["SCRIPT_NAME"] = self.prefix + return self.app(environ, start_response) + else: + start_response("404", [("Content-Type", "text/plain")]) + return ["This url does not belong to the app.".encode()] + + +@click.command() +@click.option("--host", help="set hostname to listen on", default="127.0.0.1", show_default=True) +@click.option("--port", help="set port to listen on", type=int, default=5000, show_default=True) +@click.option("--debug", help="run in debug mode", is_flag=True) +@with_appcontext +@click.pass_context +def run(ctx, host, port, debug): + """Run Flaschengeist using a development server.""" + from flaschengeist.config import config, configure_logger + + # re configure logger, as we are no logger in CLI mode + configure_logger() + current_app.wsgi_app = PrefixMiddleware(current_app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", "")) + if debug: + environ["FLASK_DEBUG"] = "1" + environ["FLASK_ENV"] = "development" + + ctx.invoke(run_command, host=host, port=port, debugger=debug) diff --git a/setup.cfg b/setup.cfg index 6d533e9..31cb338 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,7 +44,7 @@ mysql = [options.entry_points] console_scripts = - flaschengeist = flaschengeist.cli:cli + flaschengeist = flaschengeist.cli:main flask.commands = ldap = flaschengeist.plugins.auth_ldap.cli:ldap users = flaschengeist.plugins.users.cli:users From a6cbc002f6d180297a1d984ee6db24dfe9728b8e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 23 Dec 2021 02:48:28 +0100 Subject: [PATCH 382/446] fix(cli): InterfaceGenerator now works even without a namespace defined --- flaschengeist/cli/InterfaceGenerator.py | 38 ++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/flaschengeist/cli/InterfaceGenerator.py b/flaschengeist/cli/InterfaceGenerator.py index 177a1a9..3bcb545 100644 --- a/flaschengeist/cli/InterfaceGenerator.py +++ b/flaschengeist/cli/InterfaceGenerator.py @@ -1,3 +1,4 @@ +import io import sys import inspect import logging @@ -93,15 +94,32 @@ class InterfaceGenerator: self.basename = models.__name__ self.walker(("models", models)) + def _write_types(self): + TYPE = "type {name} = {alias};\n" + INTERFACE = "interface {name} {{\n{properties}}}\n" + PROPERTY = "\t{name}{modifier}: {type};\n" + + buffer = io.StringIO() + for cls, props in self.classes.items(): + if isinstance(props, str): + buffer.write(TYPE.format(name=cls, alias=props)) + else: + buffer.write( + INTERFACE.format( + name=cls, + properties="".join( + [PROPERTY.format(name=name, modifier=props[name][0], type=props[name][1]) for name in props] + ), + ) + ) + return buffer + def write(self): with (open(self.filename, "w") if self.filename else sys.stdout) as file: - file.write("declare namespace {} {{\n".format(self.namespace)) - for cls, params in self.classes.items(): - if isinstance(params, str): - file.write("\ttype {} = {};\n".format(cls, params)) - else: - file.write("\tinterface {} {{\n".format(cls)) - for name in params: - file.write("\t\t{}{}: {};\n".format(name, *params[name])) - file.write("\t}\n") - file.write("}\n") + if self.namespace: + file.write(f"declare namespace {self.namespace} {{\n") + for line in self._write_types().getvalue().split("\n"): + file.write(f"\t{line}\n") + file.write("}\n") + else: + file.write(self._write_types().getvalue()) From 54a789b7727214e8376ce65dc1bd9f7360851982 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 26 Dec 2021 15:42:31 +0100 Subject: [PATCH 383/446] fix(docs): PIP 21.0+ is required, some minor improvements --- README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 672f7d9..a6ad215 100644 --- a/README.md +++ b/README.md @@ -7,20 +7,28 @@ This is the backend of the Flaschengeist. ### Requirements - `mysql` or `mariadb` - maybe `libmariadb` development files[1] -- python 3.7+ +- pip 21.0+ -[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on +*[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on Linux / Mac the faster `mysqlclient` is used, if it is not already installed installing from pypi requires the -development files for `libmariadb` to be present on your system. +development files for `libmariadb` to be present on your system.* ### Install python files - pip3 install --user . +It is recommended to upgrade pip to the latest version before installing: + + python -m pip install --upgrade pip + +Default installation with *mariadb*/*mysql* support: + + pip3 install --user ".[mysql]" + or with ldap support pip3 install --user ".[ldap]" + or if you want to also run the tests: - pip3 install --user ".[ldap,test]" + pip3 install --user ".[ldap,tests]" You will also need a MySQL driver, recommended drivers are - `mysqlclient` From fb50ed05be36e0b07f1cf98e804c2b793afa31de Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 26 Dec 2021 15:44:04 +0100 Subject: [PATCH 384/446] deps: Require at lease python 3.9, fixes #22 --- .woodpecker/test.yml | 2 -- README.md | 1 + setup.cfg | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml index a496f9c..87b15c3 100644 --- a/.woodpecker/test.yml +++ b/.woodpecker/test.yml @@ -17,5 +17,3 @@ matrix: PYTHON: - 3.10 - 3.9 - - 3.8 - - 3.7 diff --git a/README.md b/README.md index a6ad215..4c766fb 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This is the backend of the Flaschengeist. ### Requirements - `mysql` or `mariadb` - maybe `libmariadb` development files[1] +- python 3.9+ - pip 21.0+ *[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on diff --git a/setup.cfg b/setup.cfg index 31cb338..995eb8a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,7 +19,7 @@ classifiers = [options] include_package_data = True -python_requires = >=3.7 +python_requires = >=3.9 packages = find: install_requires = Flask >= 2.0 From 90999bbefbb447e57628a20b90f74822485fd605 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 13 Feb 2022 14:31:55 +0100 Subject: [PATCH 385/446] chore(core): Seperated logic from the plugin code, reduces imports --- flaschengeist/controller/pluginController.py | 76 ++++++++++++++++++++ flaschengeist/plugins/__init__.py | 49 ++++--------- 2 files changed, 89 insertions(+), 36 deletions(-) create mode 100644 flaschengeist/controller/pluginController.py diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py new file mode 100644 index 0000000..7dbe678 --- /dev/null +++ b/flaschengeist/controller/pluginController.py @@ -0,0 +1,76 @@ +"""Controller for Plugin logic + +Used by plugins for setting and notification functionality. +""" + +import sqlalchemy +from ..database import db +from ..models.setting import _PluginSetting +from ..models.notification import Notification + + +def get_setting(plugin_id: str, name: str, **kwargs): + """Get plugin setting from database + + Args: + plugin_id: ID of the plugin + name: string identifying the setting + default: Default value + Returns: + Value stored in database (native python) + Raises: + `KeyError` if no such setting exists in the database + """ + try: + setting = ( + _PluginSetting.query.filter(_PluginSetting.plugin == plugin_id).filter(_PluginSetting.name == name).one() + ) + return setting.value + except sqlalchemy.orm.exc.NoResultFound: + if "default" in kwargs: + return kwargs["default"] + else: + raise KeyError + + +def set_setting(plugin_id: str, name: str, value): + """Save setting in database + + Args: + plugin_id: ID of the plugin + name: String identifying the setting + value: Value to be stored + """ + setting = ( + _PluginSetting.query.filter(_PluginSetting.plugin == plugin_id) + .filter(_PluginSetting.name == name) + .one_or_none() + ) + if setting is not None: + if value is None: + db.session.delete(setting) + else: + setting.value = value + else: + db.session.add(_PluginSetting(plugin=plugin_id, name=name, value=value)) + db.session.commit() + + +def notify(plugin_id: str, user, text: str, data=None): + """Create a new notification for an user + + Args: + plugin_id: ID of the plugin + user: `flaschengeist.models.user.User` to notify + text: Visibile notification text + data: Optional data passed to the notificaton + Returns: + ID of the created `flaschengeist.models.notification.Notification` + + Hint: use the data for frontend actions. + """ + if not user.deleted: + n = Notification(text=text, data=data, plugin=plugin_id, user_=user) + db.session.add(n) + db.session.commit() + return n.id diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index bcd96b3..2db90c5 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -1,13 +1,7 @@ -import sqlalchemy import pkg_resources from werkzeug.datastructures import FileStorage from werkzeug.exceptions import MethodNotAllowed, NotFound -from flaschengeist.controller import imageController - -from flaschengeist.database import db -from flaschengeist.models.notification import Notification from flaschengeist.models.user import _Avatar, User -from flaschengeist.models.setting import _PluginSetting from flaschengeist.utils.hook import HookBefore, HookAfter plugins_installed = HookAfter("plugins.installed") @@ -95,18 +89,9 @@ class Plugin: Raises: `KeyError` if no such setting exists in the database """ - try: - setting = ( - _PluginSetting.query.filter(_PluginSetting.plugin == self.name) - .filter(_PluginSetting.name == name) - .one() - ) - return setting.value - except sqlalchemy.orm.exc.NoResultFound: - if "default" in kwargs: - return kwargs["default"] - else: - raise KeyError + from ..controller import pluginController + + return pluginController.get_setting(self.id) def set_setting(self, name: str, value): """Save setting in database @@ -115,19 +100,9 @@ class Plugin: name: String identifying the setting value: Value to be stored """ - setting = ( - _PluginSetting.query.filter(_PluginSetting.plugin == self.name) - .filter(_PluginSetting.name == name) - .one_or_none() - ) - if setting is not None: - if value is None: - db.session.delete(setting) - else: - setting.value = value - else: - db.session.add(_PluginSetting(plugin=self.name, name=name, value=value)) - db.session.commit() + from ..controller import pluginController + + return pluginController.set_setting(self.id, name, value) def notify(self, user, text: str, data=None): """Create a new notification for an user @@ -141,11 +116,9 @@ class Plugin: Hint: use the data for frontend actions. """ - if not user.deleted: - n = Notification(text=text, data=data, plugin=self.id, user_=user) - db.session.add(n) - db.session.commit() - return n.id + from ..controller import pluginController + + return pluginController.notify(self.id, user, text, data) def serialize(self): """Serialize a plugin into a dict @@ -245,6 +218,10 @@ class AuthPlugin(Plugin): MethodNotAllowed: If not supported by Backend Any valid HTTP exception """ + # By default save the image to the avatar, + # deleting would happen by unsetting it + from ..controller import imageController + user.avatar_ = imageController.upload_image(file) def delete_avatar(self, user: User): From 760ee9fe36182ccaaf556c0f229464e4d218792c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 13 Feb 2022 23:01:49 +0100 Subject: [PATCH 386/446] fix(cli): Fix logging when setting verbosity on the cli --- README.md | 6 +++++ flaschengeist/__init__.py | 7 +++--- flaschengeist/app.py | 14 +++++------ flaschengeist/cli/__init__.py | 40 ++++++++++++++++++++++++++----- flaschengeist/cli/run_cmd.py | 6 ++--- flaschengeist/config.py | 41 ++++++++++++++++++-------------- flaschengeist/flaschengeist.toml | 21 +++++++++------- flaschengeist/logging.toml | 18 +++++--------- 8 files changed, 94 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 4c766fb..08aa09f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ Then you can install the database tables and initial entries: $ flaschengeist install ### Run +Flaschengeist provides a CLI, based on the flask CLI, respectivly called `flaschengeist`. + +⚠️ When using the CLI for running Flaschengeist, please note that logging will happen as configured, +with the difference of the main logger will be forced to output to `stderr` and the logging level +of the CLI will override the logging level you have configured for the main logger. + $ flaschengeist run or with debug messages: diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 8a5e19e..76909c4 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -1,12 +1,11 @@ """Flaschengeist""" import logging -import pkg_resources +from importlib.metadata import version from pathlib import Path -from werkzeug.local import LocalProxy -__version__ = pkg_resources.get_distribution("flaschengeist").version +__version__ = version("flaschengeist") _module_path = Path(__file__).parent __pdoc__ = {} -logger: logging.Logger = LocalProxy(lambda: logging.getLogger(__name__)) +logger = logging.getLogger(__name__) __pdoc__["logger"] = "Flaschengeist's logger instance (`werkzeug.local.LocalProxy`)" diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 6cd99c5..86d35a6 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,6 +1,6 @@ import enum - import pkg_resources + from flask import Flask, current_app from flask_cors import CORS from datetime import datetime, date @@ -8,11 +8,11 @@ from flask.json import JSONEncoder, jsonify from sqlalchemy.exc import OperationalError from werkzeug.exceptions import HTTPException -from . import logger -from .plugins import AuthPlugin -from flaschengeist.config import config, configure_app -from flaschengeist.controller import roleController +from flaschengeist import logger from flaschengeist.utils.hook import Hook +from flaschengeist.plugins import AuthPlugin +from flaschengeist.controller import roleController +from flaschengeist.config import config, configure_app class CustomJSONEncoder(JSONEncoder): @@ -95,7 +95,7 @@ def install_all(): roleController.create_permissions(plugin.permissions) -def create_app(test_config=None, cli=False): +def create_app(test_config=None): app = Flask(__name__) app.json_encoder = CustomJSONEncoder CORS(app) @@ -103,7 +103,7 @@ def create_app(test_config=None, cli=False): with app.app_context(): from flaschengeist.database import db - configure_app(app, test_config, cli) + configure_app(app, test_config) db.init_app(app) __load_plugins(app) diff --git a/flaschengeist/cli/__init__.py b/flaschengeist/cli/__init__.py index 945a80b..e86e60c 100644 --- a/flaschengeist/cli/__init__.py +++ b/flaschengeist/cli/__init__.py @@ -1,7 +1,14 @@ +from os import environ +import sys import click +import logging + from flask.cli import FlaskGroup, with_appcontext +from flaschengeist import logger from flaschengeist.app import create_app -from flaschengeist.config import configure_logger + +LOGGING_MIN = 5 # TRACE (custom) +LOGGING_MAX = logging.ERROR def get_version(ctx, param, value): @@ -23,19 +30,37 @@ def get_version(ctx, param, value): ctx.exit() +def configure_logger(level): + """Reconfigure main logger""" + global logger + + # Handle TRACE -> meaning enable debug even for werkzeug + if level == 5: + level = 10 + logging.getLogger("werkzeug").setLevel(level) + + logger.setLevel(level) + environ["FG_LOGGING"] = logging.getLevelName(level) + for h in logger.handlers: + if isinstance(h, logging.StreamHandler) and h.name == "wsgi": + h.setLevel(level) + h.setStream(sys.stderr) + + @with_appcontext def verbosity(ctx, param, value): - """Toggle verbosity between WARNING <-> DEBUG""" + """Callback: Toggle verbosity between ERROR <-> TRACE""" + if not value or ctx.resilient_parsing: return - configure_logger(cli=30 - max(0, min(value * 10, 20))) + configure_logger(LOGGING_MAX - max(LOGGING_MIN, min(value * 10, LOGGING_MAX - LOGGING_MIN))) @click.group( cls=FlaskGroup, add_version_option=False, add_default_commands=False, - create_app=lambda: create_app(cli=30), + create_app=create_app, ) @click.option( "--version", @@ -59,12 +84,15 @@ def cli(): def main(*args, **kwargs): - from .plugin_cmd import plugin + # from .plugin_cmd import plugin from .export_cmd import export from .docs_cmd import docs from .run_cmd import run - cli.add_command(plugin) + # Override logging level + environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX)) + + # cli.add_command(plugin) cli.add_command(export) cli.add_command(docs) cli.add_command(run) diff --git a/flaschengeist/cli/run_cmd.py b/flaschengeist/cli/run_cmd.py index 5f0eec3..ddca7c8 100644 --- a/flaschengeist/cli/run_cmd.py +++ b/flaschengeist/cli/run_cmd.py @@ -28,13 +28,11 @@ class PrefixMiddleware(object): @click.pass_context def run(ctx, host, port, debug): """Run Flaschengeist using a development server.""" - from flaschengeist.config import config, configure_logger + from flaschengeist.config import config - # re configure logger, as we are no logger in CLI mode - configure_logger() current_app.wsgi_app = PrefixMiddleware(current_app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", "")) if debug: environ["FLASK_DEBUG"] = "1" environ["FLASK_ENV"] = "development" - ctx.invoke(run_command, host=host, port=port, debugger=debug) + ctx.invoke(run_command, reload=True, host=host, port=port, debugger=debug) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 5b7031c..9cf3b69 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -41,36 +41,41 @@ def read_configuration(test_config): update_dict(config, test_config) -def configure_logger(cli=False): - global config +def configure_logger(): + """Configure the logger + + force_console: Force a console handler + """ + + def set_level(level): + # TRACE means even with werkzeug's request traces + if isinstance(level, str) and level.lower() == "trace": + level = "DEBUG" + logger_config["loggers"]["werkzeug"] = {"level": level} + logger_config["loggers"]["flaschengeist"] = {"level": level} + logger_config["handlers"]["wsgi"]["level"] = level + # Read default config logger_config = toml.load(_module_path / "logging.toml") - if "LOGGING" in config: # Override with user config update_dict(logger_config, config.get("LOGGING")) # Check for shortcuts - if "level" in config["LOGGING"] or isinstance(cli, int): - level = cli if cli and isinstance(cli, int) else config["LOGGING"]["level"] - logger_config["loggers"]["flaschengeist"] = {"level": level} - logger_config["handlers"]["console"]["level"] = level - logger_config["handlers"]["file"]["level"] = level - if cli is True or not config["LOGGING"].get("console", True): - logger_config["handlers"]["console"]["level"] = "CRITICAL" - if not cli and isinstance(config["LOGGING"].get("file", False), str): - logger_config["root"]["handlers"].append("file") - logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"] - Path(config["LOGGING"]["file"]).parent.mkdir(parents=True, exist_ok=True) - else: - del logger_config["handlers"]["file"] + if "level" in config["LOGGING"]: + set_level(config["LOGGING"]["level"]) + + # Override logging, used e.g. by CLI + if "FG_LOGGING" in os.environ: + set_level(os.environ.get("FG_LOGGING", "CRITICAL")) + logging.config.dictConfig(logger_config) -def configure_app(app, test_config=None, cli=False): +def configure_app(app, test_config=None): global config read_configuration(test_config) - configure_logger(cli) + configure_logger() # Always enable this builtin plugins! update_dict( diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index d1351a7..e2d6dc4 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -19,15 +19,20 @@ secret_key = "V3ryS3cr3t" [LOGGING] # You can override all settings from the logging.toml here -# E.g. override the formatters etc -# -# Logging level, possible: DEBUG INFO WARNING ERROR +# Default: Logging to WSGI stream (commonly stderr) + +# Logging level, possible: TRACE DEBUG INFO WARNING ERROR CRITICAL +# On TRACE level additionally every request will get logged level = "DEBUG" -# Logging to a file is simple, just add the path -# file = "/tmp/flaschengeist-debug.log" -file = false -# Uncomment to disable console logging -# console = false + +# If you want the logger to log to a file, you could use: +#[LOGGING.handlers.file] +# class = "logging.handlers.WatchedFileHandler" +# level = "WARNING" +# formatter = "extended" +# encoding = "utf8" +# filename = "flaschengeist.log" + [DATABASE] # engine = "mysql" (default) diff --git a/flaschengeist/logging.toml b/flaschengeist/logging.toml index 3891623..fb7b02f 100644 --- a/flaschengeist/logging.toml +++ b/flaschengeist/logging.toml @@ -6,22 +6,16 @@ disable_existing_loggers = false [formatters] [formatters.simple] - format = "%(asctime)s - %(levelname)s - %(message)s" + format = "[%(asctime)s] %(levelname)s - %(message)s" [formatters.extended] - format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s" + format = "[%(asctime)s] %(levelname)s %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(message)s" [handlers] - [handlers.console] + [handlers.wsgi] + stream = "ext://flask.logging.wsgi_errors_stream" class = "logging.StreamHandler" - level = "DEBUG" formatter = "simple" - stream = "ext://sys.stderr" - [handlers.file] - class = "logging.handlers.WatchedFileHandler" - level = "WARNING" - formatter = "extended" - encoding = "utf8" - filename = "flaschengeist.log" + level = "DEBUG" [loggers] [loggers.werkzeug] @@ -29,4 +23,4 @@ disable_existing_loggers = false [root] level = "WARNING" - handlers = ["console"] \ No newline at end of file + handlers = ["wsgi"] \ No newline at end of file From e82d830410aaff3df40f28860f707ebe25b71ef9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 21 Feb 2022 21:02:45 +0100 Subject: [PATCH 387/446] fix(app): Fix import_name for flask application --- flaschengeist/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 86d35a6..1ab5976 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -96,7 +96,7 @@ def install_all(): def create_app(test_config=None): - app = Flask(__name__) + app = Flask("flaschengeist") app.json_encoder = CustomJSONEncoder CORS(app) From 1484d678cee3c9767558a9f0bf3600a37f261e8e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 21 Feb 2022 21:03:15 +0100 Subject: [PATCH 388/446] feat(security): Enforce secret key for flask application. --- flaschengeist/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 9cf3b69..3dfb078 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -89,10 +89,10 @@ def configure_app(app, test_config=None): ) if "secret_key" not in config["FLASCHENGEIST"]: - logger.warning("No secret key was configured, please configure one for production systems!") - app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29" - else: - app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] + logger.critical("No secret key was configured, please configure one for production systems!") + raise RuntimeError("No secret key was configured") + + app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] if test_config is not None: config["DATABASE"]["engine"] = "sqlite" From c5db9320653156ac32612dd92a98d9a4b627fdea Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 21 Feb 2022 22:24:33 +0100 Subject: [PATCH 389/446] chore(clean): Drop `_module_path` from flaschengeist --- flaschengeist/__init__.py | 2 -- flaschengeist/config.py | 10 +++++----- flaschengeist/flaschengeist.toml | 23 +++++++++++------------ 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/flaschengeist/__init__.py b/flaschengeist/__init__.py index 76909c4..e3d4575 100644 --- a/flaschengeist/__init__.py +++ b/flaschengeist/__init__.py @@ -1,10 +1,8 @@ """Flaschengeist""" import logging from importlib.metadata import version -from pathlib import Path __version__ = version("flaschengeist") -_module_path = Path(__file__).parent __pdoc__ = {} logger = logging.getLogger(__name__) diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 3dfb078..49a9a2f 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -1,12 +1,12 @@ import os import toml -import logging.config import collections.abc from pathlib import Path +from logging.config import dictConfig from werkzeug.middleware.proxy_fix import ProxyFix -from flaschengeist import _module_path, logger +from flaschengeist import logger # Default config: config = {"DATABASE": {"engine": "mysql", "port": 3306}} @@ -23,7 +23,7 @@ def update_dict(d, u): def read_configuration(test_config): global config - paths = [_module_path] + paths = [Path(__file__).parent] if not test_config: paths.append(Path.home() / ".config") @@ -56,7 +56,7 @@ def configure_logger(): logger_config["handlers"]["wsgi"]["level"] = level # Read default config - logger_config = toml.load(_module_path / "logging.toml") + logger_config = toml.load(Path(__file__).parent /"logging.toml") if "LOGGING" in config: # Override with user config update_dict(logger_config, config.get("LOGGING")) @@ -68,7 +68,7 @@ def configure_logger(): if "FG_LOGGING" in os.environ: set_level(os.environ.get("FG_LOGGING", "CRITICAL")) - logging.config.dictConfig(logger_config) + dictConfig(logger_config) def configure_app(app, test_config=None): diff --git a/flaschengeist/flaschengeist.toml b/flaschengeist/flaschengeist.toml index e2d6dc4..bbd9807 100644 --- a/flaschengeist/flaschengeist.toml +++ b/flaschengeist/flaschengeist.toml @@ -12,10 +12,12 @@ root = "/api" secret_key = "V3ryS3cr3t" # Domain used by frontend -[scheduler] -# Possible values are: "passive_web" (default), "active_web" and "system" -# See documentation -# cron = "passive_web" +[DATABASE] +# engine = "mysql" (default) +host = "localhost" +user = "flaschengeist" +password = "flaschengeist" +database = "flaschengeist" [LOGGING] # You can override all settings from the logging.toml here @@ -33,14 +35,6 @@ level = "DEBUG" # encoding = "utf8" # filename = "flaschengeist.log" - -[DATABASE] -# engine = "mysql" (default) -host = "localhost" -user = "flaschengeist" -password = "flaschengeist" -database = "flaschengeist" - [FILES] # Path for file / image uploads data_path = "./data" @@ -54,6 +48,11 @@ allowed_mimetypes = [ "image/webp" ] +[scheduler] +# Possible values are: "passive_web" (default), "active_web" and "system" +# See documentation +# cron = "passive_web" + [auth_ldap] # Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap # host = "localhost" From e510c54bd8ffd6ea9fef23740e0733db6025f69b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 22 Feb 2022 11:07:15 +0100 Subject: [PATCH 390/446] chore(clean): Fix codestyle of config.py --- .woodpecker/lint.yml | 2 +- flaschengeist/config.py | 2 +- flaschengeist/plugins/users/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.woodpecker/lint.yml b/.woodpecker/lint.yml index 3116a12..7cfd62b 100644 --- a/.woodpecker/lint.yml +++ b/.woodpecker/lint.yml @@ -3,4 +3,4 @@ pipeline: image: python:slim commands: - pip install black - - black --check --line-length 120 --target-version=py37 . + - black --check --line-length 120 --target-version=py39 . diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 49a9a2f..3adae22 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -56,7 +56,7 @@ def configure_logger(): logger_config["handlers"]["wsgi"]["level"] = level # Read default config - logger_config = toml.load(Path(__file__).parent /"logging.toml") + logger_config = toml.load(Path(__file__).parent / "logging.toml") if "LOGGING" in config: # Override with user config update_dict(logger_config, config.get("LOGGING")) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index b485f97..eef0041 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -105,7 +105,7 @@ def frontend(userid, current_session): raise Forbidden if request.method == "POST": - if request.content_length > 1024 ** 2: + if request.content_length > 1024**2: raise BadRequest current_session.user_.set_attribute("frontend", request.get_json()) return no_content() From 2f4472e70821e8dbfe9c14bbf93d6f614c1077d5 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 23 Feb 2022 15:19:45 +0100 Subject: [PATCH 391/446] feat(docs): Added more documentation on plugins --- .gitignore | 3 ++- docs/plugin_development.md | 13 +++++++++++++ flaschengeist/cli/docs_cmd.py | 2 +- flaschengeist/plugins/__init__.py | 5 +++++ 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 docs/plugin_development.md diff --git a/.gitignore b/.gitignore index aef08d3..52b370e 100644 --- a/.gitignore +++ b/.gitignore @@ -67,7 +67,8 @@ instance/ # Sphinx documentation docs/_build/ -docs/ +# pdoc +docs/html # PyBuilder target/ diff --git a/docs/plugin_development.md b/docs/plugin_development.md new file mode 100644 index 0000000..21b69c5 --- /dev/null +++ b/docs/plugin_development.md @@ -0,0 +1,13 @@ +# Plugin Development + +## File Structure + + - your_plugin/ + - __init__.py + - ... + - ... + - setup.cfg + +The basic layout of a plugin is quite simple, you will only need the `setup.cfg` or `setup.py` and +the package containing your plugin code, at lease a `__init__.py` file with your `Plugin` class. + diff --git a/flaschengeist/cli/docs_cmd.py b/flaschengeist/cli/docs_cmd.py index 54faf02..f33a697 100644 --- a/flaschengeist/cli/docs_cmd.py +++ b/flaschengeist/cli/docs_cmd.py @@ -8,7 +8,7 @@ import subprocess "--output", "-o", help="Documentation output path", - default="./docs", + default="./docs/html", type=click.Path(file_okay=False, path_type=pathlib.Path), ) @click.pass_context diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 2db90c5..f759d24 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -1,3 +1,8 @@ +"""Flaschengeist Plugins + +.. include:: docs/plugin_development.md + +""" import pkg_resources from werkzeug.datastructures import FileStorage from werkzeug.exceptions import MethodNotAllowed, NotFound From 6f35e17fbab3333623143132a3213187fd9b8511 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 21 Feb 2022 22:22:32 +0100 Subject: [PATCH 392/446] feat(plugins): Load metadata from entry points / distribution --- flaschengeist/app.py | 11 ++- flaschengeist/plugins/__init__.py | 47 ++++++++----- flaschengeist/plugins/auth_ldap/__init__.py | 4 +- flaschengeist/plugins/balance/__init__.py | 11 ++- flaschengeist/plugins/balance/routes.py | 23 ++++--- flaschengeist/plugins/message_mail.py | 4 +- flaschengeist/plugins/pricelist/__init__.py | 74 +++++++++++---------- flaschengeist/plugins/roles/__init__.py | 3 +- flaschengeist/plugins/scheduler.py | 16 ++--- flaschengeist/plugins/users/__init__.py | 3 +- setup.cfg | 2 + 11 files changed, 102 insertions(+), 96 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 1ab5976..f2f1664 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,10 +1,10 @@ import enum -import pkg_resources from flask import Flask, current_app from flask_cors import CORS from datetime import datetime, date from flask.json import JSONEncoder, jsonify +from importlib_metadata import entry_points from sqlalchemy.exc import OperationalError from werkzeug.exceptions import HTTPException @@ -41,18 +41,15 @@ def __load_plugins(app): logger.debug("Search for plugins") app.config["FG_PLUGINS"] = {} - for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"): - logger.debug(f"Found plugin: >{entry_point.name}<") + for entry_point in entry_points(group="flaschengeist.plugins"): + logger.debug(f"Found plugin: {entry_point.name} ({entry_point.dist.version})") if entry_point.name == config["FLASCHENGEIST"]["auth"] or ( entry_point.name in config and config[entry_point.name].get("enabled", False) ): logger.debug(f"Load plugin {entry_point.name}") try: - plugin = entry_point.load() - if not hasattr(plugin, "name"): - setattr(plugin, "name", entry_point.name) - plugin = plugin(config.get(entry_point.name, {})) + plugin = entry_point.load()(entry_point, config=config.get(entry_point.name, {})) if hasattr(plugin, "blueprint") and plugin.blueprint is not None: app.register_blueprint(plugin.blueprint) except: diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index f759d24..d655510 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -3,9 +3,10 @@ .. include:: docs/plugin_development.md """ -import pkg_resources +from importlib_metadata import Distribution, EntryPoint from werkzeug.datastructures import FileStorage from werkzeug.exceptions import MethodNotAllowed, NotFound + from flaschengeist.models.user import _Avatar, User from flaschengeist.utils.hook import HookBefore, HookAfter @@ -50,31 +51,43 @@ Args: class Plugin: """Base class for all Plugins - If your class uses custom models add a static property called ``models``""" - blueprint = None # You have to override - """Override with a `flask.blueprint` if the plugin uses custom routes""" - permissions = [] # You have to override - """Override to add custom permissions used by the plugin + If your class uses custom models add a static property called ``models``. + """ + + name: str + """Name of the plugin, loaded from EntryPoint""" + + version: str + """Version of the plugin, loaded from Distribution""" + + dist: Distribution + """Distribution of this plugin""" + + blueprint = None + """Optional `flask.blueprint` if the plugin uses custom routes""" + + permissions: list[str] = [] + """Optional list of custom permissions used by the plugin A good style is to name the permissions with a prefix related to the plugin name, to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*. """ - id = "dev.flaschengeist.plugin" # You have to override - """Override with the unique ID of the plugin (Hint: FQN)""" - name = "plugin" # You have to override - """Override with human readable name of the plugin""" - models = None # You have to override - """Override with models module""" - migrations_path = None # Override this with the location of your db migrations directory - """Override with path to migration files, if custome db tables are used""" - def __init__(self, config=None): + models = None + """Optional module containing the SQLAlchemy models used by the plugin""" + + migrations_path = None + """Optional location of the path to migration files, required if custome db tables are used""" + + def __init__(self, entry_point: EntryPoint, config=None): """Constructor called by create_app Args: config: Dict configuration containing the plugin section """ - self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version + self.version = entry_point.dist.version + self.name = entry_point.name + self.dist = entry_point.dist def install(self): """Installation routine @@ -96,7 +109,7 @@ class Plugin: """ from ..controller import pluginController - return pluginController.get_setting(self.id) + return pluginController.get_setting(self.id, name, **kwargs) def set_setting(self, name: str, value): """Save setting in database diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 697e972..7aa8fb6 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -17,8 +17,8 @@ from flaschengeist.plugins import AuthPlugin, before_role_updated class AuthLDAP(AuthPlugin): - def __init__(self, config): - super().__init__() + def __init__(self, entry_point, config): + super().__init__(entry_point) app.config.update( LDAP_SERVER=config.get("host", "localhost"), LDAP_PORT=config.get("port", 389), diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index ca61a5f..c430398 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -56,16 +56,15 @@ def service_debit(): class BalancePlugin(Plugin): - name = "balance" - id = "dev.flaschengeist.balance" - blueprint = Blueprint(name, __name__) permissions = permissions.permissions plugin: "BalancePlugin" = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name]) models = models - def __init__(self, config): - super(BalancePlugin, self).__init__(config) - from . import routes + def __init__(self, entry_point, config): + super(BalancePlugin, self).__init__(entry_point, config) + from .routes import blueprint + + self.blueprint = blueprint @plugins_loaded def post_loaded(*args, **kwargs): diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py index b135c52..f62a065 100644 --- a/flaschengeist/plugins/balance/routes.py +++ b/flaschengeist/plugins/balance/routes.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone from werkzeug.exceptions import Forbidden, BadRequest -from flask import request, jsonify +from flask import Blueprint, request, jsonify from flaschengeist.utils import HTTP from flaschengeist.models.session import Session @@ -18,7 +18,10 @@ def str2bool(string: str): raise ValueError -@BalancePlugin.blueprint.route("/users//balance/shortcuts", methods=["GET", "PUT"]) +blueprint = Blueprint("balance", __package__) + + +@blueprint.route("/users//balance/shortcuts", methods=["GET", "PUT"]) @login_required() def get_shortcuts(userid, current_session: Session): """Get balance shortcuts of an user @@ -50,7 +53,7 @@ def get_shortcuts(userid, current_session: Session): return HTTP.no_content() -@BalancePlugin.blueprint.route("/users//balance/limit", methods=["GET"]) +@blueprint.route("/users//balance/limit", methods=["GET"]) @login_required() def get_limit(userid, current_session: Session): """Get limit of an user @@ -73,7 +76,7 @@ def get_limit(userid, current_session: Session): return {"limit": balance_controller.get_limit(user)} -@BalancePlugin.blueprint.route("/users//balance/limit", methods=["PUT"]) +@blueprint.route("/users//balance/limit", methods=["PUT"]) @login_required(permissions.SET_LIMIT) def set_limit(userid, current_session: Session): """Set the limit of an user @@ -99,7 +102,7 @@ def set_limit(userid, current_session: Session): return HTTP.no_content() -@BalancePlugin.blueprint.route("/users/balance/limit", methods=["GET", "PUT"]) +@blueprint.route("/users/balance/limit", methods=["GET", "PUT"]) @login_required(permission=permissions.SET_LIMIT) def limits(current_session: Session): """Get, Modify limit of all users @@ -124,7 +127,7 @@ def limits(current_session: Session): return HTTP.no_content() -@BalancePlugin.blueprint.route("/users//balance", methods=["GET"]) +@blueprint.route("/users//balance", methods=["GET"]) @login_required(permission=permissions.SHOW) def get_balance(userid, current_session: Session): """Get balance of user, optionally filtered @@ -162,7 +165,7 @@ def get_balance(userid, current_session: Session): return {"credit": balance[0], "debit": balance[1], "balance": balance[2]} -@BalancePlugin.blueprint.route("/users//balance/transactions", methods=["GET"]) +@blueprint.route("/users//balance/transactions", methods=["GET"]) @login_required(permission=permissions.SHOW) def get_transactions(userid, current_session: Session): """Get transactions of user, optionally filtered @@ -223,7 +226,7 @@ def get_transactions(userid, current_session: Session): return {"transactions": transactions, "count": count} -@BalancePlugin.blueprint.route("/users//balance", methods=["PUT"]) +@blueprint.route("/users//balance", methods=["PUT"]) @login_required() def change_balance(userid, current_session: Session): """Change balance of an user @@ -272,7 +275,7 @@ def change_balance(userid, current_session: Session): raise Forbidden -@BalancePlugin.blueprint.route("/balance/", methods=["DELETE"]) +@blueprint.route("/balance/", methods=["DELETE"]) @login_required() def reverse_transaction(transaction_id, current_session: Session): """Reverse a transaction @@ -297,7 +300,7 @@ def reverse_transaction(transaction_id, current_session: Session): raise Forbidden -@BalancePlugin.blueprint.route("/balance", methods=["GET"]) +@blueprint.route("/balance", methods=["GET"]) @login_required(permission=permissions.SHOW_OTHER) def get_balances(current_session: Session): """Get all balances diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index 79b6a64..3a9502a 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -12,8 +12,8 @@ from . import Plugin class MailMessagePlugin(Plugin): - def __init__(self, config): - super().__init__() + def __init__(self, entry_point, config): + super().__init__(entry_point) self.server = config["SERVER"] self.port = config["PORT"] self.user = config["USER"] diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 168136e..d06af7f 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -15,21 +15,23 @@ from . import models from . import pricelist_controller, permissions +blueprint = Blueprint("pricelist", __name__, url_prefix="/pricelist") + + class PriceListPlugin(Plugin): - name = "pricelist" permissions = permissions.permissions - blueprint = Blueprint(name, __name__, url_prefix="/pricelist") plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name]) models = models - def __init__(self, cfg): - super().__init__(cfg) + def __init__(self, entry_point, config=None): + super().__init__(entry_point, config) + self.blueprint = blueprint config = {"discount": 0} - config.update(cfg) + config.update(config) -@PriceListPlugin.blueprint.route("/drink-types", methods=["GET"]) -@PriceListPlugin.blueprint.route("/drink-types/", methods=["GET"]) +@blueprint.route("/drink-types", methods=["GET"]) +@blueprint.route("/drink-types/", methods=["GET"]) def get_drink_types(identifier=None): """Get DrinkType(s) @@ -49,7 +51,7 @@ def get_drink_types(identifier=None): return jsonify(result) -@PriceListPlugin.blueprint.route("/drink-types", methods=["POST"]) +@blueprint.route("/drink-types", methods=["POST"]) @login_required(permission=permissions.CREATE_TYPE) def new_drink_type(current_session): """Create new DrinkType @@ -71,7 +73,7 @@ def new_drink_type(current_session): return jsonify(drink_type) -@PriceListPlugin.blueprint.route("/drink-types/", methods=["PUT"]) +@blueprint.route("/drink-types/", methods=["PUT"]) @login_required(permission=permissions.EDIT_TYPE) def update_drink_type(identifier, current_session): """Modify DrinkType @@ -94,7 +96,7 @@ def update_drink_type(identifier, current_session): return jsonify(drink_type) -@PriceListPlugin.blueprint.route("/drink-types/", methods=["DELETE"]) +@blueprint.route("/drink-types/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_TYPE) def delete_drink_type(identifier, current_session): """Delete DrinkType @@ -112,8 +114,8 @@ def delete_drink_type(identifier, current_session): return no_content() -@PriceListPlugin.blueprint.route("/tags", methods=["GET"]) -@PriceListPlugin.blueprint.route("/tags/", methods=["GET"]) +@blueprint.route("/tags", methods=["GET"]) +@blueprint.route("/tags/", methods=["GET"]) def get_tags(identifier=None): """Get Tag(s) @@ -133,7 +135,7 @@ def get_tags(identifier=None): return jsonify(result) -@PriceListPlugin.blueprint.route("/tags", methods=["POST"]) +@blueprint.route("/tags", methods=["POST"]) @login_required(permission=permissions.CREATE_TAG) def new_tag(current_session): """Create Tag @@ -153,7 +155,7 @@ def new_tag(current_session): return jsonify(drink_type) -@PriceListPlugin.blueprint.route("/tags/", methods=["PUT"]) +@blueprint.route("/tags/", methods=["PUT"]) @login_required(permission=permissions.EDIT_TAG) def update_tag(identifier, current_session): """Modify Tag @@ -174,7 +176,7 @@ def update_tag(identifier, current_session): return jsonify(tag) -@PriceListPlugin.blueprint.route("/tags/", methods=["DELETE"]) +@blueprint.route("/tags/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_TAG) def delete_tag(identifier, current_session): """Delete Tag @@ -192,8 +194,8 @@ def delete_tag(identifier, current_session): return no_content() -@PriceListPlugin.blueprint.route("/drinks", methods=["GET"]) -@PriceListPlugin.blueprint.route("/drinks/", methods=["GET"]) +@blueprint.route("/drinks", methods=["GET"]) +@blueprint.route("/drinks/", methods=["GET"]) def get_drinks(identifier=None): """Get Drink(s) @@ -249,7 +251,7 @@ def get_drinks(identifier=None): return jsonify({"drinks": drinks, "count": count}) -@PriceListPlugin.blueprint.route("/list", methods=["GET"]) +@blueprint.route("/list", methods=["GET"]) def get_pricelist(): """Get Priclist Route: ``/pricelist/list`` | Method: ``GET`` @@ -298,7 +300,7 @@ def get_pricelist(): return jsonify({"pricelist": pricelist, "count": count}) -@PriceListPlugin.blueprint.route("/drinks/search/", methods=["GET"]) +@blueprint.route("/drinks/search/", methods=["GET"]) def search_drinks(name): """Search Drink @@ -319,7 +321,7 @@ def search_drinks(name): return jsonify(pricelist_controller.get_drinks(name, public=public)) -@PriceListPlugin.blueprint.route("/drinks", methods=["POST"]) +@blueprint.route("/drinks", methods=["POST"]) @login_required(permission=permissions.CREATE) def create_drink(current_session): """Create Drink @@ -371,7 +373,7 @@ def create_drink(current_session): return jsonify(pricelist_controller.set_drink(data)) -@PriceListPlugin.blueprint.route("/drinks/", methods=["PUT"]) +@blueprint.route("/drinks/", methods=["PUT"]) @login_required(permission=permissions.EDIT) def update_drink(identifier, current_session): """Modify Drink @@ -425,7 +427,7 @@ def update_drink(identifier, current_session): return jsonify(pricelist_controller.update_drink(identifier, data)) -@PriceListPlugin.blueprint.route("/drinks/", methods=["DELETE"]) +@blueprint.route("/drinks/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) def delete_drink(identifier, current_session): """Delete Drink @@ -443,7 +445,7 @@ def delete_drink(identifier, current_session): return no_content() -@PriceListPlugin.blueprint.route("/prices/", methods=["DELETE"]) +@blueprint.route("/prices/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_PRICE) def delete_price(identifier, current_session): """Delete Price @@ -461,7 +463,7 @@ def delete_price(identifier, current_session): return no_content() -@PriceListPlugin.blueprint.route("/volumes/", methods=["DELETE"]) +@blueprint.route("/volumes/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_VOLUME) def delete_volume(identifier, current_session): """Delete DrinkPriceVolume @@ -479,7 +481,7 @@ def delete_volume(identifier, current_session): return no_content() -@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"]) +@blueprint.route("/ingredients/extraIngredients", methods=["GET"]) @login_required() def get_extra_ingredients(current_session): """Get ExtraIngredients @@ -495,7 +497,7 @@ def get_extra_ingredients(current_session): return jsonify(pricelist_controller.get_extra_ingredients()) -@PriceListPlugin.blueprint.route("/ingredients/", methods=["DELETE"]) +@blueprint.route("/ingredients/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_INGREDIENTS_DRINK) def delete_ingredient(identifier, current_session): """Delete Ingredient @@ -513,7 +515,7 @@ def delete_ingredient(identifier, current_session): return no_content() -@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"]) +@blueprint.route("/ingredients/extraIngredients", methods=["POST"]) @login_required(permission=permissions.EDIT_INGREDIENTS) def set_extra_ingredient(current_session): """Create ExtraIngredient @@ -532,7 +534,7 @@ def set_extra_ingredient(current_session): return jsonify(pricelist_controller.set_extra_ingredient(data)) -@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", methods=["PUT"]) +@blueprint.route("/ingredients/extraIngredients/", methods=["PUT"]) @login_required(permission=permissions.EDIT_INGREDIENTS) def update_extra_ingredient(identifier, current_session): """Modify ExtraIngredient @@ -552,7 +554,7 @@ def update_extra_ingredient(identifier, current_session): return jsonify(pricelist_controller.update_extra_ingredient(identifier, data)) -@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", methods=["DELETE"]) +@blueprint.route("/ingredients/extraIngredients/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_INGREDIENTS) def delete_extra_ingredient(identifier, current_session): """Delete ExtraIngredient @@ -570,7 +572,7 @@ def delete_extra_ingredient(identifier, current_session): return no_content() -@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["GET"]) +@blueprint.route("/settings/min_prices", methods=["GET"]) @login_required() def get_pricelist_settings_min_prices(current_session): """Get MinPrices @@ -591,7 +593,7 @@ def get_pricelist_settings_min_prices(current_session): return jsonify(min_prices) -@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST"]) +@blueprint.route("/settings/min_prices", methods=["POST"]) @login_required(permission=permissions.EDIT_MIN_PRICES) def post_pricelist_settings_min_prices(current_session): """Create MinPrices @@ -614,7 +616,7 @@ def post_pricelist_settings_min_prices(current_session): return no_content() -@PriceListPlugin.blueprint.route("/users//pricecalc_columns", methods=["GET", "PUT"]) +@blueprint.route("/users//pricecalc_columns", methods=["GET", "PUT"]) @login_required() def get_columns(userid, current_session): """Get pricecalc_columns of an user @@ -646,7 +648,7 @@ def get_columns(userid, current_session): return no_content() -@PriceListPlugin.blueprint.route("/users//pricecalc_columns_order", methods=["GET", "PUT"]) +@blueprint.route("/users//pricecalc_columns_order", methods=["GET", "PUT"]) @login_required() def get_columns_order(userid, current_session): """Get pricecalc_columns_order of an user @@ -677,7 +679,7 @@ def get_columns_order(userid, current_session): return no_content() -@PriceListPlugin.blueprint.route("/users//pricelist", methods=["GET", "PUT"]) +@blueprint.route("/users//pricelist", methods=["GET", "PUT"]) @login_required() def get_priclist_setting(userid, current_session): """Get pricelistsetting of an user @@ -710,7 +712,7 @@ def get_priclist_setting(userid, current_session): return no_content() -@PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "DELETE"]) +@blueprint.route("/drinks//picture", methods=["POST", "DELETE"]) @login_required(permission=permissions.EDIT) def set_picture(identifier, current_session): """Get, Create, Delete Drink Picture @@ -737,7 +739,7 @@ def set_picture(identifier, current_session): raise BadRequest -@PriceListPlugin.blueprint.route("/drinks//picture", methods=["GET"]) +@blueprint.route("/drinks//picture", methods=["GET"]) # @headers({"Cache-Control": "private, must-revalidate"}) def _get_picture(identifier): """Get Picture diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index 954ba21..4e3c92b 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -16,8 +16,7 @@ from . import permissions class RolesPlugin(Plugin): - name = "roles" - blueprint = Blueprint(name, __name__) + blueprint = Blueprint("roles", __name__) permissions = permissions.permissions diff --git a/flaschengeist/plugins/scheduler.py b/flaschengeist/plugins/scheduler.py index 7f52db7..7d15b69 100644 --- a/flaschengeist/plugins/scheduler.py +++ b/flaschengeist/plugins/scheduler.py @@ -1,6 +1,5 @@ -import pkg_resources -from datetime import datetime, timedelta from flask import Blueprint +from datetime import datetime, timedelta from flaschengeist import logger from flaschengeist.utils.HTTP import no_content @@ -40,15 +39,9 @@ def scheduled(id: str, replace=False, **kwargs): class SchedulerPlugin(Plugin): - id = "dev.flaschengeist.scheduler" - name = "scheduler" - blueprint = Blueprint(name, __name__) - - def __init__(self, config=None): - """Constructor called by create_app - Args: - config: Dict configuration containing the plugin section - """ + def __init__(self, entry_point, config=None): + super().__init__(entry_point, config) + self.blueprint = Blueprint(self.name, __name__) def __view_func(): self.run_tasks() @@ -60,7 +53,6 @@ class SchedulerPlugin(Plugin): except: logger.error("Error while executing scheduled tasks!", exc_info=True) - self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version cron = None if config is None else config.get("cron", "passive_web").lower() if cron is None or cron == "passive_web": diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index eef0041..2e0802c 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -18,8 +18,7 @@ from flaschengeist.utils.datetime import from_iso_format class UsersPlugin(Plugin): - name = "users" - blueprint = Blueprint(name, __name__) + blueprint = Blueprint("users", __name__) permissions = permissions.permissions diff --git a/setup.cfg b/setup.cfg index 995eb8a..c18feda 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,8 @@ install_requires = Pillow>=8.4.0 flask_cors flask_sqlalchemy>=2.5 + # Importlib requirement can be dropped when python requirement is >= 3.10 + importlib_metadata>=4.3 sqlalchemy>=1.4.26 toml werkzeug From 2d4c8ebfd97b9392317e1cede36260076fdc030b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 7 Mar 2022 14:36:31 +0100 Subject: [PATCH 393/446] fix(plugins): Fix functions using id instead of name property --- flaschengeist/plugins/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index d655510..87ef13c 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -109,7 +109,7 @@ class Plugin: """ from ..controller import pluginController - return pluginController.get_setting(self.id, name, **kwargs) + return pluginController.get_setting(self.name, name, **kwargs) def set_setting(self, name: str, value): """Save setting in database @@ -120,7 +120,7 @@ class Plugin: """ from ..controller import pluginController - return pluginController.set_setting(self.id, name, value) + return pluginController.set_setting(self.name, name, value) def notify(self, user, text: str, data=None): """Create a new notification for an user @@ -136,7 +136,7 @@ class Plugin: """ from ..controller import pluginController - return pluginController.notify(self.id, user, text, data) + return pluginController.notify(self.name, user, text, data) def serialize(self): """Serialize a plugin into a dict From bf02c0e21f53d891ea3560a3336e46393fecf711 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Dec 2021 22:20:34 +0100 Subject: [PATCH 394/446] fix(db): Add __repr__ to custom column types, same as done by SQLAlchemy --- flaschengeist/models/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 8cf3850..369bb30 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -1,7 +1,7 @@ import sys import datetime -from sqlalchemy import BigInteger +from sqlalchemy import BigInteger, util from sqlalchemy.dialects import mysql, sqlite from sqlalchemy.types import DateTime, TypeDecorator @@ -50,6 +50,10 @@ class Serial(TypeDecorator): cache_ok = True impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER(), "sqlite") + # https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves + def __repr__(self) -> str: + return util.generic_repr(self) + class UtcDateTime(TypeDecorator): """Almost equivalent to `sqlalchemy.types.DateTime` with @@ -85,3 +89,7 @@ class UtcDateTime(TypeDecorator): value = value.astimezone(datetime.timezone.utc) value = value.replace(tzinfo=datetime.timezone.utc) return value + + # https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves + def __repr__(self) -> str: + return util.generic_repr(self) From 6a35137a27382a96d2fa0ff8f362f651241f69ef Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Dec 2021 22:11:57 +0100 Subject: [PATCH 395/446] feat(db): Add database migration support, implements #19 Migrations allow us to keep track of database changes and upgrading databases if needed. * Add initial migrations for core Flaschengeist * Add migrations to balance * Add migrations to pricelist * Skip plugins with not satisfied dependencies. --- flaschengeist/alembic/__init__.py | 0 flaschengeist/alembic/alembic.ini | 53 +++++++ flaschengeist/alembic/env.py | 74 +++++++++ .../255b93b6beed_flaschengeist_initial.py | 132 ++++++++++++++++ flaschengeist/alembic/migrations/__init__.py | 0 flaschengeist/alembic/script.py.mako | 25 ++++ flaschengeist/app.py | 33 +--- flaschengeist/database.py | 44 ++++++ flaschengeist/plugins/__init__.py | 36 ++++- flaschengeist/plugins/auth/__init__.py | 4 +- flaschengeist/plugins/auth_ldap/__init__.py | 2 +- flaschengeist/plugins/auth_plain/__init__.py | 2 + flaschengeist/plugins/balance/__init__.py | 6 +- .../98f2733bbe45_balance_initial.py | 47 ++++++ flaschengeist/plugins/message_mail.py | 2 +- flaschengeist/plugins/pricelist/__init__.py | 2 + .../58ab9b6a8839_pricelist_initial.py | 141 ++++++++++++++++++ setup.cfg | 8 +- 18 files changed, 571 insertions(+), 40 deletions(-) create mode 100644 flaschengeist/alembic/__init__.py create mode 100644 flaschengeist/alembic/alembic.ini create mode 100644 flaschengeist/alembic/env.py create mode 100644 flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py create mode 100644 flaschengeist/alembic/migrations/__init__.py create mode 100644 flaschengeist/alembic/script.py.mako create mode 100644 flaschengeist/plugins/balance/migrations/98f2733bbe45_balance_initial.py create mode 100644 flaschengeist/plugins/pricelist/migrations/58ab9b6a8839_pricelist_initial.py diff --git a/flaschengeist/alembic/__init__.py b/flaschengeist/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flaschengeist/alembic/alembic.ini b/flaschengeist/alembic/alembic.ini new file mode 100644 index 0000000..f9e9d8e --- /dev/null +++ b/flaschengeist/alembic/alembic.ini @@ -0,0 +1,53 @@ +# A generic, single database configuration. +# No used by flaschengeist + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +version_path_separator = os +version_locations = %(here)s/migrations + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/flaschengeist/alembic/env.py b/flaschengeist/alembic/env.py new file mode 100644 index 0000000..f8e05d5 --- /dev/null +++ b/flaschengeist/alembic/env.py @@ -0,0 +1,74 @@ +import logging +from logging.config import fileConfig +from pathlib import Path +from flask import current_app +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(Path(config.get_main_option("script_location")) / config.config_file_name.split("/")[-1]) +logger = logging.getLogger("alembic.env") + +config.set_main_option("sqlalchemy.url", str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%")) +target_metadata = current_app.extensions["migrate"].db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, "autogenerate", False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info("No changes in schema detected.") + + connectable = current_app.extensions["migrate"].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions["migrate"].configure_args, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py b/flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py new file mode 100644 index 0000000..b7deac6 --- /dev/null +++ b/flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py @@ -0,0 +1,132 @@ +"""Flaschengeist: Initial + +Revision ID: 255b93b6beed +Revises: +Create Date: 2022-02-23 14:33:02.851388 + +""" +from alembic import op +import sqlalchemy as sa +import flaschengeist + + +# revision identifiers, used by Alembic. +revision = "255b93b6beed" +down_revision = None +branch_labels = ("flaschengeist",) +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "image", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("filename_", sa.String(length=127), nullable=False), + sa.Column("mimetype_", sa.String(length=30), nullable=False), + sa.Column("thumbnail_", sa.String(length=127), nullable=True), + sa.Column("path_", sa.String(length=127), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_image")), + ) + op.create_table( + "permission", + sa.Column("name", sa.String(length=30), nullable=True), + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_permission")), + sa.UniqueConstraint("name", name=op.f("uq_permission_name")), + ) + op.create_table( + "role", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_role")), + sa.UniqueConstraint("name", name=op.f("uq_role_name")), + ) + op.create_table( + "role_x_permission", + sa.Column("role_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("permission_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint( + ["permission_id"], ["permission.id"], name=op.f("fk_role_x_permission_permission_id_permission") + ), + sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_role_x_permission_role_id_role")), + ) + op.create_table( + "user", + sa.Column("userid", sa.String(length=30), nullable=False), + sa.Column("display_name", sa.String(length=30), nullable=True), + sa.Column("firstname", sa.String(length=50), nullable=False), + sa.Column("lastname", sa.String(length=50), nullable=False), + sa.Column("deleted", sa.Boolean(), nullable=True), + sa.Column("birthday", sa.Date(), nullable=True), + sa.Column("mail", sa.String(length=60), nullable=True), + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("avatar", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["avatar"], ["image.id"], name=op.f("fk_user_avatar_image")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user")), + sa.UniqueConstraint("userid", name=op.f("uq_user_userid")), + ) + op.create_table( + "notification", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("plugin", sa.String(length=127), nullable=False), + sa.Column("text", sa.Text(), nullable=True), + sa.Column("data", sa.PickleType(), nullable=True), + sa.Column("time", flaschengeist.models.UtcDateTime(), nullable=False), + sa.Column("user_id", flaschengeist.models.Serial(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_notification_user_id_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_notification")), + ) + op.create_table( + "password_reset", + sa.Column("user", flaschengeist.models.Serial(), nullable=False), + sa.Column("token", sa.String(length=32), nullable=True), + sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True), + sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_password_reset_user_user")), + sa.PrimaryKeyConstraint("user", name=op.f("pk_password_reset")), + ) + op.create_table( + "session", + sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True), + sa.Column("token", sa.String(length=32), nullable=True), + sa.Column("lifetime", sa.Integer(), nullable=True), + sa.Column("browser", sa.String(length=30), nullable=True), + sa.Column("platform", sa.String(length=30), nullable=True), + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("user_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_session_user_id_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_session")), + sa.UniqueConstraint("token", name=op.f("uq_session_token")), + ) + op.create_table( + "user_attribute", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("user", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=True), + sa.Column("value", sa.PickleType(), nullable=True), + sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_user_attribute_user_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_attribute")), + ) + op.create_table( + "user_x_role", + sa.Column("user_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("role_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_user_x_role_role_id_role")), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_user_x_role_user_id_user")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("user_x_role") + op.drop_table("user_attribute") + op.drop_table("session") + op.drop_table("password_reset") + op.drop_table("notification") + op.drop_table("user") + op.drop_table("role_x_permission") + op.drop_table("role") + op.drop_table("permission") + op.drop_table("image") + # ### end Alembic commands ### diff --git a/flaschengeist/alembic/migrations/__init__.py b/flaschengeist/alembic/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flaschengeist/alembic/script.py.mako b/flaschengeist/alembic/script.py.mako new file mode 100644 index 0000000..a6f4fdf --- /dev/null +++ b/flaschengeist/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +import flaschengeist +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/flaschengeist/app.py b/flaschengeist/app.py index f2f1664..b6f87e1 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,6 +1,6 @@ import enum -from flask import Flask, current_app +from flask import Flask from flask_cors import CORS from datetime import datetime, date from flask.json import JSONEncoder, jsonify @@ -11,7 +11,6 @@ from werkzeug.exceptions import HTTPException from flaschengeist import logger from flaschengeist.utils.hook import Hook from flaschengeist.plugins import AuthPlugin -from flaschengeist.controller import roleController from flaschengeist.config import config, configure_app @@ -37,10 +36,9 @@ class CustomJSONEncoder(JSONEncoder): @Hook("plugins.loaded") -def __load_plugins(app): - logger.debug("Search for plugins") - +def load_plugins(app: Flask): app.config["FG_PLUGINS"] = {} + for entry_point in entry_points(group="flaschengeist.plugins"): logger.debug(f"Found plugin: {entry_point.name} ({entry_point.dist.version})") @@ -72,37 +70,22 @@ def __load_plugins(app): else: logger.debug(f"Skip disabled plugin {entry_point.name}") if "FG_AUTH_BACKEND" not in app.config: - logger.error("No authentication plugin configured or authentication plugin not found") + logger.fatal("No authentication plugin configured or authentication plugin not found") raise RuntimeError("No authentication plugin configured or authentication plugin not found") -@Hook("plugins.installed") -def install_all(): - from flaschengeist.database import db - - db.create_all() - db.session.commit() - for name, plugin in current_app.config["FG_PLUGINS"].items(): - if not plugin: - logger.debug(f"Skip disabled plugin: {name}") - continue - logger.info(f"Install plugin {name}") - plugin.install() - if plugin.permissions: - roleController.create_permissions(plugin.permissions) - - -def create_app(test_config=None): +def create_app(test_config=None, cli=False): app = Flask("flaschengeist") app.json_encoder = CustomJSONEncoder CORS(app) with app.app_context(): - from flaschengeist.database import db + from flaschengeist.database import db, migrate configure_app(app, test_config) db.init_app(app) - __load_plugins(app) + migrate.init_app(app, db, compare_type=True) + load_plugins(app) @app.route("/", methods=["GET"]) def __get_state(): diff --git a/flaschengeist/database.py b/flaschengeist/database.py index ebda993..a2c4672 100644 --- a/flaschengeist/database.py +++ b/flaschengeist/database.py @@ -1,6 +1,11 @@ +import os +from flask_migrate import Migrate, Config from flask_sqlalchemy import SQLAlchemy +from importlib_metadata import EntryPoint from sqlalchemy import MetaData +from flaschengeist import logger + # https://alembic.sqlalchemy.org/en/latest/naming.html metadata = MetaData( naming_convention={ @@ -14,6 +19,45 @@ metadata = MetaData( db = SQLAlchemy(metadata=metadata) +migrate = Migrate() + + +@migrate.configure +def configure_alembic(config: Config): + """Alembic configuration hook + + Inject all migrations paths into the ``version_locations`` config option. + This includes even disabled plugins, as simply disabling a plugin without + uninstall can break the alembic version management. + """ + from importlib_metadata import entry_points, distribution + + # Set main script location + config.set_main_option( + "script_location", str(distribution("flaschengeist").locate_file("") / "flaschengeist" / "alembic") + ) + + # Set Flaschengeist's migrations + migrations = [config.get_main_option("script_location") + "/migrations"] + + # Gather all migration paths + ep: EntryPoint + for ep in entry_points(group="flaschengeist.plugins"): + try: + directory = ep.dist.locate_file("") + for loc in ep.module.split(".") + ["migrations"]: + directory /= loc + if directory.exists(): + logger.debug(f"Adding migration version path {directory}") + migrations.append(str(directory.resolve())) + except: + logger.warning(f"Could not load migrations of plugin {ep.name} for database migration.") + logger.debug("Plugin loading failed", exc_info=True) + + # write back seperator (we changed it if neither seperator nor locations were specified) + config.set_main_option("version_path_separator", os.pathsep) + config.set_main_option("version_locations", os.pathsep.join(set(migrations))) + return config def case_sensitive(s): diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 87ef13c..49e01e1 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -3,9 +3,11 @@ .. include:: docs/plugin_development.md """ + +from typing import Optional from importlib_metadata import Distribution, EntryPoint -from werkzeug.datastructures import FileStorage from werkzeug.exceptions import MethodNotAllowed, NotFound +from werkzeug.datastructures import FileStorage from flaschengeist.models.user import _Avatar, User from flaschengeist.utils.hook import HookBefore, HookAfter @@ -77,8 +79,16 @@ class Plugin: models = None """Optional module containing the SQLAlchemy models used by the plugin""" - migrations_path = None - """Optional location of the path to migration files, required if custome db tables are used""" + migrations: Optional[tuple[str, str]] = None + """Optional identifiers of the migration versions + + If custom database tables are used migrations must be provided and the + head and removal versions had to be defined, e.g. + + ``` + migrations = ("head_hash", "removal_hash") + ``` + """ def __init__(self, entry_point: EntryPoint, config=None): """Constructor called by create_app @@ -96,6 +106,17 @@ class Plugin: """ pass + def uninstall(self): + """Uninstall routine + + If the plugin has custom database tables, make sure to remove them. + This can be either done by downgrading the plugin *head* to the *base*. + Or use custom migrations for the uninstall and *stamp* some version. + + Is always called with Flask application context. + """ + pass + def get_setting(self, name: str, **kwargs): """Get plugin setting from database @@ -148,6 +169,11 @@ class Plugin: class AuthPlugin(Plugin): + """Base class for all authentification plugins + + See also `Plugin` + """ + def login(self, user, pw): """Login routine, MUST BE IMPLEMENTED! @@ -210,7 +236,7 @@ class AuthPlugin(Plugin): """ raise MethodNotAllowed - def get_avatar(self, user: User) -> _Avatar: + def get_avatar(self, user): """Retrieve avatar for given user (if supported by auth backend) Default behavior is to use native Image objects, @@ -242,7 +268,7 @@ class AuthPlugin(Plugin): user.avatar_ = imageController.upload_image(file) - def delete_avatar(self, user: User): + def delete_avatar(self, user): """Delete the avatar for given user (if supported by auth backend) Default behavior is to use the imageController and native Image objects. diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 9c08463..66d77be 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -13,8 +13,8 @@ from flaschengeist.controller import sessionController, userController class AuthRoutePlugin(Plugin): - name = "auth" - blueprint = Blueprint(name, __name__) + id = "dev.flaschengeist.auth" + blueprint = Blueprint("auth", __name__) @AuthRoutePlugin.blueprint.route("/auth", methods=["POST"]) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 7aa8fb6..ef8ecb1 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -18,7 +18,7 @@ from flaschengeist.plugins import AuthPlugin, before_role_updated class AuthLDAP(AuthPlugin): def __init__(self, entry_point, config): - super().__init__(entry_point) + super().__init__(entry_point, config) app.config.update( LDAP_SERVER=config.get("host", "localhost"), LDAP_PORT=config.get("port", 389), diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 10d72d3..e73b98c 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -14,6 +14,8 @@ from flaschengeist import logger class AuthPlain(AuthPlugin): + id = "auth_plain" + def install(self): plugins_installed(self.post_install) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index c430398..f983a9f 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -3,7 +3,7 @@ Extends users plugin with balance functions """ -from flask import Blueprint, current_app +from flask import current_app from werkzeug.local import LocalProxy from werkzeug.exceptions import NotFound @@ -57,8 +57,10 @@ def service_debit(): class BalancePlugin(Plugin): permissions = permissions.permissions - plugin: "BalancePlugin" = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name]) models = models + migrations = True + + plugin: "BalancePlugin" = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name]) def __init__(self, entry_point, config): super(BalancePlugin, self).__init__(entry_point, config) diff --git a/flaschengeist/plugins/balance/migrations/98f2733bbe45_balance_initial.py b/flaschengeist/plugins/balance/migrations/98f2733bbe45_balance_initial.py new file mode 100644 index 0000000..2a9d322 --- /dev/null +++ b/flaschengeist/plugins/balance/migrations/98f2733bbe45_balance_initial.py @@ -0,0 +1,47 @@ +"""balance: initial + +Revision ID: 98f2733bbe45 +Revises: +Create Date: 2022-02-23 14:41:03.089145 + +""" +from alembic import op +import sqlalchemy as sa +import flaschengeist + + +# revision identifiers, used by Alembic. +revision = "98f2733bbe45" +down_revision = None +branch_labels = ("balance",) +depends_on = "flaschengeist" + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "balance_transaction", + sa.Column("receiver_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("sender_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("author_id", flaschengeist.models.Serial(), nullable=False), + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("time", flaschengeist.models.UtcDateTime(), nullable=False), + sa.Column("amount", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=False), + sa.Column("reversal_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["author_id"], ["user.id"], name=op.f("fk_balance_transaction_author_id_user")), + sa.ForeignKeyConstraint(["receiver_id"], ["user.id"], name=op.f("fk_balance_transaction_receiver_id_user")), + sa.ForeignKeyConstraint( + ["reversal_id"], + ["balance_transaction.id"], + name=op.f("fk_balance_transaction_reversal_id_balance_transaction"), + ), + sa.ForeignKeyConstraint(["sender_id"], ["user.id"], name=op.f("fk_balance_transaction_sender_id_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_balance_transaction")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("balance_transaction") + # ### end Alembic commands ### diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index 3a9502a..2fe6a76 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -13,7 +13,7 @@ from . import Plugin class MailMessagePlugin(Plugin): def __init__(self, entry_point, config): - super().__init__(entry_point) + super().__init__(entry_point, config) self.server = config["SERVER"] self.port = config["PORT"] self.user = config["USER"] diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index d06af7f..3f45351 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -1,5 +1,6 @@ """Pricelist plugin""" +import pathlib from flask import Blueprint, jsonify, request, current_app from werkzeug.local import LocalProxy from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized @@ -26,6 +27,7 @@ class PriceListPlugin(Plugin): def __init__(self, entry_point, config=None): super().__init__(entry_point, config) self.blueprint = blueprint + self.migrations_path = (pathlib.Path(__file__).parent / "migrations").resolve() config = {"discount": 0} config.update(config) diff --git a/flaschengeist/plugins/pricelist/migrations/58ab9b6a8839_pricelist_initial.py b/flaschengeist/plugins/pricelist/migrations/58ab9b6a8839_pricelist_initial.py new file mode 100644 index 0000000..3a6c5ad --- /dev/null +++ b/flaschengeist/plugins/pricelist/migrations/58ab9b6a8839_pricelist_initial.py @@ -0,0 +1,141 @@ +"""pricelist: initial + +Revision ID: 58ab9b6a8839 +Revises: +Create Date: 2022-02-23 14:45:30.563647 + +""" +from alembic import op +import sqlalchemy as sa +import flaschengeist + + +# revision identifiers, used by Alembic. +revision = "58ab9b6a8839" +down_revision = None +branch_labels = ("pricelist",) +depends_on = "flaschengeist" + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "drink_extra_ingredient", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=False), + sa.Column("price", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_extra_ingredient")), + sa.UniqueConstraint("name", name=op.f("uq_drink_extra_ingredient_name")), + ) + op.create_table( + "drink_tag", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=False), + sa.Column("color", sa.String(length=7), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_tag")), + sa.UniqueConstraint("name", name=op.f("uq_drink_tag_name")), + ) + op.create_table( + "drink_type", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_type")), + sa.UniqueConstraint("name", name=op.f("uq_drink_type_name")), + ) + op.create_table( + "drink", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("article_id", sa.String(length=64), nullable=True), + sa.Column("package_size", sa.Integer(), nullable=True), + sa.Column("name", sa.String(length=60), nullable=False), + sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True), + sa.Column("cost_per_volume", sa.Numeric(precision=5, scale=3, asdecimal=False), nullable=True), + sa.Column("cost_per_package", sa.Numeric(precision=5, scale=3, asdecimal=False), nullable=True), + sa.Column("receipt", sa.PickleType(), nullable=True), + sa.Column("type_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("image_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["image_id"], ["image.id"], name=op.f("fk_drink_image_id_image")), + sa.ForeignKeyConstraint(["type_id"], ["drink_type.id"], name=op.f("fk_drink_type_id_drink_type")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink")), + ) + op.create_table( + "drink_ingredient", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=False), + sa.Column("ingredient_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["ingredient_id"], ["drink.id"], name=op.f("fk_drink_ingredient_ingredient_id_drink")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_ingredient")), + ) + op.create_table( + "drink_price_volume", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True), + sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_price_volume_drink_id_drink")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_price_volume")), + ) + op.create_table( + "drink_x_tag", + sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("tag_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_x_tag_drink_id_drink")), + sa.ForeignKeyConstraint(["tag_id"], ["drink_tag.id"], name=op.f("fk_drink_x_tag_tag_id_drink_tag")), + ) + op.create_table( + "drink_x_type", + sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("type_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_x_type_drink_id_drink")), + sa.ForeignKeyConstraint(["type_id"], ["drink_type.id"], name=op.f("fk_drink_x_type_type_id_drink_type")), + ) + op.create_table( + "drink_ingredient_association", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("volume_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("_drink_ingredient_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("_extra_ingredient_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint( + ["_drink_ingredient_id"], + ["drink_ingredient.id"], + name=op.f("fk_drink_ingredient_association__drink_ingredient_id_drink_ingredient"), + ), + sa.ForeignKeyConstraint( + ["_extra_ingredient_id"], + ["drink_extra_ingredient.id"], + name=op.f("fk_drink_ingredient_association__extra_ingredient_id_drink_extra_ingredient"), + ), + sa.ForeignKeyConstraint( + ["volume_id"], + ["drink_price_volume.id"], + name=op.f("fk_drink_ingredient_association_volume_id_drink_price_volume"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_ingredient_association")), + ) + op.create_table( + "drink_price", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("price", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True), + sa.Column("volume_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("public", sa.Boolean(), nullable=True), + sa.Column("description", sa.String(length=30), nullable=True), + sa.ForeignKeyConstraint( + ["volume_id"], ["drink_price_volume.id"], name=op.f("fk_drink_price_volume_id_drink_price_volume") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_price")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("drink_price") + op.drop_table("drink_ingredient_association") + op.drop_table("drink_x_type") + op.drop_table("drink_x_tag") + op.drop_table("drink_price_volume") + op.drop_table("drink_ingredient") + op.drop_table("drink") + op.drop_table("drink_type") + op.drop_table("drink_tag") + op.drop_table("drink_extra_ingredient") + # ### end Alembic commands ### diff --git a/setup.cfg b/setup.cfg index c18feda..41af0da 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,16 +22,16 @@ include_package_data = True python_requires = >=3.9 packages = find: install_requires = - Flask >= 2.0 + Flask>=2.0 Pillow>=8.4.0 flask_cors + flask_migrate>=3.1.0 flask_sqlalchemy>=2.5 # Importlib requirement can be dropped when python requirement is >= 3.10 importlib_metadata>=4.3 sqlalchemy>=1.4.26 toml - werkzeug - + werkzeug >= 2.0 [options.extras_require] argon = argon2-cffi @@ -42,7 +42,7 @@ mysql = mysqlclient;platform_system!='Windows' [options.package_data] -* = *.toml +* = *.toml, script.py.mako [options.entry_points] console_scripts = From 4fbd20f78ecbf44750bc7f8812f86e0b14a33e7d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 20 Dec 2021 00:53:49 +0100 Subject: [PATCH 396/446] feat(docs): Add documentation on how to install tables Also various documentation fixed and improvments --- README.md | 48 ++++++++-------- docs/plugin_development.md | 44 ++++++++++++++ flaschengeist/plugins/__init__.py | 76 ++++++++++++++++--------- flaschengeist/plugins/balance/routes.py | 4 +- flaschengeist/utils/hook.py | 12 ++++ 5 files changed, 133 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 08aa09f..1cd3901 100644 --- a/README.md +++ b/README.md @@ -31,18 +31,35 @@ or if you want to also run the tests: pip3 install --user ".[ldap,tests]" -You will also need a MySQL driver, recommended drivers are -- `mysqlclient` -- `PyMySQL` +You will also need a MySQL driver, by default one of this is installed: +- `mysqlclient` (non Windows) +- `PyMySQL` (on Windows) -`setup.py` will try to install a matching driver. +#### Hint on MySQL driver on Windows: +If you want to use `mysqlclient` instead of `PyMySQL` (performance?) you have to follow [this guide](https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/) -#### Windows -Same as above, but if you want to use `mysqlclient` instead of `PyMySQL` (performance?) you have to follow this guide: +### Install database +The user needs to have full permissions to the database. +If not you need to create user and database manually do (or similar on Windows): -https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/ + ( + echo "CREATE DATABASE flaschengeist;" + echo "CREATE USER 'flaschengeist'@'localhost' IDENTIFIED BY 'flaschengeist';" + echo "GRANT ALL PRIVILEGES ON flaschengeist.* TO 'flaschengeist'@'localhost';" + echo "FLUSH PRIVILEGES;" + ) | sudo mysql -### Configuration +Then you can install the database tables, this will update all tables from core + all enabled plugins. +*Hint:* The same command can be later used to upgrade the database after plugins or core are updated. + + $ flaschengeist db upgrade heads + +## Plugins +To only upgrade one plugin (for example the `events` plugin): + + $ flaschengeist db upgrade events@head + +## Configuration Configuration is done within the a `flaschengeist.toml`file, you can copy the one located inside the module path (where flaschegeist is installed) or create an empty one and place it inside either: 1. `~/.config/` @@ -63,21 +80,6 @@ So you have to configure one of the following options to call flaschengeists CRO - Pros: Guaranteed execution interval, no impact on user experience (at least if you do not limit wsgi worker threads) - Cons: Uses one of the webserver threads while executing -### Database installation -The user needs to have full permissions to the database. -If not you need to create user and database manually do (or similar on Windows): - - ( - echo "CREATE DATABASE flaschengeist;" - echo "CREATE USER 'flaschengeist'@'localhost' IDENTIFIED BY 'flaschengeist';" - echo "GRANT ALL PRIVILEGES ON flaschengeist.* TO 'flaschengeist'@'localhost';" - echo "FLUSH PRIVILEGES;" - ) | sudo mysql - -Then you can install the database tables and initial entries: - - $ flaschengeist install - ### Run Flaschengeist provides a CLI, based on the flask CLI, respectivly called `flaschengeist`. diff --git a/docs/plugin_development.md b/docs/plugin_development.md index 21b69c5..ee129eb 100644 --- a/docs/plugin_development.md +++ b/docs/plugin_development.md @@ -5,9 +5,53 @@ - your_plugin/ - __init__.py - ... + - migrations/ (optional) - ... - setup.cfg The basic layout of a plugin is quite simple, you will only need the `setup.cfg` or `setup.py` and the package containing your plugin code, at lease a `__init__.py` file with your `Plugin` class. +If you use custom database tables you need to provide a `migrations` directory within your package, +see next section. + +## Database Tables / Migrations +To allow upgrades of installed plugins, the database is versioned and handled +through [Alembic](https://alembic.sqlalchemy.org/en/latest/index.html) migrations. +Each plugin, which uses custom database tables, is represented as an other base. +So you could simply follow the Alembic tutorial on [how to work with multiple bases](https://alembic.sqlalchemy.org/en/latest/branches.html#creating-a-labeled-base-revision). + +A quick overview on how to work with migrations for your plugin: + + $ flaschengeist db revision -m "Create my super plugin" \ + --head=base --branch-label=myplugin_name --version-path=your/plugin/migrations + +This would add a new base named `myplugin_name`, which should be the same as the pypi name of you plugin. +If your tables depend on an other plugin or a specific base version you could of cause add + + --depends-on=VERSION + +or + + --depends-on=other_plugin + + +### Plugin Removal and Database Tables +As generic downgrades are most often hard to write, your plugin is not required to provide such functionallity. +For Flaschengeist only instable versions provide meaningful downgrade migrations down to the latest stable version. + +So this means if you do not provide downgrades you must at lease provide a series of migrations toward removal of +the database tables in case the users wants to delete the plugin. + + (base) ----> 1.0 <----> 1.1 <----> 1.2 + | + --> removal + +After the removal step the database is stamped to to "remove" your + +## Useful Hooks +There are some predefined hooks, which might get handy for you. + +For more information, please refer to +- `flaschengeist.utils.hook.HookBefore` and +- `flaschengeist.utils.hook.HookAfter` diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 49e01e1..369e117 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -12,49 +12,73 @@ from werkzeug.datastructures import FileStorage from flaschengeist.models.user import _Avatar, User from flaschengeist.utils.hook import HookBefore, HookAfter +__all__ = [ + "plugins_installed", + "plugins_loaded", + "before_delete_user", + "before_role_updated", + "before_update_user", + "after_role_updated", + "Plugin", + "AuthPlugin", +] + +# Documentation hacks, see https://github.com/mitmproxy/pdoc/issues/320 plugins_installed = HookAfter("plugins.installed") -"""Hook decorator for when all plugins are installed - Possible use case would be to populate the database with some presets. +plugins_installed.__doc__ = """Hook decorator for when all plugins are installed - Args: - hook_result: void (kwargs) +Possible use case would be to populate the database with some presets. """ + plugins_loaded = HookAfter("plugins.loaded") -"""Hook decorator for when all plugins are loaded - Possible use case would be to check if a specific other plugin is loaded and change own behavior +plugins_loaded.__doc__ = """Hook decorator for when all plugins are loaded - Args: - app: Current flask app instance (args) - hook_result: void (kwargs) +Possible use case would be to check if a specific other plugin is loaded and change own behavior + +Passed args: + - *app:* Current flask app instance (args) """ + before_role_updated = HookBefore("update_role") -"""Hook decorator for when roles are modified -Args: - role: Role object to modify - new_name: New name if the name was changed (None if delete) +before_role_updated.__doc__ = """Hook decorator for when roles are modified + +Passed args: + - *role:* `flaschengeist.models.user.Role` to modify + - *new_name:* New name if the name was changed (*None* if delete) """ + after_role_updated = HookAfter("update_role") -"""Hook decorator for when roles are modified -Args: - role: Role object containing the modified role - new_name: New name if the name was changed (None if deleted) +after_role_updated.__doc__ = """Hook decorator for when roles are modified + +Passed args: + - *role:* modified `flaschengeist.models.user.Role` + - *new_name:* New name if the name was changed (*None* if deleted) """ + before_update_user = HookBefore("update_user") -"""Hook decorator, when ever an user update is done, this is called before. -Args: - user: User object +before_update_user.__doc__ = """Hook decorator, when ever an user update is done, this is called before. + +Passed args: + - *user:* `flaschengeist.models.user.User` object """ + before_delete_user = HookBefore("delete_user") -"""Hook decorator,this is called before an user gets deleted. -Args: - user: User object +before_delete_user.__doc__ = """Hook decorator,this is called before an user gets deleted. + +Passed args: + - *user:* `flaschengeist.models.user.User` object """ class Plugin: """Base class for all Plugins - If your class uses custom models add a static property called ``models``. + All plugins must derived from this class. + + Optional: + - *blueprint*: `flask.Blueprint` providing your routes + - *permissions*: List of your custom permissions + - *models*: Your models, used for API export """ name: str @@ -250,14 +274,14 @@ class AuthPlugin(Plugin): """ raise NotFound - def set_avatar(self, user: User, file: FileStorage): + def set_avatar(self, user, file: FileStorage): """Set the avatar for given user (if supported by auth backend) Default behavior is to use native Image objects stored on the Flaschengeist server Args: user: User to set the avatar for - file: FileStorage object uploaded by the user + file: `werkzeug.datastructures.FileStorage` uploaded by the user Raises: MethodNotAllowed: If not supported by Backend Any valid HTTP exception diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py index f62a065..f0edc62 100644 --- a/flaschengeist/plugins/balance/routes.py +++ b/flaschengeist/plugins/balance/routes.py @@ -134,7 +134,7 @@ def get_balance(userid, current_session: Session): Route: ``/users//balance`` | Method: ``GET`` - GET-parameters: ```{from?: string, to?: string}``` + GET-parameters: ``{from?: string, to?: string}`` Args: userid: Userid of user to get balance from @@ -173,7 +173,7 @@ def get_transactions(userid, current_session: Session): Route: ``/users//balance/transactions`` | Method: ``GET`` - GET-parameters: ```{from?: string, to?: string, limit?: int, offset?: int}``` + GET-parameters: ``{from?: string, to?: string, limit?: int, offset?: int}`` Args: userid: Userid of user to get transactions from diff --git a/flaschengeist/utils/hook.py b/flaschengeist/utils/hook.py index b028f30..f7c7fb7 100644 --- a/flaschengeist/utils/hook.py +++ b/flaschengeist/utils/hook.py @@ -7,6 +7,7 @@ _hooks_after = {} def Hook(function=None, id=None): """Hook decorator + Use to decorate functions as hooks, so plugins can hook up their custom functions. """ # `id` passed as `arg` not `kwarg` @@ -38,8 +39,10 @@ def Hook(function=None, id=None): def HookBefore(id: str): """Decorator for functions to be called before a Hook-Function is called + The hooked up function must accept the same arguments as the function hooked onto, as the functions are called with the same arguments. + Hint: This enables you to modify the arguments! """ if not id or not isinstance(id, str): @@ -54,9 +57,18 @@ def HookBefore(id: str): def HookAfter(id: str): """Decorator for functions to be called after a Hook-Function is called + As with the HookBefore, the hooked up function must accept the same arguments as the function hooked onto, but also receives a `hook_result` kwarg containing the result of the function. + + Example: + ```py + @HookAfter("some.id") + def my_func(hook_result): + # This function is executed after the function registered with "some.id" + print(hook_result) # This is the result of the function + ``` """ if not id or not isinstance(id, str): From 573bea2da029203a99daac1d884e75e3b850655c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 23 Dec 2021 02:49:19 +0100 Subject: [PATCH 397/446] feat(cli): Added CLI command for handling plugins * Install / Uninstall plugins * List plugins --- flaschengeist/cli/__init__.py | 4 +- flaschengeist/cli/plugin_cmd.py | 96 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 flaschengeist/cli/plugin_cmd.py diff --git a/flaschengeist/cli/__init__.py b/flaschengeist/cli/__init__.py index e86e60c..ed93b5d 100644 --- a/flaschengeist/cli/__init__.py +++ b/flaschengeist/cli/__init__.py @@ -84,7 +84,7 @@ def cli(): def main(*args, **kwargs): - # from .plugin_cmd import plugin + from .plugin_cmd import plugin from .export_cmd import export from .docs_cmd import docs from .run_cmd import run @@ -92,8 +92,8 @@ def main(*args, **kwargs): # Override logging level environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX)) - # cli.add_command(plugin) cli.add_command(export) cli.add_command(docs) + cli.add_command(plugin) cli.add_command(run) cli(*args, **kwargs) diff --git a/flaschengeist/cli/plugin_cmd.py b/flaschengeist/cli/plugin_cmd.py new file mode 100644 index 0000000..bb0dd98 --- /dev/null +++ b/flaschengeist/cli/plugin_cmd.py @@ -0,0 +1,96 @@ +import click +from click.decorators import pass_context +from flask import current_app +from flask.cli import with_appcontext +from importlib_metadata import EntryPoint, entry_points + +from flaschengeist.config import config + + +@click.group() +def plugin(): + pass + + +@plugin.command() +@click.argument("plugin", nargs=-1, type=str) +@click.option("--all", help="Install all enabled plugins", is_flag=True) +@with_appcontext +@pass_context +def install(ctx, plugin, all): + """Install one or more plugins""" + if not all and len(plugin) == 0: + ctx.fail("At least one plugin must be specified, or use `--all` flag.") + if all: + plugins = current_app.config["FG_PLUGINS"].values() + else: + try: + plugins = [current_app.config["FG_PLUGINS"][p] for p in plugin] + except KeyError as e: + ctx.fail(f"Invalid plugin ID, could not find >{e.args[0]}<") + for p in plugins: + name = p.id.split(".")[-1] + click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False) + p.install() + click.secho(" ok", fg="green") + + +@plugin.command() +@click.argument("plugin", nargs=-1, type=str) +@with_appcontext +@pass_context +def uninstall(ctx: click.Context, plugin): + """Uninstall one or more plugins""" + + if len(plugin) == 0: + ctx.fail("At least one plugin must be specified") + try: + plugins = [current_app.config["FG_PLUGINS"][p] for p in plugin] + except KeyError as e: + ctx.fail(f"Invalid plugin ID, could not find >{e.args[0]}<") + if ( + click.prompt( + "You are going to uninstall:\n\n" + f"\t{', '.join([p.id.split('.')[-1] for p in plugins])}\n\n" + "Are you sure?", + default="n", + show_choices=True, + type=click.Choice(["y", "N"], False), + ).lower() + != "y" + ): + ctx.exit() + for p in plugins: + name = p.id.split(".")[-1] + click.echo(f"Uninstalling {name}{'.'*(20-len(name))}", nl=False) + p.uninstall() + click.secho(" ok", fg="green") + + +@plugin.command() +@click.option("--enabled", "-e", help="List only enabled plugins", is_flag=True) +@click.option("--no-header", "-n", help="Do not show header", is_flag=True) +@with_appcontext +def ls(enabled, no_header): + def plugin_version(p): + if isinstance(p, EntryPoint): + return p.dist.version + return p.version + + plugins = entry_points(group="flaschengeist.plugins") + enabled_plugins = [key for key, value in config.items() if "enabled" in value] + [config["FLASCHENGEIST"]["auth"]] + loaded_plugins = current_app.config["FG_PLUGINS"].keys() + + if not no_header: + print(f"{' '*13}{'name': <20}|{'version': >10}") + print("-" * 46) + for plugin in plugins: + if enabled and plugin.name not in enabled_plugins: + continue + print( + f"{plugin.name: <33}|{plugin_version(plugin): >12}" + f"{click.style(' (enabled)', fg='green') if plugin.name in enabled_plugins else ' (disabled)'}" + ) + + for plugin in [value for value in enabled_plugins if value not in loaded_plugins]: + print(f"{plugin: <33}|{' '*12}" f"{click.style(' (not found)', fg='red')}") From f1d6b6a2f2b6600cb2f572fc1ce170873b5c5eb9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 31 Jul 2022 13:22:11 +0200 Subject: [PATCH 398/446] [plugins][cli] Fix initial migration file + Make sure plugin permissions are installed Signed-off-by: Ferdinand Thiessen --- .../255b93b6beed_flaschengeist_initial.py | 17 ++++-- flaschengeist/cli/plugin_cmd.py | 59 ++++++++++++------- flaschengeist/models/image.py | 10 ++-- flaschengeist/models/setting.py | 4 +- flaschengeist/plugins/__init__.py | 3 +- flaschengeist/plugins/auth/__init__.py | 1 - flaschengeist/plugins/auth_plain/__init__.py | 4 +- 7 files changed, 61 insertions(+), 37 deletions(-) diff --git a/flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py b/flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py index b7deac6..b18a71e 100644 --- a/flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py +++ b/flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py @@ -18,14 +18,21 @@ depends_on = None def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "plugin_setting", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("plugin", sa.String(length=127), nullable=True), + sa.Column("name", sa.String(length=127), nullable=False), + sa.Column("value", sa.PickleType(protocol=4), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin_setting")), + ) op.create_table( "image", sa.Column("id", flaschengeist.models.Serial(), nullable=False), - sa.Column("filename_", sa.String(length=127), nullable=False), - sa.Column("mimetype_", sa.String(length=30), nullable=False), - sa.Column("thumbnail_", sa.String(length=127), nullable=True), - sa.Column("path_", sa.String(length=127), nullable=True), + sa.Column("filename", sa.String(length=255), nullable=False), + sa.Column("mimetype", sa.String(length=127), nullable=False), + sa.Column("thumbnail", sa.String(length=255), nullable=True), + sa.Column("path", sa.String(length=255), nullable=True), sa.PrimaryKeyConstraint("id", name=op.f("pk_image")), ) op.create_table( diff --git a/flaschengeist/cli/plugin_cmd.py b/flaschengeist/cli/plugin_cmd.py index bb0dd98..c2b5274 100644 --- a/flaschengeist/cli/plugin_cmd.py +++ b/flaschengeist/cli/plugin_cmd.py @@ -4,7 +4,9 @@ from flask import current_app from flask.cli import with_appcontext from importlib_metadata import EntryPoint, entry_points +from flaschengeist.database import db from flaschengeist.config import config +from flaschengeist.models.user import Permission @click.group() @@ -12,6 +14,35 @@ def plugin(): pass +def install_plugin_command(ctx, plugin, all): + """Install one or more plugins""" + if not all and len(plugin) == 0: + ctx.fail("At least one plugin must be specified, or use `--all` flag.") + + if all: + plugins = current_app.config["FG_PLUGINS"] + else: + try: + plugins = {plugin_name: current_app.config["FG_PLUGINS"][plugin_name] for plugin_name in plugin} + except KeyError as e: + ctx.fail(f"Invalid plugin name, could not find >{e.args[0]}<") + + for name, plugin in plugins.items(): + click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False) + # Install permissions + if plugin.permissions: + cur_perm = set(x.name for x in Permission.query.filter(Permission.name.in_(plugin.permissions)).all()) + all_perm = set(plugin.permissions) + + add = all_perm - cur_perm + if add: + db.session.bulk_save_objects([Permission(name=x) for x in all_perm]) + db.session.commit() + # Custom installation steps + plugin.install() + click.secho(" ok", fg="green") + + @plugin.command() @click.argument("plugin", nargs=-1, type=str) @click.option("--all", help="Install all enabled plugins", is_flag=True) @@ -19,20 +50,7 @@ def plugin(): @pass_context def install(ctx, plugin, all): """Install one or more plugins""" - if not all and len(plugin) == 0: - ctx.fail("At least one plugin must be specified, or use `--all` flag.") - if all: - plugins = current_app.config["FG_PLUGINS"].values() - else: - try: - plugins = [current_app.config["FG_PLUGINS"][p] for p in plugin] - except KeyError as e: - ctx.fail(f"Invalid plugin ID, could not find >{e.args[0]}<") - for p in plugins: - name = p.id.split(".")[-1] - click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False) - p.install() - click.secho(" ok", fg="green") + return install_plugin_command(ctx, plugin, all) @plugin.command() @@ -44,14 +62,16 @@ def uninstall(ctx: click.Context, plugin): if len(plugin) == 0: ctx.fail("At least one plugin must be specified") + try: - plugins = [current_app.config["FG_PLUGINS"][p] for p in plugin] + plugins = {plugin_name: current_app.config["FG_PLUGINS"][plugin_name] for plugin_name in plugin} except KeyError as e: ctx.fail(f"Invalid plugin ID, could not find >{e.args[0]}<") + if ( click.prompt( "You are going to uninstall:\n\n" - f"\t{', '.join([p.id.split('.')[-1] for p in plugins])}\n\n" + f"\t{', '.join([plugin_name for plugin_name in plugins.keys()])}\n\n" "Are you sure?", default="n", show_choices=True, @@ -60,10 +80,9 @@ def uninstall(ctx: click.Context, plugin): != "y" ): ctx.exit() - for p in plugins: - name = p.id.split(".")[-1] + for name, plugin in plugins.items(): click.echo(f"Uninstalling {name}{'.'*(20-len(name))}", nl=False) - p.uninstall() + plugin.uninstall() click.secho(" ok", fg="green") @@ -78,7 +97,7 @@ def ls(enabled, no_header): return p.version plugins = entry_points(group="flaschengeist.plugins") - enabled_plugins = [key for key, value in config.items() if "enabled" in value] + [config["FLASCHENGEIST"]["auth"]] + enabled_plugins = [key for key, value in config.items() if "enabled" in value and value["enabled"]] + [config["FLASCHENGEIST"]["auth"]] loaded_plugins = current_app.config["FG_PLUGINS"].keys() if not no_header: diff --git a/flaschengeist/models/image.py b/flaschengeist/models/image.py index 4c963e7..9a97ea8 100644 --- a/flaschengeist/models/image.py +++ b/flaschengeist/models/image.py @@ -9,11 +9,11 @@ from ..database import db class Image(db.Model, ModelSerializeMixin): __tablename__ = "image" - id: int = db.Column("id", Serial, primary_key=True) - filename_: str = db.Column(db.String(127), nullable=False) - mimetype_: str = db.Column(db.String(30), nullable=False) - thumbnail_: str = db.Column(db.String(127)) - path_: str = db.Column(db.String(127)) + id: int = db.Column(Serial, primary_key=True) + filename_: str = db.Column("filename", db.String(255), nullable=False) + mimetype_: str = db.Column("mimetype", db.String(127), nullable=False) + thumbnail_: str = db.Column("thumbnail", db.String(255)) + path_: str = db.Column("path", db.String(255)) def open(self): return open(self.path_, "rb") diff --git a/flaschengeist/models/setting.py b/flaschengeist/models/setting.py index 277f36c..b090c3e 100644 --- a/flaschengeist/models/setting.py +++ b/flaschengeist/models/setting.py @@ -8,6 +8,6 @@ from ..database import db class _PluginSetting(db.Model): __tablename__ = "plugin_setting" id = db.Column("id", Serial, primary_key=True) - plugin: str = db.Column(db.String(30)) - name: str = db.Column(db.String(30), nullable=False) + plugin: str = db.Column(db.String(127)) + name: str = db.Column(db.String(127), nullable=False) value: Any = db.Column(db.PickleType(protocol=4)) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 369e117..13936e5 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -126,7 +126,8 @@ class Plugin: def install(self): """Installation routine - Is always called with Flask application context + Is always called with Flask application context, + it is called after the plugin permissions are installed. """ pass diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 66d77be..afcc854 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -13,7 +13,6 @@ from flaschengeist.controller import sessionController, userController class AuthRoutePlugin(Plugin): - id = "dev.flaschengeist.auth" blueprint = Blueprint("auth", __name__) diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index e73b98c..44c27f7 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -14,12 +14,10 @@ from flaschengeist import logger class AuthPlain(AuthPlugin): - id = "auth_plain" - def install(self): plugins_installed(self.post_install) - def post_install(self, **kwargs): + def post_install(self, *args, **kwargs): if User.query.filter(User.deleted == False).count() == 0: logger.info("Installing admin user") role = Role.query.filter(Role.name == "Superuser").first() From fa503fe142311246baf3a4a5bfa2ddbdea7de155 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 31 Jul 2022 15:48:47 +0200 Subject: [PATCH 399/446] [cli] Added install command to install the database and all plugins Signed-off-by: Ferdinand Thiessen --- README.md | 9 ++++++++- flaschengeist/alembic/__init__.py | 4 ++++ flaschengeist/cli/__init__.py | 2 ++ flaschengeist/cli/install_cmd.py | 20 ++++++++++++++++++++ flaschengeist/plugins/users/__init__.py | 2 +- 5 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 flaschengeist/cli/install_cmd.py diff --git a/README.md b/README.md index 1cd3901..320bed5 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,16 @@ If not you need to create user and database manually do (or similar on Windows): ) | sudo mysql Then you can install the database tables, this will update all tables from core + all enabled plugins. -*Hint:* The same command can be later used to upgrade the database after plugins or core are updated. +And also install all enabled plugins: + + $ flaschengeist install + +*Hint:* To only install the database tables, or upgrade the database after plugins or core are updated later +you can use this command: $ flaschengeist db upgrade heads + ## Plugins To only upgrade one plugin (for example the `events` plugin): @@ -88,6 +94,7 @@ with the difference of the main logger will be forced to output to `stderr` and of the CLI will override the logging level you have configured for the main logger. $ flaschengeist run + or with debug messages: $ flaschengeist run --debug diff --git a/flaschengeist/alembic/__init__.py b/flaschengeist/alembic/__init__.py index e69de29..cf71018 100644 --- a/flaschengeist/alembic/__init__.py +++ b/flaschengeist/alembic/__init__.py @@ -0,0 +1,4 @@ +from pathlib import Path + + +alembic_migrations = str(Path(__file__).resolve().parent / "migrations") diff --git a/flaschengeist/cli/__init__.py b/flaschengeist/cli/__init__.py index ed93b5d..49e3333 100644 --- a/flaschengeist/cli/__init__.py +++ b/flaschengeist/cli/__init__.py @@ -88,12 +88,14 @@ def main(*args, **kwargs): from .export_cmd import export from .docs_cmd import docs from .run_cmd import run + from .install_cmd import install # Override logging level environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX)) cli.add_command(export) cli.add_command(docs) + cli.add_command(install) cli.add_command(plugin) cli.add_command(run) cli(*args, **kwargs) diff --git a/flaschengeist/cli/install_cmd.py b/flaschengeist/cli/install_cmd.py new file mode 100644 index 0000000..3d1b1ff --- /dev/null +++ b/flaschengeist/cli/install_cmd.py @@ -0,0 +1,20 @@ +import click +from click.decorators import pass_context +from flask.cli import with_appcontext +from flask_migrate import upgrade + +from flaschengeist.alembic import alembic_migrations +from flaschengeist.cli.plugin_cmd import install_plugin_command +from flaschengeist.utils.hook import Hook + + +@click.command() +@with_appcontext +@pass_context +@Hook("plugins.installed") +def install(ctx): + # Install database + upgrade(alembic_migrations, revision="heads") + + # Install plugins + install_plugin_command(ctx, [], True) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 2e0802c..778c819 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -104,7 +104,7 @@ def frontend(userid, current_session): raise Forbidden if request.method == "POST": - if request.content_length > 1024**2: + if request.content_length > 1024 ** 2: raise BadRequest current_session.user_.set_attribute("frontend", request.get_json()) return no_content() From dc2b949225c290d6734dcc278c229661faf690d7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 31 Jul 2022 19:06:55 +0200 Subject: [PATCH 400/446] [cli] Fix exporting of plugin interfaces Signed-off-by: Ferdinand Thiessen --- flaschengeist/cli/export_cmd.py | 16 ++++++++++++---- flaschengeist/cli/plugin_cmd.py | 4 +++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/flaschengeist/cli/export_cmd.py b/flaschengeist/cli/export_cmd.py index 6f93c70..2131f7b 100644 --- a/flaschengeist/cli/export_cmd.py +++ b/flaschengeist/cli/export_cmd.py @@ -1,4 +1,5 @@ import click +from importlib_metadata import entry_points @click.command() @@ -8,14 +9,21 @@ import click @click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True) def export(namespace, output, no_core, plugin): from flaschengeist import logger, models - from flaschengeist.app import get_plugins from .InterfaceGenerator import InterfaceGenerator gen = InterfaceGenerator(namespace, output, logger) if not no_core: gen.run(models) if plugin: - for plugin_class in get_plugins(): - if (len(plugin) == 0 or plugin_class.id in plugin) and plugin_class.models is not None: - gen.run(plugin_class.models) + for entry_point in entry_points(group="flaschengeist.plugins"): + if len(plugin) == 0 or entry_point.name in plugin: + try: + plugin = entry_point.load() + gen.run(plugin.models) + except: + logger.error( + f"Plugin {entry_point.name} could not be loaded due to an error.", + exc_info=True, + ) + continue gen.write() diff --git a/flaschengeist/cli/plugin_cmd.py b/flaschengeist/cli/plugin_cmd.py index c2b5274..4a34cc6 100644 --- a/flaschengeist/cli/plugin_cmd.py +++ b/flaschengeist/cli/plugin_cmd.py @@ -97,7 +97,9 @@ def ls(enabled, no_header): return p.version plugins = entry_points(group="flaschengeist.plugins") - enabled_plugins = [key for key, value in config.items() if "enabled" in value and value["enabled"]] + [config["FLASCHENGEIST"]["auth"]] + enabled_plugins = [key for key, value in config.items() if "enabled" in value and value["enabled"]] + [ + config["FLASCHENGEIST"]["auth"] + ] loaded_plugins = current_app.config["FG_PLUGINS"].keys() if not no_header: From 7f8aa80b0e50614b9cebc68950a7f717dbc648da Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Aug 2022 19:16:29 +0200 Subject: [PATCH 401/446] Update dependencies and increase python version to 3.10 Drop future imports, not needed with python 3.10 Signed-off-by: Ferdinand Thiessen --- flaschengeist/app.py | 2 +- flaschengeist/cli/export_cmd.py | 2 +- flaschengeist/cli/plugin_cmd.py | 2 +- flaschengeist/database.py | 4 +--- flaschengeist/models/image.py | 2 -- flaschengeist/models/notification.py | 1 - flaschengeist/models/session.py | 6 ------ flaschengeist/models/user.py | 3 --- flaschengeist/plugins/__init__.py | 2 +- flaschengeist/plugins/balance/models.py | 2 -- flaschengeist/plugins/pricelist/models.py | 2 -- setup.cfg | 14 ++++++-------- 12 files changed, 11 insertions(+), 31 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index b6f87e1..4aa8b88 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -4,7 +4,7 @@ from flask import Flask from flask_cors import CORS from datetime import datetime, date from flask.json import JSONEncoder, jsonify -from importlib_metadata import entry_points +from importlib.metadata import entry_points from sqlalchemy.exc import OperationalError from werkzeug.exceptions import HTTPException diff --git a/flaschengeist/cli/export_cmd.py b/flaschengeist/cli/export_cmd.py index 2131f7b..4e0fa03 100644 --- a/flaschengeist/cli/export_cmd.py +++ b/flaschengeist/cli/export_cmd.py @@ -1,5 +1,5 @@ import click -from importlib_metadata import entry_points +from importlib.metadata import entry_points @click.command() diff --git a/flaschengeist/cli/plugin_cmd.py b/flaschengeist/cli/plugin_cmd.py index 4a34cc6..97bd1bc 100644 --- a/flaschengeist/cli/plugin_cmd.py +++ b/flaschengeist/cli/plugin_cmd.py @@ -2,7 +2,7 @@ import click from click.decorators import pass_context from flask import current_app from flask.cli import with_appcontext -from importlib_metadata import EntryPoint, entry_points +from importlib.metadata import EntryPoint, entry_points from flaschengeist.database import db from flaschengeist.config import config diff --git a/flaschengeist/database.py b/flaschengeist/database.py index a2c4672..5bb30ef 100644 --- a/flaschengeist/database.py +++ b/flaschengeist/database.py @@ -1,7 +1,7 @@ import os from flask_migrate import Migrate, Config from flask_sqlalchemy import SQLAlchemy -from importlib_metadata import EntryPoint +from importlib.metadata import EntryPoint, entry_points, distribution from sqlalchemy import MetaData from flaschengeist import logger @@ -30,8 +30,6 @@ def configure_alembic(config: Config): This includes even disabled plugins, as simply disabling a plugin without uninstall can break the alembic version management. """ - from importlib_metadata import entry_points, distribution - # Set main script location config.set_main_option( "script_location", str(distribution("flaschengeist").locate_file("") / "flaschengeist" / "alembic") diff --git a/flaschengeist/models/image.py b/flaschengeist/models/image.py index 9a97ea8..d87af8a 100644 --- a/flaschengeist/models/image.py +++ b/flaschengeist/models/image.py @@ -1,5 +1,3 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 - from sqlalchemy import event from pathlib import Path diff --git a/flaschengeist/models/notification.py b/flaschengeist/models/notification.py index 9431c17..07320c7 100644 --- a/flaschengeist/models/notification.py +++ b/flaschengeist/models/notification.py @@ -1,4 +1,3 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 from datetime import datetime from typing import Any diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index 9acf27c..7dc6df8 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -1,10 +1,4 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 - from datetime import datetime, timedelta, timezone - -from . import ModelSerializeMixin, UtcDateTime, Serial -from .user import User -from flaschengeist.database import db from secrets import compare_digest from flaschengeist import logger diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 2ce1716..2889eeb 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,6 +1,3 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 - -from flask import url_for from typing import Optional from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 13936e5..fadecff 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -5,7 +5,7 @@ """ from typing import Optional -from importlib_metadata import Distribution, EntryPoint +from importlib.metadata import Distribution, EntryPoint from werkzeug.exceptions import MethodNotAllowed, NotFound from werkzeug.datastructures import FileStorage diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 1ba206b..d5d0061 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -1,5 +1,3 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 - from datetime import datetime from typing import Optional from sqlalchemy.ext.hybrid import hybrid_property diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 630766d..543fee0 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -1,5 +1,3 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 - from flaschengeist.database import db from flaschengeist.models import ModelSerializeMixin, Serial from flaschengeist.models.image import Image diff --git a/setup.cfg b/setup.cfg index 41af0da..46433c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,19 +19,17 @@ classifiers = [options] include_package_data = True -python_requires = >=3.9 +python_requires = >=3.10 packages = find: install_requires = - Flask>=2.0 - Pillow>=8.4.0 + Flask==2.0.3 + Pillow>=9.0 flask_cors flask_migrate>=3.1.0 - flask_sqlalchemy>=2.5 - # Importlib requirement can be dropped when python requirement is >= 3.10 - importlib_metadata>=4.3 - sqlalchemy>=1.4.26 + flask_sqlalchemy>=2.5.1 + sqlalchemy>=1.4.39 toml - werkzeug >= 2.0 + werkzeug==2.0.3 [options.extras_require] argon = argon2-cffi From e41be21c47dc4bbe3bed96e1afb2ecf1c00954e4 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Aug 2022 19:53:58 +0200 Subject: [PATCH 402/446] Restructure models and database import paths Signed-off-by: Ferdinand Thiessen --- flaschengeist/app.py | 1 + flaschengeist/cli/plugin_cmd.py | 2 +- flaschengeist/controller/imageController.py | 13 ++- flaschengeist/controller/messageController.py | 2 +- flaschengeist/controller/pluginController.py | 19 ++-- flaschengeist/controller/roleController.py | 8 +- flaschengeist/controller/sessionController.py | 11 +- flaschengeist/controller/userController.py | 21 ++-- .../{database.py => database/__init__.py} | 0 flaschengeist/database/types.py | 95 +++++++++++++++++ flaschengeist/models/__init__.py | 100 +----------------- flaschengeist/models/image.py | 2 +- flaschengeist/models/notification.py | 3 +- flaschengeist/models/plugin.py | 24 +++++ flaschengeist/models/session.py | 6 +- flaschengeist/models/setting.py | 13 --- flaschengeist/models/user.py | 7 +- flaschengeist/plugins/__init__.py | 11 +- flaschengeist/plugins/auth/__init__.py | 4 +- flaschengeist/plugins/auth_ldap/__init__.py | 3 +- flaschengeist/plugins/auth_plain/__init__.py | 2 +- flaschengeist/plugins/message_mail.py | 7 +- flaschengeist/plugins/pricelist/models.py | 4 +- flaschengeist/plugins/roles/__init__.py | 7 +- flaschengeist/plugins/scheduler.py | 5 +- flaschengeist/plugins/users/__init__.py | 8 +- 26 files changed, 202 insertions(+), 176 deletions(-) rename flaschengeist/{database.py => database/__init__.py} (100%) create mode 100644 flaschengeist/database/types.py create mode 100644 flaschengeist/models/plugin.py delete mode 100644 flaschengeist/models/setting.py diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 4aa8b88..f992005 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -9,6 +9,7 @@ from sqlalchemy.exc import OperationalError from werkzeug.exceptions import HTTPException from flaschengeist import logger +from flaschengeist.models import Plugin from flaschengeist.utils.hook import Hook from flaschengeist.plugins import AuthPlugin from flaschengeist.config import config, configure_app diff --git a/flaschengeist/cli/plugin_cmd.py b/flaschengeist/cli/plugin_cmd.py index 97bd1bc..d8b90c4 100644 --- a/flaschengeist/cli/plugin_cmd.py +++ b/flaschengeist/cli/plugin_cmd.py @@ -6,7 +6,7 @@ from importlib.metadata import EntryPoint, entry_points from flaschengeist.database import db from flaschengeist.config import config -from flaschengeist.models.user import Permission +from flaschengeist.models import Permission @click.group() diff --git a/flaschengeist/controller/imageController.py b/flaschengeist/controller/imageController.py index 26bfd2d..3915bce 100644 --- a/flaschengeist/controller/imageController.py +++ b/flaschengeist/controller/imageController.py @@ -1,15 +1,14 @@ from datetime import date -from flask import send_file from pathlib import Path +from flask import send_file from PIL import Image as PImage - -from werkzeug.exceptions import NotFound, UnprocessableEntity -from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename +from werkzeug.datastructures import FileStorage +from werkzeug.exceptions import NotFound, UnprocessableEntity -from flaschengeist.models.image import Image -from flaschengeist.database import db -from flaschengeist.config import config +from ..models import Image +from ..database import db +from ..config import config def check_mimetype(mime: str): diff --git a/flaschengeist/controller/messageController.py b/flaschengeist/controller/messageController.py index f43afc8..573a149 100644 --- a/flaschengeist/controller/messageController.py +++ b/flaschengeist/controller/messageController.py @@ -1,5 +1,5 @@ from flaschengeist.utils.hook import Hook -from flaschengeist.models.user import User, Role +from flaschengeist.models import User, Role class Message: diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 7dbe678..8313935 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -4,9 +4,16 @@ Used by plugins for setting and notification functionality. """ import sqlalchemy + +from typing import Union +from flask import current_app +from werkzeug.exceptions import NotFound +from importlib.metadata import entry_points + +from .. import logger from ..database import db -from ..models.setting import _PluginSetting -from ..models.notification import Notification +from ..utils import Hook +from ..models import Plugin, PluginSetting, Notification def get_setting(plugin_id: str, name: str, **kwargs): @@ -23,7 +30,7 @@ def get_setting(plugin_id: str, name: str, **kwargs): """ try: setting = ( - _PluginSetting.query.filter(_PluginSetting.plugin == plugin_id).filter(_PluginSetting.name == name).one() + PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one() ) return setting.value except sqlalchemy.orm.exc.NoResultFound: @@ -42,8 +49,8 @@ def set_setting(plugin_id: str, name: str, value): value: Value to be stored """ setting = ( - _PluginSetting.query.filter(_PluginSetting.plugin == plugin_id) - .filter(_PluginSetting.name == name) + PluginSetting.query.filter(PluginSetting.plugin == plugin_id) + .filter(PluginSetting.name == name) .one_or_none() ) if setting is not None: @@ -52,7 +59,7 @@ def set_setting(plugin_id: str, name: str, value): else: setting.value = value else: - db.session.add(_PluginSetting(plugin=plugin_id, name=name, value=value)) + db.session.add(PluginSetting(plugin=plugin_id, name=name, value=value)) db.session.commit() diff --git a/flaschengeist/controller/roleController.py b/flaschengeist/controller/roleController.py index 23528ad..eedb7c7 100644 --- a/flaschengeist/controller/roleController.py +++ b/flaschengeist/controller/roleController.py @@ -2,10 +2,10 @@ from typing import Union from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import BadRequest, Conflict, NotFound -from flaschengeist import logger -from flaschengeist.models.user import Role, Permission -from flaschengeist.database import db, case_sensitive -from flaschengeist.utils.hook import Hook +from .. import logger +from ..models import Role, Permission +from ..database import db, case_sensitive +from ..utils.hook import Hook def get_all(): diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py index 4cae005..5d5ceae 100644 --- a/flaschengeist/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -1,9 +1,12 @@ import secrets -from flaschengeist.models.session import Session -from flaschengeist.database import db -from flaschengeist import logger -from werkzeug.exceptions import Forbidden, Unauthorized + from datetime import datetime, timezone +from werkzeug.exceptions import Forbidden, Unauthorized + +from .. import logger +from ..models import Session +from ..database import db + lifetime = 1800 diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index bd6e4b8..610db39 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,5 +1,6 @@ -import secrets import re +import secrets + from io import BytesIO from sqlalchemy import exc from flask import current_app @@ -7,15 +8,15 @@ from datetime import datetime, timedelta, timezone from flask.helpers import send_file from werkzeug.exceptions import NotFound, BadRequest, Forbidden -from flaschengeist import logger -from flaschengeist.config import config -from flaschengeist.database import db -from flaschengeist.models.notification import Notification -from flaschengeist.utils.hook import Hook -from flaschengeist.utils.datetime import from_iso_format -from flaschengeist.utils.foreign_keys import merge_references -from flaschengeist.models.user import User, Role, _PasswordReset -from flaschengeist.controller import imageController, messageController, sessionController +from .. import logger +from ..config import config +from ..database import db +from ..models import Notification, User, Role +from ..models.user import _PasswordReset +from ..utils.hook import Hook +from ..utils.datetime import from_iso_format +from ..utils.foreign_keys import merge_references +from ..controller import imageController, messageController, sessionController def __active_users(): diff --git a/flaschengeist/database.py b/flaschengeist/database/__init__.py similarity index 100% rename from flaschengeist/database.py rename to flaschengeist/database/__init__.py diff --git a/flaschengeist/database/types.py b/flaschengeist/database/types.py new file mode 100644 index 0000000..369bb30 --- /dev/null +++ b/flaschengeist/database/types.py @@ -0,0 +1,95 @@ +import sys +import datetime + +from sqlalchemy import BigInteger, util +from sqlalchemy.dialects import mysql, sqlite +from sqlalchemy.types import DateTime, TypeDecorator + + +class ModelSerializeMixin: + """Mixin class used for models to serialize them automatically + Ignores private and protected members as well as members marked as not to publish (name ends with _) + """ + + def __is_optional(self, param): + if sys.version_info < (3, 8): + return False + + import typing + + hint = typing.get_type_hints(self.__class__)[param] + if ( + typing.get_origin(hint) is typing.Union + and len(typing.get_args(hint)) == 2 + and typing.get_args(hint)[1] is type(None) + ): + return getattr(self, param) is None + + def serialize(self): + """Serialize class to dict + Returns: + Dict of all not private or protected annotated member variables. + """ + d = { + param: getattr(self, param) + for param in self.__class__.__annotations__ + if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param) + } + if len(d) == 1: + key, value = d.popitem() + return value + return d + + def __str__(self) -> str: + return self.serialize().__str__() + + +class Serial(TypeDecorator): + """Same as MariaDB Serial used for IDs""" + + cache_ok = True + impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER(), "sqlite") + + # https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves + def __repr__(self) -> str: + return util.generic_repr(self) + + +class UtcDateTime(TypeDecorator): + """Almost equivalent to `sqlalchemy.types.DateTime` with + ``timezone=True`` option, but it differs from that by: + + - Never silently take naive :class:`datetime.datetime`, instead it + always raise :exc:`ValueError` unless time zone aware value. + - :class:`datetime.datetime` value's :attr:`datetime.datetime.tzinfo` + is always converted to UTC. + - Unlike SQLAlchemy's built-in :class:`sqlalchemy.types.DateTime`, + it never return naive :class:`datetime.datetime`, but time zone + aware value, even with SQLite or MySQL. + """ + + cache_ok = True + impl = DateTime(timezone=True) + + @staticmethod + def current_utc(): + return datetime.datetime.now(tz=datetime.timezone.utc) + + def process_bind_param(self, value, dialect): + if value is not None: + if not isinstance(value, datetime.datetime): + raise TypeError("expected datetime.datetime, not " + repr(value)) + elif value.tzinfo is None: + raise ValueError("naive datetime is disallowed") + return value.astimezone(datetime.timezone.utc) + + def process_result_value(self, value, dialect): + if value is not None: + if value.tzinfo is not None: + value = value.astimezone(datetime.timezone.utc) + value = value.replace(tzinfo=datetime.timezone.utc) + return value + + # https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves + def __repr__(self) -> str: + return util.generic_repr(self) diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 369bb30..99a5e8d 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -1,95 +1,5 @@ -import sys -import datetime - -from sqlalchemy import BigInteger, util -from sqlalchemy.dialects import mysql, sqlite -from sqlalchemy.types import DateTime, TypeDecorator - - -class ModelSerializeMixin: - """Mixin class used for models to serialize them automatically - Ignores private and protected members as well as members marked as not to publish (name ends with _) - """ - - def __is_optional(self, param): - if sys.version_info < (3, 8): - return False - - import typing - - hint = typing.get_type_hints(self.__class__)[param] - if ( - typing.get_origin(hint) is typing.Union - and len(typing.get_args(hint)) == 2 - and typing.get_args(hint)[1] is type(None) - ): - return getattr(self, param) is None - - def serialize(self): - """Serialize class to dict - Returns: - Dict of all not private or protected annotated member variables. - """ - d = { - param: getattr(self, param) - for param in self.__class__.__annotations__ - if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param) - } - if len(d) == 1: - key, value = d.popitem() - return value - return d - - def __str__(self) -> str: - return self.serialize().__str__() - - -class Serial(TypeDecorator): - """Same as MariaDB Serial used for IDs""" - - cache_ok = True - impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER(), "sqlite") - - # https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves - def __repr__(self) -> str: - return util.generic_repr(self) - - -class UtcDateTime(TypeDecorator): - """Almost equivalent to `sqlalchemy.types.DateTime` with - ``timezone=True`` option, but it differs from that by: - - - Never silently take naive :class:`datetime.datetime`, instead it - always raise :exc:`ValueError` unless time zone aware value. - - :class:`datetime.datetime` value's :attr:`datetime.datetime.tzinfo` - is always converted to UTC. - - Unlike SQLAlchemy's built-in :class:`sqlalchemy.types.DateTime`, - it never return naive :class:`datetime.datetime`, but time zone - aware value, even with SQLite or MySQL. - """ - - cache_ok = True - impl = DateTime(timezone=True) - - @staticmethod - def current_utc(): - return datetime.datetime.now(tz=datetime.timezone.utc) - - def process_bind_param(self, value, dialect): - if value is not None: - if not isinstance(value, datetime.datetime): - raise TypeError("expected datetime.datetime, not " + repr(value)) - elif value.tzinfo is None: - raise ValueError("naive datetime is disallowed") - return value.astimezone(datetime.timezone.utc) - - def process_result_value(self, value, dialect): - if value is not None: - if value.tzinfo is not None: - value = value.astimezone(datetime.timezone.utc) - value = value.replace(tzinfo=datetime.timezone.utc) - return value - - # https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves - def __repr__(self) -> str: - return util.generic_repr(self) +from .session import * +from .user import * +from .plugin import * +from .notification import * +from .image import * \ No newline at end of file diff --git a/flaschengeist/models/image.py b/flaschengeist/models/image.py index d87af8a..406fefe 100644 --- a/flaschengeist/models/image.py +++ b/flaschengeist/models/image.py @@ -1,8 +1,8 @@ from sqlalchemy import event from pathlib import Path -from . import ModelSerializeMixin, Serial from ..database import db +from ..database.types import ModelSerializeMixin, Serial class Image(db.Model, ModelSerializeMixin): diff --git a/flaschengeist/models/notification.py b/flaschengeist/models/notification.py index 07320c7..a25efd4 100644 --- a/flaschengeist/models/notification.py +++ b/flaschengeist/models/notification.py @@ -1,9 +1,8 @@ from datetime import datetime from typing import Any -from . import Serial, UtcDateTime, ModelSerializeMixin from ..database import db -from .user import User +from ..database.types import Serial, UtcDateTime, ModelSerializeMixin class Notification(db.Model, ModelSerializeMixin): diff --git a/flaschengeist/models/plugin.py b/flaschengeist/models/plugin.py new file mode 100644 index 0000000..d2af46c --- /dev/null +++ b/flaschengeist/models/plugin.py @@ -0,0 +1,24 @@ +from typing import Any + +from ..database import db +from ..database.types import Serial + + +class Plugin(db.Model): + __tablename__ = "plugin" + id: int = db.Column("id", Serial, primary_key=True) + name: str = db.Column(db.String(127), nullable=False) + version: str = db.Column(db.String(30), nullable=False) + """The latest installed version""" + enabled: bool = db.Column(db.Boolean, default=False) + + settings_ = db.relationship("PluginSetting", cascade="all, delete") + permissions_ = db.relationship("Permission", cascade="all, delete") + + +class PluginSetting(db.Model): + __tablename__ = "plugin_setting" + id = db.Column("id", Serial, primary_key=True) + plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) + name: str = db.Column(db.String(127), nullable=False) + value: Any = db.Column(db.PickleType(protocol=4)) diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index 7dc6df8..dea7c62 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -1,5 +1,9 @@ from datetime import datetime, timedelta, timezone from secrets import compare_digest + +from ..database import db +from ..database.types import ModelSerializeMixin, UtcDateTime, Serial + from flaschengeist import logger @@ -22,7 +26,7 @@ class Session(db.Model, ModelSerializeMixin): _id = db.Column("id", Serial, primary_key=True) _user_id = db.Column("user_id", Serial, db.ForeignKey("user.id")) - user_: User = db.relationship("User", back_populates="sessions_") + user_: "User" = db.relationship("User", back_populates="sessions_") @property def userid(self): diff --git a/flaschengeist/models/setting.py b/flaschengeist/models/setting.py deleted file mode 100644 index b090c3e..0000000 --- a/flaschengeist/models/setting.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 -from typing import Any - -from . import Serial -from ..database import db - - -class _PluginSetting(db.Model): - __tablename__ = "plugin_setting" - id = db.Column("id", Serial, primary_key=True) - plugin: str = db.Column(db.String(127)) - name: str = db.Column(db.String(127), nullable=False) - value: Any = db.Column(db.PickleType(protocol=4)) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 2889eeb..c468ebf 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -3,9 +3,7 @@ from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection from ..database import db -from . import ModelSerializeMixin, UtcDateTime, Serial -from .image import Image - +from ..database.types import ModelSerializeMixin, UtcDateTime, Serial association_table = db.Table( "user_x_role", @@ -19,7 +17,6 @@ role_permission_association_table = db.Table( db.Column("permission_id", Serial, db.ForeignKey("permission.id")), ) - class Permission(db.Model, ModelSerializeMixin): __tablename__ = "permission" name: str = db.Column(db.String(30), unique=True) @@ -66,7 +63,7 @@ class User(db.Model, ModelSerializeMixin): sessions_: list["Session"] = db.relationship( "Session", back_populates="user_", cascade="all, delete, delete-orphan" ) - avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) + avatar_: Optional["Image"] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan") # Private stuff for internal use diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index fadecff..76a35e3 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -9,7 +9,8 @@ from importlib.metadata import Distribution, EntryPoint from werkzeug.exceptions import MethodNotAllowed, NotFound from werkzeug.datastructures import FileStorage -from flaschengeist.models.user import _Avatar, User +from flaschengeist.models import User +from flaschengeist.models.user import _Avatar from flaschengeist.utils.hook import HookBefore, HookAfter __all__ = [ @@ -19,7 +20,7 @@ __all__ = [ "before_role_updated", "before_update_user", "after_role_updated", - "Plugin", + "BasePlugin", "AuthPlugin", ] @@ -70,7 +71,7 @@ Passed args: """ -class Plugin: +class BasePlugin: """Base class for all Plugins All plugins must derived from this class. @@ -193,10 +194,10 @@ class Plugin: return {"version": self.version, "permissions": self.permissions} -class AuthPlugin(Plugin): +class AuthPlugin(BasePlugin): """Base class for all authentification plugins - See also `Plugin` + See also `BasePlugin` """ def login(self, user, pw): diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index afcc854..439b2a6 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -6,13 +6,13 @@ from flask import Blueprint, request, jsonify from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from flaschengeist import logger -from flaschengeist.plugins import Plugin +from flaschengeist.plugins import BasePlugin from flaschengeist.utils.HTTP import no_content, created from flaschengeist.utils.decorators import login_required from flaschengeist.controller import sessionController, userController -class AuthRoutePlugin(Plugin): +class AuthRoutePlugin(BasePlugin): blueprint = Blueprint("auth", __name__) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index ef8ecb1..bf2fb52 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -12,7 +12,8 @@ from werkzeug.datastructures import FileStorage from flaschengeist import logger from flaschengeist.controller import userController -from flaschengeist.models.user import User, Role, _Avatar +from flaschengeist.models import User, Role +from flaschengeist.models.user import _Avatar from flaschengeist.plugins import AuthPlugin, before_role_updated diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 44c27f7..50ab4af 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -8,7 +8,7 @@ import hashlib import binascii from werkzeug.exceptions import BadRequest from flaschengeist.plugins import AuthPlugin, plugins_installed -from flaschengeist.models.user import User, Role, Permission +from flaschengeist.models import User, Role, Permission from flaschengeist.database import db from flaschengeist import logger diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index 2fe6a76..59d82d1 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -3,15 +3,14 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from flaschengeist import logger -from flaschengeist.models.user import User +from flaschengeist.models import User +from flaschengeist.plugins import BasePlugin from flaschengeist.utils.hook import HookAfter from flaschengeist.controller import userController from flaschengeist.controller.messageController import Message -from . import Plugin - -class MailMessagePlugin(Plugin): +class MailMessagePlugin(BasePlugin): def __init__(self, entry_point, config): super().__init__(entry_point, config) self.server = config["SERVER"] diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 543fee0..1d8dc23 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -1,6 +1,6 @@ from flaschengeist.database import db -from flaschengeist.models import ModelSerializeMixin, Serial -from flaschengeist.models.image import Image +from flaschengeist.database.types import ModelSerializeMixin, Serial +from flaschengeist.models import Image from typing import Optional diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index 4e3c92b..cd2fae4 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -5,17 +5,16 @@ Provides routes used to configure roles and permissions of users / roles. from werkzeug.exceptions import BadRequest from flask import Blueprint, request, jsonify -from http.client import NO_CONTENT -from flaschengeist.plugins import Plugin -from flaschengeist.utils.decorators import login_required +from flaschengeist.plugins import BasePlugin from flaschengeist.controller import roleController from flaschengeist.utils.HTTP import created, no_content +from flaschengeist.utils.decorators import login_required from . import permissions -class RolesPlugin(Plugin): +class RolesPlugin(BasePlugin): blueprint = Blueprint("roles", __name__) permissions = permissions.permissions diff --git a/flaschengeist/plugins/scheduler.py b/flaschengeist/plugins/scheduler.py index 7d15b69..a5e6eef 100644 --- a/flaschengeist/plugins/scheduler.py +++ b/flaschengeist/plugins/scheduler.py @@ -2,10 +2,9 @@ from flask import Blueprint from datetime import datetime, timedelta from flaschengeist import logger +from flaschengeist.plugins import BasePlugin from flaschengeist.utils.HTTP import no_content -from . import Plugin - class __Task: def __init__(self, function, **kwags): @@ -38,7 +37,7 @@ def scheduled(id: str, replace=False, **kwargs): return real_decorator -class SchedulerPlugin(Plugin): +class SchedulerPlugin(BasePlugin): def __init__(self, entry_point, config=None): super().__init__(entry_point, config) self.blueprint = Blueprint(self.name, __name__) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 778c819..3cd44df 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -2,14 +2,14 @@ Provides routes used to manage users """ -from http.client import NO_CONTENT, CREATED +from http.client import CREATED from flask import Blueprint, request, jsonify, make_response -from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound +from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed from . import permissions from flaschengeist import logger from flaschengeist.config import config -from flaschengeist.plugins import Plugin +from flaschengeist.plugins import BasePlugin from flaschengeist.models.user import User from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.controller import userController @@ -17,7 +17,7 @@ from flaschengeist.utils.HTTP import created, no_content from flaschengeist.utils.datetime import from_iso_format -class UsersPlugin(Plugin): +class UsersPlugin(BasePlugin): blueprint = Blueprint("users", __name__) permissions = permissions.permissions From d3530cc15f71688893fcc7c502143e7b5d6534b8 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Aug 2022 19:58:02 +0200 Subject: [PATCH 403/446] The enabled state of plugins is now loaded from database rather than config file Signed-off-by: Ferdinand Thiessen --- flaschengeist/app.py | 58 +++++++--------- flaschengeist/config.py | 11 --- flaschengeist/controller/messageController.py | 4 +- flaschengeist/controller/pluginController.py | 69 +++++++++++++++++-- flaschengeist/models/__init__.py | 2 +- flaschengeist/models/notification.py | 13 +++- flaschengeist/models/user.py | 2 + flaschengeist/plugins/__init__.py | 14 +++- flaschengeist/plugins/scheduler.py | 9 +-- flaschengeist/plugins/users/__init__.py | 2 +- 10 files changed, 120 insertions(+), 64 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index f992005..29ece60 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -11,8 +11,7 @@ from werkzeug.exceptions import HTTPException from flaschengeist import logger from flaschengeist.models import Plugin from flaschengeist.utils.hook import Hook -from flaschengeist.plugins import AuthPlugin -from flaschengeist.config import config, configure_app +from flaschengeist.config import configure_app class CustomJSONEncoder(JSONEncoder): @@ -40,39 +39,30 @@ class CustomJSONEncoder(JSONEncoder): def load_plugins(app: Flask): app.config["FG_PLUGINS"] = {} - for entry_point in entry_points(group="flaschengeist.plugins"): - logger.debug(f"Found plugin: {entry_point.name} ({entry_point.dist.version})") + enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all() + all_plugins = entry_points(group="flaschengeist.plugins") - if entry_point.name == config["FLASCHENGEIST"]["auth"] or ( - entry_point.name in config and config[entry_point.name].get("enabled", False) - ): - logger.debug(f"Load plugin {entry_point.name}") - try: - plugin = entry_point.load()(entry_point, config=config.get(entry_point.name, {})) - if hasattr(plugin, "blueprint") and plugin.blueprint is not None: - app.register_blueprint(plugin.blueprint) - except: - logger.error( - f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", - exc_info=True, - ) - continue - if isinstance(plugin, AuthPlugin): - if entry_point.name != config["FLASCHENGEIST"]["auth"]: - logger.debug(f"Unload not configured AuthPlugin {entry_point.name}") - del plugin - continue - else: - logger.info(f"Using authentication plugin: {entry_point.name}") - app.config["FG_AUTH_BACKEND"] = plugin - else: - logger.info(f"Using plugin: {entry_point.name}") - app.config["FG_PLUGINS"][entry_point.name] = plugin - else: - logger.debug(f"Skip disabled plugin {entry_point.name}") - if "FG_AUTH_BACKEND" not in app.config: - logger.fatal("No authentication plugin configured or authentication plugin not found") - raise RuntimeError("No authentication plugin configured or authentication plugin not found") + for plugin in enabled_plugins: + logger.debug(f"Searching for enabled plugin {plugin.name}") + entry_point = all_plugins.select(name=plugin.name) + if not entry_point: + logger.error( + f"Plugin {plugin.name} was enabled, but could not be found.", + exc_info=True, + ) + continue + try: + loaded = entry_point[0].load()(entry_point[0]) + if hasattr(plugin, "blueprint") and plugin.blueprint is not None: + app.register_blueprint(plugin.blueprint) + except: + logger.error( + f"Plugin {plugin.name} was enabled, but could not be loaded due to an error.", + exc_info=True, + ) + continue + logger.info(f"Loaded plugin: {plugin.name}") + app.config["FG_PLUGINS"][plugin.name] = loaded def create_app(test_config=None, cli=False): diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 3adae22..712d5d1 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -77,17 +77,6 @@ def configure_app(app, test_config=None): configure_logger() - # Always enable this builtin plugins! - update_dict( - config, - { - "auth": {"enabled": True}, - "roles": {"enabled": True}, - "users": {"enabled": True}, - "scheduler": {"enabled": True}, - }, - ) - if "secret_key" not in config["FLASCHENGEIST"]: logger.critical("No secret key was configured, please configure one for production systems!") raise RuntimeError("No secret key was configured") diff --git a/flaschengeist/controller/messageController.py b/flaschengeist/controller/messageController.py index 573a149..d9ff78c 100644 --- a/flaschengeist/controller/messageController.py +++ b/flaschengeist/controller/messageController.py @@ -1,5 +1,5 @@ -from flaschengeist.utils.hook import Hook -from flaschengeist.models import User, Role +from ..utils.hook import Hook +from ..models import User, Role class Message: diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 8313935..6c16491 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -29,9 +29,7 @@ def get_setting(plugin_id: str, name: str, **kwargs): `KeyError` if no such setting exists in the database """ try: - setting = ( - PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one() - ) + setting = PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one() return setting.value except sqlalchemy.orm.exc.NoResultFound: if "default" in kwargs: @@ -49,9 +47,7 @@ def set_setting(plugin_id: str, name: str, value): value: Value to be stored """ setting = ( - PluginSetting.query.filter(PluginSetting.plugin == plugin_id) - .filter(PluginSetting.name == name) - .one_or_none() + PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one_or_none() ) if setting is not None: if value is None: @@ -81,3 +77,64 @@ def notify(plugin_id: str, user, text: str, data=None): db.session.add(n) db.session.commit() return n.id + + +@Hook("plugins.installed") +def install_plugin(plugin_name: str): + logger.debug(f"Installing plugin {plugin_name}") + entry_point = entry_points(group="flaschengeist.plugins", name=plugin_name) + if not entry_point: + raise NotFound + + plugin = entry_point[0].load()(entry_point[0]) + entity = Plugin(name=plugin.name, version=plugin.version) + db.session.add(entity) + db.session.commit() + return entity + + +@Hook("plugin.uninstalled") +def uninstall_plugin(plugin_id: Union[str, int]): + plugin = disable_plugin(plugin_id) + logger.debug(f"Uninstall plugin {plugin.name}") + + entity = current_app.config["FG_PLUGINS"][plugin.name] + entity.uninstall() + del current_app.config["FG_PLUGINS"][plugin.name] + db.session.delete(plugin) + db.session.commit() + + +@Hook("plugins.enabled") +def enable_plugin(plugin_id: Union[str, int]): + logger.debug(f"Enabling plugin {plugin_id}") + plugin: Plugin = Plugin.query + if isinstance(plugin_id, str): + plugin = plugin.filter(Plugin.name == plugin_id).one_or_none() + if plugin is None: + logger.debug("Plugin not installed, trying to install") + plugin = install_plugin(plugin_id) + else: + plugin = plugin.get(plugin_id) + if plugin is None: + raise NotFound + plugin.enabled = True + db.session.commit() + + return plugin + + +@Hook("plugins.disabled") +def disable_plugin(plugin_id: Union[str, int]): + logger.debug(f"Disabling plugin {plugin_id}") + plugin: Plugin = Plugin.query + if isinstance(plugin_id, str): + plugin = plugin.filter(Plugin.name == plugin_id).one_or_none() + else: + plugin = plugin.get(plugin_id) + if plugin is None: + raise NotFound + plugin.enabled = False + db.session.commit() + + return plugin diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 99a5e8d..096ac2e 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -2,4 +2,4 @@ from .session import * from .user import * from .plugin import * from .notification import * -from .image import * \ No newline at end of file +from .image import * diff --git a/flaschengeist/models/notification.py b/flaschengeist/models/notification.py index a25efd4..549a5b7 100644 --- a/flaschengeist/models/notification.py +++ b/flaschengeist/models/notification.py @@ -8,10 +8,17 @@ from ..database.types import Serial, UtcDateTime, ModelSerializeMixin class Notification(db.Model, ModelSerializeMixin): __tablename__ = "notification" id: int = db.Column("id", Serial, primary_key=True) - plugin: str = db.Column(db.String(127), nullable=False) text: str = db.Column(db.Text) data: Any = db.Column(db.PickleType(protocol=4)) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) - user_id_: int = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False) - user_: User = db.relationship("User") + user_id_: int = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False) + plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"), nullable=False) + user_: "User" = db.relationship("User") + plugin_: "Plugin" = db.relationship( + "Plugin", backref=db.backref("notifications_", cascade="all, delete, delete-orphan") + ) + + @property + def plugin(self): + return self.plugin_.name diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index c468ebf..578758c 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -17,11 +17,13 @@ role_permission_association_table = db.Table( db.Column("permission_id", Serial, db.ForeignKey("permission.id")), ) + class Permission(db.Model, ModelSerializeMixin): __tablename__ = "permission" name: str = db.Column(db.String(30), unique=True) _id = db.Column("id", Serial, primary_key=True) + _plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) class Role(db.Model, ModelSerializeMixin): diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 76a35e3..f1a68a0 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -115,10 +115,10 @@ class BasePlugin: ``` """ - def __init__(self, entry_point: EntryPoint, config=None): + def __init__(self, entry_point: EntryPoint): """Constructor called by create_app Args: - config: Dict configuration containing the plugin section + entry_point: EntryPoint from which this plugin was loaded """ self.version = entry_point.dist.version self.name = entry_point.name @@ -127,6 +127,8 @@ class BasePlugin: def install(self): """Installation routine + Also called when updating the plugin, compare `version` and `installed_version`. + Is always called with Flask application context, it is called after the plugin permissions are installed. """ @@ -143,6 +145,14 @@ class BasePlugin: """ pass + @property + def installed_version(self): + """Installed version of the plugin""" + from ..controller import pluginController + + self.__installed_version = pluginController.get_installed_version(self.name) + return self.__installed_version + def get_setting(self, name: str, **kwargs): """Get plugin setting from database diff --git a/flaschengeist/plugins/scheduler.py b/flaschengeist/plugins/scheduler.py index a5e6eef..43a0a8b 100644 --- a/flaschengeist/plugins/scheduler.py +++ b/flaschengeist/plugins/scheduler.py @@ -2,6 +2,7 @@ from flask import Blueprint from datetime import datetime, timedelta from flaschengeist import logger +from flaschengeist.config import config from flaschengeist.plugins import BasePlugin from flaschengeist.utils.HTTP import no_content @@ -38,8 +39,8 @@ def scheduled(id: str, replace=False, **kwargs): class SchedulerPlugin(BasePlugin): - def __init__(self, entry_point, config=None): - super().__init__(entry_point, config) + def __init__(self, entry_point): + super().__init__(entry_point) self.blueprint = Blueprint(self.name, __name__) def __view_func(): @@ -52,9 +53,9 @@ class SchedulerPlugin(BasePlugin): except: logger.error("Error while executing scheduled tasks!", exc_info=True) - cron = None if config is None else config.get("cron", "passive_web").lower() + cron = config.get("scheduler", {}).get("cron", "passive_web").lower() - if cron is None or cron == "passive_web": + if cron == "passive_web": self.blueprint.teardown_app_request(__passiv_func) elif cron == "active_web": self.blueprint.add_url_rule("/cron", view_func=__view_func) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 3cd44df..7511a3f 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -10,7 +10,7 @@ from . import permissions from flaschengeist import logger from flaschengeist.config import config from flaschengeist.plugins import BasePlugin -from flaschengeist.models.user import User +from flaschengeist.models import User from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.controller import userController from flaschengeist.utils.HTTP import created, no_content From ee38e46c12773e6da1256c27e965507a79396043 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 18 Aug 2022 22:59:19 +0200 Subject: [PATCH 404/446] [core] Cleanup + Fix loading migrations of (dis)abled plugins Signed-off-by: Ferdinand Thiessen --- flaschengeist/alembic/__init__.py | 3 ++- flaschengeist/app.py | 7 ++---- flaschengeist/cli/install_cmd.py | 4 ++-- flaschengeist/controller/pluginController.py | 23 +++++++++++++++++++- flaschengeist/database/__init__.py | 19 +++++++++------- 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/flaschengeist/alembic/__init__.py b/flaschengeist/alembic/__init__.py index cf71018..cd7fe4a 100644 --- a/flaschengeist/alembic/__init__.py +++ b/flaschengeist/alembic/__init__.py @@ -1,4 +1,5 @@ from pathlib import Path -alembic_migrations = str(Path(__file__).resolve().parent / "migrations") +alembic_migrations_path = str(Path(__file__).resolve().parent / "migrations") +alembic_script_path = str(Path(__file__).resolve().parent) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 29ece60..ee8a6bd 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -5,11 +5,10 @@ from flask_cors import CORS from datetime import datetime, date from flask.json import JSONEncoder, jsonify from importlib.metadata import entry_points -from sqlalchemy.exc import OperationalError from werkzeug.exceptions import HTTPException from flaschengeist import logger -from flaschengeist.models import Plugin +from flaschengeist.controller import pluginController from flaschengeist.utils.hook import Hook from flaschengeist.config import configure_app @@ -38,11 +37,9 @@ class CustomJSONEncoder(JSONEncoder): @Hook("plugins.loaded") def load_plugins(app: Flask): app.config["FG_PLUGINS"] = {} - - enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all() all_plugins = entry_points(group="flaschengeist.plugins") - for plugin in enabled_plugins: + for plugin in pluginController.get_enabled_plugins(): logger.debug(f"Searching for enabled plugin {plugin.name}") entry_point = all_plugins.select(name=plugin.name) if not entry_point: diff --git a/flaschengeist/cli/install_cmd.py b/flaschengeist/cli/install_cmd.py index 3d1b1ff..e566163 100644 --- a/flaschengeist/cli/install_cmd.py +++ b/flaschengeist/cli/install_cmd.py @@ -3,7 +3,7 @@ from click.decorators import pass_context from flask.cli import with_appcontext from flask_migrate import upgrade -from flaschengeist.alembic import alembic_migrations +from flaschengeist.alembic import alembic_migrations_path from flaschengeist.cli.plugin_cmd import install_plugin_command from flaschengeist.utils.hook import Hook @@ -14,7 +14,7 @@ from flaschengeist.utils.hook import Hook @Hook("plugins.installed") def install(ctx): # Install database - upgrade(alembic_migrations, revision="heads") + upgrade(alembic_migrations_path, revision="heads") # Install plugins install_plugin_command(ctx, [], True) diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 6c16491..ba0e91b 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -8,14 +8,35 @@ import sqlalchemy from typing import Union from flask import current_app from werkzeug.exceptions import NotFound +from sqlalchemy.exc import OperationalError from importlib.metadata import entry_points from .. import logger from ..database import db -from ..utils import Hook +from ..utils.hook import Hook from ..models import Plugin, PluginSetting, Notification +def get_enabled_plugins(): + try: + enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all() + except OperationalError as e: + + class PluginStub: + def __init__(self, name) -> None: + self.name = name + + logger.error("Could not connect to database or database not initialized! No plugins enabled!") + logger.debug("Can not query enabled plugins", exc_info=True) + enabled_plugins = [ + PluginStub("auth"), + PluginStub("roles"), + PluginStub("users"), + PluginStub("scheduler"), + ] + return enabled_plugins + + def get_setting(plugin_id: str, name: str, **kwargs): """Get plugin setting from database diff --git a/flaschengeist/database/__init__.py b/flaschengeist/database/__init__.py index 5bb30ef..21301d7 100644 --- a/flaschengeist/database/__init__.py +++ b/flaschengeist/database/__init__.py @@ -4,7 +4,9 @@ from flask_sqlalchemy import SQLAlchemy from importlib.metadata import EntryPoint, entry_points, distribution from sqlalchemy import MetaData +from flaschengeist.alembic import alembic_script_path from flaschengeist import logger +from flaschengeist.controller import pluginController # https://alembic.sqlalchemy.org/en/latest/naming.html metadata = MetaData( @@ -31,25 +33,26 @@ def configure_alembic(config: Config): uninstall can break the alembic version management. """ # Set main script location - config.set_main_option( - "script_location", str(distribution("flaschengeist").locate_file("") / "flaschengeist" / "alembic") - ) + config.set_main_option("script_location", alembic_script_path) # Set Flaschengeist's migrations migrations = [config.get_main_option("script_location") + "/migrations"] # Gather all migration paths - ep: EntryPoint - for ep in entry_points(group="flaschengeist.plugins"): + all_plugins = entry_points(group="flaschengeist.plugins") + for plugin in pluginController.get_enabled_plugins(): + entry_point = all_plugins.select(name=plugin.name) + if not entry_point: + continue try: - directory = ep.dist.locate_file("") - for loc in ep.module.split(".") + ["migrations"]: + directory = entry_point.dist.locate_file("") + for loc in entry_point.module.split(".") + ["migrations"]: directory /= loc if directory.exists(): logger.debug(f"Adding migration version path {directory}") migrations.append(str(directory.resolve())) except: - logger.warning(f"Could not load migrations of plugin {ep.name} for database migration.") + logger.warning(f"Could not load migrations of plugin {plugin.name} for database migration.") logger.debug("Plugin loading failed", exc_info=True) # write back seperator (we changed it if neither seperator nor locations were specified) From e22e38b3043de5fcbfbf86aea4742e93e4b41324 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 22 Aug 2022 17:18:03 +0200 Subject: [PATCH 405/446] Implement custom UA parsing, allowing to update Flask and Werkzeug Signed-off-by: Ferdinand Thiessen --- flaschengeist/controller/pluginController.py | 1 + flaschengeist/controller/sessionController.py | 43 +++++++++++++++++-- flaschengeist/models/session.py | 4 +- flaschengeist/utils/decorators.py | 2 +- setup.cfg | 8 ++-- 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index ba0e91b..86ea301 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -25,6 +25,7 @@ def get_enabled_plugins(): class PluginStub: def __init__(self, name) -> None: self.name = name + self.version = "?" logger.error("Could not connect to database or database not initialized! No plugins enabled!") logger.debug("Can not query enabled plugins", exc_info=True) diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py index 5d5ceae..da5c81c 100644 --- a/flaschengeist/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -11,7 +11,36 @@ from ..database import db lifetime = 1800 -def validate_token(token, user_agent, permission): +def __get_user_agent_platform(ua: str): + if "Win" in ua: + return "Windows" + if "Mac" in ua: + return "Macintosh" + if "Linux" in ua: + return "Linux" + if "Android" in ua: + return "Android" + if "like Mac" in ua: + return "iOS" + return "unknown" + + +def __get_user_agent_browser(ua: str): + ua_str = ua.lower() + if "firefox" in ua_str or "fxios" in ua_str: + return "firefox" + if "safari" in ua_str: + return "safari" + if "opr/" in ua_str: + return "opera" + if "edg" in ua_str: + return "edge" + if "chrom" in ua_str or "crios" in ua_str: + return "chrome" + return "unknown" + + +def validate_token(token, request_headers, permission): """Verify session Verify a Session and Roles so if the User has permission or not. @@ -19,7 +48,7 @@ def validate_token(token, user_agent, permission): Args: token: Token to verify. - user_agent: User agent of browser to check + request_headers: Headers to validate user agent of browser permission: Permission needed to access restricted routes Returns: A Session for this given Token @@ -31,8 +60,16 @@ def validate_token(token, user_agent, permission): session = Session.query.filter_by(token=token).one_or_none() if session: logger.debug("token found, check if expired or invalid user agent differs") + + platform = request_headers.get("Sec-CH-UA-Platform", None) or __get_user_agent_platform( + request_headers.get("User-Agent", "") + ) + browser = request_headers.get("Sec-CH-UA", None) or __get_user_agent_browser( + request_headers.get("User-Agent", "") + ) + if session.expires >= datetime.now(timezone.utc) and ( - session.browser == user_agent.browser and session.platform == user_agent.platform + session.browser == browser and session.platform == platform ): if not permission or session.user_.has_permission(permission): session.refresh() diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index dea7c62..bf584fe 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -20,8 +20,8 @@ class Session(db.Model, ModelSerializeMixin): expires: datetime = db.Column(UtcDateTime) token: str = db.Column(db.String(32), unique=True) lifetime: int = db.Column(db.Integer) - browser: str = db.Column(db.String(30)) - platform: str = db.Column(db.String(30)) + browser: str = db.Column(db.String(127)) + platform: str = db.Column(db.String(64)) userid: str = "" _id = db.Column("id", Serial, primary_key=True) diff --git a/flaschengeist/utils/decorators.py b/flaschengeist/utils/decorators.py index b26f66a..34814dc 100644 --- a/flaschengeist/utils/decorators.py +++ b/flaschengeist/utils/decorators.py @@ -14,7 +14,7 @@ def extract_session(permission=None): logger.debug("Missing Authorization header or ill-formed") raise Unauthorized - session = sessionController.validate_token(token, request.user_agent, permission) + session = sessionController.validate_token(token, request.headers, permission) return session diff --git a/setup.cfg b/setup.cfg index 46433c8..4c1c786 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,14 +22,14 @@ include_package_data = True python_requires = >=3.10 packages = find: install_requires = - Flask==2.0.3 - Pillow>=9.0 + Flask>=2.2.2 + Pillow>=9.2 flask_cors flask_migrate>=3.1.0 flask_sqlalchemy>=2.5.1 - sqlalchemy>=1.4.39 + sqlalchemy>=1.4.40 toml - werkzeug==2.0.3 + werkzeug>=2.2.2 [options.extras_require] argon = argon2-cffi From 4248825af0c9a079b8c8326b3f96aba923b41e97 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Aug 2022 12:06:59 +0200 Subject: [PATCH 406/446] Revert future imports for annotations, PEP563 is still defered Signed-off-by: Ferdinand Thiessen --- flaschengeist/models/image.py | 2 ++ flaschengeist/models/notification.py | 6 ++++-- flaschengeist/models/plugin.py | 2 ++ flaschengeist/models/session.py | 7 ++++--- flaschengeist/models/user.py | 6 ++++-- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/flaschengeist/models/image.py b/flaschengeist/models/image.py index 406fefe..101a19a 100644 --- a/flaschengeist/models/image.py +++ b/flaschengeist/models/image.py @@ -1,3 +1,5 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) + from sqlalchemy import event from pathlib import Path diff --git a/flaschengeist/models/notification.py b/flaschengeist/models/notification.py index 549a5b7..55e9640 100644 --- a/flaschengeist/models/notification.py +++ b/flaschengeist/models/notification.py @@ -1,3 +1,5 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) + from datetime import datetime from typing import Any @@ -14,8 +16,8 @@ class Notification(db.Model, ModelSerializeMixin): user_id_: int = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False) plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"), nullable=False) - user_: "User" = db.relationship("User") - plugin_: "Plugin" = db.relationship( + user_: User = db.relationship("User") + plugin_: Plugin = db.relationship( "Plugin", backref=db.backref("notifications_", cascade="all, delete, delete-orphan") ) diff --git a/flaschengeist/models/plugin.py b/flaschengeist/models/plugin.py index d2af46c..eb5cf35 100644 --- a/flaschengeist/models/plugin.py +++ b/flaschengeist/models/plugin.py @@ -1,3 +1,5 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) + from typing import Any from ..database import db diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index bf584fe..1dac1a3 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -1,11 +1,12 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) + from datetime import datetime, timedelta, timezone from secrets import compare_digest +from .. import logger from ..database import db from ..database.types import ModelSerializeMixin, UtcDateTime, Serial -from flaschengeist import logger - class Session(db.Model, ModelSerializeMixin): """Model for a Session @@ -26,7 +27,7 @@ class Session(db.Model, ModelSerializeMixin): _id = db.Column("id", Serial, primary_key=True) _user_id = db.Column("user_id", Serial, db.ForeignKey("user.id")) - user_: "User" = db.relationship("User", back_populates="sessions_") + user_: User = db.relationship("User", back_populates="sessions_") @property def userid(self): diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 578758c..077b78c 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,3 +1,5 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) + from typing import Optional from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection @@ -62,10 +64,10 @@ class User(db.Model, ModelSerializeMixin): # Protected stuff for backend use only id_ = db.Column("id", Serial, primary_key=True) roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") - sessions_: list["Session"] = db.relationship( + sessions_: list[Session] = db.relationship( "Session", back_populates="user_", cascade="all, delete, delete-orphan" ) - avatar_: Optional["Image"] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) + avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan") # Private stuff for internal use From 973b4527dfdd3e6a96f162866cf68af75725d336 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Aug 2022 14:55:49 +0200 Subject: [PATCH 407/446] [core] UA parsing: Add backwards compatibility for platform names Signed-off-by: Ferdinand Thiessen --- flaschengeist/controller/sessionController.py | 20 ++++++++++--------- flaschengeist/plugins/auth/__init__.py | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py index da5c81c..56ca32b 100644 --- a/flaschengeist/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -13,15 +13,15 @@ lifetime = 1800 def __get_user_agent_platform(ua: str): if "Win" in ua: - return "Windows" + return "windows" if "Mac" in ua: - return "Macintosh" + return "macintosh" if "Linux" in ua: - return "Linux" + return "linux" if "Android" in ua: - return "Android" + return "android" if "like Mac" in ua: - return "iOS" + return "ios" return "unknown" @@ -84,12 +84,12 @@ def validate_token(token, request_headers, permission): raise Unauthorized -def create(user, user_agent=None) -> Session: +def create(user, request_headers=None) -> Session: """Create a Session Args: user: For which User is to create a Session - user_agent: User agent to identify session + request_headers: Headers to validate user agent of browser Returns: Session: A created Token for User @@ -100,8 +100,10 @@ def create(user, user_agent=None) -> Session: token=token_str, user_=user, lifetime=lifetime, - browser=user_agent.browser, - platform=user_agent.platform, + platform=request_headers.get("Sec-CH-UA-Platform", None) + or __get_user_agent_platform(request_headers.get("User-Agent", "")), + browser=request_headers.get("Sec-CH-UA", None) + or __get_user_agent_browser(request_headers.get("User-Agent", "")), ) session.refresh() db.session.add(session) diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 439b2a6..be20ac2 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -40,7 +40,7 @@ def login(): user = userController.login_user(userid, password) if not user: raise Unauthorized - session = sessionController.create(user, user_agent=request.user_agent) + session = sessionController.create(user, request_headers=request.headers) logger.debug(f"token is {session.token}") logger.info(f"User {userid} logged in.") From e2254b71b097a7262523683882cae20a4a7c449f Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Aug 2022 15:14:11 +0200 Subject: [PATCH 408/446] [core][plugin] Unify plugin model and real plugins Plugins are now extensions of the database model, allowing plugins to access all their properties. Signed-off-by: Ferdinand Thiessen --- ...ial.py => 20482a003db8_initial_core_db.py} | 107 +++++++----- flaschengeist/app.py | 21 +-- flaschengeist/cli/install_cmd.py | 10 +- flaschengeist/cli/plugin_cmd.py | 160 ++++++++++-------- flaschengeist/controller/pluginController.py | 142 +++++++--------- flaschengeist/controller/userController.py | 96 +++++------ flaschengeist/database/types.py | 9 +- flaschengeist/models/plugin.py | 70 ++++++-- flaschengeist/models/user.py | 9 +- flaschengeist/plugins/__init__.py | 145 ++++++---------- flaschengeist/plugins/auth/__init__.py | 4 +- flaschengeist/plugins/auth_plain/__init__.py | 49 +++--- flaschengeist/plugins/message_mail.py | 4 +- flaschengeist/plugins/roles/__init__.py | 8 +- flaschengeist/plugins/scheduler.py | 9 +- flaschengeist/plugins/users/__init__.py | 8 +- 16 files changed, 434 insertions(+), 417 deletions(-) rename flaschengeist/alembic/migrations/{255b93b6beed_flaschengeist_initial.py => 20482a003db8_initial_core_db.py} (57%) diff --git a/flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py b/flaschengeist/alembic/migrations/20482a003db8_initial_core_db.py similarity index 57% rename from flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py rename to flaschengeist/alembic/migrations/20482a003db8_initial_core_db.py index b18a71e..a2a2445 100644 --- a/flaschengeist/alembic/migrations/255b93b6beed_flaschengeist_initial.py +++ b/flaschengeist/alembic/migrations/20482a003db8_initial_core_db.py @@ -1,8 +1,8 @@ -"""Flaschengeist: Initial +"""Initial core db -Revision ID: 255b93b6beed +Revision ID: 20482a003db8 Revises: -Create Date: 2022-02-23 14:33:02.851388 +Create Date: 2022-08-25 15:13:34.900996 """ from alembic import op @@ -11,24 +11,17 @@ import flaschengeist # revision identifiers, used by Alembic. -revision = "255b93b6beed" +revision = "20482a003db8" down_revision = None branch_labels = ("flaschengeist",) depends_on = None def upgrade(): - op.create_table( - "plugin_setting", - sa.Column("id", flaschengeist.models.Serial(), nullable=False), - sa.Column("plugin", sa.String(length=127), nullable=True), - sa.Column("name", sa.String(length=127), nullable=False), - sa.Column("value", sa.PickleType(protocol=4), nullable=True), - sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin_setting")), - ) + # ### commands auto generated by Alembic - please adjust! ### op.create_table( "image", - sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("id", flaschengeist.database.types.Serial(), nullable=False), sa.Column("filename", sa.String(length=255), nullable=False), sa.Column("mimetype", sa.String(length=127), nullable=False), sa.Column("thumbnail", sa.String(length=255), nullable=True), @@ -36,27 +29,37 @@ def upgrade(): sa.PrimaryKeyConstraint("id", name=op.f("pk_image")), ) op.create_table( - "permission", - sa.Column("name", sa.String(length=30), nullable=True), - sa.Column("id", flaschengeist.models.Serial(), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_permission")), - sa.UniqueConstraint("name", name=op.f("uq_permission_name")), + "plugin", + sa.Column("id", flaschengeist.database.types.Serial(), nullable=False), + sa.Column("name", sa.String(length=127), nullable=False), + sa.Column("version", sa.String(length=30), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin")), ) op.create_table( "role", - sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("id", flaschengeist.database.types.Serial(), nullable=False), sa.Column("name", sa.String(length=30), nullable=True), sa.PrimaryKeyConstraint("id", name=op.f("pk_role")), sa.UniqueConstraint("name", name=op.f("uq_role_name")), ) op.create_table( - "role_x_permission", - sa.Column("role_id", flaschengeist.models.Serial(), nullable=True), - sa.Column("permission_id", flaschengeist.models.Serial(), nullable=True), - sa.ForeignKeyConstraint( - ["permission_id"], ["permission.id"], name=op.f("fk_role_x_permission_permission_id_permission") - ), - sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_role_x_permission_role_id_role")), + "permission", + sa.Column("name", sa.String(length=30), nullable=True), + sa.Column("id", flaschengeist.database.types.Serial(), nullable=False), + sa.Column("plugin", flaschengeist.database.types.Serial(), nullable=True), + sa.ForeignKeyConstraint(["plugin"], ["plugin.id"], name=op.f("fk_permission_plugin_plugin")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_permission")), + sa.UniqueConstraint("name", name=op.f("uq_permission_name")), + ) + op.create_table( + "plugin_setting", + sa.Column("id", flaschengeist.database.types.Serial(), nullable=False), + sa.Column("plugin", flaschengeist.database.types.Serial(), nullable=True), + sa.Column("name", sa.String(length=127), nullable=False), + sa.Column("value", sa.PickleType(), nullable=True), + sa.ForeignKeyConstraint(["plugin"], ["plugin.id"], name=op.f("fk_plugin_setting_plugin_plugin")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin_setting")), ) op.create_table( "user", @@ -67,48 +70,58 @@ def upgrade(): sa.Column("deleted", sa.Boolean(), nullable=True), sa.Column("birthday", sa.Date(), nullable=True), sa.Column("mail", sa.String(length=60), nullable=True), - sa.Column("id", flaschengeist.models.Serial(), nullable=False), - sa.Column("avatar", flaschengeist.models.Serial(), nullable=True), + sa.Column("id", flaschengeist.database.types.Serial(), nullable=False), + sa.Column("avatar", flaschengeist.database.types.Serial(), nullable=True), sa.ForeignKeyConstraint(["avatar"], ["image.id"], name=op.f("fk_user_avatar_image")), sa.PrimaryKeyConstraint("id", name=op.f("pk_user")), sa.UniqueConstraint("userid", name=op.f("uq_user_userid")), ) op.create_table( "notification", - sa.Column("id", flaschengeist.models.Serial(), nullable=False), - sa.Column("plugin", sa.String(length=127), nullable=False), + sa.Column("id", flaschengeist.database.types.Serial(), nullable=False), sa.Column("text", sa.Text(), nullable=True), sa.Column("data", sa.PickleType(), nullable=True), - sa.Column("time", flaschengeist.models.UtcDateTime(), nullable=False), - sa.Column("user_id", flaschengeist.models.Serial(), nullable=False), - sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_notification_user_id_user")), + sa.Column("time", flaschengeist.database.types.UtcDateTime(), nullable=False), + sa.Column("user", flaschengeist.database.types.Serial(), nullable=False), + sa.Column("plugin", flaschengeist.database.types.Serial(), nullable=False), + sa.ForeignKeyConstraint(["plugin"], ["plugin.id"], name=op.f("fk_notification_plugin_plugin")), + sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_notification_user_user")), sa.PrimaryKeyConstraint("id", name=op.f("pk_notification")), ) op.create_table( "password_reset", - sa.Column("user", flaschengeist.models.Serial(), nullable=False), + sa.Column("user", flaschengeist.database.types.Serial(), nullable=False), sa.Column("token", sa.String(length=32), nullable=True), - sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True), + sa.Column("expires", flaschengeist.database.types.UtcDateTime(), nullable=True), sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_password_reset_user_user")), sa.PrimaryKeyConstraint("user", name=op.f("pk_password_reset")), ) + op.create_table( + "role_x_permission", + sa.Column("role_id", flaschengeist.database.types.Serial(), nullable=True), + sa.Column("permission_id", flaschengeist.database.types.Serial(), nullable=True), + sa.ForeignKeyConstraint( + ["permission_id"], ["permission.id"], name=op.f("fk_role_x_permission_permission_id_permission") + ), + sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_role_x_permission_role_id_role")), + ) op.create_table( "session", - sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True), + sa.Column("expires", flaschengeist.database.types.UtcDateTime(), nullable=True), sa.Column("token", sa.String(length=32), nullable=True), sa.Column("lifetime", sa.Integer(), nullable=True), - sa.Column("browser", sa.String(length=30), nullable=True), - sa.Column("platform", sa.String(length=30), nullable=True), - sa.Column("id", flaschengeist.models.Serial(), nullable=False), - sa.Column("user_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("browser", sa.String(length=127), nullable=True), + sa.Column("platform", sa.String(length=64), nullable=True), + sa.Column("id", flaschengeist.database.types.Serial(), nullable=False), + sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=True), sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_session_user_id_user")), sa.PrimaryKeyConstraint("id", name=op.f("pk_session")), sa.UniqueConstraint("token", name=op.f("uq_session_token")), ) op.create_table( "user_attribute", - sa.Column("id", flaschengeist.models.Serial(), nullable=False), - sa.Column("user", flaschengeist.models.Serial(), nullable=False), + sa.Column("id", flaschengeist.database.types.Serial(), nullable=False), + sa.Column("user", flaschengeist.database.types.Serial(), nullable=False), sa.Column("name", sa.String(length=30), nullable=True), sa.Column("value", sa.PickleType(), nullable=True), sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_user_attribute_user_user")), @@ -116,8 +129,8 @@ def upgrade(): ) op.create_table( "user_x_role", - sa.Column("user_id", flaschengeist.models.Serial(), nullable=True), - sa.Column("role_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=True), + sa.Column("role_id", flaschengeist.database.types.Serial(), nullable=True), sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_user_x_role_role_id_role")), sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_user_x_role_user_id_user")), ) @@ -129,11 +142,13 @@ def downgrade(): op.drop_table("user_x_role") op.drop_table("user_attribute") op.drop_table("session") + op.drop_table("role_x_permission") op.drop_table("password_reset") op.drop_table("notification") op.drop_table("user") - op.drop_table("role_x_permission") - op.drop_table("role") + op.drop_table("plugin_setting") op.drop_table("permission") + op.drop_table("role") + op.drop_table("plugin") op.drop_table("image") # ### end Alembic commands ### diff --git a/flaschengeist/app.py b/flaschengeist/app.py index ee8a6bd..d490965 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -4,7 +4,7 @@ from flask import Flask from flask_cors import CORS from datetime import datetime, date from flask.json import JSONEncoder, jsonify -from importlib.metadata import entry_points +from sqlalchemy.exc import OperationalError from werkzeug.exceptions import HTTPException from flaschengeist import logger @@ -37,21 +37,13 @@ class CustomJSONEncoder(JSONEncoder): @Hook("plugins.loaded") def load_plugins(app: Flask): app.config["FG_PLUGINS"] = {} - all_plugins = entry_points(group="flaschengeist.plugins") for plugin in pluginController.get_enabled_plugins(): logger.debug(f"Searching for enabled plugin {plugin.name}") - entry_point = all_plugins.select(name=plugin.name) - if not entry_point: - logger.error( - f"Plugin {plugin.name} was enabled, but could not be found.", - exc_info=True, - ) - continue try: - loaded = entry_point[0].load()(entry_point[0]) - if hasattr(plugin, "blueprint") and plugin.blueprint is not None: - app.register_blueprint(plugin.blueprint) + cls = plugin.entry_point.load() + if hasattr(cls, "blueprint") and cls.blueprint is not None: + app.register_blueprint(cls.blueprint) except: logger.error( f"Plugin {plugin.name} was enabled, but could not be loaded due to an error.", @@ -59,7 +51,8 @@ def load_plugins(app: Flask): ) continue logger.info(f"Loaded plugin: {plugin.name}") - app.config["FG_PLUGINS"][plugin.name] = loaded + app.config["FG_PLUGINS"][plugin.name] = cls.query.get(plugin.id) if plugin.id is not None else plugin + app.config["FG_PLUGINS"][plugin.name].load() def create_app(test_config=None, cli=False): @@ -79,7 +72,7 @@ def create_app(test_config=None, cli=False): def __get_state(): from . import __version__ as version - return jsonify({"plugins": app.config["FG_PLUGINS"], "version": version}) + return jsonify({"plugins": pluginController.get_loaded_plugins(), "version": version}) @app.errorhandler(Exception) def handle_exception(e): diff --git a/flaschengeist/cli/install_cmd.py b/flaschengeist/cli/install_cmd.py index e566163..e1e9244 100644 --- a/flaschengeist/cli/install_cmd.py +++ b/flaschengeist/cli/install_cmd.py @@ -4,7 +4,7 @@ from flask.cli import with_appcontext from flask_migrate import upgrade from flaschengeist.alembic import alembic_migrations_path -from flaschengeist.cli.plugin_cmd import install_plugin_command +from flaschengeist.controller import pluginController from flaschengeist.utils.hook import Hook @@ -12,9 +12,13 @@ from flaschengeist.utils.hook import Hook @with_appcontext @pass_context @Hook("plugins.installed") -def install(ctx): +def install(ctx: click.Context): + plugins = pluginController.get_enabled_plugins() + # Install database upgrade(alembic_migrations_path, revision="heads") # Install plugins - install_plugin_command(ctx, [], True) + for plugin in plugins: + plugin = pluginController.install_plugin(plugin.name) + pluginController.enable_plugin(plugin.id) diff --git a/flaschengeist/cli/plugin_cmd.py b/flaschengeist/cli/plugin_cmd.py index d8b90c4..5356eac 100644 --- a/flaschengeist/cli/plugin_cmd.py +++ b/flaschengeist/cli/plugin_cmd.py @@ -1,12 +1,13 @@ +import traceback import click from click.decorators import pass_context from flask import current_app from flask.cli import with_appcontext from importlib.metadata import EntryPoint, entry_points -from flaschengeist.database import db -from flaschengeist.config import config -from flaschengeist.models import Permission +from flaschengeist import logger +from flaschengeist.controller import pluginController +from werkzeug.exceptions import NotFound @click.group() @@ -14,33 +15,34 @@ def plugin(): pass -def install_plugin_command(ctx, plugin, all): - """Install one or more plugins""" - if not all and len(plugin) == 0: - ctx.fail("At least one plugin must be specified, or use `--all` flag.") - - if all: - plugins = current_app.config["FG_PLUGINS"] - else: +@plugin.command() +@click.argument("plugin", nargs=-1, required=True, type=str) +@with_appcontext +@pass_context +def enable(ctx, plugin): + """Enable one or more plugins""" + for name in plugin: + click.echo(f"Enabling {name}{'.'*(20-len(name))}", nl=False) try: - plugins = {plugin_name: current_app.config["FG_PLUGINS"][plugin_name] for plugin_name in plugin} - except KeyError as e: - ctx.fail(f"Invalid plugin name, could not find >{e.args[0]}<") + pluginController.enable_plugin(name) + click.secho(" ok", fg="green") + except NotFound: + click.secho(" not installed / not found", fg="red") - for name, plugin in plugins.items(): - click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False) - # Install permissions - if plugin.permissions: - cur_perm = set(x.name for x in Permission.query.filter(Permission.name.in_(plugin.permissions)).all()) - all_perm = set(plugin.permissions) - add = all_perm - cur_perm - if add: - db.session.bulk_save_objects([Permission(name=x) for x in all_perm]) - db.session.commit() - # Custom installation steps - plugin.install() - click.secho(" ok", fg="green") +@plugin.command() +@click.argument("plugin", nargs=-1, required=True, type=str) +@with_appcontext +@pass_context +def disable(ctx, plugin): + """Disable one or more plugins""" + for name in plugin: + click.echo(f"Disabling {name}{'.'*(20-len(name))}", nl=False) + try: + pluginController.disable_plugin(name) + click.secho(" ok", fg="green") + except NotFound: + click.secho(" not installed / not found", fg="red") @plugin.command() @@ -48,42 +50,62 @@ def install_plugin_command(ctx, plugin, all): @click.option("--all", help="Install all enabled plugins", is_flag=True) @with_appcontext @pass_context -def install(ctx, plugin, all): +def install(ctx: click.Context, plugin, all): """Install one or more plugins""" - return install_plugin_command(ctx, plugin, all) + all_plugins = entry_points(group="flaschengeist.plugins") + + if all: + plugins = [ep.name for ep in all_plugins] + elif len(plugin) > 0: + plugins = plugin + for name in plugin: + if not all_plugins.select(name=name): + ctx.fail(f"Invalid plugin name, could not find >{name}<") + else: + ctx.fail("At least one plugin must be specified, or use `--all` flag.") + + for name in plugins: + click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False) + try: + pluginController.install_plugin(name) + except Exception as e: + click.secho(" failed", fg="red") + if logger.getEffectiveLevel() > 10: + ctx.fail(f"[{e.__class__.__name__}] {e}") + else: + ctx.fail(traceback.format_exc()) + else: + click.secho(" ok", fg="green") @plugin.command() -@click.argument("plugin", nargs=-1, type=str) +@click.argument("plugin", nargs=-1, required=True, type=str) @with_appcontext @pass_context def uninstall(ctx: click.Context, plugin): """Uninstall one or more plugins""" - if len(plugin) == 0: - ctx.fail("At least one plugin must be specified") - + plugins = {plg.name: plg for plg in pluginController.get_installed_plugins() if plg.name in plugin} try: - plugins = {plugin_name: current_app.config["FG_PLUGINS"][plugin_name] for plugin_name in plugin} - except KeyError as e: - ctx.fail(f"Invalid plugin ID, could not find >{e.args[0]}<") - - if ( - click.prompt( - "You are going to uninstall:\n\n" - f"\t{', '.join([plugin_name for plugin_name in plugins.keys()])}\n\n" - "Are you sure?", - default="n", - show_choices=True, - type=click.Choice(["y", "N"], False), - ).lower() - != "y" - ): - ctx.exit() - for name, plugin in plugins.items(): - click.echo(f"Uninstalling {name}{'.'*(20-len(name))}", nl=False) - plugin.uninstall() - click.secho(" ok", fg="green") + for name in plugin: + pluginController.disable_plugin(plugins[name]) + if ( + click.prompt( + "You are going to uninstall:\n\n" + f"\t{', '.join([plugin_name for plugin_name in plugins.keys()])}\n\n" + "Are you sure?", + default="n", + show_choices=True, + type=click.Choice(["y", "N"], False), + ).lower() + != "y" + ): + ctx.exit() + click.echo(f"Uninstalling {name}{'.'*(20-len(name))}", nl=False) + pluginController.uninstall_plugin(plugins[name]) + click.secho(" ok", fg="green") + except KeyError: + ctx.fail(f"Invalid plugin ID, could not find >{name}<") @plugin.command() @@ -97,21 +119,27 @@ def ls(enabled, no_header): return p.version plugins = entry_points(group="flaschengeist.plugins") - enabled_plugins = [key for key, value in config.items() if "enabled" in value and value["enabled"]] + [ - config["FLASCHENGEIST"]["auth"] - ] + installed_plugins = {plg.name: plg for plg in pluginController.get_installed_plugins()} loaded_plugins = current_app.config["FG_PLUGINS"].keys() if not no_header: - print(f"{' '*13}{'name': <20}|{'version': >10}") - print("-" * 46) + print(f"{' '*13}{'name': <20}| version | {' ' * 8} state") + print("-" * 63) for plugin in plugins: - if enabled and plugin.name not in enabled_plugins: + is_installed = plugin.name in installed_plugins.keys() + is_enabled = is_installed and installed_plugins[plugin.name].enabled + if enabled and is_enabled: continue - print( - f"{plugin.name: <33}|{plugin_version(plugin): >12}" - f"{click.style(' (enabled)', fg='green') if plugin.name in enabled_plugins else ' (disabled)'}" - ) - - for plugin in [value for value in enabled_plugins if value not in loaded_plugins]: - print(f"{plugin: <33}|{' '*12}" f"{click.style(' (not found)', fg='red')}") + print(f"{plugin.name: <33}|{plugin_version(plugin): >12} | ", end="") + if is_enabled: + if plugin.name in loaded_plugins: + print(click.style(" enabled", fg="green")) + else: + print(click.style("(failed to load)", fg="red")) + elif is_installed: + print(click.style(" disabled", fg="yellow")) + else: + print("not installed") + for name, plugin in installed_plugins.items(): + if plugin.enabled and name not in loaded_plugins: + print(f"{name: <33}|{'': >12} |" f"{click.style(' failed to load', fg='red')}") diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 86ea301..7cccc4e 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -3,84 +3,58 @@ Used by plugins for setting and notification functionality. """ -import sqlalchemy - from typing import Union from flask import current_app -from werkzeug.exceptions import NotFound +from werkzeug.exceptions import NotFound, BadRequest from sqlalchemy.exc import OperationalError from importlib.metadata import entry_points +from flaschengeist import version as flaschengeist_version + from .. import logger from ..database import db from ..utils.hook import Hook -from ..models import Plugin, PluginSetting, Notification +from ..plugins import Plugin, AuthPlugin +from ..models import Notification -def get_enabled_plugins(): +__required_plugins = ["users", "roles", "scheduler", "auth"] + + +def get_authentication_provider(): + return [plugin for plugin in get_loaded_plugins().values() if isinstance(plugin, AuthPlugin)] + + +def get_loaded_plugins(plugin_name: str = None): + """Get loaded plugin(s)""" + plugins = current_app.config["FG_PLUGINS"] + if plugin_name is not None: + plugins = [plugins[plugin_name]] + return {name: db.session.merge(plugins[name], load=False) for name in plugins} + + +def get_installed_plugins() -> list[Plugin]: + """Get all installed plugins""" + return Plugin.query.all() + + +def get_enabled_plugins() -> list[Plugin]: + """Get all installed and enabled plugins""" try: enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all() except OperationalError as e: - - class PluginStub: - def __init__(self, name) -> None: - self.name = name - self.version = "?" - logger.error("Could not connect to database or database not initialized! No plugins enabled!") logger.debug("Can not query enabled plugins", exc_info=True) + # Fake load required plugins so the database can at least be installed enabled_plugins = [ - PluginStub("auth"), - PluginStub("roles"), - PluginStub("users"), - PluginStub("scheduler"), + entry_points(group="flaschengeist.plugins", name=name)[0].load()( + name=name, enabled=True, installed_version=flaschengeist_version + ) + for name in __required_plugins ] return enabled_plugins -def get_setting(plugin_id: str, name: str, **kwargs): - """Get plugin setting from database - - Args: - plugin_id: ID of the plugin - name: string identifying the setting - default: Default value - Returns: - Value stored in database (native python) - Raises: - `KeyError` if no such setting exists in the database - """ - try: - setting = PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one() - return setting.value - except sqlalchemy.orm.exc.NoResultFound: - if "default" in kwargs: - return kwargs["default"] - else: - raise KeyError - - -def set_setting(plugin_id: str, name: str, value): - """Save setting in database - - Args: - plugin_id: ID of the plugin - name: String identifying the setting - value: Value to be stored - """ - setting = ( - PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one_or_none() - ) - if setting is not None: - if value is None: - db.session.delete(setting) - else: - setting.value = value - else: - db.session.add(PluginSetting(plugin=plugin_id, name=name, value=value)) - db.session.commit() - - def notify(plugin_id: str, user, text: str, data=None): """Create a new notification for an user @@ -108,55 +82,67 @@ def install_plugin(plugin_name: str): if not entry_point: raise NotFound - plugin = entry_point[0].load()(entry_point[0]) - entity = Plugin(name=plugin.name, version=plugin.version) - db.session.add(entity) + cls = entry_point[0].load() + plugin = cls.query.filter(Plugin.name == plugin_name).one_or_none() + if plugin is None: + plugin = cls(name=plugin_name, installed_version=entry_point[0].dist.version) + db.session.add(plugin) + db.session.flush() + # Custom installation steps + plugin.install() db.session.commit() - return entity + + return plugin @Hook("plugin.uninstalled") -def uninstall_plugin(plugin_id: Union[str, int]): +def uninstall_plugin(plugin_id: Union[str, int, Plugin]): plugin = disable_plugin(plugin_id) logger.debug(f"Uninstall plugin {plugin.name}") - - entity = current_app.config["FG_PLUGINS"][plugin.name] - entity.uninstall() - del current_app.config["FG_PLUGINS"][plugin.name] + plugin.uninstall() db.session.delete(plugin) db.session.commit() @Hook("plugins.enabled") -def enable_plugin(plugin_id: Union[str, int]): +def enable_plugin(plugin_id: Union[str, int]) -> Plugin: logger.debug(f"Enabling plugin {plugin_id}") - plugin: Plugin = Plugin.query + plugin = Plugin.query if isinstance(plugin_id, str): plugin = plugin.filter(Plugin.name == plugin_id).one_or_none() - if plugin is None: - logger.debug("Plugin not installed, trying to install") - plugin = install_plugin(plugin_id) - else: + elif isinstance(plugin_id, int): plugin = plugin.get(plugin_id) - if plugin is None: - raise NotFound + else: + raise TypeError + if plugin is None: + raise NotFound plugin.enabled = True db.session.commit() - + plugin = plugin.entry_point.load().query.get(plugin.id) + current_app.config["FG_PLUGINS"][plugin.name] = plugin return plugin @Hook("plugins.disabled") -def disable_plugin(plugin_id: Union[str, int]): +def disable_plugin(plugin_id: Union[str, int, Plugin]): logger.debug(f"Disabling plugin {plugin_id}") plugin: Plugin = Plugin.query if isinstance(plugin_id, str): plugin = plugin.filter(Plugin.name == plugin_id).one_or_none() - else: + elif isinstance(plugin_id, int): plugin = plugin.get(plugin_id) + elif isinstance(plugin_id, Plugin): + plugin = plugin_id + else: + raise TypeError if plugin is None: raise NotFound + if plugin.name in __required_plugins: + raise BadRequest plugin.enabled = False db.session.commit() + if plugin.name in current_app.config["FG_PLUGINS"].keys(): + del current_app.config["FG_PLUGINS"][plugin.name] + return plugin diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 610db39..520ec31 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -3,7 +3,6 @@ import secrets from io import BytesIO from sqlalchemy import exc -from flask import current_app from datetime import datetime, timedelta, timezone from flask.helpers import send_file from werkzeug.exceptions import NotFound, BadRequest, Forbidden @@ -16,7 +15,8 @@ from ..models.user import _PasswordReset from ..utils.hook import Hook from ..utils.datetime import from_iso_format from ..utils.foreign_keys import merge_references -from ..controller import imageController, messageController, sessionController +from ..controller import imageController, messageController, pluginController, sessionController +from ..plugins import AuthPlugin def __active_users(): @@ -41,17 +41,33 @@ def _generate_password_reset(user): return reset +def get_provider(userid: str): + return [p for p in pluginController.get_authentication_provider() if p.user_exists(userid)][0] + + +@Hook +def update_user(user: User, backend: AuthPlugin): + """Update user data from backend + + This is seperate function to provide a hook""" + backend.update_user(user) + if not user.display_name: + user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) + db.session.commit() + + def login_user(username, password): logger.info("login user {{ {} }}".format(username)) - - user = find_user(username) - if not user: - logger.debug("User not found in Database.") - user = User(userid=username) - db.session.add(user) - if current_app.config["FG_AUTH_BACKEND"].login(user, password): - update_user(user) - return user + for provider in pluginController.get_authentication_provider(): + uid = provider.login(username, password) + if isinstance(uid, str): + user = get_user(uid) + if not user: + logger.debug("User not found in Database.") + user = User(userid=uid) + db.session.add(user) + update_user(user, provider) + return user return None @@ -84,14 +100,6 @@ def reset_password(token: str, password: str): db.session.commit() -@Hook -def update_user(user): - current_app.config["FG_AUTH_BACKEND"].update_user(user) - if not user.display_name: - user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) - db.session.commit() - - def set_roles(user: User, roles: list[str], create=False): """Set roles of user @@ -115,7 +123,7 @@ def set_roles(user: User, roles: list[str], create=False): user.roles_ = fetched -def modify_user(user, password, new_password=None): +def modify_user(user: User, password: str, new_password: str = None): """Modify given user on the backend Args: @@ -127,7 +135,8 @@ def modify_user(user, password, new_password=None): NotImplemented: If backend is not capable of this operation BadRequest: Password is wrong or other logic issues """ - current_app.config["FG_AUTH_BACKEND"].modify_user(user, password, new_password) + provider = get_provider(user.userid) + provider.modify_user(user, password, new_password) if new_password: logger.debug(f"Password changed for user {user.userid}") @@ -165,37 +174,13 @@ def get_user(uid, deleted=False) -> User: return user -def find_user(uid_mail): - """Finding an user by userid or mail in database or auth-backend - Args: - uid_mail: userid and or mail to search for - Returns: - User if found or None - """ - mail = uid_mail.split("@") - mail = len(mail) == 2 and len(mail[0]) > 0 and len(mail[1]) > 0 - - query = User.userid == uid_mail - if mail: - query |= User.mail == uid_mail - user = User.query.filter(query).one_or_none() - if user: - update_user(user) - else: - user = current_app.config["FG_AUTH_BACKEND"].find_user(uid_mail, uid_mail if mail else None) - if user: - if not user.display_name: - user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) - db.session.add(user) - db.session.commit() - return user - - @Hook def delete_user(user: User): """Delete given user""" # First let the backend delete the user, as this might fail - current_app.config["FG_AUTH_BACKEND"].delete_user(user) + provider = get_provider(user.userid) + provider.delete_user(user) + # Clear all easy relationships user.avatar_ = None user._attributes.clear() @@ -247,10 +232,14 @@ def register(data, passwd=None): set_roles(user, roles) password = passwd if passwd else secrets.token_urlsafe(16) - current_app.config["FG_AUTH_BACKEND"].create_user(user, password) try: + provider = [p for p in pluginController.get_authentication_provider() if p.can_register()][0] + provider.create_user(user, password) db.session.add(user) db.session.commit() + except IndexError: + logger.error("No authentication backend, allowing registering new users, found.") + raise BadRequest except exc.IntegrityError: raise BadRequest("userid already in use") @@ -265,7 +254,7 @@ def register(data, passwd=None): ) messageController.send_message(messageController.Message(user, text, subject)) - find_user(user.userid) + provider.update_user(user) return user @@ -274,19 +263,20 @@ def load_avatar(user: User): if user.avatar_ is not None: return imageController.send_image(image=user.avatar_) else: - avatar = current_app.config["FG_AUTH_BACKEND"].get_avatar(user) + provider = get_provider(user.userid) + avatar = provider.get_avatar(user) if len(avatar.binary) > 0: return send_file(BytesIO(avatar.binary), avatar.mimetype) raise NotFound def save_avatar(user, file): - current_app.config["FG_AUTH_BACKEND"].set_avatar(user, file) + get_provider(user.userid).set_avatar(user, file) db.session.commit() def delete_avatar(user): - current_app.config["FG_AUTH_BACKEND"].delete_avatar(user) + get_provider(user.userid).delete_avatar(user) db.session.commit() diff --git a/flaschengeist/database/types.py b/flaschengeist/database/types.py index 369bb30..645ecdd 100644 --- a/flaschengeist/database/types.py +++ b/flaschengeist/database/types.py @@ -1,4 +1,4 @@ -import sys +from importlib import import_module import datetime from sqlalchemy import BigInteger, util @@ -12,12 +12,11 @@ class ModelSerializeMixin: """ def __is_optional(self, param): - if sys.version_info < (3, 8): - return False - import typing - hint = typing.get_type_hints(self.__class__)[param] + module = import_module("flaschengeist.models").__dict__ + + hint = typing.get_type_hints(self.__class__, globalns=module)[param] if ( typing.get_origin(hint) is typing.Union and len(typing.get_args(hint)) == 2 diff --git a/flaschengeist/models/plugin.py b/flaschengeist/models/plugin.py index eb5cf35..a912afd 100644 --- a/flaschengeist/models/plugin.py +++ b/flaschengeist/models/plugin.py @@ -1,26 +1,72 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) from typing import Any +from sqlalchemy.orm.collections import attribute_mapped_collection from ..database import db from ..database.types import Serial -class Plugin(db.Model): - __tablename__ = "plugin" - id: int = db.Column("id", Serial, primary_key=True) - name: str = db.Column(db.String(127), nullable=False) - version: str = db.Column(db.String(30), nullable=False) - """The latest installed version""" - enabled: bool = db.Column(db.Boolean, default=False) - - settings_ = db.relationship("PluginSetting", cascade="all, delete") - permissions_ = db.relationship("Permission", cascade="all, delete") - - class PluginSetting(db.Model): __tablename__ = "plugin_setting" id = db.Column("id", Serial, primary_key=True) plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) name: str = db.Column(db.String(127), nullable=False) value: Any = db.Column(db.PickleType(protocol=4)) + + +class BasePlugin(db.Model): + __tablename__ = "plugin" + id: int = db.Column("id", Serial, primary_key=True) + name: str = db.Column(db.String(127), nullable=False) + """Name of the plugin, loaded from distribution""" + installed_version: str = db.Column("version", db.String(30), nullable=False) + """The latest installed version""" + enabled: bool = db.Column(db.Boolean, default=False) + """Enabled state of the plugin""" + permissions: list = db.relationship( + "Permission", cascade="all, delete, delete-orphan", back_populates="plugin_", lazy="select" + ) + """Optional list of custom permissions used by the plugin + + A good style is to name the permissions with a prefix related to the plugin name, + to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*. + """ + + __settings: dict[str, "PluginSetting"] = db.relationship( + "PluginSetting", + collection_class=attribute_mapped_collection("name"), + cascade="all, delete, delete-orphan", + lazy="select", + ) + + def get_setting(self, name: str, **kwargs): + """Get plugin setting + + Args: + name: string identifying the setting + default: Default value + Returns: + Value stored in database (native python) + Raises: + `KeyError` if no such setting exists in the database + """ + try: + return self.__settings[name].value + except KeyError as e: + if "default" in kwargs: + return kwargs["default"] + raise e + + def set_setting(self, name: str, value): + """Save setting in database + + Args: + name: String identifying the setting + value: Value to be stored + """ + if value is None and name in self.__settings.keys(): + del self.__settings[name] + else: + setting = self.__settings.setdefault(name, PluginSetting(plugin_id=self.id, name=name, value=None)) + setting.value = value diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 077b78c..e12db4a 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -24,8 +24,9 @@ class Permission(db.Model, ModelSerializeMixin): __tablename__ = "permission" name: str = db.Column(db.String(30), unique=True) - _id = db.Column("id", Serial, primary_key=True) - _plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) + id_ = db.Column("id", Serial, primary_key=True) + plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) + plugin_ = db.relationship("Plugin", lazy="select", back_populates="permissions", enable_typechecks=False) class Role(db.Model, ModelSerializeMixin): @@ -64,9 +65,7 @@ class User(db.Model, ModelSerializeMixin): # Protected stuff for backend use only id_ = db.Column("id", Serial, primary_key=True) roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") - sessions_: list[Session] = db.relationship( - "Session", back_populates="user_", cascade="all, delete, delete-orphan" - ) + sessions_: list[Session] = db.relationship("Session", back_populates="user_", cascade="all, delete, delete-orphan") avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan") diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index f1a68a0..ff53f86 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -4,13 +4,13 @@ """ -from typing import Optional -from importlib.metadata import Distribution, EntryPoint -from werkzeug.exceptions import MethodNotAllowed, NotFound +from typing import Union +from importlib.metadata import entry_points +from werkzeug.exceptions import NotFound from werkzeug.datastructures import FileStorage -from flaschengeist.models import User -from flaschengeist.models.user import _Avatar +from flaschengeist.models.plugin import BasePlugin +from flaschengeist.models.user import _Avatar, Permission from flaschengeist.utils.hook import HookBefore, HookAfter __all__ = [ @@ -20,7 +20,7 @@ __all__ = [ "before_role_updated", "before_update_user", "after_role_updated", - "BasePlugin", + "Plugin", "AuthPlugin", ] @@ -71,7 +71,7 @@ Passed args: """ -class BasePlugin: +class Plugin(BasePlugin): """Base class for all Plugins All plugins must derived from this class. @@ -82,47 +82,30 @@ class BasePlugin: - *models*: Your models, used for API export """ - name: str - """Name of the plugin, loaded from EntryPoint""" - - version: str - """Version of the plugin, loaded from Distribution""" - - dist: Distribution - """Distribution of this plugin""" - blueprint = None """Optional `flask.blueprint` if the plugin uses custom routes""" - permissions: list[str] = [] - """Optional list of custom permissions used by the plugin - - A good style is to name the permissions with a prefix related to the plugin name, - to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*. - """ - models = None """Optional module containing the SQLAlchemy models used by the plugin""" - migrations: Optional[tuple[str, str]] = None - """Optional identifiers of the migration versions - - If custom database tables are used migrations must be provided and the - head and removal versions had to be defined, e.g. - - ``` - migrations = ("head_hash", "removal_hash") - ``` - """ + @property + def version(self) -> str: + """Version of the plugin, loaded from Distribution""" + return self.dist.version - def __init__(self, entry_point: EntryPoint): - """Constructor called by create_app - Args: - entry_point: EntryPoint from which this plugin was loaded - """ - self.version = entry_point.dist.version - self.name = entry_point.name - self.dist = entry_point.dist + @property + def dist(self): + """Distribution of this plugin""" + return self.entry_point.dist + + @property + def entry_point(self): + ep = entry_points(group="flaschengeist.plugins", name=self.name) + return ep[0] + + def load(self): + """__init__ like function that is called when the plugin is initially loaded""" + pass def install(self): """Installation routine @@ -145,40 +128,6 @@ class BasePlugin: """ pass - @property - def installed_version(self): - """Installed version of the plugin""" - from ..controller import pluginController - - self.__installed_version = pluginController.get_installed_version(self.name) - return self.__installed_version - - def get_setting(self, name: str, **kwargs): - """Get plugin setting from database - - Args: - name: string identifying the setting - default: Default value - Returns: - Value stored in database (native python) - Raises: - `KeyError` if no such setting exists in the database - """ - from ..controller import pluginController - - return pluginController.get_setting(self.name, name, **kwargs) - - def set_setting(self, name: str, value): - """Save setting in database - - Args: - name: String identifying the setting - value: Value to be stored - """ - from ..controller import pluginController - - return pluginController.set_setting(self.name, name, value) - def notify(self, user, text: str, data=None): """Create a new notification for an user @@ -203,40 +152,53 @@ class BasePlugin: """ return {"version": self.version, "permissions": self.permissions} + def install_permissions(self, permissions: list[str]): + """Helper for installing a list of strings as permissions -class AuthPlugin(BasePlugin): + Args: + permissions: List of permissions to install + """ + cur_perm = set(x.name for x in self.permissions) + all_perm = set(permissions) + + new_perms = all_perm - cur_perm + self.permissions = list(filter(lambda x: x.name in permissions, self.permissions)) + [ + Permission(name=x, plugin_=self) for x in new_perms + ] + + +class AuthPlugin(Plugin): """Base class for all authentification plugins - See also `BasePlugin` + See also `Plugin` """ - def login(self, user, pw): + def login(self, login_name, password) -> Union[bool, str]: """Login routine, MUST BE IMPLEMENTED! Args: - user: User class containing at least the uid - pw: given password + login_name: The name the user entered + password: The password the user used to log in Returns: - Must return False if not found or invalid credentials, True if success + Must return False if not found or invalid credentials, otherwise the UID is returned """ raise NotImplemented - def update_user(self, user): + def update_user(self, user: "User"): """If backend is using external data, then update this user instance with external data Args: user: User object """ pass - def find_user(self, userid, mail=None): - """Find an user by userid or mail + def user_exists(self, userid) -> bool: + """Check if user exists on this backend Args: userid: Userid to search - mail: If set, mail to search Returns: - None or User + True or False """ - return None + raise NotImplemented def modify_user(self, user, password, new_password=None): """If backend is using (writeable) external data, then update the external database with the user provided. @@ -247,11 +209,14 @@ class AuthPlugin(BasePlugin): password: Password (some backends need the current password for changes) if None force edit (admin) new_password: If set a password change is requested Raises: - NotImplemented: If backend does not support this feature (or no password change) BadRequest: Logic error, e.g. password is wrong. Error: Other errors if backend went mad (are not handled and will result in a 500 error) """ - raise NotImplemented + pass + + def can_register(self): + """Check if this backend allows to register new users""" + return False def create_user(self, user, password): """If backend is using (writeable) external data, then create a new user on the external database. @@ -272,7 +237,7 @@ class AuthPlugin(BasePlugin): """ raise MethodNotAllowed - def get_avatar(self, user): + def get_avatar(self, user) -> _Avatar: """Retrieve avatar for given user (if supported by auth backend) Default behavior is to use native Image objects, diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index be20ac2..7f06302 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -6,13 +6,13 @@ from flask import Blueprint, request, jsonify from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from flaschengeist import logger -from flaschengeist.plugins import BasePlugin +from flaschengeist.plugins import Plugin from flaschengeist.utils.HTTP import no_content, created from flaschengeist.utils.decorators import login_required from flaschengeist.controller import sessionController, userController -class AuthRoutePlugin(BasePlugin): +class AuthRoutePlugin(Plugin): blueprint = Blueprint("auth", __name__) diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 50ab4af..bdf01ac 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -7,42 +7,25 @@ import os import hashlib import binascii from werkzeug.exceptions import BadRequest -from flaschengeist.plugins import AuthPlugin, plugins_installed +from flaschengeist.plugins import AuthPlugin from flaschengeist.models import User, Role, Permission from flaschengeist.database import db from flaschengeist import logger class AuthPlain(AuthPlugin): - def install(self): - plugins_installed(self.post_install) + def can_register(self): + return True - def post_install(self, *args, **kwargs): - if User.query.filter(User.deleted == False).count() == 0: - logger.info("Installing admin user") - role = Role.query.filter(Role.name == "Superuser").first() - if role is None: - role = Role(name="Superuser", permissions=Permission.query.all()) - admin = User( - userid="admin", - firstname="Admin", - lastname="Admin", - mail="", - roles_=[role], - ) - self.modify_user(admin, None, "admin") - db.session.add(admin) - db.session.commit() - logger.warning( - "New administrator user was added, please change the password or remove it before going into" - "production mode. Initial credentials:\n" - "name: admin\n" - "password: admin" - ) - - def login(self, user: User, password: str): - if user.has_attribute("password"): - return AuthPlain._verify_password(user.get_attribute("password"), password) + def login(self, login_name, password): + users: list[User] = ( + User.query.filter((User.userid == login_name) | (User.mail == login_name)) + .filter(User._attributes.any(name="password")) + .all() + ) + for user in users: + if AuthPlain._verify_password(user.get_attribute("password"), password): + return user.userid return False def modify_user(self, user, password, new_password=None): @@ -51,6 +34,12 @@ class AuthPlain(AuthPlugin): if new_password: user.set_attribute("password", AuthPlain._hash_password(new_password)) + def user_exists(self, userid) -> bool: + return ( + db.session.query(User.id_).filter(User.userid == userid, User._attributes.any(name="password")).first() + is not None + ) + def create_user(self, user, password): if not user.userid: raise BadRequest("userid is missing for new user") @@ -68,7 +57,7 @@ class AuthPlain(AuthPlugin): return (salt + pass_hash).decode("ascii") @staticmethod - def _verify_password(stored_password, provided_password): + def _verify_password(stored_password: str, provided_password: str): salt = stored_password[:64] stored_password = stored_password[64:] pass_hash = hashlib.pbkdf2_hmac("sha3-512", provided_password.encode("utf-8"), salt.encode("ascii"), 100000) diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index 59d82d1..acc00de 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -4,13 +4,13 @@ from email.mime.multipart import MIMEMultipart from flaschengeist import logger from flaschengeist.models import User -from flaschengeist.plugins import BasePlugin +from flaschengeist.plugins import Plugin from flaschengeist.utils.hook import HookAfter from flaschengeist.controller import userController from flaschengeist.controller.messageController import Message -class MailMessagePlugin(BasePlugin): +class MailMessagePlugin(Plugin): def __init__(self, entry_point, config): super().__init__(entry_point, config) self.server = config["SERVER"] diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index cd2fae4..07380cb 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -6,7 +6,7 @@ Provides routes used to configure roles and permissions of users / roles. from werkzeug.exceptions import BadRequest from flask import Blueprint, request, jsonify -from flaschengeist.plugins import BasePlugin +from flaschengeist.plugins import Plugin from flaschengeist.controller import roleController from flaschengeist.utils.HTTP import created, no_content from flaschengeist.utils.decorators import login_required @@ -14,9 +14,11 @@ from flaschengeist.utils.decorators import login_required from . import permissions -class RolesPlugin(BasePlugin): +class RolesPlugin(Plugin): blueprint = Blueprint("roles", __name__) - permissions = permissions.permissions + + def install(self): + self.install_permissions(permissions.permissions) @RolesPlugin.blueprint.route("/roles", methods=["GET"]) diff --git a/flaschengeist/plugins/scheduler.py b/flaschengeist/plugins/scheduler.py index 43a0a8b..268bafd 100644 --- a/flaschengeist/plugins/scheduler.py +++ b/flaschengeist/plugins/scheduler.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from flaschengeist import logger from flaschengeist.config import config -from flaschengeist.plugins import BasePlugin +from flaschengeist.plugins import Plugin from flaschengeist.utils.HTTP import no_content @@ -38,11 +38,10 @@ def scheduled(id: str, replace=False, **kwargs): return real_decorator -class SchedulerPlugin(BasePlugin): - def __init__(self, entry_point): - super().__init__(entry_point) - self.blueprint = Blueprint(self.name, __name__) +class SchedulerPlugin(Plugin): + blueprint = Blueprint("scheduler", __name__) + def load(self): def __view_func(): self.run_tasks() return no_content() diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 7511a3f..e819486 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -9,7 +9,7 @@ from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed from . import permissions from flaschengeist import logger from flaschengeist.config import config -from flaschengeist.plugins import BasePlugin +from flaschengeist.plugins import Plugin from flaschengeist.models import User from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.controller import userController @@ -17,9 +17,11 @@ from flaschengeist.utils.HTTP import created, no_content from flaschengeist.utils.datetime import from_iso_format -class UsersPlugin(BasePlugin): +class UsersPlugin(Plugin): blueprint = Blueprint("users", __name__) - permissions = permissions.permissions + + def install(self): + self.install_permissions(permissions.permissions) @UsersPlugin.blueprint.route("/users", methods=["POST"]) From 6ad8cd1728bcf09fe65d5aee035491b531affbac Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Aug 2022 17:04:22 +0200 Subject: [PATCH 409/446] [cli] Users and roles can be now managed using the cli Signed-off-by: Ferdinand Thiessen --- flaschengeist/controller/userController.py | 4 +- flaschengeist/plugins/users/cli.py | 57 +++++++++++++++++----- setup.cfg | 3 +- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 520ec31..65567ed 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -237,9 +237,9 @@ def register(data, passwd=None): provider.create_user(user, password) db.session.add(user) db.session.commit() - except IndexError: + except IndexError as e: logger.error("No authentication backend, allowing registering new users, found.") - raise BadRequest + raise e except exc.IntegrityError: raise BadRequest("userid already in use") diff --git a/flaschengeist/plugins/users/cli.py b/flaschengeist/plugins/users/cli.py index 4c9dab2..b5f6469 100644 --- a/flaschengeist/plugins/users/cli.py +++ b/flaschengeist/plugins/users/cli.py @@ -1,6 +1,8 @@ import click from flask.cli import with_appcontext -from werkzeug.exceptions import BadRequest, Conflict, NotFound +from werkzeug.exceptions import NotFound + +from flaschengeist.database import db from flaschengeist.controller import roleController, userController @@ -28,23 +30,52 @@ def user(ctx, param, value): @click.command() -@click.option("--add-role", help="Add new role", type=str) -@click.option("--set-admin", help="Make a role an admin role, adding all permissions", type=str) -@click.option("--add-user", help="Add new user interactivly", callback=user, is_flag=True, expose_value=False) +@click.option("--create", help="Add new role", is_flag=True) +@click.option("--delete", help="Delete role", is_flag=True) +@click.option("--set-admin", is_flag=True, help="Make a role an admin role, adding all permissions", type=str) +@click.argument("role", nargs=-1, required=True, type=str) +def role(create, delete, set_admin, role): + """Manage roles""" + ctx = click.get_current_context() + + if (create and delete) or (set_admin and delete): + ctx.fail("Do not mix --delete with --create or --set-admin") + + for role_name in role: + if create: + r = roleController.create_role(role_name) + else: + r = roleController.get(role_name) + if delete: + roleController.delete(r) + if set_admin: + r.permissions = roleController.get_permissions() + db.session.commit() + + +@click.command() +@click.option("--add-role", help="Add a role to an user", type=str) +@click.option("--create", help="Create new user interactivly", callback=user, is_flag=True, expose_value=False) +@click.option("--delete", help="Delete a user", is_flag=True) +@click.argument("user", nargs=-1, type=str) @with_appcontext -def users(add_role, set_admin): +def user(add_role, delete, user): + """Manage users""" from flaschengeist.database import db ctx = click.get_current_context() try: - if add_role: - roleController.create_role(add_role) - if set_admin: - role = roleController.get(set_admin) - role.permissions = roleController.get_permissions() - db.session.commit() if USER_KEY in ctx.meta: userController.register(ctx.meta[USER_KEY], ctx.meta[USER_KEY]["password"]) - except (BadRequest, NotFound) as e: - ctx.fail(e.description) + else: + for uid in user: + user = userController.get_user(uid) + if delete: + userController.delete_user(user) + elif add_role: + role = roleController.get(add_role) + user.roles_.append(role) + db.session.commit() + except NotFound: + ctx.fail(f"User not found {uid}") diff --git a/setup.cfg b/setup.cfg index 4c1c786..dcd1766 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,7 +47,8 @@ console_scripts = flaschengeist = flaschengeist.cli:main flask.commands = ldap = flaschengeist.plugins.auth_ldap.cli:ldap - users = flaschengeist.plugins.users.cli:users + user = flaschengeist.plugins.users.cli:user + role = flaschengeist.plugins.users.cli:role flaschengeist.plugins = # Authentication providers auth_plain = flaschengeist.plugins.auth_plain:AuthPlain From aa8f8f6e64ab05bd47fba7788a5b986263bac277 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Aug 2022 17:05:04 +0200 Subject: [PATCH 410/446] [core][plugin] Allow blueprints to be set on instance level This ensures blueprints are read from the plugin instance instead of the class, allowing custom routes to be added within the `load()` function. Signed-off-by: Ferdinand Thiessen --- flaschengeist/app.py | 13 ++++++++----- flaschengeist/plugins/__init__.py | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index d490965..be7d6fd 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -41,9 +41,14 @@ def load_plugins(app: Flask): for plugin in pluginController.get_enabled_plugins(): logger.debug(f"Searching for enabled plugin {plugin.name}") try: + # Load class cls = plugin.entry_point.load() - if hasattr(cls, "blueprint") and cls.blueprint is not None: - app.register_blueprint(cls.blueprint) + plugin = cls.query.get(plugin.id) if plugin.id is not None else plugin + # Custom loading tasks + plugin.load() + # Register blueprint + if hasattr(plugin, "blueprint") and plugin.blueprint is not None: + app.register_blueprint(plugin.blueprint) except: logger.error( f"Plugin {plugin.name} was enabled, but could not be loaded due to an error.", @@ -51,9 +56,7 @@ def load_plugins(app: Flask): ) continue logger.info(f"Loaded plugin: {plugin.name}") - app.config["FG_PLUGINS"][plugin.name] = cls.query.get(plugin.id) if plugin.id is not None else plugin - app.config["FG_PLUGINS"][plugin.name].load() - + app.config["FG_PLUGINS"][plugin.name] = plugin def create_app(test_config=None, cli=False): app = Flask("flaschengeist") diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index ff53f86..0c38c38 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -226,7 +226,7 @@ class AuthPlugin(Plugin): password: string """ - raise MethodNotAllowed + raise NotImplementedError def delete_user(self, user): """If backend is using (writeable) external data, then delete the user from external database. @@ -235,7 +235,7 @@ class AuthPlugin(Plugin): user: User object """ - raise MethodNotAllowed + raise NotImplementedError def get_avatar(self, user) -> _Avatar: """Retrieve avatar for given user (if supported by auth backend) From 0698327ef521ffe489cd8164068640dc738e683d Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Aug 2022 17:07:12 +0200 Subject: [PATCH 411/446] [core][deps] Use sqlalchemy_utils instead of copy-paste code for merging references This fixes issues when using SQLite Signed-off-by: Ferdinand Thiessen --- flaschengeist/controller/userController.py | 2 +- flaschengeist/utils/foreign_keys.py | 51 ---------------------- setup.cfg | 1 + 3 files changed, 2 insertions(+), 52 deletions(-) delete mode 100644 flaschengeist/utils/foreign_keys.py diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 65567ed..87f67c3 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -3,6 +3,7 @@ import secrets from io import BytesIO from sqlalchemy import exc +from sqlalchemy_utils import merge_references from datetime import datetime, timedelta, timezone from flask.helpers import send_file from werkzeug.exceptions import NotFound, BadRequest, Forbidden @@ -14,7 +15,6 @@ from ..models import Notification, User, Role from ..models.user import _PasswordReset from ..utils.hook import Hook from ..utils.datetime import from_iso_format -from ..utils.foreign_keys import merge_references from ..controller import imageController, messageController, pluginController, sessionController from ..plugins import AuthPlugin diff --git a/flaschengeist/utils/foreign_keys.py b/flaschengeist/utils/foreign_keys.py deleted file mode 100644 index ac2bedc..0000000 --- a/flaschengeist/utils/foreign_keys.py +++ /dev/null @@ -1,51 +0,0 @@ -# Borrowed from https://github.com/kvesteri/sqlalchemy-utils -# Modifications see: https://github.com/kvesteri/sqlalchemy-utils/issues/561 -# LICENSED under the BSD license, see upstream https://github.com/kvesteri/sqlalchemy-utils/blob/master/LICENSE - -import sqlalchemy as sa -from sqlalchemy.orm import object_session - - -def get_foreign_key_values(fk, obj): - mapper = sa.inspect(obj.__class__) - return dict( - ( - fk.constraint.columns.values()[index], - getattr(obj, element.column.key) - if hasattr(obj, element.column.key) - else getattr(obj, mapper.get_property_by_column(element.column).key), - ) - for index, element in enumerate(fk.constraint.elements) - ) - - -def get_referencing_foreign_keys(mixed): - tables = [mixed] - referencing_foreign_keys = set() - - for table in mixed.metadata.tables.values(): - if table not in tables: - for constraint in table.constraints: - if isinstance(constraint, sa.sql.schema.ForeignKeyConstraint): - for fk in constraint.elements: - if any(fk.references(t) for t in tables): - referencing_foreign_keys.add(fk) - return referencing_foreign_keys - - -def merge_references(from_, to, foreign_keys=None): - """ - Merge the references of an entity into another entity. - """ - if from_.__tablename__ != to.__tablename__: - raise TypeError("The tables of given arguments do not match.") - - session = object_session(from_) - foreign_keys = get_referencing_foreign_keys(from_.__table__) - - for fk in foreign_keys: - old_values = get_foreign_key_values(fk, from_) - new_values = get_foreign_key_values(fk, to) - session.query(from_.__mapper__).filter(*[k == old_values[k] for k in old_values]).update( - new_values, synchronize_session=False - ) diff --git a/setup.cfg b/setup.cfg index dcd1766..130d59a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,6 +28,7 @@ install_requires = flask_migrate>=3.1.0 flask_sqlalchemy>=2.5.1 sqlalchemy>=1.4.40 + sqlalchemy_utils>=0.38.3 toml werkzeug>=2.2.2 From 88a4dc24f297b2f1fafed765bf433e5ca028e350 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Aug 2022 18:44:21 +0200 Subject: [PATCH 412/446] [db] Fix automatic migration upgrade for plugins and core Signed-off-by: Ferdinand Thiessen --- flaschengeist/cli/install_cmd.py | 3 +-- flaschengeist/controller/pluginController.py | 12 +++++++++--- flaschengeist/database/__init__.py | 8 ++------ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/flaschengeist/cli/install_cmd.py b/flaschengeist/cli/install_cmd.py index e1e9244..367a1bc 100644 --- a/flaschengeist/cli/install_cmd.py +++ b/flaschengeist/cli/install_cmd.py @@ -3,7 +3,6 @@ from click.decorators import pass_context from flask.cli import with_appcontext from flask_migrate import upgrade -from flaschengeist.alembic import alembic_migrations_path from flaschengeist.controller import pluginController from flaschengeist.utils.hook import Hook @@ -16,7 +15,7 @@ def install(ctx: click.Context): plugins = pluginController.get_enabled_plugins() # Install database - upgrade(alembic_migrations_path, revision="heads") + upgrade(revision="flaschengeist@head") # Install plugins for plugin in plugins: diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 7cccc4e..4a84d3c 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -7,6 +7,7 @@ from typing import Union from flask import current_app from werkzeug.exceptions import NotFound, BadRequest from sqlalchemy.exc import OperationalError +from flask_migrate import upgrade as database_upgrade from importlib.metadata import entry_points from flaschengeist import version as flaschengeist_version @@ -83,14 +84,19 @@ def install_plugin(plugin_name: str): raise NotFound cls = entry_point[0].load() - plugin = cls.query.filter(Plugin.name == plugin_name).one_or_none() + plugin: Plugin = cls.query.filter(Plugin.name == plugin_name).one_or_none() if plugin is None: plugin = cls(name=plugin_name, installed_version=entry_point[0].dist.version) db.session.add(plugin) - db.session.flush() + db.session.commit() # Custom installation steps plugin.install() - db.session.commit() + # Check migrations + directory = entry_point[0].dist.locate_file("") + for loc in entry_point[0].module.split(".") + ["migrations"]: + directory /= loc + if directory.exists(): + database_upgrade(revision=f"{plugin_name}@head") return plugin diff --git a/flaschengeist/database/__init__.py b/flaschengeist/database/__init__.py index 21301d7..66428d0 100644 --- a/flaschengeist/database/__init__.py +++ b/flaschengeist/database/__init__.py @@ -39,11 +39,7 @@ def configure_alembic(config: Config): migrations = [config.get_main_option("script_location") + "/migrations"] # Gather all migration paths - all_plugins = entry_points(group="flaschengeist.plugins") - for plugin in pluginController.get_enabled_plugins(): - entry_point = all_plugins.select(name=plugin.name) - if not entry_point: - continue + for entry_point in entry_points(group="flaschengeist.plugins"): try: directory = entry_point.dist.locate_file("") for loc in entry_point.module.split(".") + ["migrations"]: @@ -52,7 +48,7 @@ def configure_alembic(config: Config): logger.debug(f"Adding migration version path {directory}") migrations.append(str(directory.resolve())) except: - logger.warning(f"Could not load migrations of plugin {plugin.name} for database migration.") + logger.warning(f"Could not load migrations of plugin {entry_point.name} for database migration.") logger.debug("Plugin loading failed", exc_info=True) # write back seperator (we changed it if neither seperator nor locations were specified) From 9e8117e5546f0ba616709c56de7945c08b8e5378 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 25 Aug 2022 18:45:01 +0200 Subject: [PATCH 413/446] [plugins] Fix scheduler accessing database while unbound from session Signed-off-by: Ferdinand Thiessen --- flaschengeist/plugins/scheduler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flaschengeist/plugins/scheduler.py b/flaschengeist/plugins/scheduler.py index 268bafd..1a31c8a 100644 --- a/flaschengeist/plugins/scheduler.py +++ b/flaschengeist/plugins/scheduler.py @@ -60,6 +60,9 @@ class SchedulerPlugin(Plugin): self.blueprint.add_url_rule("/cron", view_func=__view_func) def run_tasks(self): + from ..database import db + self = db.session.merge(self) + changed = False now = datetime.now() status = self.get_setting("status", default=dict()) From 9f729bda6c7513bf7ed59123c9d7b4edad93ab5a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 26 Aug 2022 17:05:03 +0200 Subject: [PATCH 414/446] [plugins] Fix `auth_ldap`, `balance`, and `pricelist` compatibility Signed-off-by: Ferdinand Thiessen --- flaschengeist/plugins/auth_ldap/__init__.py | 26 +++---- flaschengeist/plugins/balance/__init__.py | 11 +-- flaschengeist/plugins/pricelist/__init__.py | 83 ++++++++++----------- flaschengeist/plugins/pricelist/models.py | 5 +- 4 files changed, 57 insertions(+), 68 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index bf2fb52..56d139b 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -11,6 +11,7 @@ from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from werkzeug.datastructures import FileStorage from flaschengeist import logger +from flaschengeist.config import config from flaschengeist.controller import userController from flaschengeist.models import User, Role from flaschengeist.models.user import _Avatar @@ -18,8 +19,7 @@ from flaschengeist.plugins import AuthPlugin, before_role_updated class AuthLDAP(AuthPlugin): - def __init__(self, entry_point, config): - super().__init__(entry_point, config) + def load(self): app.config.update( LDAP_SERVER=config.get("host", "localhost"), LDAP_PORT=config.get("port", 389), @@ -54,27 +54,23 @@ class AuthLDAP(AuthPlugin): logger.debug(f"LDAP: before_role_updated called with ({role}, {new_name})") self.__modify_role(role, new_name) - def login(self, user, password): - if not user: + def login(self, login_name, password): + if not login_name: return False - return self.ldap.authenticate(user.userid, password, "uid", self.base_dn) + return self.ldap.authenticate(login_name, password, "uid", self.base_dn) - def find_user(self, userid, mail=None): - attr = self.__find(userid, mail) - if attr is not None: - user = User(userid=attr["uid"][0]) - self.__update(user, attr) - return user + def user_exists(self, userid) -> bool: + attr = self.__find(userid, None) + return attr is not None def update_user(self, user): attr = self.__find(user.userid) self.__update(user, attr) - def create_user(self, user, password): - if self.root_dn is None: - logger.error("root_dn missing in ldap config!") - raise InternalServerError + def can_register(self): + return self.root_dn is not None + def create_user(self, user, password): try: ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) attributes = self.user_attributes.copy() diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index f983a9f..4ccfe20 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -4,10 +4,10 @@ Extends users plugin with balance functions """ from flask import current_app -from werkzeug.local import LocalProxy from werkzeug.exceptions import NotFound from flaschengeist import logger +from flaschengeist.config import config from flaschengeist.plugins import Plugin, plugins_loaded, before_update_user from flaschengeist.plugins.scheduler import add_scheduled @@ -56,16 +56,13 @@ def service_debit(): class BalancePlugin(Plugin): - permissions = permissions.permissions models = models - migrations = True - plugin: "BalancePlugin" = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name]) + def install(self): + self.install_permissions(permissions.permissions) - def __init__(self, entry_point, config): - super(BalancePlugin, self).__init__(entry_point, config) + def load(self): from .routes import blueprint - self.blueprint = blueprint @plugins_loaded diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 3f45351..c644ca0 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -1,39 +1,32 @@ """Pricelist plugin""" - -import pathlib -from flask import Blueprint, jsonify, request, current_app -from werkzeug.local import LocalProxy +from flask import Blueprint, jsonify, request from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized from flaschengeist import logger from flaschengeist.controller import userController from flaschengeist.controller.imageController import send_image, send_thumbnail from flaschengeist.plugins import Plugin -from flaschengeist.utils.decorators import login_required, extract_session, headers +from flaschengeist.utils.decorators import login_required, extract_session from flaschengeist.utils.HTTP import no_content from . import models from . import pricelist_controller, permissions -blueprint = Blueprint("pricelist", __name__, url_prefix="/pricelist") - - class PriceListPlugin(Plugin): - permissions = permissions.permissions - plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name]) models = models + blueprint = Blueprint("pricelist", __name__, url_prefix="/pricelist") - def __init__(self, entry_point, config=None): - super().__init__(entry_point, config) - self.blueprint = blueprint - self.migrations_path = (pathlib.Path(__file__).parent / "migrations").resolve() + def install(self): + self.install_permissions(permissions.permissions) + + def load(self): config = {"discount": 0} config.update(config) -@blueprint.route("/drink-types", methods=["GET"]) -@blueprint.route("/drink-types/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drink-types", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drink-types/", methods=["GET"]) def get_drink_types(identifier=None): """Get DrinkType(s) @@ -53,7 +46,7 @@ def get_drink_types(identifier=None): return jsonify(result) -@blueprint.route("/drink-types", methods=["POST"]) +@PriceListPlugin.blueprint.route("/drink-types", methods=["POST"]) @login_required(permission=permissions.CREATE_TYPE) def new_drink_type(current_session): """Create new DrinkType @@ -75,7 +68,7 @@ def new_drink_type(current_session): return jsonify(drink_type) -@blueprint.route("/drink-types/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/drink-types/", methods=["PUT"]) @login_required(permission=permissions.EDIT_TYPE) def update_drink_type(identifier, current_session): """Modify DrinkType @@ -98,7 +91,7 @@ def update_drink_type(identifier, current_session): return jsonify(drink_type) -@blueprint.route("/drink-types/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/drink-types/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_TYPE) def delete_drink_type(identifier, current_session): """Delete DrinkType @@ -116,8 +109,8 @@ def delete_drink_type(identifier, current_session): return no_content() -@blueprint.route("/tags", methods=["GET"]) -@blueprint.route("/tags/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/tags", methods=["GET"]) +@PriceListPlugin.blueprint.route("/tags/", methods=["GET"]) def get_tags(identifier=None): """Get Tag(s) @@ -137,7 +130,7 @@ def get_tags(identifier=None): return jsonify(result) -@blueprint.route("/tags", methods=["POST"]) +@PriceListPlugin.blueprint.route("/tags", methods=["POST"]) @login_required(permission=permissions.CREATE_TAG) def new_tag(current_session): """Create Tag @@ -157,7 +150,7 @@ def new_tag(current_session): return jsonify(drink_type) -@blueprint.route("/tags/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/tags/", methods=["PUT"]) @login_required(permission=permissions.EDIT_TAG) def update_tag(identifier, current_session): """Modify Tag @@ -178,7 +171,7 @@ def update_tag(identifier, current_session): return jsonify(tag) -@blueprint.route("/tags/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/tags/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_TAG) def delete_tag(identifier, current_session): """Delete Tag @@ -196,8 +189,8 @@ def delete_tag(identifier, current_session): return no_content() -@blueprint.route("/drinks", methods=["GET"]) -@blueprint.route("/drinks/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks/", methods=["GET"]) def get_drinks(identifier=None): """Get Drink(s) @@ -253,7 +246,7 @@ def get_drinks(identifier=None): return jsonify({"drinks": drinks, "count": count}) -@blueprint.route("/list", methods=["GET"]) +@PriceListPlugin.blueprint.route("/list", methods=["GET"]) def get_pricelist(): """Get Priclist Route: ``/pricelist/list`` | Method: ``GET`` @@ -302,7 +295,7 @@ def get_pricelist(): return jsonify({"pricelist": pricelist, "count": count}) -@blueprint.route("/drinks/search/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks/search/", methods=["GET"]) def search_drinks(name): """Search Drink @@ -323,7 +316,7 @@ def search_drinks(name): return jsonify(pricelist_controller.get_drinks(name, public=public)) -@blueprint.route("/drinks", methods=["POST"]) +@PriceListPlugin.blueprint.route("/drinks", methods=["POST"]) @login_required(permission=permissions.CREATE) def create_drink(current_session): """Create Drink @@ -375,7 +368,7 @@ def create_drink(current_session): return jsonify(pricelist_controller.set_drink(data)) -@blueprint.route("/drinks/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/drinks/", methods=["PUT"]) @login_required(permission=permissions.EDIT) def update_drink(identifier, current_session): """Modify Drink @@ -429,7 +422,7 @@ def update_drink(identifier, current_session): return jsonify(pricelist_controller.update_drink(identifier, data)) -@blueprint.route("/drinks/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/drinks/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) def delete_drink(identifier, current_session): """Delete Drink @@ -447,7 +440,7 @@ def delete_drink(identifier, current_session): return no_content() -@blueprint.route("/prices/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/prices/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_PRICE) def delete_price(identifier, current_session): """Delete Price @@ -465,7 +458,7 @@ def delete_price(identifier, current_session): return no_content() -@blueprint.route("/volumes/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/volumes/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_VOLUME) def delete_volume(identifier, current_session): """Delete DrinkPriceVolume @@ -483,7 +476,7 @@ def delete_volume(identifier, current_session): return no_content() -@blueprint.route("/ingredients/extraIngredients", methods=["GET"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"]) @login_required() def get_extra_ingredients(current_session): """Get ExtraIngredients @@ -499,7 +492,7 @@ def get_extra_ingredients(current_session): return jsonify(pricelist_controller.get_extra_ingredients()) -@blueprint.route("/ingredients/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/ingredients/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_INGREDIENTS_DRINK) def delete_ingredient(identifier, current_session): """Delete Ingredient @@ -517,7 +510,7 @@ def delete_ingredient(identifier, current_session): return no_content() -@blueprint.route("/ingredients/extraIngredients", methods=["POST"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"]) @login_required(permission=permissions.EDIT_INGREDIENTS) def set_extra_ingredient(current_session): """Create ExtraIngredient @@ -536,7 +529,7 @@ def set_extra_ingredient(current_session): return jsonify(pricelist_controller.set_extra_ingredient(data)) -@blueprint.route("/ingredients/extraIngredients/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", methods=["PUT"]) @login_required(permission=permissions.EDIT_INGREDIENTS) def update_extra_ingredient(identifier, current_session): """Modify ExtraIngredient @@ -556,7 +549,7 @@ def update_extra_ingredient(identifier, current_session): return jsonify(pricelist_controller.update_extra_ingredient(identifier, data)) -@blueprint.route("/ingredients/extraIngredients/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", methods=["DELETE"]) @login_required(permission=permissions.DELETE_INGREDIENTS) def delete_extra_ingredient(identifier, current_session): """Delete ExtraIngredient @@ -574,7 +567,7 @@ def delete_extra_ingredient(identifier, current_session): return no_content() -@blueprint.route("/settings/min_prices", methods=["GET"]) +@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["GET"]) @login_required() def get_pricelist_settings_min_prices(current_session): """Get MinPrices @@ -595,7 +588,7 @@ def get_pricelist_settings_min_prices(current_session): return jsonify(min_prices) -@blueprint.route("/settings/min_prices", methods=["POST"]) +@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST"]) @login_required(permission=permissions.EDIT_MIN_PRICES) def post_pricelist_settings_min_prices(current_session): """Create MinPrices @@ -618,7 +611,7 @@ def post_pricelist_settings_min_prices(current_session): return no_content() -@blueprint.route("/users//pricecalc_columns", methods=["GET", "PUT"]) +@PriceListPlugin.blueprint.route("/users//pricecalc_columns", methods=["GET", "PUT"]) @login_required() def get_columns(userid, current_session): """Get pricecalc_columns of an user @@ -650,7 +643,7 @@ def get_columns(userid, current_session): return no_content() -@blueprint.route("/users//pricecalc_columns_order", methods=["GET", "PUT"]) +@PriceListPlugin.blueprint.route("/users//pricecalc_columns_order", methods=["GET", "PUT"]) @login_required() def get_columns_order(userid, current_session): """Get pricecalc_columns_order of an user @@ -681,7 +674,7 @@ def get_columns_order(userid, current_session): return no_content() -@blueprint.route("/users//pricelist", methods=["GET", "PUT"]) +@PriceListPlugin.blueprint.route("/users//pricelist", methods=["GET", "PUT"]) @login_required() def get_priclist_setting(userid, current_session): """Get pricelistsetting of an user @@ -714,7 +707,7 @@ def get_priclist_setting(userid, current_session): return no_content() -@blueprint.route("/drinks//picture", methods=["POST", "DELETE"]) +@PriceListPlugin.blueprint.route("/drinks//picture", methods=["POST", "DELETE"]) @login_required(permission=permissions.EDIT) def set_picture(identifier, current_session): """Get, Create, Delete Drink Picture @@ -741,7 +734,7 @@ def set_picture(identifier, current_session): raise BadRequest -@blueprint.route("/drinks//picture", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks//picture", methods=["GET"]) # @headers({"Cache-Control": "private, must-revalidate"}) def _get_picture(identifier): """Get Picture diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index 1d8dc23..1a5335d 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -1,8 +1,11 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) + +from typing import Optional + from flaschengeist.database import db from flaschengeist.database.types import ModelSerializeMixin, Serial from flaschengeist.models import Image -from typing import Optional drink_tag_association = db.Table( "drink_x_tag", From 8a5380d888c1972752d49058570d376daab5cfe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 17 Feb 2023 12:46:27 +0100 Subject: [PATCH 415/446] update sqlalchmy version. not higher than 2.0 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index c18feda..c14bb99 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,7 +28,7 @@ install_requires = flask_sqlalchemy>=2.5 # Importlib requirement can be dropped when python requirement is >= 3.10 importlib_metadata>=4.3 - sqlalchemy>=1.4.26 + sqlalchemy>=1.4.26, <2.0 toml werkzeug From 7796f45097546cfd9599aba828b5df099a1bc7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 17 Feb 2023 15:30:05 +0100 Subject: [PATCH 416/446] feat(db) fix get plugins if no database exists --- flaschengeist/controller/pluginController.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 4a84d3c..5143482 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -6,7 +6,7 @@ Used by plugins for setting and notification functionality. from typing import Union from flask import current_app from werkzeug.exceptions import NotFound, BadRequest -from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import OperationalError, ProgrammingError from flask_migrate import upgrade as database_upgrade from importlib.metadata import entry_points @@ -43,7 +43,7 @@ def get_enabled_plugins() -> list[Plugin]: """Get all installed and enabled plugins""" try: enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all() - except OperationalError as e: + except (OperationalError, ProgrammingError) as e: logger.error("Could not connect to database or database not initialized! No plugins enabled!") logger.debug("Can not query enabled plugins", exc_info=True) # Fake load required plugins so the database can at least be installed From c5436f22fa0223873ac57e0531d20f6a7a55ac2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 17 Feb 2023 16:40:54 +0100 Subject: [PATCH 417/446] feat(ldap) fix get right config --- flaschengeist/plugins/auth_ldap/__init__.py | 34 ++++++++++++--------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 56d139b..2f76498 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -20,34 +20,38 @@ from flaschengeist.plugins import AuthPlugin, before_role_updated class AuthLDAP(AuthPlugin): def load(self): + self.config = config.get("auth_ldap", None) + if self.config is None: + logger.error("auth_ldap was not configured in flaschengeist.toml", exc_info=True) + raise InternalServerError app.config.update( - LDAP_SERVER=config.get("host", "localhost"), - LDAP_PORT=config.get("port", 389), - LDAP_BINDDN=config.get("bind_dn", None), - LDAP_SECRET=config.get("secret", None), - LDAP_USE_SSL=config.get("use_ssl", False), + LDAP_SERVER=self.config.get("host", "localhost"), + LDAP_PORT=self.config.get("port", 389), + LDAP_BINDDN=self.config.get("bind_dn", None), + LDAP_SECRET=self.config.get("secret", None), + LDAP_USE_SSL=self.config.get("use_ssl", False), # That's not TLS, its dirty StartTLS on unencrypted LDAP LDAP_USE_TLS=False, LDAP_TLS_VERSION=ssl.PROTOCOL_TLS, FORCE_ATTRIBUTE_VALUE_AS_LIST=True, ) if "ca_cert" in config: - app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"] + app.config["LDAP_CA_CERTS_FILE"] = self.config["ca_cert"] else: # Default is CERT_REQUIRED app.config["LDAP_REQUIRE_CERT"] = ssl.CERT_OPTIONAL self.ldap = LDAPConn(app) - self.base_dn = config["base_dn"] - self.search_dn = config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn) - self.group_dn = config.get("group_dn", "ou=group,{base_dn}").format(base_dn=self.base_dn) - self.password_hash = config.get("password_hash", "SSHA").upper() - self.object_classes = config.get("object_classes", ["inetOrgPerson"]) - self.user_attributes: dict = config.get("user_attributes", {}) - self.dn_template = config.get("dn_template") + self.base_dn = self.config["base_dn"] + self.search_dn = self.config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn) + self.group_dn = self.config.get("group_dn", "ou=group,{base_dn}").format(base_dn=self.base_dn) + self.password_hash = self.config.get("password_hash", "SSHA").upper() + self.object_classes = self.config.get("object_classes", ["inetOrgPerson"]) + self.user_attributes: dict = self.config.get("user_attributes", {}) + self.dn_template = self.config.get("dn_template") # TODO: might not be set if modify is called - self.root_dn = config.get("root_dn", None) - self.root_secret = config.get("root_secret", None) + self.root_dn = self.config.get("root_dn", None) + self.root_secret = self.config.get("root_secret", None) @before_role_updated def _role_updated(role, new_name): From e0acb80f5d9a4a3769213db3c39e52d8e6100c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 17 Feb 2023 17:52:20 +0100 Subject: [PATCH 418/446] feat(plugin) fix get right instance auf auth_provider --- flaschengeist/controller/pluginController.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 5143482..0c0b22f 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -23,7 +23,7 @@ __required_plugins = ["users", "roles", "scheduler", "auth"] def get_authentication_provider(): - return [plugin for plugin in get_loaded_plugins().values() if isinstance(plugin, AuthPlugin)] + return [current_app.config["FG_PLUGINS"][plugin.name] for plugin in get_loaded_plugins().values() if isinstance(plugin, AuthPlugin)] def get_loaded_plugins(plugin_name: str = None): From a50ba403fc3040f141c552d74ee92b8494f3d179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 17 Feb 2023 20:40:27 +0100 Subject: [PATCH 419/446] feat(ldap) fix sync from ldap --- flaschengeist/plugins/auth_ldap/cli.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/flaschengeist/plugins/auth_ldap/cli.py b/flaschengeist/plugins/auth_ldap/cli.py index d306d21..e3d772f 100644 --- a/flaschengeist/plugins/auth_ldap/cli.py +++ b/flaschengeist/plugins/auth_ldap/cli.py @@ -1,6 +1,7 @@ import click from flask import current_app from flask.cli import with_appcontext +from werkzeug.exceptions import NotFound @click.command(no_args_is_help=True) @@ -13,8 +14,10 @@ def ldap(ctx, sync): from flaschengeist.controller import userController from flaschengeist.plugins.auth_ldap import AuthLDAP from ldap3 import SUBTREE + from flaschengeist.models import User + from flaschengeist.database import db - auth_ldap: AuthLDAP = current_app.config.get("FG_AUTH_BACKEND") + auth_ldap: AuthLDAP = current_app.config.get("FG_PLUGINS").get("auth_ldap") if auth_ldap is None or not isinstance(auth_ldap, AuthLDAP): ctx.fail("auth_ldap plugin not found or not enabled!") conn = auth_ldap.ldap.connection @@ -24,4 +27,9 @@ def ldap(ctx, sync): ldap_users_response = conn.response for ldap_user in ldap_users_response: uid = ldap_user["attributes"]["uid"][0] - userController.find_user(uid) + try: + user = userController.get_user(uid) + except NotFound: + user = User(userid=uid) + db.session.add(user) + userController.update_user(user, auth_ldap) From d475f3f8e2a1950b39d365c756a42f1024bc407f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 18 Feb 2023 15:11:42 +0100 Subject: [PATCH 420/446] feat(ldap) fix login on ldap --- flaschengeist/plugins/auth_ldap/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 2f76498..8a99284 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -61,7 +61,7 @@ class AuthLDAP(AuthPlugin): def login(self, login_name, password): if not login_name: return False - return self.ldap.authenticate(login_name, password, "uid", self.base_dn) + return login_name if self.ldap.authenticate(login_name, password, "uid", self.base_dn) else False def user_exists(self, userid) -> bool: attr = self.__find(userid, None) @@ -306,3 +306,5 @@ class AuthLDAP(AuthPlugin): except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest + except IndexError as e: + logger.error("Roles in LDAP", exc_info=True) From ba93345a0938c94751a8a394d9db0ae1630e1bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sat, 18 Feb 2023 15:48:53 +0100 Subject: [PATCH 421/446] feat(users) fix cli if user get role, that provider is updatet too --- flaschengeist/plugins/users/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flaschengeist/plugins/users/cli.py b/flaschengeist/plugins/users/cli.py index b5f6469..5e69d91 100644 --- a/flaschengeist/plugins/users/cli.py +++ b/flaschengeist/plugins/users/cli.py @@ -1,4 +1,5 @@ import click +import sqlalchemy.exc from flask.cli import with_appcontext from werkzeug.exceptions import NotFound @@ -76,6 +77,7 @@ def user(add_role, delete, user): elif add_role: role = roleController.get(add_role) user.roles_.append(role) + userController.modify_user(user, None) db.session.commit() except NotFound: ctx.fail(f"User not found {uid}") From af2c674ce445d17214f8f53e0fa7b05b2b2c0b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 9 Apr 2023 20:57:15 +0200 Subject: [PATCH 422/446] fixed most deprecations from flask and sqlalchemy --- flaschengeist/app.py | 29 ++++++++++++++++++++--- flaschengeist/config.py | 2 +- flaschengeist/models/image.py | 1 + flaschengeist/models/notification.py | 1 + flaschengeist/models/plugin.py | 10 ++++---- flaschengeist/models/session.py | 1 + flaschengeist/models/user.py | 17 ++++++++----- flaschengeist/plugins/__init__.py | 11 ++++----- flaschengeist/plugins/balance/__init__.py | 3 ++- flaschengeist/plugins/balance/models.py | 1 + setup.cfg | 2 +- 11 files changed, 56 insertions(+), 22 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index be7d6fd..0e2031c 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,9 +1,12 @@ import enum +import json from flask import Flask from flask_cors import CORS from datetime import datetime, date -from flask.json import JSONEncoder, jsonify +from flask.json import jsonify +from json import JSONEncoder +from flask.json.provider import JSONProvider from sqlalchemy.exc import OperationalError from werkzeug.exceptions import HTTPException @@ -11,6 +14,9 @@ from flaschengeist import logger from flaschengeist.controller import pluginController from flaschengeist.utils.hook import Hook from flaschengeist.config import configure_app +from flaschengeist.plugins import Plugin + +from flaschengeist.database import db class CustomJSONEncoder(JSONEncoder): @@ -33,6 +39,19 @@ class CustomJSONEncoder(JSONEncoder): return list(iterable) return JSONEncoder.default(self, o) +class CustomJSONProvider(JSONProvider): + + ensure_ascii: bool = True + sort_keys: bool = True + + def dumps(self, obj, **kwargs): + kwargs.setdefault("ensure_ascii", self.ensure_ascii) + kwargs.setdefault("sort_keys", self.sort_keys) + return json.dumps(obj, **kwargs, cls=CustomJSONEncoder) + + def loads(self, s: str | bytes, **kwargs): + return json.loads(s, **kwargs) + @Hook("plugins.loaded") def load_plugins(app: Flask): @@ -43,7 +62,9 @@ def load_plugins(app: Flask): try: # Load class cls = plugin.entry_point.load() - plugin = cls.query.get(plugin.id) if plugin.id is not None else plugin + #plugin = cls.query.get(plugin.id) if plugin.id is not None else plugin + #plugin = db.session.query(cls).get(plugin.id) if plugin.id is not None else plugin + plugin = db.session.get(cls, plugin.id) if plugin.id is not None else plugin # Custom loading tasks plugin.load() # Register blueprint @@ -60,7 +81,9 @@ def load_plugins(app: Flask): def create_app(test_config=None, cli=False): app = Flask("flaschengeist") - app.json_encoder = CustomJSONEncoder + # app.json_encoder = CustomJSONEncoder + app.json_provider_class = CustomJSONProvider + app.json = CustomJSONProvider(app) CORS(app) with app.app_context(): diff --git a/flaschengeist/config.py b/flaschengeist/config.py index 712d5d1..fb1963e 100644 --- a/flaschengeist/config.py +++ b/flaschengeist/config.py @@ -28,7 +28,7 @@ def read_configuration(test_config): if not test_config: paths.append(Path.home() / ".config") if "FLASCHENGEIST_CONF" in os.environ: - paths.append(Path(os.environ.get("FLASCHENGEIST_CONF"))) + paths.append(Path(str(os.environ.get("FLASCHENGEIST_CONF")))) for loc in paths: try: diff --git a/flaschengeist/models/image.py b/flaschengeist/models/image.py index 101a19a..dffd275 100644 --- a/flaschengeist/models/image.py +++ b/flaschengeist/models/image.py @@ -8,6 +8,7 @@ from ..database.types import ModelSerializeMixin, Serial class Image(db.Model, ModelSerializeMixin): + __allow_unmapped__ = True __tablename__ = "image" id: int = db.Column(Serial, primary_key=True) filename_: str = db.Column("filename", db.String(255), nullable=False) diff --git a/flaschengeist/models/notification.py b/flaschengeist/models/notification.py index 55e9640..33b1d8d 100644 --- a/flaschengeist/models/notification.py +++ b/flaschengeist/models/notification.py @@ -8,6 +8,7 @@ from ..database.types import Serial, UtcDateTime, ModelSerializeMixin class Notification(db.Model, ModelSerializeMixin): + __allow_unmapped__ = True __tablename__ = "notification" id: int = db.Column("id", Serial, primary_key=True) text: str = db.Column(db.Text) diff --git a/flaschengeist/models/plugin.py b/flaschengeist/models/plugin.py index a912afd..4254cc8 100644 --- a/flaschengeist/models/plugin.py +++ b/flaschengeist/models/plugin.py @@ -1,6 +1,6 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) -from typing import Any +from typing import Any, List, Dict from sqlalchemy.orm.collections import attribute_mapped_collection from ..database import db @@ -8,6 +8,7 @@ from ..database.types import Serial class PluginSetting(db.Model): + __allow_unmapped__ = True __tablename__ = "plugin_setting" id = db.Column("id", Serial, primary_key=True) plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) @@ -16,6 +17,7 @@ class PluginSetting(db.Model): class BasePlugin(db.Model): + __allow_unmapped__ = True __tablename__ = "plugin" id: int = db.Column("id", Serial, primary_key=True) name: str = db.Column(db.String(127), nullable=False) @@ -24,7 +26,7 @@ class BasePlugin(db.Model): """The latest installed version""" enabled: bool = db.Column(db.Boolean, default=False) """Enabled state of the plugin""" - permissions: list = db.relationship( + permissions: List["Permission"] = db.relationship( "Permission", cascade="all, delete, delete-orphan", back_populates="plugin_", lazy="select" ) """Optional list of custom permissions used by the plugin @@ -33,11 +35,11 @@ class BasePlugin(db.Model): to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*. """ - __settings: dict[str, "PluginSetting"] = db.relationship( + __settings: Dict[str, "PluginSetting"] = db.relationship( "PluginSetting", collection_class=attribute_mapped_collection("name"), cascade="all, delete, delete-orphan", - lazy="select", + lazy="subquery", ) def get_setting(self, name: str, **kwargs): diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index 1dac1a3..668622d 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -17,6 +17,7 @@ class Session(db.Model, ModelSerializeMixin): token: String to verify access later. """ + __allow_unmapped__ = True __tablename__ = "session" expires: datetime = db.Column(UtcDateTime) token: str = db.Column(db.String(32), unique=True) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index e12db4a..a51cedc 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,6 +1,6 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) -from typing import Optional +from typing import Optional, Union, List from datetime import date, datetime from sqlalchemy.orm.collections import attribute_mapped_collection @@ -21,6 +21,7 @@ role_permission_association_table = db.Table( class Permission(db.Model, ModelSerializeMixin): + __allow_unmapped__ = True __tablename__ = "permission" name: str = db.Column(db.String(30), unique=True) @@ -30,10 +31,11 @@ class Permission(db.Model, ModelSerializeMixin): class Role(db.Model, ModelSerializeMixin): + __allow_unmapped__ = True __tablename__ = "role" id: int = db.Column(Serial, primary_key=True) name: str = db.Column(db.String(30), unique=True) - permissions: list[Permission] = db.relationship("Permission", secondary=role_permission_association_table) + permissions: List[Permission] = db.relationship("Permission", secondary=role_permission_association_table) class User(db.Model, ModelSerializeMixin): @@ -51,6 +53,7 @@ class User(db.Model, ModelSerializeMixin): birthday: Birthday of the user """ + __allow_unmapped__ = True __tablename__ = "user" userid: str = db.Column(db.String(30), unique=True, nullable=False) display_name: str = db.Column(db.String(30)) @@ -59,15 +62,15 @@ class User(db.Model, ModelSerializeMixin): deleted: bool = db.Column(db.Boolean(), default=False) birthday: Optional[date] = db.Column(db.Date) mail: str = db.Column(db.String(60)) - roles: list[str] = [] permissions: Optional[list[str]] = None + roles: List[str] = [] # Protected stuff for backend use only id_ = db.Column("id", Serial, primary_key=True) - roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") - sessions_: list[Session] = db.relationship("Session", back_populates="user_", cascade="all, delete, delete-orphan") + roles_: List[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") + sessions_: List[Session] = db.relationship("Session", back_populates="user_", cascade="all, delete, delete-orphan") avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) - reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan") + reset_requests_: List["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan") # Private stuff for internal use _avatar_id = db.Column("avatar", Serial, db.ForeignKey("image.id")) @@ -107,6 +110,7 @@ class User(db.Model, ModelSerializeMixin): class _UserAttribute(db.Model, ModelSerializeMixin): + __allow_unmapped__ = True __tablename__ = "user_attribute" id = db.Column("id", Serial, primary_key=True) user: User = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False) @@ -117,6 +121,7 @@ class _UserAttribute(db.Model, ModelSerializeMixin): class _PasswordReset(db.Model): """Table containing password reset requests""" + __allow_unmapped__ = True __tablename__ = "password_reset" _user_id: User = db.Column("user", Serial, db.ForeignKey("user.id"), primary_key=True) user: User = db.relationship("User", back_populates="reset_requests_", foreign_keys=[_user_id]) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 0c38c38..dadcb85 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -100,7 +100,7 @@ class Plugin(BasePlugin): @property def entry_point(self): - ep = entry_points(group="flaschengeist.plugins", name=self.name) + ep = tuple(entry_points(group="flaschengeist.plugins", name=self.name)) return ep[0] def load(self): @@ -158,14 +158,13 @@ class Plugin(BasePlugin): Args: permissions: List of permissions to install """ - cur_perm = set(x.name for x in self.permissions) + cur_perm = set(x.name for x in self.permissions or []) all_perm = set(permissions) new_perms = all_perm - cur_perm - self.permissions = list(filter(lambda x: x.name in permissions, self.permissions)) + [ - Permission(name=x, plugin_=self) for x in new_perms - ] - + _perms = [ Permission(name=x, plugin_=self) for x in new_perms ] + #self.permissions = list(filter(lambda x: x.name in permissions, self.permissions and isinstance(self.permissions, list) or [])) + self.permissions.extend(_perms) class AuthPlugin(Plugin): """Base class for all authentification plugins diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 4ccfe20..0f923c4 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -56,6 +56,7 @@ def service_debit(): class BalancePlugin(Plugin): + #id = "dev.flaschengeist.balance" models = models def install(self): @@ -71,7 +72,7 @@ class BalancePlugin(Plugin): add_scheduled(f"{id}.service_debit", service_debit, minutes=1) @before_update_user - def set_default_limit(user): + def set_default_limit(user, *args): from . import balance_controller try: diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index d5d0061..2d9525b 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -8,6 +8,7 @@ from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial class Transaction(db.Model, ModelSerializeMixin): + __allow_unmapped__ = True __tablename__ = "balance_transaction" # Protected foreign key properties _receiver_id = db.Column("receiver_id", Serial, db.ForeignKey("user.id")) diff --git a/setup.cfg b/setup.cfg index b98fc08..27dc3d4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,7 +30,7 @@ install_requires = sqlalchemy_utils>=0.38.3 # Importlib requirement can be dropped when python requirement is >= 3.10 importlib_metadata>=4.3 - sqlalchemy>=1.4.40, <2.0 + sqlalchemy >= 2.0 toml werkzeug>=2.2.2 From 47442fe21186dfbe37c89cb202fdb4e74bf9f64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Sun, 9 Apr 2023 21:15:07 +0200 Subject: [PATCH 423/446] fix Flask dependencie for #28 --- setup.cfg | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 27dc3d4..3a54392 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,7 +22,7 @@ include_package_data = True python_requires = >=3.10 packages = find: install_requires = - Flask>=2.2.2 + Flask>=2.2.2, <2.3 Pillow>=9.2 flask_cors flask_migrate>=3.1.0 @@ -30,6 +30,7 @@ install_requires = sqlalchemy_utils>=0.38.3 # Importlib requirement can be dropped when python requirement is >= 3.10 importlib_metadata>=4.3 + #sqlalchemy>=1.4.40, <2.0 sqlalchemy >= 2.0 toml werkzeug>=2.2.2 @@ -73,4 +74,4 @@ testpaths = tests [coverage:run] branch = True -source = flaschengeist \ No newline at end of file +source = flaschengeist From f38fb334f19e50818fa1228d12cf992a56e25b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 1 May 2023 21:53:48 +0200 Subject: [PATCH 424/446] add get notifications of plugin --- flaschengeist/controller/pluginController.py | 17 ++++++++++++++--- flaschengeist/models/notification.py | 3 ++- flaschengeist/plugins/__init__.py | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 0c0b22f..486ffea 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -3,7 +3,7 @@ Used by plugins for setting and notification functionality. """ -from typing import Union +from typing import Union, List from flask import current_app from werkzeug.exceptions import NotFound, BadRequest from sqlalchemy.exc import OperationalError, ProgrammingError @@ -56,7 +56,7 @@ def get_enabled_plugins() -> list[Plugin]: return enabled_plugins -def notify(plugin_id: str, user, text: str, data=None): +def notify(plugin_id: int, user, text: str, data=None): """Create a new notification for an user Args: @@ -70,12 +70,23 @@ def notify(plugin_id: str, user, text: str, data=None): Hint: use the data for frontend actions. """ if not user.deleted: - n = Notification(text=text, data=data, plugin=plugin_id, user_=user) + n = Notification(text=text, data=data, plugin_id_=plugin_id, user_=user) db.session.add(n) db.session.commit() return n.id +def get_notifications(plugin_id) -> List[Notification]: + """Get all notifications for a plugin + + Args: + plugin_id: ID of the plugin + Returns: + List of `flaschengeist.models.notification.Notification` + """ + return db.session.execute(db.select(Notification).where(Notification.plugin_id_ == plugin_id)).scalars().all() + + @Hook("plugins.installed") def install_plugin(plugin_name: str): logger.debug(f"Installing plugin {plugin_name}") diff --git a/flaschengeist/models/notification.py b/flaschengeist/models/notification.py index 33b1d8d..c126a84 100644 --- a/flaschengeist/models/notification.py +++ b/flaschengeist/models/notification.py @@ -21,7 +21,8 @@ class Notification(db.Model, ModelSerializeMixin): plugin_: Plugin = db.relationship( "Plugin", backref=db.backref("notifications_", cascade="all, delete, delete-orphan") ) + plugin: str @property - def plugin(self): + def plugin(self) -> str: return self.plugin_.name diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index dadcb85..c9c5cd1 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -4,7 +4,7 @@ """ -from typing import Union +from typing import Union, List from importlib.metadata import entry_points from werkzeug.exceptions import NotFound from werkzeug.datastructures import FileStorage @@ -142,7 +142,18 @@ class Plugin(BasePlugin): """ from ..controller import pluginController - return pluginController.notify(self.name, user, text, data) + return pluginController.notify(self.id, user, text, data) + + @property + def notifications(self) -> List["Notification"]: + """Get all notifications for this plugin + + Returns: + List of `flaschengeist.models.notification.Notification` + """ + from ..controller import pluginController + + return pluginController.get_notifications(self.id) def serialize(self): """Serialize a plugin into a dict From 59f5d4529d564c8280cc77fc079012008d921e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 3 May 2023 06:29:28 +0200 Subject: [PATCH 425/446] add ide to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 52b370e..f0eb86c 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,7 @@ dmypy.json *.swo .vscode/ *.log +.fleet/ data/ From e6c143ad928cec475423979f5c1e6f1f88fdf753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 3 May 2023 06:29:55 +0200 Subject: [PATCH 426/446] fix json_encoder for flask 2.x --- flaschengeist/app.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 0e2031c..e1a5ee3 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -6,7 +6,7 @@ from flask_cors import CORS from datetime import datetime, date from flask.json import jsonify from json import JSONEncoder -from flask.json.provider import JSONProvider +from flask.json.provider import JSONProvider from sqlalchemy.exc import OperationalError from werkzeug.exceptions import HTTPException @@ -14,7 +14,6 @@ from flaschengeist import logger from flaschengeist.controller import pluginController from flaschengeist.utils.hook import Hook from flaschengeist.config import configure_app -from flaschengeist.plugins import Plugin from flaschengeist.database import db @@ -39,8 +38,8 @@ class CustomJSONEncoder(JSONEncoder): return list(iterable) return JSONEncoder.default(self, o) -class CustomJSONProvider(JSONProvider): +class CustomJSONProvider(JSONProvider): ensure_ascii: bool = True sort_keys: bool = True @@ -62,8 +61,8 @@ def load_plugins(app: Flask): try: # Load class cls = plugin.entry_point.load() - #plugin = cls.query.get(plugin.id) if plugin.id is not None else plugin - #plugin = db.session.query(cls).get(plugin.id) if plugin.id is not None else plugin + # plugin = cls.query.get(plugin.id) if plugin.id is not None else plugin + # plugin = db.session.query(cls).get(plugin.id) if plugin.id is not None else plugin plugin = db.session.get(cls, plugin.id) if plugin.id is not None else plugin # Custom loading tasks plugin.load() @@ -79,9 +78,9 @@ def load_plugins(app: Flask): logger.info(f"Loaded plugin: {plugin.name}") app.config["FG_PLUGINS"][plugin.name] = plugin + def create_app(test_config=None, cli=False): app = Flask("flaschengeist") - # app.json_encoder = CustomJSONEncoder app.json_provider_class = CustomJSONProvider app.json = CustomJSONProvider(app) CORS(app) From f7c8ae10375cfdc9b0fb78bcc9ef9a141b38516f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 3 May 2023 06:30:42 +0200 Subject: [PATCH 427/446] blacked and add some typings --- flaschengeist/cli/InterfaceGenerator.py | 4 +--- flaschengeist/cli/export_cmd.py | 2 +- flaschengeist/cli/plugin_cmd.py | 1 - flaschengeist/cli/run_cmd.py | 1 - flaschengeist/controller/pluginController.py | 8 ++++++-- flaschengeist/controller/userController.py | 7 +++++-- flaschengeist/database/__init__.py | 5 +++-- flaschengeist/database/types.py | 19 +++++++++++-------- flaschengeist/models/user.py | 6 +++--- flaschengeist/plugins/__init__.py | 7 ++++--- flaschengeist/plugins/balance/__init__.py | 3 ++- .../plugins/balance/balance_controller.py | 1 - flaschengeist/plugins/scheduler.py | 1 + flaschengeist/plugins/users/__init__.py | 6 ++++-- 14 files changed, 41 insertions(+), 30 deletions(-) diff --git a/flaschengeist/cli/InterfaceGenerator.py b/flaschengeist/cli/InterfaceGenerator.py index 3bcb545..e03dde2 100644 --- a/flaschengeist/cli/InterfaceGenerator.py +++ b/flaschengeist/cli/InterfaceGenerator.py @@ -37,7 +37,6 @@ class InterfaceGenerator: if origin is typing.ForwardRef: # isinstance(cls, typing.ForwardRef): return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__ if origin is typing.Union: - if len(arguments) == 2 and arguments[1] is type(None): return "?", self.pytype(arguments[0])[1] else: @@ -81,7 +80,6 @@ class InterfaceGenerator: d = {} for param, ptype in typing.get_type_hints(module[1], globalns=None, localns=None).items(): if not param.startswith("_") and not param.endswith("_"): - d[param] = self.pytype(ptype) if len(d) == 1: @@ -115,7 +113,7 @@ class InterfaceGenerator: return buffer def write(self): - with (open(self.filename, "w") if self.filename else sys.stdout) as file: + with open(self.filename, "w") if self.filename else sys.stdout as file: if self.namespace: file.write(f"declare namespace {self.namespace} {{\n") for line in self._write_types().getvalue().split("\n"): diff --git a/flaschengeist/cli/export_cmd.py b/flaschengeist/cli/export_cmd.py index 4e0fa03..0a611a3 100644 --- a/flaschengeist/cli/export_cmd.py +++ b/flaschengeist/cli/export_cmd.py @@ -9,7 +9,7 @@ from importlib.metadata import entry_points @click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True) def export(namespace, output, no_core, plugin): from flaschengeist import logger, models - from .InterfaceGenerator import InterfaceGenerator + from flaschengeist.cli.InterfaceGenerator import InterfaceGenerator gen = InterfaceGenerator(namespace, output, logger) if not no_core: diff --git a/flaschengeist/cli/plugin_cmd.py b/flaschengeist/cli/plugin_cmd.py index 5356eac..b2a9260 100644 --- a/flaschengeist/cli/plugin_cmd.py +++ b/flaschengeist/cli/plugin_cmd.py @@ -53,7 +53,6 @@ def disable(ctx, plugin): def install(ctx: click.Context, plugin, all): """Install one or more plugins""" all_plugins = entry_points(group="flaschengeist.plugins") - if all: plugins = [ep.name for ep in all_plugins] elif len(plugin) > 0: diff --git a/flaschengeist/cli/run_cmd.py b/flaschengeist/cli/run_cmd.py index ddca7c8..60c93af 100644 --- a/flaschengeist/cli/run_cmd.py +++ b/flaschengeist/cli/run_cmd.py @@ -10,7 +10,6 @@ class PrefixMiddleware(object): self.prefix = prefix def __call__(self, environ, start_response): - if environ["PATH_INFO"].startswith(self.prefix): environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :] environ["SCRIPT_NAME"] = self.prefix diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 486ffea..6325f98 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -23,7 +23,11 @@ __required_plugins = ["users", "roles", "scheduler", "auth"] def get_authentication_provider(): - return [current_app.config["FG_PLUGINS"][plugin.name] for plugin in get_loaded_plugins().values() if isinstance(plugin, AuthPlugin)] + return [ + current_app.config["FG_PLUGINS"][plugin.name] + for plugin in get_loaded_plugins().values() + if isinstance(plugin, AuthPlugin) + ] def get_loaded_plugins(plugin_name: str = None): @@ -108,7 +112,7 @@ def install_plugin(plugin_name: str): directory /= loc if directory.exists(): database_upgrade(revision=f"{plugin_name}@head") - + db.session.commit() return plugin diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 87f67c3..b5e427d 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -2,6 +2,7 @@ import re import secrets from io import BytesIO +from typing import Optional from sqlalchemy import exc from sqlalchemy_utils import merge_references from datetime import datetime, timedelta, timezone @@ -41,15 +42,17 @@ def _generate_password_reset(user): return reset -def get_provider(userid: str): +def get_provider(userid: str) -> AuthPlugin: return [p for p in pluginController.get_authentication_provider() if p.user_exists(userid)][0] @Hook -def update_user(user: User, backend: AuthPlugin): +def update_user(user: User, backend: Optional[AuthPlugin] = None): """Update user data from backend This is seperate function to provide a hook""" + if not backend: + backend = get_provider(user.userid) backend.update_user(user) if not user.display_name: user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) diff --git a/flaschengeist/database/__init__.py b/flaschengeist/database/__init__.py index 66428d0..914db1d 100644 --- a/flaschengeist/database/__init__.py +++ b/flaschengeist/database/__init__.py @@ -6,7 +6,8 @@ from sqlalchemy import MetaData from flaschengeist.alembic import alembic_script_path from flaschengeist import logger -from flaschengeist.controller import pluginController + +# from flaschengeist.controller import pluginController # https://alembic.sqlalchemy.org/en/latest/naming.html metadata = MetaData( @@ -20,7 +21,7 @@ metadata = MetaData( ) -db = SQLAlchemy(metadata=metadata) +db = SQLAlchemy(metadata=metadata, session_options={"expire_on_commit": False}) migrate = Migrate() diff --git a/flaschengeist/database/types.py b/flaschengeist/database/types.py index 645ecdd..0b34a60 100644 --- a/flaschengeist/database/types.py +++ b/flaschengeist/database/types.py @@ -16,13 +16,16 @@ class ModelSerializeMixin: module = import_module("flaschengeist.models").__dict__ - hint = typing.get_type_hints(self.__class__, globalns=module)[param] - if ( - typing.get_origin(hint) is typing.Union - and len(typing.get_args(hint)) == 2 - and typing.get_args(hint)[1] is type(None) - ): - return getattr(self, param) is None + try: + hint = typing.get_type_hints(self.__class__, globalns=module, locals=locals())[param] + if ( + typing.get_origin(hint) is typing.Union + and len(typing.get_args(hint)) == 2 + and typing.get_args(hint)[1] is type(None) + ): + return getattr(self, param) is None + except: + pass def serialize(self): """Serialize class to dict @@ -35,7 +38,7 @@ class ModelSerializeMixin: if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param) } if len(d) == 1: - key, value = d.popitem() + _, value = d.popitem() return value return d diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index a51cedc..74f7950 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -27,7 +27,7 @@ class Permission(db.Model, ModelSerializeMixin): id_ = db.Column("id", Serial, primary_key=True) plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) - plugin_ = db.relationship("Plugin", lazy="select", back_populates="permissions", enable_typechecks=False) + plugin_ = db.relationship("Plugin", lazy="subquery", back_populates="permissions", enable_typechecks=False) class Role(db.Model, ModelSerializeMixin): @@ -62,8 +62,8 @@ class User(db.Model, ModelSerializeMixin): deleted: bool = db.Column(db.Boolean(), default=False) birthday: Optional[date] = db.Column(db.Date) mail: str = db.Column(db.String(60)) - permissions: Optional[list[str]] = None roles: List[str] = [] + permissions: Optional[list[str]] = [] # Protected stuff for backend use only id_ = db.Column("id", Serial, primary_key=True) @@ -81,7 +81,7 @@ class User(db.Model, ModelSerializeMixin): ) @property - def roles(self): + def roles(self) -> List[str]: return [role.name for role in self.roles_] def set_attribute(self, name, value): diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index c9c5cd1..2907166 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -169,14 +169,15 @@ class Plugin(BasePlugin): Args: permissions: List of permissions to install """ - cur_perm = set(x.name for x in self.permissions or []) + cur_perm = set(x for x in self.permissions or []) all_perm = set(permissions) new_perms = all_perm - cur_perm - _perms = [ Permission(name=x, plugin_=self) for x in new_perms ] - #self.permissions = list(filter(lambda x: x.name in permissions, self.permissions and isinstance(self.permissions, list) or [])) + _perms = [Permission(name=x, plugin_=self) for x in new_perms] + # self.permissions = list(filter(lambda x: x.name in permissions, self.permissions and isinstance(self.permissions, list) or [])) self.permissions.extend(_perms) + class AuthPlugin(Plugin): """Base class for all authentification plugins diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 0f923c4..03b5e4a 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -56,7 +56,7 @@ def service_debit(): class BalancePlugin(Plugin): - #id = "dev.flaschengeist.balance" + # id = "dev.flaschengeist.balance" models = models def install(self): @@ -64,6 +64,7 @@ class BalancePlugin(Plugin): def load(self): from .routes import blueprint + self.blueprint = blueprint @plugins_loaded diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index e11b6b7..48a261b 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -147,7 +147,6 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse all = {} for user in users: - all[user.userid] = [user.get_credit(start, end), 0] all[user.userid][1] = user.get_debit(start, end) diff --git a/flaschengeist/plugins/scheduler.py b/flaschengeist/plugins/scheduler.py index 1a31c8a..ea26282 100644 --- a/flaschengeist/plugins/scheduler.py +++ b/flaschengeist/plugins/scheduler.py @@ -61,6 +61,7 @@ class SchedulerPlugin(Plugin): def run_tasks(self): from ..database import db + self = db.session.merge(self) changed = False diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index e819486..af344e2 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -106,7 +106,7 @@ def frontend(userid, current_session): raise Forbidden if request.method == "POST": - if request.content_length > 1024 ** 2: + if request.content_length > 1024**2: raise BadRequest current_session.user_.set_attribute("frontend", request.get_json()) return no_content() @@ -218,7 +218,9 @@ def edit_user(userid, current_session): userController.set_roles(user, roles) userController.modify_user(user, password, new_password) - userController.update_user(user) + userController.update_user( + user, + ) return no_content() From cb0795a6acbce7eac63998d57b615d37060f4377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 3 May 2023 07:46:50 +0200 Subject: [PATCH 428/446] add ua-parser to pares user-agent --- flaschengeist/controller/sessionController.py | 48 ++++--------------- setup.cfg | 1 + 2 files changed, 11 insertions(+), 38 deletions(-) diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py index 56ca32b..afed11e 100644 --- a/flaschengeist/controller/sessionController.py +++ b/flaschengeist/controller/sessionController.py @@ -2,6 +2,7 @@ import secrets from datetime import datetime, timezone from werkzeug.exceptions import Forbidden, Unauthorized +from ua_parser import user_agent_parser from .. import logger from ..models import Session @@ -11,33 +12,8 @@ from ..database import db lifetime = 1800 -def __get_user_agent_platform(ua: str): - if "Win" in ua: - return "windows" - if "Mac" in ua: - return "macintosh" - if "Linux" in ua: - return "linux" - if "Android" in ua: - return "android" - if "like Mac" in ua: - return "ios" - return "unknown" - - -def __get_user_agent_browser(ua: str): - ua_str = ua.lower() - if "firefox" in ua_str or "fxios" in ua_str: - return "firefox" - if "safari" in ua_str: - return "safari" - if "opr/" in ua_str: - return "opera" - if "edg" in ua_str: - return "edge" - if "chrom" in ua_str or "crios" in ua_str: - return "chrome" - return "unknown" +def get_user_agent(request_headers): + return user_agent_parser.Parse(request_headers.get("User-Agent", "") if request_headers else "") def validate_token(token, request_headers, permission): @@ -60,13 +36,9 @@ def validate_token(token, request_headers, permission): session = Session.query.filter_by(token=token).one_or_none() if session: logger.debug("token found, check if expired or invalid user agent differs") - - platform = request_headers.get("Sec-CH-UA-Platform", None) or __get_user_agent_platform( - request_headers.get("User-Agent", "") - ) - browser = request_headers.get("Sec-CH-UA", None) or __get_user_agent_browser( - request_headers.get("User-Agent", "") - ) + user_agent = get_user_agent(request_headers) + platform = user_agent["os"]["family"] + browser = user_agent["user_agent"]["family"] if session.expires >= datetime.now(timezone.utc) and ( session.browser == browser and session.platform == platform @@ -96,14 +68,14 @@ def create(user, request_headers=None) -> Session: """ logger.debug("create access token") token_str = secrets.token_hex(16) + user_agent = get_user_agent(request_headers) + logger.debug(f"platform: {user_agent['os']['family']}, browser: {user_agent['user_agent']['family']}") session = Session( token=token_str, user_=user, lifetime=lifetime, - platform=request_headers.get("Sec-CH-UA-Platform", None) - or __get_user_agent_platform(request_headers.get("User-Agent", "")), - browser=request_headers.get("Sec-CH-UA", None) - or __get_user_agent_browser(request_headers.get("User-Agent", "")), + platform=user_agent["os"]["family"], + browser=user_agent["user_agent"]["family"], ) session.refresh() db.session.add(session) diff --git a/setup.cfg b/setup.cfg index 3a54392..f0a30a9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ install_requires = sqlalchemy >= 2.0 toml werkzeug>=2.2.2 + ua-parser>=0.16.1 [options.extras_require] argon = argon2-cffi From 11204662be80ea2dee37d3b6e72d13eee79547ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 3 May 2023 14:03:59 +0200 Subject: [PATCH 429/446] (balance) add filter to search user --- .../plugins/balance/balance_controller.py | 16 ++++++++++++++-- flaschengeist/plugins/balance/routes.py | 7 ++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 48a261b..0d9093b 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -3,13 +3,14 @@ # English: Debit -> from account # Credit -> to account from enum import IntEnum -from sqlalchemy import func, case, and_ +from sqlalchemy import func, case, and_, or_ from sqlalchemy.ext.hybrid import hybrid_property from datetime import datetime from werkzeug.exceptions import BadRequest, NotFound, Conflict from flaschengeist.database import db from flaschengeist.models.user import User, _UserAttribute +from flaschengeist.app import logger from .models import Transaction from . import permissions, BalancePlugin @@ -44,7 +45,13 @@ def get_balance(user, start: datetime = None, end: datetime = None): return credit, debit, credit - debit -def get_balances(start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None): +def get_balances( + start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None, _filter=None +): + logger.debug( + f"get_balances(start={start}, end={end}, limit={limit}, offset={offset}, descending={descending}, sortBy={sortBy}, _filter={_filter})" + ) + class _User(User): _debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id]) _credit = db.relationship(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id]) @@ -116,6 +123,11 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse q2 = query.join(_User._debit).filter(Transaction.time <= end) query = q1.union(q2) + if _filter: + query = query.filter( + or_(_User.firstname.ilike(f"%{_filter.lower()}%"), _User.lastname.ilike(f"%{_filter.lower()}%")) + ) + if sortBy == "balance": if descending: query = query.order_by((_User.credit - _User.debit).desc(), _User.lastname.asc(), _User.firstname.asc()) diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py index f0edc62..8ba1efb 100644 --- a/flaschengeist/plugins/balance/routes.py +++ b/flaschengeist/plugins/balance/routes.py @@ -7,6 +7,7 @@ from flaschengeist.models.session import Session from flaschengeist.utils.datetime import from_iso_format from flaschengeist.utils.decorators import login_required from flaschengeist.controller import userController +from flaschengeist.app import logger from . import BalancePlugin, balance_controller, permissions @@ -317,7 +318,11 @@ def get_balances(current_session: Session): offset = request.args.get("offset", type=int) descending = request.args.get("descending", False, type=bool) sortBy = request.args.get("sortBy", type=str) - balances, count = balance_controller.get_balances(limit=limit, offset=offset, descending=descending, sortBy=sortBy) + _filter = request.args.get("filter", None, type=str) + logger.debug(f"request.args: {request.args}") + balances, count = balance_controller.get_balances( + limit=limit, offset=offset, descending=descending, sortBy=sortBy, _filter=_filter + ) return jsonify( { "balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()], From 7eb30b662f1f14e48433ec88a2f2fe0e680ec3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 5 May 2023 10:00:08 +0200 Subject: [PATCH 430/446] fix mail-plugin this fix load config the right way. now you can install mail-plugin with ```flaschengeist plugin install mail && flaschengeist plugin enable mail``` --- flaschengeist/plugins/message_mail.py | 34 ++++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index acc00de..978ab0e 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -1,6 +1,7 @@ import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from werkzeug.exceptions import InternalServerError from flaschengeist import logger from flaschengeist.models import User @@ -8,23 +9,29 @@ from flaschengeist.plugins import Plugin from flaschengeist.utils.hook import HookAfter from flaschengeist.controller import userController from flaschengeist.controller.messageController import Message +from flaschengeist.config import config class MailMessagePlugin(Plugin): - def __init__(self, entry_point, config): - super().__init__(entry_point, config) - self.server = config["SERVER"] - self.port = config["PORT"] - self.user = config["USER"] - self.password = config["PASSWORD"] - self.crypt = config["CRYPT"] - self.mail = config["MAIL"] + def load(self): + self.config = config.get("mail", None) + if self.config is None: + logger.error("mail was not configured in flaschengeist.toml") + raise InternalServerError + self.server = self.config["SERVER"] + self.port = self.config["PORT"] + self.user = self.config["USER"] + self.password = self.config["PASSWORD"] + self.crypt = self.config["CRYPT"] + self.mail = self.config["MAIL"] @HookAfter("send_message") - def dummy_send(msg): + def dummy_send(msg, *args, **kwargs): + logger.info(f"(dummy_send) Sending message to {msg.receiver}") self.send_mail(msg) def send_mail(self, msg: Message): + logger.debug(f"Sending mail to {msg.receiver}") if isinstance(msg.receiver, User): if not msg.receiver.mail: logger.warning("Could not send Mail, mail missing: {}".format(msg.receiver)) @@ -34,8 +41,13 @@ class MailMessagePlugin(Plugin): recipients = userController.get_user_by_role(msg.receiver) mail = MIMEMultipart() - mail["From"] = self.mail - mail["To"] = ", ".join(recipients) + try: + mail["From"] = self.mail + mail["To"] = ", ".join(recipients) + except Exception as e: + import traceback + + print(traceback.format_exc()) mail["Subject"] = msg.subject mail.attach(MIMEText(msg.message)) if not hasattr(self, "smtp"): From 193ffeff9ded6235762da1b49fcf6713b4c74984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 5 May 2023 10:00:49 +0200 Subject: [PATCH 431/446] fix reset password wrong method in userController was executed to get user --- flaschengeist/plugins/auth/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 7f06302..9d0b073 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -165,7 +165,7 @@ def get_assocd_user(token, current_session, **kwargs): def reset_password(): data = request.get_json() if "userid" in data: - user = userController.find_user(data["userid"]) + user = userController.get_user(data["userid"]) if user: userController.request_reset(user) elif "password" in data and "token" in data: From ae583a6d18b33ed915a7ec1fee629cca337f29da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 9 May 2023 21:16:17 +0200 Subject: [PATCH 432/446] add black to pyproject.toml --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9787c3b..3fec353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 120 From 8b15a4590295d614b071acdb67a7e6b71fc9afa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 9 May 2023 21:23:47 +0200 Subject: [PATCH 433/446] add docker cmd, more debug, add migrations to package --- flaschengeist/cli/__init__.py | 2 + flaschengeist/cli/docker_cmd.py | 54 ++++++++++++++++++++ flaschengeist/controller/pluginController.py | 3 ++ setup.cfg | 4 +- 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 flaschengeist/cli/docker_cmd.py diff --git a/flaschengeist/cli/__init__.py b/flaschengeist/cli/__init__.py index 49e3333..13cded8 100644 --- a/flaschengeist/cli/__init__.py +++ b/flaschengeist/cli/__init__.py @@ -89,6 +89,7 @@ def main(*args, **kwargs): from .docs_cmd import docs from .run_cmd import run from .install_cmd import install + from .docker_cmd import docker # Override logging level environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX)) @@ -98,4 +99,5 @@ def main(*args, **kwargs): cli.add_command(install) cli.add_command(plugin) cli.add_command(run) + cli.add_command(docker) cli(*args, **kwargs) diff --git a/flaschengeist/cli/docker_cmd.py b/flaschengeist/cli/docker_cmd.py new file mode 100644 index 0000000..5924b9e --- /dev/null +++ b/flaschengeist/cli/docker_cmd.py @@ -0,0 +1,54 @@ +import click +from click.decorators import pass_context +from flask.cli import with_appcontext +from os import environ + +from flaschengeist import logger +from flaschengeist.controller import pluginController +from werkzeug.exceptions import NotFound +import traceback + + +@click.group() +def docker(): + pass + + +@docker.command() +@with_appcontext +@pass_context +def setup(ctx): + """Setup flaschengesit in docker container""" + click.echo("Setup docker") + + plugins = environ.get("FG_ENABLE_PLUGINS") + + if not plugins: + click.secho("no evironment variable is set for 'FG_ENABLE_PLUGINS'", fg="yellow") + click.secho("set 'FG_ENABLE_PLUGINS' to 'auth_ldap', 'mail', 'balance', 'pricelist_old', 'events'") + plugins = ("auth_ldap", "mail", "pricelist_old", "events", "balance") + else: + plugins = plugins.split(" ") + + print(plugins) + + for name in plugins: + click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False) + try: + pluginController.install_plugin(name) + except Exception as e: + click.secho(" failed", fg="red") + if logger.getEffectiveLevel() > 10: + ctx.fail(f"[{e.__class__.__name__}] {e}") + else: + ctx.fail(traceback.format_exc()) + else: + click.secho(" ok", fg="green") + + for name in plugins: + click.echo(f"Enabling {name}{'.'*(20-len(name))}", nl=False) + try: + pluginController.enable_plugin(name) + click.secho(" ok", fg="green") + except NotFound: + click.secho(" not installed / not found", fg="red") diff --git a/flaschengeist/controller/pluginController.py b/flaschengeist/controller/pluginController.py index 6325f98..6f3bff6 100644 --- a/flaschengeist/controller/pluginController.py +++ b/flaschengeist/controller/pluginController.py @@ -108,9 +108,12 @@ def install_plugin(plugin_name: str): plugin.install() # Check migrations directory = entry_point[0].dist.locate_file("") + logger.debug(f"Checking for migrations in {directory}") for loc in entry_point[0].module.split(".") + ["migrations"]: directory /= loc + logger.debug(f"Checking for migrations with loc in {directory}") if directory.exists(): + logger.debug(f"Found migrations in {directory}") database_upgrade(revision=f"{plugin_name}@head") db.session.commit() return plugin diff --git a/setup.cfg b/setup.cfg index f0a30a9..fe404cd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] license = MIT -version = 2.0.0.dev0 +version = 2.0.0.dev1 name = flaschengeist author = Tim Gröger author_email = flaschengeist@wu5.de @@ -45,7 +45,7 @@ mysql = mysqlclient;platform_system!='Windows' [options.package_data] -* = *.toml, script.py.mako +* = *.toml, script.py.mako, *.ini, */migrations/*, migrations/versions/* [options.entry_points] console_scripts = From d8028c46812e7045706f6c59966095cc54e0c09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 9 May 2023 21:25:19 +0200 Subject: [PATCH 434/446] fixed timeout in mailing #30 --- flaschengeist/plugins/message_mail.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index 978ab0e..ecaa042 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -31,7 +31,7 @@ class MailMessagePlugin(Plugin): self.send_mail(msg) def send_mail(self, msg: Message): - logger.debug(f"Sending mail to {msg.receiver}") + logger.debug(f"Sending mail to {msg.receiver} with subject {msg.subject}") if isinstance(msg.receiver, User): if not msg.receiver.mail: logger.warning("Could not send Mail, mail missing: {}".format(msg.receiver)) @@ -41,18 +41,12 @@ class MailMessagePlugin(Plugin): recipients = userController.get_user_by_role(msg.receiver) mail = MIMEMultipart() - try: - mail["From"] = self.mail - mail["To"] = ", ".join(recipients) - except Exception as e: - import traceback - - print(traceback.format_exc()) + mail["From"] = self.mail + mail["To"] = ", ".join(recipients) mail["Subject"] = msg.subject mail.attach(MIMEText(msg.message)) - if not hasattr(self, "smtp"): - self.__connect() - self.smtp.sendmail(self.mail, recipients, mail.as_string()) + with self.__connect() as smtp: + smtp.sendmail(self.mail, recipients, mail.as_string()) def __connect(self): if self.crypt == "SSL": @@ -63,3 +57,4 @@ class MailMessagePlugin(Plugin): else: raise ValueError("Invalid CRYPT given") self.smtp.login(self.user, self.password) + return self.smtp From 5bab4a7cdeefd312d039bd5ad6a08b22a6fe45f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 9 May 2023 21:59:00 +0200 Subject: [PATCH 435/446] fix update ldap, no none types pushed, add more debugging --- flaschengeist/plugins/auth_ldap/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 8a99284..db2823a 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -126,9 +126,12 @@ class AuthLDAP(AuthPlugin): def modify_user(self, user: User, password=None, new_password=None): try: dn = user.get_attribute("DN") + logger.debug(f"LDAP: modify_user for user {user.userid} with dn {dn}") if password: + logger.debug(f"LDAP: modify_user for user {user.userid} with password") ldap_conn = self.ldap.connect(dn, password) else: + logger.debug(f"LDAP: modify_user for user {user.userid} with root_dn") if self.root_dn is None: logger.error("root_dn missing in ldap config!") raise InternalServerError @@ -141,9 +144,15 @@ class AuthLDAP(AuthPlugin): ("display_name", "displayName"), ]: if hasattr(user, name): - modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] + attribute = getattr(user, name) + if attribute: + modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] if new_password: modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])] + if "userPassword" in modifier: + logger.debug(f"LDAP: modify_user for user {user.userid} with password change (can't show >modifier<)") + else: + logger.debug(f"LDAP: modify_user for user {user.userid} with modifier {modifier}") ldap_conn.modify(dn, modifier) self._set_roles(user) except (LDAPPasswordIsMandatoryError, LDAPBindError): From d7428b2ed1ba265b750561b3bfc80b19d629ca11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 9 May 2023 21:59:15 +0200 Subject: [PATCH 436/446] fix add role to user --- flaschengeist/plugins/users/cli.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/flaschengeist/plugins/users/cli.py b/flaschengeist/plugins/users/cli.py index 5e69d91..8f8589a 100644 --- a/flaschengeist/plugins/users/cli.py +++ b/flaschengeist/plugins/users/cli.py @@ -3,6 +3,7 @@ import sqlalchemy.exc from flask.cli import with_appcontext from werkzeug.exceptions import NotFound +from flaschengeist import logger from flaschengeist.database import db from flaschengeist.controller import roleController, userController @@ -70,12 +71,19 @@ def user(add_role, delete, user): if USER_KEY in ctx.meta: userController.register(ctx.meta[USER_KEY], ctx.meta[USER_KEY]["password"]) else: + if not isinstance(user, list) or not isinstance(user, tuple): + user = [user] for uid in user: + logger.debug(f"Userid: {uid}") user = userController.get_user(uid) + logger.debug(f"User: {user}") if delete: + logger.debug(f"Deleting user {user}") userController.delete_user(user) elif add_role: + logger.debug(f"Adding role {add_role} to user {user}") role = roleController.get(add_role) + logger.debug(f"Role: {role}") user.roles_.append(role) userController.modify_user(user, None) db.session.commit() From 9077c9fd118eb48e75a04924916b8c662eff87b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 10 May 2023 01:12:41 +0200 Subject: [PATCH 437/446] (balance) fix notifications if only author and sender oder receiver exists, create special notifications --- flaschengeist/plugins/balance/__init__.py | 5 ++ .../plugins/balance/balance_controller.py | 49 ++++++++++++++----- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 03b5e4a..31fdfc3 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -5,6 +5,7 @@ Extends users plugin with balance functions from flask import current_app from werkzeug.exceptions import NotFound +from werkzeug.local import LocalProxy from flaschengeist import logger from flaschengeist.config import config @@ -82,3 +83,7 @@ class BalancePlugin(Plugin): balance_controller.set_limit(user, limit, override=False) except KeyError: pass + + @staticmethod + def getPlugin() -> LocalProxy["BalancePlugin"]: + return LocalProxy(lambda: current_app.config["FG_PLUGINS"]["balance"]) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 0d9093b..797b039 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -21,6 +21,8 @@ __attribute_limit = "balance_limit" class NotifyType(IntEnum): SEND_TO = 0x01 SEND_FROM = 0x02 + ADD_FROM = 0x03 + SUB_FROM = 0x04 def set_limit(user: User, limit: float, override=True): @@ -178,6 +180,7 @@ def send(sender: User, receiver, amount: float, author: User): Raises: BadRequest if amount <= 0 """ + logger.debug(f"send(sender={sender}, receiver={receiver}, amount={amount}, author={author})") if amount <= 0: raise BadRequest @@ -191,20 +194,40 @@ def send(sender: User, receiver, amount: float, author: User): db.session.add(transaction) db.session.commit() if sender is not None and sender.id_ != author.id_: - BalancePlugin.plugin.notify( - sender, - "Neue Transaktion", - { - "type": NotifyType.SEND_FROM, - "receiver_id": receiver.userid, - "author_id": author.userid, - "amount": amount, - }, - ) + if receiver is not None: + BalancePlugin.getPlugin().notify( + sender, + "Neue Transaktion", + { + "type": NotifyType.SEND_FROM, + "receiver_id": receiver.userid, + "author_id": author.userid, + "amount": amount, + }, + ) + else: + BalancePlugin.getPlugin().notify( + sender, + "Neue Transaktion", + {"type": NotifyType.SUB_FROM, "author_id": author.userid, "amount": amount}, + ) if receiver is not None and receiver.id_ != author.id_: - BalancePlugin.plugin.notify( - receiver, "Neue Transaktion", {"type": NotifyType.SEND_TO, "sender_id": sender.userid, "amount": amount} - ) + if sender is not None: + BalancePlugin.getPlugin().notify( + receiver, + "Neue Transaktion", + { + "type": NotifyType.SEND_TO, + "sender_id": sender.userid, + "amount": amount, + }, + ) + else: + BalancePlugin.getPlugin().notify( + receiver, + "Neue Transaktion", + {"type": NotifyType.ADD_FROM, "author_id": author.userid, "amount": amount}, + ) return transaction From 4be7cccadb6d87142c75aaec20fd656d5fe0b4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 12 May 2023 17:11:18 +0200 Subject: [PATCH 438/446] (auth_ldap) add get_last_modified from provider --- flaschengeist/controller/userController.py | 9 +++++++-- flaschengeist/plugins/__init__.py | 10 ++++++++++ flaschengeist/plugins/auth_ldap/__init__.py | 13 +++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index b5e427d..9d8b2ac 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -2,7 +2,7 @@ import re import secrets from io import BytesIO -from typing import Optional +from typing import Optional, Union from sqlalchemy import exc from sqlalchemy_utils import merge_references from datetime import datetime, timedelta, timezone @@ -262,7 +262,12 @@ def register(data, passwd=None): return user -def load_avatar(user: User): +def get_last_modified(user: User): + """Get the last modification date of the user""" + return get_provider(user.userid).get_last_modified(user) + + +def load_avatar(user: User, etag: Union[str, None] = None): if user.avatar_ is not None: return imageController.send_image(image=user.avatar_) else: diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 2907166..23d3783 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -248,6 +248,16 @@ class AuthPlugin(Plugin): """ raise NotImplementedError + def get_modified_time(self, user): + """If backend is using external data, then return the timestamp of the last modification + + Args: + user: User object + Returns: + Timestamp of last modification + """ + pass + def get_avatar(self, user) -> _Avatar: """Retrieve avatar for given user (if supported by auth backend) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index db2823a..4574929 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -10,6 +10,8 @@ from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from werkzeug.datastructures import FileStorage +from datetime import datetime + from flaschengeist import logger from flaschengeist.config import config from flaschengeist.controller import userController @@ -158,6 +160,17 @@ class AuthLDAP(AuthPlugin): except (LDAPPasswordIsMandatoryError, LDAPBindError): raise BadRequest + def get_modified_time(self, user): + self.ldap.connection.search( + self.search_dn, + "(uid={})".format(user.userid), + SUBTREE, + attributes=["modifyTimestamp"], + ) + r = self.ldap.connection.response[0]["attributes"] + modified_time = r["modifyTimestamp"][0] + return datetime.strptime(modified_time, "%Y%m%d%H%M%SZ") + def get_avatar(self, user): self.ldap.connection.search( self.search_dn, From 319889ee43b264a923cf776ce4134636d056da36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Fri, 12 May 2023 17:12:36 +0200 Subject: [PATCH 439/446] (user) better avatar cache-control etag is added to header, If etag is the same a not modified will be respond --- flaschengeist/controller/userController.py | 8 +++++++- flaschengeist/plugins/users/__init__.py | 10 +++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index 9d8b2ac..a94c022 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -1,8 +1,11 @@ import re import secrets +import hashlib from io import BytesIO from typing import Optional, Union +from flask import make_response +from flask.json import provider from sqlalchemy import exc from sqlalchemy_utils import merge_references from datetime import datetime, timedelta, timezone @@ -273,8 +276,11 @@ def load_avatar(user: User, etag: Union[str, None] = None): else: provider = get_provider(user.userid) avatar = provider.get_avatar(user) + new_etag = hashlib.md5(avatar.binary).hexdigest() + if new_etag == etag: + return make_response("", 304) if len(avatar.binary) > 0: - return send_file(BytesIO(avatar.binary), avatar.mimetype) + return send_file(BytesIO(avatar.binary), avatar.mimetype, etag=new_etag) raise NotFound diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index af344e2..23dac59 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -3,8 +3,9 @@ Provides routes used to manage users """ from http.client import CREATED -from flask import Blueprint, request, jsonify, make_response +from flask import Blueprint, request, jsonify, make_response, after_this_request, Response from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed +from datetime import datetime from . import permissions from flaschengeist import logger @@ -118,10 +119,13 @@ def frontend(userid, current_session): @UsersPlugin.blueprint.route("/users//avatar", methods=["GET"]) -@headers({"Cache-Control": "public, max-age=604800"}) +@headers({"Cache-Control": "public, must-revalidate, max-age=10"}) def get_avatar(userid): + etag = None + if "If-None-Match" in request.headers: + etag = request.headers["If-None-Match"] user = userController.get_user(userid) - return userController.load_avatar(user) + return userController.load_avatar(user, etag) @UsersPlugin.blueprint.route("/users//avatar", methods=["POST"]) From b40d40644d8eab5f4f8eb678810a550565a7d69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Mon, 15 May 2023 23:52:49 +0200 Subject: [PATCH 440/446] if birthday is date then take it otherwise parse from string; prettier --- flaschengeist/controller/userController.py | 25 +++++++++++++++++----- flaschengeist/models/user.py | 6 ++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index a94c022..a59bbce 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -8,7 +8,7 @@ from flask import make_response from flask.json import provider from sqlalchemy import exc from sqlalchemy_utils import merge_references -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, date from flask.helpers import send_file from werkzeug.exceptions import NotFound, BadRequest, Forbidden @@ -19,7 +19,12 @@ from ..models import Notification, User, Role from ..models.user import _PasswordReset from ..utils.hook import Hook from ..utils.datetime import from_iso_format -from ..controller import imageController, messageController, pluginController, sessionController +from ..controller import ( + imageController, + messageController, + pluginController, + sessionController, +) from ..plugins import AuthPlugin @@ -198,7 +203,11 @@ def delete_user(user: User): deleted_user = get_user("__deleted_user__", True) except NotFound: deleted_user = User( - userid="__deleted_user__", firstname="USER", lastname="DELETED", display_name="DELETED USER", deleted=True + userid="__deleted_user__", + firstname="USER", + lastname="DELETED", + display_name="DELETED USER", + deleted=True, ) db.session.add(user) db.session.flush() @@ -209,7 +218,10 @@ def delete_user(user: User): db.session.delete(user) db.session.commit() except exc.IntegrityError: - logger.error("Delete of user failed, there might be ForeignKey contraits from disabled plugins", exec_info=True) + logger.error( + "Delete of user failed, there might be ForeignKey contraits from disabled plugins", + exec_info=True, + ) # Remove at least all personal data user.userid = f"__deleted_user__{user.id_}" user.display_name = "DELETED USER" @@ -231,7 +243,10 @@ def register(data, passwd=None): values = {key: value for key, value in data.items() if key in allowed_keys} roles = values.pop("roles", []) if "birthday" in data: - values["birthday"] = from_iso_format(data["birthday"]).date() + if isinstance(data["birthday"], date): + values["birthday"] = data["birthday"] + else: + values["birthday"] = from_iso_format(data["birthday"]).date() if "mail" in data and not re.match(r"[^@]+@[^@]+\.[^@]+", data["mail"]): raise BadRequest("Invalid mail given") user = User(**values) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 74f7950..21e9604 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -1,4 +1,6 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) +from __future__ import ( + annotations, +) # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) from typing import Optional, Union, List from datetime import date, datetime @@ -45,7 +47,7 @@ class User(db.Model, ModelSerializeMixin): Attributes: id: Id in Database as Primary Key. - uid: User ID used by authentication provider + userid: User ID used by authentication provider display_name: Name to show firstname: Firstname of the User lastname: Lastname of the User From cab172dc65a71070f06af6866bc6ec8ca2887b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 17 May 2023 14:47:40 +0200 Subject: [PATCH 441/446] fix floor transaction with value which has more ziffers than scale #33 --- .../plugins/balance/balance_controller.py | 50 ++++++++++++++++--- flaschengeist/plugins/balance/models.py | 16 +++++- flaschengeist/plugins/balance/routes.py | 9 +++- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 797b039..5201d76 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -48,7 +48,13 @@ def get_balance(user, start: datetime = None, end: datetime = None): def get_balances( - start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None, _filter=None + start: datetime = None, + end: datetime = None, + limit=None, + offset=None, + descending=None, + sortBy=None, + _filter=None, ): logger.debug( f"get_balances(start={start}, end={end}, limit={limit}, offset={offset}, descending={descending}, sortBy={sortBy}, _filter={_filter})" @@ -56,7 +62,11 @@ def get_balances( class _User(User): _debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id]) - _credit = db.relationship(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id]) + _credit = db.relationship( + Transaction, + back_populates="receiver_", + foreign_keys=[Transaction._receiver_id], + ) @hybrid_property def debit(self): @@ -92,7 +102,12 @@ def get_balances( def limit(cls): return ( db.select(_UserAttribute.value) - .where(and_(cls.id_ == _UserAttribute.user, _UserAttribute.name == "balance_limit")) + .where( + and_( + cls.id_ == _UserAttribute.user, + _UserAttribute.name == "balance_limit", + ) + ) .scalar_subquery() ) @@ -127,14 +142,25 @@ def get_balances( if _filter: query = query.filter( - or_(_User.firstname.ilike(f"%{_filter.lower()}%"), _User.lastname.ilike(f"%{_filter.lower()}%")) + or_( + _User.firstname.ilike(f"%{_filter.lower()}%"), + _User.lastname.ilike(f"%{_filter.lower()}%"), + ) ) if sortBy == "balance": if descending: - query = query.order_by((_User.credit - _User.debit).desc(), _User.lastname.asc(), _User.firstname.asc()) + query = query.order_by( + (_User.credit - _User.debit).desc(), + _User.lastname.asc(), + _User.firstname.asc(), + ) else: - query = query.order_by((_User.credit - _User.debit).asc(), _User.lastname.asc(), _User.firstname.asc()) + query = query.order_by( + (_User.credit - _User.debit).asc(), + _User.lastname.asc(), + _User.firstname.asc(), + ) elif sortBy == "limit": if descending: query = query.order_by(_User.limit.desc(), User.lastname.asc(), User.firstname.asc()) @@ -209,7 +235,11 @@ def send(sender: User, receiver, amount: float, author: User): BalancePlugin.getPlugin().notify( sender, "Neue Transaktion", - {"type": NotifyType.SUB_FROM, "author_id": author.userid, "amount": amount}, + { + "type": NotifyType.SUB_FROM, + "author_id": author.userid, + "amount": amount, + }, ) if receiver is not None and receiver.id_ != author.id_: if sender is not None: @@ -226,7 +256,11 @@ def send(sender: User, receiver, amount: float, author: User): BalancePlugin.getPlugin().notify( receiver, "Neue Transaktion", - {"type": NotifyType.ADD_FROM, "author_id": author.userid, "amount": amount}, + { + "type": NotifyType.ADD_FROM, + "author_id": author.userid, + "amount": amount, + }, ) return transaction diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 2d9525b..fa51f7d 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -1,7 +1,9 @@ from datetime import datetime from typing import Optional from sqlalchemy.ext.hybrid import hybrid_property +from math import floor +from flaschengeist import logger from flaschengeist.database import db from flaschengeist.models.user import User from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial @@ -18,8 +20,9 @@ class Transaction(db.Model, ModelSerializeMixin): # Public and exported member id: int = db.Column("id", Serial, primary_key=True) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) - amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) + _amount: float = db.Column("amount", db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) reversal_id: Optional[int] = db.Column(Serial, db.ForeignKey("balance_transaction.id")) + amount: float # Dummy properties used for JSON serialization (userid instead of full user) author_id: Optional[str] = None @@ -56,3 +59,14 @@ class Transaction(db.Model, ModelSerializeMixin): @property def original_id(self): return self.original_.id if self.original_ else None + + @property + def amount(self): + return self._amount + + @amount.setter + def amount(self, value): + self._amount = floor(value * 100) / 100 + + def __repr__(self): + return f"" diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py index 8ba1efb..0e14a81 100644 --- a/flaschengeist/plugins/balance/routes.py +++ b/flaschengeist/plugins/balance/routes.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from logging import log from werkzeug.exceptions import Forbidden, BadRequest from flask import Blueprint, request, jsonify @@ -163,6 +164,7 @@ def get_balance(userid, current_session: Session): end = datetime.now(tz=timezone.utc) balance = balance_controller.get_balance(user, start, end) + logger.debug(f"Balance of {user.userid} from {start} to {end}: {balance}") return {"credit": balance[0], "debit": balance[1], "balance": balance[2]} @@ -224,6 +226,7 @@ def get_transactions(userid, current_session: Session): show_cancelled=show_cancelled, descending=descending, ) + logger.debug(f"transactions: {transactions}") return {"transactions": transactions, "count": count} @@ -321,7 +324,11 @@ def get_balances(current_session: Session): _filter = request.args.get("filter", None, type=str) logger.debug(f"request.args: {request.args}") balances, count = balance_controller.get_balances( - limit=limit, offset=offset, descending=descending, sortBy=sortBy, _filter=_filter + limit=limit, + offset=offset, + descending=descending, + sortBy=sortBy, + _filter=_filter, ) return jsonify( { From bddb11d1b4373691792554ccbbb8a099a9a59407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 16 Jan 2024 19:49:56 +0100 Subject: [PATCH 442/446] update version to 2.0.0 --- .../migrations/98f2733bbe45_balance_initial.py | 1 + .../9d5bf89eb130_init_old_pricelist.py | 1 + .../migrations/e70508bd8cb4_init_events.py | 1 + flaschengeist/plugins/auth_ldap/cli.py | 18 +++++++++++++++--- setup.cfg | 5 +++-- 5 files changed, 21 insertions(+), 5 deletions(-) create mode 120000 flaschengeist/alembic/migrations/98f2733bbe45_balance_initial.py create mode 120000 flaschengeist/alembic/migrations/9d5bf89eb130_init_old_pricelist.py create mode 120000 flaschengeist/alembic/migrations/e70508bd8cb4_init_events.py diff --git a/flaschengeist/alembic/migrations/98f2733bbe45_balance_initial.py b/flaschengeist/alembic/migrations/98f2733bbe45_balance_initial.py new file mode 120000 index 0000000..5b2a4df --- /dev/null +++ b/flaschengeist/alembic/migrations/98f2733bbe45_balance_initial.py @@ -0,0 +1 @@ +/home/crimsen/git/flaschengeist/flaschengeist/flaschengeist/plugins/balance/migrations/98f2733bbe45_balance_initial.py \ No newline at end of file diff --git a/flaschengeist/alembic/migrations/9d5bf89eb130_init_old_pricelist.py b/flaschengeist/alembic/migrations/9d5bf89eb130_init_old_pricelist.py new file mode 120000 index 0000000..713ecd1 --- /dev/null +++ b/flaschengeist/alembic/migrations/9d5bf89eb130_init_old_pricelist.py @@ -0,0 +1 @@ +/home/crimsen/git/flaschengeist/flaschengeist-pricelist-old/backend/flaschengeist_pricelist_old/migrations/9d5bf89eb130_init_old_pricelist.py \ No newline at end of file diff --git a/flaschengeist/alembic/migrations/e70508bd8cb4_init_events.py b/flaschengeist/alembic/migrations/e70508bd8cb4_init_events.py new file mode 120000 index 0000000..7bbfcb3 --- /dev/null +++ b/flaschengeist/alembic/migrations/e70508bd8cb4_init_events.py @@ -0,0 +1 @@ +/home/crimsen/git/flaschengeist/flaschengeist-schedule/backend/flaschengeist_events/migrations/e70508bd8cb4_init_events.py \ No newline at end of file diff --git a/flaschengeist/plugins/auth_ldap/cli.py b/flaschengeist/plugins/auth_ldap/cli.py index e3d772f..e2c82aa 100644 --- a/flaschengeist/plugins/auth_ldap/cli.py +++ b/flaschengeist/plugins/auth_ldap/cli.py @@ -6,13 +6,15 @@ from werkzeug.exceptions import NotFound @click.command(no_args_is_help=True) @click.option("--sync", is_flag=True, default=False, help="Synchronize users from LDAP -> database") +@click.option("--sync-ldap", is_flag=True, default=False, help="Synchronize users from database -> LDAP") @with_appcontext @click.pass_context -def ldap(ctx, sync): +def ldap(ctx, sync, sync_ldap): """Tools for the LDAP authentification""" + from flaschengeist.controller import userController + from flaschengeist.plugins.auth_ldap import AuthLDAP if sync: - from flaschengeist.controller import userController - from flaschengeist.plugins.auth_ldap import AuthLDAP + click.echo("Synchronizing users from LDAP -> database") from ldap3 import SUBTREE from flaschengeist.models import User from flaschengeist.database import db @@ -33,3 +35,13 @@ def ldap(ctx, sync): user = User(userid=uid) db.session.add(user) userController.update_user(user, auth_ldap) + if sync_ldap: + click.echo("Synchronizing users from database -> LDAP") + + auth_ldap: AuthLDAP = current_app.config.get("FG_PLUGINS").get("auth_ldap") + if auth_ldap is None or not isinstance(auth_ldap, AuthLDAP): + ctx.fail("auth_ldap plugin not found or not enabled!") + users = userController.get_users() + for user in users: + userController.update_user(user, auth_ldap) + diff --git a/setup.cfg b/setup.cfg index fe404cd..9800361 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] license = MIT -version = 2.0.0.dev1 +version = 2.0.0 name = flaschengeist author = Tim Gröger author_email = flaschengeist@wu5.de @@ -22,7 +22,8 @@ include_package_data = True python_requires = >=3.10 packages = find: install_requires = - Flask>=2.2.2, <2.3 + #Flask>=2.2.2, <2.3 + Flask>=2.2.2 Pillow>=9.2 flask_cors flask_migrate>=3.1.0 From 645e2865a6413521f318407b7bde0134d60f7e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 16 Jan 2024 22:34:35 +0100 Subject: [PATCH 443/446] update dependencies --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 9800361..0fe5f0f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,7 +23,7 @@ python_requires = >=3.10 packages = find: install_requires = #Flask>=2.2.2, <2.3 - Flask>=2.2.2 + Flask>=2.2.2, <3.0 Pillow>=9.2 flask_cors flask_migrate>=3.1.0 From 0ae334620bdd0d96a367fe6e28e99438ec8ec79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Tue, 16 Jan 2024 22:43:49 +0100 Subject: [PATCH 444/446] update dependencies --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 0fe5f0f..568c20e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,7 +23,7 @@ python_requires = >=3.10 packages = find: install_requires = #Flask>=2.2.2, <2.3 - Flask>=2.2.2, <3.0 + Flask>=2.2.2, <2.9 Pillow>=9.2 flask_cors flask_migrate>=3.1.0 From 001ef13014eb49cda0b9f14d058b81cf130902e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 17 Jan 2024 00:20:40 +0100 Subject: [PATCH 445/446] remove links --- flaschengeist/alembic/migrations/98f2733bbe45_balance_initial.py | 1 - .../alembic/migrations/9d5bf89eb130_init_old_pricelist.py | 1 - flaschengeist/alembic/migrations/e70508bd8cb4_init_events.py | 1 - 3 files changed, 3 deletions(-) delete mode 120000 flaschengeist/alembic/migrations/98f2733bbe45_balance_initial.py delete mode 120000 flaschengeist/alembic/migrations/9d5bf89eb130_init_old_pricelist.py delete mode 120000 flaschengeist/alembic/migrations/e70508bd8cb4_init_events.py diff --git a/flaschengeist/alembic/migrations/98f2733bbe45_balance_initial.py b/flaschengeist/alembic/migrations/98f2733bbe45_balance_initial.py deleted file mode 120000 index 5b2a4df..0000000 --- a/flaschengeist/alembic/migrations/98f2733bbe45_balance_initial.py +++ /dev/null @@ -1 +0,0 @@ -/home/crimsen/git/flaschengeist/flaschengeist/flaschengeist/plugins/balance/migrations/98f2733bbe45_balance_initial.py \ No newline at end of file diff --git a/flaschengeist/alembic/migrations/9d5bf89eb130_init_old_pricelist.py b/flaschengeist/alembic/migrations/9d5bf89eb130_init_old_pricelist.py deleted file mode 120000 index 713ecd1..0000000 --- a/flaschengeist/alembic/migrations/9d5bf89eb130_init_old_pricelist.py +++ /dev/null @@ -1 +0,0 @@ -/home/crimsen/git/flaschengeist/flaschengeist-pricelist-old/backend/flaschengeist_pricelist_old/migrations/9d5bf89eb130_init_old_pricelist.py \ No newline at end of file diff --git a/flaschengeist/alembic/migrations/e70508bd8cb4_init_events.py b/flaschengeist/alembic/migrations/e70508bd8cb4_init_events.py deleted file mode 120000 index 7bbfcb3..0000000 --- a/flaschengeist/alembic/migrations/e70508bd8cb4_init_events.py +++ /dev/null @@ -1 +0,0 @@ -/home/crimsen/git/flaschengeist/flaschengeist-schedule/backend/flaschengeist_events/migrations/e70508bd8cb4_init_events.py \ No newline at end of file From 4f20a94f60dd0da3b43caae6f991592d701e58e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Gr=C3=B6ger?= Date: Wed, 17 Jan 2024 13:04:29 +0100 Subject: [PATCH 446/446] fix some func to get balance --- flaschengeist/plugins/balance/balance_controller.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 5201d76..82aa6f7 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -36,7 +36,7 @@ def get_limit(user: User) -> float: def get_balance(user, start: datetime = None, end: datetime = None): - query = db.session.query(func.sum(Transaction.amount)) + query = db.session.query(func.sum(Transaction._amount)) if start: query = query.filter(start <= Transaction.time) if end: @@ -75,8 +75,8 @@ def get_balances( @debit.expression def debit(cls): a = ( - db.select(func.sum(Transaction.amount)) - .where(cls.id_ == Transaction._sender_id, Transaction.amount) + db.select(func.sum(Transaction._amount)) + .where(cls.id_ == Transaction._sender_id, Transaction._amount) .scalar_subquery() ) return case([(a, a)], else_=0) @@ -88,8 +88,8 @@ def get_balances( @credit.expression def credit(cls): b = ( - db.select(func.sum(Transaction.amount)) - .where(cls.id_ == Transaction._receiver_id, Transaction.amount) + db.select(func.sum(Transaction._amount)) + .where(cls.id_ == Transaction._receiver_id, Transaction._amount) .scalar_subquery() ) return case([(b, b)], else_=0)