initial migration to scala

This commit is contained in:
Evgenii Alekseev 2019-10-16 02:58:08 +03:00
parent 2d84459c4d
commit e9d5d8c363
145 changed files with 2227 additions and 4976 deletions

161
.gitignore vendored
View File

@ -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

27
build.sbt Normal file
View File

@ -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"

View File

@ -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)''')
]

View File

@ -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)''')
]

View File

@ -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

View File

@ -1,3 +0,0 @@
[ariyala]
ariyala_url = https://ffxiv.ariyala.com
xivapi_url = https://xivapi.com

View File

@ -1,4 +0,0 @@
[auth]
enabled = yes
root_username = admin
root_password = $1$R3j4sym6$HtvrKOJ66f7w3.9Zc3U6h1

View File

@ -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

View File

@ -1,3 +0,0 @@
[sqlite]
database_path = /home/arcanis/Documents/github/ffxivbis/ffxivbis.db
migrations_path = /home/arcanis/Documents/github/ffxivbis/migrations

1
project/assembly.sbt Normal file
View File

@ -0,0 +1 @@
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6")

1
project/build.properties Normal file
View File

@ -0,0 +1 @@
sbt.version = 1.3.2

View File

@ -1,5 +0,0 @@
[aliases]
test=pytest
[tool:pytest]
addopts = --verbose --pyargs .

View File

@ -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'],
},
)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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'
)

View File

@ -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)

View File

@ -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({})

View File

@ -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({})

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 {}

View File

@ -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)

View File

@ -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
}

View File

@ -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)

View File

@ -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
]
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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')))

View File

@ -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())

View File

@ -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}')

View File

@ -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)]

View File

@ -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)

View File

@ -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
)

View File

@ -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)
)

View File

@ -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()

View File

@ -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'

View File

@ -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()

View File

@ -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)

View File

@ -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']

View File

@ -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

View File

@ -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']

View File

@ -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

View File

@ -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<nick>.*) \((?P<job>[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

View File

@ -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']

View File

@ -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'

View File

@ -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))

View File

@ -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']

View File

@ -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);

View File

@ -0,0 +1,32 @@
me.arcanis.ffxivbis {
ariyala {
ariyala-url = "https://ffxiv.ariyala.com"
xivapi-url = "https://xivapi.com"
}
database {
mode = "sqlite"
sqlite {
profile = "slick.jdbc.SQLiteProfile$"
db {
url = "jdbc:sqlite:/home/arcanis/Documents/github/ffxivbis-scala/ffxivbis.db"
user = "user"
password = "password"
}
numThreads = 10
}
}
settings {
priority = [
"isRequired", "lootCountBiS", "priority", "lootCount", "lootCountTotal"
]
request-timeout = 10s
}
web {
host = "0.0.0.0"
port = 8000
}
}

View File

@ -1,6 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<title>ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
@ -16,9 +16,9 @@
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='/api-docs/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</head>
<body>
<redoc spec-url='/api-docs/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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(_))
}

View File

@ -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)
}

View File

@ -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) }
}

View File

@ -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)
}

View File

@ -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) }
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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) }
}

View File

@ -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
}

View File

@ -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)
}
}
}
}
}
}
}

View File

@ -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)
}
}
}
}
}
}
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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))
}
}
}
}
}
}

View File

@ -0,0 +1,5 @@
package me.arcanis.ffxivbis.http.api.v1.json
object ApiAction extends Enumeration {
val add, remove = Value
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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))
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -0,0 +1,3 @@
package me.arcanis.ffxivbis.models
case class Loot(playerId: Long, piece: Piece)

Some files were not shown because too many files have changed in this diff Show More