From 49fd33fffc15b2db85a4f53ed7240369d9b77e06 Mon Sep 17 00:00:00 2001 From: Evgeniy Alekseev Date: Wed, 16 Oct 2019 03:06:58 +0300 Subject: [PATCH] Scala (#3) * initial migration to scala --- .gitignore | 161 +++++----- README.md | 86 +----- build.sbt | 27 ++ migrations/20190830_01_sYYZL-init-tables.py | 38 --- migrations/20190910_01_tgBmx-users-table.py | 17 -- package/ini/ffxivbis.ini | 10 - package/ini/ffxivbis.ini.d/ariyala.ini | 3 - package/ini/ffxivbis.ini.d/auth.ini | 4 - package/ini/ffxivbis.ini.d/logging.ini | 44 --- package/ini/ffxivbis.ini.d/sqlite.ini | 3 - project/assembly.sbt | 1 + project/build.properties | 1 + setup.cfg | 5 - setup.py | 52 ---- src/ffxivbis/__init__.py | 0 src/ffxivbis/api/__init__.py | 0 src/ffxivbis/api/auth.py | 53 ---- src/ffxivbis/api/json.py | 34 --- src/ffxivbis/api/routes.py | 66 ----- src/ffxivbis/api/spec.py | 78 ----- src/ffxivbis/api/utils.py | 42 --- src/ffxivbis/api/views/__init__.py | 0 src/ffxivbis/api/views/api/__init__.py | 0 src/ffxivbis/api/views/api/bis.py | 159 ---------- src/ffxivbis/api/views/api/login.py | 139 --------- src/ffxivbis/api/views/api/logout.py | 46 --- src/ffxivbis/api/views/api/loot.py | 159 ---------- src/ffxivbis/api/views/api/openapi.py | 195 ------------ src/ffxivbis/api/views/api/player.py | 107 ------- src/ffxivbis/api/views/common/__init__.py | 0 src/ffxivbis/api/views/common/bis_base.py | 49 ---- src/ffxivbis/api/views/common/login_base.py | 43 --- src/ffxivbis/api/views/common/loot_base.py | 43 --- src/ffxivbis/api/views/common/player_base.py | 50 ---- src/ffxivbis/api/views/html/__init__.py | 0 src/ffxivbis/api/views/html/api.py | 29 -- src/ffxivbis/api/views/html/bis.py | 82 ------ src/ffxivbis/api/views/html/index.py | 23 -- src/ffxivbis/api/views/html/loot.py | 70 ----- src/ffxivbis/api/views/html/loot_suggest.py | 64 ---- src/ffxivbis/api/views/html/player.py | 67 ----- src/ffxivbis/api/views/html/static.py | 31 -- src/ffxivbis/api/views/html/users.py | 62 ---- src/ffxivbis/api/web.py | 71 ----- src/ffxivbis/application/__init__.py | 0 src/ffxivbis/application/application.py | 31 -- src/ffxivbis/application/core.py | 41 --- src/ffxivbis/core/__init__.py | 0 src/ffxivbis/core/ariyala_parser.py | 83 ------ src/ffxivbis/core/config.py | 65 ---- src/ffxivbis/core/database.py | 110 ------- src/ffxivbis/core/exceptions.py | 27 -- src/ffxivbis/core/loot_selector.py | 32 -- src/ffxivbis/core/party.py | 81 ----- src/ffxivbis/core/postgres.py | 164 ----------- src/ffxivbis/core/sqlite.py | 152 ---------- src/ffxivbis/core/sqlite_helper.py | 36 --- src/ffxivbis/core/version.py | 9 - src/ffxivbis/models/__init__.py | 0 src/ffxivbis/models/action.py | 16 - src/ffxivbis/models/bis.py | 140 --------- src/ffxivbis/models/error.py | 36 --- src/ffxivbis/models/job.py | 87 ------ src/ffxivbis/models/loot.py | 37 --- src/ffxivbis/models/piece.py | 168 ----------- src/ffxivbis/models/player.py | 201 ------------- src/ffxivbis/models/player_edit.py | 35 --- src/ffxivbis/models/serializable.py | 57 ---- src/ffxivbis/models/upgrade.py | 23 -- src/ffxivbis/models/user.py | 42 --- .../db/migration/V1_0__Create_tables.sql | 36 +++ src/main/resources/reference.conf | 43 +++ .../main/resources/swagger/index.html | 22 +- .../me/arcanis/ffxivbis/Application.scala | 43 +++ .../scala/me/arcanis/ffxivbis/ffxivbis.scala | 12 + .../arcanis/ffxivbis/http/AriyalaHelper.scala | 16 + .../arcanis/ffxivbis/http/Authorization.scala | 53 ++++ .../me/arcanis/ffxivbis/http/BiSHelper.scala | 29 ++ .../me/arcanis/ffxivbis/http/LootHelper.scala | 29 ++ .../arcanis/ffxivbis/http/PlayerHelper.scala | 37 +++ .../arcanis/ffxivbis/http/RootEndpoint.scala | 45 +++ .../me/arcanis/ffxivbis/http/Swagger.scala | 24 ++ .../me/arcanis/ffxivbis/http/UserHelper.scala | 28 ++ .../ffxivbis/http/api/v1/ApiV1Endpoint.scala | 17 ++ .../ffxivbis/http/api/v1/BiSEndpoint.scala | 132 +++++++++ .../ffxivbis/http/api/v1/LootEndpoint.scala | 139 +++++++++ .../ffxivbis/http/api/v1/PlayerEndpoint.scala | 97 ++++++ .../ffxivbis/http/api/v1/UserEndpoint.scala | 152 ++++++++++ .../ffxivbis/http/api/v1/json/ApiAction.scala | 5 + .../http/api/v1/json/JsonSupport.scala | 31 ++ .../api/v1/json/PieceActionResponse.scala | 8 + .../http/api/v1/json/PieceResponse.scala | 16 + .../api/v1/json/PlayerActionResponse.scala | 7 + .../api/v1/json/PlayerBiSLinkResponse.scala | 7 + .../http/api/v1/json/PlayerIdResponse.scala | 12 + .../json/PlayerIdWithCountersResponse.scala | 29 ++ .../http/api/v1/json/PlayerResponse.scala | 25 ++ .../http/api/v1/json/UserResponse.scala | 18 ++ .../me/arcanis/ffxivbis/models/BiS.scala | 72 +++++ .../me/arcanis/ffxivbis/models/Job.scala | 65 ++++ .../me/arcanis/ffxivbis/models/Loot.scala | 3 + .../me/arcanis/ffxivbis/models/Piece.scala | 97 ++++++ .../me/arcanis/ffxivbis/models/Player.scala | 44 +++ .../me/arcanis/ffxivbis/models/PlayerId.scala | 18 ++ .../models/PlayerIdWithCounters.scala | 45 +++ .../me/arcanis/ffxivbis/models/User.scala | 17 ++ .../me/arcanis/ffxivbis/service/Ariyala.scala | 117 ++++++++ .../arcanis/ffxivbis/service/Database.scala | 26 ++ .../ffxivbis/service/LootSelector.scala | 20 ++ .../me/arcanis/ffxivbis/service/Party.scala | 48 +++ .../service/impl/DatabaseBiSHandler.scala | 29 ++ .../ffxivbis/service/impl/DatabaseImpl.scala | 22 ++ .../service/impl/DatabaseLootHandler.scala | 34 +++ .../service/impl/DatabasePartyHandler.scala | 39 +++ .../service/impl/DatabaseUserHandler.scala | 33 +++ .../arcanis/ffxivbis/storage/BiSProfile.scala | 48 +++ .../ffxivbis/storage/DatabaseProfile.scala | 62 ++++ .../ffxivbis/storage/LootProfile.scala | 50 ++++ .../arcanis/ffxivbis/storage/Migration.scala | 23 ++ .../ffxivbis/storage/PlayersProfile.scala | 58 ++++ .../ffxivbis/storage/UsersProfile.scala | 48 +++ .../me/arcanis/ffxivbis/utils/Implicits.scala | 14 + templates/bis.jinja2 | 74 ----- templates/error.jinja2 | 3 - templates/export_to_csv.jinja2 | 2 - templates/index.jinja2 | 34 --- templates/loot.jinja2 | 61 ---- templates/loot_suggest.jinja2 | 59 ---- templates/party.jinja2 | 60 ---- templates/root.jinja2 | 1 - templates/search_line.jinja2 | 3 - templates/static/styles.css | 277 ------------------ templates/static/table_export.js | 31 -- templates/static/table_search.js | 21 -- templates/users.jinja2 | 51 ---- test/__init__.py | 5 - test/conftest.py | 131 --------- test/test_ariyala.py | 11 - test/test_bis.py | 20 -- test/test_loot_selector.py | 10 - test/test_party.py | 95 ------ test/test_piece.py | 13 - test/test_player.py | 73 ----- test/test_view_bis.py | 67 ----- test/test_view_loot.py | 86 ------ test/test_view_player.py | 81 ----- 146 files changed, 2242 insertions(+), 5058 deletions(-) create mode 100644 build.sbt delete mode 100644 migrations/20190830_01_sYYZL-init-tables.py delete mode 100644 migrations/20190910_01_tgBmx-users-table.py delete mode 100644 package/ini/ffxivbis.ini delete mode 100644 package/ini/ffxivbis.ini.d/ariyala.ini delete mode 100644 package/ini/ffxivbis.ini.d/auth.ini delete mode 100644 package/ini/ffxivbis.ini.d/logging.ini delete mode 100644 package/ini/ffxivbis.ini.d/sqlite.ini create mode 100644 project/assembly.sbt create mode 100644 project/build.properties delete mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 src/ffxivbis/__init__.py delete mode 100644 src/ffxivbis/api/__init__.py delete mode 100644 src/ffxivbis/api/auth.py delete mode 100644 src/ffxivbis/api/json.py delete mode 100644 src/ffxivbis/api/routes.py delete mode 100644 src/ffxivbis/api/spec.py delete mode 100644 src/ffxivbis/api/utils.py delete mode 100644 src/ffxivbis/api/views/__init__.py delete mode 100644 src/ffxivbis/api/views/api/__init__.py delete mode 100644 src/ffxivbis/api/views/api/bis.py delete mode 100644 src/ffxivbis/api/views/api/login.py delete mode 100644 src/ffxivbis/api/views/api/logout.py delete mode 100644 src/ffxivbis/api/views/api/loot.py delete mode 100644 src/ffxivbis/api/views/api/openapi.py delete mode 100644 src/ffxivbis/api/views/api/player.py delete mode 100644 src/ffxivbis/api/views/common/__init__.py delete mode 100644 src/ffxivbis/api/views/common/bis_base.py delete mode 100644 src/ffxivbis/api/views/common/login_base.py delete mode 100644 src/ffxivbis/api/views/common/loot_base.py delete mode 100644 src/ffxivbis/api/views/common/player_base.py delete mode 100644 src/ffxivbis/api/views/html/__init__.py delete mode 100644 src/ffxivbis/api/views/html/api.py delete mode 100644 src/ffxivbis/api/views/html/bis.py delete mode 100644 src/ffxivbis/api/views/html/index.py delete mode 100644 src/ffxivbis/api/views/html/loot.py delete mode 100644 src/ffxivbis/api/views/html/loot_suggest.py delete mode 100644 src/ffxivbis/api/views/html/player.py delete mode 100644 src/ffxivbis/api/views/html/static.py delete mode 100644 src/ffxivbis/api/views/html/users.py delete mode 100644 src/ffxivbis/api/web.py delete mode 100644 src/ffxivbis/application/__init__.py delete mode 100644 src/ffxivbis/application/application.py delete mode 100644 src/ffxivbis/application/core.py delete mode 100644 src/ffxivbis/core/__init__.py delete mode 100644 src/ffxivbis/core/ariyala_parser.py delete mode 100644 src/ffxivbis/core/config.py delete mode 100644 src/ffxivbis/core/database.py delete mode 100644 src/ffxivbis/core/exceptions.py delete mode 100644 src/ffxivbis/core/loot_selector.py delete mode 100644 src/ffxivbis/core/party.py delete mode 100644 src/ffxivbis/core/postgres.py delete mode 100644 src/ffxivbis/core/sqlite.py delete mode 100644 src/ffxivbis/core/sqlite_helper.py delete mode 100644 src/ffxivbis/core/version.py delete mode 100644 src/ffxivbis/models/__init__.py delete mode 100644 src/ffxivbis/models/action.py delete mode 100644 src/ffxivbis/models/bis.py delete mode 100644 src/ffxivbis/models/error.py delete mode 100644 src/ffxivbis/models/job.py delete mode 100644 src/ffxivbis/models/loot.py delete mode 100644 src/ffxivbis/models/piece.py delete mode 100644 src/ffxivbis/models/player.py delete mode 100644 src/ffxivbis/models/player_edit.py delete mode 100644 src/ffxivbis/models/serializable.py delete mode 100644 src/ffxivbis/models/upgrade.py delete mode 100644 src/ffxivbis/models/user.py create mode 100644 src/main/resources/db/migration/V1_0__Create_tables.sql create mode 100644 src/main/resources/reference.conf rename templates/api.jinja2 => src/main/resources/swagger/index.html (61%) create mode 100644 src/main/scala/me/arcanis/ffxivbis/Application.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/ffxivbis.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/AriyalaHelper.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/Authorization.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/Swagger.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/ApiV1Endpoint.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/ApiAction.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PieceActionResponse.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PieceResponse.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerActionResponse.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerBiSLinkResponse.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerIdResponse.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerIdWithCountersResponse.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerResponse.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/UserResponse.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/models/BiS.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/models/Job.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/models/Loot.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/models/Piece.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/models/Player.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/models/PlayerIdWithCounters.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/models/User.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/service/Database.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/service/LootSelector.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/service/Party.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseImpl.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/storage/BiSProfile.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/storage/DatabaseProfile.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/storage/LootProfile.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/storage/Migration.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/storage/PlayersProfile.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/utils/Implicits.scala delete mode 100644 templates/bis.jinja2 delete mode 100644 templates/error.jinja2 delete mode 100644 templates/export_to_csv.jinja2 delete mode 100644 templates/index.jinja2 delete mode 100644 templates/loot.jinja2 delete mode 100644 templates/loot_suggest.jinja2 delete mode 100644 templates/party.jinja2 delete mode 100644 templates/root.jinja2 delete mode 100644 templates/search_line.jinja2 delete mode 100644 templates/static/styles.css delete mode 100644 templates/static/table_export.js delete mode 100644 templates/static/table_search.js delete mode 100644 templates/users.jinja2 delete mode 100644 test/__init__.py delete mode 100644 test/conftest.py delete mode 100644 test/test_ariyala.py delete mode 100644 test/test_bis.py delete mode 100644 test/test_loot_selector.py delete mode 100644 test/test_party.py delete mode 100644 test/test_piece.py delete mode 100644 test/test_player.py delete mode 100644 test/test_view_bis.py delete mode 100644 test/test_view_loot.py delete mode 100644 test/test_view_player.py diff --git a/.gitignore b/.gitignore index 7f6977d..f93713a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,96 +1,87 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +#### joe made this: http://goel.io/joe -# C extensions -*.so +#### jetbrains #### -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -parts/ -sdist/ -*.egg-info/ -.installed.cfg -*.egg +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec +# User-specific stuff: +.idea -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +## File-based project format: +*.iws -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +#### gradle #### + +.gradle +/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties + +#### java #### + +*.class + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +#### scala #### + +*.class +*.log + +# sbt specific .cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log* -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder +.history +.lib/ +dist/* target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ -# IPython Notebook -.ipynb_checkpoints +# Scala-IDE specific +.scala_dependencies +.worksheet -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# dotenv -.env - -# virtualenv -venv/ -ENV/ - -# Spyder project settings -.spyderproject - -# Rope project settings -.ropeproject - -*.deb - -.idea/ - -.mypy_cache/ - -/cache +# ENSIME specific +.ensime_cache/ +.ensime *.db diff --git a/README.md b/README.md index c29c8d4..e5f2526 100644 --- a/README.md +++ b/README.md @@ -4,98 +4,20 @@ Service which allows to manage savage loot distribution easy. ## Installation and usage -This service requires python >= 3.7. For other dependencies see `setup.py`. - In general installation process looks like: ```bash -python setup.py build install -python setup.py test # if you want to run tests +sbt assembly ``` -With virtualenv (make sure that virtualenv package was installed) the process may look like: +Service can be run by using command: ```bash -virtualenv -p python3.7 env -source env/bin/activate -python setup.py install -pip install aiosqlite # setup.py does not handle extras +java -cp ./target/scala-2.13/ffxivbis-scala-assembly-0.1.jar me.arcanis.ffxivbis.ffxivbis ``` -Service can be run by using command (if you don't use virtualenv, you have to run it from `src` directory): - -```bash -python -m ffxivbis.application.application -``` - -To see all available options type `--help`. - ## Web service -REST API documentation is available at `http://0.0.0.0:8000/api-docs`. HTML representation is available at `http://0.0.0.0:8000`. +REST API documentation is available at `http://0.0.0.0:8000/swagger`. HTML representation is available at `http://0.0.0.0:8000`. *Note*: host and port depend on configuration settings. - -### Authorization - -Default admin user is `admin:qwerty`, but it may be changed by generating new hash, e.g.: - -```python -from passlib.hash import md5_crypt -md5_crypt.hash('newstrongpassword') -``` - -and add new password to configuration. - -## Configuration - -* `settings` section - - General project settings. - - * `include`: path to include configuration directory, string, optional. - * `logging`: path to logging configuration, see `logging.ini` for reference, string, optional. - * `database`: database provide name, string, required. Allowed values: `sqlite`, `postgres`. - * `priority`: methods of `Player` class which will be called to sort players for loot priority, space separated list of strings, required. - -* `ariyala` section - - Settings related to ariyala parser. - - * `ariyala_url`: ariyala base url, string, required. - * `xivapi_key`: xivapi developer key, string, optional. - * `xivapi_url`: xivapi base url, string, required. - -* `auth` section - - Authentication settings. - - * `enabled`: whether authentication enabled or not, boolean, required. - * `root_username`: username of administrator, string, required. - * `root_password`: md5 hashed password of administrator, string, required. - -* `postgres` section - - Database settings for `postgres` provider. - - * `database`: database name, string, required. - * `host`: database host, string, required. - * `password`: database password, string, required. - * `port`: database port, int, required. - * `username`: database username, string, required. - * `migrations_path`: path to database migrations, string, required. - -* `sqlite` section - - Database settings for `sqlite` provider. - - * `database_path`: path to sqlite database, string, required. - * `migrations_path`: path to database migrations, string, required. - -* `web` section - - Web server related settings. - - * `host`: address to bind, string, required. - * `port`: port to bind, int, required. - * `templates`: path to directory with jinja templates, string, required. \ No newline at end of file diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..b8d5ece --- /dev/null +++ b/build.sbt @@ -0,0 +1,27 @@ +name := "ffxivbis-scala" + +version := "0.9.0" + +scalaVersion := "2.13.1" + +scalacOptions ++= Seq("-deprecation", "-feature") + +libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.3" +libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2" + +libraryDependencies += "io.spray" %% "spray-json" % "1.3.5" + +libraryDependencies += "com.typesafe.akka" %% "akka-http" % "10.1.10" +libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.10" +libraryDependencies += "com.typesafe.akka" %% "akka-stream" % "2.5.23" +libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.0.4" +libraryDependencies += "javax.ws.rs" % "javax.ws.rs-api" % "2.1.1" + +libraryDependencies += "com.typesafe.slick" %% "slick" % "3.3.2" +libraryDependencies += "com.typesafe.slick" %% "slick-hikaricp" % "3.3.2" +libraryDependencies += "org.flywaydb" % "flyway-core" % "6.0.6" +libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.28.0" +libraryDependencies += "org.postgresql" % "postgresql" % "9.3-1104-jdbc4" + +libraryDependencies += "org.mindrot" % "jbcrypt" % "0.3m" + diff --git a/migrations/20190830_01_sYYZL-init-tables.py b/migrations/20190830_01_sYYZL-init-tables.py deleted file mode 100644 index d027c4a..0000000 --- a/migrations/20190830_01_sYYZL-init-tables.py +++ /dev/null @@ -1,38 +0,0 @@ -''' -init tables -''' - -from yoyo import step - -__depends__ = {} - -steps = [ - step('''create table players ( - player_id integer primary key, - created integer not null, - nick text not null, - job text not null, - bis_link text, - priority integer not null default 1 - )'''), - step('''create unique index players_nick_job_idx on players(nick, job)'''), - - step('''create table loot ( - loot_id integer primary key, - player_id integer not null, - created integer not null, - piece text not null, - is_tome integer not null, - foreign key (player_id) references players(player_id) on delete cascade - )'''), - step('''create index loot_owner_idx on loot(player_id)'''), - - step('''create table bis ( - player_id integer not null, - created integer not null, - piece text not null, - is_tome integer not null, - foreign key (player_id) references players(player_id) on delete cascade - )'''), - step('''create unique index bis_piece_player_id_idx on bis(player_id, piece)''') -] diff --git a/migrations/20190910_01_tgBmx-users-table.py b/migrations/20190910_01_tgBmx-users-table.py deleted file mode 100644 index f4ad5f7..0000000 --- a/migrations/20190910_01_tgBmx-users-table.py +++ /dev/null @@ -1,17 +0,0 @@ -''' -users table -''' - -from yoyo import step - -__depends__ = {} - -steps = [ - step('''create table users ( - user_id integer primary key, - username text not null, - password text not null, - permission text not null - )'''), - step('''create unique index users_username_idx on users(username)''') -] diff --git a/package/ini/ffxivbis.ini b/package/ini/ffxivbis.ini deleted file mode 100644 index 7bdb2f4..0000000 --- a/package/ini/ffxivbis.ini +++ /dev/null @@ -1,10 +0,0 @@ -[settings] -include = ffxivbis.ini.d -logging = ffxivbis.ini.d/logging.ini -database = sqlite -priority = is_required loot_count_bis loot_priority loot_count loot_count_total - -[web] -host = 0.0.0.0 -port = 8000 -templates = templates diff --git a/package/ini/ffxivbis.ini.d/ariyala.ini b/package/ini/ffxivbis.ini.d/ariyala.ini deleted file mode 100644 index a11caae..0000000 --- a/package/ini/ffxivbis.ini.d/ariyala.ini +++ /dev/null @@ -1,3 +0,0 @@ -[ariyala] -ariyala_url = https://ffxiv.ariyala.com -xivapi_url = https://xivapi.com diff --git a/package/ini/ffxivbis.ini.d/auth.ini b/package/ini/ffxivbis.ini.d/auth.ini deleted file mode 100644 index 42a4b8d..0000000 --- a/package/ini/ffxivbis.ini.d/auth.ini +++ /dev/null @@ -1,4 +0,0 @@ -[auth] -enabled = yes -root_username = admin -root_password = $1$R3j4sym6$HtvrKOJ66f7w3.9Zc3U6h1 \ No newline at end of file diff --git a/package/ini/ffxivbis.ini.d/logging.ini b/package/ini/ffxivbis.ini.d/logging.ini deleted file mode 100644 index 54fdfe2..0000000 --- a/package/ini/ffxivbis.ini.d/logging.ini +++ /dev/null @@ -1,44 +0,0 @@ -[loggers] -keys = root,application,database,http - -[handlers] -keys = file_handler - -[formatters] -keys = generic_format - -[handler_console_handler] -class = StreamHandler -level = INFO -formatter = generic_format -args = (sys.stdout,) - -[handler_file_handler] -class = logging.handlers.RotatingFileHandler -level = INFO -formatter = generic_format -args = ('ffxivbis.log', 'a', 20971520, 20) - -[formatter_generic_format] -format = [%(levelname)s] [%(asctime)s] [%(threadName)s] [%(name)s] [%(funcName)s]: %(message)s -datefmt = - -[logger_root] -level = INFO -handlers = file_handler -qualname = root - -[logger_application] -level = INFO -handlers = file_handler -qualname = application - -[logger_database] -level = INFO -handlers = file_handler -qualname = database - -[logger_http] -level = INFO -handlers = file_handler -qualname = http diff --git a/package/ini/ffxivbis.ini.d/sqlite.ini b/package/ini/ffxivbis.ini.d/sqlite.ini deleted file mode 100644 index 0513526..0000000 --- a/package/ini/ffxivbis.ini.d/sqlite.ini +++ /dev/null @@ -1,3 +0,0 @@ -[sqlite] -database_path = /home/arcanis/Documents/github/ffxivbis/ffxivbis.db -migrations_path = /home/arcanis/Documents/github/ffxivbis/migrations \ No newline at end of file diff --git a/project/assembly.sbt b/project/assembly.sbt new file mode 100644 index 0000000..652a3b9 --- /dev/null +++ b/project/assembly.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6") diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..2b52f97 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version = 1.3.2 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5d55b53..0000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[aliases] -test=pytest - -[tool:pytest] -addopts = --verbose --pyargs . diff --git a/setup.py b/setup.py deleted file mode 100644 index 1bc576d..0000000 --- a/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -from distutils.util import convert_path -from setuptools import setup, find_packages -from os import path - - -here = path.abspath(path.dirname(__file__)) -metadata = dict() -with open(convert_path('src/ffxivbis/core/version.py')) as metadata_file: - exec(metadata_file.read(), metadata) - - -setup( - name='ffxivbis', - - version=metadata['__version__'], - zip_safe=False, - - description='Helper to handle loot drop', - - author='Evgeniy Alekseev', - author_email='i@arcanis.me', - - license='BSD', - - package_dir={'': 'src'}, - packages=find_packages(where='src', exclude=['contrib', 'docs', 'test']), - - install_requires=[ - 'aiohttp==3.6.0', - 'aiohttp_jinja2', - 'aiohttp_security', - 'apispec', - 'iniherit', - 'Jinja2', - 'passlib', - 'yoyo_migrations' - ], - setup_requires=[ - 'pytest-runner' - ], - tests_require=[ - 'pytest', 'pytest-aiohttp', 'pytest-asyncio' - ], - - include_package_data=True, - - extras_require={ - 'Postgresql': ['asyncpg'], - 'SQLite': ['aiosqlite'], - 'test': ['coverage', 'pytest'], - }, -) diff --git a/src/ffxivbis/__init__.py b/src/ffxivbis/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ffxivbis/api/__init__.py b/src/ffxivbis/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ffxivbis/api/auth.py b/src/ffxivbis/api/auth.py deleted file mode 100644 index efff86e..0000000 --- a/src/ffxivbis/api/auth.py +++ /dev/null @@ -1,53 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import middleware, Request, Response -from aiohttp_security import AbstractAuthorizationPolicy, check_permission -from typing import Callable, Optional - -from ffxivbis.core.database import Database - - -class AuthorizationPolicy(AbstractAuthorizationPolicy): - - def __init__(self, database: Database) -> None: - self.database = database - - async def authorized_userid(self, identity: str) -> Optional[str]: - user = await self.database.get_user(identity) - return identity if user is not None else None - - async def permits(self, identity: str, permission: str, context: str = None) -> bool: - user = await self.database.get_user(identity) - if user is None: - return False - if user.username != identity: - return False - if user.permission == 'admin': - return True - return permission == 'get' or user.permission == permission - - -def authorize_factory() -> Callable: - allowed_paths = {'/', '/favicon.ico', '/api/v1/login', '/api/v1/logout'} - allowed_paths_groups = {'/api-docs', '/static'} - - @middleware - async def authorize(request: Request, handler: Callable) -> Response: - if request.path.startswith('/admin'): - permission = 'admin' - else: - permission = 'get' if request.method in ('GET', 'HEAD') else 'post' - if request.path not in allowed_paths \ - and not any(request.path.startswith(path) for path in allowed_paths_groups): - await check_permission(request, permission) - - return await handler(request) - - return authorize - diff --git a/src/ffxivbis/api/json.py b/src/ffxivbis/api/json.py deleted file mode 100644 index b8b3137..0000000 --- a/src/ffxivbis/api/json.py +++ /dev/null @@ -1,34 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from enum import Enum -from json import JSONEncoder -from typing import Any - - -class HttpEncoder(JSONEncoder): - def default(self, obj: Any) -> Any: - if isinstance(obj, dict): - data = {} - for key, value in obj.items(): - data[key] = self.default(value) - return data - elif isinstance(obj, Enum): - return obj.name - elif hasattr(obj, '_ast'): - return self.default(obj._ast()) - elif hasattr(obj, '__iter__') and not isinstance(obj, str): - return [self.default(value) for value in obj] - elif hasattr(obj, '__dict__'): - data = { - key: self.default(value) - for key, value in obj.__dict__.items() - if not callable(value) and not key.startswith('_')} - return data - else: - return obj diff --git a/src/ffxivbis/api/routes.py b/src/ffxivbis/api/routes.py deleted file mode 100644 index a288912..0000000 --- a/src/ffxivbis/api/routes.py +++ /dev/null @@ -1,66 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import Application - -from .views.api.bis import BiSView -from .views.api.login import LoginView -from .views.api.logout import LogoutView -from .views.api.loot import LootView -from .views.api.player import PlayerView -from .views.html.api import ApiDocVIew, ApiHtmlView -from .views.html.bis import BiSHtmlView -from .views.html.index import IndexHtmlView -from .views.html.loot import LootHtmlView -from .views.html.loot_suggest import LootSuggestHtmlView -from .views.html.player import PlayerHtmlView -from .views.html.static import StaticHtmlView -from .views.html.users import UsersHtmlView - - -def setup_routes(app: Application) -> None: - # api routes - app.router.add_delete('/admin/api/v1/login/{username}', LoginView) - app.router.add_post('/api/v1/login', LoginView) - app.router.add_post('/api/v1/logout', LogoutView) - app.router.add_put('/admin/api/v1/login', LoginView) - - app.router.add_get('/api/v1/party', PlayerView) - app.router.add_post('/api/v1/party', PlayerView) - - app.router.add_get('/api/v1/party/bis', BiSView) - app.router.add_post('/api/v1/party/bis', BiSView) - app.router.add_put('/api/v1/party/bis', BiSView) - - app.router.add_get('/api/v1/party/loot', LootView) - app.router.add_post('/api/v1/party/loot', LootView) - app.router.add_put('/api/v1/party/loot', LootView) - - # html routes - app.router.add_get('/', IndexHtmlView) - app.router.add_get('/static/{resource_id}', StaticHtmlView) - - app.router.add_get('/api-docs', ApiHtmlView) - app.router.add_get('/api-docs/swagger.json', ApiDocVIew) - - app.router.add_get('/party', PlayerHtmlView) - app.router.add_post('/party', PlayerHtmlView) - - app.router.add_get('/bis', BiSHtmlView) - app.router.add_post('/bis', BiSHtmlView) - - app.router.add_get('/loot', LootHtmlView) - app.router.add_post('/loot', LootHtmlView) - - app.router.add_get('/suggest', LootSuggestHtmlView) - app.router.add_post('/suggest', LootSuggestHtmlView) - - app.router.add_get('/admin/users', UsersHtmlView) - app.router.add_post('/admin/users', UsersHtmlView) - - diff --git a/src/ffxivbis/api/spec.py b/src/ffxivbis/api/spec.py deleted file mode 100644 index 42fff69..0000000 --- a/src/ffxivbis/api/spec.py +++ /dev/null @@ -1,78 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import Application -from apispec import APISpec - -from ffxivbis.core.version import __version__ -from ffxivbis.models.action import Action -from ffxivbis.models.bis import BiS, BiSLink -from ffxivbis.models.error import Error -from ffxivbis.models.job import Job -from ffxivbis.models.loot import Loot -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player, PlayerId, PlayerIdWithCounters -from ffxivbis.models.player_edit import PlayerEdit -from ffxivbis.models.upgrade import Upgrade -from ffxivbis.models.user import User - - -def get_spec(app: Application) -> APISpec: - spec = APISpec( - title='FFXIV loot helper', - version=__version__, - openapi_version='3.0.2', - info=dict(description='Loot manager for FFXIV statics'), - ) - - # routes - for route in app.router.routes(): - path = route.get_info().get('path') or route.get_info().get('formatter') - method = route.method.lower() - - spec_method = f'endpoint_{method}_spec' - if not hasattr(route.handler, spec_method): - continue - operations = getattr(route.handler, spec_method)() - if not operations: - continue - - spec.path(path, operations={method: operations}) - - # components - spec.components.schema(Action.model_name(), Action.model_spec()) - spec.components.schema(BiS.model_name(), BiS.model_spec()) - spec.components.schema(BiSLink.model_name(), BiSLink.model_spec()) - spec.components.schema(Error.model_name(), Error.model_spec()) - spec.components.schema(Job.model_name(), Job.model_spec()) - spec.components.schema(Loot.model_name(), Loot.model_spec()) - spec.components.schema(Piece.model_name(), Piece.model_spec()) - spec.components.schema(Player.model_name(), Player.model_spec()) - spec.components.schema(PlayerEdit.model_name(), PlayerEdit.model_spec()) - spec.components.schema(PlayerId.model_name(), PlayerId.model_spec()) - spec.components.schema(PlayerIdWithCounters.model_name(), PlayerIdWithCounters.model_spec()) - spec.components.schema(Upgrade.model_name(), Upgrade.model_spec()) - spec.components.schema(User.model_name(), User.model_spec()) - - # default responses - spec.components.response('BadRequest', dict( - description='Bad parameters applied or bad request was formed', - content={'application/json': {'schema': {'$ref': Error.model_ref('Error')}}} - )) - spec.components.response('Forbidden', dict( - description='User permissions do not allow this action' - )) - spec.components.response('ServerError', dict( - description='Server was unable to process request', - content={'application/json': {'schema': {'$ref': Error.model_ref('Error')}}} - )) - spec.components.response('Unauthorized', dict( - description='User was not authorized' - )) - - return spec \ No newline at end of file diff --git a/src/ffxivbis/api/utils.py b/src/ffxivbis/api/utils.py deleted file mode 100644 index 97607ff..0000000 --- a/src/ffxivbis/api/utils.py +++ /dev/null @@ -1,42 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -import json - -from aiohttp.web import HTTPException, Response -from typing import Any, Mapping, List - -from .json import HttpEncoder - - -def make_json(response: Any) -> str: - return json.dumps(response, cls=HttpEncoder, sort_keys=True) - - -def wrap_exception(exception: Exception, args: Mapping[str, Any], code: int = 500) -> Response: - if isinstance(exception, HTTPException): - raise exception # reraise return - return wrap_json({ - 'message': repr(exception), - 'arguments': dict(args) - }, code) - - -def wrap_invalid_param(params: List[str], args: Mapping[str, Any], code: int = 400) -> Response: - return wrap_json({ - 'message': f'invalid or missing parameters: `{params}`', - 'arguments': dict(args) - }, code) - - -def wrap_json(response: Any, code: int = 200) -> Response: - return Response( - text=make_json(response), - status=code, - content_type='application/json' - ) diff --git a/src/ffxivbis/api/views/__init__.py b/src/ffxivbis/api/views/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ffxivbis/api/views/api/__init__.py b/src/ffxivbis/api/views/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ffxivbis/api/views/api/bis.py b/src/ffxivbis/api/views/api/bis.py deleted file mode 100644 index 7393f33..0000000 --- a/src/ffxivbis/api/views/api/bis.py +++ /dev/null @@ -1,159 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import Response -from typing import Any, Dict, List, Optional, Type - -from ffxivbis.models.job import Job -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import PlayerId - -from ffxivbis.api.utils import wrap_exception, wrap_invalid_param, wrap_json -from ffxivbis.api.views.common.bis_base import BiSBaseView - -from .openapi import OpenApi - - -class BiSView(BiSBaseView, OpenApi): - - @classmethod - def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Get party players BiS items' - - @classmethod - def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]: - return [ - { - 'name': 'nick', - 'in': 'query', - 'description': 'player nick name to filter', - 'required': False, - 'type': 'string' - } - ] - - @classmethod - def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': { 'schema': { - 'type': 'array', - 'items': { - 'allOf': [{'$ref': cls.model_ref('Piece')}] - }}}}} - } - - @classmethod - def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'get party BiS items' - - @classmethod - def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]: - return ['BiS'] - - @classmethod - def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Add new item to player BiS or remove existing' - - @classmethod - def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return ['Piece', 'PlayerEdit'] - - @classmethod - def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('Loot')}}}} - } - - @classmethod - def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'edit BiS' - - @classmethod - def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]: - return ['BiS'] - - @classmethod - def endpoint_put_consumes(cls: Type[OpenApi]) -> List[str]: - return ['application/json'] - - @classmethod - def endpoint_put_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Generate new BiS set' - - @classmethod - def endpoint_put_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return ['BiSLink'] - - @classmethod - def endpoint_put_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('BiS')}}}} - } - - @classmethod - def endpoint_put_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'update BiS' - - @classmethod - def endpoint_put_tags(cls: Type[OpenApi]) -> List[str]: - return ['BiS'] - - async def get(self) -> Response: - try: - loot = self.bis_get(self.request.query.getone('nick', None)) - - except Exception as e: - self.request.app.logger.exception('could not get bis') - return wrap_exception(e, self.request.query) - - return wrap_json(loot) - - async def post(self) -> Response: - try: - data = await self.request.json() - except Exception: - data = dict(await self.request.post()) - - required = ['action', 'is_tome', 'job', 'name', 'nick'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - action = data.get('action') - if action not in ('add', 'remove'): - return wrap_invalid_param(['action'], data) - - try: - player_id = PlayerId(Job[data['job']], data['nick']) - piece: Piece = Piece.get(data) # type: ignore - await self.bis_post(action, player_id, piece) - - except Exception as e: - self.request.app.logger.exception('could not add bis') - return wrap_exception(e, data) - - return wrap_json({'piece': piece, 'player_id': player_id}) - - async def put(self) -> Response: - try: - data = await self.request.json() - except Exception: - data = dict(await self.request.post()) - - required = ['job', 'link', 'nick'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - try: - player_id = PlayerId(Job[data['job']], data['nick']) - bis = await self.bis_put(player_id, data['link']) - - except Exception as e: - self.request.app.logger.exception('could not parse bis') - return wrap_exception(e, data) - - return wrap_json(bis) \ No newline at end of file diff --git a/src/ffxivbis/api/views/api/login.py b/src/ffxivbis/api/views/api/login.py deleted file mode 100644 index 29194aa..0000000 --- a/src/ffxivbis/api/views/api/login.py +++ /dev/null @@ -1,139 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import Response -from typing import Any, Dict, List, Optional, Type - -from ffxivbis.api.utils import wrap_exception, wrap_invalid_param, wrap_json -from ffxivbis.api.views.common.login_base import LoginBaseView - -from .openapi import OpenApi - - -class LoginView(LoginBaseView, OpenApi): - - @classmethod - def endpoint_delete_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Delete registered user' - - @classmethod - def endpoint_delete_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]: - return [ - { - 'name': 'username', - 'in': 'path', - 'description': 'username to remove', - 'required': True, - 'type': 'string' - } - ] - - @classmethod - def endpoint_delete_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'type': 'object'}}} - } - - @classmethod - def endpoint_delete_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'delete user' - - @classmethod - def endpoint_delete_tags(cls: Type[OpenApi]) -> List[str]: - return ['users'] - - @classmethod - def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Login as user' - - @classmethod - def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return ['User'] - - @classmethod - def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'type': 'object'}}} - } - - @classmethod - def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'login' - - @classmethod - def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]: - return ['users'] - - @classmethod - def endpoint_put_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Create new user' - - @classmethod - def endpoint_put_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return ['User'] - - @classmethod - def endpoint_put_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'type': 'object'}}} - } - - @classmethod - def endpoint_put_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'create user' - - @classmethod - def endpoint_put_tags(cls: Type[OpenApi]) -> List[str]: - return ['users'] - - async def delete(self) -> Response: - username = self.request.match_info['username'] - - try: - await self.remove_user(username) - except Exception as e: - self.request.app.logger.exception('cannot remove user') - return wrap_exception(e, {'username': username}) - - return wrap_json({}) - - async def post(self) -> Response: - try: - data = await self.request.json() - except Exception: - data = dict(await self.request.post()) - - required = ['username', 'password'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - try: - await self.login(data['username'], data['password']) - except Exception as e: - self.request.app.logger.exception('cannot login user') - return wrap_exception(e, data) - - return wrap_json({}) - - async def put(self) -> Response: - try: - data = await self.request.json() - except Exception: - data = dict(await self.request.post()) - - required = ['username', 'password'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - try: - await self.create_user(data['username'], data['password'], data.get('permission', 'get')) - except Exception as e: - self.request.app.logger.exception('cannot create user') - return wrap_exception(e, data) - - return wrap_json({}) \ No newline at end of file diff --git a/src/ffxivbis/api/views/api/logout.py b/src/ffxivbis/api/views/api/logout.py deleted file mode 100644 index 3cbcc57..0000000 --- a/src/ffxivbis/api/views/api/logout.py +++ /dev/null @@ -1,46 +0,0 @@ -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import Response -from typing import Any, Dict, List, Optional, Type - -from ffxivbis.api.utils import wrap_exception, wrap_json -from ffxivbis.api.views.common.login_base import LoginBaseView - -from .openapi import OpenApi - - -class LogoutView(LoginBaseView, OpenApi): - - @classmethod - def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Logout' - - @classmethod - def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return [] - - @classmethod - def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'type': 'object'}}} - } - - @classmethod - def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'logout' - - @classmethod - def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]: - return ['users'] - - async def post(self) -> Response: - try: - await self.logout() - except Exception as e: - self.request.app.logger.exception('cannot logout user') - return wrap_exception(e, {}) - - return wrap_json({}) \ No newline at end of file diff --git a/src/ffxivbis/api/views/api/loot.py b/src/ffxivbis/api/views/api/loot.py deleted file mode 100644 index 9e437da..0000000 --- a/src/ffxivbis/api/views/api/loot.py +++ /dev/null @@ -1,159 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import Response -from typing import Any, Dict, List, Optional, Type - -from ffxivbis.models.job import Job -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import PlayerId - -from ffxivbis.api.utils import wrap_exception, wrap_invalid_param, wrap_json -from ffxivbis.api.views.common.loot_base import LootBaseView - -from .openapi import OpenApi - - -class LootView(LootBaseView, OpenApi): - - @classmethod - def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Get party players loot' - - @classmethod - def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]: - return [ - { - 'name': 'nick', - 'in': 'query', - 'description': 'player nick name to filter', - 'required': False, - 'type': 'string' - } - ] - - @classmethod - def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'schema': { - 'type': 'array', - 'items': { - 'allOf': [{'$ref': cls.model_ref('Piece')}] - }}}}} - } - - @classmethod - def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'get party loot' - - @classmethod - def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]: - return ['loot'] - - @classmethod - def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Add new loot item or remove existing' - - @classmethod - def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return ['Piece', 'PlayerEdit'] - - @classmethod - def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('Loot')}}}} - } - - @classmethod - def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'edit loot' - - @classmethod - def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]: - return ['loot'] - - @classmethod - def endpoint_put_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Suggest loot to party member' - - @classmethod - def endpoint_put_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return ['Piece'] - - @classmethod - def endpoint_put_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'schema': { - 'type': 'array', - 'items': { - 'allOf': [{'$ref': cls.model_ref('PlayerIdWithCounters')}] - }}}}} - } - - @classmethod - def endpoint_put_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'suggest loot' - - @classmethod - def endpoint_put_tags(cls: Type[OpenApi]) -> List[str]: - return ['loot'] - - async def get(self) -> Response: - try: - loot = self.loot_get(self.request.query.getone('nick', None)) - - except Exception as e: - self.request.app.logger.exception('could not get loot') - return wrap_exception(e, self.request.query) - - return wrap_json(loot) - - async def post(self) -> Response: - try: - data = await self.request.json() - except Exception: - data = dict(await self.request.post()) - - required = ['action', 'is_tome', 'job', 'name', 'nick'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - action = data.get('action') - if action not in ('add', 'remove'): - return wrap_invalid_param(['action'], data) - - try: - player_id = PlayerId(Job[data['job']], data['nick']) - piece: Piece = Piece.get(data) # type: ignore - await self.loot_post(action, player_id, piece) - - except Exception as e: - self.request.app.logger.exception('could not add loot') - return wrap_exception(e, data) - - return wrap_json({'piece': piece, 'player_id': player_id}) - - async def put(self) -> Response: - try: - data = await self.request.json() - except Exception: - data = dict(await self.request.post()) - - required = ['is_tome', 'name'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - try: - piece: Piece = Piece.get(data) # type: ignore - players = self.loot_put(piece) - - except Exception as e: - self.request.app.logger.exception('could not suggest loot') - return wrap_exception(e, data) - - return wrap_json(players) \ No newline at end of file diff --git a/src/ffxivbis/api/views/api/openapi.py b/src/ffxivbis/api/views/api/openapi.py deleted file mode 100644 index bcf02ac..0000000 --- a/src/ffxivbis/api/views/api/openapi.py +++ /dev/null @@ -1,195 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from __future__ import annotations - -from typing import Any, Dict, List, Optional, Type - -from ffxivbis.models.serializable import Serializable - - -class OpenApi(Serializable): - - @classmethod - def endpoint_delete_description(cls: Type[OpenApi]) -> Optional[str]: - return None - - @classmethod - def endpoint_delete_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]: - return [] - - @classmethod - def endpoint_delete_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return {} - - @classmethod - def endpoint_delete_summary(cls: Type[OpenApi]) -> Optional[str]: - return None - - @classmethod - def endpoint_delete_tags(cls: Type[OpenApi]) -> List[str]: - return [] - - @classmethod - def endpoint_delete_spec(cls: Type[OpenApi]) -> Dict[str, Any]: - description = cls.endpoint_delete_description() - if description is None: - return {} - return { - 'description': description, - 'parameters': cls.endpoint_delete_parameters(), - 'responses': cls.endpoint_with_default_responses(cls.endpoint_delete_responses()), - 'summary': cls.endpoint_delete_summary(), - 'tags': cls.endpoint_delete_tags() - } - - @classmethod - def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]: - return None - - @classmethod - def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]: - return [] - - @classmethod - def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return {} - - @classmethod - def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]: - return None - - @classmethod - def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]: - return [] - - @classmethod - def endpoint_get_spec(cls: Type[OpenApi]) -> Dict[str, Any]: - description = cls.endpoint_get_description() - if description is None: - return {} - return { - 'description': description, - 'parameters': cls.endpoint_get_parameters(), - 'responses': cls.endpoint_with_default_responses(cls.endpoint_get_responses()), - 'summary': cls.endpoint_get_summary(), - 'tags': cls.endpoint_get_tags() - } - - @classmethod - def endpoint_post_consumes(cls: Type[OpenApi]) -> List[str]: - return ['application/json'] - - @classmethod - def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]: - return None - - @classmethod - def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return [] - - @classmethod - def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return {} - - @classmethod - def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]: - return None - - @classmethod - def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]: - return [] - - @classmethod - def endpoint_post_spec(cls: Type[OpenApi]) -> Dict[str, Any]: - description = cls.endpoint_post_description() - if description is None: - return {} - return { - 'consumes': cls.endpoint_post_consumes(), - 'description': description, - 'requestBody': { - 'content': { - content_type: { - 'schema': {'allOf': [ - {'$ref': cls.model_ref(ref)} - for ref in cls.endpoint_post_request_body(content_type) - ]} - } - for content_type in cls.endpoint_post_consumes() - } - }, - 'responses': cls.endpoint_with_default_responses(cls.endpoint_post_responses()), - 'summary': cls.endpoint_post_summary(), - 'tags': cls.endpoint_post_tags() - } - - @classmethod - def endpoint_put_consumes(cls: Type[OpenApi]) -> List[str]: - return ['application/json'] - - @classmethod - def endpoint_put_description(cls: Type[OpenApi]) -> Optional[str]: - return None - - @classmethod - def endpoint_put_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return [] - - @classmethod - def endpoint_put_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return {} - - @classmethod - def endpoint_put_summary(cls: Type[OpenApi]) -> Optional[str]: - return None - - @classmethod - def endpoint_put_tags(cls: Type[OpenApi]) -> List[str]: - return [] - - @classmethod - def endpoint_put_spec(cls: Type[OpenApi]) -> Dict[str, Any]: - description = cls.endpoint_put_description() - if description is None: - return {} - return { - 'consumes': cls.endpoint_put_consumes(), - 'description': description, - 'requestBody': { - 'content': { - content_type: { - 'schema': {'allOf': [ - {'$ref': cls.model_ref(ref)} - for ref in cls.endpoint_put_request_body(content_type) - ]} - } - for content_type in cls.endpoint_put_consumes() - } - }, - 'responses': cls.endpoint_with_default_responses(cls.endpoint_put_responses()), - 'summary': cls.endpoint_put_summary(), - 'tags': cls.endpoint_put_tags() - } - - @classmethod - def endpoint_spec(cls: Type[OpenApi], operations: List[str]) -> Dict[str, Any]: - return { - operation.lower(): getattr(cls, f'endpoint_{operation.lower()}_spec') - for operation in operations - } - - @classmethod - def endpoint_with_default_responses(cls: Type[OpenApi], responses: Dict[str, Any]) -> Dict[str, Any]: - responses.update({ - '400': {'$ref': cls.model_ref('BadRequest', 'responses')}, - '401': {'$ref': cls.model_ref('Unauthorized', 'responses')}, - '403': {'$ref': cls.model_ref('Forbidden', 'responses')}, - '500': {'$ref': cls.model_ref('ServerError', 'responses')} - }) - return responses diff --git a/src/ffxivbis/api/views/api/player.py b/src/ffxivbis/api/views/api/player.py deleted file mode 100644 index 7a5e477..0000000 --- a/src/ffxivbis/api/views/api/player.py +++ /dev/null @@ -1,107 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import Response -from typing import Any, Dict, List, Optional, Type - -from ffxivbis.models.job import Job - -from ffxivbis.api.utils import wrap_exception, wrap_invalid_param, wrap_json -from ffxivbis.api.views.common.player_base import PlayerBaseView - -from .openapi import OpenApi - - -class PlayerView(PlayerBaseView, OpenApi): - - @classmethod - def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Get party players with optional nick filter' - - @classmethod - def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]: - return [ - { - 'name': 'nick', - 'in': 'query', - 'description': 'player nick name to filter', - 'required': False, - 'type': 'string' - } - ] - - @classmethod - def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('Player')}}}} - } - - @classmethod - def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'get party players' - - @classmethod - def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]: - return ['party'] - - @classmethod - def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]: - return 'Create new party player or remove existing' - - @classmethod - def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]: - return ['PlayerEdit'] - - @classmethod - def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]: - return { - '200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('PlayerId')}}}} - } - - @classmethod - def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]: - return 'add or remove player' - - @classmethod - def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]: - return ['party'] - - async def get(self) -> Response: - try: - party = self.player_get(self.request.query.getone('nick', None)) - - except Exception as e: - self.request.app.logger.exception('could not get party') - return wrap_exception(e, self.request.query) - - return wrap_json(party) - - async def post(self) -> Response: - try: - data = await self.request.json() - except Exception: - data = dict(await self.request.post()) - - required = ['action', 'job', 'nick'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - priority = data.get('priority', 0) - link = data.get('link', None) - - action = data.get('action') - if action not in ('add', 'remove'): - return wrap_invalid_param(['action'], data) - - try: - player_id = await self.player_post(action, Job[data['job']], data['nick'], link, priority) - - except Exception as e: - self.request.app.logger.exception('could not add loot') - return wrap_exception(e, data) - - return wrap_json(player_id) \ No newline at end of file diff --git a/src/ffxivbis/api/views/common/__init__.py b/src/ffxivbis/api/views/common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ffxivbis/api/views/common/bis_base.py b/src/ffxivbis/api/views/common/bis_base.py deleted file mode 100644 index d364ea5..0000000 --- a/src/ffxivbis/api/views/common/bis_base.py +++ /dev/null @@ -1,49 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import View -from typing import List, Optional - -from ffxivbis.core.ariyala_parser import AriyalaParser -from ffxivbis.models.bis import BiS -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import PlayerId - - -class BiSBaseView(View): - - async def bis_add(self, player_id: PlayerId, piece: Piece) -> Piece: - await self.request.app['party'].set_item_bis(player_id, piece) - return piece - - def bis_get(self, nick: Optional[str]) -> List[Piece]: - party = [ - player - for player in self.request.app['party'].party - if nick is None or player.nick == nick - ] - return list(sum([player.bis.pieces for player in party], [])) - - async def bis_post(self, action: str, player_id: PlayerId, piece: Piece) -> Optional[Piece]: - if action == 'add': - return await self.bis_add(player_id, piece) - elif action == 'remove': - return await self.bis_remove(player_id, piece) - return None - - async def bis_put(self, player_id: PlayerId, link: str) -> BiS: - parser = AriyalaParser(self.request.app['config']) - items = await parser.get(link, player_id.job.name) - for piece in items: - await self.request.app['party'].set_item_bis(player_id, piece) - await self.request.app['party'].set_bis_link(player_id, link) - return self.request.app['party'].players[player_id].bis - - async def bis_remove(self, player_id: PlayerId, piece: Piece) -> Piece: - await self.request.app['party'].remove_item_bis(player_id, piece) - return piece diff --git a/src/ffxivbis/api/views/common/login_base.py b/src/ffxivbis/api/views/common/login_base.py deleted file mode 100644 index dd0132f..0000000 --- a/src/ffxivbis/api/views/common/login_base.py +++ /dev/null @@ -1,43 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import HTTPFound, HTTPUnauthorized, View -from aiohttp_security import check_authorized, forget, remember -from passlib.hash import md5_crypt - -from ffxivbis.models.user import User - - -class LoginBaseView(View): - - async def check_credentials(self, username: str, password: str) -> bool: - user = await self.request.app['database'].get_user(username) - if user is None: - return False - return md5_crypt.verify(password, user.password) - - async def create_user(self, username: str, password: str, permission: str) -> None: - await self.request.app['database'].insert_user(User(username, password, permission), False) - - async def login(self, username: str, password: str) -> None: - if await self.check_credentials(username, password): - response = HTTPFound('/') - await remember(self.request, response, username) - raise response - - raise HTTPUnauthorized() - - async def logout(self) -> None: - await check_authorized(self.request) - response = HTTPFound('/') - await forget(self.request, response) - - raise response - - async def remove_user(self, username: str) -> None: - await self.request.app['database'].delete_user(username) \ No newline at end of file diff --git a/src/ffxivbis/api/views/common/loot_base.py b/src/ffxivbis/api/views/common/loot_base.py deleted file mode 100644 index 0ae449a..0000000 --- a/src/ffxivbis/api/views/common/loot_base.py +++ /dev/null @@ -1,43 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import View -from typing import List, Optional, Union - -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import PlayerId, PlayerIdWithCounters -from ffxivbis.models.upgrade import Upgrade - - -class LootBaseView(View): - - async def loot_add(self, player_id: PlayerId, piece: Piece) -> Piece: - await self.request.app['party'].set_item(player_id, piece) - return piece - - def loot_get(self, nick: Optional[str]) -> List[Piece]: - party = [ - player - for player in self.request.app['party'].party - if nick is None or player.nick == nick - ] - return list(sum([player.loot for player in party], [])) - - async def loot_post(self, action: str, player_id: PlayerId, piece: Piece) -> Optional[Piece]: - if action == 'add': - return await self.loot_add(player_id, piece) - elif action == 'remove': - return await self.loot_remove(player_id, piece) - return None - - def loot_put(self, piece: Union[Piece, Upgrade]) -> List[PlayerIdWithCounters]: - return self.request.app['loot'].suggest(piece) - - async def loot_remove(self, player_id: PlayerId, piece: Piece) -> Piece: - await self.request.app['party'].remove_item(player_id, piece) - return piece \ No newline at end of file diff --git a/src/ffxivbis/api/views/common/player_base.py b/src/ffxivbis/api/views/common/player_base.py deleted file mode 100644 index 81b0b0b..0000000 --- a/src/ffxivbis/api/views/common/player_base.py +++ /dev/null @@ -1,50 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import View -from typing import List, Optional - -from ffxivbis.core.ariyala_parser import AriyalaParser -from ffxivbis.models.bis import BiS -from ffxivbis.models.job import Job -from ffxivbis.models.player import Player, PlayerId - - -class PlayerBaseView(View): - - async def player_add(self, job: Job, nick: str, link: Optional[str], priority: int) -> PlayerId: - player = Player(job, nick, BiS(), [], link, int(priority)) - player_id = player.player_id - await self.request.app['party'].set_player(player) - - if link: - parser = AriyalaParser(self.request.app['config']) - items = await parser.get(link, job.name) - for piece in items: - await self.request.app['party'].set_item_bis(player_id, piece) - - return player_id - - def player_get(self, nick: Optional[str]) -> List[Player]: - return [ - player - for player in self.request.app['party'].party - if nick is None or player.nick == nick - ] - - async def player_post(self, action: str, job: Job, nick: str, link: Optional[str], priority: int) -> Optional[PlayerId]: - if action == 'add': - return await self.player_add(job, nick, link, priority) - elif action == 'remove': - return await self.player_remove(job, nick) - return None - - async def player_remove(self, job: Job, nick: str) -> PlayerId: - player_id = PlayerId(job, nick) - await self.request.app['party'].remove_player(player_id) - return player_id \ No newline at end of file diff --git a/src/ffxivbis/api/views/html/__init__.py b/src/ffxivbis/api/views/html/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ffxivbis/api/views/html/api.py b/src/ffxivbis/api/views/html/api.py deleted file mode 100644 index 0be13df..0000000 --- a/src/ffxivbis/api/views/html/api.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -import json - -from aiohttp.web import Response, View -from aiohttp_jinja2 import template -from typing import Any, Dict - - -class ApiDocVIew(View): - - async def get(self) -> Response: - return Response( - text=json.dumps(self.request.app['spec'].to_dict()), - status=200, - content_type='application/json' - ) - - -class ApiHtmlView(View): - - @template('api.jinja2') - async def get(self) -> Dict[str, Any]: - return {} \ No newline at end of file diff --git a/src/ffxivbis/api/views/html/bis.py b/src/ffxivbis/api/views/html/bis.py deleted file mode 100644 index e85be90..0000000 --- a/src/ffxivbis/api/views/html/bis.py +++ /dev/null @@ -1,82 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import HTTPFound, Response -from aiohttp_jinja2 import template -from typing import Any, Dict, List - -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player, PlayerId - -from ffxivbis.api.utils import wrap_exception, wrap_invalid_param -from ffxivbis.api.views.common.bis_base import BiSBaseView -from ffxivbis.api.views.common.player_base import PlayerBaseView - - -class BiSHtmlView(BiSBaseView, PlayerBaseView): - - @template('bis.jinja2') - async def get(self) -> Dict[str, Any]: - error = None - items: List[Dict[str, str]] = [] - players: List[Player] = [] - - try: - players = self.player_get(None) - items = [ - { - 'player': player.player_id.pretty_name, - 'piece': piece.name, - 'is_tome': 'yes' if piece.is_tome else 'no' - } - for player in players - for piece in player.bis.pieces - ] - - except Exception as e: - self.request.app.logger.exception('could not get bis') - error = repr(e) - - return { - 'items': items, - 'pieces': Piece.available(), - 'players': [player.player_id.pretty_name for player in players], - 'request_error': error - } - - async def post(self) -> Response: - data = await self.request.post() - - required = ['method', 'player'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - try: - method = data.getone('method') - player_id = PlayerId.from_pretty_name(data.getone('player')) # type: ignore - - if method == 'post': - required = ['action', 'piece'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - is_tome = (data.getone('is_tome', None) == 'on') - await self.bis_post(data.getone('action'), player_id, # type: ignore - Piece.get({'piece': data.getone('piece'), 'is_tome': is_tome})) # type: ignore - - elif method == 'put': - required = ['bis'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - await self.bis_put(player_id, data.getone('bis')) # type: ignore - - except Exception as e: - self.request.app.logger.exception('could not manage bis') - return wrap_exception(e, data) - - return HTTPFound(self.request.url) \ No newline at end of file diff --git a/src/ffxivbis/api/views/html/index.py b/src/ffxivbis/api/views/html/index.py deleted file mode 100644 index e3ec325..0000000 --- a/src/ffxivbis/api/views/html/index.py +++ /dev/null @@ -1,23 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import View -from aiohttp_jinja2 import template -from aiohttp_security import authorized_userid -from typing import Any, Dict - - -class IndexHtmlView(View): - - @template('index.jinja2') - async def get(self) -> Dict[str, Any]: - username = await authorized_userid(self.request) - - return { - 'logged': username - } diff --git a/src/ffxivbis/api/views/html/loot.py b/src/ffxivbis/api/views/html/loot.py deleted file mode 100644 index 3647a30..0000000 --- a/src/ffxivbis/api/views/html/loot.py +++ /dev/null @@ -1,70 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import HTTPFound, Response -from aiohttp_jinja2 import template -from typing import Any, Dict, List - -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player, PlayerId -from ffxivbis.models.upgrade import Upgrade - -from ffxivbis.api.utils import wrap_exception, wrap_invalid_param -from ffxivbis.api.views.common.loot_base import LootBaseView -from ffxivbis.api.views.common.player_base import PlayerBaseView - - -class LootHtmlView(LootBaseView, PlayerBaseView): - - @template('loot.jinja2') - async def get(self) -> Dict[str, Any]: - error = None - items: List[Dict[str, str]] = [] - players: List[Player] = [] - - try: - players = self.player_get(None) - items = [ - { - 'player': player.player_id.pretty_name, - 'piece': piece.name, - 'is_tome': 'yes' if getattr(piece, 'is_tome', True) else 'no' - } - for player in players - for piece in player.loot - ] - - except Exception as e: - self.request.app.logger.exception('could not get loot') - error = repr(e) - - return { - 'items': items, - 'pieces': Piece.available() + [upgrade.name for upgrade in Upgrade], - 'players': [player.player_id.pretty_name for player in players], - 'request_error': error - } - - async def post(self) -> Response: - data = await self.request.post() - - required = ['action', 'piece', 'player'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - try: - player_id = PlayerId.from_pretty_name(data.getone('player')) # type: ignore - is_tome = (data.getone('is_tome', None) == 'on') - await self.loot_post(data.getone('action'), player_id, # type: ignore - Piece.get({'piece': data.getone('piece'), 'is_tome': is_tome})) # type: ignore - - except Exception as e: - self.request.app.logger.exception('could not manage loot') - return wrap_exception(e, data) - - return HTTPFound(self.request.url) diff --git a/src/ffxivbis/api/views/html/loot_suggest.py b/src/ffxivbis/api/views/html/loot_suggest.py deleted file mode 100644 index 1b78546..0000000 --- a/src/ffxivbis/api/views/html/loot_suggest.py +++ /dev/null @@ -1,64 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import Response -from aiohttp_jinja2 import template -from typing import Any, Dict, List, Union - -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import PlayerIdWithCounters -from ffxivbis.models.upgrade import Upgrade - -from ffxivbis.api.utils import wrap_invalid_param -from ffxivbis.api.views.common.loot_base import LootBaseView -from ffxivbis.api.views.common.player_base import PlayerBaseView - - -class LootSuggestHtmlView(LootBaseView, PlayerBaseView): - - @template('loot_suggest.jinja2') - async def get(self) -> Dict[str, Any]: - return { - 'pieces': Piece.available() + [upgrade.name for upgrade in Upgrade] - } - - @template('loot_suggest.jinja2') - async def post(self) -> Union[Dict[str, Any], Response]: - data = await self.request.post() - error = None - item_values: Dict[str, Any] = {} - players: List[PlayerIdWithCounters] = [] - - required = ['piece'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - try: - piece = Piece.get({'piece': data.getone('piece'), 'is_tome': data.getone('is_tome', False)}) - players = self.loot_put(piece) - item_values = {'piece': piece.name, 'is_tome': getattr(piece, 'is_tome', True)} - - except Exception as e: - self.request.app.logger.exception('could not manage loot') - error = repr(e) - - return { - 'item': item_values, - 'pieces': Piece.available() + [upgrade.name for upgrade in Upgrade], - 'request_error': error, - 'suggest': [ - { - 'player': player.pretty_name, - 'is_required': 'yes' if player.is_required else 'no', - 'loot_count': player.loot_count, - 'loot_count_bis': player.loot_count_bis, - 'loot_count_total': player.loot_count_total - } - for player in players - ] - } \ No newline at end of file diff --git a/src/ffxivbis/api/views/html/player.py b/src/ffxivbis/api/views/html/player.py deleted file mode 100644 index 95b1cc0..0000000 --- a/src/ffxivbis/api/views/html/player.py +++ /dev/null @@ -1,67 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import HTTPFound, Response -from aiohttp_jinja2 import template -from typing import Any, Dict, List - -from ffxivbis.models.job import Job -from ffxivbis.models.player import PlayerIdWithCounters - -from ffxivbis.api.utils import wrap_exception, wrap_invalid_param -from ffxivbis.api.views.common.player_base import PlayerBaseView - - -class PlayerHtmlView(PlayerBaseView): - - @template('party.jinja2') - async def get(self) -> Dict[str, Any]: - counters: List[PlayerIdWithCounters] = [] - error = None - - try: - party = self.player_get(None) - counters = [player.player_id_with_counters(None) for player in party] - - except Exception as e: - self.request.app.logger.exception('could not get party') - error = repr(e) - - return { - 'jobs': [job.name for job in Job], - 'players': [ - { - 'job': player.job.name, - 'nick': player.nick, - 'loot_count_bis': player.loot_count_bis, - 'loot_count_total': player.loot_count_total, - 'priority': player.priority - } - for player in counters - ], - 'request_error': error - } - - async def post(self) -> Response: - data = await self.request.post() - - required = ['action', 'job', 'nick'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - try: - action = data.getone('action') - priority = data.getone('priority', 0) - link = data.getone('bis', None) - await self.player_post(action, Job[data['job'].upper()], data['nick'], link, priority) # type: ignore - - except Exception as e: - self.request.app.logger.exception('could not manage players') - return wrap_exception(e, data) - - return HTTPFound(self.request.url) \ No newline at end of file diff --git a/src/ffxivbis/api/views/html/static.py b/src/ffxivbis/api/views/html/static.py deleted file mode 100644 index d39d531..0000000 --- a/src/ffxivbis/api/views/html/static.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -import os - -from aiohttp.web import HTTPNotFound, Response, View - - -class StaticHtmlView(View): - - def __get_content_type(self, filename: str) -> str: - _, ext = os.path.splitext(filename) - if ext == '.css': - return 'text/css' - elif ext == '.js': - return 'text/javascript' - return 'text/plain' - - async def get(self) -> Response: - resource_name = self.request.match_info['resource_id'] - resource_path = os.path.join(self.request.app['templates_root'], 'static', resource_name) - if not os.path.exists(resource_path) or os.path.isdir(resource_path): - return HTTPNotFound() - content_type = self.__get_content_type(resource_name) - - with open(resource_path) as resource_file: - return Response(text=resource_file.read(), content_type=content_type) \ No newline at end of file diff --git a/src/ffxivbis/api/views/html/users.py b/src/ffxivbis/api/views/html/users.py deleted file mode 100644 index ea4314f..0000000 --- a/src/ffxivbis/api/views/html/users.py +++ /dev/null @@ -1,62 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from aiohttp.web import HTTPFound, Response -from aiohttp_jinja2 import template -from typing import Any, Dict, List - -from ffxivbis.models.user import User - -from ffxivbis.api.utils import wrap_exception, wrap_invalid_param -from ffxivbis.api.views.common.login_base import LoginBaseView - - -class UsersHtmlView(LoginBaseView): - - @template('users.jinja2') - async def get(self) -> Dict[str, Any]: - error = None - users: List[User] = [] - - try: - users = await self.request.app['database'].get_users() - except Exception as e: - self.request.app.logger.exception('could not get users') - error = repr(e) - - return { - 'request_error': error, - 'users': users - } - - async def post(self) -> Response: - data = await self.request.post() - - required = ['action', 'username'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - - try: - action = data.getone('action') - username = str(data.getone('username')) - - if action == 'add': - required = ['password', 'permission'] - if any(param not in data for param in required): - return wrap_invalid_param(required, data) - await self.create_user(username, data.getone('password'), data.getone('permission')) # type: ignore - elif action == 'remove': - await self.remove_user(username) - else: - return wrap_invalid_param(['action'], data) - - except Exception as e: - self.request.app.logger.exception('could not manage users') - return wrap_exception(e, data) - - return HTTPFound(self.request.url) \ No newline at end of file diff --git a/src/ffxivbis/api/web.py b/src/ffxivbis/api/web.py deleted file mode 100644 index 65743c6..0000000 --- a/src/ffxivbis/api/web.py +++ /dev/null @@ -1,71 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -import aiohttp_jinja2 -import jinja2 -import logging - -from aiohttp import web -from aiohttp_security import setup as setup_security -from aiohttp_security import CookiesIdentityPolicy - -from ffxivbis.core.config import Configuration -from ffxivbis.core.database import Database -from ffxivbis.core.loot_selector import LootSelector -from ffxivbis.core.party import Party - -from .auth import AuthorizationPolicy, authorize_factory -from .routes import setup_routes -from .spec import get_spec - - -async def on_shutdown(app: web.Application) -> None: - app.logger.warning('server terminated') - - -def run_server(app: web.Application) -> None: - app.logger.info('start server') - web.run_app(app, - host=app['config'].get('web', 'host'), - port=app['config'].getint('web', 'port'), - handle_signals=False) - -def setup_service(config: Configuration, database: Database, loot: LootSelector, party: Party) -> web.Application: - app = web.Application(logger=logging.getLogger('http')) - app.on_shutdown.append(on_shutdown) - - app.middlewares.append(web.normalize_path_middleware(append_slash=False, remove_slash=True)) - - # auth related - auth_required = config.getboolean('auth', 'enabled') - if auth_required: - setup_security(app, CookiesIdentityPolicy(), AuthorizationPolicy(database)) - app.middlewares.append(authorize_factory()) - - # routes - app.logger.info('setup routes') - setup_routes(app) - if config.has_option('web', 'templates'): - templates_root = app['templates_root'] = config.get('web', 'templates') - app['static_root_url'] = '/static' - aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(templates_root)) - app['spec'] = get_spec(app) - - app.logger.info('setup configuration') - app['config'] = config - - app.logger.info('setup database') - app['database'] = database - - app.logger.info('setup loot selector') - app['loot'] = loot - - app.logger.info('setup party worker') - app['party'] = party - - return app diff --git a/src/ffxivbis/application/__init__.py b/src/ffxivbis/application/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ffxivbis/application/application.py b/src/ffxivbis/application/application.py deleted file mode 100644 index c3f8c75..0000000 --- a/src/ffxivbis/application/application.py +++ /dev/null @@ -1,31 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from ffxivbis.core.config import Configuration - -from .core import Application - - -def get_config(config_path: str) -> Configuration: - config = Configuration() - config.load(config_path, {}) - config.load_logging() - - return config - - -if __name__ == '__main__': - import argparse - - parser = argparse.ArgumentParser(description='Simple loot recorder for FFXIV') - parser.add_argument('-c', '--config', help='configuration path', default='ffxivbis.ini') - args = parser.parse_args() - - config = get_config(args.config) - app = Application(config) - app.run() diff --git a/src/ffxivbis/application/core.py b/src/ffxivbis/application/core.py deleted file mode 100644 index 9190e74..0000000 --- a/src/ffxivbis/application/core.py +++ /dev/null @@ -1,41 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -import asyncio -import logging - -from ffxivbis.api.web import run_server, setup_service -from ffxivbis.core.config import Configuration -from ffxivbis.core.database import Database -from ffxivbis.core.loot_selector import LootSelector -from ffxivbis.core.party import Party -from ffxivbis.models.user import User - - -class Application: - - def __init__(self, config: Configuration) -> None: - self.config = config - self.logger = logging.getLogger('application') - - def run(self) -> None: - loop = asyncio.get_event_loop() - - database = loop.run_until_complete(Database.get(self.config)) - database.migration() - - party = loop.run_until_complete(Party.get(database)) - - admin = User(self.config.get('auth', 'root_username'), self.config.get('auth', 'root_password'), 'admin') - loop.run_until_complete(database.insert_user(admin, True)) - - priority = self.config.get('settings', 'priority').split() - loot_selector = LootSelector(party, priority) - - web = setup_service(self.config, database, loot_selector, party) - run_server(web) \ No newline at end of file diff --git a/src/ffxivbis/core/__init__.py b/src/ffxivbis/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ffxivbis/core/ariyala_parser.py b/src/ffxivbis/core/ariyala_parser.py deleted file mode 100644 index 2f3909a..0000000 --- a/src/ffxivbis/core/ariyala_parser.py +++ /dev/null @@ -1,83 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -import os -import socket - -from aiohttp import ClientSession -from typing import Dict, List, Optional - -from ffxivbis.models.piece import Piece - -from .config import Configuration - - -class AriyalaParser: - - def __init__(self, config: Configuration) -> None: - self.ariyala_url = config.get('ariyala', 'ariyala_url') - self.xivapi_key = config.get('ariyala', 'xivapi_key', fallback=None) - self.xivapi_url = config.get('ariyala', 'xivapi_url') - - def __remap_key(self, key: str) -> Optional[str]: - if key == 'mainhand': - return 'weapon' - elif key == 'chest': - return 'body' - elif key == 'ringLeft': - return 'left_ring' - elif key == 'ringRight': - return 'right_ring' - elif key in ('head', 'hands', 'waist', 'legs', 'feet', 'ears', 'neck', 'wrist'): - return key - return None - - async def get(self, url: str, job: str) -> List[Piece]: - items = await self.get_ids(url, job) - return [ - Piece.get({'piece': slot, 'is_tome': await self.get_is_tome(item_id)}) # type: ignore - for slot, item_id in items.items() - ] - - async def get_ids(self, url: str, job: str) -> Dict[str, int]: - norm_path = os.path.normpath(url) - set_id = os.path.basename(norm_path) - async with ClientSession() as session: - async with session.get(f'{self.ariyala_url}/store.app', params={'identifier': set_id}) as response: - response.raise_for_status() - data = await response.json(content_type='text/html') - - # it has job in response but for some reasons job name differs sometimes from one in dictionary, - # e.g. http://ffxiv.ariyala.com/store.app?identifier=1AJB8 - api_job = data['content'] - try: - bis = data['datasets'][api_job]['normal']['items'] - except KeyError: - bis = data['datasets'][job]['normal']['items'] - - result: Dict[str, int] = {} - for original_key, value in bis.items(): - key = self.__remap_key(original_key) - if key is None: - continue - result[key] = value - return result - - async def get_is_tome(self, item_id: int) -> bool: - params = {'columns': 'IsEquippable'} - if self.xivapi_key is not None: - params['private_key'] = self.xivapi_key - - async with ClientSession() as session: - # for some reasons ipv6 does not work for me - session.connector._family = socket.AF_INET # type: ignore - async with session.get(f'{self.xivapi_url}/item/{item_id}', params=params) as response: - response.raise_for_status() - data = await response.json() - - return data['IsEquippable'] == 0 # don't ask diff --git a/src/ffxivbis/core/config.py b/src/ffxivbis/core/config.py deleted file mode 100644 index 49ccb93..0000000 --- a/src/ffxivbis/core/config.py +++ /dev/null @@ -1,65 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -import configparser -import os - -from logging.config import fileConfig -from typing import Any, Dict, Mapping, Optional - -from .exceptions import MissingConfiguration - - -class Configuration(configparser.RawConfigParser): - - def __init__(self) -> None: - configparser.RawConfigParser.__init__(self, allow_no_value=True) - self.path: Optional[str] = None - self.root_path: Optional[str] = None - - @property - def include(self) -> str: - return self.__with_root_path(self.get('settings', 'include')) - - def __load_section(self, conf: str) -> None: - self.read(os.path.join(self.include, conf)) - - def __with_root_path(self, path: str) -> str: - if self.root_path is None: - return path - return os.path.join(self.root_path, path) - - def get_section(self, section: str) -> Dict[str, str]: - if not self.has_section(section): - raise MissingConfiguration(section) - return dict(self[section]) - - def load(self, path: str, values: Mapping[str, Mapping[str, Any]]) -> None: - self.path = path - self.root_path = os.path.dirname(self.path) - - self.read(self.path) - self.load_includes() - - # don't use direct ConfigParser.update here, it overrides whole section - for section, options in values.items(): - if section not in self: - self.add_section(section) - for key, value in options.items(): - self.set(section, key, value) - - def load_includes(self) -> None: - try: - include_dir = self.include - for conf in filter(lambda p: p.endswith('.ini'), sorted(os.listdir(include_dir))): - self.__load_section(conf) - except (FileNotFoundError, configparser.NoOptionError, configparser.NoSectionError): - pass - - def load_logging(self) -> None: - fileConfig(self.__with_root_path(self.get('settings', 'logging'))) diff --git a/src/ffxivbis/core/database.py b/src/ffxivbis/core/database.py deleted file mode 100644 index 3fc0511..0000000 --- a/src/ffxivbis/core/database.py +++ /dev/null @@ -1,110 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from __future__ import annotations - -import datetime -import logging - -from yoyo import get_backend, read_migrations -from typing import List, Mapping, Optional, Type, Union - -from ffxivbis.models.loot import Loot -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player, PlayerId -from ffxivbis.models.upgrade import Upgrade -from ffxivbis.models.user import User - -from .config import Configuration -from .exceptions import InvalidDatabase - - -class Database: - - def __init__(self, migrations_path: str) -> None: - self.migrations_path = migrations_path - self.logger = logging.getLogger('database') - - @staticmethod - def now() -> int: - return int(datetime.datetime.now().timestamp()) - - @classmethod - async def get(cls: Type[Database], config: Configuration) -> Database: - database_type = config.get('settings', 'database') - database_settings = config.get_section(database_type) - - if database_type == 'sqlite': - from .sqlite import SQLiteDatabase - obj: Type[Database] = SQLiteDatabase - elif database_type == 'postgres': - from .postgres import PostgresDatabase - obj = PostgresDatabase - else: - raise InvalidDatabase(database_type) - - database = obj(**database_settings) - await database.init() - return database - - @property - def connection(self) -> str: - raise NotImplementedError - - async def init(self) -> None: - pass - - async def delete_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: - raise NotImplementedError - - async def delete_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: - raise NotImplementedError - - async def delete_player(self, player_id: PlayerId) -> None: - raise NotImplementedError - - async def delete_user(self, username: str) -> None: - raise NotImplementedError - - async def get_party(self) -> List[Player]: - raise NotImplementedError - - async def get_player(self, player_id: PlayerId) -> Optional[int]: - raise NotImplementedError - - async def get_user(self, username: str) -> Optional[User]: - raise NotImplementedError - - async def get_users(self) -> List[User]: - raise NotImplementedError - - async def insert_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: - raise NotImplementedError - - async def insert_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: - raise NotImplementedError - - async def insert_player(self, player: Player) -> None: - raise NotImplementedError - - async def insert_user(self, user: User, hashed_password: bool) -> None: - raise NotImplementedError - - def migration(self) -> None: - self.logger.info('perform migrations') - backend = get_backend(self.connection) - migrations = read_migrations(self.migrations_path) - with backend.lock(): - backend.apply_migrations(backend.to_apply(migrations)) - - def set_loot(self, party: Mapping[int, Player], bis: List[Loot], loot: List[Loot]) -> List[Player]: - for piece in bis: - party[piece.player_id].bis.set_item(piece.piece) - for piece in loot: - party[piece.player_id].loot.append(piece.piece) - return list(party.values()) diff --git a/src/ffxivbis/core/exceptions.py b/src/ffxivbis/core/exceptions.py deleted file mode 100644 index a2a0cb7..0000000 --- a/src/ffxivbis/core/exceptions.py +++ /dev/null @@ -1,27 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from typing import Any, Mapping - - -class InvalidDatabase(Exception): - - def __init__(self, database_type: str) -> None: - Exception.__init__(self, f'Unsupported database {database_type}') - - -class InvalidDataRow(Exception): - - def __init__(self, data: Mapping[str, Any]) -> None: - Exception.__init__(self, f'Invalid data row `{data}`') - - -class MissingConfiguration(Exception): - - def __init__(self, section: str) -> None: - Exception.__init__(self, f'Missing configuration section {section}') \ No newline at end of file diff --git a/src/ffxivbis/core/loot_selector.py b/src/ffxivbis/core/loot_selector.py deleted file mode 100644 index d2de3ed..0000000 --- a/src/ffxivbis/core/loot_selector.py +++ /dev/null @@ -1,32 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from typing import Iterable, List, Tuple, Union - -from ffxivbis.models.player import Player, PlayerIdWithCounters -from ffxivbis.models.piece import Piece -from ffxivbis.models.upgrade import Upgrade - -from .party import Party - - -class LootSelector: - - def __init__(self, party: Party, order_by: List[str] = None) -> None: - self.party = party - self.order_by = order_by or ['is_required', 'loot_count_bis', 'loot_count_total', 'loot_count', 'loot_priority'] - - def __order_by(self, player: Player, piece: Union[Piece, Upgrade]) -> Tuple: - return tuple(map(lambda method: getattr(player, method)(piece), self.order_by)) - - def __sorted_by(self, piece: Union[Piece, Upgrade]) -> Iterable[Player]: - # pycharm is lying, don't trust it - return sorted(self.party.players.values(), key=lambda player: self.__order_by(player, piece), reverse=True) - - def suggest(self, piece: Union[Piece, Upgrade]) -> List[PlayerIdWithCounters]: - return [player.player_id_with_counters(piece) for player in self.__sorted_by(piece)] \ No newline at end of file diff --git a/src/ffxivbis/core/party.py b/src/ffxivbis/core/party.py deleted file mode 100644 index 48d770b..0000000 --- a/src/ffxivbis/core/party.py +++ /dev/null @@ -1,81 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from __future__ import annotations - -from threading import Lock -from typing import Dict, List, Optional, Type, Union - -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player, PlayerId -from ffxivbis.models.upgrade import Upgrade - -from .database import Database - - -class Party: - - def __init__(self, database: Database) -> None: - self.lock = Lock() - self.players: Dict[PlayerId, Player] = {} - self.database = database - - @property - def party(self) -> List[Player]: - with self.lock: - return list(self.players.values()) - - @classmethod - async def get(cls: Type[Party], database: Database) -> Party: - obj = Party(database) - players = await database.get_party() - for player in players: - obj.players[player.player_id] = player - return obj - - async def set_bis_link(self, player_id: PlayerId, link: str) -> None: - with self.lock: - player = self.players[player_id] - player.link = link - await self.database.insert_player(player) - - async def remove_player(self, player_id: PlayerId) -> Optional[Player]: - await self.database.delete_player(player_id) - with self.lock: - player = self.players.pop(player_id, None) - return player - - async def set_player(self, player: Player) -> PlayerId: - player_id = player.player_id - await self.database.insert_player(player) - with self.lock: - self.players[player_id] = player - return player_id - - async def set_item(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: - await self.database.insert_piece(player_id, piece) - with self.lock: - self.players[player_id].loot.append(piece) - - async def remove_item(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: - await self.database.delete_piece(player_id, piece) - with self.lock: - try: - self.players[player_id].loot.remove(piece) - except ValueError: - pass - - async def set_item_bis(self, player_id: PlayerId, piece: Piece) -> None: - await self.database.insert_piece_bis(player_id, piece) - with self.lock: - self.players[player_id].bis.set_item(piece) - - async def remove_item_bis(self, player_id: PlayerId, piece: Piece) -> None: - await self.database.delete_piece_bis(player_id, piece) - with self.lock: - self.players[player_id].bis.remove_item(piece) diff --git a/src/ffxivbis/core/postgres.py b/src/ffxivbis/core/postgres.py deleted file mode 100644 index 079be64..0000000 --- a/src/ffxivbis/core/postgres.py +++ /dev/null @@ -1,164 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -import asyncpg - -from passlib.hash import md5_crypt -from psycopg2.extras import DictCursor -from typing import List, Optional, Union - -from ffxivbis.models.bis import BiS -from ffxivbis.models.job import Job -from ffxivbis.models.loot import Loot -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player, PlayerId -from ffxivbis.models.upgrade import Upgrade -from ffxivbis.models.user import User - -from .database import Database - - -class PostgresDatabase(Database): - - def __init__(self, host: str, port: int, username: str, password: str, database: str, migrations_path: str) -> None: - Database.__init__(self, migrations_path) - self.host = host - self.port = int(port) - self.username = username - self.password = password - self.database = database - self.pool: asyncpg.pool.Pool = None # type: ignore - - @property - def connection(self) -> str: - return f'postgresql://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}' - - async def init(self) -> None: - self.pool = await asyncpg.create_pool(host=self.host, port=self.port, username=self.username, - password=self.password, database=self.database) - - async def delete_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: - player = await self.get_player(player_id) - if player is None: - return - - async with self.pool.acquire() as conn: - await conn.execute( - '''delete from loot - where loot_id in ( - select loot_id from loot - where player_id = $1 and piece = $2 and is_tome = $3 order by created desc limit 1 - )''', - player, piece.name, getattr(piece, 'is_tome', True) - ) - - async def delete_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: - player = await self.get_player(player_id) - if player is None: - return - - async with self.pool.acquire() as conn: - await conn.execute( - '''delete from bis where player_id = $1 and piece = $2''', - player, piece.name) - - async def delete_player(self, player_id: PlayerId) -> None: - async with self.pool.acquire() as conn: - await conn.execute('''delete from players where nick = $1 and job = $2''', - player_id.nick, player_id.job.name) - - async def delete_user(self, username: str) -> None: - async with self.pool.acquire() as conn: - await conn.execute('''delete from users where username = $1''', username) - - async def get_party(self) -> List[Player]: - async with self.pool.acquire() as conn: - rows = await conn.fetch('''select * from bis''') - bis_pieces = [Loot(row['player_id'], Piece.get(row)) for row in rows] - - rows = await conn.fetch('''select * from loot''') - loot_pieces = [Loot(row['player_id'], Piece.get(row)) for row in rows] - - rows = await conn.fetch('''select * from players''') - party = { - row['player_id']: Player(Job[row['job']], row['nick'], BiS(), [], row['bis_link'], row['priority']) - for row in rows - } - - return self.set_loot(party, bis_pieces, loot_pieces) - - async def get_player(self, player_id: PlayerId) -> Optional[int]: - async with self.pool.acquire() as conn: - player = await conn.fetchrow('''select player_id from players where nick = $1 and job = $2''', - player_id.nick, player_id.job.name) - return player['player_id'] if player is not None else None - - async def get_user(self, username: str) -> Optional[User]: - async with self.pool.acquire() as conn: - user = await conn.fetchrow('''select * from users where username = $1''', username) - return User(user['username'], user['password'], user['permission']) if user is not None else None - - async def get_users(self) -> List[User]: - async with self.pool.acquire() as conn: - users = await conn.fetch('''select * from users''') - return [User(user['username'], user['password'], user['permission']) for user in users] - - async def insert_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: - player = await self.get_player(player_id) - if player is None: - return - - async with self.pool.acquire() as conn: - await conn.execute( - '''insert into loot - (created, piece, is_tome, player_id) - values - ($1, $2, $3, $4)''', - Database.now(), piece.name, getattr(piece, 'is_tome', True), player - ) - - async def insert_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: - player = await self.get_player(player_id) - if player is None: - return - - async with self.pool.acquire() as conn: - await conn.execute( - '''insert into bis - (created, piece, is_tome, player_id) - values - ($1, $2, $3, $4) - on conflict on constraint bis_piece_player_id_idx do update set - created = $1, is_tome = $3''', - Database.now(), piece.name, piece.is_tome, player - ) - - async def insert_player(self, player: Player) -> None: - async with self.pool.acquire() as conn: - await conn.execute( - '''insert into players - (created, nick, job, bis_link, priority) - values - ($1, $2, $3, $4, $5) - on conflict on constraint players_nick_job_idx do update set - created = $1, bis_link = $4, priority = $5''', - Database.now(), player.nick, player.job.name, player.link, player.priority - ) - - async def insert_user(self, user: User, hashed_password: bool) -> None: - password = user.password if hashed_password else md5_crypt.hash(user.password) - async with self.pool.acquire() as conn: - await conn.execute( - '''insert into users - (username, password, permission) - values - ($1, $2, $3) - on conflict on constraint users_username_idx do update set - password = $2, permission = $3''', - user.username, password, user.permission - ) \ No newline at end of file diff --git a/src/ffxivbis/core/sqlite.py b/src/ffxivbis/core/sqlite.py deleted file mode 100644 index fe1ce98..0000000 --- a/src/ffxivbis/core/sqlite.py +++ /dev/null @@ -1,152 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from passlib.hash import md5_crypt -from typing import List, Optional, Union - -from ffxivbis.models.bis import BiS -from ffxivbis.models.job import Job -from ffxivbis.models.loot import Loot -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player, PlayerId -from ffxivbis.models.upgrade import Upgrade -from ffxivbis.models.user import User - -from .database import Database -from .sqlite_helper import SQLiteHelper - - -class SQLiteDatabase(Database): - - def __init__(self, database_path: str, migrations_path: str) -> None: - Database.__init__(self, migrations_path) - self.database_path = database_path - - @property - def connection(self) -> str: - return f'sqlite:///{self.database_path}' - - async def delete_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: - player = await self.get_player(player_id) - if player is None: - return - - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute( - '''delete from loot - where loot_id in ( - select loot_id from loot - where player_id = ? and piece = ? and is_tome = ? order by created desc limit 1 - )''', - (player, piece.name, getattr(piece, 'is_tome', True))) - - async def delete_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: - player = await self.get_player(player_id) - if player is None: - return - - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute( - '''delete from bis where player_id = ? and piece = ?''', - (player, piece.name)) - - async def delete_player(self, player_id: PlayerId) -> None: - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute('''delete from players where nick = ? and job = ?''', - (player_id.nick, player_id.job.name)) - - async def delete_user(self, username: str) -> None: - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute('''delete from users where username = ?''', (username,)) - - async def get_party(self) -> List[Player]: - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute('''select * from bis''') - rows = await cursor.fetchall() - bis_pieces = [Loot(row['player_id'], Piece.get(row)) for row in rows] - - await cursor.execute('''select * from loot''') - rows = await cursor.fetchall() - loot_pieces = [Loot(row['player_id'], Piece.get(row)) for row in rows] - - await cursor.execute('''select * from players''') - rows = await cursor.fetchall() - party = { - row['player_id']: Player(Job[row['job']], row['nick'], BiS(), [], row['bis_link'], row['priority']) - for row in rows - } - - return self.set_loot(party, bis_pieces, loot_pieces) - - async def get_player(self, player_id: PlayerId) -> Optional[int]: - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute('''select player_id from players where nick = ? and job = ?''', - (player_id.nick, player_id.job.name)) - player = await cursor.fetchone() - return player['player_id'] if player is not None else None - - async def get_user(self, username: str) -> Optional[User]: - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute('''select * from users where username = ?''', (username,)) - user = await cursor.fetchone() - return User(user['username'], user['password'], user['permission']) if user is not None else None - - async def get_users(self) -> List[User]: - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute('''select * from users''') - users = await cursor.fetchall() - return [User(user['username'], user['password'], user['permission']) for user in users] - - async def insert_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: - player = await self.get_player(player_id) - if player is None: - return - - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute( - '''insert into loot - (created, piece, is_tome, player_id) - values - (?, ?, ?, ?)''', - (Database.now(), piece.name, getattr(piece, 'is_tome', True), player) - ) - - async def insert_piece_bis(self, player_id: PlayerId, piece: Piece) -> None: - player = await self.get_player(player_id) - if player is None: - return - - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute( - '''replace into bis - (created, piece, is_tome, player_id) - values - (?, ?, ?, ?)''', - (Database.now(), piece.name, piece.is_tome, player) - ) - - async def insert_player(self, player: Player) -> None: - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute( - '''replace into players - (created, nick, job, bis_link, priority) - values - (?, ?, ?, ?, ?)''', - (Database.now(), player.nick, player.job.name, player.link, player.priority) - ) - - async def insert_user(self, user: User, hashed_password: bool) -> None: - password = user.password if hashed_password else md5_crypt.hash(user.password) - async with SQLiteHelper(self.database_path) as cursor: - await cursor.execute( - '''replace into users - (username, password, permission) - values - (?, ?, ?)''', - (user.username, password, user.permission) - ) \ No newline at end of file diff --git a/src/ffxivbis/core/sqlite_helper.py b/src/ffxivbis/core/sqlite_helper.py deleted file mode 100644 index 42c4e78..0000000 --- a/src/ffxivbis/core/sqlite_helper.py +++ /dev/null @@ -1,36 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -# because sqlite3 does not support context management -import aiosqlite - -from types import TracebackType -from typing import Any, Dict, Optional, Type - - -def dict_factory(cursor: aiosqlite.Cursor, row: aiosqlite.Row) -> Dict[str, Any]: - return { - key: value - for key, value in zip([column[0] for column in cursor.description], row) - } - - -class SQLiteHelper(): - def __init__(self, database_path: str) -> None: - self.database_path = database_path - - async def __aenter__(self) -> aiosqlite.Cursor: - self.conn = await aiosqlite.connect(self.database_path) - self.conn.row_factory = dict_factory - await self.conn.execute('''pragma foreign_keys = on''') - return await self.conn.cursor() - - async def __aexit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], - traceback: Optional[TracebackType]) -> None: - await self.conn.commit() - await self.conn.close() \ No newline at end of file diff --git a/src/ffxivbis/core/version.py b/src/ffxivbis/core/version.py deleted file mode 100644 index a39b12d..0000000 --- a/src/ffxivbis/core/version.py +++ /dev/null @@ -1,9 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -__version__ = '0.1.1' \ No newline at end of file diff --git a/src/ffxivbis/models/__init__.py b/src/ffxivbis/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ffxivbis/models/action.py b/src/ffxivbis/models/action.py deleted file mode 100644 index 7110c35..0000000 --- a/src/ffxivbis/models/action.py +++ /dev/null @@ -1,16 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from enum import auto - -from .serializable import SerializableEnum - - -class Action(SerializableEnum): - add = auto() - remove = auto() \ No newline at end of file diff --git a/src/ffxivbis/models/bis.py b/src/ffxivbis/models/bis.py deleted file mode 100644 index 2bb293f..0000000 --- a/src/ffxivbis/models/bis.py +++ /dev/null @@ -1,140 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -import itertools - -from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Type, Union - -from .job import Job -from .piece import Piece -from .serializable import Serializable -from .upgrade import Upgrade - - -@dataclass -class BiSLink(Serializable): - nick: str - job: Job - link: str - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'job': { - 'description': 'player job name', - '$ref': cls.model_ref('Job') - }, - 'link': { - 'description': 'link to BiS set', - 'example': 'https://ffxiv.ariyala.com/19V5R', - 'type': 'string' - }, - 'nick': { - 'description': 'player nick name', - 'example': 'Siuan Sanche', - 'type': 'string' - } - } - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return ['job', 'link', 'nick'] - - -@dataclass -class BiS(Serializable): - weapon: Optional[Piece] = None - head: Optional[Piece] = None - body: Optional[Piece] = None - hands: Optional[Piece] = None - waist: Optional[Piece] = None - legs: Optional[Piece] = None - feet: Optional[Piece] = None - ears: Optional[Piece] = None - neck: Optional[Piece] = None - wrist: Optional[Piece] = None - left_ring: Optional[Piece] = None - right_ring: Optional[Piece] = None - - @property - def pieces(self) -> List[Piece]: - return [piece for piece in self.__dict__.values() if isinstance(piece, Piece)] - - @property - def upgrades_required(self) -> Dict[Upgrade, int]: - return { - upgrade: len(list(pieces)) - for upgrade, pieces in itertools.groupby(self.pieces, lambda piece: piece.upgrade) - } - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'weapon': { - 'description': 'weapon part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'head': { - 'description': 'head part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'body': { - 'description': 'body part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'hands': { - 'description': 'hands part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'waist': { - 'description': 'waist part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'legs': { - 'description': 'legs part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'feet': { - 'description': 'feet part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'ears': { - 'description': 'ears part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'neck': { - 'description': 'neck part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'wrist': { - 'description': 'wrist part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'left_ring': { - 'description': 'left_ring part of BiS', - '$ref': cls.model_ref('Piece') - }, - 'right_ring': { - 'description': 'right_ring part of BiS', - '$ref': cls.model_ref('Piece') - } - } - - def has_piece(self, piece: Union[Piece, Upgrade]) -> bool: - if isinstance(piece, Piece): - return piece in self.pieces - elif isinstance(piece, Upgrade): - return self.upgrades_required.get(piece) is not None - return False - - def set_item(self, piece: Union[Piece, Upgrade]) -> None: - setattr(self, piece.name, piece) - - def remove_item(self, piece: Union[Piece, Upgrade]) -> None: - setattr(self, piece.name, None) \ No newline at end of file diff --git a/src/ffxivbis/models/error.py b/src/ffxivbis/models/error.py deleted file mode 100644 index 5701053..0000000 --- a/src/ffxivbis/models/error.py +++ /dev/null @@ -1,36 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from dataclasses import dataclass -from typing import Any, Dict, List, Type - -from .serializable import Serializable - - -@dataclass -class Error(Serializable): - message: str - arguments: Dict[str, Any] - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'arguments': { - 'description': 'arguments passed to request', - 'type': 'object', - 'additionalProperties': True - }, - 'message': { - 'description': 'error message', - 'type': 'string' - } - } - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return ['arguments', 'message'] \ No newline at end of file diff --git a/src/ffxivbis/models/job.py b/src/ffxivbis/models/job.py deleted file mode 100644 index 420cc13..0000000 --- a/src/ffxivbis/models/job.py +++ /dev/null @@ -1,87 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from __future__ import annotations - -from enum import auto -from typing import Tuple - -from .piece import Piece, PieceAccessory, Weapon -from .serializable import SerializableEnum - - -class Job(SerializableEnum): - PLD = auto() - WAR = auto() - DRK = auto() - GNB = auto() - WHM = auto() - SCH = auto() - AST = auto() - MNK = auto() - DRG = auto() - NIN = auto() - SAM = auto() - BRD = auto() - MCH = auto() - DNC = auto() - BLM = auto() - SMN = auto() - RDM = auto() - - @staticmethod - def group_accs_dex() -> Tuple: - return Job.group_ranges() + (Job.NIN,) - - @staticmethod - def group_accs_str() -> Tuple: - return Job.group_mnk() + (Job.DRG,) - - @staticmethod - def group_casters() -> Tuple: - return (Job.BLM, Job.SMN, Job.RDM) - - @staticmethod - def group_healers() -> Tuple: - return (Job.WHM, Job.SCH, Job.AST) - - @staticmethod - def group_mnk() -> Tuple: - return (Job.MNK, Job.SAM) - - @staticmethod - def group_ranges() -> Tuple: - return (Job.BRD, Job.MCH, Job.DNC) - - @staticmethod - def group_tanks() -> Tuple: - return (Job.PLD, Job.WAR, Job.DRK, Job.GNB) - - @staticmethod - def has_same_loot(left: Job, right: Job, piece: Piece) -> bool: - # same jobs, alright - if left == right: - return True - - # weapons are unique per class always - if isinstance(piece, Weapon): - return False - - # group comparison - for group in (Job.group_casters(), Job.group_healers(), Job.group_mnk(), Job.group_ranges(), Job.group_tanks()): - if left in group and right in group: - return True - - # accessories group comparison - if isinstance(Piece, PieceAccessory): - for group in (Job.group_accs_dex(), Job.group_accs_str()): - if left in group and right in group: - return True - - return False - diff --git a/src/ffxivbis/models/loot.py b/src/ffxivbis/models/loot.py deleted file mode 100644 index 0fa04c3..0000000 --- a/src/ffxivbis/models/loot.py +++ /dev/null @@ -1,37 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from dataclasses import dataclass -from typing import Any, Dict, List, Type, Union - -from .piece import Piece -from .serializable import Serializable -from .upgrade import Upgrade - - -@dataclass -class Loot(Serializable): - player_id: int - piece: Union[Piece, Upgrade] - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'piece': { - 'description': 'player piece', - '$ref': cls.model_ref('Piece') - }, - 'player_id': { - 'description': 'player identifier', - '$ref': cls.model_ref('PlayerId') - } - } - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return ['piece', 'player_id'] \ No newline at end of file diff --git a/src/ffxivbis/models/piece.py b/src/ffxivbis/models/piece.py deleted file mode 100644 index c95e389..0000000 --- a/src/ffxivbis/models/piece.py +++ /dev/null @@ -1,168 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Dict, List, Mapping, Type, Union - -from ffxivbis.core.exceptions import InvalidDataRow - -from .serializable import Serializable -from .upgrade import Upgrade - - - -@dataclass -class Piece(Serializable): - is_tome: bool - name: str - - @property - def upgrade(self) -> Upgrade: - if not self.is_tome: - return Upgrade.NoUpgrade - elif isinstance(self, Waist) or isinstance(self, PieceAccessory): - return Upgrade.AccessoryUpgrade - elif isinstance(self, Weapon): - return Upgrade.WeaponUpgrade - elif isinstance(self, PieceGear): - return Upgrade.GearUpgrade - return Upgrade.NoUpgrade - - @staticmethod - def available() -> List[str]: - return [ - 'weapon', - 'head', 'body', 'hands', 'waist', 'legs', 'feet', - 'ears', 'neck', 'wrist', 'left_ring', 'right_ring' - ] - - @classmethod - def get(cls: Type[Piece], data: Mapping[str, Any]) -> Union[Piece, Upgrade]: - try: - piece_type = data.get('piece') or data.get('name') - if piece_type is None: - raise KeyError - is_tome = data['is_tome'] in ('yes', 'on', '1', 1, True) - except KeyError: - raise InvalidDataRow(data) - if piece_type.lower() == 'weapon': - return Weapon(is_tome) - elif piece_type.lower() == 'head': - return Head(is_tome) - elif piece_type.lower() == 'body': - return Body(is_tome) - elif piece_type.lower() == 'hands': - return Hands(is_tome) - elif piece_type.lower() == 'waist': - return Waist(is_tome) - elif piece_type.lower() == 'legs': - return Legs(is_tome) - elif piece_type.lower() == 'feet': - return Feet(is_tome) - elif piece_type.lower() == 'ears': - return Ears(is_tome) - elif piece_type.lower() == 'neck': - return Neck(is_tome) - elif piece_type.lower() == 'wrist': - return Wrist(is_tome) - elif piece_type.lower() in ('left_ring', 'right_ring', 'ring'): - return Ring(is_tome, piece_type.lower()) - elif piece_type.lower() in Upgrade.dict_types(): - return Upgrade[piece_type] - else: - raise InvalidDataRow(data) - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'is_tome': { - 'description': 'is this piece tome gear or not', - 'type': 'boolean' - }, - 'name': { - 'description': 'piece name', - 'type': 'string' - } - } - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return ['is_tome', 'name'] - - -@dataclass -class PieceAccessory(Piece): - pass - - -@dataclass -class PieceGear(Piece): - pass - - -@dataclass -class Weapon(Piece): - name: str = 'weapon' - - -@dataclass -class Head(PieceGear): - name: str = 'head' - - -@dataclass -class Body(PieceGear): - name: str = 'body' - - -@dataclass -class Hands(PieceGear): - name: str = 'hands' - - -@dataclass -class Waist(PieceGear): - name: str = 'waist' - - -@dataclass -class Legs(PieceGear): - name: str = 'legs' - - -@dataclass -class Feet(PieceGear): - name: str = 'feet' - - -@dataclass -class Ears(PieceAccessory): - name: str = 'ears' - - -@dataclass -class Neck(PieceAccessory): - name: str = 'neck' - - -@dataclass -class Wrist(PieceAccessory): - name: str = 'wrist' - - -@dataclass -class Ring(PieceAccessory): - name: str = 'ring' - - # override __eq__method to be able to compare left/right rings - def __eq__(self, other: Any) -> bool: - if not isinstance(other, Ring): - return False - return self.is_tome == other.is_tome diff --git a/src/ffxivbis/models/player.py b/src/ffxivbis/models/player.py deleted file mode 100644 index f53fdd7..0000000 --- a/src/ffxivbis/models/player.py +++ /dev/null @@ -1,201 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from __future__ import annotations - -import re - -from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Type, Union - -from .bis import BiS -from .job import Job -from .piece import Piece -from .serializable import Serializable -from .upgrade import Upgrade - - -@dataclass -class PlayerId(Serializable): - job: Job - nick: str - - @property - def pretty_name(self) -> str: - return f'{self.nick} ({self.job.name})' - - @classmethod - def from_pretty_name(cls: Type[PlayerId], value: str) -> Optional[PlayerId]: - matches = re.search('^(?P.*) \((?P[A-Z]+)\)$', value) - if matches is None: - return None - return PlayerId(Job[matches.group('job')], matches.group('nick')) - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'job': { - 'description': 'player job name', - '$ref': cls.model_ref('Job') - }, - 'nick': { - 'description': 'player nick name', - 'type': 'string' - } - } - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return ['job', 'nick'] - - def __hash__(self) -> int: - return hash(str(self)) - - -@dataclass -class PlayerIdWithCounters(PlayerId): - is_required: bool - priority: int - loot_count: int - loot_count_bis: int - loot_count_total: int - bis_count_total: int - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'bis_count_total': { - 'description': 'total savage pieces in BiS', - 'type': 'integer' - }, - 'is_required': { - 'description': 'is item required by BiS or not', - 'type': 'boolean' - }, - 'job': { - 'description': 'player job name', - '$ref': cls.model_ref('Job') - }, - 'loot_count': { - 'description': 'count of this item which was already looted', - 'type': 'integer' - }, - 'loot_count_bis': { - 'description': 'count of BiS items which were already looted', - 'type': 'integer' - }, - 'loot_count_total': { - 'description': 'total count of items which were looted', - 'type': 'integer' - }, - 'nick': { - 'description': 'player nick name', - 'type': 'string' - }, - 'priority': { - 'description': 'player loot priority', - 'type': 'integer' - } - } - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return ['bis_count_total', 'is_required', 'job', 'loot_count', - 'loot_count_bis', 'loot_count_total', 'nick', 'priority'] - - -@dataclass -class Player(Serializable): - job: Job - nick: str - bis: BiS - loot: List[Union[Piece, Upgrade]] - link: Optional[str] = None - priority: int = 0 - - @property - def player_id(self) -> PlayerId: - return PlayerId(self.job, self.nick) - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'bis': { - 'description': 'player BiS', - '$ref': cls.model_ref('BiS') - }, - 'job': { - 'description': 'player job name', - '$ref': cls.model_ref('Job') - }, - 'link': { - 'description': 'link to player BiS', - 'type': 'string' - }, - 'loot': { - 'description': 'player looted items', - 'type': 'array', - 'items': { - 'anyOf': [ - {'$ref': cls.model_ref('Piece')}, - {'$ref': cls.model_ref('Upgrade')} - ] - } - }, - 'nick': { - 'description': 'player nick name', - 'type': 'string' - }, - 'priority': { - 'description': 'player loot priority', - 'type': 'integer' - } - } - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return ['bis', 'job', 'loot', 'nick', 'priority'] - - def player_id_with_counters(self, piece: Union[Piece, Upgrade, None]) -> PlayerIdWithCounters: - return PlayerIdWithCounters(self.job, self.nick, self.is_required(piece), self.priority, - abs(self.loot_count(piece)), abs(self.loot_count_bis(piece)), - abs(self.loot_count_total(piece)), abs(self.bis_count_total(piece))) - - # ordering methods - def is_required(self, piece: Union[Piece, Upgrade, None]) -> bool: - if piece is None: - return False - - # lets check if it is even in bis - if not self.bis.has_piece(piece): - return False - - if isinstance(piece, Piece): - # alright it is in is, lets check if he even got it - return self.loot_count(piece) == 0 - elif isinstance(piece, Upgrade): - # alright it lets check how much upgrades does they need - return self.bis.upgrades_required[piece] > self.loot_count(piece) - return False - - def loot_count(self, piece: Union[Piece, Upgrade, None]) -> int: - if piece is None: - return -self.loot_count_total(piece) - return -self.loot.count(piece) - - def loot_count_bis(self, _: Union[Piece, Upgrade, None]) -> int: - return -len([piece for piece in self.loot if self.bis.has_piece(piece)]) - - def loot_count_total(self, _: Union[Piece, Upgrade, None]) -> int: - return -len(self.loot) - - def bis_count_total(self, _: Union[Piece, Upgrade, None]) -> int: - return len([piece for piece in self.bis.pieces if not piece.is_tome]) - - def loot_priority(self, _: Union[Piece, Upgrade, None]) -> int: - return self.priority diff --git a/src/ffxivbis/models/player_edit.py b/src/ffxivbis/models/player_edit.py deleted file mode 100644 index e3270f2..0000000 --- a/src/ffxivbis/models/player_edit.py +++ /dev/null @@ -1,35 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from typing import Any, Dict, List, Type - -from .serializable import Serializable - - -class PlayerEdit(Serializable): - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'action': { - 'description': 'action to perform', - '$ref': cls.model_ref('Action') - }, - 'job': { - 'description': 'player job name to edit', - '$ref': cls.model_ref('Job') - }, - 'nick': { - 'description': 'player nick name to edit', - 'type': 'string' - } - } - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return ['action', 'nick', 'job'] \ No newline at end of file diff --git a/src/ffxivbis/models/serializable.py b/src/ffxivbis/models/serializable.py deleted file mode 100644 index 247b5ec..0000000 --- a/src/ffxivbis/models/serializable.py +++ /dev/null @@ -1,57 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from __future__ import annotations - -from enum import Enum -from typing import Any, Dict, List, Type - - -class Serializable: - - @classmethod - def model_name(cls: Type[Serializable]) -> str: - return cls.__name__ - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - raise NotImplementedError - - @staticmethod - def model_ref(model_name: str, model_group: str = 'schemas') -> str: - return f'#/components/{model_group}/{model_name}' - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return [] - - @classmethod - def model_spec(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'type': cls.model_type(), - 'properties': cls.model_properties(), - 'required': cls.model_required() - } - - @classmethod - def model_type(cls: Type[Serializable]) -> str: - return 'object' - - -class SerializableEnum(Serializable, Enum): - - @classmethod - def model_spec(cls: Type[SerializableEnum]) -> Dict[str, Any]: - return { - 'type': cls.model_type(), - 'enum': [item.name for item in cls] - } - - @classmethod - def model_type(cls: Type[Serializable]) -> str: - return 'string' \ No newline at end of file diff --git a/src/ffxivbis/models/upgrade.py b/src/ffxivbis/models/upgrade.py deleted file mode 100644 index b5fb86d..0000000 --- a/src/ffxivbis/models/upgrade.py +++ /dev/null @@ -1,23 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from enum import auto -from typing import List - -from .serializable import SerializableEnum - - -class Upgrade(SerializableEnum): - NoUpgrade = auto() - AccessoryUpgrade = auto() - GearUpgrade = auto() - WeaponUpgrade = auto() - - @staticmethod - def dict_types() -> List[str]: - return list(map(lambda t: t.name.lower(), Upgrade)) \ No newline at end of file diff --git a/src/ffxivbis/models/user.py b/src/ffxivbis/models/user.py deleted file mode 100644 index 588ebf4..0000000 --- a/src/ffxivbis/models/user.py +++ /dev/null @@ -1,42 +0,0 @@ -# -# Copyright (c) 2019 Evgeniy Alekseev. -# -# This file is part of ffxivbis -# (see https://github.com/arcan1s/ffxivbis). -# -# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause -# -from dataclasses import dataclass -from typing import Any, Dict, List, Type - -from .serializable import Serializable - - -@dataclass -class User(Serializable): - username: str - password: str - permission: str - - @classmethod - def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: - return { - 'password': { - 'description': 'user password', - 'type': 'string' - }, - 'permission': { - 'default': 'get', - 'description': 'user action permissions', - 'type': 'string', - 'enum': ['admin', 'get', 'post'] - }, - 'username': { - 'description': 'user name', - 'type': 'string' - } - } - - @classmethod - def model_required(cls: Type[Serializable]) -> List[str]: - return ['password', 'username'] \ No newline at end of file diff --git a/src/main/resources/db/migration/V1_0__Create_tables.sql b/src/main/resources/db/migration/V1_0__Create_tables.sql new file mode 100644 index 0000000..ec21e44 --- /dev/null +++ b/src/main/resources/db/migration/V1_0__Create_tables.sql @@ -0,0 +1,36 @@ +create table players ( + party_id text not null, + player_id integer primary key, + created integer not null, + nick text not null, + job text not null, + bis_link text, + priority integer not null default 1); +create unique index players_nick_job_idx on players(party_id, nick, job); + +create table loot ( + loot_id integer primary key, + player_id integer not null, + created integer not null, + piece text not null, + is_tome integer not null, + job text not null, + foreign key (player_id) references players(player_id) on delete cascade); +create index loot_owner_idx on loot(player_id); + +create table bis ( + player_id integer not null, + created integer not null, + piece text not null, + is_tome integer not null, + job text not null, + foreign key (player_id) references players(player_id) on delete cascade); +create unique index bis_piece_player_id_idx on bis(player_id, piece); + +create table users ( + party_id text not null, + user_id integer primary key, + username text not null, + password text not null, + permission text not null); +create unique index users_username_idx on users(party_id, username); diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf new file mode 100644 index 0000000..e1432b4 --- /dev/null +++ b/src/main/resources/reference.conf @@ -0,0 +1,43 @@ +me.arcanis.ffxivbis { + ariyala { + // ariyala base url, string, required + ariyala-url = "https://ffxiv.ariyala.com" + // xivapi base url, string, required + xivapi-url = "https://xivapi.com" + // xivapi developer key, string, optional + # xivapi-key = "abc-def" + } + + database { + // database section. Section must be declared inside + // for more detailed section descriptions refer to slick documentation + mode = "sqlite" + + sqlite { + profile = "slick.jdbc.SQLiteProfile$" + db { + url = "jdbc:sqlite:ffxivbis.db" + user = "user" + password = "password" + } + numThreads = 10 + } + } + + settings { + // counters of Player class which will be called to sort players for loot priority + // list of strings, required + priority = [ + "isRequired", "lootCountBiS", "priority", "lootCount", "lootCountTotal" + ] + // general request timeout, duratin, required + request-timeout = 10s + } + + web { + // address to bind, string, required + host = "0.0.0.0" + // port to bind, int, required + port = 8000 + } +} diff --git a/templates/api.jinja2 b/src/main/resources/swagger/index.html similarity index 61% rename from templates/api.jinja2 rename to src/main/resources/swagger/index.html index 0a9fda0..6116952 100644 --- a/templates/api.jinja2 +++ b/src/main/resources/swagger/index.html @@ -1,6 +1,6 @@ - + ReDoc @@ -11,14 +11,14 @@ ReDoc doesn't change outer page styles --> - - - - - - \ No newline at end of file + + + + + + diff --git a/src/main/scala/me/arcanis/ffxivbis/Application.scala b/src/main/scala/me/arcanis/ffxivbis/Application.scala new file mode 100644 index 0000000..7b651d0 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/Application.scala @@ -0,0 +1,43 @@ +package me.arcanis.ffxivbis + +import akka.actor.{Actor, Props} +import akka.http.scaladsl.Http +import akka.stream.ActorMaterializer +import com.typesafe.scalalogging.StrictLogging +import me.arcanis.ffxivbis.http.RootEndpoint +import me.arcanis.ffxivbis.service.Ariyala +import me.arcanis.ffxivbis.service.impl.DatabaseImpl +import me.arcanis.ffxivbis.storage.Migration + +import scala.concurrent.{Await, ExecutionContext} +import scala.concurrent.duration.Duration +import scala.util.{Failure, Success} + +class Application extends Actor with StrictLogging { + implicit private val executionContext: ExecutionContext = context.system.dispatcher + implicit private val materializer: ActorMaterializer = ActorMaterializer() + + private val config = context.system.settings.config + private val host = config.getString("me.arcanis.ffxivbis.web.host") + private val port = config.getInt("me.arcanis.ffxivbis.web.port") + + override def receive: Receive = Actor.emptyBehavior + + Migration(config).onComplete { + case Success(_) => + val ariyala = context.system.actorOf(Ariyala.props, "ariyala") + val storage = context.system.actorOf(DatabaseImpl.props, "storage") + val http = new RootEndpoint(context.system, storage, ariyala) + + logger.info(s"start server at $host:$port") + val bind = Http()(context.system).bindAndHandle(http.route, host, port) + Await.result(context.system.whenTerminated, Duration.Inf) + bind.foreach(_.unbind()) + + case Failure(exception) => throw exception + } +} + +object Application { + def props: Props = Props(new Application) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/ffxivbis.scala b/src/main/scala/me/arcanis/ffxivbis/ffxivbis.scala new file mode 100644 index 0000000..91f0899 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/ffxivbis.scala @@ -0,0 +1,12 @@ +package me.arcanis.ffxivbis + +import akka.actor.ActorSystem +import com.typesafe.config.ConfigFactory + +object ffxivbis { + def main(args: Array[String]): Unit = { + val config = ConfigFactory.load() + val actorSystem = ActorSystem("ffxivbis", config) + actorSystem.actorOf(Application.props, "ffxivbis") + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/AriyalaHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/AriyalaHelper.scala new file mode 100644 index 0000000..2edc7dd --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/AriyalaHelper.scala @@ -0,0 +1,16 @@ +package me.arcanis.ffxivbis.http + +import akka.actor.ActorRef +import akka.pattern.ask +import akka.util.Timeout +import me.arcanis.ffxivbis.models.{BiS, Job, Piece} +import me.arcanis.ffxivbis.service.Ariyala + +import scala.concurrent.{ExecutionContext, Future} + +class AriyalaHelper(ariyala: ActorRef) { + + def downloadBiS(link: String, job: Job.Job) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[BiS] = + (ariyala ? Ariyala.GetBiS(link, job)).mapTo[Seq[Piece]].map(BiS(_)) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/Authorization.scala b/src/main/scala/me/arcanis/ffxivbis/http/Authorization.scala new file mode 100644 index 0000000..8597d22 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/Authorization.scala @@ -0,0 +1,53 @@ +package me.arcanis.ffxivbis.http + +import akka.actor.ActorRef +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.server.AuthenticationFailedRejection._ +import akka.http.scaladsl.server._ +import akka.http.scaladsl.server.Directives._ +import akka.pattern.ask +import akka.util.Timeout +import me.arcanis.ffxivbis.models.{Permission, User} +import me.arcanis.ffxivbis.service.impl.DatabaseUserHandler + +import scala.concurrent.{ExecutionContext, Future} + +// idea comes from https://synkre.com/bcrypt-for-akka-http-password-encryption/ +trait Authorization { + + def storage: ActorRef + + def authenticateBasicBCrypt[T](realm: String, + authenticate: (String, String) => Future[Option[T]]): Directive1[T] = { + def challenge = HttpChallenges.basic(realm) + + extractCredentials.flatMap { + case Some(BasicHttpCredentials(username, password)) => + onSuccess(authenticate(username, password)).flatMap { + case Some(client) => provide(client) + case None => reject(AuthenticationFailedRejection(CredentialsRejected, challenge)) + } + case _ => reject(AuthenticationFailedRejection(CredentialsMissing, challenge)) + } + } + + def authenticator(scope: Permission.Value)(partyId: String) + (username: String, password: String) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] = + (storage ? DatabaseUserHandler.GetUser(partyId, username)).mapTo[Option[User]].map { + case Some(user) if user.verify(password) && user.verityScope(scope) => Some(username) + case _ => None + } + + def authAdmin(partyId: String)(username: String, password: String) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] = + authenticator(Permission.admin)(partyId)(username, password) + + def authGet(partyId: String)(username: String, password: String) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] = + authenticator(Permission.get)(partyId)(username, password) + + def authPost(partyId: String)(username: String, password: String) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] = + authenticator(Permission.post)(partyId)(username, password) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala new file mode 100644 index 0000000..6030b43 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala @@ -0,0 +1,29 @@ +package me.arcanis.ffxivbis.http + +import akka.actor.ActorRef +import akka.pattern.ask +import akka.util.Timeout +import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId} +import me.arcanis.ffxivbis.service.impl.DatabaseBiSHandler + +import scala.concurrent.{ExecutionContext, Future} + +class BiSHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) { + + def addPieceBiS(playerId: PlayerId, piece: Piece) + (implicit executionContext: ExecutionContext): Future[Unit] = + Future { storage ! DatabaseBiSHandler.AddPieceToBis(playerId, piece) } + + def bis(partyId: String, playerId: Option[PlayerId]) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] = + (storage ? DatabaseBiSHandler.GetBiS(partyId, playerId)).mapTo[Seq[Player]] + + def putBiS(playerId: PlayerId, link: String) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = + downloadBiS(link, playerId.job).map(_.pieces.map(addPieceBiS(playerId, _))) + + def removePieceBiS(playerId: PlayerId, piece: Piece) + (implicit executionContext: ExecutionContext): Future[Unit] = + Future { storage ! DatabaseBiSHandler.RemovePieceFromBiS(playerId, piece) } + +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala new file mode 100644 index 0000000..5440010 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala @@ -0,0 +1,29 @@ +package me.arcanis.ffxivbis.http + +import akka.actor.ActorRef +import akka.pattern.ask +import akka.util.Timeout +import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId, PlayerIdWithCounters} +import me.arcanis.ffxivbis.service.LootSelector.LootSelectorResult +import me.arcanis.ffxivbis.service.impl.DatabaseLootHandler + +import scala.concurrent.{ExecutionContext, Future} + +class LootHelper(storage: ActorRef) { + + def addPieceLoot(playerId: PlayerId, piece: Piece) + (implicit executionContext: ExecutionContext): Future[Unit] = + Future { storage ! DatabaseLootHandler.AddPieceTo(playerId, piece) } + + def loot(partyId: String, playerId: Option[PlayerId]) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] = + (storage ? DatabaseLootHandler.GetLoot(partyId, playerId)).mapTo[Seq[Player]] + + def removePieceLoot(playerId: PlayerId, piece: Piece) + (implicit executionContext: ExecutionContext): Future[Unit] = + Future { storage ! DatabaseLootHandler.RemovePieceFrom(playerId, piece) } + + def suggestPiece(partyId: String, piece: Piece) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[PlayerIdWithCounters]] = + (storage ? DatabaseLootHandler.SuggestLoot(partyId, piece)).mapTo[LootSelectorResult].map(_.result) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala new file mode 100644 index 0000000..2034e1e --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala @@ -0,0 +1,37 @@ +package me.arcanis.ffxivbis.http + +import akka.actor.ActorRef +import akka.pattern.ask +import akka.util.Timeout +import me.arcanis.ffxivbis.models.{Player, PlayerId} +import me.arcanis.ffxivbis.service.Party +import me.arcanis.ffxivbis.service.impl.{DatabaseBiSHandler, DatabasePartyHandler} + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} + +class PlayerHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) { + + def addPlayer(player: Player) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = + Future { storage ! DatabasePartyHandler.AddPlayer(player) }.andThen { + case Success(_) if player.link.isDefined => + downloadBiS(player.link.get, player.job).map { bis => + bis.pieces.map(storage ! DatabaseBiSHandler.AddPieceToBis(player.playerId, _)) + }.map(_ => ()) + case Success(_) => Future.successful(()) + case Failure(exception) => Future.failed(exception) + } + + def getPlayers(partyId: String, maybePlayerId: Option[PlayerId]) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] = + maybePlayerId match { + case Some(playerId) => + (storage ? DatabasePartyHandler.GetPlayer(playerId)).mapTo[Player].map(Seq(_)) + case None => + (storage ? DatabasePartyHandler.GetParty(partyId)).mapTo[Party].map(_.players.values.toSeq) + } + + def removePlayer(playerId: PlayerId)(implicit executionContext: ExecutionContext): Future[Unit] = + Future { storage ! DatabasePartyHandler.RemovePlayer(playerId) } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala new file mode 100644 index 0000000..17049d9 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala @@ -0,0 +1,45 @@ +package me.arcanis.ffxivbis.http + +import akka.actor.{ActorRef, ActorSystem} +import akka.http.scaladsl.model._ +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.util.Timeout +import com.typesafe.scalalogging.StrictLogging +import me.arcanis.ffxivbis.http.api.v1.ApiV1Endpoint + +class RootEndpoint(system: ActorSystem, storage: ActorRef, ariyala: ActorRef) + extends StrictLogging { + import me.arcanis.ffxivbis.utils.Implicits._ + + private val config = system.settings.config + + implicit val timeout: Timeout = + config.getDuration("me.arcanis.ffxivbis.settings.request-timeout") + + private val apiV1Endpoint: ApiV1Endpoint = new ApiV1Endpoint(storage, ariyala) + + def route: Route = apiRoute ~ htmlRoute ~ Swagger.routes ~ swaggerUIRoute + + private def apiRoute: Route = + ignoreTrailingSlash { + pathPrefix("api") { + pathPrefix(Segment) { + case "v1" => apiV1Endpoint.route + case _ => reject + } + } + } + + private def htmlRoute: Route = + ignoreTrailingSlash { + pathEndOrSingleSlash { + complete(StatusCodes.OK) + } + } + + private def swaggerUIRoute: Route = + path("swagger") { + getFromResource("swagger/index.html") + } ~ getFromResourceDirectory("swagger") +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/Swagger.scala b/src/main/scala/me/arcanis/ffxivbis/http/Swagger.scala new file mode 100644 index 0000000..940e634 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/Swagger.scala @@ -0,0 +1,24 @@ +package me.arcanis.ffxivbis.http + +import com.github.swagger.akka.SwaggerHttpService +import com.github.swagger.akka.model.Info +import io.swagger.v3.oas.models.security.SecurityScheme + +object Swagger extends SwaggerHttpService { + override val apiClasses: Set[Class[_]] = Set( + classOf[api.v1.BiSEndpoint], classOf[api.v1.LootEndpoint], + classOf[api.v1.PlayerEndpoint], classOf[api.v1.UserEndpoint] + ) + + override val info: Info = Info() + + private val basicAuth = new SecurityScheme() + .description("basic http auth") + .`type`(SecurityScheme.Type.HTTP) + .in(SecurityScheme.In.HEADER) + .scheme("bearer") + override def securitySchemes: Map[String, SecurityScheme] = Map("basic auth" -> basicAuth) + + override val unwantedDefinitions: Seq[String] = + Seq("Function1", "Function1RequestContextFutureRouteResult") +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala new file mode 100644 index 0000000..2509fc0 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala @@ -0,0 +1,28 @@ +package me.arcanis.ffxivbis.http + +import akka.actor.ActorRef +import akka.pattern.ask +import akka.util.Timeout +import me.arcanis.ffxivbis.models.User +import me.arcanis.ffxivbis.service.impl.DatabaseUserHandler + +import scala.concurrent.{ExecutionContext, Future} + +class UserHelper(storage: ActorRef) { + + def addUser(user: User, isHashedPassword: Boolean) + (implicit executionContext: ExecutionContext): Future[Unit] = + Future { storage ! DatabaseUserHandler.InsertUser(user, isHashedPassword) } + + def user(partyId: String, username: String) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[User]] = + (storage ? DatabaseUserHandler.GetUser(partyId, username)).mapTo[Option[User]] + + def users(partyId: String) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[User]] = + (storage ? DatabaseUserHandler.GetUsers(partyId)).mapTo[Seq[User]] + + def removeUser(partyId: String, username: String) + (implicit executionContext: ExecutionContext): Future[Unit] = + Future { storage ! DatabaseUserHandler.DeleteUser(partyId, username) } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/ApiV1Endpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/ApiV1Endpoint.scala new file mode 100644 index 0000000..ba03c61 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/ApiV1Endpoint.scala @@ -0,0 +1,17 @@ +package me.arcanis.ffxivbis.http.api.v1 + +import akka.actor.ActorRef +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import akka.util.Timeout + +class ApiV1Endpoint(storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) { + + private val biSEndpoint = new BiSEndpoint(storage, ariyala) + private val lootEndpoint = new LootEndpoint(storage) + private val playerEndpoint = new PlayerEndpoint(storage, ariyala) + private val userEndpoint = new UserEndpoint(storage) + + def route: Route = + biSEndpoint.route ~ lootEndpoint.route ~ playerEndpoint.route ~ userEndpoint.route +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala new file mode 100644 index 0000000..a0f4193 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala @@ -0,0 +1,132 @@ +package me.arcanis.ffxivbis.http.api.v1 + +import akka.actor.ActorRef +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.util.Timeout +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema} +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.{Operation, Parameter} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import javax.ws.rs.{Consumes, GET, POST, PUT, Path, Produces} +import me.arcanis.ffxivbis.http.{Authorization, BiSHelper} +import me.arcanis.ffxivbis.http.api.v1.json._ +import me.arcanis.ffxivbis.models.PlayerId + +@Path("api/v1") +class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) + extends BiSHelper(storage, ariyala) with Authorization with JsonSupport { + import spray.json.DefaultJsonProtocol._ + + def route: Route = createBiS ~ getBiS ~ modifyBiS + + @PUT + @Path("party/{partyId}/bis") + @Consumes(value = Array("application/json")) + @Operation(summary = "create best in slot", description = "Create the best in slot set", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + ), + requestBody = new RequestBody(description = "player best in slot description", required = true, + content = Array(new Content(schema = new Schema(implementation = classOf[PlayerBiSLinkResponse])))), + responses = Array( + new ApiResponse(responseCode = "201", description = "Best in slot set has been created"), + new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), + tags = Array("best in slot"), + ) + def createBiS: Route = + path("party" / Segment / "bis") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => + put { + entity(as[PlayerBiSLinkResponse]) { bisLink => + val playerId = bisLink.playerId.withPartyId(partyId) + complete(putBiS(playerId, bisLink.link).map(_ => StatusCodes.Created)) + } + } + } + } + } + + @GET + @Path("party/{partyId}/bis") + @Produces(value = Array("application/json")) + @Operation(summary = "get best in slot", description = "Return the best in slot items", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + new Parameter(name = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"), + new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"), + ), + responses = Array( + new ApiResponse(responseCode = "200", description = "Best in slot", + content = Array(new Content( + array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])) + ))), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), + tags = Array("best in slot"), + ) + def getBiS: Route = + path("party" / Segment / "bis") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + get { + parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) => + val playerId = PlayerId(partyId, maybeNick, maybeJob) + complete(bis(partyId, playerId).map(_.map(PlayerResponse.fromPlayer))) + } + } + } + } + } + + @POST + @Path("party/{partyId}/bis") + @Consumes(value = Array("application/json")) + @Operation(summary = "modify best in slot", description = "Add or remove an item from the best in slot", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + ), + requestBody = new RequestBody(description = "action and piece description", required = true, + content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))), + responses = Array( + new ApiResponse(responseCode = "202", description = "Best in slot set has been modified"), + new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), + tags = Array("best in slot"), + ) + def modifyBiS: Route = + path("party" / Segment / "bis") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => + post { + entity(as[PieceActionResponse]) { action => + val playerId = action.playerIdResponse.withPartyId(partyId) + complete { + val result = action.action match { + case ApiAction.add => addPieceBiS(playerId, action.piece.toPiece) + case ApiAction.remove => removePieceBiS(playerId, action.piece.toPiece) + } + result.map(_ => StatusCodes.Accepted) + } + } + } + } + } + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala new file mode 100644 index 0000000..3ae6871 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala @@ -0,0 +1,139 @@ +package me.arcanis.ffxivbis.http.api.v1 + +import akka.actor.ActorRef +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.util.Timeout +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema} +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.{Operation, Parameter} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import javax.ws.rs.{Consumes, GET, POST, PUT, Path, Produces} +import me.arcanis.ffxivbis.http.{Authorization, LootHelper} +import me.arcanis.ffxivbis.http.api.v1.json._ +import me.arcanis.ffxivbis.models.PlayerId + +@Path("api/v1") +class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) + extends LootHelper(storage) with Authorization with JsonSupport { + import spray.json.DefaultJsonProtocol._ + + def route: Route = getLoot ~ modifyLoot + + @GET + @Path("party/{partyId}/loot") + @Produces(value = Array("application/json")) + @Operation(summary = "get loot list", description = "Return the looted items", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + new Parameter(name = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"), + new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"), + ), + responses = Array( + new ApiResponse(responseCode = "200", description = "Loot list", + content = Array(new Content( + array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])) + ))), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), + tags = Array("loot"), + ) + def getLoot: Route = + path("party" / Segment / "loot") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + get { + parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) => + val playerId = PlayerId(partyId, maybeNick, maybeJob) + complete(loot(partyId, playerId).map(_.map(PlayerResponse.fromPlayer))) + } + } + } + } + } + + @POST + @Consumes(value = Array("application/json")) + @Path("party/{partyId}/loot") + @Operation(summary = "modify loot list", description = "Add or remove an item from the loot list", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + ), + requestBody = new RequestBody(description = "action and piece description", required = true, + content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))), + responses = Array( + new ApiResponse(responseCode = "202", description = "Loot list has been modified"), + new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), + tags = Array("loot"), + ) + def modifyLoot: Route = + path("party" / Segment / "loot") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => + post { + entity(as[PieceActionResponse]) { action => + val playerId = action.playerIdResponse.withPartyId(partyId) + complete { + val result = action.action match { + case ApiAction.add => addPieceLoot(playerId, action.piece.toPiece) + case ApiAction.remove => removePieceLoot(playerId, action.piece.toPiece) + } + result.map(_ => StatusCodes.Accepted) + } + } + } + } + } + } + + @PUT + @Path("party/{partyId}/loot") + @Consumes(value = Array("application/json")) + @Produces(value = Array("application/json")) + @Operation(summary = "suggest loot", description = "Suggest loot piece to party", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + ), + requestBody = new RequestBody(description = "piece description", required = true, + content = Array(new Content(schema = new Schema(implementation = classOf[PieceResponse])))), + responses = Array( + new ApiResponse(responseCode = "200", description = "Players with counters ordered by priority to get this item", + content = Array(new Content( + array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerIdWithCountersResponse])), + ))), + new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), + tags = Array("loot"), + ) + def suggestLoot: Route = + path("party" / Segment / "loot") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + put { + entity(as[PieceResponse]) { piece => + complete { + suggestPiece(partyId, piece.toPiece).map { players => + players.map(PlayerIdWithCountersResponse.fromPlayerId) + } + } + } + } + } + } + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala new file mode 100644 index 0000000..693a7ad --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala @@ -0,0 +1,97 @@ +package me.arcanis.ffxivbis.http.api.v1 + +import akka.actor.ActorRef +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.util.Timeout +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema} +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.{Operation, Parameter} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import javax.ws.rs.{Consumes, GET, POST, Path, Produces} +import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper} +import me.arcanis.ffxivbis.http.api.v1.json._ +import me.arcanis.ffxivbis.models.PlayerId + +@Path("api/v1") +class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) + extends PlayerHelper(storage, ariyala) with Authorization with JsonSupport { + import spray.json.DefaultJsonProtocol._ + + def route: Route = getParty ~ modifyParty + + @GET + @Path("party/{partyId}") + @Produces(value = Array("application/json")) + @Operation(summary = "get party", description = "Return the players who belong to the party", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + new Parameter(name = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"), + new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"), + ), + responses = Array( + new ApiResponse(responseCode = "200", description = "Players list", + content = Array(new Content( + array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])), + ))), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))), + tags = Array("party"), + ) + def getParty: Route = + path("party" / Segment) { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + get { + parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) => + val playerId = PlayerId(partyId, maybeNick, maybeJob) + complete(getPlayers(partyId, playerId).map(_.map(PlayerResponse.fromPlayer))) + } + } + } + } + } + + @POST + @Path("party/{partyId}") + @Consumes(value = Array("application/json")) + @Operation(summary = "modify party", description = "Add or remove a player from party list", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + ), + requestBody = new RequestBody(description = "player description", required = true, + content = Array(new Content(schema = new Schema(implementation = classOf[PlayerActionResponse])))), + responses = Array( + new ApiResponse(responseCode = "202", description = "Party has been modified"), + new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))), + tags = Array("party"), + ) + def modifyParty: Route = + path("party" / Segment) { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ => + entity(as[PlayerActionResponse]) { action => + val player = action.playerIdResponse.toPlayer.copy(partyId = partyId) + complete { + val result = action.action match { + case ApiAction.add => addPlayer(player) + case ApiAction.remove => removePlayer(player.playerId) + } + result.map(_ => StatusCodes.Accepted) + } + } + } + } + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala new file mode 100644 index 0000000..a1d65dc --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala @@ -0,0 +1,152 @@ +package me.arcanis.ffxivbis.http.api.v1 + +import akka.actor.ActorRef +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.util.Timeout +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema} +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.{Operation, Parameter} +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import javax.ws.rs.{Consumes, DELETE, GET, POST, PUT, Path, Produces} +import me.arcanis.ffxivbis.http.{Authorization, UserHelper} +import me.arcanis.ffxivbis.http.api.v1.json._ +import me.arcanis.ffxivbis.models.Permission + +@Path("api/v1") +class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) + extends UserHelper(storage) with Authorization with JsonSupport { + import spray.json.DefaultJsonProtocol._ + + def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers + + @PUT + @Path("party/{partyId}") + @Consumes(value = Array("application/json")) + @Operation(summary = "create new party", description = "Create new party with specified ID", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + ), + requestBody = new RequestBody(description = "party administrator description", required = true, + content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))), + responses = Array( + new ApiResponse(responseCode = "201", description = "Party has been created"), + new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), + new ApiResponse(responseCode = "406", description = "Party with the specified ID already exists"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + tags = Array("party"), + ) + def createParty: Route = + path("party" / Segment) { partyId: String => + extractExecutionContext { implicit executionContext => + put { + entity(as[UserResponse]) { user => + val admin = user.toUser.copy(partyId = partyId, permission = Permission.admin) + complete { + addUser(admin, isHashedPassword = false).map(_ => StatusCodes.Created) + } + } + } + } + } + + @POST + @Path("party/{partyId}/users") + @Consumes(value = Array("application/json")) + @Operation(summary = "create new user", description = "Add an user to the specified party", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + ), + requestBody = new RequestBody(description = "user description", required = true, + content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))), + responses = Array( + new ApiResponse(responseCode = "201", description = "User has been created"), + new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))), + tags = Array("users"), + ) + def createUser: Route = + path("party" / Segment / "users") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => + post { + entity(as[UserResponse]) { user => + val withPartyId = user.toUser.copy(partyId = partyId) + complete { + addUser(withPartyId, isHashedPassword = false).map(_ => StatusCodes.Created) + } + } + } + } + } + } + + @DELETE + @Path("party/{partyId}/users/{username}") + @Operation(summary = "remove user", description = "Remove an user from the specified party", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + new Parameter(name = "username", in = ParameterIn.PATH, description = "username to remove", example = "siuan"), + ), + responses = Array( + new ApiResponse(responseCode = "202", description = "User has been removed"), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))), + tags = Array("users"), + ) + def deleteUser: Route = + path("party" / Segment / "users" / Segment) { (partyId: String, username: String) => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => + delete { + complete { + removeUser(partyId, username).map(_ => StatusCodes.Accepted) + } + } + } + } + } + + @GET + @Path("party/{partyId}/users") + @Produces(value = Array("application/json")) + @Operation(summary = "get users", description = "Return the list of users belong to party", + parameters = Array( + new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"), + ), + responses = Array( + new ApiResponse(responseCode = "200", description = "Users list", + content = Array(new Content( + array = new ArraySchema(schema = new Schema(implementation = classOf[UserResponse])), + ))), + new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"), + new ApiResponse(responseCode = "403", description = "Access is forbidden"), + new ApiResponse(responseCode = "500", description = "Internal server error"), + ), + security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))), + tags = Array("users"), + ) + def getUsers: Route = + path("party" / Segment / "users") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => + get { + complete { + users(partyId).map(_.map(UserResponse.fromUser)) + } + } + } + } + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/ApiAction.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/ApiAction.scala new file mode 100644 index 0000000..9e20b4d --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/ApiAction.scala @@ -0,0 +1,5 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +object ApiAction extends Enumeration { + val add, remove = Value +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala new file mode 100644 index 0000000..5797168 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala @@ -0,0 +1,31 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import me.arcanis.ffxivbis.models.Permission +import spray.json._ + +trait JsonSupport extends SprayJsonSupport { + import DefaultJsonProtocol._ + + private def enumFormat[E <: Enumeration](enum: E): RootJsonFormat[E#Value] = + new RootJsonFormat[E#Value] { + override def write(obj: E#Value): JsValue = obj.toString.toJson + override def read(json: JsValue): E#Value = json match { + case JsString(name) => enum.withName(name) + case other => deserializationError(s"String or number expected, got $other") + } + } + + implicit val actionFormat: RootJsonFormat[ApiAction.Value] = enumFormat(ApiAction) + implicit val permissionFormat: RootJsonFormat[Permission.Value] = enumFormat(Permission) + + implicit val pieceFormat: RootJsonFormat[PieceResponse] = jsonFormat3(PieceResponse.apply) + implicit val pieceActionFormat: RootJsonFormat[PieceActionResponse] = jsonFormat3(PieceActionResponse.apply) + implicit val playerFormat: RootJsonFormat[PlayerResponse] = jsonFormat7(PlayerResponse.apply) + implicit val playerActionFormat: RootJsonFormat[PlayerActionResponse] = jsonFormat2(PlayerActionResponse.apply) + implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkResponse] = jsonFormat2(PlayerBiSLinkResponse.apply) + implicit val playerIdFormat: RootJsonFormat[PlayerIdResponse] = jsonFormat3(PlayerIdResponse.apply) + implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersResponse] = + jsonFormat9(PlayerIdWithCountersResponse.apply) + implicit val userFormat: RootJsonFormat[UserResponse] = jsonFormat4(UserResponse.apply) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PieceActionResponse.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PieceActionResponse.scala new file mode 100644 index 0000000..bd9f7af --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PieceActionResponse.scala @@ -0,0 +1,8 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import io.swagger.v3.oas.annotations.media.Schema + +case class PieceActionResponse( + @Schema(description = "action to perform", required = true, `type` = "string", allowableValues = Array("add", "remove")) action: ApiAction.Value, + @Schema(description = "piece description", required = true) piece: PieceResponse, + @Schema(description = "player description", required = true) playerIdResponse: PlayerIdResponse) diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PieceResponse.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PieceResponse.scala new file mode 100644 index 0000000..10d03fe --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PieceResponse.scala @@ -0,0 +1,16 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import io.swagger.v3.oas.annotations.media.Schema +import me.arcanis.ffxivbis.models.{Job, Piece} + +case class PieceResponse( + @Schema(description = "is piece tome gear", required = true) isTome: Boolean, + @Schema(description = "job name to which piece belong or AnyJob", required = true, example = "DNC") job: String, + @Schema(description = "piece name", required = true, example = "body") piece: String) { + def toPiece: Piece = Piece(piece, isTome, Job.fromString(job)) +} + +object PieceResponse { + def fromPiece(piece: Piece): PieceResponse = + PieceResponse(piece.isTome, piece.job.toString, piece.piece) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerActionResponse.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerActionResponse.scala new file mode 100644 index 0000000..3bb583e --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerActionResponse.scala @@ -0,0 +1,7 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import io.swagger.v3.oas.annotations.media.Schema + +case class PlayerActionResponse( + @Schema(description = "action to perform", required = true, `type` = "string", allowableValues = Array("add", "remove"), example = "add") action: ApiAction.Value, + @Schema(description = "player description", required = true) playerIdResponse: PlayerResponse) diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerBiSLinkResponse.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerBiSLinkResponse.scala new file mode 100644 index 0000000..f35f7c7 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerBiSLinkResponse.scala @@ -0,0 +1,7 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import io.swagger.v3.oas.annotations.media.Schema + +case class PlayerBiSLinkResponse( + @Schema(description = "link to player best in slot", required = true, example = "https://ffxiv.ariyala.com/19V5R") link: String, + @Schema(description = "player description", required = true) playerId: PlayerIdResponse) diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerIdResponse.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerIdResponse.scala new file mode 100644 index 0000000..723cb28 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerIdResponse.scala @@ -0,0 +1,12 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import io.swagger.v3.oas.annotations.media.Schema +import me.arcanis.ffxivbis.models.{Job, PlayerId} + +case class PlayerIdResponse( + @Schema(description = "unique party ID. Required in responses", example = "abcdefgh") partyId: Option[String], + @Schema(description = "job name", required = true, example = "DNC") job: String, + @Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String) { + def withPartyId(partyId: String): PlayerId = + PlayerId(partyId, Job.fromString(job), nick) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerIdWithCountersResponse.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerIdWithCountersResponse.scala new file mode 100644 index 0000000..be0a2d3 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerIdWithCountersResponse.scala @@ -0,0 +1,29 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import io.swagger.v3.oas.annotations.media.Schema +import me.arcanis.ffxivbis.models.PlayerIdWithCounters + +case class PlayerIdWithCountersResponse( + @Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String, + @Schema(description = "job name", required = true, example = "DNC") job: String, + @Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String, + @Schema(description = "is piece required by player or not", required = true) isRequired: Boolean, + @Schema(description = "player loot priority", required = true) priority: Int, + @Schema(description = "count of savage pieces in best in slot", required = true) bisCountTotal: Int, + @Schema(description = "count of looted pieces", required = true) lootCount: Int, + @Schema(description = "count of looted pieces which are parts of best in slot", required = true) lootCountBiS: Int, + @Schema(description = "total count of looted pieces", required = true) lootCountTotal: Int) + +object PlayerIdWithCountersResponse { + def fromPlayerId(playerIdWithCounters: PlayerIdWithCounters): PlayerIdWithCountersResponse = + PlayerIdWithCountersResponse( + playerIdWithCounters.partyId, + playerIdWithCounters.job.toString, + playerIdWithCounters.nick, + playerIdWithCounters.isRequired, + playerIdWithCounters.priority, + playerIdWithCounters.bisCountTotal, + playerIdWithCounters.lootCount, + playerIdWithCounters.lootCountBiS, + playerIdWithCounters.lootCountTotal) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerResponse.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerResponse.scala new file mode 100644 index 0000000..2d3943f --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/PlayerResponse.scala @@ -0,0 +1,25 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import io.swagger.v3.oas.annotations.media.Schema +import me.arcanis.ffxivbis.models.{BiS, Job, Player} + +case class PlayerResponse( + @Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String, + @Schema(description = "job name", required = true, example = "DNC") job: String, + @Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String, + @Schema(description = "pieces in best in slot") bis: Option[Seq[PieceResponse]], + @Schema(description = "looted pieces") loot: Option[Seq[PieceResponse]], + @Schema(description = "link to best in slot", example = "https://ffxiv.ariyala.com/19V5R") link: Option[String], + @Schema(description = "player loot priority") priority: Option[Int]) { + def toPlayer: Player = + Player(partyId, Job.fromString(job), nick, + BiS(bis.getOrElse(Seq.empty).map(_.toPiece)), loot.getOrElse(Seq.empty).map(_.toPiece), + link, priority.getOrElse(0)) +} + +object PlayerResponse { + def fromPlayer(player: Player): PlayerResponse = + PlayerResponse(player.partyId, player.job.toString, player.nick, + Some(player.bis.pieces.map(PieceResponse.fromPiece)), Some(player.loot.map(PieceResponse.fromPiece)), + player.link, Some(player.priority)) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/UserResponse.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/UserResponse.scala new file mode 100644 index 0000000..1ad5724 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/UserResponse.scala @@ -0,0 +1,18 @@ +package me.arcanis.ffxivbis.http.api.v1.json + +import io.swagger.v3.oas.annotations.media.Schema +import me.arcanis.ffxivbis.models.{Permission, User} + +case class UserResponse( + @Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String, + @Schema(description = "username to login to party", required = true, example = "siuan") username: String, + @Schema(description = "password to login to party", required = true, example = "pa55w0rd") password: String, + @Schema(description = "user permission", defaultValue = "get", allowableValues = Array("get", "post", "admin")) permission: Option[Permission.Value] = None) { + def toUser: User = + User(partyId, username, password, permission.getOrElse(Permission.get)) +} + +object UserResponse { + def fromUser(user: User): UserResponse = + UserResponse(user.partyId, user.username, "", Some(user.permission)) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/models/BiS.scala b/src/main/scala/me/arcanis/ffxivbis/models/BiS.scala new file mode 100644 index 0000000..7f114b8 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/models/BiS.scala @@ -0,0 +1,72 @@ +package me.arcanis.ffxivbis.models + +case class BiS(weapon: Option[Piece], + head: Option[Piece], + body: Option[Piece], + hands: Option[Piece], + waist: Option[Piece], + legs: Option[Piece], + feet: Option[Piece], + ears: Option[Piece], + neck: Option[Piece], + wrist: Option[Piece], + leftRing: Option[Piece], + rightRing: Option[Piece]) { + + val pieces: Seq[Piece] = + Seq(weapon, head, body, hands, waist, legs, feet, ears, neck, wrist, leftRing, rightRing).flatten + + def hasPiece(piece: Piece): Boolean = piece match { + case upgrade: PieceUpgrade => upgrades.contains(upgrade) + case _ => pieces.contains(piece) + } + + def upgrades: Map[PieceUpgrade, Int] = + pieces.groupBy(_.upgrade).foldLeft(Map.empty[PieceUpgrade, Int]) { + case (acc, (Some(k), v)) => acc + (k -> v.length) + case (acc, _) => acc + } withDefaultValue 0 + + def withPiece(piece: Piece): BiS = copyWithPiece(piece.piece, Some(piece)) + def withoutPiece(piece: Piece): BiS = copyWithPiece(piece.piece, None) + + private def copyWithPiece(name: String, piece: Option[Piece]): BiS = { + val params = Map( + "weapon" -> weapon, + "head" -> head, + "body" -> body, + "hands" -> hands, + "waist" -> waist, + "legs" -> legs, + "feet" -> feet, + "ears" -> ears, + "neck" -> neck, + "wrist" -> wrist, + "leftRing" -> leftRing, + "rightRing" -> rightRing + ) + (name -> piece) + BiS(params) + } +} + +object BiS { + def apply(data: Map[String, Option[Piece]]): BiS = + BiS( + data.get("weapon").flatten, + data.get("head").flatten, + data.get("body").flatten, + data.get("hands").flatten, + data.get("waist").flatten, + data.get("legs").flatten, + data.get("feet").flatten, + data.get("ears").flatten, + data.get("neck").flatten, + data.get("wrist").flatten, + data.get("leftRing").flatten, + data.get("rightRing").flatten) + + def apply(): BiS = BiS(Seq.empty) + + def apply(pieces: Seq[Piece]): BiS = + BiS(pieces.map { piece => piece.piece -> Some(piece) }.toMap) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/models/Job.scala b/src/main/scala/me/arcanis/ffxivbis/models/Job.scala new file mode 100644 index 0000000..c53f896 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/models/Job.scala @@ -0,0 +1,65 @@ +package me.arcanis.ffxivbis.models + +object Job { + sealed trait Job + + case object AnyJob extends Job { + override def equals(obj: Any): Boolean = obj match { + case Job => true + case _ => false + } + } + + case object PLD extends Job + case object WAR extends Job + case object DRK extends Job + case object GNB extends Job + + case object WHM extends Job + case object SCH extends Job + case object AST extends Job + + case object MNK extends Job + case object DRG extends Job + case object NIN extends Job + case object SAM extends Job + + case object BRD extends Job + case object MCH extends Job + case object DNC extends Job + + case object BLM extends Job + case object SMN extends Job + case object RDM extends Job + + def groupAccessoriesDex: Seq[Job.Job] = groupRanges :+ NIN + def groupAccessoriesStr: Seq[Job.Job] = groupMnk :+ DRG + def groupAll: Seq[Job.Job] = groupCasters ++ groupHealers ++ groupRanges ++ groupTanks + def groupCasters: Seq[Job.Job] = Seq(BLM, SMN, RDM) + def groupHealers: Seq[Job.Job] = Seq(WHM, SCH, AST) + def groupMnk: Seq[Job.Job] = Seq(MNK, SAM) + def groupRanges: Seq[Job.Job] = Seq(BRD, MCH, DNC) + def groupTanks: Seq[Job.Job] = Seq(PLD, WAR, DRK, GNB) + + def groupFull: Seq[Seq[Job.Job]] = Seq(groupCasters, groupHealers, groupMnk, groupRanges, groupTanks) + def groupRight: Seq[Seq[Job.Job]] = Seq(groupAccessoriesDex, groupAccessoriesStr) + + def fromString(job: String): Job.Job = groupAll.find(_.toString == job.toUpperCase).orNull + + def hasSameLoot(left: Job, right: Job, piece: Piece): Boolean = { + def isAccessory(piece: Piece): Boolean = piece match { + case _: PieceAccessory => true + case _ => false + } + def isWeapon(piece: Piece): Boolean = piece match { + case _: PieceWeapon => true + case _ => false + } + + if (left == right) true + else if (isWeapon(piece)) false + else if (groupFull.exists(group => group.contains(left) && group.contains(right))) true + else if (isAccessory(piece) && groupRight.exists(group => group.contains(left) && group.contains(right))) true + else false + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/models/Loot.scala b/src/main/scala/me/arcanis/ffxivbis/models/Loot.scala new file mode 100644 index 0000000..1c188eb --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/models/Loot.scala @@ -0,0 +1,3 @@ +package me.arcanis.ffxivbis.models + +case class Loot(playerId: Long, piece: Piece) diff --git a/src/main/scala/me/arcanis/ffxivbis/models/Piece.scala b/src/main/scala/me/arcanis/ffxivbis/models/Piece.scala new file mode 100644 index 0000000..e499058 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/models/Piece.scala @@ -0,0 +1,97 @@ +package me.arcanis.ffxivbis.models + +trait Piece { + def isTome: Boolean + def job: Job.Job + def piece: String + + def upgrade: Option[PieceUpgrade] = this match { + case _ if !isTome => None + case _: Waist => Some(AccessoryUpgrade) + case _: PieceAccessory => Some(AccessoryUpgrade) + case _: PieceBody => Some(BodyUpgrade) + case _: PieceWeapon => Some(WeaponUpgrade) + case _ => None + } +} + +trait PieceAccessory extends Piece +trait PieceBody extends Piece +trait PieceUpgrade extends Piece { + val isTome: Boolean = true + val job: Job.Job = Job.AnyJob +} +trait PieceWeapon extends Piece + +case class Weapon(override val isTome: Boolean, override val job: Job.Job) extends PieceWeapon { + val piece: String = "weapon" +} + +case class Head(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { + val piece: String = "head" +} +case class Body(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { + val piece: String = "body" +} +case class Hands(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { + val piece: String = "hands" +} +case class Waist(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { + val piece: String = "waist" +} +case class Legs(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { + val piece: String = "legs" +} +case class Feet(override val isTome: Boolean, override val job: Job.Job) extends PieceBody { + val piece: String = "feet" +} + +case class Ears(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory { + val piece: String = "ears" +} +case class Neck(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory { + val piece: String = "neck" +} +case class Wrist(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory { + val piece: String = "wrist" +} +case class Ring(override val isTome: Boolean, override val job: Job.Job, override val piece: String = "ring") + extends PieceAccessory { + override def equals(obj: Any): Boolean = obj match { + case Ring(thatIsTome, thatJob, _) => (thatIsTome == isTome) && (thatJob == job) + case _ => false + } +} + +case object AccessoryUpgrade extends PieceUpgrade { + val piece: String = "accessory upgrade" +} +case object BodyUpgrade extends PieceUpgrade { + val piece: String = "body upgrade" +} +case object WeaponUpgrade extends PieceUpgrade { + val piece: String = "weapon upgrade" +} + +object Piece { + def apply(piece: String, isTome: Boolean, job: Job.Job = Job.AnyJob): Piece = + piece.toLowerCase match { + case "weapon" => Weapon(isTome, job) + case "head" => Head(isTome, job) + case "body" => Body(isTome, job) + case "hands" => Hands(isTome, job) + case "waist" => Waist(isTome, job) + case "legs" => Legs(isTome, job) + case "feet" => Feet(isTome, job) + case "ears" => Ears(isTome, job) + case "neck" => Neck(isTome, job) + case "wrist" => Wrist(isTome, job) + case "ring" => Ring(isTome, job) + case "leftring" => Ring(isTome, job).copy(piece = "leftRing") + case "rightring" => Ring(isTome, job).copy(piece = "rightRing") + case "accessory upgrade" => AccessoryUpgrade + case "body upgrade" => BodyUpgrade + case "weapon upgrade" => WeaponUpgrade + case other => throw new Error(s"Unknown item type $other") + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/models/Player.scala b/src/main/scala/me/arcanis/ffxivbis/models/Player.scala new file mode 100644 index 0000000..8cde805 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/models/Player.scala @@ -0,0 +1,44 @@ +package me.arcanis.ffxivbis.models + +case class Player(partyId: String, + job: Job.Job, + nick: String, + bis: BiS, + loot: Seq[Piece], + link: Option[String] = None, + priority: Int = 0) { + require(job != Job.AnyJob, "AnyJob is not allowed") + + val playerId: PlayerId = PlayerId(partyId, job, nick) + def withBiS(set: Option[BiS]): Player = set match { + case Some(value) => copy(bis = value) + case None => this + } + def withCounters(piece: Option[Piece]): PlayerIdWithCounters = + PlayerIdWithCounters( + partyId, job, nick, isRequired(piece), priority, + bisCountTotal(piece), lootCount(piece), + lootCountBiS(piece), lootCountTotal(piece)) + def withLoot(list: Option[Seq[Piece]]): Player = list match { + case Some(value) => copy(loot = value) + case None => this + } + + def isRequired(piece: Option[Piece]): Boolean = { + piece match { + case None => false + case Some(p) if !bis.hasPiece(p) => false + case Some(p: PieceUpgrade) => bis.upgrades(p) > lootCount(piece) + case Some(_) => lootCount(piece) == 0 + } + } + + def bisCountTotal(piece: Option[Piece]): Int = bis.pieces.count(!_.isTome) + def lootCount(piece: Option[Piece]): Int = piece match { + case Some(p) => loot.count(_ == p) + case None => lootCountTotal(piece) + } + def lootCountBiS(piece: Option[Piece]): Int = loot.count(bis.hasPiece) + def lootCountTotal(piece: Option[Piece]): Int = loot.length + def lootPriority(piece: Piece): Int = priority +} diff --git a/src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala b/src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala new file mode 100644 index 0000000..7155722 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala @@ -0,0 +1,18 @@ +package me.arcanis.ffxivbis.models + +trait PlayerIdBase { + def job: Job.Job + def nick: String + + override def toString: String = s"$nick ($job)" +} + +case class PlayerId(partyId: String, job: Job.Job, nick: String) extends PlayerIdBase + +object PlayerId { + def apply(partyId: String, maybeNick: Option[String], maybeJob: Option[String]): Option[PlayerId] = + (maybeNick, maybeJob) match { + case (Some(nick), Some(job)) => Some(PlayerId(partyId, Job.fromString(job), nick)) + case _ => None + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/models/PlayerIdWithCounters.scala b/src/main/scala/me/arcanis/ffxivbis/models/PlayerIdWithCounters.scala new file mode 100644 index 0000000..0761f60 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/models/PlayerIdWithCounters.scala @@ -0,0 +1,45 @@ +package me.arcanis.ffxivbis.models + +case class PlayerIdWithCounters(partyId: String, + job: Job.Job, + nick: String, + isRequired: Boolean, + priority: Int, + bisCountTotal: Int, + lootCount: Int, + lootCountBiS: Int, + lootCountTotal: Int) + extends PlayerIdBase { + import PlayerIdWithCounters._ + + def playerId: PlayerId = PlayerId(partyId, job, nick) + + def gt(that: PlayerIdWithCounters, orderBy: Seq[String]): Boolean = + withCounters(orderBy) > that.withCounters(orderBy) + + private val counters: Map[String, Int] = Map( + "isRequired" -> (if (isRequired) 1 else 0), + "priority" -> priority, + "bisCountTotal" -> bisCountTotal, + "lootCount" -> lootCount, + "lootCountBiS" -> lootCountBiS, + "lootCountTotal" -> lootCountTotal) withDefaultValue 0 + + private def withCounters(orderBy: Seq[String]): PlayerCountersComparator = + PlayerCountersComparator(orderBy.map(counters): _*) +} + +object PlayerIdWithCounters { + private case class PlayerCountersComparator(values: Int*) { + def >(that: PlayerCountersComparator): Boolean = { + @scala.annotation.tailrec + def compareLists(left: List[Int], right: List[Int]): Boolean = + (left, right) match { + case (hl :: tl, hr :: tr) => if (hl == hr) compareLists(tl, tr) else hl > hr + case (_ :: _, Nil) => true + case (_, _) => false + } + compareLists(values.toList, that.values.toList) + } + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/models/User.scala b/src/main/scala/me/arcanis/ffxivbis/models/User.scala new file mode 100644 index 0000000..5b1e979 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/models/User.scala @@ -0,0 +1,17 @@ +package me.arcanis.ffxivbis.models + +import org.mindrot.jbcrypt.BCrypt + +object Permission extends Enumeration { + val get, post, admin = Value +} + +case class User(partyId: String, + username: String, + password: String, + permission: Permission.Value) { + + def hash: String = BCrypt.hashpw(password, BCrypt.gensalt) + def verify(plain: String): Boolean = BCrypt.checkpw(plain, password) + def verityScope(scope: Permission.Value): Boolean = permission >= scope +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala b/src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala new file mode 100644 index 0000000..2a3d239 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala @@ -0,0 +1,117 @@ +package me.arcanis.ffxivbis.service + +import java.nio.file.Paths + +import akka.actor.{Actor, Props} +import akka.http.scaladsl.model._ +import akka.http.scaladsl.Http +import akka.pattern.pipe +import akka.stream.ActorMaterializer +import akka.stream.scaladsl.{Keep, Sink} +import akka.util.ByteString +import com.typesafe.scalalogging.StrictLogging +import me.arcanis.ffxivbis.models.{Job, Piece} +import spray.json._ + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +class Ariyala extends Actor with StrictLogging { + import Ariyala._ + + private val settings = context.system.settings.config + private val ariyalaUrl = settings.getString("me.arcanis.ffxivbis.ariyala.ariyala-url") + private val xivapiUrl = settings.getString("me.arcanis.ffxivbis.ariyala.xivapi-url") + private val xivapiKey = Try(settings.getString("me.arcanis.ffxivbis.ariyala.xivapi-key")).toOption + + private val http = Http()(context.system) + private implicit val materializer: ActorMaterializer = ActorMaterializer() + private implicit val executionContext: ExecutionContext = context.dispatcher + + override def receive: Receive = { + case GetBiS(link, job) => + val client = sender() + get(link, job).pipeTo(client) + } + + private def get(link: String, job: Job.Job): Future[Seq[Piece]] = { + val id = Paths.get(link).normalize.getFileName.toString + val uri = Uri(ariyalaUrl) + .withPath(Uri.Path / "store.app") + .withQuery(Uri.Query(Map("identifier" -> id))) + + sendRequest(uri, Ariyala.parseAriyalaJsonToPieces(job, getIsTome)) + } + + private def getIsTome(itemId: Long): Future[Boolean] = { + val uri = Try(Uri(xivapiUrl) + .withPath(Uri.Path / "item" / itemId.toString) + .withQuery(Uri.Query(Map("columns" -> "IsEquippable", "private_key" -> xivapiKey.getOrElse(""))))) + + sendRequest(uri.toOption.get, Ariyala.parseXivapiJson) + } + + private def sendRequest[T](uri: Uri, parser: JsObject => Future[T]): Future[T] = + http.singleRequest(HttpRequest(uri = uri)).map { + case HttpResponse(status, _, entity, _) if status.isSuccess() => + entity.dataBytes + .fold(ByteString.empty)(_ ++ _) + .map(_.utf8String) + .map(result => parser(result.parseJson.asJsObject)) + .toMat(Sink.head)(Keep.right) + .run().flatten + case _ => Future.failed(deserializationError("Invalid response from server")) + }.flatten +} + +object Ariyala { + def props: Props = Props(new Ariyala) + + case class GetBiS(link: String, job: Job.Job) + + private def parseAriyalaJson(job: Job.Job)(js: JsObject): Future[Map[String, Long]] = { + try { + val apiJob = js.fields.get("content") match { + case Some(JsString(value)) => value + case other => throw deserializationError(s"Invalid job name $other") + } + Future.successful(js.fields.get("datasets") match { + case Some(datasets: JsObject) => + val fields = datasets.fields + fields.getOrElse(apiJob, fields(job.toString)).asJsObject + .fields("normal").asJsObject + .fields("items").asJsObject + .fields.foldLeft(Map.empty[String, Long]) { + case (acc, (key, JsNumber(id))) => remapKey(key).map(k => acc + (k -> id.toLong)).getOrElse(acc) + case (acc, _) => acc + } + case other => throw deserializationError(s"Invalid json $other") + }) + } catch { + case e: Exception => Future.failed(e) + } + } + + private def parseAriyalaJsonToPieces(job: Job.Job, isTome: Long => Future[Boolean])(js: JsObject) + (implicit executionContext: ExecutionContext): Future[Seq[Piece]] = + parseAriyalaJson(job)(js).map { pieces => + Future.sequence(pieces.toSeq.map { + case (itemName, itemId) => isTome(itemId).map(Piece(itemName, _, job)) + }) + }.flatten + + private def parseXivapiJson(js: JsObject): Future[Boolean] = + js.fields.get("IsEquippable") match { + case Some(JsNumber(value)) => Future.successful(value == 0) // don't ask + case other => Future.failed(deserializationError(s"Could not parse $other")) + } + + private def remapKey(key: String): Option[String] = key match { + case "mainhand" => Some("weapon") + case "chest" => Some("body") + case "ringLeft" => Some("leftRing") + case "ringRight" => Some("rightRing") + case "head" | "hands" | "waist" | "legs" | "feet" | "ears" | "neck" | "wrist" => Some(key) + case _ => None + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/Database.scala b/src/main/scala/me/arcanis/ffxivbis/service/Database.scala new file mode 100644 index 0000000..2f02be7 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/Database.scala @@ -0,0 +1,26 @@ +package me.arcanis.ffxivbis.service + +import akka.actor.Actor +import com.typesafe.scalalogging.StrictLogging +import me.arcanis.ffxivbis.models.{Player, PlayerId} +import me.arcanis.ffxivbis.storage.DatabaseProfile + +import scala.concurrent.{ExecutionContext, Future} + +trait Database extends Actor with StrictLogging { + implicit def executionContext: ExecutionContext + def profile: DatabaseProfile + + def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] = + (party, maybePlayerId) match { + case (_, Some(playerId)) => party.player(playerId).map(Seq(_)).getOrElse(Seq.empty) + case (_, _) => party.getPlayers + } + + def getParty(partyId: String, withBiS: Boolean, withLoot: Boolean): Future[Party] = + for { + players <- profile.getParty(partyId) + bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future(Seq.empty) + loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty) + } yield Party(partyId, context.system.settings.config, players, bis, loot) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/LootSelector.scala b/src/main/scala/me/arcanis/ffxivbis/service/LootSelector.scala new file mode 100644 index 0000000..de4bff3 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/LootSelector.scala @@ -0,0 +1,20 @@ +package me.arcanis.ffxivbis.service + +import me.arcanis.ffxivbis.models.{Piece, Player, PlayerIdWithCounters} + +class LootSelector(players: Seq[Player], piece: Piece, orderBy: Seq[String]) { + + val counters: Seq[PlayerIdWithCounters] = players.map(_.withCounters(Some(piece))) + + def suggest: LootSelector.LootSelectorResult = + LootSelector.LootSelectorResult { + counters.sortWith { case (left, right) => left.gt(right, orderBy) } + } +} + +object LootSelector { + def apply(players: Seq[Player], piece: Piece, orderBy: Seq[String]): LootSelectorResult = + new LootSelector(players, piece, orderBy).suggest + + case class LootSelectorResult(result: Seq[PlayerIdWithCounters]) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/Party.scala b/src/main/scala/me/arcanis/ffxivbis/service/Party.scala new file mode 100644 index 0000000..7463ed1 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/Party.scala @@ -0,0 +1,48 @@ +package me.arcanis.ffxivbis.service + +import com.typesafe.config.Config +import com.typesafe.scalalogging.StrictLogging +import me.arcanis.ffxivbis.models.{BiS, Loot, Piece, Player, PlayerId} + +import scala.jdk.CollectionConverters._ +import scala.util.Random + +case class Party(partyId: String, config: Config, players: Map[PlayerId, Player]) + extends StrictLogging { + + private val rules = + config.getStringList("me.arcanis.ffxivbis.settings.priority").asScala.toSeq + + def getPlayers: Seq[Player] = players.values.toSeq + def player(playerId: PlayerId): Option[Player] = players.get(playerId) + def withPlayer(player: Player): Party = + try { + require(player.partyId == partyId, "player must belong to this party") + copy(players = players + (player.playerId -> player)) + } catch { + case exception: Exception => + logger.error("cannot add player", exception) + this + } + + def suggestLoot(piece: Piece): LootSelector.LootSelectorResult = + LootSelector(getPlayers, piece, rules) +} + +object Party { + def apply(partyId: Option[String], config: Config): Party = + new Party(partyId.getOrElse(Random.alphanumeric.take(20).mkString), config, Map.empty) + + def apply(partyId: String, config: Config, + players: Map[Long, Player], bis: Seq[Loot], loot: Seq[Loot]): Party = { + val bisByPlayer = bis.groupBy(_.playerId).view.mapValues(piece => BiS(piece.map(_.piece))) + val lootByPlayer = loot.groupBy(_.playerId).view.mapValues(_.map(_.piece)) + val playersWithItems = players.foldLeft(Map.empty[PlayerId, Player]) { + case (acc, (playerId, player)) => + acc + (player.playerId -> player + .withBiS(bisByPlayer.get(playerId)) + .withLoot(lootByPlayer.get(playerId))) + } + Party(partyId, config, playersWithItems) + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala new file mode 100644 index 0000000..fad8c69 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala @@ -0,0 +1,29 @@ +package me.arcanis.ffxivbis.service.impl + +import akka.pattern.pipe +import me.arcanis.ffxivbis.models.{BiS, Piece, PlayerId} +import me.arcanis.ffxivbis.service.Database + +trait DatabaseBiSHandler { this: Database => + import DatabaseBiSHandler._ + + def bisHandler: Receive = { + case AddPieceToBis(playerId, piece) => + profile.insertPieceBiS(playerId, piece) + + case GetBiS(partyId, maybePlayerId) => + val client = sender() + getParty(partyId, withBiS = true, withLoot = false) + .map(filterParty(_, maybePlayerId)) + .pipeTo(client) + + case RemovePieceFromBiS(playerId, piece) => + profile.deletePieceBiS(playerId, piece) + } +} + +object DatabaseBiSHandler { + case class AddPieceToBis(playerId: PlayerId, piece: Piece) + case class GetBiS(partyId: String, playerId: Option[PlayerId]) + case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseImpl.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseImpl.scala new file mode 100644 index 0000000..0cfc6ab --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseImpl.scala @@ -0,0 +1,22 @@ +package me.arcanis.ffxivbis.service.impl + +import akka.actor.Props +import me.arcanis.ffxivbis.service.Database +import me.arcanis.ffxivbis.storage.DatabaseProfile + +import scala.concurrent.ExecutionContext + +class DatabaseImpl extends Database + with DatabaseBiSHandler with DatabaseLootHandler + with DatabasePartyHandler with DatabaseUserHandler { + + implicit val executionContext: ExecutionContext = context.dispatcher + val profile = new DatabaseProfile(executionContext, context.system.settings.config) + + override def receive: Receive = + bisHandler orElse lootHandler orElse partyHandler orElse userHandler +} + +object DatabaseImpl { + def props: Props = Props(new DatabaseImpl) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala new file mode 100644 index 0000000..d976e40 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala @@ -0,0 +1,34 @@ +package me.arcanis.ffxivbis.service.impl + +import akka.pattern.pipe +import me.arcanis.ffxivbis.models.{Piece, PlayerId} +import me.arcanis.ffxivbis.service.Database + +trait DatabaseLootHandler { this: Database => + import DatabaseLootHandler._ + + def lootHandler: Receive = { + case AddPieceTo(playerId, piece) => + profile.insertPiece(playerId, piece) + + case GetLoot(partyId, maybePlayerId) => + val client = sender() + getParty(partyId, withBiS = false, withLoot = true) + .map(filterParty(_, maybePlayerId)) + .pipeTo(client) + + case RemovePieceFrom(playerId, piece) => + profile.deletePiece(playerId, piece) + + case SuggestLoot(partyId, piece) => + val client = sender() + getParty(partyId, withBiS = true, withLoot = true).map(_.suggestLoot(piece)).pipeTo(client) + } +} + +object DatabaseLootHandler { + case class AddPieceTo(playerId: PlayerId, piece: Piece) + case class GetLoot(partyId: String, playerId: Option[PlayerId]) + case class RemovePieceFrom(playerId: PlayerId, piece: Piece) + case class SuggestLoot(partyId: String, piece: Piece) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala new file mode 100644 index 0000000..a78e958 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala @@ -0,0 +1,39 @@ +package me.arcanis.ffxivbis.service.impl + +import akka.actor.Actor +import akka.pattern.pipe +import com.typesafe.scalalogging.StrictLogging +import me.arcanis.ffxivbis.models.{BiS, Player, PlayerId} +import me.arcanis.ffxivbis.service.Database + +trait DatabasePartyHandler { this: Actor with StrictLogging with Database => + import DatabasePartyHandler._ + + def partyHandler: Receive = { + case AddPlayer(player) => + profile.insertPlayer(player) + + case GetParty(partyId) => + val client = sender() + getParty(partyId, withBiS = true, withLoot = true).pipeTo(client) + + case GetPlayer(playerId) => + val client = sender() + val player = for { + bis <- profile.getPiecesBiS(playerId) + loot <- profile.getPieces(playerId) + } yield Player(playerId.partyId, playerId.job, playerId.nick, + BiS(bis.map(_.piece)), loot.map(_.piece)) + player.pipeTo(client) + + case RemovePlayer(playerId) => + profile.deletePlayer(playerId) + } +} + +object DatabasePartyHandler { + case class AddPlayer(player: Player) + case class GetParty(partyId: String) + case class GetPlayer(playerId: PlayerId) + case class RemovePlayer(playerId: PlayerId) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala new file mode 100644 index 0000000..29fe75f --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala @@ -0,0 +1,33 @@ +package me.arcanis.ffxivbis.service.impl + +import akka.pattern.pipe +import me.arcanis.ffxivbis.models.User +import me.arcanis.ffxivbis.service.Database + +trait DatabaseUserHandler { this: Database => + import DatabaseUserHandler._ + + def userHandler: Receive = { + case DeleteUser(partyId, username) => + profile.deleteUser(partyId, username) + + case GetUser(partyId, username) => + val client = sender() + profile.getUser(partyId, username).pipeTo(client) + + case GetUsers(partyId) => + val client = sender() + profile.getUsers(partyId).pipeTo(client) + + case InsertUser(user, isHashedPassword) => + val toInsert = if (isHashedPassword) user else user.copy(password = user.hash) + profile.insertUser(toInsert) + } +} + +object DatabaseUserHandler { + case class DeleteUser(partyId: String, username: String) + case class GetUser(partyId: String, username: String) + case class GetUsers(partyId: String) + case class InsertUser(user: User, isHashedPassword: Boolean) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/BiSProfile.scala b/src/main/scala/me/arcanis/ffxivbis/storage/BiSProfile.scala new file mode 100644 index 0000000..c81248a --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/storage/BiSProfile.scala @@ -0,0 +1,48 @@ +package me.arcanis.ffxivbis.storage + +import me.arcanis.ffxivbis.models.{Job, Loot, Piece} +import slick.lifted.{ForeignKeyQuery, Index} + +import scala.concurrent.Future + +trait BiSProfile { this: DatabaseProfile => + import dbConfig.profile.api._ + + case class BiSRep(playerId: Long, created: Long, piece: String, isTome: Int, job: String) { + def toLoot: Loot = Loot(playerId, Piece(piece, isTome == 1, Job.fromString(job))) + } + object BiSRep { + def fromPiece(playerId: Long, piece: Piece) = + BiSRep(playerId, DatabaseProfile.now, piece.piece, if (piece.isTome) 1 else 0, + piece.job.toString) + } + + class BiSPieces(tag: Tag) extends Table[BiSRep](tag, "bis") { + def playerId: Rep[Long] = column[Long]("player_id") + def created: Rep[Long] = column[Long]("created") + def piece: Rep[String] = column[String]("piece") + def isTome: Rep[Int] = column[Int]("is_tome") + def job: Rep[String] = column[String]("job") + + def * = + (playerId, created, piece, isTome, job) <> ((BiSRep.apply _).tupled, BiSRep.unapply) + + def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] = + foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade) + def bisPiecePlayerIdIdx: Index = + index("bis_piece_player_id_idx", (playerId, piece), unique = true) + } + + def deletePieceBiSById(piece: Piece)(playerId: Long): Future[Int] = + db.run(pieceBiS(BiSRep.fromPiece(playerId, piece)).delete) + def getPiecesBiSById(playerId: Long): Future[Seq[Loot]] = getPiecesBiSById(Seq(playerId)) + def getPiecesBiSById(playerIds: Seq[Long]): Future[Seq[Loot]] = + db.run(piecesBiS(playerIds).result).map(_.map(_.toLoot)) + def insertPieceBiSById(piece: Piece)(playerId: Long): Future[Int] = + db.run(bisTable.insertOrUpdate(BiSRep.fromPiece(playerId, piece))) + + private def pieceBiS(piece: BiSRep) = + piecesBiS(Seq(piece.playerId)).filter(_.piece === piece.piece) + private def piecesBiS(playerIds: Seq[Long]) = + bisTable.filter(_.playerId.inSet(playerIds.toSet)) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/DatabaseProfile.scala b/src/main/scala/me/arcanis/ffxivbis/storage/DatabaseProfile.scala new file mode 100644 index 0000000..31374ac --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/storage/DatabaseProfile.scala @@ -0,0 +1,62 @@ +package me.arcanis.ffxivbis.storage + +import java.time.Instant + +import com.typesafe.config.Config +import me.arcanis.ffxivbis.models.{Loot, Piece, PlayerId} +import slick.basic.DatabaseConfig +import slick.jdbc.JdbcProfile + +import scala.concurrent.{ExecutionContext, Future} + +class DatabaseProfile(context: ExecutionContext, config: Config) + extends BiSProfile with LootProfile with PlayersProfile with UsersProfile { + + implicit val executionContext: ExecutionContext = context + + val dbConfig: DatabaseConfig[JdbcProfile] = + DatabaseConfig.forConfig[JdbcProfile]("", DatabaseProfile.getSection(config)) + import dbConfig.profile.api._ + val db = dbConfig.db + + val bisTable: TableQuery[BiSPieces] = TableQuery[BiSPieces] + val lootTable: TableQuery[LootPieces] = TableQuery[LootPieces] + val playersTable: TableQuery[Players] = TableQuery[Players] + val usersTable: TableQuery[Users] = TableQuery[Users] + + // generic bis api + def deletePieceBiS(playerId: PlayerId, piece: Piece): Future[Int] = + byPlayerId(playerId, deletePieceBiSById(piece)) + def getPiecesBiS(playerId: PlayerId): Future[Seq[Loot]] = + byPlayerId(playerId, getPiecesBiSById) + def getPiecesBiS(partyId: String): Future[Seq[Loot]] = + byPartyId(partyId, getPiecesBiSById) + def insertPieceBiS(playerId: PlayerId, piece: Piece): Future[Int] = + byPlayerId(playerId, insertPieceBiSById(piece)) + + // generic loot api + def deletePiece(playerId: PlayerId, piece: Piece): Future[Int] = + byPlayerId(playerId, deletePieceById(piece)) + def getPieces(playerId: PlayerId): Future[Seq[Loot]] = + byPlayerId(playerId, getPiecesById) + def getPieces(partyId: String): Future[Seq[Loot]] = + byPartyId(partyId, getPiecesById) + def insertPiece(playerId: PlayerId, piece: Piece): Future[Int] = + byPlayerId(playerId, insertPieceById(piece)) + + private def byPartyId[T](partyId: String, callback: Seq[Long] => Future[T]): Future[T] = + getPlayers(partyId).map(callback).flatten + private def byPlayerId[T](playerId: PlayerId, callback: Long => Future[T]): Future[T] = + getPlayer(playerId).map { + case Some(id) => callback(id) + case None => Future.failed(new Error(s"Could not find player $playerId")) + }.flatten +} + +object DatabaseProfile { + def now: Long = Instant.now.toEpochMilli + def getSection(config: Config): Config = { + val section = config.getString("me.arcanis.ffxivbis.database.mode") + config.getConfig("me.arcanis.ffxivbis.database").getConfig(section) + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/LootProfile.scala b/src/main/scala/me/arcanis/ffxivbis/storage/LootProfile.scala new file mode 100644 index 0000000..c816a29 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/storage/LootProfile.scala @@ -0,0 +1,50 @@ +package me.arcanis.ffxivbis.storage + +import me.arcanis.ffxivbis.models.{Job, Loot, Piece} +import slick.lifted.{ForeignKeyQuery, Index} + +import scala.concurrent.Future + +trait LootProfile { this: DatabaseProfile => + import dbConfig.profile.api._ + + case class LootRep(lootId: Option[Long], playerId: Long, created: Long, piece: String, + isTome: Int, job: String) { + def toLoot: Loot = Loot(playerId, Piece(piece, isTome == 1, Job.fromString(job))) + } + object LootRep { + def fromPiece(playerId: Long, piece: Piece) = + LootRep(None, playerId, DatabaseProfile.now, piece.piece, if (piece.isTome) 1 else 0, + piece.job.toString) + } + + class LootPieces(tag: Tag) extends Table[LootRep](tag, "loot") { + def lootId: Rep[Long] = column[Long]("loot_id", O.AutoInc, O.PrimaryKey) + def playerId: Rep[Long] = column[Long]("player_id") + def created: Rep[Long] = column[Long]("created") + def piece: Rep[String] = column[String]("piece") + def isTome: Rep[Int] = column[Int]("is_tome") + def job: Rep[String] = column[String]("job") + + def * = + (lootId.?, playerId, created, piece, isTome, job) <> ((LootRep.apply _).tupled, LootRep.unapply) + + def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] = + foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade) + def lootOwnerIdx: Index = + index("loot_owner_idx", (playerId), unique = false) + } + + def deletePieceById(piece: Piece)(playerId: Long): Future[Int] = + db.run(pieceLoot(LootRep.fromPiece(playerId, piece)).take(1).delete) + def getPiecesById(playerId: Long): Future[Seq[Loot]] = getPiecesById(Seq(playerId)) + def getPiecesById(playerIds: Seq[Long]): Future[Seq[Loot]] = + db.run(piecesLoot(playerIds).result).map(_.map(_.toLoot)) + def insertPieceById(piece: Piece)(playerId: Long): Future[Int] = + db.run(lootTable.insertOrUpdate(LootRep.fromPiece(playerId, piece))) + + private def pieceLoot(piece: LootRep) = + piecesLoot(Seq(piece.playerId)).filter(_.piece === piece.piece) + private def piecesLoot(playerIds: Seq[Long]) = + lootTable.filter(_.playerId.inSet(playerIds.toSet)) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/Migration.scala b/src/main/scala/me/arcanis/ffxivbis/storage/Migration.scala new file mode 100644 index 0000000..fed7b68 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/storage/Migration.scala @@ -0,0 +1,23 @@ +package me.arcanis.ffxivbis.storage + +import com.typesafe.config.Config +import org.flywaydb.core.Flyway + +import scala.concurrent.Future + +class Migration(config: Config) { + def performMigration(): Future[Int] = { + val section = DatabaseProfile.getSection(config) + + val url = section.getString("db.url") + val username = section.getString("db.user") + val password = section.getString("db.password") + + val flyway = Flyway.configure().dataSource(url, username, password).load() + Future.successful(flyway.migrate()) + } +} + +object Migration { + def apply(config: Config): Future[Int] = new Migration(config).performMigration() +} diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/PlayersProfile.scala b/src/main/scala/me/arcanis/ffxivbis/storage/PlayersProfile.scala new file mode 100644 index 0000000..e41c893 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/storage/PlayersProfile.scala @@ -0,0 +1,58 @@ +package me.arcanis.ffxivbis.storage + +import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId} +import slick.lifted.Index + +import scala.concurrent.Future + +trait PlayersProfile { this: DatabaseProfile => + import dbConfig.profile.api._ + + case class PlayerRep(partyId: String, playerId: Option[Long], created: Long, nick: String, + job: String, link: Option[String], priority: Int) { + def toPlayer: Player = + Player(partyId, Job.fromString(job), nick, BiS(Seq.empty), List.empty, link, priority) + } + object PlayerRep { + def fromPlayer(player: Player): PlayerRep = + PlayerRep(player.partyId, None, DatabaseProfile.now, player.nick, + player.job.toString, player.link, player.priority) + } + + class Players(tag: Tag) extends Table[PlayerRep](tag, "players") { + def partyId: Rep[String] = column[String]("party_id") + def playerId: Rep[Long] = column[Long]("player_id", O.AutoInc, O.PrimaryKey) + def created: Rep[Long] = column[Long]("created") + def nick: Rep[String] = column[String]("nick") + def job: Rep[String] = column[String]("job") + def bisLink: Rep[Option[String]] = column[Option[String]]("bis_link") + def priority: Rep[Int] = column[Int]("priority", O.Default(1)) + + def * = + (partyId, playerId.?, created, nick, job, bisLink, priority) <> ((PlayerRep.apply _).tupled, PlayerRep.unapply) + + def playersNickJobIdx: Index = + index("players_nick_job_idx", (partyId, nick, job), unique = true) + } + + def deletePlayer(playerId: PlayerId): Future[Int] = db.run(player(playerId).delete) + def getParty(partyId: String): Future[Map[Long, Player]] = + db.run(players(partyId).result).map(_.foldLeft(Map.empty[Long, Player]) { + case (acc, p @ PlayerRep(_, Some(id), _, _, _, _, _)) => acc + (id -> p.toPlayer) + case (acc, _) => acc + }) + def getPlayer(playerId: PlayerId): Future[Option[Long]] = + db.run(player(playerId).map(_.playerId).result.headOption) + def getPlayers(partyId: String): Future[Seq[Long]] = + db.run(players(partyId).map(_.playerId).result) + def insertPlayer(player: Player): Future[Int] = + db.run(playersTable.insertOrUpdate(PlayerRep.fromPlayer(player))) + + private def player(playerId: PlayerId) = + playersTable + .filter(_.partyId === playerId.partyId) + .filter(_.job === playerId.job.toString) + .filter(_.nick === playerId.nick) + private def players(partyId: String) = + playersTable.filter(_.partyId === partyId) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala b/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala new file mode 100644 index 0000000..b720149 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala @@ -0,0 +1,48 @@ +package me.arcanis.ffxivbis.storage + +import me.arcanis.ffxivbis.models.{Permission, User} +import slick.lifted.Index + +import scala.concurrent.Future + +trait UsersProfile { this: DatabaseProfile => + import dbConfig.profile.api._ + + case class UserRep(partyId: String, userId: Option[Long], username: String, password: String, + permission: String) { + def toUser: User = User(partyId, username, password, Permission.withName(permission)) + } + object UserRep { + def fromUser(user: User): UserRep = + UserRep(user.partyId, None, user.username, user.password, user.permission.toString) + } + + class Users(tag: Tag) extends Table[UserRep](tag, "users") { + def partyId: Rep[String] = column[String]("party_id") + def userId: Rep[Long] = column[Long]("user_id", O.AutoInc, O.PrimaryKey) + def username: Rep[String] = column[String]("username") + def password: Rep[String] = column[String]("password") + def permission: Rep[String] = column[String]("permission") + + def * = + (partyId, userId.?, username, password, permission) <> ((UserRep.apply _).tupled, UserRep.unapply) + + def usersUsernameIdx: Index = + index("users_username_idx", (partyId, username), unique = true) + } + + def deleteUser(partyId: String, username: String): Future[Int] = + db.run(user(partyId, Some(username)).delete) + def getUser(partyId: String, username: String): Future[Option[User]] = + db.run(user(partyId, Some(username)).result.headOption).map(_.map(_.toUser)) + def getUsers(partyId: String): Future[Seq[User]] = + db.run(user(partyId, None).result).map(_.map(_.toUser)) + def insertUser(user: User): Future[Int] = { + db.run(usersTable.insertOrUpdate(UserRep.fromUser(user))) + } + + private def user(partyId: String, username: Option[String]) = + usersTable + .filter(_.partyId === partyId) + .filterIf(username.isDefined)(_.username === username.orNull) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/utils/Implicits.scala b/src/main/scala/me/arcanis/ffxivbis/utils/Implicits.scala new file mode 100644 index 0000000..636bb28 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/utils/Implicits.scala @@ -0,0 +1,14 @@ +package me.arcanis.ffxivbis.utils + +import java.time.Duration +import java.util.concurrent.TimeUnit + +import akka.util.Timeout + +import scala.concurrent.duration.FiniteDuration +import scala.language.implicitConversions + +object Implicits { + implicit def getFiniteDuration(duration: Duration): Timeout = + FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS) +} diff --git a/templates/bis.jinja2 b/templates/bis.jinja2 deleted file mode 100644 index 0092e70..0000000 --- a/templates/bis.jinja2 +++ /dev/null @@ -1,74 +0,0 @@ - - - - Best in slot - - - - -

best in slot

- - {% include "error.jinja2" %} - {% include "search_line.jinja2" %} - -
- - - - - - - -
-
- - - - -
- - - - - - - - - - {% for item in items %} - - - - - - - {% endfor %} -
playerpieceis_tome
{{ item.is_tome|e }} -
- - - - - - -
-
- - {% include "export_to_csv.jinja2" %} - {% include "root.jinja2" %} - - - diff --git a/templates/error.jinja2 b/templates/error.jinja2 deleted file mode 100644 index 40716b8..0000000 --- a/templates/error.jinja2 +++ /dev/null @@ -1,3 +0,0 @@ -{% if request_error is defined and request_error is not none %} -

Error occurs: {{ request_error|e }}

-{% endif %} diff --git a/templates/export_to_csv.jinja2 b/templates/export_to_csv.jinja2 deleted file mode 100644 index 37013a4..0000000 --- a/templates/export_to_csv.jinja2 +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/templates/index.jinja2 b/templates/index.jinja2 deleted file mode 100644 index d261bfe..0000000 --- a/templates/index.jinja2 +++ /dev/null @@ -1,34 +0,0 @@ - - - - FFXIV loot helper - - - - - -
- {% if logged is defined and logged %} -
- logged in as {{ logged|e }} - -
- {% else %} -
- - - -
- {% endif %} - -
- -

party

-

bis

-

loot

-

suggest

-
-

manage users

-
- - diff --git a/templates/loot.jinja2 b/templates/loot.jinja2 deleted file mode 100644 index 294ba17..0000000 --- a/templates/loot.jinja2 +++ /dev/null @@ -1,61 +0,0 @@ - - - - Loot - - - - -

Loot

- - {% include "error.jinja2" %} - {% include "search_line.jinja2" %} - -
- - - - - - -
- - - - - - - - - - {% for item in items %} - - - - - - - {% endfor %} -
playerpieceis_tome
{{ item.is_tome|e }} -
- - - - - -
-
- - {% include "export_to_csv.jinja2" %} - {% include "root.jinja2" %} - - - diff --git a/templates/loot_suggest.jinja2 b/templates/loot_suggest.jinja2 deleted file mode 100644 index 9c2d29b..0000000 --- a/templates/loot_suggest.jinja2 +++ /dev/null @@ -1,59 +0,0 @@ - - - - Suggest loot - - - - -

suggest loot

- - {% include "error.jinja2" %} - {% include "search_line.jinja2" %} - -
- - - - -
- - - - - - - - - - - - {% for player in suggest %} - - - - - - - - - {% endfor %} -
playeris requiredthese pieces lootedtotal bis pieces lootedtotal pieces looted
{{ player.is_required|e }}{{ player.loot_count|e }}{{ player.loot_count_bis|e }}{{ player.loot_count_total|e }} -
- - - - - -
-
- - {% include "export_to_csv.jinja2" %} - {% include "root.jinja2" %} - - - diff --git a/templates/party.jinja2 b/templates/party.jinja2 deleted file mode 100644 index 304ff9e..0000000 --- a/templates/party.jinja2 +++ /dev/null @@ -1,60 +0,0 @@ - - - - Party - - - - -

party

- - {% include "error.jinja2" %} - {% include "search_line.jinja2" %} - -
- - - - - - -
- - - - - - - - - - - - {% for player in players %} - - - - - - - - - {% endfor %} -
nickjobtotal bis pieces lootedtotal pieces lootedpriority
{{ player.loot_count_bis|e }}{{ player.loot_count_total|e }}{{ player.priority|e }} -
- - - - -
-
- - {% include "export_to_csv.jinja2" %} - {% include "root.jinja2" %} - - - diff --git a/templates/root.jinja2 b/templates/root.jinja2 deleted file mode 100644 index 4d63855..0000000 --- a/templates/root.jinja2 +++ /dev/null @@ -1 +0,0 @@ -

root

\ No newline at end of file diff --git a/templates/search_line.jinja2 b/templates/search_line.jinja2 deleted file mode 100644 index ccf4288..0000000 --- a/templates/search_line.jinja2 +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/templates/static/styles.css b/templates/static/styles.css deleted file mode 100644 index c5be043..0000000 --- a/templates/static/styles.css +++ /dev/null @@ -1,277 +0,0 @@ -/* in-text images */ -figure.img { - float: right; - border: 0px solid #333; - padding: 0px; - margin: 5px 0px 5px 10px; -} -figure.img img { - max-width: 100%; - height: auto; -} -figure.img figcaption { - margin: 0px; - font-size: 90%; - font-style: italic; - text-align: center; -} - -h1 .octicon-link, h2 .octicon-link, h3 .octicon-link, h4 .octicon-link, h5 .octicon-link, h6 .octicon-link { - display: none; - color: #222222; - vertical-align: middle; -} - -h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, h5:hover .anchor, h6:hover .anchor{ - padding-left: 8px; - margin-left: -24px; - text-decoration: none; -} - -h1:hover .anchor .octicon-link, h2:hover .anchor .octicon-link, h3:hover .anchor .octicon-link, h4:hover .anchor .octicon-link, h5:hover .anchor .octicon-link, h6:hover .anchor .octicon-link { - display: inline-block; -} - -body { - padding: 50px; - font: 14px/1.5 "Liberation Sans", Helvetica, Arial, sans-serif; - color: #555555; - background: #eaeaea -} - -h1, h2, h3, h4, h5, h6 { - color: #222222; - margin: 0 0 20px; -} - -p, ul, ol, table, pre, dl { - margin: 0 0 20px; - text-align: justify; -} - -h1, h2, h3 { - line-height: 1.1; -} - -h1 { - font-size: 28px; -} - -h2 { - color: #393939; -} - -h3, h4, h5, h6 { - color: #494949; -} - -a { - color: #3399cc; - font-weight: 350; - text-decoration: none; -} - -a small { - font-size: 11px; - color: #777777; - margin-top: -0.6em; - display: block; -} - -.wrapper { - width: 80%; - margin: 0 auto; -} - -blockquote { - border-left: 1px solid #ffffff; - margin: 0; - padding: 0 0 0 20px; - font-style: italic; -} - -code, pre { - font-family: "Liberation Mono", Monaco, Bitstream Vera Sans Mono, Lucida Console, Terminal; - color: #222222; - font-size: 12px; -} - -pre { - padding: 8px 15px; - border-radius: 5px; - border: 1px solid #e5e5e5; - overflow-x: auto; - overflow-y: auto; -} - -input, select{ - box-sizing: border-box; -} - -table { - width: 100%; - border-collapse: collapse; -} - -th, td { - padding: 5px 10px; - border-bottom: 1px solid #ffffff; -} - -td { - text-align: justify; -} - -dt { - color: #444444; - font-weight: 700; -} - -th { - text-align: left; - color: #444444; -} - -img { - max-width: 100%; -} - -header { - width: 20%; - float: left; - position: fixed; -} - -header ul { - list-style: none; - height: 40px; - padding: 0; - background: #eeeeee; - border-radius: 5px; - border: 1px solid #d2d2d2; - box-shadow: inset #fff 0 1px 0, inset rgba(0,0,0,0.03) 0 -1px 0; - width: 15%; -} - -header li { - width: 8%; - float: left; - border-right: 1px solid #d2d2d2; - height: 40px; -} - -header ul a { - line-height: 1; - font-size: 11px; - color: #999999; - display: block; - text-align: center; - padding-top: 6px; - height: 40px; -} - -strong { - color: #222222; - font-weight: 700; -} - -header ul li + li { - width: 8%; - border-left: 1px solid #ffffff; -} - -header ul li + li + li { - width: 8%; - border-right: none; -} - -header ul a strong { - font-size: 14px; - display: block; - color: #222222; -} - -section { - width: 70%; - float: right; - padding-bottom: 50px; -} - -small { - font-size: 11px; -} - -hr { - border: 0; - background: #ffffff; - height: 1px; - margin: 0 0 20px; -} - -footer { - width: 20%; - float: left; - position: fixed; - bottom: 50px; -} - -@media print, screen and (max-width: 960px) { - div.wrapper { - width: auto; - margin: 0; - } - header, section, footer { - float: none; - position: static; - width: auto; - } - header { - padding-right: 320px; - } - section { - border: 1px solid #e5e5e5; - border-width: 1px 0; - padding: 20px 0; - margin: 0 0 20px; - } - header a small { - display: inline; - } - header ul { - position: absolute; - right: 50px; - top: 52px; - } -} - -@media print, screen and (max-width: 720px) { - body { - word-wrap: break-word; - } - header { - padding: 0; - } - header ul, header p.view { - position: static; - } - pre, code { - word-wrap: normal; - } -} - -@media print, screen and (max-width: 480px) { - body { - padding: 15px; - } - header ul { - display: none; - } -} - -@media print { - body { - padding: 0.4in; - font-size: 12pt; - color: #444444; - } -} diff --git a/templates/static/table_export.js b/templates/static/table_export.js deleted file mode 100644 index 93ec8d7..0000000 --- a/templates/static/table_export.js +++ /dev/null @@ -1,31 +0,0 @@ -function downloadCsv(csv, filename) { - var csvFile = new Blob([csv], {"type": "text/csv"}); - - var downloadLink = document.createElement("a"); - downloadLink.download = filename; - downloadLink.href = window.URL.createObjectURL(csvFile); - downloadLink.style.display = "none"; - - document.body.appendChild(downloadLink); - downloadLink.click(); -} - -function exportTableToCsv(filename) { - var table = document.getElementById("result"); - var rows = table.getElementsByTagName("tr"); - - var csv = []; - for (var i = 0; i < rows.length; i++) { - if (rows[i].style.display === "none") - continue - var cols = rows[i].querySelectorAll("td, th"); - - var row = []; - for (var j = 0; j < cols.length; j++) - row.push(cols[j].innerText); - - csv.push(row.join(",")); - } - - downloadCsv(csv.join("\n"), filename); -} \ No newline at end of file diff --git a/templates/static/table_search.js b/templates/static/table_search.js deleted file mode 100644 index e269f2c..0000000 --- a/templates/static/table_search.js +++ /dev/null @@ -1,21 +0,0 @@ -function searchTable() { - var input = document.getElementById("search"); - var filter = input.value.toLowerCase(); - var table = document.getElementById("result"); - var tr = table.getElementsByTagName("tr"); - - // from 1 coz of header - for (var i = 1; i < tr.length; i++) { - var td = tr[i].getElementsByClassName("include_search"); - var display = "none"; - for (var j = 0; j < td.length; j++) { - if (td[j].tagName.toLowerCase() === "td") { - if (td[j].innerHTML.toLowerCase().indexOf(filter) > -1) { - display = ""; - break; - } - } - } - tr[i].style.display = display; - } -} \ No newline at end of file diff --git a/templates/users.jinja2 b/templates/users.jinja2 deleted file mode 100644 index e57d35d..0000000 --- a/templates/users.jinja2 +++ /dev/null @@ -1,51 +0,0 @@ - - - - Users - - - - -

users

- - {% include "error.jinja2" %} - {% include "search_line.jinja2" %} - -
- - - - - -
- - - - - - - - - {% for user in users %} - - - - - - {% endfor %} -
usernamepermission
{{ user.permission|e }} -
- - - -
-
- - {% include "export_to_csv.jinja2" %} - {% include "root.jinja2" %} - - - diff --git a/test/__init__.py b/test/__init__.py deleted file mode 100644 index 8ec72fd..0000000 --- a/test/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -import os -import sys - - -sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src')) diff --git a/test/conftest.py b/test/conftest.py deleted file mode 100644 index 3fa1552..0000000 --- a/test/conftest.py +++ /dev/null @@ -1,131 +0,0 @@ -import os -import pytest -import tempfile - -from typing import Any, List - -from ffxivbis.api.web import setup_service -from ffxivbis.core.ariyala_parser import AriyalaParser -from ffxivbis.core.config import Configuration -from ffxivbis.core.database import Database -from ffxivbis.core.loot_selector import LootSelector -from ffxivbis.core.party import Party -from ffxivbis.core.sqlite import SQLiteDatabase -from ffxivbis.models.bis import BiS -from ffxivbis.models.job import Job -from ffxivbis.models.piece import Head, Piece, Weapon -from ffxivbis.models.player import Player - - -@pytest.fixture -def parser(config: Configuration) -> AriyalaParser: - return AriyalaParser(config) - - -@pytest.fixture -def bis() -> BiS: - return BiS() - - -@pytest.fixture -def bis2() -> BiS: - return BiS() - - -@pytest.fixture -def bis_link() -> str: - return 'https://ffxiv.ariyala.com/19V5R' - - -@pytest.fixture -def bis_set() -> List[Piece]: - items: List[Piece] = [] - items.append(Piece.get({'piece': 'weapon', 'is_tome': False})) - items.append(Piece.get({'piece': 'head', 'is_tome': False})) - items.append(Piece.get({'piece': 'body', 'is_tome': False})) - items.append(Piece.get({'piece': 'hands', 'is_tome': True})) - items.append(Piece.get({'piece': 'waist', 'is_tome': True})) - items.append(Piece.get({'piece': 'legs', 'is_tome': True})) - items.append(Piece.get({'piece': 'feet', 'is_tome': False})) - items.append(Piece.get({'piece': 'ears', 'is_tome': False})) - items.append(Piece.get({'piece': 'neck', 'is_tome': True})) - items.append(Piece.get({'piece': 'wrist', 'is_tome': False})) - items.append(Piece.get({'piece': 'left_ring', 'is_tome': True})) - items.append(Piece.get({'piece': 'right_ring', 'is_tome': True})) - - return items - - -@pytest.fixture -def config() -> Configuration: - config = Configuration() - config.load('/dev/null', { - 'ariyala': { - 'ariyala_url': 'https://ffxiv.ariyala.com', - 'request_timeout': 1, - 'xivapi_url': 'https://xivapi.com' - }, - 'auth': { - 'enabled': 'no' - }, - 'settings': { - 'include': '/dev/null' - } - }) - return config - - -@pytest.fixture -def database() -> SQLiteDatabase: - db = SQLiteDatabase( - tempfile.mktemp('-ffxivbis.db'), - os.path.join(os.path.dirname(os.path.dirname(__file__)), 'migrations')) - db.migration() - return db - - -@pytest.fixture -def head_with_upgrade() -> Piece: - return Head(is_tome=True) - - -@pytest.fixture -def party(database: Database) -> Party: - return Party(database) - - -@pytest.fixture -def player(bis: BiS) -> Player: - return Player(Job.WHM, 'A nick', bis, []) - - -@pytest.fixture -def player2(bis2: BiS) -> Player: - return Player(Job.AST, 'Another nick', bis2, [], priority=0) - - -@pytest.fixture -async def selector(party: Party, player: Player, player2: Player, - head_with_upgrade: Piece, weapon: Piece) -> LootSelector: - obj = LootSelector(party) - - await obj.party.set_player(player) - player.bis.set_item(weapon) - - await obj.party.set_player(player2) - player2.bis.set_item(head_with_upgrade) - player2.bis.set_item(weapon) - - return LootSelector(party) - - -@pytest.fixture -def server(loop: Any, aiohttp_client: Any, - config: Configuration, database: Database, selector: LootSelector, party: Party) -> Any: - app = setup_service(config, database, selector, party) - return loop.run_until_complete(aiohttp_client(app)) - - -@pytest.fixture -def weapon() -> Piece: - return Weapon(is_tome=False) \ No newline at end of file diff --git a/test/test_ariyala.py b/test/test_ariyala.py deleted file mode 100644 index 97722ac..0000000 --- a/test/test_ariyala.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import List - -from ffxivbis.core.ariyala_parser import AriyalaParser -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player - - -async def test_get(parser: AriyalaParser, player: Player, bis_link: str, bis_set: List[Piece]) -> None: - items = await parser.get(bis_link, player.job.name) - - assert items == bis_set diff --git a/test/test_bis.py b/test/test_bis.py deleted file mode 100644 index 9b4107b..0000000 --- a/test/test_bis.py +++ /dev/null @@ -1,20 +0,0 @@ -from ffxivbis.models.bis import BiS -from ffxivbis.models.piece import Piece -from ffxivbis.models.upgrade import Upgrade - - -def test_set_item(bis: BiS, weapon: Piece) -> None: - bis.set_item(weapon) - assert bis.has_piece(weapon) - - -def test_remove_item(bis: BiS, weapon: Piece) -> None: - test_set_item(bis, weapon) - bis.remove_item(weapon) - assert not bis.has_piece(weapon) - - -def test_upgrades_required(bis: BiS, weapon: Piece, head_with_upgrade: Piece) -> None: - bis.set_item(weapon) - bis.set_item(head_with_upgrade) - assert bis.upgrades_required == {Upgrade.NoUpgrade: 1, Upgrade.GearUpgrade: 1} diff --git a/test/test_loot_selector.py b/test/test_loot_selector.py deleted file mode 100644 index c65445c..0000000 --- a/test/test_loot_selector.py +++ /dev/null @@ -1,10 +0,0 @@ -from ffxivbis.core.loot_selector import LootSelector -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player - - -def test_suggest_by_need(selector: LootSelector, player: Player, player2: Player, head_with_upgrade: Piece) -> None: - assert selector.suggest(head_with_upgrade) == \ - [player2.player_id_with_counters(head_with_upgrade), player.player_id_with_counters(head_with_upgrade)] - - diff --git a/test/test_party.py b/test/test_party.py deleted file mode 100644 index 87df7e0..0000000 --- a/test/test_party.py +++ /dev/null @@ -1,95 +0,0 @@ -from ffxivbis.core.database import Database -from ffxivbis.core.party import Party -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player - - -async def test_set_player(party: Party, player: Player) -> None: - assert len(party.players) == 0 - - await party.set_player(player) - assert len(party.players) == 1 - - -async def test_remove_player(party: Party, player: Player) -> None: - await party.remove_player(player.player_id) - assert len(party.players) == 0 - - await party.set_player(player) - await party.remove_player(player.player_id) - assert len(party.players) == 0 - - -async def test_set_bis_link(party: Party, player: Player, bis_link: str) -> None: - await party.set_player(player) - - await party.set_bis_link(player.player_id, bis_link) - assert player.link == bis_link - - -async def test_set_item(party: Party, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - await party.set_player(player) - - await party.set_item(player.player_id, weapon) - assert abs(player.loot_count(weapon)) == 1 - assert abs(player.loot_count(head_with_upgrade)) == 0 - - -async def test_remove_item(party: Party, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - await party.set_player(player) - - await party.remove_item(player.player_id, weapon) - assert abs(player.loot_count(weapon)) == 0 - assert abs(player.loot_count(head_with_upgrade)) == 0 - - await party.set_item(player.player_id, weapon) - assert abs(player.loot_count(weapon)) == 1 - assert abs(player.loot_count(head_with_upgrade)) == 0 - - await party.remove_item(player.player_id, weapon) - assert abs(player.loot_count(weapon)) == 0 - assert abs(player.loot_count(head_with_upgrade)) == 0 - - -async def test_set_item_bis(party: Party, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - await party.set_player(player) - - await party.set_item_bis(player.player_id, head_with_upgrade) - assert player.bis.has_piece(head_with_upgrade) - assert not player.bis.has_piece(weapon) - - -async def test_remove_item_bis(party: Party, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - await party.set_player(player) - - await party.remove_item_bis(player.player_id, head_with_upgrade) - assert not player.bis.has_piece(head_with_upgrade) - assert not player.bis.has_piece(weapon) - - await party.set_item_bis(player.player_id, head_with_upgrade) - assert player.bis.has_piece(head_with_upgrade) - assert not player.bis.has_piece(weapon) - - await party.set_item_bis(player.player_id, weapon) - assert player.bis.has_piece(head_with_upgrade) - assert player.bis.has_piece(weapon) - - await party.remove_item_bis(player.player_id, head_with_upgrade) - assert not player.bis.has_piece(head_with_upgrade) - assert player.bis.has_piece(weapon) - - -async def test_get(party: Party, database: Database, player: Player, head_with_upgrade: Piece, - weapon: Piece, bis_link: str) -> None: - await party.set_player(player) - await party.set_bis_link(player.player_id, bis_link) - await party.set_item_bis(player.player_id, head_with_upgrade) - await party.set_item_bis(player.player_id, weapon) - await party.set_item(player.player_id, weapon) - - new_party = await Party.get(database) - assert party.party == new_party.party - - await party.remove_player(player.player_id) - new_party = await Party.get(database) - assert party.party == new_party.party diff --git a/test/test_piece.py b/test/test_piece.py deleted file mode 100644 index 182c37d..0000000 --- a/test/test_piece.py +++ /dev/null @@ -1,13 +0,0 @@ -from ffxivbis.models.piece import Piece - - -def test_parse_head(head_with_upgrade: Piece) -> None: - assert Piece.get({'piece': 'head', 'is_tome': True}) == head_with_upgrade - - -def test_parse_weapon(weapon: Piece) -> None: - assert Piece.get({'piece': 'weapon', 'is_tome': False}) == weapon - - -def test_parse_upgrade(head_with_upgrade: Piece) -> None: - assert Piece.get({'piece': head_with_upgrade.upgrade.name, 'is_tome': True}) == head_with_upgrade.upgrade \ No newline at end of file diff --git a/test/test_player.py b/test/test_player.py deleted file mode 100644 index 7ce792a..0000000 --- a/test/test_player.py +++ /dev/null @@ -1,73 +0,0 @@ -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player - - -def test_loot_count(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - assert abs(player.loot_count(head_with_upgrade)) == 0 - assert abs(player.loot_count(weapon)) == 0 - - player.loot.append(head_with_upgrade) - assert abs(player.loot_count(head_with_upgrade)) == 1 - assert abs(player.loot_count(weapon)) == 0 - - player.loot.append(weapon) - assert abs(player.loot_count(head_with_upgrade)) == 1 - assert abs(player.loot_count(weapon)) == 1 - - -def test_loot_count_bis(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - assert abs(player.bis_count_total(head_with_upgrade)) == 0 - assert abs(player.bis_count_total(weapon)) == 0 - - player.bis.set_item(head_with_upgrade) - player.loot.append(head_with_upgrade) - assert abs(player.bis_count_total(head_with_upgrade)) == 0 - assert abs(player.bis_count_total(weapon)) == 0 - - player.bis.set_item(weapon) - assert abs(player.bis_count_total(head_with_upgrade)) == 1 - assert abs(player.bis_count_total(weapon)) == 1 - - -def test_loot_count_total(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - assert abs(player.loot_count_total(head_with_upgrade)) == 0 - assert abs(player.loot_count_total(weapon)) == 0 - - player.loot.append(head_with_upgrade) - assert abs(player.loot_count_total(head_with_upgrade)) == 1 - assert abs(player.loot_count_total(weapon)) == 1 - - player.loot.append(weapon) - assert abs(player.loot_count_total(head_with_upgrade)) == 2 - assert abs(player.loot_count_total(weapon)) == 2 - - -def test_loot_priority(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - assert abs(player.priority) == abs(player.loot_priority(head_with_upgrade)) - assert abs(player.priority) == abs(player.loot_priority(weapon)) - - player.loot.append(head_with_upgrade) - assert abs(player.priority) == abs(player.loot_priority(head_with_upgrade)) - assert abs(player.priority) == abs(player.loot_priority(weapon)) - - player.loot.append(weapon) - assert abs(player.priority) == abs(player.loot_priority(head_with_upgrade)) - assert abs(player.priority) == abs(player.loot_priority(weapon)) - - -def test_is_required(player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - assert not player.is_required(weapon) - assert not player.is_required(head_with_upgrade) - - player.bis.set_item(weapon) - assert player.is_required(weapon) - assert not player.is_required(head_with_upgrade) - - player.loot.append(weapon) - assert not player.is_required(weapon) - assert not player.is_required(head_with_upgrade) - - player.bis.set_item(head_with_upgrade) - assert not player.is_required(weapon) - assert player.is_required(head_with_upgrade) - assert player.is_required(head_with_upgrade.upgrade) \ No newline at end of file diff --git a/test/test_view_bis.py b/test/test_view_bis.py deleted file mode 100644 index 8e80fcc..0000000 --- a/test/test_view_bis.py +++ /dev/null @@ -1,67 +0,0 @@ -from typing import Any, List - -from ffxivbis.api.utils import make_json -from ffxivbis.core.party import Party -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player - - -async def test_bis_get(server: Any, party: Party, player: Player, player2: Player, - head_with_upgrade: Piece, weapon: Piece) -> None: - await party.set_item_bis(player.player_id, weapon) - await party.set_item_bis(player2.player_id, weapon) - await party.set_item_bis(player2.player_id, head_with_upgrade) - - response = await server.get('/api/v1/party/bis') - assert response.status == 200 - assert await response.text() == make_json([weapon, weapon, head_with_upgrade]) - - -async def test_bis_get_with_filter(server: Any, party: Party, player: Player, player2: Player, - head_with_upgrade: Piece, weapon: Piece) -> None: - await party.set_item_bis(player.player_id, weapon) - await party.set_item_bis(player2.player_id, weapon) - await party.set_item_bis(player2.player_id, head_with_upgrade) - - response = await server.get('/api/v1/party/bis', params={'nick': player.nick}) - assert response.status == 200 - assert await response.text() == make_json([weapon]) - - response = await server.get('/api/v1/party/bis', params={'nick': player2.nick}) - assert response.status == 200 - assert await response.text() == make_json([weapon, head_with_upgrade]) - - -async def test_bis_post_add(server: Any, player: Player, head_with_upgrade: Piece) -> None: - response = await server.post('/api/v1/party/bis', json={ - 'action': 'add', - 'name': head_with_upgrade.name, - 'is_tome': head_with_upgrade.is_tome, - 'job': player.job.name, - 'nick': player.nick - }) - assert response.status == 200 - assert player.bis.has_piece(head_with_upgrade) - - -async def test_bis_post_remove(server: Any, player: Player, player2: Player, weapon: Piece) -> None: - response = await server.post('/api/v1/party/bis', json={ - 'action': 'remove', - 'name': weapon.name, - 'is_tome': weapon.is_tome, - 'job': player.job.name, - 'nick': player.nick - }) - assert response.status == 200 - assert not player.bis.has_piece(weapon) - assert player2.bis.has_piece(weapon) - - -async def test_bis_put(server: Any, player: Player, bis_link: str, bis_set: List[Piece]) -> None: - response = await server.put('/api/v1/party/bis', json={ - 'job': player.job.name, - 'link': bis_link, - 'nick': player.nick - }) - assert response.status == 200 - assert player.bis.pieces == bis_set diff --git a/test/test_view_loot.py b/test/test_view_loot.py deleted file mode 100644 index 088e412..0000000 --- a/test/test_view_loot.py +++ /dev/null @@ -1,86 +0,0 @@ -from typing import Any - -from ffxivbis.api.utils import make_json -from ffxivbis.core.party import Party -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player - - -async def test_loot_get(server: Any, party: Party, player: Player, player2: Player, weapon: Piece) -> None: - await party.set_item(player.player_id, weapon) - await party.set_item(player2.player_id, weapon) - - response = await server.get('/api/v1/party/loot') - assert response.status == 200 - assert await response.text() == make_json([weapon, weapon]) - - -async def test_loot_get_with_filter(server: Any, party: Party, player: Player, player2: Player, weapon: Piece) -> None: - await party.set_item(player.player_id, weapon) - await party.set_item(player2.player_id, weapon) - - response = await server.get('/api/v1/party/loot', params={'nick': player.nick}) - assert response.status == 200 - assert await response.text() == make_json([weapon]) - - response = await server.get('/api/v1/party/loot', params={'nick': player2.nick}) - assert response.status == 200 - assert await response.text() == make_json([weapon]) - - -async def test_loot_post_add(server: Any, player: Player, weapon: Piece) -> None: - response = await server.get('/api/v1/party/loot') - assert response.status == 200 - assert await response.text() == make_json([]) - assert weapon not in player.loot - - response = await server.post('/api/v1/party/loot', json={ - 'action': 'add', - 'name': weapon.name, - 'is_tome': weapon.is_tome, - 'job': player.job.name, - 'nick': player.nick - }) - assert response.status == 200 - assert weapon in player.loot - - -async def test_loot_post_remove(server: Any, player: Player, head_with_upgrade: Piece, weapon: Piece) -> None: - assert weapon not in player.loot - player.loot.append(weapon) - player.loot.append(weapon) - assert player.loot.count(weapon) == 2 - - response = await server.post('/api/v1/party/loot', json={ - 'action': 'remove', - 'name': weapon.name, - 'is_tome': weapon.is_tome, - 'job': player.job.name, - 'nick': player.nick - }) - assert response.status == 200 - assert player.loot.count(weapon) == 1 - - player.loot.append(head_with_upgrade) - - response = await server.post('/api/v1/party/loot', json={ - 'action': 'remove', - 'name': weapon.name, - 'is_tome': weapon.is_tome, - 'job': player.job.name, - 'nick': player.nick - }) - assert response.status == 200 - assert player.loot.count(weapon) == 0 - assert player.loot.count(head_with_upgrade) == 1 - - -async def test_loot_put(server: Any, player: Player, player2: Player, head_with_upgrade: Piece) -> None: - response = await server.put('/api/v1/party/loot', json={ - 'is_tome': head_with_upgrade.is_tome, - 'name': head_with_upgrade.name - }) - assert response.status == 200 - assert await response.text() == make_json( - [player2.player_id_with_counters(head_with_upgrade), player.player_id_with_counters(head_with_upgrade)] - ) diff --git a/test/test_view_player.py b/test/test_view_player.py deleted file mode 100644 index 49ef8cd..0000000 --- a/test/test_view_player.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import Any, List - -from ffxivbis.api.utils import make_json -from ffxivbis.core.party import Party -from ffxivbis.models.piece import Piece -from ffxivbis.models.player import Player - - -async def test_players_get(server: Any, party: Party, player: Player) -> None: - await party.set_player(player) - - response = await server.get('/api/v1/party') - assert response.status == 200 - assert await response.text() == make_json(party.party) - - -async def test_players_get_with_filter(server: Any, party: Party, player: Player, player2: Player) -> None: - await party.set_player(player) - - response = await server.get('/api/v1/party', params={'nick': player.nick}) - assert response.status == 200 - assert await response.text() == make_json([player]) - - response = await server.get('/api/v1/party', params={'nick': player2.nick}) - assert response.status == 200 - assert await response.text() == make_json([player2]) - - -async def test_players_post_add(server: Any, party: Party, player: Player) -> None: - await party.remove_player(player.player_id) - - response = await server.get('/api/v1/party', params={'nick': player.nick}) - assert response.status == 200 - assert await response.text() == make_json([]) - - response = await server.post('/api/v1/party', json={ - 'action': 'add', - 'job': player.job.name, - 'nick': player.nick - }) - assert response.status == 200 - - assert player.player_id in party.players - - -async def test_players_post_remove(server: Any, party: Party, player: Player) -> None: - response = await server.get('/api/v1/party', params={'nick': player.nick}) - assert response.status == 200 - assert await response.text() == make_json([player]) - - response = await server.post('/api/v1/party', json={ - 'action': 'remove', - 'job': player.job.name, - 'nick': player.nick - }) - assert response.status == 200 - - response = await server.get('/api/v1/party', params={'nick': player.nick}) - assert response.status == 200 - assert await response.text() == make_json([]) - - assert player.player_id not in party.players - - -async def test_players_post_add_with_link(server: Any, party: Party, player: Player, - bis_link: str, bis_set: List[Piece]) -> None: - await party.remove_player(player.player_id) - - response = await server.get('/api/v1/party', params={'nick': player.nick}) - assert response.status == 200 - assert await response.text() == make_json([]) - - response = await server.post('/api/v1/party', json={ - 'action': 'add', - 'job': player.job.name, - 'nick': player.nick, - 'link': bis_link - }) - assert response.status == 200 - - assert party.players[player.player_id].bis.pieces == bis_set