From d52864a231a0e6039b1dd44b42eca07ae653b457 Mon Sep 17 00:00:00 2001 From: Maxim Slipenko Date: Sat, 20 Jul 2024 12:01:00 +0300 Subject: [PATCH] wip --- poetry.lock | 377 +++++++++++++++++- pyproject.toml | 4 + src/ocab_core/logger.py | 9 +- src/ocab_core/main.py | 19 +- src/ocab_core/modules_system/loaders/base.py | 23 +- .../loaders/fs_loader/FSLoader.py | 38 +- .../modules_system/modules_manager.py | 112 ++++-- .../modules_system/public_api/__init__.py | 1 + .../modules_system/public_api/public_api.py | 48 ++- src/ocab_core/modules_system/safe/policy.py | 41 +- src/ocab_modules/standard/admin/info.json | 4 +- .../standard/command_helper/info.json | 20 +- src/ocab_modules/standard/config/__init__.py | 1 + src/ocab_modules/standard/config/config.py | 69 +++- .../standard/config/config_manager.py | 259 ++++++++++++ src/ocab_modules/standard/config/info.json | 11 +- src/ocab_modules/standard/config/main.py | 35 ++ src/ocab_modules/standard/filters/info.json | 6 +- .../standard/fsm_database_storage/info.json | 18 +- src/ocab_modules/standard/info/info.json | 8 +- .../standard/message_processing/info.json | 8 +- src/ocab_modules/standard/miniapp/__init__.py | 3 +- src/ocab_modules/standard/miniapp/info.json | 14 +- src/ocab_modules/standard/miniapp/lib.py | 172 ++++++++ src/ocab_modules/standard/miniapp/main.py | 29 ++ src/ocab_modules/standard/roles/info.json | 6 +- 26 files changed, 1241 insertions(+), 94 deletions(-) create mode 100644 src/ocab_modules/standard/config/config_manager.py create mode 100644 src/ocab_modules/standard/config/main.py create mode 100644 src/ocab_modules/standard/miniapp/lib.py diff --git a/poetry.lock b/poetry.lock index 1423d5f..e38258f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -301,6 +301,28 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "blinker" +version = "1.8.2" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.8" +files = [ + {file = "blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01"}, + {file = "blinker-1.8.2.tar.gz", hash = "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"}, +] + +[[package]] +name = "cachelib" +version = "0.9.0" +description = "A collection of cache libraries in the same API interface." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachelib-0.9.0-py3-none-any.whl", hash = "sha256:811ceeb1209d2fe51cd2b62810bd1eccf70feba5c52641532498be5c675493b3"}, + {file = "cachelib-0.9.0.tar.gz", hash = "sha256:38222cc7c1b79a23606de5c2607f4925779e37cdcea1c2ad21b8bae94b5425a5"}, +] + [[package]] name = "certifi" version = "2024.7.4" @@ -479,6 +501,128 @@ pyyaml = ">=5.3.1" requests = ">=2.23.0" rich = "*" +[[package]] +name = "dash" +version = "2.17.1" +description = "A Python framework for building reactive web-apps. Developed by Plotly." +optional = false +python-versions = ">=3.8" +files = [ + {file = "dash-2.17.1-py3-none-any.whl", hash = "sha256:3eefc9ac67003f93a06bc3e500cae0a6787c48e6c81f6f61514239ae2da414e4"}, + {file = "dash-2.17.1.tar.gz", hash = "sha256:ee2d9c319de5dcc1314085710b72cd5fa63ff994d913bf72979b7130daeea28e"}, +] + +[package.dependencies] +dash-core-components = "2.0.0" +dash-html-components = "2.0.0" +dash-table = "5.0.0" +Flask = ">=1.0.4,<3.1" +importlib-metadata = "*" +nest-asyncio = "*" +plotly = ">=5.0.0" +requests = "*" +retrying = "*" +setuptools = "*" +typing-extensions = ">=4.1.1" +Werkzeug = "<3.1" + +[package.extras] +celery = ["celery[redis] (>=5.1.2)", "redis (>=3.5.3)"] +ci = ["black (==22.3.0)", "dash-dangerously-set-inner-html", "dash-flow-example (==0.0.5)", "flake8 (==7.0.0)", "flaky (==3.8.1)", "flask-talisman (==1.0.0)", "jupyterlab (<4.0.0)", "mimesis (<=11.1.0)", "mock (==4.0.3)", "numpy (<=1.26.3)", "openpyxl", "orjson (==3.10.3)", "pandas (>=1.4.0)", "pyarrow", "pylint (==3.0.3)", "pytest-mock", "pytest-rerunfailures", "pytest-sugar (==0.9.6)", "pyzmq (==25.1.2)", "xlrd (>=2.0.1)"] +compress = ["flask-compress"] +dev = ["PyYAML (>=5.4.1)", "coloredlogs (>=15.0.1)", "fire (>=0.4.0)"] +diskcache = ["diskcache (>=5.2.1)", "multiprocess (>=0.70.12)", "psutil (>=5.8.0)"] +testing = ["beautifulsoup4 (>=4.8.2)", "cryptography", "dash-testing-stub (>=0.0.2)", "lxml (>=4.6.2)", "multiprocess (>=0.70.12)", "percy (>=2.0.2)", "psutil (>=5.8.0)", "pytest (>=6.0.2)", "requests[security] (>=2.21.0)", "selenium (>=3.141.0,<=4.2.0)", "waitress (>=1.4.4)"] + +[[package]] +name = "dash-bootstrap-components" +version = "1.6.0" +description = "Bootstrap themed components for use in Plotly Dash" +optional = false +python-versions = "<4,>=3.8" +files = [ + {file = "dash_bootstrap_components-1.6.0-py3-none-any.whl", hash = "sha256:97f0f47b38363f18863e1b247462229266ce12e1e171cfb34d3c9898e6e5cd1e"}, + {file = "dash_bootstrap_components-1.6.0.tar.gz", hash = "sha256:960a1ec9397574792f49a8241024fa3cecde0f5930c971a3fc81f016cbeb1095"}, +] + +[package.dependencies] +dash = ">=2.0.0" + +[package.extras] +pandas = ["numpy", "pandas"] + +[[package]] +name = "dash-core-components" +version = "2.0.0" +description = "Core component suite for Dash" +optional = false +python-versions = "*" +files = [ + {file = "dash_core_components-2.0.0-py3-none-any.whl", hash = "sha256:52b8e8cce13b18d0802ee3acbc5e888cb1248a04968f962d63d070400af2e346"}, + {file = "dash_core_components-2.0.0.tar.gz", hash = "sha256:c6733874af975e552f95a1398a16c2ee7df14ce43fa60bb3718a3c6e0b63ffee"}, +] + +[[package]] +name = "dash-extensions" +version = "1.0.18" +description = "Extensions for Plotly Dash." +optional = false +python-versions = "<4,>=3.9" +files = [ + {file = "dash_extensions-1.0.18-py3-none-any.whl", hash = "sha256:17f4469670bd70ce12fac1a05baaae119fb65eee7b012af47aff7377d0399eeb"}, + {file = "dash_extensions-1.0.18.tar.gz", hash = "sha256:a6b6c0952b3af7ae84c418fea4b43cbd0bd4e82f20d91f1573380b8a3d90df0a"}, +] + +[package.dependencies] +dash = ">=2.17.0" +dataclass-wizard = ">=0.22.2,<0.23.0" +Flask-Caching = ">=2.1.0,<3.0.0" +jsbeautifier = ">=1.14.3,<2.0.0" +more-itertools = ">=10.2.0,<11.0.0" +pydantic = ">=2.7.1,<3.0.0" +ruff = ">=0.4.5,<0.5.0" + +[package.extras] +mantine = ["dash-mantine-components (>=0.14.3,<0.15.0)"] + +[[package]] +name = "dash-html-components" +version = "2.0.0" +description = "Vanilla HTML components for Dash" +optional = false +python-versions = "*" +files = [ + {file = "dash_html_components-2.0.0-py3-none-any.whl", hash = "sha256:b42cc903713c9706af03b3f2548bda4be7307a7cf89b7d6eae3da872717d1b63"}, + {file = "dash_html_components-2.0.0.tar.gz", hash = "sha256:8703a601080f02619a6390998e0b3da4a5daabe97a1fd7a9cebc09d015f26e50"}, +] + +[[package]] +name = "dash-table" +version = "5.0.0" +description = "Dash table" +optional = false +python-versions = "*" +files = [ + {file = "dash_table-5.0.0-py3-none-any.whl", hash = "sha256:19036fa352bb1c11baf38068ec62d172f0515f73ca3276c79dee49b95ddc16c9"}, + {file = "dash_table-5.0.0.tar.gz", hash = "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308"}, +] + +[[package]] +name = "dataclass-wizard" +version = "0.22.3" +description = "Marshal dataclasses to/from JSON. Use field properties with initial values. Construct a dataclass schema with JSON input." +optional = false +python-versions = "*" +files = [ + {file = "dataclass-wizard-0.22.3.tar.gz", hash = "sha256:4c46591782265058f1148cfd1f54a3a91221e63986fdd04c9d59f4ced61f4424"}, + {file = "dataclass_wizard-0.22.3-py2.py3-none-any.whl", hash = "sha256:63751203e54b9b9349212cc185331da73c1adc99c51312575eb73bb5c00c1962"}, +] + +[package.extras] +dev = ["Sphinx (==5.3.0)", "bump2version (==1.0.1)", "coverage (>=6.2)", "dataclass-factory (==2.12)", "dataclasses-json (==0.5.6)", "flake8 (>=3)", "jsons (==1.6.1)", "pip (>=21.3.1)", "pytest (==7.0.1)", "pytest-cov (==3.0.0)", "pytest-mock (>=3.6.1)", "pytimeparse (==1.1.8)", "sphinx-issues (==3.0.1)", "sphinx-issues (==4.0.0)", "tox (==3.24.5)", "twine (==3.8.0)", "watchdog[watchmedo] (==2.1.6)", "wheel (==0.37.1)", "wheel (==0.42.0)"] +timedelta = ["pytimeparse (>=1.1.7)"] +yaml = ["PyYAML (>=5.3)"] + [[package]] name = "dataclasses-json" version = "0.6.7" @@ -525,6 +669,16 @@ idna = ["idna (>=3.6)"] trio = ["trio (>=0.23)"] wmi = ["wmi (>=1.5.1)"] +[[package]] +name = "editorconfig" +version = "0.12.4" +description = "EditorConfig File Locator and Interpreter for Python" +optional = false +python-versions = "*" +files = [ + {file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"}, +] + [[package]] name = "email-validator" version = "2.2.0" @@ -614,6 +768,43 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.12.0,<2.13.0" pyflakes = ">=3.2.0,<3.3.0" +[[package]] +name = "flask" +version = "3.0.3" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, + {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=3.0.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "flask-caching" +version = "2.3.0" +description = "Adds caching support to Flask applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Flask_Caching-2.3.0-py3-none-any.whl", hash = "sha256:51771c75682e5abc1483b78b96d9131d7941dc669b073852edfa319dd4e29b6e"}, + {file = "flask_caching-2.3.0.tar.gz", hash = "sha256:d7e4ca64a33b49feb339fcdd17e6ba25f5e01168cf885e53790e885f83a4d2cf"}, +] + +[package.dependencies] +cachelib = ">=0.9.0,<0.10.0" +Flask = "*" + [[package]] name = "flet" version = "0.23.2" @@ -947,6 +1138,25 @@ files = [ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] +[[package]] +name = "importlib-metadata" +version = "8.0.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.0.0-py3-none-any.whl", hash = "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f"}, + {file = "importlib_metadata-8.0.0.tar.gz", hash = "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] + [[package]] name = "isort" version = "5.13.2" @@ -961,6 +1171,17 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jinja2" version = "3.1.4" @@ -978,6 +1199,20 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jsbeautifier" +version = "1.15.1" +description = "JavaScript unobfuscator and beautifier." +optional = false +python-versions = "*" +files = [ + {file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"}, +] + +[package.dependencies] +editorconfig = ">=0.12.2" +six = ">=1.13.0" + [[package]] name = "magic-filter" version = "1.0.12" @@ -1126,6 +1361,17 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "more-itertools" +version = "10.3.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.8" +files = [ + {file = "more-itertools-10.3.0.tar.gz", hash = "sha256:e5d93ef411224fbcef366a6e8ddc4c5781bc6359d43412a65dd5964e46111463"}, + {file = "more_itertools-10.3.0-py3-none-any.whl", hash = "sha256:ea6a02e24a9161e51faad17a8782b92a0df82c12c1c8886fec7f0c3fa1a1b320"}, +] + [[package]] name = "multidict" version = "6.0.5" @@ -1236,6 +1482,17 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "nest-asyncio" +version = "1.6.0" +description = "Patch asyncio to allow nested event loops" +optional = false +python-versions = ">=3.5" +files = [ + {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, + {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -1322,6 +1579,21 @@ docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx- test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] type = ["mypy (>=1.8)"] +[[package]] +name = "plotly" +version = "5.22.0" +description = "An open-source, interactive data visualization library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "plotly-5.22.0-py3-none-any.whl", hash = "sha256:68fc1901f098daeb233cc3dd44ec9dc31fb3ca4f4e53189344199c43496ed006"}, + {file = "plotly-5.22.0.tar.gz", hash = "sha256:859fdadbd86b5770ae2466e542b761b247d1c6b49daed765b95bb8c7063e7469"}, +] + +[package.dependencies] +packaging = "*" +tenacity = ">=6.2.0" + [[package]] name = "pre-commit" version = "3.7.1" @@ -1710,6 +1982,20 @@ files = [ docs = ["Sphinx", "sphinx-rtd-theme"] test = ["pytest", "pytest-mock"] +[[package]] +name = "retrying" +version = "1.3.4" +description = "Retrying" +optional = false +python-versions = "*" +files = [ + {file = "retrying-1.3.4-py3-none-any.whl", hash = "sha256:8cc4d43cb8e1125e0ff3344e9de678fefd85db3b750b81b2240dc0183af37b35"}, + {file = "retrying-1.3.4.tar.gz", hash = "sha256:345da8c5765bd982b1d1915deb9102fd3d1f7ad16bd84a9700b85f64d24e8f3e"}, +] + +[package.dependencies] +six = ">=1.7.0" + [[package]] name = "rich" version = "13.7.1" @@ -1728,6 +2014,32 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "ruff" +version = "0.4.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, + {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, + {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, + {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, + {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, + {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, +] + [[package]] name = "semver" version = "3.0.2" @@ -1739,6 +2051,22 @@ files = [ {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, ] +[[package]] +name = "setuptools" +version = "71.0.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-71.0.1-py3-none-any.whl", hash = "sha256:1eb8ef012efae7f6acbc53ec0abde4bc6746c43087fd215ee09e1df48998711f"}, + {file = "setuptools-71.0.1.tar.gz", hash = "sha256:c51d7fd29843aa18dad362d4b4ecd917022131425438251f4e3d766c964dd1ad"}, +] + +[package.extras] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (<7.4)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "shellingham" version = "1.5.4" @@ -1803,6 +2131,21 @@ files = [ [package.dependencies] pbr = ">=2.0.0,<2.1.0 || >2.1.0" +[[package]] +name = "tenacity" +version = "8.5.0" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"}, + {file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "text-unidecode" version = "1.3" @@ -2186,6 +2529,23 @@ files = [ {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] +[[package]] +name = "werkzeug" +version = "3.0.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8"}, + {file = "werkzeug-3.0.3.tar.gz", hash = "sha256:097e5bfda9f0aba8da6b8545146def481d06aa7d3266e7448e2cccf67dd8bd18"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [[package]] name = "wsproto" version = "1.2.0" @@ -2303,7 +2663,22 @@ files = [ idna = ">=2.0" multidict = ">=4.0" +[[package]] +name = "zipp" +version = "3.19.2" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"}, + {file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"}, +] + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + [metadata] lock-version = "2.0" python-versions = ">=3.11.6,<3.13" -content-hash = "3dfc8d87dd19fe6222428891e73a1bc61edf045596fdecbc7897ecd1356d8b3d" +content-hash = "60d3a08ec1ea70b53fe4e2f3ccc112c30e88a1908acf4af93a24e186f3addea8" diff --git a/pyproject.toml b/pyproject.toml index 4572e41..6c6896f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,10 @@ semver = "^3.0.2" hypercorn = "^0.17.3" flet = "^0.23.2" fastapi = "^0.111.1" +setuptools = "^71.0.1" +dash = "^2.17.1" +dash-extensions = "^1.0.18" +dash-bootstrap-components = "^1.6.0" [tool.poetry.group.dev.dependencies] flake8 = "^7.1.0" diff --git a/src/ocab_core/logger.py b/src/ocab_core/logger.py index b562c01..94bca51 100644 --- a/src/ocab_core/logger.py +++ b/src/ocab_core/logger.py @@ -1,6 +1,7 @@ import logging import os -import time + +# import time import traceback @@ -8,13 +9,13 @@ def setup_logger(): """ Настройка логирования """ - current_date = time.strftime("%d-%m-%Y") + # current_date = time.strftime("%d-%m-%Y") log_dir = os.path.join(os.path.dirname(__file__), "log") os.makedirs(log_dir, exist_ok=True) - log_file = os.path.join(log_dir, f"log-{current_date}.log") + # log_file = os.path.join(log_dir, f"log-{current_date}.log") logging.basicConfig( - filename=log_file, + # filename=log_file, level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S", diff --git a/src/ocab_core/main.py b/src/ocab_core/main.py index d6df85f..7305428 100644 --- a/src/ocab_core/main.py +++ b/src/ocab_core/main.py @@ -11,6 +11,8 @@ from ocab_core.logger import log, setup_logger from ocab_core.modules_system import ModulesManager from ocab_core.modules_system.loaders import FSLoader, UnsafeFSLoader from ocab_core.singleton import Singleton + +# TODO: заменить на get_module("standard.config") from ocab_modules.standard.config.config import get_telegram_token ocab_modules_path = get_module_directory("ocab_modules") @@ -26,16 +28,17 @@ def ocab_modules_loader(namespace: str, module_name: str, safe=True): bot_modules = [ ocab_modules_loader("standard", "config", safe=False), ocab_modules_loader("standard", "database", safe=False), - ocab_modules_loader("standard", "fsm_database_storage", safe=False), + # ocab_modules_loader("standard", "fsm_database_storage", safe=False), ocab_modules_loader("standard", "roles", safe=False), ocab_modules_loader("external", "yandexgpt", safe=False), - ocab_modules_loader("standard", "miniapp", safe=False), + # ocab_modules_loader("standard", "command_helper"), - ocab_modules_loader("standard", "info"), - ocab_modules_loader("standard", "filters"), - ocab_modules_loader("external", "create_report_apps"), - ocab_modules_loader("standard", "admin"), + # ocab_modules_loader("standard", "info"), + # ocab_modules_loader("standard", "filters"), + # ocab_modules_loader("external", "create_report_apps"), + # ocab_modules_loader("standard", "admin"), ocab_modules_loader("standard", "message_processing"), + ocab_modules_loader("standard", "miniapp", safe=False), ] @@ -48,6 +51,9 @@ async def long_polling_mode(): async def webhook_mode(): singleton = Singleton() app = FastAPI() + + app.mount("/webapp", singleton.storage["webapp"]) + await register_bot_webhook(app, singleton.bot, singleton.dp) await singleton.bot.set_webhook( "https://mackerel-pumped-foal.ngrok-free.app/webhook" @@ -71,7 +77,6 @@ async def init_app(): await singleton.modules_manager.load(module_loader) singleton.dp = Dispatcher(storage=singleton.storage["_fsm_storage"]) - singleton.dp.include_routers(*singleton.storage["_routers"]) for middleware in singleton.storage["_outer_message_middlewares"]: diff --git a/src/ocab_core/modules_system/loaders/base.py b/src/ocab_core/modules_system/loaders/base.py index 166ede8..ebcb89e 100644 --- a/src/ocab_core/modules_system/loaders/base.py +++ b/src/ocab_core/modules_system/loaders/base.py @@ -1,9 +1,27 @@ import types from dataclasses import dataclass +from typing import Dict, List, Optional, Union from dataclasses_json import dataclass_json +@dataclass_json +@dataclass +class DependencyInfo: + version: str + uses: Optional[List[str]] = None + + +DependencyType = Union[str, DependencyInfo] + + +@dataclass_json +@dataclass +class Dependencies: + required: Optional[Dict[str, DependencyType]] = None + optional: Optional[Dict[str, DependencyType]] = None + + @dataclass_json @dataclass class ModuleInfo: @@ -11,9 +29,10 @@ class ModuleInfo: name: str description: str version: str - author: str | list[str] + author: Union[str, List[str]] privileged: bool - dependencies: dict + dependencies: Dependencies + pythonDependencies: Optional[Dependencies] = None class AbstractLoader: diff --git a/src/ocab_core/modules_system/loaders/fs_loader/FSLoader.py b/src/ocab_core/modules_system/loaders/fs_loader/FSLoader.py index 2c17ecc..3a04dd9 100644 --- a/src/ocab_core/modules_system/loaders/fs_loader/FSLoader.py +++ b/src/ocab_core/modules_system/loaders/fs_loader/FSLoader.py @@ -3,6 +3,7 @@ from pathlib import Path from RestrictedPython import compile_restricted_exec +# from ocab_core.logger import log from ocab_core.modules_system.loaders.unsafe_fs_loader import UnsafeFSLoader from ocab_core.modules_system.safe.policy import ( ALLOWED_IMPORTS, @@ -16,13 +17,30 @@ class FSLoader(UnsafeFSLoader): super().__init__(path) self.builtins = BUILTINS.copy() self.builtins["__import__"] = self._hook_import + self.module_info = self.info() + self.allowed_python_dependencies = self._get_allowed_python_dependencies() def load(self): - info = self.info() - if info.privileged: + if self.module_info.privileged: raise Exception("Only non privileged modules are allowed to be imported") + self.module_id = self.module_info.id + return self._hook_import(".") + def _get_allowed_python_dependencies(self): + allowed = {} + + if self.module_info.pythonDependencies: + if self.module_info.pythonDependencies.required: + allowed.update(self.module_info.pythonDependencies.required) + if self.module_info.pythonDependencies.optional: + allowed.update(self.module_info.pythonDependencies.optional) + + for allowed_module in ALLOWED_IMPORTS: + allowed[allowed_module] = "*" + + return allowed + def _resolve_module_from_path(self, module_name: str): path = Path(self.path) @@ -44,12 +62,14 @@ class FSLoader(UnsafeFSLoader): return file_path def _hook_import(self, name: str, *args, **kwargs): - for allowed in ALLOWED_IMPORTS: - if name == allowed or name.startswith(f"{allowed}."): - return __import__(name, *args, **kwargs) - if name == "ocab_core.modules_system.public_api": - return __import__(name, *args, **kwargs) + module = __import__(name, *args, **kwargs) + module.__ocab_module_id__ = self.module_id + return module + + for key in self.allowed_python_dependencies.keys(): + if name == key or name.startswith(f"{key}."): + return __import__(name, *args, **kwargs) module_file_path = self._resolve_module_from_path(name) @@ -58,9 +78,7 @@ class FSLoader(UnsafeFSLoader): module = types.ModuleType(name) module.__dict__.update( - { - "__builtins__": self.builtins, - } + {"__builtins__": self.builtins, "__ocab_module_id__": self.module_id} ) result = compile_restricted_exec(src, "", policy=RestrictedPythonPolicy) diff --git a/src/ocab_core/modules_system/modules_manager.py b/src/ocab_core/modules_system/modules_manager.py index a3340ac..81c1d0f 100644 --- a/src/ocab_core/modules_system/modules_manager.py +++ b/src/ocab_core/modules_system/modules_manager.py @@ -1,8 +1,13 @@ import inspect +import pkg_resources import semver -from ocab_core.modules_system.loaders.base import AbstractLoader +from ocab_core.modules_system.loaders.base import ( + AbstractLoader, + DependencyInfo, + ModuleInfo, +) def is_version_compatible(version, requirement): @@ -23,6 +28,46 @@ def is_version_compatible(version, requirement): return True +def check_python_dependencies(info: ModuleInfo): + if info.pythonDependencies and info.pythonDependencies.required: + for dependency, req in info.pythonDependencies.required.items(): + try: + installed_version = pkg_resources.get_distribution(dependency).version + except pkg_resources.DistributionNotFound: + raise Exception( + f"Module {info.id} requires {dependency}," + f"but it is not installed" + ) + + if isinstance(req, str): + required_version = req + elif isinstance(req, DependencyInfo): + required_version = req.version + else: + raise ValueError(f"Invalid dependency specification for {dependency}") + + if not is_version_compatible(installed_version, required_version): + raise Exception( + f"Module {info.id} depends on {dependency} {required_version}, " + f"but version {installed_version} is installed" + ) + + +def check_dependency_uses( + loaded_dependency, required_uses, dependent_module_id, dependency_id +): + module = loaded_dependency.get("module") + if not module: + raise Exception(f"Module object not found for dependency {dependency_id}") + + for required_attr in required_uses: + if not hasattr(module, required_attr): + raise Exception( + f"Module {dependent_module_id} requires '{required_attr}' " + f"from {dependency_id}, but it is not available" + ) + + async def await_if_async(module, method_name): if hasattr(module, method_name): method = getattr(module, method_name) @@ -43,33 +88,54 @@ class ModulesManager: if any(mod["info"].id == info.id for mod in self.modules): return - # Check dependencies - for dependency, version in info.dependencies.items(): - loaded_dependency = next( - (mod for mod in self.modules if mod["info"].id == dependency), None - ) - if not loaded_dependency: - raise Exception( - f"Module {info.id} depends on {dependency}, but it is not loaded" - ) - loaded_dependency_info = loaded_dependency["info"] - if not is_version_compatible(loaded_dependency_info.version, version): - raise Exception( - f"Module {info.id} depends on {dependency}, " - f"but version {version} is not compatible" - ) + self.check_module_dependencies(info) + check_python_dependencies(info) + module_info = { + "info": info, + "module": None, + } + self.modules.append(module_info) module = loader.load() - - self.modules.append( - { - "info": info, - "module": module, - } - ) + module_info["module"] = module await await_if_async(module, "module_init") + def check_module_dependencies(self, info: ModuleInfo): + if info.dependencies.required: + for dependency, req in info.dependencies.required.items(): + loaded_dependency = next( + (mod for mod in self.modules if mod["info"].id == dependency), None + ) + if not loaded_dependency: + raise Exception( + f"Module {info.id} depends on {dependency}," + f"but it is not loaded" + ) + + loaded_dependency_info = loaded_dependency["info"] + + if isinstance(req, str): + required_version = req + elif isinstance(req, DependencyInfo): + required_version = req.version + if req.uses: + check_dependency_uses( + loaded_dependency, req.uses, info.id, dependency + ) + else: + raise ValueError( + f"Invalid dependency specification for {dependency}" + ) + + if not is_version_compatible( + loaded_dependency_info.version, required_version + ): + raise Exception( + f"Module {info.id} depends on {dependency} {required_version}, " + f"but version {loaded_dependency_info.version} is loaded" + ) + async def late_init(self): for m in self.modules: module = m["module"] diff --git a/src/ocab_core/modules_system/public_api/__init__.py b/src/ocab_core/modules_system/public_api/__init__.py index 41384ac..1a1cadb 100644 --- a/src/ocab_core/modules_system/public_api/__init__.py +++ b/src/ocab_core/modules_system/public_api/__init__.py @@ -6,6 +6,7 @@ from .public_api import ( get_module, register_outer_message_middleware, register_router, + set_chat_menu_button, set_my_commands, ) from .utils import Utils diff --git a/src/ocab_core/modules_system/public_api/public_api.py b/src/ocab_core/modules_system/public_api/public_api.py index 8fada13..d7598ec 100644 --- a/src/ocab_core/modules_system/public_api/public_api.py +++ b/src/ocab_core/modules_system/public_api/public_api.py @@ -1,3 +1,4 @@ +import inspect import types from typing import Any, Tuple, Union @@ -5,9 +6,16 @@ from aiogram import BaseMiddleware, Router from aiogram.fsm.context import FSMContext from aiogram.fsm.storage.base import StorageKey +# from ocab_core.logger import log +from ocab_core.modules_system.loaders.base import DependencyInfo from ocab_core.singleton import Singleton +async def set_chat_menu_button(menu_button): + app = Singleton() + await app.bot.set_chat_menu_button(menu_button=menu_button) + + def register_router(router: Router): app = Singleton() app.storage["_routers"].append(router) @@ -45,10 +53,41 @@ def set_fsm(storage): def get_module( module_id: str, paths=None ) -> Union[types.ModuleType, Union[Any, None], Tuple[Union[Any, None], ...]]: + + caller_globals = inspect.currentframe().f_back.f_globals app = Singleton() + + allowed_uses = None + + if "__ocab_module_id__" in caller_globals: + caller_module_id = caller_globals["__ocab_module_id__"] + caller_module_info = app.modules_manager.get_info_by_id(caller_module_id) + + if caller_module_info and caller_module_info.dependencies: + dependency = None + if caller_module_info.dependencies.required: + dependency = caller_module_info.dependencies.required.get(module_id) + if not dependency and caller_module_info.dependencies.optional: + dependency = caller_module_info.dependencies.optional.get(module_id) + + if ( + dependency + and isinstance(dependency, DependencyInfo) + and dependency.uses + ): + allowed_uses = set(dependency.uses) + module = app.modules_manager.get_by_id(module_id) + if not module: + raise ModuleNotFoundError(f"Module {module_id} not found") + if paths is None: + if allowed_uses is not None: + raise PermissionError( + f"Direct access to module {module_id} is " + f"not allowed for {caller_module_id}. Specify allowed attributes." + ) return module if isinstance(paths, str): @@ -61,9 +100,16 @@ def get_module( try: parts = path.split(".") for part in parts: + if allowed_uses is not None and part not in allowed_uses: + raise AttributeError( + f"Access to '{part}' is not allowed " + + f"for module {caller_module_id}" + ) current_obj = getattr(current_obj, part) results.append(current_obj) - except AttributeError: + except AttributeError as e: + if "is not allowed" in str(e): + raise PermissionError(str(e)) results.append(None) if len(results) == 1: diff --git a/src/ocab_core/modules_system/safe/policy.py b/src/ocab_core/modules_system/safe/policy.py index def90d0..d9957ae 100644 --- a/src/ocab_core/modules_system/safe/policy.py +++ b/src/ocab_core/modules_system/safe/policy.py @@ -1,6 +1,8 @@ +import types from _ast import AnnAssign from typing import Any +import flet as ft from aiogram import Bot from RestrictedPython import ( RestrictingNodeTransformer, @@ -9,8 +11,8 @@ from RestrictedPython import ( utility_builtins, ) from RestrictedPython.Eval import default_guarded_getitem, default_guarded_getiter -from RestrictedPython.Guards import ( - full_write_guard, +from RestrictedPython.Guards import ( # guarded_setattr,; full_write_guard, + _write_wrapper, guarded_unpack_sequence, safer_getattr, ) @@ -95,6 +97,39 @@ def safes_getattr(object, name, default=None, getattr=safer_getattr): return getattr(object, name, default) +trusted_settters_classes = [ft.Page, ft.View] + + +def safes_setattr(self, key, value): + if ( + isinstance(getattr(type(self), key, None), property) + and getattr(type(self), key).fset is not None + ): + getattr(type(self), key).fset(self, value) + return + + +def write_guard(): + # ed scope abuse! + # safetypes and Wrapper variables are used by guard() + safetypes = {dict, list} + Wrapper = _write_wrapper() + + def guard(ob): + # Don't bother wrapping simple types, or objects that claim to + # handle their own write security. + if type(ob) in safetypes or hasattr(ob, "_guarded_writes"): + return ob + + if type(ob) in trusted_settters_classes: + setattr(ob, "__guarded_setattr__", types.MethodType(safes_setattr, ob)) + + # Hand the object to the Wrapper instance, then return the instance. + return Wrapper(ob) + + return guard + + BUILTINS = safe_builtins.copy() BUILTINS.update(utility_builtins) BUILTINS.update(limited_builtins) @@ -103,7 +138,7 @@ BUILTINS["__metaclass__"] = _metaclass BUILTINS["_getitem_"] = default_guarded_getitem BUILTINS["_getattr_"] = safes_getattr BUILTINS["_getiter_"] = default_guarded_getiter -BUILTINS["_write_"] = full_write_guard +BUILTINS["_write_"] = write_guard() BUILTINS["_unpack_sequence_"] = guarded_unpack_sequence BUILTINS["staticmethod"] = staticmethod BUILTINS["tuple"] = tuple diff --git a/src/ocab_modules/standard/admin/info.json b/src/ocab_modules/standard/admin/info.json index e447822..b2d01d3 100644 --- a/src/ocab_modules/standard/admin/info.json +++ b/src/ocab_modules/standard/admin/info.json @@ -6,6 +6,8 @@ "version": "1.0.0", "privileged": false, "dependencies": { - "standard.filters": "^1.0.0" + "required": { + "standard.filters": "^1.0.0" + } } } diff --git a/src/ocab_modules/standard/command_helper/info.json b/src/ocab_modules/standard/command_helper/info.json index 28cadc9..dda6c9e 100644 --- a/src/ocab_modules/standard/command_helper/info.json +++ b/src/ocab_modules/standard/command_helper/info.json @@ -1,12 +1,14 @@ { - "id": "standard.command_helper", - "name": "Command helper", - "description": "Модуль для отображения команд при вводе '/'", - "author": "OCAB Team", - "version": "1.0.0", - "privileged": false, - "dependencies": { - "standard.roles": "^1.0.0", - "standard.database": "^1.0.0" + "id": "standard.command_helper", + "name": "Command helper", + "description": "Модуль для отображения команд при вводе '/'", + "author": "OCAB Team", + "version": "1.0.0", + "privileged": false, + "dependencies": { + "required": { + "standard.roles": "^1.0.0", + "standard.database": "^1.0.0" } + } } diff --git a/src/ocab_modules/standard/config/__init__.py b/src/ocab_modules/standard/config/__init__.py index 24fd17a..da07248 100644 --- a/src/ocab_modules/standard/config/__init__.py +++ b/src/ocab_modules/standard/config/__init__.py @@ -5,3 +5,4 @@ from .config import ( get_yandexgpt_in_words, get_yandexgpt_start_words, ) +from .main import module_late_init diff --git a/src/ocab_modules/standard/config/config.py b/src/ocab_modules/standard/config/config.py index 8835b1d..2caa089 100644 --- a/src/ocab_modules/standard/config/config.py +++ b/src/ocab_modules/standard/config/config.py @@ -1,22 +1,67 @@ # flake8: noqa -import yaml +from .config_manager import ConfigManager -from src.service import paths +config = ConfigManager( + config_path="/home/maxim/dev/alt-gnome-infrastructure/ocab/src/ocab_core/config.yaml" +) -def get_config(is_test: bool = False) -> dict: - if is_test: - path = f"{paths.modules_standard}/config/tests" - else: - path = paths.core - path = f"{path}/config.yaml" +def register_settings(settings_manager: ConfigManager): + # TELEGRAM settings + settings_manager.register_setting( + ["TELEGRAM", "TOKEN"], "", "string", is_private=True + ) + settings_manager.register_setting( + ["TELEGRAM", "APPROVED_CHAT_ID"], + "-123456789 | -012345678", + "string", + pretty_name="ID разрешенных чатов", + description='Чаты, в которых будет работать бот. "|" - разделитель', + ) + settings_manager.register_setting( + ["TELEGRAM", "ADMINCHATID"], + -12345678, + "number", + pretty_name="ID чата администраторов", + ) + settings_manager.register_setting( + ["TELEGRAM", "DEFAULT_CHAT_TAG"], + "@alt_gnome_chat", + "string", + pretty_name="Основной чат", + ) + settings_manager.register_setting(["TELEGRAM", "CHECK_BOT"], True, "checkbox") - with open(path, "r") as file: - return yaml.full_load(file) + # YANDEXGPT settings + settings_manager.register_setting( + ["YANDEXGPT", "TOKEN"], "", "string", is_private=True + ) + settings_manager.register_setting( + ["YANDEXGPT", "TOKEN_FOR_REQUEST"], 8000, "number" + ) + settings_manager.register_setting(["YANDEXGPT", "TOKEN_FOR_ANSWER"], 2000, "number") + settings_manager.register_setting( + ["YANDEXGPT", "CATALOGID"], "", "string", is_private=True + ) + settings_manager.register_setting( + ["YANDEXGPT", "PROMPT"], "Ты чат-бот ...", "string" + ) + settings_manager.register_setting( + ["YANDEXGPT", "STARTWORD"], "Бот| Бот, | бот | бот,", "string" + ) + settings_manager.register_setting( + ["YANDEXGPT", "INWORD"], "помогите | не работает", "string" + ) + + # ROLES settings + settings_manager.register_setting(["ROLES", "ADMIN"], 2, "number") + settings_manager.register_setting(["ROLES", "MODERATOR"], 1, "number") + settings_manager.register_setting(["ROLES", "USER"], 0, "number") + settings_manager.register_setting(["ROLES", "BOT"], 3, "number") -config = get_config() +register_settings(config) def get_telegram_token() -> str: @@ -76,4 +121,4 @@ def get_yandexgpt_token_for_answer() -> int: def get_access_rights() -> dict: - return get_config()["ACCESS_RIGHTS"] + return config["ACCESS_RIGHTS"] diff --git a/src/ocab_modules/standard/config/config_manager.py b/src/ocab_modules/standard/config/config_manager.py new file mode 100644 index 0000000..d496a96 --- /dev/null +++ b/src/ocab_modules/standard/config/config_manager.py @@ -0,0 +1,259 @@ +from typing import Any, Dict, List, Optional, Union + +import yaml + +# from ocab_core.modules_system.public_api import get_module, log + +try: + import dash_bootstrap_components as dbc + from dash_extensions.enrich import Input, Output, dcc, html + + DASH_AVAILABLE = True +except ImportError: + DASH_AVAILABLE = False + + +class ConfigManager: + def __init__(self, config_path: str): + self._config_path = config_path + self._config = self.load_config() + self._registered_settings = dict() + self._registered_settings_meta = dict() + self._update_callbacks = [] + self._update_required = False + + @property + def config(self): + return self._config + + @config.setter + def config(self, value): + self._config = value + + def load_config(self) -> Dict[str, Any]: + with open(self._config_path, "r") as file: + return yaml.safe_load(file) + + def save_config(self): + with open(self._config_path, "w") as file: + yaml.dump(self._config, file) + + def register_setting( + self, + key: Union[str, List[str]], + default_value: Any, + setting_type: str, + is_private: bool = False, + pretty_name: str = None, + description: str = None, + options: Optional[List[str]] = None, + ): + if isinstance(key, str): + key = [key] + + current = self._registered_settings + for k in key[:-1]: + if k not in current: + current[k] = {} + current = current[k] + + current[key[-1]] = self.get_nested_setting(self._config, key, default_value) + + self.set_nested_setting( + self._registered_settings_meta, + key, + { + "type": setting_type, + "is_private": is_private, + "options": options, + "pretty_name": pretty_name, + "description": description, + }, + ) + + def get_nested_setting( + self, config: dict, keys: List[str], default: Any = None + ) -> Any: + current = config + for key in keys: + if key in current: + current = current[key] + else: + return default + return current + + def set_nested_setting(self, config: dict, keys: List[str], value: Any): + current = config + for key in keys[:-1]: + if key not in current: + current[key] = {} + current = current[key] + current[keys[-1]] = value + + def __getitem__(self, key): + if key in self._registered_settings: + return self._registered_settings[key] + raise KeyError(key) + + def update_setting(self, key: Union[str, List[str]], value: Any): + if isinstance(key, str): + key = [key] + self.set_nested_setting(self._registered_settings, key, value) + self.set_nested_setting(self._config, key, value) + self.save_config() + + def get_settings_layout(self): + from dash_extensions.enrich import DashBlueprint + + bp = DashBlueprint() + + def create_layout(): + def create_nested_layout(settings: dict, key_list=None): + if key_list is None: + key_list = [] + + components = [] + + for key, value in settings.items(): + current_key_list = key_list.copy() + current_key_list.append(key) + + if isinstance(value, dict): + nested = create_nested_layout(value, current_key_list) + if len(nested) > 0: + components.append( + dbc.Card( + [ + dbc.CardHeader(html.H3(key, className="mb-0")), + dbc.CardBody(nested), + ], + className="mb-3", + ) + ) + else: + meta = self.get_nested_setting( + self._registered_settings_meta, current_key_list + ) + + if not meta.get("is_private"): + row = [] + label_text = meta.get("pretty_name", key) + + if meta.get("type") != "checkbox": + row.append(dbc.Label(label_text)) + + component_id = { + "type": "setting", + "key": "-".join(current_key_list), + } + + if meta.get("type") == "string": + component = dbc.Input( + id=component_id, type="text", value=value + ) + elif meta.get("type") == "number": + component = dbc.Input( + id=component_id, type="number", value=value + ) + elif meta.get("type") == "checkbox": + component = dbc.Col( + [ + dbc.Checkbox( + id=component_id, + value=value, + label=dbc.Label( + label_text, + style={"margin-right": "10px"}, + check=True, + ), + ) + ] + ) + elif meta.get("type") == "select": + options = [ + {"label": opt, "value": opt} + for opt in meta.get("options", []) + ] + component = dcc.Dropdown( + id=component_id, options=options, value=value + ) + else: + continue + + row.append(component) + + if meta.get("description"): + row.append(dbc.FormText(meta.get("description"))) + + components.append(dbc.Row(row, className="mb-3")) + + return components + + settings_components = create_nested_layout(self._registered_settings) + + layout = html.Div( + [ + html.H1("Настройки"), + dbc.Form(settings_components), + dbc.Button( + "Сохранить", + id="save-settings", + color="primary", + className="mt-3", + n_clicks=0, + ), + html.Div(id="settings-update-trigger", style={"display": "none"}), + html.Span( + id="save-confirmation", style={"verticalAlign": "middle"} + ), + dcc.Store(id="settings-store"), + ], + style={ + "padding": "20px", + }, + ) + + return layout + + bp.layout = create_layout + self.setup_callbacks(bp) + + return bp + + def setup_callbacks(self, app): + # ws = WebSocket(app, url="/ws") + + @app.callback( + Output("save-confirmation", "children"), + # Output("settings-store", "data"), + Input("save-settings", "n_clicks"), + # State({"type": "setting", "key": ALL}, "value"), + # State({"type": "setting", "key": ALL}, "id"), + running=[(Output("save-settings", "disabled"), True, False)], + ) + def save_settings(n_clicks, values, ids): + if n_clicks > 0: + # updated_settings = {} + # print(ids) + # for value, id_dict in zip(values, ids): + # key = id_dict["key"] + # self.update_setting(key.split("-"), value) + # updated_settings[key] = value + + return "Настройки сохранены!" # , json.dumps(updated_settings) + return "" # , None + + # @app.callback( + # Output({"type": "setting", "key": ALL}, "value"), + # Input("settings-store", "data"), + # ) + # def update_settings_from_store(data): + # if data: + # updated_settings = json.loads(data) + # print( + # [current_value for key, current_value in updated_settings.items()] + # ) + # return [ + # current_value for key, current_value in updated_settings.items() + # ] + # raise dash.exceptions.PreventUpdate() diff --git a/src/ocab_modules/standard/config/info.json b/src/ocab_modules/standard/config/info.json index 81bdd92..9d6b7ef 100644 --- a/src/ocab_modules/standard/config/info.json +++ b/src/ocab_modules/standard/config/info.json @@ -5,5 +5,14 @@ "author": "OCAB Team", "version": "1.0.0", "privileged": true, - "dependencies": {} + "dependencies": { + "optional": { + "standard.miniapp": "^1.0.0" + } + }, + "pythonDependencies": { + "optional": { + "flet": "^0.23.2" + } + } } diff --git a/src/ocab_modules/standard/config/main.py b/src/ocab_modules/standard/config/main.py new file mode 100644 index 0000000..b450224 --- /dev/null +++ b/src/ocab_modules/standard/config/main.py @@ -0,0 +1,35 @@ +from ocab_core.modules_system.public_api import get_module, log + +from .config import config + + +def register_settings_page(): + try: + register_page = get_module("standard.miniapp", "register_page") + # + # def setup_callbacks_wrapper(config_manager): + # def setup(app): + # config_manager.setup_callbacks(app) + # + # return setup + # + # register_page( + # name="Настройки", + # path="/settings", + # layout=config.get_settings_layout(), + # setup_callbacks=setup_callbacks_wrapper(config), + # ) + register_page( + name="Настройки", path="/settings", blueprint=config.get_settings_layout() + ) + pass + + except Exception as e: + log(str(e)) + pass + + +def module_late_init(): + register_settings_page() + + pass diff --git a/src/ocab_modules/standard/filters/info.json b/src/ocab_modules/standard/filters/info.json index e65ad0b..205b829 100644 --- a/src/ocab_modules/standard/filters/info.json +++ b/src/ocab_modules/standard/filters/info.json @@ -6,7 +6,9 @@ "version": "1.0.0", "privileged": false, "dependencies": { - "standard.roles": "^1.0.0", - "standard.config": "^1.0.0" + "required": { + "standard.roles": "^1.0.0", + "standard.config": "^1.0.0" + } } } diff --git a/src/ocab_modules/standard/fsm_database_storage/info.json b/src/ocab_modules/standard/fsm_database_storage/info.json index a592463..58c05b4 100644 --- a/src/ocab_modules/standard/fsm_database_storage/info.json +++ b/src/ocab_modules/standard/fsm_database_storage/info.json @@ -1,11 +1,13 @@ { - "id": "standard.fsm_database_storage", - "name": "FSM Database Storage", - "description": "Очень полезный модуль", - "author": "OCAB Team", - "version": "1.0.0", - "privileged": false, - "dependencies": { - "standard.database": "^1.0.0" + "id": "standard.fsm_database_storage", + "name": "FSM Database Storage", + "description": "Очень полезный модуль", + "author": "OCAB Team", + "version": "1.0.0", + "privileged": false, + "dependencies": { + "required": { + "standard.database": "^1.0.0" } + } } diff --git a/src/ocab_modules/standard/info/info.json b/src/ocab_modules/standard/info/info.json index fdccd3a..3a37f73 100644 --- a/src/ocab_modules/standard/info/info.json +++ b/src/ocab_modules/standard/info/info.json @@ -6,8 +6,10 @@ "version": "1.0.0", "privileged": false, "dependencies": { - "standard.roles": "^1.0.0", - "standard.database": "^1.0.0", - "standard.command_helper": "^1.0.0" + "required": { + "standard.roles": "^1.0.0", + "standard.database": "^1.0.0", + "standard.command_helper": "^1.0.0" + } } } diff --git a/src/ocab_modules/standard/message_processing/info.json b/src/ocab_modules/standard/message_processing/info.json index b138c6a..8fbeb1a 100644 --- a/src/ocab_modules/standard/message_processing/info.json +++ b/src/ocab_modules/standard/message_processing/info.json @@ -6,8 +6,10 @@ "version": "1.0.0", "privileged": false, "dependencies": { - "standard.roles": "^1.0.0", - "standard.database": "^1.0.0", - "standard.command_helper": "^1.0.0" + "required": { + "standard.roles": "^1.0.0", + "standard.database": "^1.0.0", + "standard.command_helper": "^1.0.0" + } } } diff --git a/src/ocab_modules/standard/miniapp/__init__.py b/src/ocab_modules/standard/miniapp/__init__.py index c8fccb0..19b7130 100644 --- a/src/ocab_modules/standard/miniapp/__init__.py +++ b/src/ocab_modules/standard/miniapp/__init__.py @@ -1 +1,2 @@ -from .main import module_init +from .lib import register_page +from .main import module_init, module_late_init diff --git a/src/ocab_modules/standard/miniapp/info.json b/src/ocab_modules/standard/miniapp/info.json index 61da1a7..872a9d3 100644 --- a/src/ocab_modules/standard/miniapp/info.json +++ b/src/ocab_modules/standard/miniapp/info.json @@ -5,5 +5,17 @@ "author": "OCAB Team", "version": "1.0.0", "privileged": false, - "dependencies": {} + "dependencies": { + "required": { + "standard.config": { + "version": "^1.0.0", + "uses": [] + } + } + }, + "pythonDependencies": { + "required": { + "flet": "^0.23.2" + } + } } diff --git a/src/ocab_modules/standard/miniapp/lib.py b/src/ocab_modules/standard/miniapp/lib.py new file mode 100644 index 0000000..63e033f --- /dev/null +++ b/src/ocab_modules/standard/miniapp/lib.py @@ -0,0 +1,172 @@ +from collections import OrderedDict + +import dash +import dash_bootstrap_components as dbc +from dash_extensions.enrich import DashBlueprint, DashProxy, Input, Output, dcc, html +from dash_extensions.pages import setup_page_components +from flask import Flask + +pages = OrderedDict() + + +def register_page(name, path, blueprint): + pages[path] = { + "name": name, + "blueprint": blueprint, + } + + +def register_home_page(): + page = DashBlueprint() + page.layout = html.Div([html.H1("Главная")]) + register_page("Главная", path="/", blueprint=page) + + +register_home_page() + + +def create_dash_app(requests_pathname_prefix: str = None) -> dash.Dash: + server = Flask(__name__) + app = DashProxy( + pages_folder="", + use_pages=True, + suppress_callback_exceptions=True, + external_stylesheets=[ + dbc.themes.BOOTSTRAP, + dbc.icons.BOOTSTRAP, + ], + external_scripts=[ + "https://telegram.org/js/telegram-web-app.js" + ], # Add Telegram Mini Apps script to + server=server, + requests_pathname_prefix=requests_pathname_prefix, + meta_tags=[ + {"name": "viewport", "content": "width=device-width, initial-scale=1"}, + ], + ) + + app.enable_dev_tools( + dev_tools_ui=True, + dev_tools_serve_dev_bundles=True, + ) + + # Register pages + for path, page in pages.items(): + # dash.register_page(page["name"], path=path, layout=page["layout"]) + page["blueprint"].register(app, path, prefix="a") + + # Create sidebar + sidebar = dbc.Offcanvas( + id="offcanvas", + title="Меню", + is_open=False, + children=[ + dbc.Nav( + [ + dbc.NavLink( + page["name"], + href=f"{requests_pathname_prefix}{path.lstrip('/')}", + id={"type": "nav-link", "index": path}, + ) + for path, page in pages.items() + ], + vertical=True, + pills=True, + ), + ], + ) + + # Create navbar + navbar = dbc.Navbar( + dbc.Container( + [ + dbc.Button( + html.I(className="bi bi-list"), + id="open-offcanvas", + color="light", + className="me-2", + ), + dbc.NavbarBrand("OCAB"), + ] + ), + color="primary", + dark=True, + ) + + # Define app layout + app.layout = html.Div( + [ + dcc.Location(id="url", refresh=False), + dcc.Store(id="user-data", storage_type="session"), + dcc.Interval( + id="init-telegram-interval", + interval=100, + n_intervals=0, + max_intervals=1, + ), + # WebSocket(url="/ws"), + html.Div(id="telegram-login-info"), + navbar, + sidebar, + dash.page_container, + setup_page_components(), + ] + ) + + # Clientside callback to initialize Telegram Mini Apps and get user data + app.clientside_callback( + """ + function(n_intervals) { + return new Promise((resolve, reject) => { + resolve("test"); + if (window.Telegram && window.Telegram.WebApp) { + const webapp = window.Telegram.WebApp; + webapp.ready(); + if (webapp.initDataUnsafe && webapp.initDataUnsafe.user) { + resolve(webapp.initDataUnsafe.user); + } else { + reject("User not authorized"); + } + } else { + reject("Telegram Mini Apps not available"); + } + }); + } + """, + Output("user-data", "data"), + Input("init-telegram-interval", "n_intervals"), + ) + + # Открытие на кнопку меню + app.clientside_callback( + """ + function(n_clicks) { + if (n_clicks == null) { + return false; + } + return true; + } + """, + Output( + "offcanvas", + "is_open", + ), + Input("open-offcanvas", "n_clicks"), + ) + + # Закрываем offcanvas при клике на ссылку в меню + app.clientside_callback( + """ + function(n_clicks) { + if (n_clicks == null) { + return true; + } + return false; + } + """, + Output("offcanvas", "is_open", allow_duplicate=True), + Input({"type": "nav-link", "index": dash.dependencies.ALL}, "n_clicks"), + prevent_initial_call="initial_duplicate", + ) + + return app diff --git a/src/ocab_modules/standard/miniapp/main.py b/src/ocab_modules/standard/miniapp/main.py index ade39ee..8ae3d99 100644 --- a/src/ocab_modules/standard/miniapp/main.py +++ b/src/ocab_modules/standard/miniapp/main.py @@ -1,2 +1,31 @@ +from aiogram import types +from fastapi.middleware.wsgi import WSGIMiddleware + +from ocab_core.modules_system.public_api import Storage, set_chat_menu_button + + +def get_link(): + pass + + def module_init(): pass + + +def register_page(): + pass + + +async def module_late_init(): + from .lib import create_dash_app + + dash_app = create_dash_app(requests_pathname_prefix="/webapp/") + + Storage.set("webapp", WSGIMiddleware(dash_app.server)) + + web_app_info = types.WebAppInfo( + url="https://mackerel-pumped-foal.ngrok-free.app/webapp" + ) + menu_button = types.MenuButtonWebApp(text="Меню", web_app=web_app_info) + + await set_chat_menu_button(menu_button) diff --git a/src/ocab_modules/standard/roles/info.json b/src/ocab_modules/standard/roles/info.json index 8a70a31..474430e 100644 --- a/src/ocab_modules/standard/roles/info.json +++ b/src/ocab_modules/standard/roles/info.json @@ -6,7 +6,9 @@ "version": "1.0.0", "privileged": true, "dependencies": { - "standard.config": "^1.0.0", - "standard.database": "^1.0.0" + "required": { + "standard.config": "^1.0.0", + "standard.database": "^1.0.0" + } } }