mirror of
https://github.com/arcan1s/ffxivbis.git
synced 2025-07-14 22:35:50 +00:00
@ -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
|
||||
|
@ -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
|
@ -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)
|
||||
|
||||
|
@ -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
|
@ -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'
|
||||
)
|
@ -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)
|
@ -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({})
|
@ -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({})
|
@ -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)
|
@ -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
|
@ -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)
|
@ -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
|
@ -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)
|
@ -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
|
@ -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
|
@ -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 {}
|
@ -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)
|
@ -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
|
||||
}
|
@ -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)
|
@ -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
|
||||
]
|
||||
}
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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
|
@ -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()
|
@ -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)
|
@ -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
|
@ -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')))
|
@ -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())
|
@ -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}')
|
@ -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)]
|
@ -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)
|
@ -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
|
||||
)
|
@ -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)
|
||||
)
|
@ -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()
|
@ -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'
|
@ -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()
|
@ -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)
|
@ -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']
|
@ -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
|
||||
|
@ -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']
|
@ -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
|
@ -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
|
@ -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']
|
@ -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'
|
@ -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))
|
@ -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']
|
36
src/main/resources/db/migration/V1_0__Create_tables.sql
Normal file
36
src/main/resources/db/migration/V1_0__Create_tables.sql
Normal 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);
|
43
src/main/resources/reference.conf
Normal file
43
src/main/resources/reference.conf
Normal file
@ -0,0 +1,43 @@
|
||||
me.arcanis.ffxivbis {
|
||||
ariyala {
|
||||
// ariyala base url, string, required
|
||||
ariyala-url = "https://ffxiv.ariyala.com"
|
||||
// xivapi base url, string, required
|
||||
xivapi-url = "https://xivapi.com"
|
||||
// xivapi developer key, string, optional
|
||||
# xivapi-key = "abc-def"
|
||||
}
|
||||
|
||||
database {
|
||||
// database section. Section must be declared inside
|
||||
// for more detailed section descriptions refer to slick documentation
|
||||
mode = "sqlite"
|
||||
|
||||
sqlite {
|
||||
profile = "slick.jdbc.SQLiteProfile$"
|
||||
db {
|
||||
url = "jdbc:sqlite:ffxivbis.db"
|
||||
user = "user"
|
||||
password = "password"
|
||||
}
|
||||
numThreads = 10
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
// counters of Player class which will be called to sort players for loot priority
|
||||
// list of strings, required
|
||||
priority = [
|
||||
"isRequired", "lootCountBiS", "priority", "lootCount", "lootCountTotal"
|
||||
]
|
||||
// general request timeout, duratin, required
|
||||
request-timeout = 10s
|
||||
}
|
||||
|
||||
web {
|
||||
// address to bind, string, required
|
||||
host = "0.0.0.0"
|
||||
// port to bind, int, required
|
||||
port = 8000
|
||||
}
|
||||
}
|
24
src/main/resources/swagger/index.html
Normal file
24
src/main/resources/swagger/index.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>ReDoc</title>
|
||||
<!-- needed for adaptive design -->
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
|
||||
<!--
|
||||
ReDoc doesn't change outer page styles
|
||||
-->
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
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>
|
||||
</html>
|
43
src/main/scala/me/arcanis/ffxivbis/Application.scala
Normal file
43
src/main/scala/me/arcanis/ffxivbis/Application.scala
Normal 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)
|
||||
}
|
12
src/main/scala/me/arcanis/ffxivbis/ffxivbis.scala
Normal file
12
src/main/scala/me/arcanis/ffxivbis/ffxivbis.scala
Normal 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")
|
||||
}
|
||||
}
|
16
src/main/scala/me/arcanis/ffxivbis/http/AriyalaHelper.scala
Normal file
16
src/main/scala/me/arcanis/ffxivbis/http/AriyalaHelper.scala
Normal 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(_))
|
||||
}
|
53
src/main/scala/me/arcanis/ffxivbis/http/Authorization.scala
Normal file
53
src/main/scala/me/arcanis/ffxivbis/http/Authorization.scala
Normal 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)
|
||||
}
|
29
src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala
Normal file
29
src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala
Normal 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) }
|
||||
|
||||
}
|
29
src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala
Normal file
29
src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala
Normal 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)
|
||||
}
|
37
src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala
Normal file
37
src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala
Normal 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) }
|
||||
}
|
45
src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala
Normal file
45
src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala
Normal 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")
|
||||
}
|
24
src/main/scala/me/arcanis/ffxivbis/http/Swagger.scala
Normal file
24
src/main/scala/me/arcanis/ffxivbis/http/Swagger.scala
Normal 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")
|
||||
}
|
28
src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala
Normal file
28
src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala
Normal 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) }
|
||||
}
|
@ -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
|
||||
}
|
132
src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala
Normal file
132
src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package me.arcanis.ffxivbis.http.api.v1.json
|
||||
|
||||
object ApiAction extends Enumeration {
|
||||
val add, remove = Value
|
||||
}
|
@ -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)
|
||||
}
|
@ -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)
|
@ -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)
|
||||
}
|
@ -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)
|
@ -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)
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
@ -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))
|
||||
}
|
@ -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))
|
||||
}
|
72
src/main/scala/me/arcanis/ffxivbis/models/BiS.scala
Normal file
72
src/main/scala/me/arcanis/ffxivbis/models/BiS.scala
Normal 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)
|
||||
}
|
65
src/main/scala/me/arcanis/ffxivbis/models/Job.scala
Normal file
65
src/main/scala/me/arcanis/ffxivbis/models/Job.scala
Normal 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
|
||||
}
|
||||
}
|
3
src/main/scala/me/arcanis/ffxivbis/models/Loot.scala
Normal file
3
src/main/scala/me/arcanis/ffxivbis/models/Loot.scala
Normal file
@ -0,0 +1,3 @@
|
||||
package me.arcanis.ffxivbis.models
|
||||
|
||||
case class Loot(playerId: Long, piece: Piece)
|
97
src/main/scala/me/arcanis/ffxivbis/models/Piece.scala
Normal file
97
src/main/scala/me/arcanis/ffxivbis/models/Piece.scala
Normal file
@ -0,0 +1,97 @@
|
||||
package me.arcanis.ffxivbis.models
|
||||
|
||||
trait Piece {
|
||||
def isTome: Boolean
|
||||
def job: Job.Job
|
||||
def piece: String
|
||||
|
||||
def upgrade: Option[PieceUpgrade] = this match {
|
||||
case _ if !isTome => None
|
||||
case _: Waist => Some(AccessoryUpgrade)
|
||||
case _: PieceAccessory => Some(AccessoryUpgrade)
|
||||
case _: PieceBody => Some(BodyUpgrade)
|
||||
case _: PieceWeapon => Some(WeaponUpgrade)
|
||||
case _ => None
|
||||
}
|
||||
}
|
||||
|
||||
trait PieceAccessory extends Piece
|
||||
trait PieceBody extends Piece
|
||||
trait PieceUpgrade extends Piece {
|
||||
val isTome: Boolean = true
|
||||
val job: Job.Job = Job.AnyJob
|
||||
}
|
||||
trait PieceWeapon extends Piece
|
||||
|
||||
case class Weapon(override val isTome: Boolean, override val job: Job.Job) extends PieceWeapon {
|
||||
val piece: String = "weapon"
|
||||
}
|
||||
|
||||
case class Head(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
|
||||
val piece: String = "head"
|
||||
}
|
||||
case class Body(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
|
||||
val piece: String = "body"
|
||||
}
|
||||
case class Hands(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
|
||||
val piece: String = "hands"
|
||||
}
|
||||
case class Waist(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
|
||||
val piece: String = "waist"
|
||||
}
|
||||
case class Legs(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
|
||||
val piece: String = "legs"
|
||||
}
|
||||
case class Feet(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
|
||||
val piece: String = "feet"
|
||||
}
|
||||
|
||||
case class Ears(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory {
|
||||
val piece: String = "ears"
|
||||
}
|
||||
case class Neck(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory {
|
||||
val piece: String = "neck"
|
||||
}
|
||||
case class Wrist(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory {
|
||||
val piece: String = "wrist"
|
||||
}
|
||||
case class Ring(override val isTome: Boolean, override val job: Job.Job, override val piece: String = "ring")
|
||||
extends PieceAccessory {
|
||||
override def equals(obj: Any): Boolean = obj match {
|
||||
case Ring(thatIsTome, thatJob, _) => (thatIsTome == isTome) && (thatJob == job)
|
||||
case _ => false
|
||||
}
|
||||
}
|
||||
|
||||
case object AccessoryUpgrade extends PieceUpgrade {
|
||||
val piece: String = "accessory upgrade"
|
||||
}
|
||||
case object BodyUpgrade extends PieceUpgrade {
|
||||
val piece: String = "body upgrade"
|
||||
}
|
||||
case object WeaponUpgrade extends PieceUpgrade {
|
||||
val piece: String = "weapon upgrade"
|
||||
}
|
||||
|
||||
object Piece {
|
||||
def apply(piece: String, isTome: Boolean, job: Job.Job = Job.AnyJob): Piece =
|
||||
piece.toLowerCase match {
|
||||
case "weapon" => Weapon(isTome, job)
|
||||
case "head" => Head(isTome, job)
|
||||
case "body" => Body(isTome, job)
|
||||
case "hands" => Hands(isTome, job)
|
||||
case "waist" => Waist(isTome, job)
|
||||
case "legs" => Legs(isTome, job)
|
||||
case "feet" => Feet(isTome, job)
|
||||
case "ears" => Ears(isTome, job)
|
||||
case "neck" => Neck(isTome, job)
|
||||
case "wrist" => Wrist(isTome, job)
|
||||
case "ring" => Ring(isTome, job)
|
||||
case "leftring" => Ring(isTome, job).copy(piece = "leftRing")
|
||||
case "rightring" => Ring(isTome, job).copy(piece = "rightRing")
|
||||
case "accessory upgrade" => AccessoryUpgrade
|
||||
case "body upgrade" => BodyUpgrade
|
||||
case "weapon upgrade" => WeaponUpgrade
|
||||
case other => throw new Error(s"Unknown item type $other")
|
||||
}
|
||||
}
|
44
src/main/scala/me/arcanis/ffxivbis/models/Player.scala
Normal file
44
src/main/scala/me/arcanis/ffxivbis/models/Player.scala
Normal file
@ -0,0 +1,44 @@
|
||||
package me.arcanis.ffxivbis.models
|
||||
|
||||
case class Player(partyId: String,
|
||||
job: Job.Job,
|
||||
nick: String,
|
||||
bis: BiS,
|
||||
loot: Seq[Piece],
|
||||
link: Option[String] = None,
|
||||
priority: Int = 0) {
|
||||
require(job != Job.AnyJob, "AnyJob is not allowed")
|
||||
|
||||
val playerId: PlayerId = PlayerId(partyId, job, nick)
|
||||
def withBiS(set: Option[BiS]): Player = set match {
|
||||
case Some(value) => copy(bis = value)
|
||||
case None => this
|
||||
}
|
||||
def withCounters(piece: Option[Piece]): PlayerIdWithCounters =
|
||||
PlayerIdWithCounters(
|
||||
partyId, job, nick, isRequired(piece), priority,
|
||||
bisCountTotal(piece), lootCount(piece),
|
||||
lootCountBiS(piece), lootCountTotal(piece))
|
||||
def withLoot(list: Option[Seq[Piece]]): Player = list match {
|
||||
case Some(value) => copy(loot = value)
|
||||
case None => this
|
||||
}
|
||||
|
||||
def isRequired(piece: Option[Piece]): Boolean = {
|
||||
piece match {
|
||||
case None => false
|
||||
case Some(p) if !bis.hasPiece(p) => false
|
||||
case Some(p: PieceUpgrade) => bis.upgrades(p) > lootCount(piece)
|
||||
case Some(_) => lootCount(piece) == 0
|
||||
}
|
||||
}
|
||||
|
||||
def bisCountTotal(piece: Option[Piece]): Int = bis.pieces.count(!_.isTome)
|
||||
def lootCount(piece: Option[Piece]): Int = piece match {
|
||||
case Some(p) => loot.count(_ == p)
|
||||
case None => lootCountTotal(piece)
|
||||
}
|
||||
def lootCountBiS(piece: Option[Piece]): Int = loot.count(bis.hasPiece)
|
||||
def lootCountTotal(piece: Option[Piece]): Int = loot.length
|
||||
def lootPriority(piece: Piece): Int = priority
|
||||
}
|
18
src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala
Normal file
18
src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala
Normal file
@ -0,0 +1,18 @@
|
||||
package me.arcanis.ffxivbis.models
|
||||
|
||||
trait PlayerIdBase {
|
||||
def job: Job.Job
|
||||
def nick: String
|
||||
|
||||
override def toString: String = s"$nick ($job)"
|
||||
}
|
||||
|
||||
case class PlayerId(partyId: String, job: Job.Job, nick: String) extends PlayerIdBase
|
||||
|
||||
object PlayerId {
|
||||
def apply(partyId: String, maybeNick: Option[String], maybeJob: Option[String]): Option[PlayerId] =
|
||||
(maybeNick, maybeJob) match {
|
||||
case (Some(nick), Some(job)) => Some(PlayerId(partyId, Job.fromString(job), nick))
|
||||
case _ => None
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
package me.arcanis.ffxivbis.models
|
||||
|
||||
case class PlayerIdWithCounters(partyId: String,
|
||||
job: Job.Job,
|
||||
nick: String,
|
||||
isRequired: Boolean,
|
||||
priority: Int,
|
||||
bisCountTotal: Int,
|
||||
lootCount: Int,
|
||||
lootCountBiS: Int,
|
||||
lootCountTotal: Int)
|
||||
extends PlayerIdBase {
|
||||
import PlayerIdWithCounters._
|
||||
|
||||
def playerId: PlayerId = PlayerId(partyId, job, nick)
|
||||
|
||||
def gt(that: PlayerIdWithCounters, orderBy: Seq[String]): Boolean =
|
||||
withCounters(orderBy) > that.withCounters(orderBy)
|
||||
|
||||
private val counters: Map[String, Int] = Map(
|
||||
"isRequired" -> (if (isRequired) 1 else 0),
|
||||
"priority" -> priority,
|
||||
"bisCountTotal" -> bisCountTotal,
|
||||
"lootCount" -> lootCount,
|
||||
"lootCountBiS" -> lootCountBiS,
|
||||
"lootCountTotal" -> lootCountTotal) withDefaultValue 0
|
||||
|
||||
private def withCounters(orderBy: Seq[String]): PlayerCountersComparator =
|
||||
PlayerCountersComparator(orderBy.map(counters): _*)
|
||||
}
|
||||
|
||||
object PlayerIdWithCounters {
|
||||
private case class PlayerCountersComparator(values: Int*) {
|
||||
def >(that: PlayerCountersComparator): Boolean = {
|
||||
@scala.annotation.tailrec
|
||||
def compareLists(left: List[Int], right: List[Int]): Boolean =
|
||||
(left, right) match {
|
||||
case (hl :: tl, hr :: tr) => if (hl == hr) compareLists(tl, tr) else hl > hr
|
||||
case (_ :: _, Nil) => true
|
||||
case (_, _) => false
|
||||
}
|
||||
compareLists(values.toList, that.values.toList)
|
||||
}
|
||||
}
|
||||
}
|
17
src/main/scala/me/arcanis/ffxivbis/models/User.scala
Normal file
17
src/main/scala/me/arcanis/ffxivbis/models/User.scala
Normal file
@ -0,0 +1,17 @@
|
||||
package me.arcanis.ffxivbis.models
|
||||
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
object Permission extends Enumeration {
|
||||
val get, post, admin = Value
|
||||
}
|
||||
|
||||
case class User(partyId: String,
|
||||
username: String,
|
||||
password: String,
|
||||
permission: Permission.Value) {
|
||||
|
||||
def hash: String = BCrypt.hashpw(password, BCrypt.gensalt)
|
||||
def verify(plain: String): Boolean = BCrypt.checkpw(plain, password)
|
||||
def verityScope(scope: Permission.Value): Boolean = permission >= scope
|
||||
}
|
117
src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala
Normal file
117
src/main/scala/me/arcanis/ffxivbis/service/Ariyala.scala
Normal file
@ -0,0 +1,117 @@
|
||||
package me.arcanis.ffxivbis.service
|
||||
|
||||
import java.nio.file.Paths
|
||||
|
||||
import akka.actor.{Actor, Props}
|
||||
import akka.http.scaladsl.model._
|
||||
import akka.http.scaladsl.Http
|
||||
import akka.pattern.pipe
|
||||
import akka.stream.ActorMaterializer
|
||||
import akka.stream.scaladsl.{Keep, Sink}
|
||||
import akka.util.ByteString
|
||||
import com.typesafe.scalalogging.StrictLogging
|
||||
import me.arcanis.ffxivbis.models.{Job, Piece}
|
||||
import spray.json._
|
||||
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
import scala.util.Try
|
||||
|
||||
class Ariyala extends Actor with StrictLogging {
|
||||
import Ariyala._
|
||||
|
||||
private val settings = context.system.settings.config
|
||||
private val ariyalaUrl = settings.getString("me.arcanis.ffxivbis.ariyala.ariyala-url")
|
||||
private val xivapiUrl = settings.getString("me.arcanis.ffxivbis.ariyala.xivapi-url")
|
||||
private val xivapiKey = Try(settings.getString("me.arcanis.ffxivbis.ariyala.xivapi-key")).toOption
|
||||
|
||||
private val http = Http()(context.system)
|
||||
private implicit val materializer: ActorMaterializer = ActorMaterializer()
|
||||
private implicit val executionContext: ExecutionContext = context.dispatcher
|
||||
|
||||
override def receive: Receive = {
|
||||
case GetBiS(link, job) =>
|
||||
val client = sender()
|
||||
get(link, job).pipeTo(client)
|
||||
}
|
||||
|
||||
private def get(link: String, job: Job.Job): Future[Seq[Piece]] = {
|
||||
val id = Paths.get(link).normalize.getFileName.toString
|
||||
val uri = Uri(ariyalaUrl)
|
||||
.withPath(Uri.Path / "store.app")
|
||||
.withQuery(Uri.Query(Map("identifier" -> id)))
|
||||
|
||||
sendRequest(uri, Ariyala.parseAriyalaJsonToPieces(job, getIsTome))
|
||||
}
|
||||
|
||||
private def getIsTome(itemId: Long): Future[Boolean] = {
|
||||
val uri = Try(Uri(xivapiUrl)
|
||||
.withPath(Uri.Path / "item" / itemId.toString)
|
||||
.withQuery(Uri.Query(Map("columns" -> "IsEquippable", "private_key" -> xivapiKey.getOrElse("")))))
|
||||
|
||||
sendRequest(uri.toOption.get, Ariyala.parseXivapiJson)
|
||||
}
|
||||
|
||||
private def sendRequest[T](uri: Uri, parser: JsObject => Future[T]): Future[T] =
|
||||
http.singleRequest(HttpRequest(uri = uri)).map {
|
||||
case HttpResponse(status, _, entity, _) if status.isSuccess() =>
|
||||
entity.dataBytes
|
||||
.fold(ByteString.empty)(_ ++ _)
|
||||
.map(_.utf8String)
|
||||
.map(result => parser(result.parseJson.asJsObject))
|
||||
.toMat(Sink.head)(Keep.right)
|
||||
.run().flatten
|
||||
case _ => Future.failed(deserializationError("Invalid response from server"))
|
||||
}.flatten
|
||||
}
|
||||
|
||||
object Ariyala {
|
||||
def props: Props = Props(new Ariyala)
|
||||
|
||||
case class GetBiS(link: String, job: Job.Job)
|
||||
|
||||
private def parseAriyalaJson(job: Job.Job)(js: JsObject): Future[Map[String, Long]] = {
|
||||
try {
|
||||
val apiJob = js.fields.get("content") match {
|
||||
case Some(JsString(value)) => value
|
||||
case other => throw deserializationError(s"Invalid job name $other")
|
||||
}
|
||||
Future.successful(js.fields.get("datasets") match {
|
||||
case Some(datasets: JsObject) =>
|
||||
val fields = datasets.fields
|
||||
fields.getOrElse(apiJob, fields(job.toString)).asJsObject
|
||||
.fields("normal").asJsObject
|
||||
.fields("items").asJsObject
|
||||
.fields.foldLeft(Map.empty[String, Long]) {
|
||||
case (acc, (key, JsNumber(id))) => remapKey(key).map(k => acc + (k -> id.toLong)).getOrElse(acc)
|
||||
case (acc, _) => acc
|
||||
}
|
||||
case other => throw deserializationError(s"Invalid json $other")
|
||||
})
|
||||
} catch {
|
||||
case e: Exception => Future.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
private def parseAriyalaJsonToPieces(job: Job.Job, isTome: Long => Future[Boolean])(js: JsObject)
|
||||
(implicit executionContext: ExecutionContext): Future[Seq[Piece]] =
|
||||
parseAriyalaJson(job)(js).map { pieces =>
|
||||
Future.sequence(pieces.toSeq.map {
|
||||
case (itemName, itemId) => isTome(itemId).map(Piece(itemName, _, job))
|
||||
})
|
||||
}.flatten
|
||||
|
||||
private def parseXivapiJson(js: JsObject): Future[Boolean] =
|
||||
js.fields.get("IsEquippable") match {
|
||||
case Some(JsNumber(value)) => Future.successful(value == 0) // don't ask
|
||||
case other => Future.failed(deserializationError(s"Could not parse $other"))
|
||||
}
|
||||
|
||||
private def remapKey(key: String): Option[String] = key match {
|
||||
case "mainhand" => Some("weapon")
|
||||
case "chest" => Some("body")
|
||||
case "ringLeft" => Some("leftRing")
|
||||
case "ringRight" => Some("rightRing")
|
||||
case "head" | "hands" | "waist" | "legs" | "feet" | "ears" | "neck" | "wrist" => Some(key)
|
||||
case _ => None
|
||||
}
|
||||
}
|
26
src/main/scala/me/arcanis/ffxivbis/service/Database.scala
Normal file
26
src/main/scala/me/arcanis/ffxivbis/service/Database.scala
Normal file
@ -0,0 +1,26 @@
|
||||
package me.arcanis.ffxivbis.service
|
||||
|
||||
import akka.actor.Actor
|
||||
import com.typesafe.scalalogging.StrictLogging
|
||||
import me.arcanis.ffxivbis.models.{Player, PlayerId}
|
||||
import me.arcanis.ffxivbis.storage.DatabaseProfile
|
||||
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
|
||||
trait Database extends Actor with StrictLogging {
|
||||
implicit def executionContext: ExecutionContext
|
||||
def profile: DatabaseProfile
|
||||
|
||||
def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] =
|
||||
(party, maybePlayerId) match {
|
||||
case (_, Some(playerId)) => party.player(playerId).map(Seq(_)).getOrElse(Seq.empty)
|
||||
case (_, _) => party.getPlayers
|
||||
}
|
||||
|
||||
def getParty(partyId: String, withBiS: Boolean, withLoot: Boolean): Future[Party] =
|
||||
for {
|
||||
players <- profile.getParty(partyId)
|
||||
bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future(Seq.empty)
|
||||
loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty)
|
||||
} yield Party(partyId, context.system.settings.config, players, bis, loot)
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package me.arcanis.ffxivbis.service
|
||||
|
||||
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerIdWithCounters}
|
||||
|
||||
class LootSelector(players: Seq[Player], piece: Piece, orderBy: Seq[String]) {
|
||||
|
||||
val counters: Seq[PlayerIdWithCounters] = players.map(_.withCounters(Some(piece)))
|
||||
|
||||
def suggest: LootSelector.LootSelectorResult =
|
||||
LootSelector.LootSelectorResult {
|
||||
counters.sortWith { case (left, right) => left.gt(right, orderBy) }
|
||||
}
|
||||
}
|
||||
|
||||
object LootSelector {
|
||||
def apply(players: Seq[Player], piece: Piece, orderBy: Seq[String]): LootSelectorResult =
|
||||
new LootSelector(players, piece, orderBy).suggest
|
||||
|
||||
case class LootSelectorResult(result: Seq[PlayerIdWithCounters])
|
||||
}
|
48
src/main/scala/me/arcanis/ffxivbis/service/Party.scala
Normal file
48
src/main/scala/me/arcanis/ffxivbis/service/Party.scala
Normal file
@ -0,0 +1,48 @@
|
||||
package me.arcanis.ffxivbis.service
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.scalalogging.StrictLogging
|
||||
import me.arcanis.ffxivbis.models.{BiS, Loot, Piece, Player, PlayerId}
|
||||
|
||||
import scala.jdk.CollectionConverters._
|
||||
import scala.util.Random
|
||||
|
||||
case class Party(partyId: String, config: Config, players: Map[PlayerId, Player])
|
||||
extends StrictLogging {
|
||||
|
||||
private val rules =
|
||||
config.getStringList("me.arcanis.ffxivbis.settings.priority").asScala.toSeq
|
||||
|
||||
def getPlayers: Seq[Player] = players.values.toSeq
|
||||
def player(playerId: PlayerId): Option[Player] = players.get(playerId)
|
||||
def withPlayer(player: Player): Party =
|
||||
try {
|
||||
require(player.partyId == partyId, "player must belong to this party")
|
||||
copy(players = players + (player.playerId -> player))
|
||||
} catch {
|
||||
case exception: Exception =>
|
||||
logger.error("cannot add player", exception)
|
||||
this
|
||||
}
|
||||
|
||||
def suggestLoot(piece: Piece): LootSelector.LootSelectorResult =
|
||||
LootSelector(getPlayers, piece, rules)
|
||||
}
|
||||
|
||||
object Party {
|
||||
def apply(partyId: Option[String], config: Config): Party =
|
||||
new Party(partyId.getOrElse(Random.alphanumeric.take(20).mkString), config, Map.empty)
|
||||
|
||||
def apply(partyId: String, config: Config,
|
||||
players: Map[Long, Player], bis: Seq[Loot], loot: Seq[Loot]): Party = {
|
||||
val bisByPlayer = bis.groupBy(_.playerId).view.mapValues(piece => BiS(piece.map(_.piece)))
|
||||
val lootByPlayer = loot.groupBy(_.playerId).view.mapValues(_.map(_.piece))
|
||||
val playersWithItems = players.foldLeft(Map.empty[PlayerId, Player]) {
|
||||
case (acc, (playerId, player)) =>
|
||||
acc + (player.playerId -> player
|
||||
.withBiS(bisByPlayer.get(playerId))
|
||||
.withLoot(lootByPlayer.get(playerId)))
|
||||
}
|
||||
Party(partyId, config, playersWithItems)
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package me.arcanis.ffxivbis.service.impl
|
||||
|
||||
import akka.pattern.pipe
|
||||
import me.arcanis.ffxivbis.models.{BiS, Piece, PlayerId}
|
||||
import me.arcanis.ffxivbis.service.Database
|
||||
|
||||
trait DatabaseBiSHandler { this: Database =>
|
||||
import DatabaseBiSHandler._
|
||||
|
||||
def bisHandler: Receive = {
|
||||
case AddPieceToBis(playerId, piece) =>
|
||||
profile.insertPieceBiS(playerId, piece)
|
||||
|
||||
case GetBiS(partyId, maybePlayerId) =>
|
||||
val client = sender()
|
||||
getParty(partyId, withBiS = true, withLoot = false)
|
||||
.map(filterParty(_, maybePlayerId))
|
||||
.pipeTo(client)
|
||||
|
||||
case RemovePieceFromBiS(playerId, piece) =>
|
||||
profile.deletePieceBiS(playerId, piece)
|
||||
}
|
||||
}
|
||||
|
||||
object DatabaseBiSHandler {
|
||||
case class AddPieceToBis(playerId: PlayerId, piece: Piece)
|
||||
case class GetBiS(partyId: String, playerId: Option[PlayerId])
|
||||
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece)
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package me.arcanis.ffxivbis.service.impl
|
||||
|
||||
import akka.actor.Props
|
||||
import me.arcanis.ffxivbis.service.Database
|
||||
import me.arcanis.ffxivbis.storage.DatabaseProfile
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
class DatabaseImpl extends Database
|
||||
with DatabaseBiSHandler with DatabaseLootHandler
|
||||
with DatabasePartyHandler with DatabaseUserHandler {
|
||||
|
||||
implicit val executionContext: ExecutionContext = context.dispatcher
|
||||
val profile = new DatabaseProfile(executionContext, context.system.settings.config)
|
||||
|
||||
override def receive: Receive =
|
||||
bisHandler orElse lootHandler orElse partyHandler orElse userHandler
|
||||
}
|
||||
|
||||
object DatabaseImpl {
|
||||
def props: Props = Props(new DatabaseImpl)
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package me.arcanis.ffxivbis.service.impl
|
||||
|
||||
import akka.pattern.pipe
|
||||
import me.arcanis.ffxivbis.models.{Piece, PlayerId}
|
||||
import me.arcanis.ffxivbis.service.Database
|
||||
|
||||
trait DatabaseLootHandler { this: Database =>
|
||||
import DatabaseLootHandler._
|
||||
|
||||
def lootHandler: Receive = {
|
||||
case AddPieceTo(playerId, piece) =>
|
||||
profile.insertPiece(playerId, piece)
|
||||
|
||||
case GetLoot(partyId, maybePlayerId) =>
|
||||
val client = sender()
|
||||
getParty(partyId, withBiS = false, withLoot = true)
|
||||
.map(filterParty(_, maybePlayerId))
|
||||
.pipeTo(client)
|
||||
|
||||
case RemovePieceFrom(playerId, piece) =>
|
||||
profile.deletePiece(playerId, piece)
|
||||
|
||||
case SuggestLoot(partyId, piece) =>
|
||||
val client = sender()
|
||||
getParty(partyId, withBiS = true, withLoot = true).map(_.suggestLoot(piece)).pipeTo(client)
|
||||
}
|
||||
}
|
||||
|
||||
object DatabaseLootHandler {
|
||||
case class AddPieceTo(playerId: PlayerId, piece: Piece)
|
||||
case class GetLoot(partyId: String, playerId: Option[PlayerId])
|
||||
case class RemovePieceFrom(playerId: PlayerId, piece: Piece)
|
||||
case class SuggestLoot(partyId: String, piece: Piece)
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package me.arcanis.ffxivbis.service.impl
|
||||
|
||||
import akka.actor.Actor
|
||||
import akka.pattern.pipe
|
||||
import com.typesafe.scalalogging.StrictLogging
|
||||
import me.arcanis.ffxivbis.models.{BiS, Player, PlayerId}
|
||||
import me.arcanis.ffxivbis.service.Database
|
||||
|
||||
trait DatabasePartyHandler { this: Actor with StrictLogging with Database =>
|
||||
import DatabasePartyHandler._
|
||||
|
||||
def partyHandler: Receive = {
|
||||
case AddPlayer(player) =>
|
||||
profile.insertPlayer(player)
|
||||
|
||||
case GetParty(partyId) =>
|
||||
val client = sender()
|
||||
getParty(partyId, withBiS = true, withLoot = true).pipeTo(client)
|
||||
|
||||
case GetPlayer(playerId) =>
|
||||
val client = sender()
|
||||
val player = for {
|
||||
bis <- profile.getPiecesBiS(playerId)
|
||||
loot <- profile.getPieces(playerId)
|
||||
} yield Player(playerId.partyId, playerId.job, playerId.nick,
|
||||
BiS(bis.map(_.piece)), loot.map(_.piece))
|
||||
player.pipeTo(client)
|
||||
|
||||
case RemovePlayer(playerId) =>
|
||||
profile.deletePlayer(playerId)
|
||||
}
|
||||
}
|
||||
|
||||
object DatabasePartyHandler {
|
||||
case class AddPlayer(player: Player)
|
||||
case class GetParty(partyId: String)
|
||||
case class GetPlayer(playerId: PlayerId)
|
||||
case class RemovePlayer(playerId: PlayerId)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user