1 Commits

Author SHA1 Message Date
4ff985bf81 some party impl 2019-10-16 02:48:55 +03:00
237 changed files with 5196 additions and 10201 deletions

View File

@ -1,40 +0,0 @@
name: release
on:
push:
tags:
- '*.*.*'
jobs:
make-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: extract version
id: version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: create changelog
id: changelog
uses: jaywcjlove/changelog-generator@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
filter: 'Release \d+\.\d+\.\d+'
- name: setup JDK
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 17
- name: create dist
run: make dist
- name: release
uses: softprops/action-gh-release@v1
with:
body: |
${{ steps.changelog.outputs.compareurl }}
${{ steps.changelog.outputs.changelog }}
files: target/universal/ffxivbis-*.zip
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,22 +0,0 @@
name: tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: setup JDK
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 17
- name: run tests
run: make tests

163
.gitignore vendored
View File

@ -1,89 +1,96 @@
#### joe made this: http://goel.io/joe
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
#### jetbrains ####
# C extensions
*.so
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
parts/
sdist/
*.egg-info/
.installed.cfg
*.egg
# User-specific stuff:
.idea
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
## File-based project format:
*.iws
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
## Plugin-specific files:
# IntelliJ
/out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
#### gradle ####
.gradle
/build/
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Cache of project
.gradletasknamecache
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
# gradle/wrapper/gradle-wrapper.properties
#### java ####
*.class
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.ear
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
#### scala ####
*.class
*.log
# sbt specific
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
.history
.lib/
dist/*
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log*
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
lib_managed/
src_managed/
project/boot/
project/plugins/project/
.bsp/
# Scala-IDE specific
.scala_dependencies
.worksheet
# IPython Notebook
.ipynb_checkpoints
# ENSIME specific
.ensime_cache/
.ensime
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
*.deb
.idea/
.mypy_cache/
/cache
*.db
*.sc

View File

@ -1,35 +0,0 @@
version = 3.3.1
runner.dialect = "scala213"
maxColumn = 120
align.preset = none
continuationIndent {
defnSite = 2
extendSite = 2
}
rewrite {
rules = [
AvoidInfix,
RedundantBraces,
RedundantParens,
SortImports,
SortModifiers
]
redundantBraces {
generalExpressions = yes
ifElseExpressions = yes
includeUnitMethods = yes
methodBodies = yes
parensForOneLineApply = yes
stringInterpolation = yes
}
}
importSelectors = singleLine
trailingCommas = preserve

View File

@ -1,35 +0,0 @@
.PHONY: check clean compile dist push tests version
.DEFAULT_GOAL := compile
PROJECT := ffxivbis
check:
sbt scalafmtCheck
clean:
sbt clean
compile: clean
sbt compile
format:
sbt scalafmt
dist: tests
sbt dist
push: version dist
git add version.sbt
git commit -m "Release $(VERSION)"
git tag "$(VERSION)"
git push
git push --tags
tests: compile check
sbt test
version:
ifndef VERSION
$(error VERSION is required, but not set)
endif
sed -i '/version := "[0-9.]*/s/[^"][^)]*/version := "$(VERSION)"/' version.sbt

View File

@ -1,24 +1,34 @@
# FFXIV BiS
[![Build status](https://github.com/arcan1s/ffxivbis/actions/workflows/run-tests.yml/badge.svg)](https://github.com/arcan1s/ffxivbis/actions/workflows/run-tests.yml) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/arcan1s/ffxivbis)
Service which allows managing savage loot distribution easy.
Service which allows to manage savage loot distribution easy.
## Installation and usage
In general compilation process looks like:
This service requires python >= 3.7. For other dependencies see `setup.py`.
In general installation process looks like:
```bash
sbt dist
python setup.py build install
python setup.py test # if you want to run tests
```
Or alternatively you can download the latest distribution zip from the releases page. Service can be run by using command:
With virtualenv (make sure that virtualenv package was installed) the process may look like:
```bash
bin/ffxivbis
virtualenv -p python3.7 env
source env/bin/activate
python setup.py install
pip install aiosqlite # setup.py does not handle extras
```
from the extracted archive root.
Service can be run by using command (if you don't use virtualenv, you have to run it from `src` directory):
```bash
python -m ffxivbis.application.application
```
To see all available options type `--help`.
## Web service
@ -26,6 +36,66 @@ REST API documentation is available at `http://0.0.0.0:8000/api-docs`. HTML repr
*Note*: host and port depend on configuration settings.
## Public service
### Authorization
There is also public service which is available at https://ffxivbis.arcanis.me.
Default admin user is `admin:qwerty`, but it may be changed by generating new hash, e.g.:
```python
from passlib.hash import md5_crypt
md5_crypt.hash('newstrongpassword')
```
and add new password to configuration.
## Configuration
* `settings` section
General project settings.
* `include`: path to include configuration directory, string, optional.
* `logging`: path to logging configuration, see `logging.ini` for reference, string, optional.
* `database`: database provide name, string, required. Allowed values: `sqlite`, `postgres`.
* `priority`: methods of `Player` class which will be called to sort players for loot priority, space separated list of strings, required.
* `ariyala` section
Settings related to ariyala parser.
* `ariyala_url`: ariyala base url, string, required.
* `xivapi_key`: xivapi developer key, string, optional.
* `xivapi_url`: xivapi base url, string, required.
* `auth` section
Authentication settings.
* `enabled`: whether authentication enabled or not, boolean, required.
* `root_username`: username of administrator, string, required.
* `root_password`: md5 hashed password of administrator, string, required.
* `postgres` section
Database settings for `postgres` provider.
* `database`: database name, string, required.
* `host`: database host, string, required.
* `password`: database password, string, required.
* `port`: database port, int, required.
* `username`: database username, string, required.
* `migrations_path`: path to database migrations, string, required.
* `sqlite` section
Database settings for `sqlite` provider.
* `database_path`: path to sqlite database, string, required.
* `migrations_path`: path to database migrations, string, required.
* `web` section
Web server related settings.
* `host`: address to bind, string, required.
* `port`: port to bind, int, required.
* `templates`: path to directory with jinja templates, string, required.

View File

@ -1,3 +1,3 @@
* [x] items improvements
* [x] multiple parties support
* [ ] items improvements
* [ ] multiple parties support
* [ ] pretty UI

View File

@ -1,9 +0,0 @@
organization := "me.arcanis"
name := "ffxivbis"
scalaVersion := "2.13.6"
scalacOptions ++= Seq("-deprecation", "-feature")
enablePlugins(JavaAppPackaging)

View File

@ -1,56 +0,0 @@
import json
import requests
# NOTE: it does not cover all items, just workaround to extract most gear pieces from patches
MIN_ILVL = 580
MAX_ILVL = 605
TOME = (
'radiant',
)
SAVAGE = (
'asphodelos',
)
payload = {
'queries': [
{
'slots': []
},
{
'jobs': [],
'minItemLevel': 580,
'maxItemLevel': 605
}
],
'existing': []
}
# it does not support application/json
r = requests.post('https://ffxiv.ariyala.com/items.app', data=json.dumps(payload))
r.raise_for_status()
result = []
for item in r.json():
item_id = item['itemID']
source_dict = item['source']
name = item['name']['en']
if 'crafting' in source_dict:
source = 'Crafted'
elif 'gathering' in source_dict:
continue # some random shit
elif 'purchase' in source_dict:
if any(tome in name.lower() for tome in TOME):
source = 'Tome'
elif any(savage in name.lower() for savage in SAVAGE):
source = 'Savage'
else:
source = None
continue
else:
raise RuntimeError(f'Unknown source {source_dict}')
result.append({'id': item_id, 'source': source, 'name': name})
output = {'cached-items': result}
print(json.dumps(output, indent=4, sort_keys=True))

View File

@ -1,34 +0,0 @@
val AkkaVersion = "2.6.18"
val AkkaHttpVersion = "10.2.7"
val ScalaTestVersion = "3.2.10"
val SlickVersion = "3.3.3"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.10"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4"
libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion
libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % AkkaHttpVersion
libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion
libraryDependencies += "com.typesafe.akka" %% "akka-stream" % AkkaVersion
libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.6.0"
libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "9.1.0"
libraryDependencies += "ch.megard" %% "akka-http-cors" % "1.1.2"
libraryDependencies += "io.spray" %% "spray-json" % "1.3.6"
libraryDependencies += "org.playframework.anorm" %% "anorm" % "2.6.10"
libraryDependencies += "com.zaxxer" % "HikariCP" % "5.0.1" exclude("org.slf4j", "slf4j-api")
libraryDependencies += "org.flywaydb" % "flyway-core" % "8.4.1"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.36.0.3"
libraryDependencies += "org.postgresql" % "postgresql" % "42.3.1"
libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4"
libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre"
// testing
libraryDependencies += "org.scalactic" %% "scalactic" % ScalaTestVersion % "test"
libraryDependencies += "org.scalatest" %% "scalatest" % ScalaTestVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-actor-testkit-typed" % AkkaVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-stream-testkit" % AkkaVersion % "test"
libraryDependencies += "com.typesafe.akka" %% "akka-http-testkit" % AkkaHttpVersion % "test"

View File

@ -0,0 +1,38 @@
'''
init tables
'''
from yoyo import step
__depends__ = {}
steps = [
step('''create table players (
player_id integer primary key,
created integer not null,
nick text not null,
job text not null,
bis_link text,
priority integer not null default 1
)'''),
step('''create unique index players_nick_job_idx on players(nick, job)'''),
step('''create table loot (
loot_id integer primary key,
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
foreign key (player_id) references players(player_id) on delete cascade
)'''),
step('''create index loot_owner_idx on loot(player_id)'''),
step('''create table bis (
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
foreign key (player_id) references players(player_id) on delete cascade
)'''),
step('''create unique index bis_piece_player_id_idx on bis(player_id, piece)''')
]

View File

@ -0,0 +1,17 @@
'''
users table
'''
from yoyo import step
__depends__ = {}
steps = [
step('''create table users (
user_id integer primary key,
username text not null,
password text not null,
permission text not null
)'''),
step('''create unique index users_username_idx on users(username)''')
]

View File

@ -0,0 +1,75 @@
'''
party id
'''
import random
import string
from yoyo import step
__depends__ = {'20190830_01_sYYZL-init-tables', '20190910_01_tgBmx-users-table'}
party_id = ''.join(random.sample(string.ascii_letters, 16))
steps = [
step('''create table players2 (
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
)'''),
# not safe for injections, but sqlite and psycopg have different placeholders for parameters
step('''insert into players2 select '%s' as party_id, players.* from players''' % (party_id,)),
step('''drop index if exists players_nick_job_idx'''),
step('''create unique index players_nick_job_idx on players2(party_id, nick, job)'''),
step('''create table loot2 (
loot_id integer primary key,
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
foreign key (player_id) references players2(player_id) on delete cascade
)'''),
step('''insert into loot2 select * from loot'''),
step('''drop index if exists loot_owner_idx'''),
step('''create index loot_owner_idx on loot(player_id)'''),
step('''create table bis2 (
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
foreign key (player_id) references players2(player_id) on delete cascade
)'''),
step('''insert into bis2 select * from bis'''),
step('''drop index if exists bis_piece_player_id_idx'''),
step('''create unique index bis_piece_player_id_idx on bis2(player_id, piece)'''),
step('''create table users2 (
party_id text not null,
user_id integer primary key,
username text not null,
password text not null,
permission text not null,
foreign key (party_id) references players2(party_id) on delete cascade
)'''),
# not safe for injections, but sqlite and psycopg have different placeholders for parameters
step('''insert into users2 select '%s' as party_id, users.* from users''' % (party_id,)),
step('''drop index if exists users_username_idx'''),
step('''create unique index users_username_idx on users2(party_id, username)'''),
step('''drop table users'''),
step('''alter table users2 rename to users'''),
step('''drop table loot'''),
step('''alter table loot2 rename to loot'''),
step('''drop table bis'''),
step('''alter table bis2 rename to bis'''),
step('''drop table players'''),
step('''alter table players2 rename to players''')
]

10
package/ini/ffxivbis.ini Normal file
View File

@ -0,0 +1,10 @@
[settings]
include = ffxivbis.ini.d
logging = ffxivbis.ini.d/logging.ini
database = sqlite
priority = is_required loot_count_bis loot_priority loot_count loot_count_total
[web]
host = 0.0.0.0
port = 8000
templates = /home/arcanis/Documents/github/ffxivbis/templates

View File

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

View File

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

View File

@ -0,0 +1,44 @@
[loggers]
keys = root,application,database,http
[handlers]
keys = file_handler
[formatters]
keys = generic_format
[handler_console_handler]
class = StreamHandler
level = INFO
formatter = generic_format
args = (sys.stdout,)
[handler_file_handler]
class = logging.handlers.RotatingFileHandler
level = INFO
formatter = generic_format
args = ('ffxivbis.log', 'a', 20971520, 20)
[formatter_generic_format]
format = [%(levelname)s] [%(asctime)s] [%(threadName)s] [%(name)s] [%(funcName)s]: %(message)s
datefmt =
[logger_root]
level = INFO
handlers = file_handler
qualname = root
[logger_application]
level = INFO
handlers = file_handler
qualname = application
[logger_database]
level = INFO
handlers = file_handler
qualname = database
[logger_http]
level = INFO
handlers = file_handler
qualname = http

View File

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

View File

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

View File

@ -1,4 +0,0 @@
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3")
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.3")
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.4")
addDependencyTreePlugin

5
setup.cfg Normal file
View File

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

52
setup.py Normal file
View File

@ -0,0 +1,52 @@
from distutils.util import convert_path
from setuptools import setup, find_packages
from os import path
here = path.abspath(path.dirname(__file__))
metadata = dict()
with open(convert_path('src/ffxivbis/core/version.py')) as metadata_file:
exec(metadata_file.read(), metadata)
setup(
name='ffxivbis',
version=metadata['__version__'],
zip_safe=False,
description='Helper to handle loot drop',
author='Evgeniy Alekseev',
author_email='i@arcanis.me',
license='BSD',
package_dir={'': 'src'},
packages=find_packages(where='src', exclude=['contrib', 'docs', 'test']),
install_requires=[
'aiohttp==3.6.0',
'aiohttp_jinja2',
'aiohttp_security',
'apispec',
'iniherit',
'Jinja2',
'passlib',
'yoyo_migrations'
],
setup_requires=[
'pytest-runner'
],
tests_require=[
'pytest', 'pytest-aiohttp', 'pytest-asyncio'
],
include_package_data=True,
extras_require={
'Postgresql': ['asyncpg'],
'SQLite': ['aiosqlite'],
'test': ['coverage', 'pytest'],
},
)

View File

60
src/ffxivbis/api/auth.py Normal file
View File

@ -0,0 +1,60 @@
#
# 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, Tuple
from ffxivbis.core.database import Database
class AuthorizationPolicy(AbstractAuthorizationPolicy):
def __init__(self, database: Database) -> None:
self.database = database
def split_identity(self, identity: str) -> Tuple[str, str]:
# identity is party_id + username
party_id, username = identity.split('+')
return party_id, username
async def authorized_userid(self, identity: str) -> Optional[str]:
party_id, username = self.split_identity(identity)
user = await self.database.get_user(party_id, username)
return username if user is not None else None
async def permits(self, identity: str, permission: str, context: str = None) -> bool:
party_id, username = self.split_identity(identity)
user = await self.database.get_user(party_id, username)
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

34
src/ffxivbis/api/json.py Normal file
View File

@ -0,0 +1,34 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from enum import Enum
from json import JSONEncoder
from typing import Any
class HttpEncoder(JSONEncoder):
def default(self, obj: Any) -> Any:
if isinstance(obj, dict):
data = {}
for key, value in obj.items():
data[key] = self.default(value)
return data
elif isinstance(obj, Enum):
return obj.name
elif hasattr(obj, '_ast'):
return self.default(obj._ast())
elif hasattr(obj, '__iter__') and not isinstance(obj, str):
return [self.default(value) for value in obj]
elif hasattr(obj, '__dict__'):
data = {
key: self.default(value)
for key, value in obj.__dict__.items()
if not callable(value) and not key.startswith('_')}
return data
else:
return obj

View File

@ -0,0 +1,66 @@
#
# 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/{party_id}/login/{username}', LoginView)
app.router.add_post('/api/v1/{party_id}/login', LoginView)
app.router.add_post('/api/v1/{party_id}/logout', LogoutView)
app.router.add_put('/admin/api/v1/{party_id}/login', LoginView)
app.router.add_get('/api/v1/party/{party_id}', PlayerView)
app.router.add_post('/api/v1/party/{party_id}', PlayerView)
app.router.add_get('/api/v1/party/{party_id}/bis', BiSView)
app.router.add_post('/api/v1/party/{party_id}/bis', BiSView)
app.router.add_put('/api/v1/party/{party_id}/bis', BiSView)
app.router.add_get('/api/v1/party/{party_id}/loot', LootView)
app.router.add_post('/api/v1/party/{party_id}/loot', LootView)
app.router.add_put('/api/v1/party/{party_id}/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/{party_id}', PlayerHtmlView)
app.router.add_post('/party/{party_id}', PlayerHtmlView)
app.router.add_get('/bis/{party_id}', BiSHtmlView)
app.router.add_post('/bis/{party_id}', BiSHtmlView)
app.router.add_get('/loot/{party_id}', LootHtmlView)
app.router.add_post('/loot/{party_id}', LootHtmlView)
app.router.add_get('/suggest/{party_id}', LootSuggestHtmlView)
app.router.add_post('/suggest/{party_id}', LootSuggestHtmlView)
app.router.add_get('/admin/users/{party_id}', UsersHtmlView)
app.router.add_post('/admin/users/{party_id}', UsersHtmlView)

78
src/ffxivbis/api/spec.py Normal file
View File

@ -0,0 +1,78 @@
#
# 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

42
src/ffxivbis/api/utils.py Normal file
View File

@ -0,0 +1,42 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
import json
from aiohttp.web import HTTPException, Response
from typing import Any, Mapping, List
from .json import HttpEncoder
def make_json(response: Any) -> str:
return json.dumps(response, cls=HttpEncoder, sort_keys=True)
def wrap_exception(exception: Exception, args: Mapping[str, Any], code: int = 500) -> Response:
if isinstance(exception, HTTPException):
raise exception # reraise return
return wrap_json({
'message': repr(exception),
'arguments': dict(args)
}, code)
def wrap_invalid_param(params: List[str], args: Mapping[str, Any], code: int = 400) -> Response:
return wrap_json({
'message': f'invalid or missing parameters: `{params}`',
'arguments': dict(args)
}, code)
def wrap_json(response: Any, code: int = 200) -> Response:
return Response(
text=make_json(response),
status=code,
content_type='application/json'
)

View File

View File

View File

@ -0,0 +1,159 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import Response
from typing import Any, Dict, List, Optional, Type
from ffxivbis.models.job import Job
from ffxivbis.models.piece import Piece
from ffxivbis.models.player import PlayerId
from ffxivbis.api.utils import wrap_exception, wrap_invalid_param, wrap_json
from ffxivbis.api.views.common.bis_base import BiSBaseView
from .openapi import OpenApi
class BiSView(BiSBaseView, OpenApi):
@classmethod
def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]:
return 'Get party players BiS items'
@classmethod
def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]:
return [
{
'name': 'nick',
'in': 'query',
'description': 'player nick name to filter',
'required': False,
'type': 'string'
}
]
@classmethod
def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {
'200': {'content': {'application/json': { 'schema': {
'type': 'array',
'items': {
'allOf': [{'$ref': cls.model_ref('Piece')}]
}}}}}
}
@classmethod
def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]:
return 'get party BiS items'
@classmethod
def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]:
return ['BiS']
@classmethod
def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]:
return 'Add new item to player BiS or remove existing'
@classmethod
def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]:
return ['Piece', 'PlayerEdit']
@classmethod
def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {
'200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('Loot')}}}}
}
@classmethod
def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]:
return 'edit BiS'
@classmethod
def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]:
return ['BiS']
@classmethod
def endpoint_put_consumes(cls: Type[OpenApi]) -> List[str]:
return ['application/json']
@classmethod
def endpoint_put_description(cls: Type[OpenApi]) -> Optional[str]:
return 'Generate new BiS set'
@classmethod
def endpoint_put_request_body(cls: Type[OpenApi], content_type: str) -> List[str]:
return ['BiSLink']
@classmethod
def endpoint_put_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {
'200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('BiS')}}}}
}
@classmethod
def endpoint_put_summary(cls: Type[OpenApi]) -> Optional[str]:
return 'update BiS'
@classmethod
def endpoint_put_tags(cls: Type[OpenApi]) -> List[str]:
return ['BiS']
async def get(self) -> Response:
try:
loot = self.bis_get(self.request.query.getone('nick', None))
except Exception as e:
self.request.app.logger.exception('could not get bis')
return wrap_exception(e, self.request.query)
return wrap_json(loot)
async def post(self) -> Response:
try:
data = await self.request.json()
except Exception:
data = dict(await self.request.post())
required = ['action', 'is_tome', 'job', 'name', 'nick']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
action = data.get('action')
if action not in ('add', 'remove'):
return wrap_invalid_param(['action'], data)
try:
player_id = PlayerId(Job[data['job']], data['nick'])
piece: Piece = Piece.get(data) # type: ignore
await self.bis_post(action, player_id, piece)
except Exception as e:
self.request.app.logger.exception('could not add bis')
return wrap_exception(e, data)
return wrap_json({'piece': piece, 'player_id': player_id})
async def put(self) -> Response:
try:
data = await self.request.json()
except Exception:
data = dict(await self.request.post())
required = ['job', 'link', 'nick']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
try:
player_id = PlayerId(Job[data['job']], data['nick'])
bis = await self.bis_put(player_id, data['link'])
except Exception as e:
self.request.app.logger.exception('could not parse bis')
return wrap_exception(e, data)
return wrap_json(bis)

View File

@ -0,0 +1,140 @@
#
# 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', 'party_id']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
try:
await self.create_user(data['party_id'], data['username'],
data['password'], data.get('permission', 'get'))
except Exception as e:
self.request.app.logger.exception('cannot create user')
return wrap_exception(e, data)
return wrap_json({})

View File

@ -0,0 +1,46 @@
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import Response
from typing import Any, Dict, List, Optional, Type
from ffxivbis.api.utils import wrap_exception, wrap_json
from ffxivbis.api.views.common.login_base import LoginBaseView
from .openapi import OpenApi
class LogoutView(LoginBaseView, OpenApi):
@classmethod
def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]:
return 'Logout'
@classmethod
def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]:
return []
@classmethod
def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {
'200': {'content': {'application/json': {'type': 'object'}}}
}
@classmethod
def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]:
return 'logout'
@classmethod
def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]:
return ['users']
async def post(self) -> Response:
try:
await self.logout()
except Exception as e:
self.request.app.logger.exception('cannot logout user')
return wrap_exception(e, {})
return wrap_json({})

View File

@ -0,0 +1,159 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import Response
from typing import Any, Dict, List, Optional, Type
from ffxivbis.models.job import Job
from ffxivbis.models.piece import Piece
from ffxivbis.models.player import PlayerId
from ffxivbis.api.utils import wrap_exception, wrap_invalid_param, wrap_json
from ffxivbis.api.views.common.loot_base import LootBaseView
from .openapi import OpenApi
class LootView(LootBaseView, OpenApi):
@classmethod
def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]:
return 'Get party players loot'
@classmethod
def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]:
return [
{
'name': 'nick',
'in': 'query',
'description': 'player nick name to filter',
'required': False,
'type': 'string'
}
]
@classmethod
def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {
'200': {'content': {'application/json': {'schema': {
'type': 'array',
'items': {
'allOf': [{'$ref': cls.model_ref('Piece')}]
}}}}}
}
@classmethod
def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]:
return 'get party loot'
@classmethod
def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]:
return ['loot']
@classmethod
def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]:
return 'Add new loot item or remove existing'
@classmethod
def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]:
return ['Piece', 'PlayerEdit']
@classmethod
def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {
'200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('Loot')}}}}
}
@classmethod
def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]:
return 'edit loot'
@classmethod
def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]:
return ['loot']
@classmethod
def endpoint_put_description(cls: Type[OpenApi]) -> Optional[str]:
return 'Suggest loot to party member'
@classmethod
def endpoint_put_request_body(cls: Type[OpenApi], content_type: str) -> List[str]:
return ['Piece']
@classmethod
def endpoint_put_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {
'200': {'content': {'application/json': {'schema': {
'type': 'array',
'items': {
'allOf': [{'$ref': cls.model_ref('PlayerIdWithCounters')}]
}}}}}
}
@classmethod
def endpoint_put_summary(cls: Type[OpenApi]) -> Optional[str]:
return 'suggest loot'
@classmethod
def endpoint_put_tags(cls: Type[OpenApi]) -> List[str]:
return ['loot']
async def get(self) -> Response:
try:
loot = self.loot_get(self.request.query.getone('nick', None))
except Exception as e:
self.request.app.logger.exception('could not get loot')
return wrap_exception(e, self.request.query)
return wrap_json(loot)
async def post(self) -> Response:
try:
data = await self.request.json()
except Exception:
data = dict(await self.request.post())
required = ['action', 'is_tome', 'job', 'name', 'nick']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
action = data.get('action')
if action not in ('add', 'remove'):
return wrap_invalid_param(['action'], data)
try:
player_id = PlayerId(Job[data['job']], data['nick'])
piece: Piece = Piece.get(data) # type: ignore
await self.loot_post(action, player_id, piece)
except Exception as e:
self.request.app.logger.exception('could not add loot')
return wrap_exception(e, data)
return wrap_json({'piece': piece, 'player_id': player_id})
async def put(self) -> Response:
try:
data = await self.request.json()
except Exception:
data = dict(await self.request.post())
required = ['is_tome', 'name']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
try:
piece: Piece = Piece.get(data) # type: ignore
players = self.loot_put(piece)
except Exception as e:
self.request.app.logger.exception('could not suggest loot')
return wrap_exception(e, data)
return wrap_json(players)

View File

@ -0,0 +1,195 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from __future__ import annotations
from typing import Any, Dict, List, Optional, Type
from ffxivbis.models.serializable import Serializable
class OpenApi(Serializable):
@classmethod
def endpoint_delete_description(cls: Type[OpenApi]) -> Optional[str]:
return None
@classmethod
def endpoint_delete_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]:
return []
@classmethod
def endpoint_delete_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {}
@classmethod
def endpoint_delete_summary(cls: Type[OpenApi]) -> Optional[str]:
return None
@classmethod
def endpoint_delete_tags(cls: Type[OpenApi]) -> List[str]:
return []
@classmethod
def endpoint_delete_spec(cls: Type[OpenApi]) -> Dict[str, Any]:
description = cls.endpoint_delete_description()
if description is None:
return {}
return {
'description': description,
'parameters': cls.endpoint_delete_parameters(),
'responses': cls.endpoint_with_default_responses(cls.endpoint_delete_responses()),
'summary': cls.endpoint_delete_summary(),
'tags': cls.endpoint_delete_tags()
}
@classmethod
def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]:
return None
@classmethod
def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]:
return []
@classmethod
def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {}
@classmethod
def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]:
return None
@classmethod
def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]:
return []
@classmethod
def endpoint_get_spec(cls: Type[OpenApi]) -> Dict[str, Any]:
description = cls.endpoint_get_description()
if description is None:
return {}
return {
'description': description,
'parameters': cls.endpoint_get_parameters(),
'responses': cls.endpoint_with_default_responses(cls.endpoint_get_responses()),
'summary': cls.endpoint_get_summary(),
'tags': cls.endpoint_get_tags()
}
@classmethod
def endpoint_post_consumes(cls: Type[OpenApi]) -> List[str]:
return ['application/json']
@classmethod
def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]:
return None
@classmethod
def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]:
return []
@classmethod
def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {}
@classmethod
def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]:
return None
@classmethod
def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]:
return []
@classmethod
def endpoint_post_spec(cls: Type[OpenApi]) -> Dict[str, Any]:
description = cls.endpoint_post_description()
if description is None:
return {}
return {
'consumes': cls.endpoint_post_consumes(),
'description': description,
'requestBody': {
'content': {
content_type: {
'schema': {'allOf': [
{'$ref': cls.model_ref(ref)}
for ref in cls.endpoint_post_request_body(content_type)
]}
}
for content_type in cls.endpoint_post_consumes()
}
},
'responses': cls.endpoint_with_default_responses(cls.endpoint_post_responses()),
'summary': cls.endpoint_post_summary(),
'tags': cls.endpoint_post_tags()
}
@classmethod
def endpoint_put_consumes(cls: Type[OpenApi]) -> List[str]:
return ['application/json']
@classmethod
def endpoint_put_description(cls: Type[OpenApi]) -> Optional[str]:
return None
@classmethod
def endpoint_put_request_body(cls: Type[OpenApi], content_type: str) -> List[str]:
return []
@classmethod
def endpoint_put_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {}
@classmethod
def endpoint_put_summary(cls: Type[OpenApi]) -> Optional[str]:
return None
@classmethod
def endpoint_put_tags(cls: Type[OpenApi]) -> List[str]:
return []
@classmethod
def endpoint_put_spec(cls: Type[OpenApi]) -> Dict[str, Any]:
description = cls.endpoint_put_description()
if description is None:
return {}
return {
'consumes': cls.endpoint_put_consumes(),
'description': description,
'requestBody': {
'content': {
content_type: {
'schema': {'allOf': [
{'$ref': cls.model_ref(ref)}
for ref in cls.endpoint_put_request_body(content_type)
]}
}
for content_type in cls.endpoint_put_consumes()
}
},
'responses': cls.endpoint_with_default_responses(cls.endpoint_put_responses()),
'summary': cls.endpoint_put_summary(),
'tags': cls.endpoint_put_tags()
}
@classmethod
def endpoint_spec(cls: Type[OpenApi], operations: List[str]) -> Dict[str, Any]:
return {
operation.lower(): getattr(cls, f'endpoint_{operation.lower()}_spec')
for operation in operations
}
@classmethod
def endpoint_with_default_responses(cls: Type[OpenApi], responses: Dict[str, Any]) -> Dict[str, Any]:
responses.update({
'400': {'$ref': cls.model_ref('BadRequest', 'responses')},
'401': {'$ref': cls.model_ref('Unauthorized', 'responses')},
'403': {'$ref': cls.model_ref('Forbidden', 'responses')},
'500': {'$ref': cls.model_ref('ServerError', 'responses')}
})
return responses

View File

@ -0,0 +1,107 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import Response
from typing import Any, Dict, List, Optional, Type
from ffxivbis.models.job import Job
from ffxivbis.api.utils import wrap_exception, wrap_invalid_param, wrap_json
from ffxivbis.api.views.common.player_base import PlayerBaseView
from .openapi import OpenApi
class PlayerView(PlayerBaseView, OpenApi):
@classmethod
def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]:
return 'Get party players with optional nick filter'
@classmethod
def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]:
return [
{
'name': 'nick',
'in': 'query',
'description': 'player nick name to filter',
'required': False,
'type': 'string'
}
]
@classmethod
def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {
'200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('Player')}}}}
}
@classmethod
def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]:
return 'get party players'
@classmethod
def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]:
return ['party']
@classmethod
def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]:
return 'Create new party player or remove existing'
@classmethod
def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> List[str]:
return ['PlayerEdit']
@classmethod
def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]:
return {
'200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('PlayerId')}}}}
}
@classmethod
def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]:
return 'add or remove player'
@classmethod
def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]:
return ['party']
async def get(self) -> Response:
try:
party = self.player_get(self.request.query.getone('nick', None))
except Exception as e:
self.request.app.logger.exception('could not get party')
return wrap_exception(e, self.request.query)
return wrap_json(party)
async def post(self) -> Response:
try:
data = await self.request.json()
except Exception:
data = dict(await self.request.post())
required = ['action', 'job', 'nick']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
priority = data.get('priority', 0)
link = data.get('link', None)
action = data.get('action')
if action not in ('add', 'remove'):
return wrap_invalid_param(['action'], data)
try:
player_id = await self.player_post(action, Job[data['job']], data['nick'], link, priority)
except Exception as e:
self.request.app.logger.exception('could not add loot')
return wrap_exception(e, data)
return wrap_json(player_id)

View File

@ -0,0 +1,49 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import View
from typing import List, Optional
from ffxivbis.core.ariyala_parser import AriyalaParser
from ffxivbis.models.bis import BiS
from ffxivbis.models.piece import Piece
from ffxivbis.models.player import PlayerId
class BiSBaseView(View):
async def bis_add(self, player_id: PlayerId, piece: Piece) -> Piece:
await self.request.app['party'].set_item_bis(player_id, piece)
return piece
def bis_get(self, nick: Optional[str]) -> List[Piece]:
party = [
player
for player in self.request.app['party'].party
if nick is None or player.nick == nick
]
return list(sum([player.bis.pieces for player in party], []))
async def bis_post(self, action: str, player_id: PlayerId, piece: Piece) -> Optional[Piece]:
if action == 'add':
return await self.bis_add(player_id, piece)
elif action == 'remove':
return await self.bis_remove(player_id, piece)
return None
async def bis_put(self, player_id: PlayerId, link: str) -> BiS:
parser = AriyalaParser(self.request.app['config'])
items = await parser.get(link, player_id.job.name)
for piece in items:
await self.request.app['party'].set_item_bis(player_id, piece)
await self.request.app['party'].set_bis_link(player_id, link)
return self.request.app['party'].players[player_id].bis
async def bis_remove(self, player_id: PlayerId, piece: Piece) -> Piece:
await self.request.app['party'].remove_item_bis(player_id, piece)
return piece

View File

@ -0,0 +1,43 @@
#
# 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, party_id: str, username: str, password: str, permission: str) -> None:
await self.request.app['database'].insert_user(party_id, User(username, password, permission), False)
async def login(self, username: str, password: str) -> None:
if await self.check_credentials(username, password):
response = HTTPFound('/')
await remember(self.request, response, username)
raise response
raise HTTPUnauthorized()
async def logout(self) -> None:
await check_authorized(self.request)
response = HTTPFound('/')
await forget(self.request, response)
raise response
async def remove_user(self, username: str) -> None:
await self.request.app['database'].delete_user(username)

View File

@ -0,0 +1,43 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import View
from typing import List, Optional, Union
from ffxivbis.models.piece import Piece
from ffxivbis.models.player import PlayerId, PlayerIdWithCounters
from ffxivbis.models.upgrade import Upgrade
class LootBaseView(View):
async def loot_add(self, player_id: PlayerId, piece: Piece) -> Piece:
await self.request.app['party'].set_item(player_id, piece)
return piece
def loot_get(self, nick: Optional[str]) -> List[Piece]:
party = [
player
for player in self.request.app['party'].party
if nick is None or player.nick == nick
]
return list(sum([player.loot for player in party], []))
async def loot_post(self, action: str, player_id: PlayerId, piece: Piece) -> Optional[Piece]:
if action == 'add':
return await self.loot_add(player_id, piece)
elif action == 'remove':
return await self.loot_remove(player_id, piece)
return None
def loot_put(self, piece: Union[Piece, Upgrade]) -> List[PlayerIdWithCounters]:
return self.request.app['loot'].suggest(piece)
async def loot_remove(self, player_id: PlayerId, piece: Piece) -> Piece:
await self.request.app['party'].remove_item(player_id, piece)
return piece

View File

@ -0,0 +1,50 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import View
from typing import List, Optional
from ffxivbis.core.ariyala_parser import AriyalaParser
from ffxivbis.models.bis import BiS
from ffxivbis.models.job import Job
from ffxivbis.models.player import Player, PlayerId
class PlayerBaseView(View):
async def player_add(self, job: Job, nick: str, link: Optional[str], priority: int) -> PlayerId:
player = Player(job, nick, BiS(), [], link, int(priority))
player_id = player.player_id
await self.request.app['party'].set_player(player)
if link:
parser = AriyalaParser(self.request.app['config'])
items = await parser.get(link, job.name)
for piece in items:
await self.request.app['party'].set_item_bis(player_id, piece)
return player_id
def player_get(self, nick: Optional[str]) -> List[Player]:
return [
player
for player in self.request.app['party'].party
if nick is None or player.nick == nick
]
async def player_post(self, action: str, job: Job, nick: str, link: Optional[str], priority: int) -> Optional[PlayerId]:
if action == 'add':
return await self.player_add(job, nick, link, priority)
elif action == 'remove':
return await self.player_remove(job, nick)
return None
async def player_remove(self, job: Job, nick: str) -> PlayerId:
player_id = PlayerId(job, nick)
await self.request.app['party'].remove_player(player_id)
return player_id

View File

View File

@ -0,0 +1,29 @@
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
import json
from aiohttp.web import Response, View
from aiohttp_jinja2 import template
from typing import Any, Dict
class ApiDocVIew(View):
async def get(self) -> Response:
return Response(
text=json.dumps(self.request.app['spec'].to_dict()),
status=200,
content_type='application/json'
)
class ApiHtmlView(View):
@template('api.jinja2')
async def get(self) -> Dict[str, Any]:
return {}

View File

@ -0,0 +1,82 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import HTTPFound, Response
from aiohttp_jinja2 import template
from typing import Any, Dict, List
from ffxivbis.models.piece import Piece
from ffxivbis.models.player import Player, PlayerId
from ffxivbis.api.utils import wrap_exception, wrap_invalid_param
from ffxivbis.api.views.common.bis_base import BiSBaseView
from ffxivbis.api.views.common.player_base import PlayerBaseView
class BiSHtmlView(BiSBaseView, PlayerBaseView):
@template('bis.jinja2')
async def get(self) -> Dict[str, Any]:
error = None
items: List[Dict[str, str]] = []
players: List[Player] = []
try:
players = self.player_get(None)
items = [
{
'player': player.player_id.pretty_name,
'piece': piece.name,
'is_tome': 'yes' if piece.is_tome else 'no'
}
for player in players
for piece in player.bis.pieces
]
except Exception as e:
self.request.app.logger.exception('could not get bis')
error = repr(e)
return {
'items': items,
'pieces': Piece.available(),
'players': [player.player_id.pretty_name for player in players],
'request_error': error
}
async def post(self) -> Response:
data = await self.request.post()
required = ['method', 'player']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
try:
method = data.getone('method')
player_id = PlayerId.from_pretty_name(data.getone('player')) # type: ignore
if method == 'post':
required = ['action', 'piece']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
is_tome = (data.getone('is_tome', None) == 'on')
await self.bis_post(data.getone('action'), player_id, # type: ignore
Piece.get({'piece': data.getone('piece'), 'is_tome': is_tome})) # type: ignore
elif method == 'put':
required = ['bis']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
await self.bis_put(player_id, data.getone('bis')) # type: ignore
except Exception as e:
self.request.app.logger.exception('could not manage bis')
return wrap_exception(e, data)
return HTTPFound(self.request.url)

View File

@ -0,0 +1,23 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import View
from aiohttp_jinja2 import template
from aiohttp_security import authorized_userid
from typing import Any, Dict
class IndexHtmlView(View):
@template('index.jinja2')
async def get(self) -> Dict[str, Any]:
username = await authorized_userid(self.request)
return {
'logged': username
}

View File

@ -0,0 +1,70 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import HTTPFound, Response
from aiohttp_jinja2 import template
from typing import Any, Dict, List
from ffxivbis.models.piece import Piece
from ffxivbis.models.player import Player, PlayerId
from ffxivbis.models.upgrade import Upgrade
from ffxivbis.api.utils import wrap_exception, wrap_invalid_param
from ffxivbis.api.views.common.loot_base import LootBaseView
from ffxivbis.api.views.common.player_base import PlayerBaseView
class LootHtmlView(LootBaseView, PlayerBaseView):
@template('loot.jinja2')
async def get(self) -> Dict[str, Any]:
error = None
items: List[Dict[str, str]] = []
players: List[Player] = []
try:
players = self.player_get(None)
items = [
{
'player': player.player_id.pretty_name,
'piece': piece.name,
'is_tome': 'yes' if getattr(piece, 'is_tome', True) else 'no'
}
for player in players
for piece in player.loot
]
except Exception as e:
self.request.app.logger.exception('could not get loot')
error = repr(e)
return {
'items': items,
'pieces': Piece.available() + [upgrade.name for upgrade in Upgrade],
'players': [player.player_id.pretty_name for player in players],
'request_error': error
}
async def post(self) -> Response:
data = await self.request.post()
required = ['action', 'piece', 'player']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
try:
player_id = PlayerId.from_pretty_name(data.getone('player')) # type: ignore
is_tome = (data.getone('is_tome', None) == 'on')
await self.loot_post(data.getone('action'), player_id, # type: ignore
Piece.get({'piece': data.getone('piece'), 'is_tome': is_tome})) # type: ignore
except Exception as e:
self.request.app.logger.exception('could not manage loot')
return wrap_exception(e, data)
return HTTPFound(self.request.url)

View File

@ -0,0 +1,64 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import Response
from aiohttp_jinja2 import template
from typing import Any, Dict, List, Union
from ffxivbis.models.piece import Piece
from ffxivbis.models.player import PlayerIdWithCounters
from ffxivbis.models.upgrade import Upgrade
from ffxivbis.api.utils import wrap_invalid_param
from ffxivbis.api.views.common.loot_base import LootBaseView
from ffxivbis.api.views.common.player_base import PlayerBaseView
class LootSuggestHtmlView(LootBaseView, PlayerBaseView):
@template('loot_suggest.jinja2')
async def get(self) -> Dict[str, Any]:
return {
'pieces': Piece.available() + [upgrade.name for upgrade in Upgrade]
}
@template('loot_suggest.jinja2')
async def post(self) -> Union[Dict[str, Any], Response]:
data = await self.request.post()
error = None
item_values: Dict[str, Any] = {}
players: List[PlayerIdWithCounters] = []
required = ['piece']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
try:
piece = Piece.get({'piece': data.getone('piece'), 'is_tome': data.getone('is_tome', False)})
players = self.loot_put(piece)
item_values = {'piece': piece.name, 'is_tome': getattr(piece, 'is_tome', True)}
except Exception as e:
self.request.app.logger.exception('could not manage loot')
error = repr(e)
return {
'item': item_values,
'pieces': Piece.available() + [upgrade.name for upgrade in Upgrade],
'request_error': error,
'suggest': [
{
'player': player.pretty_name,
'is_required': 'yes' if player.is_required else 'no',
'loot_count': player.loot_count,
'loot_count_bis': player.loot_count_bis,
'loot_count_total': player.loot_count_total
}
for player in players
]
}

View File

@ -0,0 +1,67 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from aiohttp.web import HTTPFound, Response
from aiohttp_jinja2 import template
from typing import Any, Dict, List
from ffxivbis.models.job import Job
from ffxivbis.models.player import PlayerIdWithCounters
from ffxivbis.api.utils import wrap_exception, wrap_invalid_param
from ffxivbis.api.views.common.player_base import PlayerBaseView
class PlayerHtmlView(PlayerBaseView):
@template('party.jinja2')
async def get(self) -> Dict[str, Any]:
counters: List[PlayerIdWithCounters] = []
error = None
try:
party = self.player_get(None)
counters = [player.player_id_with_counters(None) for player in party]
except Exception as e:
self.request.app.logger.exception('could not get party')
error = repr(e)
return {
'jobs': [job.name for job in Job],
'players': [
{
'job': player.job.name,
'nick': player.nick,
'loot_count_bis': player.loot_count_bis,
'loot_count_total': player.loot_count_total,
'priority': player.priority
}
for player in counters
],
'request_error': error
}
async def post(self) -> Response:
data = await self.request.post()
required = ['action', 'job', 'nick']
if any(param not in data for param in required):
return wrap_invalid_param(required, data)
try:
action = data.getone('action')
priority = data.getone('priority', 0)
link = data.getone('bis', None)
await self.player_post(action, Job[data['job'].upper()], data['nick'], link, priority) # type: ignore
except Exception as e:
self.request.app.logger.exception('could not manage players')
return wrap_exception(e, data)
return HTTPFound(self.request.url)

View File

@ -0,0 +1,31 @@
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
import os
from aiohttp.web import HTTPNotFound, Response, View
class StaticHtmlView(View):
def __get_content_type(self, filename: str) -> str:
_, ext = os.path.splitext(filename)
if ext == '.css':
return 'text/css'
elif ext == '.js':
return 'text/javascript'
return 'text/plain'
async def get(self) -> Response:
resource_name = self.request.match_info['resource_id']
resource_path = os.path.join(self.request.app['templates_root'], 'static', resource_name)
if not os.path.exists(resource_path) or os.path.isdir(resource_path):
return HTTPNotFound()
content_type = self.__get_content_type(resource_name)
with open(resource_path) as resource_file:
return Response(text=resource_file.read(), content_type=content_type)

View File

@ -0,0 +1,62 @@
#
# 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)

67
src/ffxivbis/api/web.py Normal file
View File

@ -0,0 +1,67 @@
#
# 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.party_aggregator import PartyAggregator
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, aggregator: PartyAggregator) -> 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 aggregator')
app['aggregator'] = aggregator
return app

View File

View File

@ -0,0 +1,31 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from ffxivbis.core.config import Configuration
from .core import Application
def get_config(config_path: str) -> Configuration:
config = Configuration()
config.load(config_path, {})
config.load_logging()
return config
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Simple loot recorder for FFXIV')
parser.add_argument('-c', '--config', help='configuration path', default='ffxivbis.ini')
args = parser.parse_args()
config = get_config(args.config)
app = Application(config)
app.run()

View File

@ -0,0 +1,33 @@
#
# 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.party_aggregator import PartyAggregator
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()
aggregator = PartyAggregator(self.config, database)
web = setup_service(self.config, database, aggregator)
run_server(web)

View File

View File

@ -0,0 +1,83 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
import os
import socket
from aiohttp import ClientSession
from typing import Dict, List, Optional
from ffxivbis.models.piece import Piece
from .config import Configuration
class AriyalaParser:
def __init__(self, config: Configuration) -> None:
self.ariyala_url = config.get('ariyala', 'ariyala_url')
self.xivapi_key = config.get('ariyala', 'xivapi_key', fallback=None)
self.xivapi_url = config.get('ariyala', 'xivapi_url')
def __remap_key(self, key: str) -> Optional[str]:
if key == 'mainhand':
return 'weapon'
elif key == 'chest':
return 'body'
elif key == 'ringLeft':
return 'left_ring'
elif key == 'ringRight':
return 'right_ring'
elif key in ('head', 'hands', 'waist', 'legs', 'feet', 'ears', 'neck', 'wrist'):
return key
return None
async def get(self, url: str, job: str) -> List[Piece]:
items = await self.get_ids(url, job)
return [
Piece.get({'piece': slot, 'is_tome': await self.get_is_tome(item_id)}) # type: ignore
for slot, item_id in items.items()
]
async def get_ids(self, url: str, job: str) -> Dict[str, int]:
norm_path = os.path.normpath(url)
set_id = os.path.basename(norm_path)
async with ClientSession() as session:
async with session.get(f'{self.ariyala_url}/store.app', params={'identifier': set_id}) as response:
response.raise_for_status()
data = await response.json(content_type='text/html')
# it has job in response but for some reasons job name differs sometimes from one in dictionary,
# e.g. http://ffxiv.ariyala.com/store.app?identifier=1AJB8
api_job = data['content']
try:
bis = data['datasets'][api_job]['normal']['items']
except KeyError:
bis = data['datasets'][job]['normal']['items']
result: Dict[str, int] = {}
for original_key, value in bis.items():
key = self.__remap_key(original_key)
if key is None:
continue
result[key] = value
return result
async def get_is_tome(self, item_id: int) -> bool:
params = {'columns': 'IsEquippable'}
if self.xivapi_key is not None:
params['private_key'] = self.xivapi_key
async with ClientSession() as session:
# for some reasons ipv6 does not work for me
session.connector._family = socket.AF_INET # type: ignore
async with session.get(f'{self.xivapi_url}/item/{item_id}', params=params) as response:
response.raise_for_status()
data = await response.json()
return data['IsEquippable'] == 0 # don't ask

View File

@ -0,0 +1,65 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
import configparser
import os
from logging.config import fileConfig
from typing import Any, Dict, Mapping, Optional
from .exceptions import MissingConfiguration
class Configuration(configparser.RawConfigParser):
def __init__(self) -> None:
configparser.RawConfigParser.__init__(self, allow_no_value=True)
self.path: Optional[str] = None
self.root_path: Optional[str] = None
@property
def include(self) -> str:
return self.__with_root_path(self.get('settings', 'include'))
def __load_section(self, conf: str) -> None:
self.read(os.path.join(self.include, conf))
def __with_root_path(self, path: str) -> str:
if self.root_path is None:
return path
return os.path.join(self.root_path, path)
def get_section(self, section: str) -> Dict[str, str]:
if not self.has_section(section):
raise MissingConfiguration(section)
return dict(self[section])
def load(self, path: str, values: Mapping[str, Mapping[str, Any]]) -> None:
self.path = path
self.root_path = os.path.dirname(self.path)
self.read(self.path)
self.load_includes()
# don't use direct ConfigParser.update here, it overrides whole section
for section, options in values.items():
if section not in self:
self.add_section(section)
for key, value in options.items():
self.set(section, key, value)
def load_includes(self) -> None:
try:
include_dir = self.include
for conf in filter(lambda p: p.endswith('.ini'), sorted(os.listdir(include_dir))):
self.__load_section(conf)
except (FileNotFoundError, configparser.NoOptionError, configparser.NoSectionError):
pass
def load_logging(self) -> None:
fileConfig(self.__with_root_path(self.get('settings', 'logging')))

View File

@ -0,0 +1,113 @@
#
# 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, party_id: str, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None:
raise NotImplementedError
async def delete_piece_bis(self, party_id: str, player_id: PlayerId, piece: Piece) -> None:
raise NotImplementedError
async def delete_player(self, party_id: str, player_id: PlayerId) -> None:
raise NotImplementedError
async def delete_user(self, party_id: str, username: str) -> None:
raise NotImplementedError
async def get_party(self, party_id: str) -> List[Player]:
raise NotImplementedError
async def get_player(self, party_id: str, player_id: PlayerId) -> Optional[int]:
raise NotImplementedError
async def get_players(self, party_id: str) -> List[int]:
raise NotImplementedError
async def get_user(self, party_id: str, username: str) -> Optional[User]:
raise NotImplementedError
async def get_users(self, party_id: str) -> List[User]:
raise NotImplementedError
async def insert_piece(self, party_id: str, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None:
raise NotImplementedError
async def insert_piece_bis(self, party_id: str, player_id: PlayerId, piece: Piece) -> None:
raise NotImplementedError
async def insert_player(self, party_id: str, player: Player) -> None:
raise NotImplementedError
async def insert_user(self, party_id: str, user: User, hashed_password: bool) -> None:
raise NotImplementedError
def migration(self) -> None:
self.logger.info('perform migrations')
backend = get_backend(self.connection)
migrations = read_migrations(self.migrations_path)
with backend.lock():
backend.apply_migrations(backend.to_apply(migrations))
def set_loot(self, party: Mapping[int, Player], bis: List[Loot], loot: List[Loot]) -> List[Player]:
for piece in bis:
party[piece.player_id].bis.set_item(piece.piece)
for piece in loot:
party[piece.player_id].loot.append(piece.piece)
return list(party.values())

View File

@ -0,0 +1,27 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from typing import Any, Mapping
class InvalidDatabase(Exception):
def __init__(self, database_type: str) -> None:
Exception.__init__(self, f'Unsupported database {database_type}')
class InvalidDataRow(Exception):
def __init__(self, data: Mapping[str, Any]) -> None:
Exception.__init__(self, f'Invalid data row `{data}`')
class MissingConfiguration(Exception):
def __init__(self, section: str) -> None:
Exception.__init__(self, f'Missing configuration section {section}')

View File

@ -0,0 +1,32 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from typing import Iterable, List, Tuple, Union
from ffxivbis.models.player import Player, PlayerIdWithCounters
from ffxivbis.models.piece import Piece
from ffxivbis.models.upgrade import Upgrade
from .party import Party
class LootSelector:
def __init__(self, party: Party, order_by: List[str] = None) -> None:
self.party = party
self.order_by = order_by or ['is_required', 'loot_count_bis', 'loot_count_total', 'loot_count', 'loot_priority']
def __order_by(self, player: Player, piece: Union[Piece, Upgrade]) -> Tuple:
return tuple(map(lambda method: getattr(player, method)(piece), self.order_by))
def __sorted_by(self, piece: Union[Piece, Upgrade]) -> Iterable[Player]:
# pycharm is lying, don't trust it
return sorted(self.party.players.values(), key=lambda player: self.__order_by(player, piece), reverse=True)
def suggest(self, piece: Union[Piece, Upgrade]) -> List[PlayerIdWithCounters]:
return [player.player_id_with_counters(piece) for player in self.__sorted_by(piece)]

View File

@ -0,0 +1,82 @@
#
# 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, party_id: str, database: Database) -> None:
self.lock = Lock()
self.party_id = party_id
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], party_id: str, database: Database) -> Party:
obj = cls(party_id, database)
players = await database.get_party(party_id)
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(self.party_id, player)
async def remove_player(self, player_id: PlayerId) -> Optional[Player]:
await self.database.delete_player(self.party_id, 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(self.party_id, 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(self.party_id, 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(self.party_id, 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(self.party_id, 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(self.party_id, player_id, piece)
with self.lock:
self.players[player_id].bis.remove_item(piece)

View File

@ -0,0 +1,26 @@
#
# 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 .config import Configuration
from .database import Database
from .loot_selector import LootSelector
from .party import Party
class PartyAggregator:
def __init__(self, config: Configuration, database: Database) -> None:
self.config = config
self.database = database
async def get_party(self, party_id: str) -> Party:
return await Party.get(party_id, self.database)
async def get_loot_selector(self, party: Party) -> LootSelector:
priority = self.config.get('settings', 'priority').split()
return LootSelector(party, priority)

View File

@ -0,0 +1,175 @@
#
# 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, party_id: str, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None:
player = await self.get_player(party_id, 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, party_id: str, player_id: PlayerId, piece: Piece) -> None:
player = await self.get_player(party_id, 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, party_id: str, player_id: PlayerId) -> None:
async with self.pool.acquire() as conn:
await conn.execute('''delete from players where nick = $1 and job = $2 and party_id = $3''',
player_id.nick, player_id.job.name, party_id)
async def delete_user(self, party_id: str, username: str) -> None:
async with self.pool.acquire() as conn:
await conn.execute('''delete from users where username = $1 and party_id = $2''',
(username, party_id))
async def get_party(self, party_id: str) -> List[Player]:
players = await self.get_players(party_id)
if not players:
return []
async with self.pool.acquire() as conn:
rows = await conn.fetch('''select * from bis where player_id in $1''', players)
bis_pieces = [Loot(row['player_id'], Piece.get(row)) for row in rows]
rows = await conn.fetch('''select * from loot where player_id in $1''', players)
loot_pieces = [Loot(row['player_id'], Piece.get(row)) for row in rows]
rows = await conn.fetch('''select * from players where party_id = $1''', party_id)
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, party_id: str, 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 and party_id = $3''',
player_id.nick, player_id.job.name, party_id)
return player['player_id'] if player is not None else None
async def get_players(self, party_id: str) -> List[int]:
async with self.pool.acquire() as conn:
players = await conn.fetch('''select player_id from players where party_id = $1''', (party_id,))
return [player['player_id'] for player in players]
async def get_user(self, party_id: str, username: str) -> Optional[User]:
async with self.pool.acquire() as conn:
user = await conn.fetchrow('''select * from users where username = $1 and party_id = $2''',
username, party_id)
return User(user['username'], user['password'], user['permission']) if user is not None else None
async def get_users(self, party_id: str) -> List[User]:
async with self.pool.acquire() as conn:
users = await conn.fetch('''select * from users where party_id = $1''', party_id)
return [User(user['username'], user['password'], user['permission']) for user in users]
async def insert_piece(self, party_id: str, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None:
player = await self.get_player(party_id, 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, party_id: str, player_id: PlayerId, piece: Piece) -> None:
player = await self.get_player(party_id, 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, party_id: str, player: Player) -> None:
async with self.pool.acquire() as conn:
await conn.execute(
'''insert into players
(party_id, created, nick, job, bis_link, priority)
values
($1, $2, $3, $4, $5, $6)
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, party_id
)
async def insert_user(self, party_id: str, 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
(party_id, username, password, permission)
values
($1, $2, $3, $4)
on conflict on constraint users_username_idx do update set
password = $2, permission = $3''',
party_id, user.username, password, user.permission
)

165
src/ffxivbis/core/sqlite.py Normal file
View File

@ -0,0 +1,165 @@
#
# 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, party_id: str, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None:
player = await self.get_player(party_id, 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, party_id: str, player_id: PlayerId, piece: Piece) -> None:
player = await self.get_player(party_id, 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, party_id: str, player_id: PlayerId) -> None:
async with SQLiteHelper(self.database_path) as cursor:
await cursor.execute('''delete from players where nick = ? and job = ? and party_id = ?''',
(player_id.nick, player_id.job.name, party_id))
async def delete_user(self, party_id: str, username: str) -> None:
async with SQLiteHelper(self.database_path) as cursor:
await cursor.execute('''delete from users where username = ? and party_id = ?''',
(username, party_id))
async def get_party(self, party_id: str) -> List[Player]:
players = await self.get_players(party_id)
if not players:
return []
placeholder = ', '.join(['?'] * len(players))
async with SQLiteHelper(self.database_path) as cursor:
await cursor.execute('''select * from bis where player_id in ({})'''.format(placeholder), players)
rows = await cursor.fetchall()
bis_pieces = [Loot(row['player_id'], Piece.get(row)) for row in rows]
await cursor.execute('''select * from loot where player_id in ({})'''.format(placeholder), players)
rows = await cursor.fetchall()
loot_pieces = [Loot(row['player_id'], Piece.get(row)) for row in rows]
await cursor.execute('''select * from players where party_id = ?''', (party_id,))
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, party_id: str, 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 = ? and party_id = ?''',
(player_id.nick, player_id.job.name, party_id))
player = await cursor.fetchone()
return player['player_id'] if player is not None else None
async def get_players(self, party_id: str) -> List[int]:
async with SQLiteHelper(self.database_path) as cursor:
await cursor.execute('''select player_id from players where party_id = ?''', (party_id,))
players = await cursor.fetchall()
return [player['player_id'] for player in players]
async def get_user(self, party_id: str, username: str) -> Optional[User]:
async with SQLiteHelper(self.database_path) as cursor:
await cursor.execute('''select * from users where username = ? and party_id = ?''',
(username, party_id))
user = await cursor.fetchone()
return User(user['username'], user['password'], user['permission']) if user is not None else None
async def get_users(self, party_id: str) -> List[User]:
async with SQLiteHelper(self.database_path) as cursor:
await cursor.execute('''select * from users where party_id = ?''', (party_id,))
users = await cursor.fetchall()
return [User(user['username'], user['password'], user['permission']) for user in users]
async def insert_piece(self, party_id: str, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None:
player = await self.get_player(party_id, 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, party_id: str, player_id: PlayerId, piece: Piece) -> None:
player = await self.get_player(party_id, 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, party_id: str, player: Player) -> None:
async with SQLiteHelper(self.database_path) as cursor:
await cursor.execute(
'''replace into players
(party_id, created, nick, job, bis_link, priority)
values
(?, ?, ?, ?, ?, ?)''',
(party_id, Database.now(), player.nick, player.job.name, player.link, player.priority)
)
async def insert_user(self, party_id: str, 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
(party_id, username, password, permission)
values
(?, ?, ?, ?)''',
(party_id, user.username, password, user.permission)
)

View File

@ -0,0 +1,36 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
# because sqlite3 does not support context management
import aiosqlite
from types import TracebackType
from typing import Any, Dict, Optional, Type
def dict_factory(cursor: aiosqlite.Cursor, row: aiosqlite.Row) -> Dict[str, Any]:
return {
key: value
for key, value in zip([column[0] for column in cursor.description], row)
}
class SQLiteHelper():
def __init__(self, database_path: str) -> None:
self.database_path = database_path
async def __aenter__(self) -> aiosqlite.Cursor:
self.conn = await aiosqlite.connect(self.database_path)
self.conn.row_factory = dict_factory
await self.conn.execute('''pragma foreign_keys = on''')
return await self.conn.cursor()
async def __aexit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException],
traceback: Optional[TracebackType]) -> None:
await self.conn.commit()
await self.conn.close()

View File

@ -0,0 +1,9 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
__version__ = '0.1.1'

View File

View File

@ -0,0 +1,16 @@
#
# 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()

140
src/ffxivbis/models/bis.py Normal file
View File

@ -0,0 +1,140 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
import itertools
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Type, Union
from .job import Job
from .piece import Piece
from .serializable import Serializable
from .upgrade import Upgrade
@dataclass
class BiSLink(Serializable):
nick: str
job: Job
link: str
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'job': {
'description': 'player job name',
'$ref': cls.model_ref('Job')
},
'link': {
'description': 'link to BiS set',
'example': 'https://ffxiv.ariyala.com/19V5R',
'type': 'string'
},
'nick': {
'description': 'player nick name',
'example': 'Siuan Sanche',
'type': 'string'
}
}
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return ['job', 'link', 'nick']
@dataclass
class BiS(Serializable):
weapon: Optional[Piece] = None
head: Optional[Piece] = None
body: Optional[Piece] = None
hands: Optional[Piece] = None
waist: Optional[Piece] = None
legs: Optional[Piece] = None
feet: Optional[Piece] = None
ears: Optional[Piece] = None
neck: Optional[Piece] = None
wrist: Optional[Piece] = None
left_ring: Optional[Piece] = None
right_ring: Optional[Piece] = None
@property
def pieces(self) -> List[Piece]:
return [piece for piece in self.__dict__.values() if isinstance(piece, Piece)]
@property
def upgrades_required(self) -> Dict[Upgrade, int]:
return {
upgrade: len(list(pieces))
for upgrade, pieces in itertools.groupby(self.pieces, lambda piece: piece.upgrade)
}
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'weapon': {
'description': 'weapon part of BiS',
'$ref': cls.model_ref('Piece')
},
'head': {
'description': 'head part of BiS',
'$ref': cls.model_ref('Piece')
},
'body': {
'description': 'body part of BiS',
'$ref': cls.model_ref('Piece')
},
'hands': {
'description': 'hands part of BiS',
'$ref': cls.model_ref('Piece')
},
'waist': {
'description': 'waist part of BiS',
'$ref': cls.model_ref('Piece')
},
'legs': {
'description': 'legs part of BiS',
'$ref': cls.model_ref('Piece')
},
'feet': {
'description': 'feet part of BiS',
'$ref': cls.model_ref('Piece')
},
'ears': {
'description': 'ears part of BiS',
'$ref': cls.model_ref('Piece')
},
'neck': {
'description': 'neck part of BiS',
'$ref': cls.model_ref('Piece')
},
'wrist': {
'description': 'wrist part of BiS',
'$ref': cls.model_ref('Piece')
},
'left_ring': {
'description': 'left_ring part of BiS',
'$ref': cls.model_ref('Piece')
},
'right_ring': {
'description': 'right_ring part of BiS',
'$ref': cls.model_ref('Piece')
}
}
def has_piece(self, piece: Union[Piece, Upgrade]) -> bool:
if isinstance(piece, Piece):
return piece in self.pieces
elif isinstance(piece, Upgrade):
return self.upgrades_required.get(piece) is not None
return False
def set_item(self, piece: Union[Piece, Upgrade]) -> None:
setattr(self, piece.name, piece)
def remove_item(self, piece: Union[Piece, Upgrade]) -> None:
setattr(self, piece.name, None)

View File

@ -0,0 +1,36 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from dataclasses import dataclass
from typing import Any, Dict, List, Type
from .serializable import Serializable
@dataclass
class Error(Serializable):
message: str
arguments: Dict[str, Any]
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'arguments': {
'description': 'arguments passed to request',
'type': 'object',
'additionalProperties': True
},
'message': {
'description': 'error message',
'type': 'string'
}
}
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return ['arguments', 'message']

View File

@ -0,0 +1,87 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from __future__ import annotations
from enum import auto
from typing import Tuple
from .piece import Piece, PieceAccessory, Weapon
from .serializable import SerializableEnum
class Job(SerializableEnum):
PLD = auto()
WAR = auto()
DRK = auto()
GNB = auto()
WHM = auto()
SCH = auto()
AST = auto()
MNK = auto()
DRG = auto()
NIN = auto()
SAM = auto()
BRD = auto()
MCH = auto()
DNC = auto()
BLM = auto()
SMN = auto()
RDM = auto()
@staticmethod
def group_accs_dex() -> Tuple:
return Job.group_ranges() + (Job.NIN,)
@staticmethod
def group_accs_str() -> Tuple:
return Job.group_mnk() + (Job.DRG,)
@staticmethod
def group_casters() -> Tuple:
return (Job.BLM, Job.SMN, Job.RDM)
@staticmethod
def group_healers() -> Tuple:
return (Job.WHM, Job.SCH, Job.AST)
@staticmethod
def group_mnk() -> Tuple:
return (Job.MNK, Job.SAM)
@staticmethod
def group_ranges() -> Tuple:
return (Job.BRD, Job.MCH, Job.DNC)
@staticmethod
def group_tanks() -> Tuple:
return (Job.PLD, Job.WAR, Job.DRK, Job.GNB)
@staticmethod
def has_same_loot(left: Job, right: Job, piece: Piece) -> bool:
# same jobs, alright
if left == right:
return True
# weapons are unique per class always
if isinstance(piece, Weapon):
return False
# group comparison
for group in (Job.group_casters(), Job.group_healers(), Job.group_mnk(), Job.group_ranges(), Job.group_tanks()):
if left in group and right in group:
return True
# accessories group comparison
if isinstance(Piece, PieceAccessory):
for group in (Job.group_accs_dex(), Job.group_accs_str()):
if left in group and right in group:
return True
return False

View File

@ -0,0 +1,37 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from dataclasses import dataclass
from typing import Any, Dict, List, Type, Union
from .piece import Piece
from .serializable import Serializable
from .upgrade import Upgrade
@dataclass
class Loot(Serializable):
player_id: int
piece: Union[Piece, Upgrade]
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'piece': {
'description': 'player piece',
'$ref': cls.model_ref('Piece')
},
'player_id': {
'description': 'player identifier',
'$ref': cls.model_ref('PlayerId')
}
}
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return ['piece', 'player_id']

View File

@ -0,0 +1,168 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Type, Union
from ffxivbis.core.exceptions import InvalidDataRow
from .serializable import Serializable
from .upgrade import Upgrade
@dataclass
class Piece(Serializable):
is_tome: bool
name: str
@property
def upgrade(self) -> Upgrade:
if not self.is_tome:
return Upgrade.NoUpgrade
elif isinstance(self, Waist) or isinstance(self, PieceAccessory):
return Upgrade.AccessoryUpgrade
elif isinstance(self, Weapon):
return Upgrade.WeaponUpgrade
elif isinstance(self, PieceGear):
return Upgrade.GearUpgrade
return Upgrade.NoUpgrade
@staticmethod
def available() -> List[str]:
return [
'weapon',
'head', 'body', 'hands', 'waist', 'legs', 'feet',
'ears', 'neck', 'wrist', 'left_ring', 'right_ring'
]
@classmethod
def get(cls: Type[Piece], data: Mapping[str, Any]) -> Union[Piece, Upgrade]:
try:
piece_type = data.get('piece') or data.get('name')
if piece_type is None:
raise KeyError
is_tome = data['is_tome'] in ('yes', 'on', '1', 1, True)
except KeyError:
raise InvalidDataRow(data)
if piece_type.lower() == 'weapon':
return Weapon(is_tome)
elif piece_type.lower() == 'head':
return Head(is_tome)
elif piece_type.lower() == 'body':
return Body(is_tome)
elif piece_type.lower() == 'hands':
return Hands(is_tome)
elif piece_type.lower() == 'waist':
return Waist(is_tome)
elif piece_type.lower() == 'legs':
return Legs(is_tome)
elif piece_type.lower() == 'feet':
return Feet(is_tome)
elif piece_type.lower() == 'ears':
return Ears(is_tome)
elif piece_type.lower() == 'neck':
return Neck(is_tome)
elif piece_type.lower() == 'wrist':
return Wrist(is_tome)
elif piece_type.lower() in ('left_ring', 'right_ring', 'ring'):
return Ring(is_tome, piece_type.lower())
elif piece_type.lower() in Upgrade.dict_types():
return Upgrade[piece_type]
else:
raise InvalidDataRow(data)
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'is_tome': {
'description': 'is this piece tome gear or not',
'type': 'boolean'
},
'name': {
'description': 'piece name',
'type': 'string'
}
}
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return ['is_tome', 'name']
@dataclass
class PieceAccessory(Piece):
pass
@dataclass
class PieceGear(Piece):
pass
@dataclass
class Weapon(Piece):
name: str = 'weapon'
@dataclass
class Head(PieceGear):
name: str = 'head'
@dataclass
class Body(PieceGear):
name: str = 'body'
@dataclass
class Hands(PieceGear):
name: str = 'hands'
@dataclass
class Waist(PieceGear):
name: str = 'waist'
@dataclass
class Legs(PieceGear):
name: str = 'legs'
@dataclass
class Feet(PieceGear):
name: str = 'feet'
@dataclass
class Ears(PieceAccessory):
name: str = 'ears'
@dataclass
class Neck(PieceAccessory):
name: str = 'neck'
@dataclass
class Wrist(PieceAccessory):
name: str = 'wrist'
@dataclass
class Ring(PieceAccessory):
name: str = 'ring'
# override __eq__method to be able to compare left/right rings
def __eq__(self, other: Any) -> bool:
if not isinstance(other, Ring):
return False
return self.is_tome == other.is_tome

View File

@ -0,0 +1,201 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Type, Union
from .bis import BiS
from .job import Job
from .piece import Piece
from .serializable import Serializable
from .upgrade import Upgrade
@dataclass
class PlayerId(Serializable):
job: Job
nick: str
@property
def pretty_name(self) -> str:
return f'{self.nick} ({self.job.name})'
@classmethod
def from_pretty_name(cls: Type[PlayerId], value: str) -> Optional[PlayerId]:
matches = re.search('^(?P<nick>.*) \((?P<job>[A-Z]+)\)$', value)
if matches is None:
return None
return PlayerId(Job[matches.group('job')], matches.group('nick'))
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'job': {
'description': 'player job name',
'$ref': cls.model_ref('Job')
},
'nick': {
'description': 'player nick name',
'type': 'string'
}
}
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return ['job', 'nick']
def __hash__(self) -> int:
return hash(str(self))
@dataclass
class PlayerIdWithCounters(PlayerId):
is_required: bool
priority: int
loot_count: int
loot_count_bis: int
loot_count_total: int
bis_count_total: int
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'bis_count_total': {
'description': 'total savage pieces in BiS',
'type': 'integer'
},
'is_required': {
'description': 'is item required by BiS or not',
'type': 'boolean'
},
'job': {
'description': 'player job name',
'$ref': cls.model_ref('Job')
},
'loot_count': {
'description': 'count of this item which was already looted',
'type': 'integer'
},
'loot_count_bis': {
'description': 'count of BiS items which were already looted',
'type': 'integer'
},
'loot_count_total': {
'description': 'total count of items which were looted',
'type': 'integer'
},
'nick': {
'description': 'player nick name',
'type': 'string'
},
'priority': {
'description': 'player loot priority',
'type': 'integer'
}
}
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return ['bis_count_total', 'is_required', 'job', 'loot_count',
'loot_count_bis', 'loot_count_total', 'nick', 'priority']
@dataclass
class Player(Serializable):
job: Job
nick: str
bis: BiS
loot: List[Union[Piece, Upgrade]]
link: Optional[str] = None
priority: int = 0
@property
def player_id(self) -> PlayerId:
return PlayerId(self.job, self.nick)
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'bis': {
'description': 'player BiS',
'$ref': cls.model_ref('BiS')
},
'job': {
'description': 'player job name',
'$ref': cls.model_ref('Job')
},
'link': {
'description': 'link to player BiS',
'type': 'string'
},
'loot': {
'description': 'player looted items',
'type': 'array',
'items': {
'anyOf': [
{'$ref': cls.model_ref('Piece')},
{'$ref': cls.model_ref('Upgrade')}
]
}
},
'nick': {
'description': 'player nick name',
'type': 'string'
},
'priority': {
'description': 'player loot priority',
'type': 'integer'
}
}
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return ['bis', 'job', 'loot', 'nick', 'priority']
def player_id_with_counters(self, piece: Union[Piece, Upgrade, None]) -> PlayerIdWithCounters:
return PlayerIdWithCounters(self.job, self.nick, self.is_required(piece), self.priority,
abs(self.loot_count(piece)), abs(self.loot_count_bis(piece)),
abs(self.loot_count_total(piece)), abs(self.bis_count_total(piece)))
# ordering methods
def is_required(self, piece: Union[Piece, Upgrade, None]) -> bool:
if piece is None:
return False
# lets check if it is even in bis
if not self.bis.has_piece(piece):
return False
if isinstance(piece, Piece):
# alright it is in is, lets check if he even got it
return self.loot_count(piece) == 0
elif isinstance(piece, Upgrade):
# alright it lets check how much upgrades does they need
return self.bis.upgrades_required[piece] > self.loot_count(piece)
return False
def loot_count(self, piece: Union[Piece, Upgrade, None]) -> int:
if piece is None:
return -self.loot_count_total(piece)
return -self.loot.count(piece)
def loot_count_bis(self, _: Union[Piece, Upgrade, None]) -> int:
return -len([piece for piece in self.loot if self.bis.has_piece(piece)])
def loot_count_total(self, _: Union[Piece, Upgrade, None]) -> int:
return -len(self.loot)
def bis_count_total(self, _: Union[Piece, Upgrade, None]) -> int:
return len([piece for piece in self.bis.pieces if not piece.is_tome])
def loot_priority(self, _: Union[Piece, Upgrade, None]) -> int:
return self.priority

View File

@ -0,0 +1,35 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from typing import Any, Dict, List, Type
from .serializable import Serializable
class PlayerEdit(Serializable):
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'action': {
'description': 'action to perform',
'$ref': cls.model_ref('Action')
},
'job': {
'description': 'player job name to edit',
'$ref': cls.model_ref('Job')
},
'nick': {
'description': 'player nick name to edit',
'type': 'string'
}
}
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return ['action', 'nick', 'job']

View File

@ -0,0 +1,57 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from __future__ import annotations
from enum import Enum
from typing import Any, Dict, List, Type
class Serializable:
@classmethod
def model_name(cls: Type[Serializable]) -> str:
return cls.__name__
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
raise NotImplementedError
@staticmethod
def model_ref(model_name: str, model_group: str = 'schemas') -> str:
return f'#/components/{model_group}/{model_name}'
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return []
@classmethod
def model_spec(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'type': cls.model_type(),
'properties': cls.model_properties(),
'required': cls.model_required()
}
@classmethod
def model_type(cls: Type[Serializable]) -> str:
return 'object'
class SerializableEnum(Serializable, Enum):
@classmethod
def model_spec(cls: Type[SerializableEnum]) -> Dict[str, Any]:
return {
'type': cls.model_type(),
'enum': [item.name for item in cls]
}
@classmethod
def model_type(cls: Type[Serializable]) -> str:
return 'string'

View File

@ -0,0 +1,23 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from enum import auto
from typing import List
from .serializable import SerializableEnum
class Upgrade(SerializableEnum):
NoUpgrade = auto()
AccessoryUpgrade = auto()
GearUpgrade = auto()
WeaponUpgrade = auto()
@staticmethod
def dict_types() -> List[str]:
return list(map(lambda t: t.name.lower(), Upgrade))

View File

@ -0,0 +1,42 @@
#
# Copyright (c) 2019 Evgeniy Alekseev.
#
# This file is part of ffxivbis
# (see https://github.com/arcan1s/ffxivbis).
#
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
#
from dataclasses import dataclass
from typing import Any, Dict, List, Type
from .serializable import Serializable
@dataclass
class User(Serializable):
username: str
password: str
permission: str
@classmethod
def model_properties(cls: Type[Serializable]) -> Dict[str, Any]:
return {
'password': {
'description': 'user password',
'type': 'string'
},
'permission': {
'default': 'get',
'description': 'user action permissions',
'type': 'string',
'enum': ['admin', 'get', 'post']
},
'username': {
'description': 'user name',
'type': 'string'
}
}
@classmethod
def model_required(cls: Type[Serializable]) -> List[str]:
return ['password', 'username']

View File

@ -1,36 +0,0 @@
create table players (
party_id text not null,
player_id bigserial unique,
created bigint 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 bigserial unique,
player_id bigint not null,
created bigint 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 bigint not null,
created bigint 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 bigserial unique,
username text not null,
password text not null,
permission text not null);
create unique index users_username_idx on users(party_id, username);

View File

@ -1,5 +0,0 @@
update loot set piece = 'left ring' where piece = 'leftRing';
update loot set piece = 'right ring' where piece = 'rightRing';
update bis set piece = 'left ring' where piece = 'leftRing';
update bis set piece = 'right ring' where piece = 'rightRing';

View File

@ -1,5 +0,0 @@
create table parties (
player_id bigserial unique,
party_name text not null,
party_alias text);
create unique index parties_party_name_idx on parties(party_name);

View File

@ -1,17 +0,0 @@
-- loot
alter table loot add column piece_type text;
update loot set piece_type = 'Tome' where is_tome = 1;
update loot set piece_type = 'Savage' where is_tome = 0;
alter table loot alter column piece_type set not null;
alter table loot drop column is_tome;
-- bis
alter table bis add column piece_type text;
update bis set piece_type = 'Tome' where is_tome = 1;
update bis set piece_type = 'Savage' where is_tome = 0;
alter table bis alter column piece_type set not null;
alter table bis drop column is_tome;

View File

@ -1 +0,0 @@
alter table loot add column is_free_loot integer not null default 0;

View File

@ -1,2 +0,0 @@
drop index bis_piece_player_id_idx;
create index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -1 +0,0 @@
alter table parties rename column player_id to party_id;

View File

@ -1,2 +0,0 @@
drop index bis_piece_type_player_id_idx;
create unique index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -1,36 +0,0 @@
create table players (
party_id text not null,
player_id integer primary key autoincrement,
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 autoincrement,
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 autoincrement,
username text not null,
password text not null,
permission text not null);
create unique index users_username_idx on users(party_id, username);

View File

@ -1,5 +0,0 @@
update loot set piece = 'left ring' where piece = 'leftRing';
update loot set piece = 'right ring' where piece = 'rightRing';
update bis set piece = 'left ring' where piece = 'leftRing';
update bis set piece = 'right ring' where piece = 'rightRing';

View File

@ -1,5 +0,0 @@
create table parties (
player_id integer primary key autoincrement,
party_name text not null,
party_alias text);
create unique index parties_party_name_idx on parties(party_name);

View File

@ -1,42 +0,0 @@
-- loot
alter table loot add column piece_type text;
update loot set piece_type = 'Tome' where is_tome = 1;
update loot set piece_type = 'Savage' where is_tome = 0;
create table loot_new (
loot_id integer primary key autoincrement,
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into loot_new select loot_id, player_id, created, piece, piece_type, job from loot;
drop index loot_owner_idx;
drop table loot;
alter table loot_new rename to loot;
create index loot_owner_idx on loot(player_id);
-- bis
alter table bis add column piece_type text;
update bis set piece_type = 'Tome' where is_tome = 1;
update bis set piece_type = 'Savage' where is_tome = 0;
create table bis_new (
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into bis_new select player_id, created, piece, piece_type, job from bis;
drop index bis_piece_player_id_idx;
drop table bis;
alter table bis_new rename to bis;
create unique index bis_piece_player_id_idx on bis(player_id, piece);

View File

@ -1,20 +0,0 @@
alter table loot add column is_free_loot integer;
update loot set is_free_loot = 0;
create table loot_new (
loot_id integer primary key autoincrement,
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
is_free_loot integer not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into loot_new select loot_id, player_id, created, piece, piece_type, job, is_free_loot from loot;
drop index loot_owner_idx;
drop table loot;
alter table loot_new rename to loot;
create index loot_owner_idx on loot(player_id);

View File

@ -1,2 +0,0 @@
drop index bis_piece_player_id_idx;
create index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -1,11 +0,0 @@
create table parties_new (
party_id integer primary key autoincrement,
party_name text not null,
party_alias text);
insert into parties_new select player_id, party_name, party_alias from parties;
drop index parties_party_name_idx;
drop table parties;
alter table parties_new rename to parties;
create unique index parties_party_name_idx on parties(party_name);

View File

@ -1,2 +0,0 @@
drop index bis_piece_type_player_id_idx;
create unique index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FFXIV loot helper API</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Embed elements Elements via Web Component -->
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css" type="text/css">
<link rel="shortcut icon" href="/static/favicon.ico">
</head>
<body>
<elements-api
apiDescriptionUrl="/api-docs/swagger.json"
router="hash"
layout="sidebar"
/>
</body>
</html>

View File

@ -1,365 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Best in slot</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link rel="stylesheet" href="/static/styles.css" type="text/css">
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg justify-content-between align-items-center border-bottom">
<a class="navbar-brand" id="navbar-title">Party</a>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-bis">best in slot</a>
<a class="nav-item nav-link" id="navbar-loot">looted items</a>
</ul>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-users">users</a>
</ul>
</nav>
</div>
<div id="alert-placeholder" class="container"></div>
<div class="container">
<h2>Best in slot</h2>
</div>
<div class="container">
<div id="toolbar">
<button id="update-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#update-bis-dialog" hidden>
<i class="bi bi-plus"></i> update
</button>
<button class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i> reload
</button>
<button id="remove-btn" class="btn btn-danger" onclick="removePiece()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
</div>
<table id="bis" class="table table-striped table-hover"
data-click-to-select="true"
data-export-options='{"fileName": "bis"}'
data-page-list="[25, 50, 100, all]"
data-page-size="25"
data-pagination="true"
data-resizable="true"
data-search="true"
data-show-columns="true"
data-show-columns-search="true"
data-show-columns-toggle-all="true"
data-show-export="true"
data-show-fullscreen="true"
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="nick"
data-sort-order="asc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="nick">nick</th>
<th data-sortable="true" data-switchable="false" data-field="job">job</th>
<th data-sortable="true" data-field="piece">piece</th>
<th data-sortable="true" data-field="pieceType">piece type</th>
</tr>
</thead>
</table>
</div>
<div id="update-bis-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<form class="modal-content" action="javascript:" onsubmit="updateBis()">
<div class="modal-header form-group row">
<div class="btn-group" role="group" aria-label="Update bis">
<input id="add-piece-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="add-piece-btn">add piece</label>
<input id="update-bis-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off">
<label class="btn btn-outline-primary" for="update-bis-btn">update bis</label>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
</div>
<div class="modal-body">
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="player">player</label>
<div class="col-sm-8">
<select id="player" name="player" class="form-control" title="player" required></select>
</div>
</div>
<div id="piece-row" class="form-group row">
<label class="col-sm-4 col-form-label" for="piece">piece</label>
<div class="col-sm-8">
<select id="piece" name="piece" class="form-control" title="piece"></select>
</div>
</div>
<div id="piece-type-row" class="form-group row">
<label class="col-sm-4 col-form-label" for="piece-type">piece type</label>
<div class="col-sm-8">
<select id="piece-type" name="pieceType" class="form-control" title="piece-type"></select>
</div>
</div>
<div id="bis-link-row" class="form-group row" style="display: none">
<label class="col-sm-4 col-form-label" for="bis-link">link</label>
<div class="col-sm-8">
<input id="bis-link" name="link" class="form-control" placeholder="link to bis">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-add-bis-btn" type="submit" class="btn btn-primary">add</button>
<button id="submit-set-bis-btn" type="submit" class="btn btn-primary" style="display: none">set</button>
</div>
</div>
</form>
</div>
</div>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<li><a class="nav-link" href="/" title="home">home</a></li>
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul>
<ul class="nav">
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://unpkg.com/tableexport.jquery.plugin/tableExport.min.js"></script>
<script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script>
<script src="/static/load.js"></script>
<script>
const [partyId, isReadOnly] = getPartyId();
const table = $("#bis");
const removeButton = $("#remove-btn");
const updateButton = $("#update-btn");
const submitAddBisButton = $("#submit-add-bis-btn");
const submitSetBisButton = $("#submit-set-bis-btn");
const updateBisDialog = $("#update-bis-dialog");
const addPieceButton = $("#add-piece-btn");
const updateBisButton = $("#update-bis-btn");
const bisLinkRow = $("#bis-link-row");
const pieceRow = $("#piece-row");
const pieceTypeRow = $("#piece-type-row");
const linkInput = $("#bis-link");
const pieceInput = $("#piece");
const pieceTypeInput = $("#piece-type");
const playerInput = $("#player");
function addPiece() {
const player = getCurrentOption(playerInput);
$.ajax({
url: `/api/v1/party/${partyId}/bis`,
data: JSON.stringify({
action: "add",
piece: {
pieceType: pieceTypeInput.val(),
job: player.dataset.job,
piece: pieceInput.val(),
},
playerId: {
partyId: partyId,
nick: player.dataset.nick,
job: player.dataset.job,
},
}),
type: "POST",
contentType: "application/json",
success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
updateBisDialog.modal("hide");
return true; // action expects boolean result
}
function hideControls() {
removeButton.attr("hidden", isReadOnly);
updateButton.attr("hidden", isReadOnly);
}
function hideLinkPart() {
bisLinkRow.hide();
linkInput.prop("required", false);
submitSetBisButton.hide();
pieceRow.show();
pieceTypeRow.show();
pieceInput.prop("required", true);
pieceTypeInput.prop("required", true);
submitAddBisButton.show();
}
function hidePiecePart() {
bisLinkRow.show();
linkInput.prop("required", true);
submitSetBisButton.show();
pieceRow.hide();
pieceTypeRow.hide();
pieceInput.prop("required", false);
pieceTypeInput.prop("required", false);
submitAddBisButton.hide();
}
function reload() {
table.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}`,
type: "GET",
dataType: "json",
success: function (data) {
const items = data.map(function (player) {
return player.bis.map(function (loot) {
return {
nick: player.nick,
job: player.job,
piece: loot.piece,
pieceType: loot.pieceType,
};
});
});
const payload = items.reduce(function (left, right) { return left.concat(right); }, []);
table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
const options = data.map(function (player) {
const option = document.createElement("option");
option.innerText = formatPlayerId(player);
option.dataset.nick = player.nick;
option.dataset.job = player.job;
return option;
});
playerInput.empty().append(options);
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function removePiece() {
const pieces = table.bootstrapTable("getSelections");
pieces.map(function (loot) {
$.ajax({
url: `/api/v1/party/${partyId}/bis`,
data: JSON.stringify({
action: "remove",
piece: {
pieceType: loot.pieceType,
job: loot.job,
piece: loot.piece,
},
playerId: {
partyId: partyId,
job: loot.job,
nick: loot.nick,
},
}),
type: "POST",
contentType: "application/json",
success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
});
}
function reset() {
if (updateBisButton.is(":checked")) {
hidePiecePart();
}
if (addPieceButton.is(":checked")) {
hideLinkPart();
}
}
function setBis() {
const player = getCurrentOption(playerInput);
$.ajax({
url: `/api/v1/party/${partyId}/bis`,
data: JSON.stringify({
link: linkInput.val(),
playerId: {
partyId: partyId,
nick: player.dataset.nick,
job: player.dataset.job,
},
}),
type: "PUT",
contentType: "application/json",
success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
updateBisDialog.modal("hide");
return true; // action expects boolean result
}
function updateBis() {
if (updateBisButton.is(":checked")) {
return setBis();
}
if (addPieceButton.is(":checked")) {
return addPiece();
}
return false; // should not happen
}
$(function () {
setupFormClear(updateBisDialog, reset);
setupRemoveButton(table, removeButton);
loadHeader(partyId);
loadTypes("/api/v1/types/pieces", pieceInput);
loadTypes("/api/v1/types/pieces/types", pieceTypeInput);
hideControls();
updateBisButton.click(function () { reset(); });
addPieceButton.click(function () { reset(); });
table.bootstrapTable({});
reload();
reset();
});
</script>
</body>
</html>

View File

@ -1,185 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FFXIV loot helper</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="/static/styles.css" type="text/css">
</head>
<body>
<div id="alert-placeholder" class="container"></div>
<div class="container mb-5">
<div class="form-group row">
<div class="btn-group" role="group" aria-label="Sign in">
<input id="signin-btn" name="signin" type="radio" class="btn-check" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="signin-btn">login to existing party</label>
<input id="signup-btn" name="signin" type="radio" class="btn-check" autocomplete="off">
<label class="btn btn-outline-primary" for="signup-btn">create a new party</label>
</div>
</div>
</div>
<form id="signup-form" class="container mb-5" style="display: none">
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="alias">party alias</label>
<div class="col-sm-10">
<input id="alias" name="alias" class="form-control" placeholder="alias">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="username">username</label>
<div class="col-sm-10">
<input id="username" name="username" class="form-control" placeholder="admin user name" onkeyup="disableAddButton()">
</div>
</div>
<div class="form-group row">
<label for="password" class="col-sm-2 col-form-label">password</label>
<div class="col-sm-10">
<input id="password" name="password" type="password" class="form-control" placeholder="admin password" onkeyup="disableAddButton()">
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button id="add-btn" type="button" class="btn btn-primary" onclick="createParty()" disabled>add</button>
</div>
</div>
</form>
<form id="signin-form" class="container mb-5">
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="party-id">party id</label>
<div class="col-sm-10">
<input id="party-id" name="partyId" class="form-control" placeholder="id" onkeyup="disableRedirectButton()">
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button id="redirect-btn" type="button" class="btn btn-primary" onclick="redirectToParty()" disabled>go</button>
</div>
</div>
</form>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul>
<ul class="nav">
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script>
const signinButton = $("#signin-btn");
const signupButton = $("#signup-btn");
const addButton = $("#add-btn");
const redirectButton = $("#redirect-btn");
const signinForm = $("#signin-form");
const signupForm = $("#signup-form");
const aliasInput = $("#alias");
const partyIdInput = $("#party-id");
const passwordInput = $("#password");
const usernameInput = $("#username");
function createDescription(partyId) {
$.ajax({
url: `/api/v1/party/${partyId}/description`,
data: JSON.stringify({
partyId: partyId,
partyAlias: aliasInput.val(),
}),
type: "POST",
contentType: "application/json",
success: function (_) { doRedirect(partyId); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function createParty() {
$.ajax({
url: `/api/v1/party`,
data: JSON.stringify({
partyId: "",
username: usernameInput.val(),
password: passwordInput.val(),
permission: "admin",
}),
type: "PUT",
contentType: "application/json",
dataType: "json",
success: function (data) {
if (aliasInput.val()) {
createDescription(data.partyId);
} else {
doRedirect(data.partyId);
}
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function disableAddButton() {
addButton.attr("disabled", !(passwordInput.val() && usernameInput.val()));
}
function disableRedirectButton() {
redirectButton.attr("disabled", !partyIdInput.val());
}
function doRedirect(partyId) {
location.href = `/party/${partyId}`;
}
function hideSigninPart() {
signinForm.hide();
signupForm.show();
}
function hideSignupPart() {
signinForm.show();
signupForm.hide();
}
function redirectToParty() {
return doRedirect(partyIdInput.val());
}
function reset() {
signinForm.trigger("reset");
signupForm.trigger("reset");
if (signinButton.is(":checked")) {
hideSignupPart();
}
if (signupButton.is(":checked")) {
hideSigninPart();
}
}
$(function () {
signinButton.click(function () { reset(); });
signupButton.click(function () { reset(); });
});
</script>
</body>
</html>

View File

@ -1,346 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Loot table</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link rel="stylesheet" href="/static/styles.css" type="text/css">
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg justify-content-between align-items-center border-bottom">
<a class="navbar-brand" id="navbar-title">Party</a>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-bis">best in slot</a>
<a class="nav-item nav-link" id="navbar-loot">looted items</a>
</ul>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-users">users</a>
</ul>
</nav>
</div>
<div id="alert-placeholder" class="container"></div>
<div class="container">
<h2>Looted items</h2>
</div>
<div class="container">
<div id="toolbar">
<button id="add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-loot-dialog" hidden>
<i class="bi bi-plus"></i> add
</button>
<button class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i> reload
</button>
<button id="remove-btn" class="btn btn-danger" onclick="removeLoot()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
</div>
<table id="loot" class="table table-striped table-hover"
data-click-to-select="true"
data-export-options='{"fileName": "loot"}'
data-page-list="[25, 50, 100, all]"
data-page-size="25"
data-pagination="true"
data-resizable="true"
data-search="true"
data-show-columns="true"
data-show-columns-search="true"
data-show-columns-toggle-all="true"
data-show-export="true"
data-show-fullscreen="true"
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="timestamp"
data-sort-order="desc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="nick">nick</th>
<th data-sortable="true" data-switchable="false" data-field="job">job</th>
<th data-sortable="true" data-field="piece">piece</th>
<th data-sortable="true" data-field="pieceType">piece type</th>
<th data-sortable="true" data-field="isFreeLoot">is free loot</th>
<th data-sortable="true" data-field="timestamp">date</th>
</tr>
</thead>
</table>
</div>
<div id="add-loot-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog modal-lg" role="document">
<form class="modal-content" action="javascript:" onsubmit="addLoot()">
<div class="modal-header">
<h4 class="modal-title">add looted piece</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="player">player</label>
<div class="col-sm-8">
<select id="player" name="player" class="form-control" title="player" required></select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="piece">piece</label>
<div class="col-sm-8">
<select id="piece" name="piece" class="form-control" title="piece" required></select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="piece-type">piece type</label>
<div class="col-sm-8">
<select id="piece-type" name="pieceType" class="form-control" title="pieceType" required></select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="job">job</label>
<div class="col-sm-8">
<select id="job" name="job" class="form-control" title="job" required></select>
</div>
</div>
<div class="form-group row">
<div class="col-sm-4"></div>
<div class="col-sm-8">
<div class="form-check">
<input id="free-loot" name="freeLoot" type="checkbox" class="form-check-input">
<label class="form-check-label" for="free-loot">as free loot</label>
</div>
</div>
</div>
<table id="stats" class="table table-striped table-hover">
<thead class="table-primary">
<tr>
<th data-field="nick">nick</th>
<th data-field="job">job</th>
<th data-field="isRequired">required</th>
<th data-field="lootCount">these pieces looted</th>
<th data-field="lootCountBiS">total bis pieces looted</th>
<th data-field="lootCountTotal">total pieces looted</th>
</tr>
</thead>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button type="button" class="btn btn-secondary" onclick="suggestLoot()">suggest</button>
<button type="submit" class="btn btn-primary">add</button>
</div>
</form>
</div>
</div>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<li><a class="nav-link" href="/" title="home">home</a></li>
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul>
<ul class="nav">
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://unpkg.com/tableexport.jquery.plugin/tableExport.min.js"></script>
<script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script>
<script src="/static/load.js"></script>
<script>
const [partyId, isReadOnly] = getPartyId();
const table = $("#loot");
const stats = $("#stats");
const addButton = $("#add-btn");
const removeButton = $("#remove-btn");
const addLootDialog = $("#add-loot-dialog");
const freeLootInput = $("#free-loot");
const jobInput = $("#job");
const pieceInput = $("#piece");
const pieceTypeInput = $("#piece-type");
const playerInput = $("#player");
function addLoot() {
const player = getCurrentOption(playerInput);
$.ajax({
url: `/api/v1/party/${partyId}/loot`,
data: JSON.stringify({
action: "add",
piece: {
pieceType: pieceTypeInput.val(),
job: player.dataset.job,
piece: pieceInput.val(),
},
playerId: {
partyId: partyId,
nick: player.dataset.nick,
job: player.dataset.job,
},
isFreeLoot: freeLootInput.is(":checked"),
}),
type: "POST",
contentType: "application/json",
success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
addLootDialog.modal("hide");
return true; // action expects boolean result
}
function hideControls() {
addButton.attr("hidden", isReadOnly);
removeButton.attr("hidden", isReadOnly);
}
function reload() {
table.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}`,
type: "GET",
dataType: "json",
success: function (data) {
const items = data.map(function (player) {
return player.loot.map(function (loot) {
return {
nick: player.nick,
job: player.job,
piece: loot.piece.piece,
pieceType: loot.piece.pieceType,
isFreeLoot: loot.isFreeLoot ? "yes" : "no",
timestamp: loot.timestamp,
};
});
});
const payload = items.reduce(function (left, right) { return left.concat(right); }, []);
table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
const options = data.map(function (player) {
const option = document.createElement("option");
option.innerText = formatPlayerId(player);
option.dataset.nick = player.nick;
option.dataset.job = player.job;
return option;
});
playerInput.empty().append(options);
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function removeLoot() {
const pieces = table.bootstrapTable("getSelections");
pieces.map(function (loot) {
$.ajax({
url: `/api/v1/party/${partyId}/loot`,
data: JSON.stringify({
action: "remove",
piece: {
pieceType: loot.pieceType,
job: loot.job,
piece: loot.piece,
},
playerId: {
partyId: partyId,
nick: loot.nick,
job: loot.job,
},
isFreeLoot: loot.isFreeLoot === "yes",
}),
type: "POST",
contentType: "application/json",
success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
});
}
function suggestLoot() {
stats.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}/loot`,
data: JSON.stringify({
pieceType: pieceTypeInput.val(),
job: jobInput.val(),
piece: pieceInput.val(),
}),
type: "PUT",
contentType: "application/json",
dataType: "json",
success: function (data) {
const payload = data.map(function (stat) {
return {
nick: stat.nick,
job: stat.job,
isRequired: stat.isRequired ? "yes" : "no",
lootCount: stat.lootCount,
lootCountBiS: stat.lootCountBiS,
lootCountTotal: stat.lootCountTotal,
};
});
stats.bootstrapTable("load", payload);
stats.bootstrapTable("uncheckAll");
stats.bootstrapTable("hideLoading");
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
$(function () {
setupFormClear(addLootDialog);
setupRemoveButton(table, removeButton);
loadHeader(partyId);
loadTypes("/api/v1/types/jobs/all", jobInput);
loadTypes("/api/v1/types/pieces", pieceInput);
loadTypes("/api/v1/types/pieces/types", pieceTypeInput);
hideControls();
table.bootstrapTable({});
stats.bootstrapTable({});
reload();
});
</script>
</body>
</html>

View File

@ -1,258 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FFXIV loot helper</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link rel="stylesheet" href="/static/styles.css" type="text/css">
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg justify-content-between align-items-center border-bottom">
<a class="navbar-brand" id="navbar-title">Party</a>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-bis">best in slot</a>
<a class="nav-item nav-link" id="navbar-loot">looted items</a>
</ul>
<ul class="navbar-nav">
<a class="nav-item nav-link" id="navbar-users">users</a>
</ul>
</nav>
</div>
<div id="alert-placeholder" class="container"></div>
<div class="container">
<div id="toolbar">
<button id="add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-player-dialog" hidden>
<i class="bi bi-plus"></i> add
</button>
<button class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i> reload
</button>
<button id="remove-btn" class="btn btn-danger" onclick="removePlayers()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
</div>
<table id="players" class="table table-striped table-hover"
data-click-to-select="true"
data-export-options='{"fileName": "players"}'
data-page-list="[25, 50, 100, all]"
data-page-size="25"
data-pagination="true"
data-resizable="true"
data-search="true"
data-show-columns="true"
data-show-columns-search="true"
data-show-columns-toggle-all="true"
data-show-export="true"
data-show-fullscreen="true"
data-show-search-clear-button="true"
data-single-select="true"
data-sortable="true"
data-sort-name="nick"
data-sort-order="asc"
data-sort-reset="true"
data-toolbar="#toolbar">
<thead class="table-primary">
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="nick">nick</th>
<th data-sortable="true" data-field="job">job</th>
<th data-sortable="true" data-field="link" data-formatter="bisLinkFormatter">best in slot link</th>
<th data-sortable="true" data-field="lootCountBiS">total bis pieces looted</th>
<th data-sortable="true" data-field="lootCountTotal">total pieces looted</th>
<th data-sortable="true" data-field="priority">priority</th>
</tr>
</thead>
</table>
</div>
<div id="add-player-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<form class="modal-content" action="javascript:" onsubmit="addPlayer()">
<div class="modal-header">
<h4 class="modal-title">add new player</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="nick">player name</label>
<div class="col-sm-8">
<input id="nick" name="nick" class="form-control" placeholder="nick" required>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="job">player job</label>
<div class="col-sm-8">
<select id="job" name="job" class="form-control" title="job" required></select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="link">link to best in slot</label>
<div class="col-sm-8">
<input id="link" name="link" class="form-control" placeholder="link to bis">
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label" for="priority">priority</label>
<div class="col-sm-8">
<input id="priority" name="priority" type="number" class="form-control" value="0">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button type="submit" class="btn btn-primary">add</button>
</div>
</form>
</div>
</div>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<li><a class="nav-link" href="/" title="home">home</a></li>
<li><a class="nav-link" href="/api-docs" title="api">api</a></li>
</ul>
<ul class="nav">
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis" title="sources">ffxivbis</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ffxivbis/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://unpkg.com/tableexport.jquery.plugin/tableExport.min.js"></script>
<script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script src="/static/utils.js"></script>
<script src="/static/load.js"></script>
<script>
const [partyId, isReadOnly] = getPartyId();
const table = $("#players");
const addButton = $("#add-btn");
const removeButton = $("#remove-btn");
const addPlayerDialog = $("#add-player-dialog");
const jobInput = $("#job");
const linkInput = $("#link");
const nickInput = $("#nick");
const priorityInput = $("#priority");
function addPlayer() {
$.ajax({
url: `/api/v1/party/${partyId}`,
data: JSON.stringify({
action: "add",
playerId: {
partyId: partyId,
job: jobInput.val(),
nick: nickInput.val(),
link: linkInput.val() || null,
priority: parseInt(priorityInput.val(), 10),
},
}),
type: "POST",
contentType: "application/json",
success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
addPlayerDialog.modal("hide");
return true; // action expects boolean result
}
function bisLinkFormatter(link, row) {
if (link) {
return `<a href="${safe(link)}" title="${safe(row.nick)} best in slot for ${safe(row.job)}">${safe(link)}</a>`;
} else {
return "-";
}
}
function hideControls() {
addButton.attr("hidden", isReadOnly);
removeButton.attr("hidden", isReadOnly);
}
function reload() {
table.bootstrapTable("showLoading");
$.ajax({
url: `/api/v1/party/${partyId}`,
type: "GET",
dataType: "json",
success: function (data) {
table.bootstrapTable("load", data);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
},
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
}
function removePlayers() {
const players = table.bootstrapTable("getSelections");
players.map(function (player) {
$.ajax({
url: `/api/v1/party/${partyId}`,
data: JSON.stringify({
action: "remove",
playerId: {
partyId: partyId,
job: player.job,
nick: player.nick,
},
}),
type: "POST",
contentType: "application/json",
success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
});
});
}
$(function () {
setupFormClear(addPlayerDialog);
setupRemoveButton(table, removeButton);
loadHeader(partyId);
loadTypes("/api/v1/types/jobs", jobInput);
hideControls();
table.bootstrapTable({});
reload();
});
</script>
</body>
</html>

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