diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..36f331f --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,3 @@ +# Migrate code style to Black +41e60425a96eeddb45f3a9f9bfc5ef491ce8e071 +36c4027c5dd1c631bf6ca84890b6ad9c916e1888 diff --git a/.gitignore b/.gitignore index ed93d09..f0eb86c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,6 @@ coverage.xml *.pot # Django stuff: -*.log local_settings.py db.sqlite3 @@ -68,6 +67,8 @@ instance/ # Sphinx documentation docs/_build/ +# pdoc +docs/html # PyBuilder target/ @@ -117,12 +118,16 @@ dmypy.json #ide .idea - +*.swp +*.swo .vscode/ *.log +.fleet/ -# custom -test_pricelist/ -test_project/ -config.yml -geruecht.config.yml +data/ + +# config +flaschengeist/flaschengeist.toml + +# start flaschengeist in pycharme professional +run_flaschengeist_pycharm.py diff --git a/.woodpecker/lint.yml b/.woodpecker/lint.yml new file mode 100644 index 0000000..7cfd62b --- /dev/null +++ b/.woodpecker/lint.yml @@ -0,0 +1,6 @@ +pipeline: + lint: + image: python:slim + commands: + - pip install black + - black --check --line-length 120 --target-version=py39 . diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..87b15c3 --- /dev/null +++ b/.woodpecker/test.yml @@ -0,0 +1,19 @@ +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 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/README.md b/README.md new file mode 100644 index 0000000..320bed5 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Flaschengeist +![status-badge](https://ci.os-sc.org/api/badges/Flaschengeist/flaschengeist/status.svg) + +This is the backend of the Flaschengeist. + +## Installation +### 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 +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 +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,tests]" + +You will also need a MySQL driver, by default one of this is installed: +- `mysqlclient` (non Windows) +- `PyMySQL` (on Windows) + +#### 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/) + +### 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): + + ( + 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, this will update all tables from core + all enabled plugins. +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): + + $ 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/` +2. A custom path and set environment variable `FLASCHENGEIST_CONF` + +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 + +### 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: + + $ flaschengeist run --debug + +This will run the backend on http://localhost:5000 + +## 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 +Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development). diff --git a/docs/plugin_development.md b/docs/plugin_development.md new file mode 100644 index 0000000..ee129eb --- /dev/null +++ b/docs/plugin_development.md @@ -0,0 +1,57 @@ +# Plugin Development + +## File Structure + + - 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.wsgi b/flaschengeist.wsgi new file mode 100644 index 0000000..069b135 --- /dev/null +++ b/flaschengeist.wsgi @@ -0,0 +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/__init__.py b/flaschengeist/__init__.py new file mode 100644 index 0000000..e3d4575 --- /dev/null +++ b/flaschengeist/__init__.py @@ -0,0 +1,9 @@ +"""Flaschengeist""" +import logging +from importlib.metadata import version + +__version__ = version("flaschengeist") +__pdoc__ = {} + +logger = logging.getLogger(__name__) +__pdoc__["logger"] = "Flaschengeist's logger instance (`werkzeug.local.LocalProxy`)" diff --git a/flaschengeist/alembic/__init__.py b/flaschengeist/alembic/__init__.py new file mode 100644 index 0000000..cd7fe4a --- /dev/null +++ b/flaschengeist/alembic/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + + +alembic_migrations_path = str(Path(__file__).resolve().parent / "migrations") +alembic_script_path = str(Path(__file__).resolve().parent) 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/20482a003db8_initial_core_db.py b/flaschengeist/alembic/migrations/20482a003db8_initial_core_db.py new file mode 100644 index 0000000..a2a2445 --- /dev/null +++ b/flaschengeist/alembic/migrations/20482a003db8_initial_core_db.py @@ -0,0 +1,154 @@ +"""Initial core db + +Revision ID: 20482a003db8 +Revises: +Create Date: 2022-08-25 15:13:34.900996 + +""" +from alembic import op +import sqlalchemy as sa +import flaschengeist + + +# revision identifiers, used by Alembic. +revision = "20482a003db8" +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.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), + sa.Column("path", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_image")), + ) + op.create_table( + "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.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( + "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", + 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.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.database.types.Serial(), nullable=False), + sa.Column("text", sa.Text(), nullable=True), + sa.Column("data", sa.PickleType(), nullable=True), + 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.database.types.Serial(), nullable=False), + sa.Column("token", sa.String(length=32), 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.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=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.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")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_attribute")), + ) + op.create_table( + "user_x_role", + 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")), + ) + # ### 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("role_x_permission") + op.drop_table("password_reset") + op.drop_table("notification") + op.drop_table("user") + 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/geruecht/baruser/__init__.py b/flaschengeist/alembic/migrations/__init__.py similarity index 100% rename from geruecht/baruser/__init__.py rename to flaschengeist/alembic/migrations/__init__.py 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 new file mode 100644 index 0000000..e1a5ee3 --- /dev/null +++ b/flaschengeist/app.py @@ -0,0 +1,113 @@ +import enum +import json + +from flask import Flask +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 sqlalchemy.exc import OperationalError +from werkzeug.exceptions import HTTPException + +from flaschengeist import logger +from flaschengeist.controller import pluginController +from flaschengeist.utils.hook import Hook +from flaschengeist.config import configure_app + +from flaschengeist.database import db + + +class CustomJSONEncoder(JSONEncoder): + def default(self, o): + 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 + try: + # Check if iterable + iterable = iter(o) + except TypeError: + pass + else: + 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): + app.config["FG_PLUGINS"] = {} + + for plugin in pluginController.get_enabled_plugins(): + logger.debug(f"Searching for enabled plugin {plugin.name}") + 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 = db.session.get(cls, 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.", + exc_info=True, + ) + continue + 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_provider_class = CustomJSONProvider + app.json = CustomJSONProvider(app) + CORS(app) + + with app.app_context(): + from flaschengeist.database import db, migrate + + configure_app(app, test_config) + db.init_app(app) + migrate.init_app(app, db, compare_type=True) + load_plugins(app) + + @app.route("/", methods=["GET"]) + def __get_state(): + from . import __version__ as version + + return jsonify({"plugins": pluginController.get_loaded_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 + 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 + + return app diff --git a/flaschengeist/cli/InterfaceGenerator.py b/flaschengeist/cli/InterfaceGenerator.py new file mode 100644 index 0000000..e03dde2 --- /dev/null +++ b/flaschengeist/cli/InterfaceGenerator.py @@ -0,0 +1,123 @@ +import io +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_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: + 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()) diff --git a/flaschengeist/cli/__init__.py b/flaschengeist/cli/__init__.py new file mode 100644 index 0000000..13cded8 --- /dev/null +++ b/flaschengeist/cli/__init__.py @@ -0,0 +1,103 @@ +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 + +LOGGING_MIN = 5 # TRACE (custom) +LOGGING_MAX = logging.ERROR + + +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() + + +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): + """Callback: Toggle verbosity between ERROR <-> TRACE""" + + if not value or ctx.resilient_parsing: + return + 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=create_app, +) +@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 + + +def main(*args, **kwargs): + from .plugin_cmd import plugin + from .export_cmd import export + 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)) + + cli.add_command(export) + cli.add_command(docs) + 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/cli/docs_cmd.py b/flaschengeist/cli/docs_cmd.py new file mode 100644 index 0000000..f33a697 --- /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/html", + 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..0a611a3 --- /dev/null +++ b/flaschengeist/cli/export_cmd.py @@ -0,0 +1,29 @@ +import click +from importlib.metadata import entry_points + + +@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.cli.InterfaceGenerator import InterfaceGenerator + + gen = InterfaceGenerator(namespace, output, logger) + if not no_core: + gen.run(models) + if plugin: + 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/install_cmd.py b/flaschengeist/cli/install_cmd.py new file mode 100644 index 0000000..367a1bc --- /dev/null +++ b/flaschengeist/cli/install_cmd.py @@ -0,0 +1,23 @@ +import click +from click.decorators import pass_context +from flask.cli import with_appcontext +from flask_migrate import upgrade + +from flaschengeist.controller import pluginController +from flaschengeist.utils.hook import Hook + + +@click.command() +@with_appcontext +@pass_context +@Hook("plugins.installed") +def install(ctx: click.Context): + plugins = pluginController.get_enabled_plugins() + + # Install database + upgrade(revision="flaschengeist@head") + + # Install plugins + 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 new file mode 100644 index 0000000..b2a9260 --- /dev/null +++ b/flaschengeist/cli/plugin_cmd.py @@ -0,0 +1,144 @@ +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 import logger +from flaschengeist.controller import pluginController +from werkzeug.exceptions import NotFound + + +@click.group() +def plugin(): + pass + + +@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: + pluginController.enable_plugin(name) + click.secho(" ok", fg="green") + except NotFound: + click.secho(" not installed / not found", fg="red") + + +@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() +@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: 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: + 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, required=True, type=str) +@with_appcontext +@pass_context +def uninstall(ctx: click.Context, plugin): + """Uninstall one or more plugins""" + + plugins = {plg.name: plg for plg in pluginController.get_installed_plugins() if plg.name in plugin} + try: + 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() +@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") + 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 | {' ' * 8} state") + print("-" * 63) + for plugin in 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} | ", 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/cli/run_cmd.py b/flaschengeist/cli/run_cmd.py new file mode 100644 index 0000000..60c93af --- /dev/null +++ b/flaschengeist/cli/run_cmd.py @@ -0,0 +1,37 @@ +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 + + 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, reload=True, host=host, port=port, debugger=debug) diff --git a/flaschengeist/config.py b/flaschengeist/config.py new file mode 100644 index 0000000..fb1963e --- /dev/null +++ b/flaschengeist/config.py @@ -0,0 +1,122 @@ +import os +import toml +import collections.abc + +from pathlib import Path +from logging.config import dictConfig +from werkzeug.middleware.proxy_fix import ProxyFix + +from flaschengeist import logger + +# Default config: +config = {"DATABASE": {"engine": "mysql", "port": 3306}} + + +def update_dict(d, u): + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = update_dict(d.get(k, {}), v) + else: + d[k] = v + return d + + +def read_configuration(test_config): + global config + paths = [Path(__file__).parent] + + if not test_config: + paths.append(Path.home() / ".config") + if "FLASCHENGEIST_CONF" in os.environ: + paths.append(Path(str(os.environ.get("FLASCHENGEIST_CONF")))) + + for loc in paths: + try: + with (loc / "flaschengeist.toml").open() as source: + logger.warning(f"Reading config file from >{loc}<") # default root logger, goes to stderr + update_dict(config, toml.load(source)) + except IOError: + pass + if test_config: + update_dict(config, test_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(Path(__file__).parent / "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"]: + 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")) + + dictConfig(logger_config) + + +def configure_app(app, test_config=None): + global config + read_configuration(test_config) + + configure_logger() + + 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") + + app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] + + if test_config is not None: + config["DATABASE"]["engine"] = "sqlite" + + if config["DATABASE"]["engine"] == "mysql": + 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" + 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"], + password=config["DATABASE"]["password"], + host=config["DATABASE"]["host"], + port=config["DATABASE"]["port"], + ) + app.config["SQLALCHEMY_DATABASE_URI"] = f"{engine}://{host}/{config['DATABASE']['database']}{options}" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = 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) diff --git a/flaschengeist/controller/__init__.py b/flaschengeist/controller/__init__.py new file mode 100644 index 0000000..9967031 --- /dev/null +++ b/flaschengeist/controller/__init__.py @@ -0,0 +1 @@ +"""Basic controllers for interaction with the Flaschengeist core""" diff --git a/flaschengeist/controller/imageController.py b/flaschengeist/controller/imageController.py new file mode 100644 index 0000000..3915bce --- /dev/null +++ b/flaschengeist/controller/imageController.py @@ -0,0 +1,64 @@ +from datetime import date +from pathlib import Path +from flask import send_file +from PIL import Image as PImage +from werkzeug.utils import secure_filename +from werkzeug.datastructures import FileStorage +from werkzeug.exceptions import NotFound, UnprocessableEntity + +from ..models import Image +from ..database import db +from ..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/controller/messageController.py b/flaschengeist/controller/messageController.py new file mode 100644 index 0000000..d9ff78c --- /dev/null +++ b/flaschengeist/controller/messageController.py @@ -0,0 +1,14 @@ +from ..utils.hook import Hook +from ..models import User, Role + + +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/controller/pluginController.py b/flaschengeist/controller/pluginController.py new file mode 100644 index 0000000..6f3bff6 --- /dev/null +++ b/flaschengeist/controller/pluginController.py @@ -0,0 +1,172 @@ +"""Controller for Plugin logic + +Used by plugins for setting and notification functionality. +""" + +from typing import Union, List +from flask import current_app +from werkzeug.exceptions import NotFound, BadRequest +from sqlalchemy.exc import OperationalError, ProgrammingError +from flask_migrate import upgrade as database_upgrade +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 ..plugins import Plugin, AuthPlugin +from ..models import Notification + + +__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) + ] + + +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, 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 + enabled_plugins = [ + 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 notify(plugin_id: int, 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_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}") + entry_point = entry_points(group="flaschengeist.plugins", name=plugin_name) + if not entry_point: + raise NotFound + + cls = entry_point[0].load() + 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.commit() + # Custom installation steps + 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 + + +@Hook("plugin.uninstalled") +def uninstall_plugin(plugin_id: Union[str, int, Plugin]): + plugin = disable_plugin(plugin_id) + logger.debug(f"Uninstall plugin {plugin.name}") + plugin.uninstall() + db.session.delete(plugin) + db.session.commit() + + +@Hook("plugins.enabled") +def enable_plugin(plugin_id: Union[str, int]) -> Plugin: + logger.debug(f"Enabling plugin {plugin_id}") + plugin = Plugin.query + if isinstance(plugin_id, str): + plugin = plugin.filter(Plugin.name == plugin_id).one_or_none() + elif isinstance(plugin_id, int): + plugin = plugin.get(plugin_id) + 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, 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() + 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/roleController.py b/flaschengeist/controller/roleController.py new file mode 100644 index 0000000..eedb7c7 --- /dev/null +++ b/flaschengeist/controller/roleController.py @@ -0,0 +1,85 @@ +from typing import Union +from sqlalchemy.exc import IntegrityError +from werkzeug.exceptions import BadRequest, Conflict, NotFound + +from .. import logger +from ..models import Role, Permission +from ..database import db, case_sensitive +from ..utils.hook import Hook + + +def get_all(): + return Role.query.all() + + +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: + role = Role.query.filter(Role.name == role_name).one_or_none() + if not role: + raise NotFound("no such role") + return role + + +def get_permissions(): + return Permission.query.all() + + +@Hook +def update_role(role, new_name): + 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") + role.name = new_name + db.session.commit() + + +def set_permissions(role, permissions): + 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() + + +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: str, permissions=[]): + logger.debug(f"Create new role with name: {name}") + 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 + + +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 Conflict("Role still in use") diff --git a/flaschengeist/controller/sessionController.py b/flaschengeist/controller/sessionController.py new file mode 100644 index 0000000..afed11e --- /dev/null +++ b/flaschengeist/controller/sessionController.py @@ -0,0 +1,144 @@ +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 +from ..database import db + + +lifetime = 1800 + + +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): + """Verify session + + Verify a Session and Roles so if the User has permission or not. + Retrieves the access token if valid else retrieves False + + Args: + token: Token to verify. + request_headers: Headers to validate user agent of browser + permission: Permission needed to access restricted routes + Returns: + 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() + if session: + logger.debug("token found, check if expired or invalid user agent differs") + 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 + ): + 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 permission: {{ {} }}".format(token, permission)) + raise Unauthorized + + +def create(user, request_headers=None) -> Session: + """Create a Session + + Args: + user: For which User is to create a Session + request_headers: Headers to validate user agent of browser + + Returns: + Session: A created Token for User + """ + 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=user_agent["os"]["family"], + browser=user_agent["user_agent"]["family"], + ) + session.refresh() + db.session.add(session) + db.session.commit() + logger.debug("access token is {{ {} }}".format(session.token)) + return session + + +def get_session(token, owner=None): + """Retrieves Session 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: + 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 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 + + Args: + token (Session): Token to delete + """ + db.session.delete(token) + db.session.commit() + + +def update_session(session): + session.refresh() + 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/controller/userController.py b/flaschengeist/controller/userController.py new file mode 100644 index 0000000..a59bbce --- /dev/null +++ b/flaschengeist/controller/userController.py @@ -0,0 +1,327 @@ +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, date +from flask.helpers import send_file +from werkzeug.exceptions import NotFound, BadRequest, Forbidden + +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 ..controller import ( + imageController, + messageController, + pluginController, + sessionController, +) +from ..plugins import AuthPlugin + + +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_) + 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 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: 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]) + db.session.commit() + + +def login_user(username, password): + logger.info("login user {{ {} }}".format(username)) + 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 + + +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) + 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): + raise Forbidden + + modify_user(reset.user, None, password) + sessionController.delete_sessions(reset.user) + + db.session.delete(reset) + db.session.commit() + + +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): + 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 + + +def modify_user(user: User, password: str, new_password: str = 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 + """ + provider = get_provider(user.userid) + provider.modify_user(user, password, new_password) + + 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 + ) + 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(deleted=False): + query = __active_users() if not deleted else User.query + return 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, deleted=False) -> User: + """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 = (__active_users() if not deleted else User.query).filter(User.userid == uid).one_or_none() + if not user: + raise NotFound + return user + + +@Hook +def delete_user(user: User): + """Delete given user""" + # First let the backend delete the user, as this might fail + provider = get_provider(user.userid) + provider.delete_user(user) + + # Clear all easy relationships + user.avatar_ = None + user._attributes.clear() + user.roles_.clear() + user.sessions_.clear() + user.reset_requests_.clear() + # 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: + 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.deleted = True + user.birthday = None + user.mail = None + db.session.commit() + + +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: + 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) + set_roles(user, roles) + + password = passwd if passwd else secrets.token_urlsafe(16) + 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 as e: + logger.error("No authentication backend, allowing registering new users, found.") + raise e + except exc.IntegrityError: + raise BadRequest("userid already in use") + + 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)) + + provider.update_user(user) + + return 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: + 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, etag=new_etag) + raise NotFound + + +def save_avatar(user, file): + get_provider(user.userid).set_avatar(user, file) + db.session.commit() + + +def delete_avatar(user): + get_provider(user.userid).delete_avatar(user) + db.session.commit() + + +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/database/__init__.py b/flaschengeist/database/__init__.py new file mode 100644 index 0000000..914db1d --- /dev/null +++ b/flaschengeist/database/__init__.py @@ -0,0 +1,75 @@ +import os +from flask_migrate import Migrate, Config +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( + 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, session_options={"expire_on_commit": False}) +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. + """ + # Set main script location + 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 + for entry_point in entry_points(group="flaschengeist.plugins"): + try: + 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 {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) + 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): + """ + 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 + + return func.binary(s) + return s diff --git a/flaschengeist/database/types.py b/flaschengeist/database/types.py new file mode 100644 index 0000000..0b34a60 --- /dev/null +++ b/flaschengeist/database/types.py @@ -0,0 +1,97 @@ +from importlib import import_module +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): + import typing + + module = import_module("flaschengeist.models").__dict__ + + 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 + 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: + _, 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/flaschengeist.toml b/flaschengeist/flaschengeist.toml new file mode 100644 index 0000000..bbd9807 --- /dev/null +++ b/flaschengeist/flaschengeist.toml @@ -0,0 +1,127 @@ +# 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 + +[DATABASE] +# engine = "mysql" (default) +host = "localhost" +user = "flaschengeist" +password = "flaschengeist" +database = "flaschengeist" + +[LOGGING] +# You can override all settings from the logging.toml here +# 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" + +# 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" + +[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" +] + +[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" +# 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}" +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 +# Uncomment to allow active services to debit other users (requires events plugin) +# allow_service_debit = true diff --git a/flaschengeist/logging.toml b/flaschengeist/logging.toml new file mode 100644 index 0000000..fb7b02f --- /dev/null +++ b/flaschengeist/logging.toml @@ -0,0 +1,26 @@ +# 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] %(levelname)s - %(message)s" + [formatters.extended] + format = "[%(asctime)s] %(levelname)s %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(message)s" + +[handlers] + [handlers.wsgi] + stream = "ext://flask.logging.wsgi_errors_stream" + class = "logging.StreamHandler" + formatter = "simple" + level = "DEBUG" + +[loggers] + [loggers.werkzeug] + level = "WARNING" + +[root] + level = "WARNING" + handlers = ["wsgi"] \ No newline at end of file diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py new file mode 100644 index 0000000..096ac2e --- /dev/null +++ b/flaschengeist/models/__init__.py @@ -0,0 +1,5 @@ +from .session import * +from .user import * +from .plugin import * +from .notification import * +from .image import * diff --git a/flaschengeist/models/image.py b/flaschengeist/models/image.py new file mode 100644 index 0000000..dffd275 --- /dev/null +++ b/flaschengeist/models/image.py @@ -0,0 +1,32 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) + +from sqlalchemy import event +from pathlib import Path + +from ..database import db +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) + 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") + + +@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/flaschengeist/models/notification.py b/flaschengeist/models/notification.py new file mode 100644 index 0000000..c126a84 --- /dev/null +++ b/flaschengeist/models/notification.py @@ -0,0 +1,28 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) + +from datetime import datetime +from typing import Any + +from ..database import db +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) + 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", 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") + ) + plugin: str + + @property + def plugin(self) -> str: + return self.plugin_.name diff --git a/flaschengeist/models/plugin.py b/flaschengeist/models/plugin.py new file mode 100644 index 0000000..4254cc8 --- /dev/null +++ b/flaschengeist/models/plugin.py @@ -0,0 +1,74 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) + +from typing import Any, List, Dict +from sqlalchemy.orm.collections import attribute_mapped_collection + +from ..database import db +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")) + name: str = db.Column(db.String(127), nullable=False) + value: Any = db.Column(db.PickleType(protocol=4)) + + +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) + """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["Permission"] = 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="subquery", + ) + + 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/session.py b/flaschengeist/models/session.py new file mode 100644 index 0000000..668622d --- /dev/null +++ b/flaschengeist/models/session.py @@ -0,0 +1,49 @@ +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 + + +class Session(db.Model, ModelSerializeMixin): + """Model for a Session + + Args: + expires: Is a Datetime from current Time. + user: Is an User. + 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) + lifetime: int = db.Column(db.Integer) + browser: str = db.Column(db.String(127)) + platform: str = db.Column(db.String(64)) + userid: str = "" + + _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 + def userid(self): + return self.user_.userid + + def refresh(self): + """Update the Timestamp + + Update the Timestamp to the current Time. + """ + 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): + 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 new file mode 100644 index 0000000..21e9604 --- /dev/null +++ b/flaschengeist/models/user.py @@ -0,0 +1,138 @@ +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 +from sqlalchemy.orm.collections import attribute_mapped_collection + +from ..database import db +from ..database.types import ModelSerializeMixin, UtcDateTime, Serial + +association_table = db.Table( + "user_x_role", + 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", Serial, db.ForeignKey("role.id")), + db.Column("permission_id", Serial, db.ForeignKey("permission.id")), +) + + +class Permission(db.Model, ModelSerializeMixin): + __allow_unmapped__ = True + __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")) + plugin_ = db.relationship("Plugin", lazy="subquery", back_populates="permissions", enable_typechecks=False) + + +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) + + +class User(db.Model, ModelSerializeMixin): + """Database Object for User + + Table for all saved User + + Attributes: + id: Id in Database as Primary Key. + userid: 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 + 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)) + firstname: str = db.Column(db.String(50), nullable=False) + lastname: str = db.Column(db.String(50), nullable=False) + 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]] = [] + + # 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") + 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, delete-orphan", + ) + + @property + def roles(self) -> List[str]: + return [role.name for role in self.roles_] + + 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 has_attribute(self, name): + return name in self._attributes + + def get_attribute(self, name, default=None): + if name in self._attributes: + 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] + + def has_permission(self, permission): + return permission in self.get_permissions() + + +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) + name: str = db.Column(db.String(30)) + value: any = db.Column(db.PickleType(protocol=4)) + + +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]) + token: str = db.Column(db.String(32)) + expires: datetime = db.Column(UtcDateTime) + + +class _Avatar: + """Wrapper class for avatar binaries""" + + mimetype = "" + binary = bytearray() diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py new file mode 100644 index 0000000..23d3783 --- /dev/null +++ b/flaschengeist/plugins/__init__.py @@ -0,0 +1,304 @@ +"""Flaschengeist Plugins + +.. include:: docs/plugin_development.md + +""" + +from typing import Union, List +from importlib.metadata import entry_points +from werkzeug.exceptions import NotFound +from werkzeug.datastructures import FileStorage + +from flaschengeist.models.plugin import BasePlugin +from flaschengeist.models.user import _Avatar, Permission +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") +plugins_installed.__doc__ = """Hook decorator for when all plugins are installed + +Possible use case would be to populate the database with some presets. +""" + +plugins_loaded = HookAfter("plugins.loaded") +plugins_loaded.__doc__ = """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 + +Passed args: + - *app:* Current flask app instance (args) +""" + +before_role_updated = HookBefore("update_role") +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") +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") +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") +before_delete_user.__doc__ = """Hook decorator,this is called before an user gets deleted. + +Passed args: + - *user:* `flaschengeist.models.user.User` object +""" + + +class Plugin(BasePlugin): + """Base class for all Plugins + + 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 + """ + + blueprint = None + """Optional `flask.blueprint` if the plugin uses custom routes""" + + models = None + """Optional module containing the SQLAlchemy models used by the plugin""" + + @property + def version(self) -> str: + """Version of the plugin, loaded from Distribution""" + return self.dist.version + + @property + def dist(self): + """Distribution of this plugin""" + return self.entry_point.dist + + @property + def entry_point(self): + ep = tuple(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 + + 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. + """ + 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 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. + """ + from ..controller import pluginController + + 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 + + Returns: + Dict containing version and permissions of the plugin + """ + return {"version": self.version, "permissions": self.permissions} + + def install_permissions(self, permissions: list[str]): + """Helper for installing a list of strings as permissions + + Args: + permissions: List of permissions to install + """ + 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 [])) + self.permissions.extend(_perms) + + +class AuthPlugin(Plugin): + """Base class for all authentification plugins + + See also `Plugin` + """ + + def login(self, login_name, password) -> Union[bool, str]: + """Login routine, MUST BE IMPLEMENTED! + + Args: + 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, otherwise the UID is returned + """ + raise NotImplemented + + 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 user_exists(self, userid) -> bool: + """Check if user exists on this backend + Args: + userid: Userid to search + Returns: + True or False + """ + 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. + User might have roles not existing on the external database, so you might have to create those. + + Args: + user: User object + 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: + 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 + + 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. + + Args: + user: User object + password: string + + """ + raise NotImplementedError + + def delete_user(self, user): + """If backend is using (writeable) external data, then delete the user from external database. + + Args: + user: User object + + """ + 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) + + 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: + NotFound: If no avatar found or not implemented + """ + raise NotFound + + 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: `werkzeug.datastructures.FileStorage` uploaded by the user + Raises: + 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): + """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 + """ + user.avatar_ = None diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py new file mode 100644 index 0000000..9d0b073 --- /dev/null +++ b/flaschengeist/plugins/auth/__init__.py @@ -0,0 +1,176 @@ +"""Authentication plugin, provides basic routes + +Allow management of authentication, login, logout, etc. +""" +from flask import Blueprint, request, jsonify +from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized + +from flaschengeist import logger +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(Plugin): + blueprint = Blueprint("auth", __name__) + + +@AuthRoutePlugin.blueprint.route("/auth", methods=["POST"]) +def login(): + """Login in an user and create a session + + Route: ``/auth`` | Method: ``POST`` + + POST-data: ``{userid: string, password: string}`` + + Returns: + 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() + try: + userid = str(data["userid"]) + password = str(data["password"]) + except (KeyError, ValueError, TypeError): + raise BadRequest("Missing parameter(s)") + + logger.debug(f"search user {userid} in database") + user = userController.login_user(userid, password) + if not user: + raise Unauthorized + session = sessionController.create(user, request_headers=request.headers) + logger.debug(f"token is {session.token}") + logger.info(f"User {userid} logged in.") + + # Lets cleanup the DB + sessionController.clear_expired() + return created(session) + + +@AuthRoutePlugin.blueprint.route("/auth", methods=["GET"]) +@login_required() +def get_sessions(current_session, **kwargs): + """Get all valid sessions of current user + + Route: ``/auth`` | Method: ``GET`` + + Returns: + A JSON array of `flaschengeist.models.session.Session` or HTTP error + """ + sessions = sessionController.get_users_sessions(current_session.user_) + return jsonify(sessions) + + +@AuthRoutePlugin.blueprint.route("/auth/", methods=["DELETE"]) +@login_required() +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: + 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(session) + sessionController.clear_expired() + return "" + + +@AuthRoutePlugin.blueprint.route("/auth/", methods=["GET"]) +@login_required() +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.models.session.Session` 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) + + +@AuthRoutePlugin.blueprint.route("/auth/", methods=["PUT"]) +@login_required() +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-204 or HTTP error + """ + 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(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): + raise BadRequest + + +@AuthRoutePlugin.blueprint.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.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_) + + +@AuthRoutePlugin.blueprint.route("/auth/reset", methods=["POST"]) +def reset_password(): + data = request.get_json() + if "userid" in data: + user = userController.get_user(data["userid"]) + if user: + userController.request_reset(user) + elif "password" in data and "token" in data: + userController.reset_password(data["token"], data["password"]) + else: + raise BadRequest("Missing parameter(s)") + + return no_content() diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py new file mode 100644 index 0000000..4574929 --- /dev/null +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -0,0 +1,332 @@ +"""LDAP Authentication Provider Plugin""" +import os +import ssl +from PIL import Image +from io import BytesIO +from flask_ldapconn import LDAPConn +from flask import current_app as app +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 datetime import datetime + +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 +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=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"] = self.config["ca_cert"] + else: + # Default is CERT_REQUIRED + app.config["LDAP_REQUIRE_CERT"] = ssl.CERT_OPTIONAL + self.ldap = LDAPConn(app) + 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 = self.config.get("root_dn", None) + self.root_secret = self.config.get("root_secret", None) + + @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, login_name, password): + if not login_name: + return False + 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) + return attr is not None + + def update_user(self, user): + attr = self.__find(user.userid) + self.__update(user, attr) + + 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() + 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["uidNumber"] = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"] + dn = self.dn_template.format( + 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, + } + ) + 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) + except (LDAPPasswordIsMandatoryError, LDAPBindError): + raise BadRequest + + 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 + ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) + modifier = {} + for name, ldap_name in [ + ("firstname", "givenName"), + ("lastname", "sn"), + ("mail", "mail"), + ("display_name", "displayName"), + ]: + if hasattr(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): + 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, + "(uid={})".format(user.userid), + SUBTREE, + attributes=["jpegPhoto"], + ) + r = self.ldap.connection.response[0]["attributes"] + + if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0: + avatar = _Avatar() + avatar.mimetype = "image/jpeg" + avatar.binary = bytearray(r["jpegPhoto"][0]) + return avatar + else: + raise NotFound + + def set_avatar(self, user: User, file: FileStorage): + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") + raise InternalServerError + + 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, [image_bytes.getvalue()])]}) + + 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 + if not con: + con = self.ldap.connect(self.root_dn, self.root_secret) + con.search( + self.search_dn, + f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})", + SUBTREE, + attributes=["uid", "givenName", "sn", "mail"], + ) + return con.response[0]["attributes"] if len(con.response) > 0 else None + + 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, + new_name, + ): + if self.root_dn is None: + logger.error("root_dn missing in ldap config!") + raise InternalServerError + try: + 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: + ldap_conn.modify_dn(dn, f"cn={new_name}") + else: + ldap_conn.delete(dn) + + 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 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.encode() + salt).digest() + salt).decode()}" + + def _get_groups(self, uid): + groups = [] + self.ldap.connection.search( + self.group_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): + self.ldap.connection.search( + self.group_dn, + "(cn=*)", + SUBTREE, + attributes=["cn", "gidNumber", "memberUid"], + ) + return self.ldap.response() + + def _set_roles(self, user: User): + try: + 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) + 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},{self.group_dn}", + ["posixGroup"], + attributes={"gidNumber": gid_number}, + ) + + ldap_roles = self._get_all_roles() + + 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])]} + ldap_conn.modify(ldap_role["dn"], modify) + + except (LDAPPasswordIsMandatoryError, LDAPBindError): + raise BadRequest + except IndexError as e: + logger.error("Roles in LDAP", exc_info=True) diff --git a/flaschengeist/plugins/auth_ldap/cli.py b/flaschengeist/plugins/auth_ldap/cli.py new file mode 100644 index 0000000..e2c82aa --- /dev/null +++ b/flaschengeist/plugins/auth_ldap/cli.py @@ -0,0 +1,47 @@ +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) +@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, sync_ldap): + """Tools for the LDAP authentification""" + from flaschengeist.controller import userController + from flaschengeist.plugins.auth_ldap import AuthLDAP + if sync: + click.echo("Synchronizing users from LDAP -> database") + from ldap3 import SUBTREE + from flaschengeist.models import User + from flaschengeist.database import db + + 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 + 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] + try: + user = userController.get_user(uid) + except NotFound: + 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/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py new file mode 100644 index 0000000..bdf01ac --- /dev/null +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -0,0 +1,65 @@ +"""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 import User, Role, Permission +from flaschengeist.database import db +from flaschengeist import logger + + +class AuthPlain(AuthPlugin): + def can_register(self): + return True + + 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): + if password is not None and not self.login(user, password): + raise BadRequest + 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") + 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") + 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: 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) + pass_hash = binascii.hexlify(pass_hash).decode("ascii") + return pass_hash == stored_password diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py new file mode 100644 index 0000000..31fdfc3 --- /dev/null +++ b/flaschengeist/plugins/balance/__init__.py @@ -0,0 +1,89 @@ +"""Balance plugin + +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 +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): + # id = "dev.flaschengeist.balance" + models = models + + def install(self): + self.install_permissions(permissions.permissions) + + def load(self): + from .routes import blueprint + + self.blueprint = blueprint + + @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, *args): + from . import balance_controller + + 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 + + @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 new file mode 100644 index 0000000..82aa6f7 --- /dev/null +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -0,0 +1,335 @@ +# German: Soll -> Abgang vom Konto +# Haben -> Zugang aufs Konto +# English: Debit -> from account +# Credit -> to account +from enum import IntEnum +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 + +__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): + if override or not user.has_attribute(__attribute_limit): + 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_balance(user, start: datetime = None, end: datetime = None): + 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 + + +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], + ) + + @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 _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(), + ) + 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 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): + """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 + Returns: + Transaction that was created + Raises: + BadRequest if amount <= 0 + """ + logger.debug(f"send(sender={sender}, receiver={receiver}, amount={amount}, author={author})") + if amount <= 0: + raise BadRequest + + if sender and sender.has_attribute(__attribute_limit): + if (get_balance(sender)[2] - amount) < sender.get_attribute(__attribute_limit) and not author.has_permission( + permissions.EXCEED_LIMIT + ): + raise Conflict("Limit exceeded") + + 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_: + 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_: + 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 + + +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 + 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 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: + 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)) + 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) + if offset is not None: + query = query.offset(offset) + + return query.all(), count + + +def reverse_transaction(transaction: Transaction, author: User): + """Reverse a transaction + + Args: + 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.original_ = transaction + transaction.reversal = reversal + db.session.commit() + return reversal 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/balance/models.py b/flaschengeist/plugins/balance/models.py new file mode 100644 index 0000000..fa51f7d --- /dev/null +++ b/flaschengeist/plugins/balance/models.py @@ -0,0 +1,72 @@ +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 + + +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")) + _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", Serial, primary_key=True) + time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) + _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 + 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]) + 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])) + + @hybrid_property + def sender_id(self): + return self.sender_.userid if self.sender_ else None + + @sender_id.expression + def sender_id(cls): + return db.select([User.userid]).where(cls._sender_id == User.id_).scalar_subquery() + + @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_).scalar_subquery() + + @property + def author_id(self): + return self.author_.userid + + @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/permissions.py b/flaschengeist/plugins/balance/permissions.py new file mode 100644 index 0000000..feb0c81 --- /dev/null +++ b/flaschengeist/plugins/balance/permissions.py @@ -0,0 +1,27 @@ +SHOW = "balance_show" +"""Show own balance""" +SHOW_OTHER = "balance_show_others" +"""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 to other""" +SEND_OTHER = "balance_send_others" +"""Send from other to another""" + +SET_LIMIT = "balance_set_limit" +"""Can set limit for users""" +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/balance/routes.py b/flaschengeist/plugins/balance/routes.py new file mode 100644 index 0000000..0e14a81 --- /dev/null +++ b/flaschengeist/plugins/balance/routes.py @@ -0,0 +1,338 @@ +from datetime import datetime, timezone +from logging import log +from werkzeug.exceptions import Forbidden, BadRequest +from flask import Blueprint, 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 flaschengeist.app import logger +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 + + +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 + + 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() + + +@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)} + + +@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() + + +@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() + + +@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) + logger.debug(f"Balance of {user.userid} from {start} to {end}: {balance}") + return {"credit": balance[0], "debit": balance[1], "balance": balance[2]} + + +@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") + descending = request.args.get("descending", False) + 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) + 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, + descending=descending, + ) + logger.debug(f"transactions: {transactions}") + return {"transactions": transactions, "count": count} + + +@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 + + +@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 + + +@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 + """ + 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) + _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()], + "count": count, + } + ) diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py new file mode 100644 index 0000000..ecaa042 --- /dev/null +++ b/flaschengeist/plugins/message_mail.py @@ -0,0 +1,60 @@ +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 +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 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, *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} 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)) + return + 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 + mail.attach(MIMEText(msg.message)) + with self.__connect() as smtp: + smtp.sendmail(self.mail, recipients, mail.as_string()) + + def __connect(self): + if self.crypt == "SSL": + self.smtp = smtplib.SMTP_SSL(self.server, 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) + return self.smtp diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py new file mode 100644 index 0000000..c644ca0 --- /dev/null +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -0,0 +1,753 @@ +"""Pricelist plugin""" +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 +from flaschengeist.utils.HTTP import no_content + +from . import models +from . import pricelist_controller, permissions + + +class PriceListPlugin(Plugin): + models = models + blueprint = Blueprint("pricelist", __name__, url_prefix="/pricelist") + + def install(self): + self.install_permissions(permissions.permissions) + + def load(self): + config = {"discount": 0} + config.update(config) + + +@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: + result = pricelist_controller.get_drink_type(identifier) + return jsonify(result) + + +@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 + drink_type = pricelist_controller.create_drink_type(data["name"]) + return jsonify(drink_type) + + +@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 + drink_type = pricelist_controller.rename_drink_type(identifier, data["name"]) + return jsonify(drink_type) + + +@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() + + +@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: + result = pricelist_controller.get_tags() + return jsonify(result) + + +@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) + + +@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) + + +@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() + + +@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() + public = False + except Unauthorized: + public = True + + if identifier: + result = pricelist_controller.get_drink(identifier, public=public) + return jsonify(result) + 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") + 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 + drinks, count = pricelist_controller.get_drinks( + public=public, + limit=limit, + offset=offset, + search_name=search_name, + search_key=search_key, + 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) + descending = request.args.get("descending", type=bool) + sortBy = request.args.get("sortBy") + 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) + 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, + descending=descending, + sortBy=sortBy, + ) + 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 + + 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"]) +@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"]) +@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"]) +@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"]) +@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"]) +@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"]) +@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"]) +@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"]) +@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"]) +@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=["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("/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("/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() +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", "DELETE"]) +@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() + + file = request.files.get("file") + if file: + return jsonify(pricelist_controller.save_drink_picture(identifier, file)) + else: + raise BadRequest + + +@PriceListPlugin.blueprint.route("/drinks//picture", methods=["GET"]) +# @headers({"Cache-Control": "private, must-revalidate"}) +def _get_picture(identifier): + """Get Picture + + Args: + identifier: Identifier of Drink + + Returns: + Picture or HTTP-error + """ + 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/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/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py new file mode 100644 index 0000000..1a5335d --- /dev/null +++ b/flaschengeist/plugins/pricelist/models.py @@ -0,0 +1,180 @@ +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 + + +drink_tag_association = db.Table( + "drink_x_tag", + 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", Serial, db.ForeignKey("drink.id")), + db.Column("type_id", Serial, db.ForeignKey("drink_type.id")), +) + + +class Tag(db.Model, ModelSerializeMixin): + """ + Tag + """ + + __tablename__ = "drink_tag" + 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) + + +class DrinkType(db.Model, ModelSerializeMixin): + """ + DrinkType + """ + + __tablename__ = "drink_type" + id: int = db.Column("id", Serial, 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", Serial, primary_key=True) + price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False)) + 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) + 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): + """ + ExtraIngredient + """ + + __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)) + + +class DrinkIngredient(db.Model, ModelSerializeMixin): + """ + Drink Ingredient + """ + + __tablename__ = "drink_ingredient" + 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(Serial, db.ForeignKey("drink.id")) + 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 name(self): + return self._drink_ingredient.name if self._drink_ingredient else None + + +class Ingredient(db.Model, ModelSerializeMixin): + """ + Ingredient Associationtable + """ + + __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, cascade="all,delete") + extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient) + + _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): + """ + MinPrices + """ + + percentage: float + price: float + + +class DrinkPriceVolume(db.Model, ModelSerializeMixin): + """ + Drink Volumes and Prices + """ + + __tablename__ = "drink_price_volume" + 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)) + min_prices: list[MinPrices] = [] + # ingredients: list[Ingredient] = [] + 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, + cascade="all,delete,delete-orphan", + ) + + def __repr__(self): + return f"DrinkPriceVolume({self.id},{self.drink_id},{self.volume},{self.prices})" + + +class Drink(db.Model, ModelSerializeMixin): + """ + DrinkPrice + """ + + __tablename__ = "drink" + 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) + 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 + + 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]) + + 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] = [] + _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})" + + @property + def has_image(self): + return self.image_ is not None diff --git a/flaschengeist/plugins/pricelist/permissions.py b/flaschengeist/plugins/pricelist/permissions.py new file mode 100644 index 0000000..a94b62b --- /dev/null +++ b/flaschengeist/plugins/pricelist/permissions.py @@ -0,0 +1,37 @@ +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_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" + +CREATE_TYPE = "drink_type_create" + +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 new file mode 100644 index 0000000..872b412 --- /dev/null +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -0,0 +1,540 @@ +from werkzeug.exceptions import BadRequest, NotFound +from sqlalchemy.exc import IntegrityError + +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 .permissions import EDIT_VOLUME, EDIT_PRICE, EDIT_INGREDIENTS_DRINK + +import flaschengeist.controller.imageController as image_controller + + +def update(): + db.session.commit() + + +def get_tags(): + return Tag.query.all() + + +def get_tag(identifier): + if isinstance(identifier, int): + ret = Tag.query.get(identifier) + elif isinstance(identifier, str): + ret = Tag.query.filter(Tag.name == identifier).one_or_none() + else: + logger.debug("Invalid identifier type for Tag") + raise BadRequest + if ret is None: + raise NotFound + return ret + + +def create_tag(data): + try: + 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 + except IntegrityError: + raise BadRequest("Name already exists") + + +def update_tag(identifier, data): + tag = get_tag(identifier) + 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: + 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): + ret = DrinkType.query.get(identifier) + elif isinstance(identifier, str): + ret = DrinkType.query.filter(Tag.name == identifier).one_or_none() + else: + logger.debug("Invalid identifier type for DrinkType") + raise BadRequest + if ret is None: + raise NotFound + return ret + + +def create_drink_type(name): + try: + drink_type = DrinkType(name=name) + db.session.add(drink_type) + update() + return drink_type + 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): + drink_type = get_drink_type(identifier) + db.session.delete(drink_type) + try: + update() + 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, + 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)) + else: + 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 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)) + 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))) + ) + query = query.order_by(Drink.name.asc()) + + if limit is not None: + count = query.count() + query = query.limit(limit) + 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, + sortBy=None, + descending=False, +): + count = None + query = DrinkPrice.query + if 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)))) + 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.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 + price.volume = price._volume + return prices, count + + +def get_drink(identifier, public=False): + drink = None + if isinstance(identifier, int): + drink = Drink.query.get(identifier) + elif isinstance(identifier, str): + drink = Drink.query.filter(Tag.name == identifier).one_or_none() + else: + raise BadRequest("Invalid identifier type for Drink") + if drink is None: + raise NotFound + if public: + return _create_public_drink(drink) + for volume in drink._volumes: + volume.prices = volume._prices + drink.volumes = drink._volumes + return drink + + +def set_drink(data): + return update_drink(-1, 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 + tags = [] + if "tags" in data: + _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"] + 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) and key != "has_image": + setattr(drink, key, value if value != "" else None) + + 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) + 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 + + +def set_volumes(volumes): + retVal = [] + if not isinstance(volumes, list): + raise BadRequest + for volume in volumes: + retVal.append(set_volume(volume)) + return retVal + + +def delete_drink(identifier): + drink = get_drink(identifier) + db.session.delete(drink) + db.session.commit() + + +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(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 + ingredients = None + if "prices" in values: + prices = values.pop("prices") + if "ingredients" in values: + ingredients = values.pop("ingredients") + values.pop("id", None) + volume = DrinkPriceVolume(**values) + db.session.add(volume) + + if prices and session.user_.has_permission(EDIT_PRICE): + set_prices(prices, volume) + if ingredients and session.user_.has_permission(EDIT_INGREDIENTS_DRINK): + set_ingredients(ingredients, volume) + 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): + volume = get_volume(identifier) + db.session.delete(volume) + db.session.commit() + + +def get_price(identifier): + if isinstance(identifier, int): + return DrinkPrice.query.get(identifier) + raise NotFound + + +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(data): + 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} + values.pop("id", -1) + price = DrinkPrice(**values) + db.session.add(price) + + return price + + +def delete_price(identifier): + price = get_price(identifier) + db.session.delete(price) + db.session.commit() + + +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") + values.pop("id", -1) + drink_ingredient = DrinkIngredient(**values) + db.session.add(drink_ingredient) + return drink_ingredient + + +def get_ingredient(identifier): + return Ingredient.query.get(identifier) + + +def set_ingredient(data): + drink_ingredient_value = None + extra_ingredient_value = None + if "drink_ingredient" in data: + drink_ingredient_value = data.pop("drink_ingredient") + if "extra_ingredient" in data: + extra_ingredient_value = data.pop("extra_ingredient") + 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: + if "id" in extra_ingredient_value: + ingredient.extra_ingredient = get_extra_ingredient(extra_ingredient_value.get("id")) + 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) + + +def set_extra_ingredient(data): + allowed_keys = ExtraIngredient().serialize().keys() + if "id" in data: + data.pop("id") + 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() + return extra_ingredient + + +def update_extra_ingredient(identifier, data): + allowed_keys = ExtraIngredient().serialize().keys() + if "id" in data: + data.pop("id") + 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(): + 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() + + +def save_drink_picture(identifier, file): + drink = delete_drink_picture(identifier) + drink.image_ = image_controller.upload_image(file) + db.session.commit() + return drink + + +def delete_drink_picture(identifier): + drink = get_drink(identifier) + if drink.image_: + db.session.delete(drink.image_) + drink.image_ = None + db.session.commit() + return drink diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py new file mode 100644 index 0000000..07380cb --- /dev/null +++ b/flaschengeist/plugins/roles/__init__.py @@ -0,0 +1,141 @@ +"""Roles plugin + +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 Plugin +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): + blueprint = Blueprint("roles", __name__) + + def install(self): + self.install_permissions(permissions.permissions) + + +@RolesPlugin.blueprint.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 encoded array of `flaschengeist.models.user.Role` + """ + roles = roleController.get_all() + return jsonify(roles) + + +@RolesPlugin.blueprint.route("/roles", methods=["POST"]) +@login_required(permission=permissions.EDIT) +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-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"] + return created(roleController.create_role(data["name"], permissions)) + + +@RolesPlugin.blueprint.route("/roles/permissions", methods=["GET"]) +@login_required() +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) + + +@RolesPlugin.blueprint.route("/roles/", methods=["GET"]) +@login_required() +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) + + +@RolesPlugin.blueprint.route("/roles/", methods=["PUT"]) +@login_required(permission=permissions.EDIT) +def edit_role(role_id, current_session): + """Edit role, rename and / or set permissions + + Route: ``/roles/`` | Method: ``PUT`` + + POST-data: ``{name?: string, permissions?: string[]}`` + + Args: + role_id: Identifier of the role + current_session: Session sent with Authorization Header + + Returns: + HTTP-200 or HTTP error + """ + role = roleController.get(role_id) + + data = request.get_json() + if "permissions" in data: + roleController.set_permissions(role, data["permissions"]) + if "name" in data and data["name"] != role.name: + roleController.update_role(role, data["name"]) + return no_content() + + +@RolesPlugin.blueprint.route("/roles/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE) +def delete_role(role_id, current_session): + """Delete role + + Route: ``/roles/`` | Method: ``DELETE`` + + Args: + role_id: Identifier of the role + current_session: Session sent with Authorization Header + + Returns: + HTTP-204 or HTTP error (HTTP-409 Conflict if role still in use) + """ + role = roleController.get(role_id) + roleController.delete(role) + 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("_")] diff --git a/flaschengeist/plugins/scheduler.py b/flaschengeist/plugins/scheduler.py new file mode 100644 index 0000000..ea26282 --- /dev/null +++ b/flaschengeist/plugins/scheduler.py @@ -0,0 +1,86 @@ +from flask import Blueprint +from datetime import datetime, timedelta + +from flaschengeist import logger +from flaschengeist.config import config +from flaschengeist.plugins import Plugin +from flaschengeist.utils.HTTP import no_content + + +class __Task: + def __init__(self, function, **kwags): + self.function = function + self.interval = timedelta(**kwags) + + +_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): + add_scheduled(id, function, replace, **kwargs) + return function + + if not isinstance(id, str): + raise TypeError + return real_decorator + + +class SchedulerPlugin(Plugin): + blueprint = Blueprint("scheduler", __name__) + + def load(self): + 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) + + cron = config.get("scheduler", {}).get("cron", "passive_web").lower() + + 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) + + 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()) + + 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 + 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) diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py new file mode 100644 index 0000000..23dac59 --- /dev/null +++ b/flaschengeist/plugins/users/__init__.py @@ -0,0 +1,262 @@ +"""Users plugin + +Provides routes used to manage users +""" +from http.client import CREATED +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 +from flaschengeist.config import config +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 +from flaschengeist.utils.HTTP import created, no_content +from flaschengeist.utils.datetime import from_iso_format + + +class UsersPlugin(Plugin): + blueprint = Blueprint("users", __name__) + + def install(self): + self.install_permissions(permissions.permissions) + + +@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. + 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` + + 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(permissions.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) + + +@UsersPlugin.blueprint.route("/users", methods=["GET"]) +@login_required() +@headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) +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) + + +@UsersPlugin.blueprint.route("/users/", methods=["GET"]) +@login_required() +@headers({"Cache-Control": "private, must-revalidate, max-age=300"}) +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 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, 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() + return jsonify(serial) + + +@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, 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, etag) + + +@UsersPlugin.blueprint.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(permissions.EDIT): + raise Forbidden + + file = request.files.get("file") + if file: + userController.save_avatar(user, file) + return created() + else: + 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): + """Delete user by userid + + Route: ``/users/`` | Method: ``DELETE`` + + Args: + userid: UserID of user to retrieve + current_session: Session sent with Authorization Header + + Returns: + HTTP-204 or HTTP error + """ + logger.debug("Delete user {{ {} }}".format(userid)) + user = userController.get_user(userid) + userController.delete_user(user) + return no_content() + + +@UsersPlugin.blueprint.route("/users/", methods=["PUT"]) +@login_required() +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-204 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 + + author = user + if userid != current_session.user_.userid: + author = current_session.user_ + if not author.has_permission(permissions.EDIT): + raise 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]) + if "birthday" in data: + user.birthday = from_iso_format(data["birthday"]) + + if "roles" in data: + roles = set(data["roles"]) + if not author.has_permission(permissions.SET_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, + ) + 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_notification(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() diff --git a/flaschengeist/plugins/users/cli.py b/flaschengeist/plugins/users/cli.py new file mode 100644 index 0000000..8f8589a --- /dev/null +++ b/flaschengeist/plugins/users/cli.py @@ -0,0 +1,91 @@ +import click +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 + + +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("--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 user(add_role, delete, user): + """Manage users""" + from flaschengeist.database import db + + ctx = click.get_current_context() + + try: + 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() + except NotFound: + ctx.fail(f"User not found {uid}") 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("_")] diff --git a/flaschengeist/utils/HTTP.py b/flaschengeist/utils/HTTP.py new file mode 100644 index 0000000..e81316b --- /dev/null +++ b/flaschengeist/utils/HTTP.py @@ -0,0 +1,29 @@ +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) + + +def created(obj=None): + return make_response(jsonify(obj if obj is not None else ""), CREATED) diff --git a/flaschengeist/utils/__init__.py b/flaschengeist/utils/__init__.py new file mode 100644 index 0000000..d85bd72 --- /dev/null +++ b/flaschengeist/utils/__init__.py @@ -0,0 +1 @@ +"""Common utilities""" diff --git a/flaschengeist/utils/datetime.py b/flaschengeist/utils/datetime.py new file mode 100644 index 0000000..87ec300 --- /dev/null +++ b/flaschengeist/utils/datetime.py @@ -0,0 +1,11 @@ +import datetime + + +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) + return time.replace(tzinfo=datetime.timezone.utc) diff --git a/flaschengeist/utils/decorators.py b/flaschengeist/utils/decorators.py new file mode 100644 index 0000000..34814dc --- /dev/null +++ b/flaschengeist/utils/decorators.py @@ -0,0 +1,68 @@ +from functools import wraps +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.headers, permission) + 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. + + 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): + session = extract_session(permission) + kwargs["current_session"] = session + logger.debug("token {{ {} }} is valid".format(session.token)) + return func(*args, **kwargs) + + 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 diff --git a/flaschengeist/utils/hook.py b/flaschengeist/utils/hook.py new file mode 100644 index 0000000..f7c7fb7 --- /dev/null +++ b/flaschengeist/utils/hook.py @@ -0,0 +1,81 @@ +from functools import wraps + + +_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. + + 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): + raise TypeError("HookAfter requires the ID of the function to hook up") + + def wrapped(function): + _hooks_after.setdefault(id, []).append(function) + return function + + return wrapped diff --git a/geruecht/__init__.py b/geruecht/__init__.py deleted file mode 100644 index 2687a9a..0000000 --- a/geruecht/__init__.py +++ /dev/null @@ -1,59 +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 .logger import getDebugLogger -from geruecht.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) - -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) diff --git a/geruecht/baruser/routes.py b/geruecht/baruser/routes.py deleted file mode 100644 index dc7c383..0000000 --- a/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.uid] = {"username": user.uid, - "firstname": user.firstname, - "lastname": user.lastname, - "amount": all, - "locked": user.locked, - "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 - 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/geruecht/config.yml.example b/geruecht/config.yml.example deleted file mode 100644 index 62a16a2..0000000 --- a/geruecht/config.yml.example +++ /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/geruecht/configparser.py b/geruecht/configparser.py deleted file mode 100644 index 1fbe90c..0000000 --- a/geruecht/configparser.py +++ /dev/null @@ -1,134 +0,0 @@ -import yaml -import sys -from .logger import getDebugLogger -DEBUG = getDebugLogger() - -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'] - DEBUG.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']: - DEBUG.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( - '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( - '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( - '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( - '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( - '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( - '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)) - if 'AccessTokenLifeTime' in self.config: - self.accessTokenLifeTime = int(self.config['AccessTokenLifeTime']) - DEBUG.info("Set AccessTokenLifeTime: {}".format( - self.accessTokenLifeTime)) - else: - self.accessTokenLifeTime = default['AccessTokenLifeTime'] - DEBUG.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( - 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") - 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") - else: - self.config['Mail']['port'] = int(self.config['Mail']['port']) - DEBUG.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") - 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") - 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") - 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") - self.mail = self.config['Mail'] - DEBUG.info('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): - DEBUG.error(msg, exc_info=True) - sys.exit(-1) - - -if __name__ == '__main__': - ConifgParser() diff --git a/geruecht/controller/__init__.py b/geruecht/controller/__init__.py deleted file mode 100644 index b7f1734..0000000 --- a/geruecht/controller/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from geruecht.logger import getDebugLogger -from geruecht.configparser import ConifgParser -import os - -print(os.getcwd()) - -config = ConifgParser('geruecht/config.yml') - -LOGGER = getDebugLogger() - -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] - -dbConfig = config.getDatabase() -ldapConfig = config.getLDAP() -accConfig = config.getAccessToken() -mailConfig = config.getMail() diff --git a/geruecht/controller/accesTokenController.py b/geruecht/controller/accesTokenController.py deleted file mode 100644 index bec7703..0000000 --- a/geruecht/controller/accesTokenController.py +++ /dev/null @@ -1,129 +0,0 @@ -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 datetime import datetime, timedelta -import hashlib -from . import Singleton -from geruecht.logger import getDebugLogger - -debug = getDebugLogger() - -mainController = mc.MainController() -db = dc.DatabaseController() - -class AccesTokenController(metaclass=Singleton): - """ Control all createt AccesToken - - 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 - tokenList = None - - def __init__(self, lifetime=1800): - """ Initialize AccessTokenController - - 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)) - - def validateAccessToken(self, token, group): - """ Verify Accestoken - - Verify an Accestoken and Group so if the User has permission or not. - Retrieves the accestoken if valid else retrieves False - - Args: - token: Token to verify. - group: Group like 'moneymaster', 'gastro', 'user' or 'bar' - 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)) - endTime = accToken.timestamp + timedelta(seconds=accToken.lifetime) - now = datetime.now() - debug.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, endTime)) - if now <= endTime: - debug.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 - 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)) - return False - - def createAccesToken(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 - - 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)) - 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) - - def deleteAccessToken(self, accToken): - db.deleteAccessToken(accToken) - - def updateAccessToken(self, accToken): - accToken.updateTimestamp() - return db.updateAccessToken(accToken) diff --git a/geruecht/controller/databaseController/__init__.py b/geruecht/controller/databaseController/__init__.py deleted file mode 100644 index 8f783fd..0000000 --- a/geruecht/controller/databaseController/__init__.py +++ /dev/null @@ -1,71 +0,0 @@ -from ..mainController import Singleton -from geruecht import db -from ..databaseController import dbUserController, dbCreditListController, dbJobKindController, dbPricelistController, dbWorkerController, dbWorkgroupController, dbJobInviteController, dbJobRequesController, dbAccessTokenController, dbRegistrationController -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, - 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/geruecht/controller/databaseController/dbAccessTokenController.py b/geruecht/controller/databaseController/dbAccessTokenController.py deleted file mode 100644 index 13ae442..0000000 --- a/geruecht/controller/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/geruecht/controller/databaseController/dbCreditListController.py b/geruecht/controller/databaseController/dbCreditListController.py deleted file mode 100644 index 62a3528..0000000 --- a/geruecht/controller/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.uid == '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/geruecht/controller/databaseController/dbJobInviteController.py b/geruecht/controller/databaseController/dbJobInviteController.py deleted file mode 100644 index 91d54ad..0000000 --- a/geruecht/controller/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/geruecht/controller/databaseController/dbJobKindController.py b/geruecht/controller/databaseController/dbJobKindController.py deleted file mode 100644 index d131eb3..0000000 --- a/geruecht/controller/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/geruecht/controller/databaseController/dbJobRequesController.py b/geruecht/controller/databaseController/dbJobRequesController.py deleted file mode 100644 index 8f77752..0000000 --- a/geruecht/controller/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/geruecht/controller/databaseController/dbPricelistController.py b/geruecht/controller/databaseController/dbPricelistController.py deleted file mode 100644 index 2b0c749..0000000 --- a/geruecht/controller/databaseController/dbPricelistController.py +++ /dev/null @@ -1,128 +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") - return cursor.fetchall() - 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) - return cursor.fetchone() - 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/geruecht/controller/databaseController/dbRegistrationController.py b/geruecht/controller/databaseController/dbRegistrationController.py deleted file mode 100644 index 39aa2e1..0000000 --- a/geruecht/controller/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/geruecht/controller/databaseController/dbUserController.py b/geruecht/controller/databaseController/dbUserController.py deleted file mode 100644 index 3e419d4..0000000 --- a/geruecht/controller/databaseController/dbUserController.py +++ /dev/null @@ -1,214 +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): - 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) - 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.uid, 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.uid) - 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.uid) - 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/geruecht/controller/databaseController/dbWorkerController.py b/geruecht/controller/databaseController/dbWorkerController.py deleted file mode 100644 index 3d7652d..0000000 --- a/geruecht/controller/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='{}'".format(user.id, 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/geruecht/controller/databaseController/dbWorkgroupController.py b/geruecht/controller/databaseController/dbWorkgroupController.py deleted file mode 100644 index c67f5d2..0000000 --- a/geruecht/controller/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/geruecht/controller/emailController.py b/geruecht/controller/emailController.py deleted file mode 100644 index 067a34d..0000000 --- a/geruecht/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/geruecht/controller/ldapController.py b/geruecht/controller/ldapController.py deleted file mode 100644 index da3044e..0000000 --- a/geruecht/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 geruecht 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/geruecht/controller/mainController/__init__.py b/geruecht/controller/mainController/__init__.py deleted file mode 100644 index ae3f73a..0000000 --- a/geruecht/controller/mainController/__init__.py +++ /dev/null @@ -1,152 +0,0 @@ -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 datetime import datetime, timedelta -from geruecht.logger import getDebugLogger -from ..mainController import mainJobKindController, mainCreditListController, mainPricelistController, mainUserController, mainWorkerController, mainWorkgroupController, mainJobInviteController, mainJobRequestController, mainRegistrationController, mainPasswordReset - -db = dc.DatabaseController() -ldap = lc.LDAPController() -emailController = ec.EmailController() - -debug = getDebugLogger() - - -class MainController(mainJobKindController.Base, - mainCreditListController.Base, - mainPricelistController.Base, - mainUserController.Base, - mainWorkerController.Base, - mainWorkgroupController.Base, - mainJobInviteController.Base, - mainJobRequestController.Base, - mainRegistrationController.Base, - mainPasswordReset.Base, - metaclass=Singleton): - - def __init__(self): - debug.debug("init UserController") - pass - - def setLockedDay(self, date, locked, hard=False): - debug.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)) - return retVal - - def getLockedDays(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 - lockday = self.getLockedDay(startdate) - retVal.append(lockday) - debug.debug("lock days are {{ {} }}".format(retVal)) - return retVal - - def getLockedDaysFromList(self, date_list): - debug.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): - debug.info("get locked day on {{ {} }}".format(date)) - now = datetime.now() - debug.debug("now is {{ {} }}".format(now)) - oldMonth = False - debug.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)) - 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") - 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) - debug.debug("get lock day from database") - retVal = db.getLockedDay(date.date()) - if not retVal: - debug.debug( - "lock day not exists, retVal is {{ {} }}".format(retVal)) - if date.date() <= monthLockedEndDate.date(): - debug.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)) - return retVal - - def __updateDataFromLDAP(self, user): - debug.info("update data from ldap for user {{ {} }}".format(user)) - groups = ldap.getGroup(user.uid) - debug.debug("ldap gorups are {{ {} }}".format(groups)) - user_data = ldap.getUserData(user.uid) - debug.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") - 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) - debug.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)) - return True if result else False - - def sendMail(self, username): - debug.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)) - return retVal - - def sendAllMail(self): - debug.info("send mail to all user") - 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/geruecht/controller/mainController/mainCreditListController.py b/geruecht/controller/mainController/mainCreditListController.py deleted file mode 100644 index 7e6f19f..0000000 --- a/geruecht/controller/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.uid == '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.uid == '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/geruecht/controller/mainController/mainJobInviteController.py b/geruecht/controller/mainController/mainJobInviteController.py deleted file mode 100644 index ed8e50c..0000000 --- a/geruecht/controller/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/geruecht/controller/mainController/mainJobKindController.py b/geruecht/controller/mainController/mainJobKindController.py deleted file mode 100644 index 3eb67f0..0000000 --- a/geruecht/controller/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/geruecht/controller/mainController/mainJobRequestController.py b/geruecht/controller/mainController/mainJobRequestController.py deleted file mode 100644 index e31845e..0000000 --- a/geruecht/controller/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/geruecht/controller/mainController/mainPasswordReset.py b/geruecht/controller/mainController/mainPasswordReset.py deleted file mode 100644 index 8cc031d..0000000 --- a/geruecht/controller/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/geruecht/controller/mainController/mainPricelistController.py b/geruecht/controller/mainController/mainPricelistController.py deleted file mode 100644 index 06cec00..0000000 --- a/geruecht/controller/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/geruecht/controller/mainController/mainRegistrationController.py b/geruecht/controller/mainController/mainRegistrationController.py deleted file mode 100644 index a32893f..0000000 --- a/geruecht/controller/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/geruecht/controller/mainController/mainUserController.py b/geruecht/controller/mainController/mainUserController.py deleted file mode 100644 index 135663d..0000000 --- a/geruecht/controller/mainController/mainUserController.py +++ /dev/null @@ -1,179 +0,0 @@ -from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError - -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() - -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): - 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 diff --git a/geruecht/controller/mainController/mainWorkerController.py b/geruecht/controller/mainController/mainWorkerController.py deleted file mode 100644 index 91a3068..0000000 --- a/geruecht/controller/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/geruecht/controller/mainController/mainWorkgroupController.py b/geruecht/controller/mainController/mainWorkgroupController.py deleted file mode 100644 index c32132e..0000000 --- a/geruecht/controller/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/geruecht/decorator.py b/geruecht/decorator.py deleted file mode 100644 index fe9fb58..0000000 --- a/geruecht/decorator.py +++ /dev/null @@ -1,38 +0,0 @@ -from functools import wraps -from .logger import getDebugLogger -DEBUG = getDebugLogger() - - -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"] - DEBUG.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)) - accToken = accessController.validateAccessToken(token, groups) - DEBUG.debug("accToken is {{ {} }}".format(accToken)) - kwargs['accToken'] = accToken - if accToken: - DEBUG.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)) - return jsonify({"error": "error", - "message": "permission denied"}), 401 - return wrapper - return real_decorator diff --git a/geruecht/exceptions/__init__.py b/geruecht/exceptions/__init__.py deleted file mode 100644 index 307c48e..0000000 --- a/geruecht/exceptions/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -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/geruecht/finanzer/__init__.py b/geruecht/finanzer/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/geruecht/finanzer/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/geruecht/finanzer/routes.py b/geruecht/finanzer/routes.py deleted file mode 100644 index 8b2e2fa..0000000 --- a/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.uid] = user.toJSON() - dic[user.uid]['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.uid] = user.toJSON() - dic[user.uid]['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/geruecht/gastro/__init__.py b/geruecht/gastro/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/geruecht/gastro/routes.py b/geruecht/gastro/routes.py deleted file mode 100644 index baca57a..0000000 --- a/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/geruecht/logger.py b/geruecht/logger.py deleted file mode 100644 index 0348262..0000000 --- a/geruecht/logger.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging -import logging.config -import yaml -from os import path, makedirs, getcwd - -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 open("geruecht/logging.yml", '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/geruecht/logging.yml b/geruecht/logging.yml deleted file mode 100644 index 6ca9241..0000000 --- a/geruecht/logging.yml +++ /dev/null @@ -1,57 +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: 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 - encoding: utf8 - -loggers: - debug_logger: - level: DEBUG - handlers: [console, debug] - propagate: no - - credit_logger: - level: INFO - handlers: [credit] - propagate: no - - jobs_logger: - level: INFO - handlers: [jobs] - propagate: no - -root: - level: INFO - handlers: [console, debug] \ No newline at end of file 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/geruecht/model/accessToken.py b/geruecht/model/accessToken.py deleted file mode 100644 index 6e777f7..0000000 --- a/geruecht/model/accessToken.py +++ /dev/null @@ -1,79 +0,0 @@ -from datetime import datetime -from geruecht.logger import getDebugLogger - -debug = getDebugLogger() - -class AccessToken(): - """ Model for an AccessToken - - Attributes: - timestamp: Is a Datetime from current Time. - 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)) - - 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() - - def toJSON(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 - }, - "lifetime": self.lifetime, - "browser": self.browser, - "platform": self.platform - } - return dic - - def __eq__(self, token): - return True if self.token == token else False - - 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) diff --git a/geruecht/model/creditList.py b/geruecht/model/creditList.py deleted file mode 100644 index 9c909f8..0000000 --- a/geruecht/model/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/geruecht/model/user.py b/geruecht/model/user.py deleted file mode 100644 index 543859c..0000000 --- a/geruecht/model/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/geruecht/registration_route.py b/geruecht/registration_route.py deleted file mode 100644 index 8a4bed1..0000000 --- a/geruecht/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/geruecht/routes.py b/geruecht/routes.py deleted file mode 100644 index 07b78ce..0000000 --- a/geruecht/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/geruecht/user/__init__.py b/geruecht/user/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/geruecht/user/routes.py b/geruecht/user/routes.py deleted file mode 100644 index 6b5e3c2..0000000 --- a/geruecht/user/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/geruecht/vorstand/__init__.py b/geruecht/vorstand/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/geruecht/vorstand/routes.py b/geruecht/vorstand/routes.py deleted file mode 100644 index 143a6c6..0000000 --- a/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/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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3fec353 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 120 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.py deleted file mode 100644 index b6d40ab..0000000 --- a/run.py +++ /dev/null @@ -1,9 +0,0 @@ -from geruecht import app - -""" Main - - Start the backend -""" - -if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0') 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----- diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..568c20e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,79 @@ +[metadata] +license = MIT +version = 2.0.0 +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 + +[options] +include_package_data = True +python_requires = >=3.10 +packages = find: +install_requires = + #Flask>=2.2.2, <2.3 + Flask>=2.2.2, <2.9 + Pillow>=9.2 + flask_cors + flask_migrate>=3.1.0 + flask_sqlalchemy>=2.5.1 + 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 + ua-parser>=0.16.1 + +[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, script.py.mako, *.ini, */migrations/*, migrations/versions/* + +[options.entry_points] +console_scripts = + flaschengeist = flaschengeist.cli:main +flask.commands = + ldap = flaschengeist.plugins.auth_ldap.cli:ldap + user = flaschengeist.plugins.users.cli:user + role = flaschengeist.plugins.users.cli:role +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 + +[tool:pytest] +testpaths = tests + +[coverage:run] +branch = True +source = flaschengeist diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e5f798f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +import os +import tempfile +import pytest + +from flaschengeist import database +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: + _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": {"engine": "sqlite", "database": f"/{db_path}"}, + "LOGGING": {"level": "DEBUG"}, + } + ) + 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: + connection.execute(statement) + yield app + os.close(db_fd) + # os.unlink(db_path) + print(db_path) + + +@pytest.fixture +def client(app): + return app.test_client() diff --git a/tests/data.sql b/tests/data.sql new file mode 100644 index 0000000..98bb489 --- /dev/null +++ b/tests/data.sql @@ -0,0 +1,8 @@ +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); +-- 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 new file mode 100644 index 0000000..b4c60a5 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,32 @@ +import pytest + + +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["userid"] == USERID + # Token works + assert client.get("/auth", headers={"Authorization": f"Bearer {json['token']}"}).status_code == 200 + + +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 + # Valid Token + assert client.get("/auth", headers={"Authorization": f"Bearer {VALID_TOKEN}"}).status_code == 200 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