Q3Stats is now open source! :)

This commit is contained in:
2017-03-06 20:33:09 +01:00
commit bfdcb87cef
197 changed files with 16395 additions and 0 deletions

3
q3stats/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
__version__ = '1.0'

67
q3stats/database.py Normal file
View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.database
================
This module contains database utilities.
"""
import sqlalchemy as sa
from sqlalchemy.ext.declarative import declarative_base
#: Base for declarative models
Base = declarative_base()
#: Current SQLAlchemy engine
db_engine = None
#: Current SQLAlchemy session
db_session = None
def create_engine(config):
"""Creates and initializes engine according to *config* dictionary.
Returns the initialized engine."""
engine = sa.create_engine(
config['SQLALCHEMY_URL'], **config.get('SQLALCHEMY_OPTS', {})
)
return engine
def create_session(engine, config, scoped=True):
"""Creates a session factory bound to *engine* according to *config*
dictionary. If *scoped* is ``True`` then the session will be scoped.
Otherwise, it'll be a standard ``sessionmaker``.
Returns the initialized session factory."""
Session = sa.orm.sessionmaker(
bind=engine, **config.get('SQLALCHEMY_SESSION_OPTS', {})
)
if scoped:
return sa.orm.scoped_session(Session)
return Session

0
q3stats/lib/__init__.py Normal file
View File

View File

@@ -0,0 +1,2 @@
from .day import get_day_chart
from .player import get_player_wins_chart, get_player_avg_accuracy_chart

64
q3stats/lib/charts/day.py Normal file
View File

@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.lib.charts.day
======================
This module contains functions for genering day chart.
"""
from collections import defaultdict
from q3stats.models import Game, Score
def get_day_chart(day, session):
"""Returns chart data for *day*."""
games = session.query(Game).\
filter_by(date=day).\
order_by(Game.time).\
all()
maps = []
game_ids = []
player_scores = defaultdict(dict)
for game in games:
maps.append(game.map)
game_ids.append(game.id)
for score in game.scores:
player_scores[score.player][game.id] = score.score
scores = []
players = sorted(player_scores.keys(), key=lambda x: x.lower())
for player in players:
scores.append({
'name': player,
'data': [
player_scores[player].get(game_id, 0) for game_id in game_ids
]
})
return maps, scores

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.lib.charts.player
=========================
This module contains functions for genering player charts.
"""
from collections import defaultdict
import six
from q3stats.lib import defs, queries
from q3stats.models import Game, Score
def get_player_wins_chart(session, player, agg_by='date'):
"""Returns wins chart data for *player*.
Use the *agg_by* kwargs to specify aggregation level. Valid values are
``date`` and ``map``."""
assert agg_by in ('date', 'map')
categories = []
wins_serie = []
losses_serie = []
player_sessions = queries.get_player_sessions(session, player)
if player_sessions:
scores = session.query(Score).\
join(Score.game).\
filter(Game.date.in_(player_sessions)).\
filter(Score.player == player).\
all()
if scores:
intermediary = defaultdict(lambda: [0, 0])
for score in scores:
agg = getattr(score.game, agg_by)
if score.score == score.game.fraglimit:
intermediary[agg][0] += 1
else:
intermediary[agg][1] += 1
categories = sorted(intermediary.keys())
for key in categories:
wins_serie.append(intermediary[key][0])
losses_serie.append(intermediary[key][1])
return categories, wins_serie, losses_serie
def get_player_avg_accuracy_chart(session, player, agg_by='date'):
"""Returns avg accuracy chart data for *player*.
Use the *agg_by* kwargs to specify aggregation level. Valid values are
``date`` and ``map``."""
assert agg_by in ('date', 'map')
categories = []
series = []
player_sessions = queries.get_player_sessions(session, player)
if player_sessions:
scores = session.query(Score).\
join(Score.game).\
filter(Game.date.in_(player_sessions)).\
filter(Score.player == player).\
all()
if scores:
intermediary = defaultdict(lambda: defaultdict(list))
for score in scores:
for weapon, weapon_stats in six.iteritems(score.weapons):
if weapon_stats['shots'] > 0:
agg = getattr(score.game, agg_by)
intermediary[agg][weapon].append(
weapon_stats['hits'] / float(weapon_stats['shots'])
)
categories = sorted(intermediary.keys())
weapons = list(defs.WEAPON_NAMES.keys())
weapons.remove('G')
weapons.sort()
for weapon in weapons:
serie = {
'name': defs.WEAPON_NAMES[weapon],
'data': []
}
for key in categories:
accuracies = intermediary[key][weapon]
if not accuracies:
serie['data'].append(None)
else:
serie['data'].append(
round(sum(accuracies) / len(accuracies), 4) * 100
)
series.append(serie)
return categories, series

62
q3stats/lib/config.py Normal file
View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.lib.config
==================
This module contains configuration handling functions.
"""
import os
from flask import Config
#: Default configuration file name.
DEFAULT_CONFIG_FILE = 'development.cfg'
def get_config_path(config_path=None):
"""Returns an absolute path to a config file."""
if config_path is None:
config_path = os.environ.get(
'Q3STATS_CONFIG_FILE', DEFAULT_CONFIG_FILE
)
if not os.path.isabs(config_path):
config_path = os.path.abspath(os.path.join(
os.getcwd(), config_path
))
return config_path
def get_config(config_path=None):
"""Returns an instance of ``flask.Config`` class with config loaded from
*config_path*."""
config_path = get_config_path(config_path)
dirname, basename = os.path.split(config_path)
config = Config(dirname)
config.from_pyfile(basename)
return config

61
q3stats/lib/defs.py Normal file
View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.lib.defs
================
This module contains constants used across the project.
"""
#: Standard date format.
DATE_FORMAT = '%Y-%m-%d'
#: Standard time format.
TIME_FORMAT = '%H:%M:%S'
#: Dictionary mapping Q3A weapon symbols to human readable names.
WEAPON_NAMES = {
'BFG': 'BFG10k',
'G': 'Gauntlet',
'GL': 'Grenade Launcher',
'LG': 'Lightning Gun',
'MG': 'Machine Gun',
'PG': 'Plasma Gun',
'RG': 'Railgun',
'RL': 'Rocket Launcher',
'SG': 'Shotgun',
}
#: Dictionary mapping Q3A item symbols to human readable names.
ITEM_NAMES = {
'GA': 'Green Armor',
'MH': 'Mega Health',
'RA': 'Red Armor',
'Regen': 'Regeneration',
'YA': 'Yellow Armor',
}
#: Dictionary mapping Q3A powerup symbols to human readable names.
POWERUP_NAMES = {
'Quad': 'Quad Damage'
}

105
q3stats/lib/queries.py Normal file
View File

@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.lib.queries
===================
This module contains functions for querying the database.
"""
import datetime
import sqlalchemy as sa
from q3stats.models import Game, Score
SQL_TOP_PLAYERS = """SELECT
scores.player,
sum(scores.%s) as sum_frags
FROM scores
LEFT JOIN games ON games.id = scores.game_id
WHERE
games.date >= :date_start
AND games.date < :date_end
GROUP BY scores.player
ORDER BY sum_frags DESC
LIMIT :limit"""
def get_game_dates(db_session):
"""Returns a list of unique game dates."""
dates_query = sa.sql.expression.select([Game.__table__.c.date]).\
distinct(Game.__table__.c.date).\
order_by(Game.__table__.c.date.desc())
dates = [x.date for x in db_session.execute(dates_query)]
return dates
def get_player_sessions(db_session, player, limit=7):
"""Returns a list of unique sessions for the *player*."""
player_sessions_query = sa.sql.expression.\
select([Game.__table__.c.date]).\
distinct(Game.__table__.c.date).\
select_from(Game.__table__.join(
Score.__table__, Score.__table__.c.game_id == Game.__table__.c.id
)).\
where(Score.__table__.c.player == player).\
order_by(Game.__table__.c.date.desc()).\
limit(limit)
player_sessions = [
x.date for x in db_session.execute(player_sessions_query)
]
return player_sessions
def get_top_players(session, agg_by='kills', limit=3):
"""Returns a list of ``(player, score)`` tuples identifying top players
in the current month.
Use the *agg_by* kwargs to specify aggregation level. Valid values are
``kills`` and ``suicides``."""
assert agg_by in ('kills', 'suicides')
date_start = datetime.datetime.now().replace(day=1).date()
date_end = None
if date_start.month == 12:
date_end = datetime.date(
date_start.year + 1, 1, 1
)
else:
date_end = datetime.date(
date_start.year, date_start.month + 1, 1
)
query = SQL_TOP_PLAYERS % agg_by
params = {
'date_start': date_start, 'date_end': date_end, 'limit': limit
}
return [
(x.player, x.sum_frags) for x in session.execute(query, params)
]

View File

View File

@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.lib.scripts.import_games
================================
This module contains the Q3A game importer.
"""
from argparse import ArgumentParser
import datetime
import codecs
import logging
import os
import lxml.etree
from q3stats.lib.config import get_config
from q3stats.lib.scripts import utils
from q3stats.models import Game, Score
__all__ = ['script_main']
XML_STAT_TO_MODEL_FIELD = {
'Score': 'score',
'Kills': 'kills',
'Deaths': 'deaths',
'Suicides': 'suicides',
'Net': 'net',
'DamageGiven': 'damage_given',
'DamageTaken': 'damage_taken',
'HealthTotal': 'total_health',
'ArmorTotal': 'total_armor',
}
XML_STAT_POWERUP_NAMES = ['Quad']
def _find_stats_files(stats_path):
"""Finds Q3A stats files in *stats_path*."""
stats_files = []
for root, dirs, files in os.walk(stats_path):
for filename in files:
_, ext = os.path.splitext(filename)
if ext.lower() == '.xml':
stats_files.append(os.path.join(root, filename))
return stats_files
def _read_match(file_path):
"""Reads a Q3A match file from *file_path*."""
with codecs.open(file_path, 'r', 'utf-8') as stats_f:
stats_xml = lxml.etree.parse(stats_f)
xmlroot = stats_xml.getroot()
if xmlroot.tag != 'match':
return None
return xmlroot
def _read_game(match):
"""Creates and returns an instance of :py:class:`q3stats.models.game.Game`
filled with game data from *match*."""
ts = datetime.datetime.strptime(
match.get('datetime'), '%Y/%m/%d %H:%M:%S'
)
is_team = False
if match.get('isTeamGame') == 'true':
is_team = True
game = Game(
map=match.get('map').upper(),
date=ts.date(),
time=ts.time(),
attrs={
'type': match.get('type'),
'team_game': is_team,
'duration': int(match.get('duration')),
}
)
game.update_uuid()
return game
def _read_scores(match):
"""Reads game data from *match* and extracts player scores.
Returns an array of :py:class:`q3stats.models.score.Score` objects and
the actual fraglimit (aka the highest score)."""
scores = []
current_score = None
fraglimit = 0
for element in match.iter():
if element.tag == 'player':
if current_score is not None:
if current_score.score > fraglimit:
fraglimit = current_score.score
scores.append(current_score)
current_score = Score(player=element.get('name'))
current_score.weapons = {}
current_score.items = {}
current_score.powerups = {}
elif element.tag == 'stat':
name = element.get('name')
if name in XML_STAT_TO_MODEL_FIELD:
setattr(
current_score, XML_STAT_TO_MODEL_FIELD[name],
int(element.get('value'))
)
elif element.tag == 'weapon':
current_score.weapons[element.get('name')] = {
'hits': int(element.get('hits')),
'shots': int(element.get('shots')),
'kills': int(element.get('kills')),
}
elif element.tag == 'item':
name = element.get('name')
if name in XML_STAT_POWERUP_NAMES:
current_score.powerups[name] = [
int(element.get('pickups')), int(element.get('time'))
]
else:
current_score.items[name] = int(element.get('pickups'))
if current_score is not None:
if current_score.score > fraglimit:
fraglimit = current_score.score
scores.append(current_score)
return scores, fraglimit
def _read_stats_file(file_path):
"""Reads stats file from *file_path*.
Returns an instance of :py:class:`q3stats.models.game.Game` with
associated instances of :py:class:`q3stats.models.score.Score`."""
match = _read_match(file_path)
if match is None:
raise RuntimeError('Invalid stats file: %s' % file_path)
game = _read_game(match)
scores, fraglimit = _read_scores(match)
game.scores.extend(scores)
game.fraglimit = fraglimit
return game
def _get_argument_parser():
"""Creates and returns an instance of ``ArgumentParser`` configured for
the script."""
parser = ArgumentParser(description='Import Q3A stats')
parser.add_argument('stats_path', help='path to stats directory')
parser.add_argument(
'-v', '--verbose', action='store_true', default=False,
help='be verbose'
)
return parser
def script_main(argv, config=None):
"""Script entry point."""
result = utils.RET_OK
parser = _get_argument_parser()
args = parser.parse_args(args=argv)
logger = utils.get_logger('import_games', args)
try:
if config is None:
config = get_config()
stats_files = _find_stats_files(args.stats_path)
logger.debug('Found %d stat files.' % len(stats_files))
with utils.db_session(config) as session:
for stats_file in stats_files:
logger.debug('Processing file: %s' % stats_file)
game = _read_stats_file(stats_file)
current_game = session.query(Game).\
filter_by(uuid=game.uuid).\
first()
if current_game is not None:
logger.debug('Skipping!')
else:
session.add(game)
session.commit()
except Exception as exc:
logger.error('Unhandled exception', exc_info=True)
result = utils.RET_ERROR
finally:
return result

View File

@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.lib.scripts.utils
=========================
This module contains utility functions used by scripts.
"""
import contextlib
import json
import logging
import sys
import q3stats.database
RET_OK = 0
RET_ERROR = 99
def pprint(value, stream=None):
"""JSON-based pretty print. *stream* defaults to ``sys.stdout``."""
if stream is None:
stream = sys.stdout
stream.write(json.dumps(value, indent=4, sort_keys=True) + "\n")
@contextlib.contextmanager
def db_session(config):
"""Context manager for getting the DB session configured for the current
environment.
On exception, the session will be rolled back. The session is always
properly disposed of, regardless of any errors.
**Example**:
.. sourcecode:: python
def some_function():
with db_session() as session:
# Do something with the session.
session.commit()
"""
if q3stats.database.db_engine is None:
q3stats.database.db_engine = q3stats.database.create_engine(config)
q3stats.database.db_session = q3stats.database.create_session(
q3stats.database.db_engine, config
)
session = q3stats.database.db_session
try:
yield session
except:
session.rollback()
raise
finally:
session.remove()
def get_logger(name, args):
"""Sets up a ``logging.Logger`` instance for script named *name*."""
logger = logging.getLogger(name)
handler = logging.StreamHandler()
formatter = logging.Formatter(
'%(asctime)s ' + name + ': %(levelname)s: %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
if args.verbose is True:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.WARNING)
return logger

53
q3stats/lib/stats.py Normal file
View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.lib.stats
=================
This module contains functions for dealing with stats.
"""
def weapon_accuracy(weapon, weapon_stats):
"""Returns a string representation of weapon accuracy."""
if weapon == 'G':
return '---'
if weapon_stats['shots'] == 0:
return 'NaN'
accuracy = float(weapon_stats['hits']) / float(weapon_stats['shots'])
return '{:.2%}'.format(accuracy)
def powerup_time(seconds):
"""Returns a string representation of powerup time."""
if not seconds:
return 'NaN'
result = str(seconds)
if len(result) < 4:
result = result.zfill(4)
result = result[0:-3] + '.' + result[-3:]
return result

View File

@@ -0,0 +1,2 @@
from .game import Game
from .score import Score

103
q3stats/models/game.py Normal file
View File

@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.models.game
===================
This module contains the ``Game`` model.
"""
import uuid
import sqlalchemy as sa
import sqlalchemy.dialects.postgresql as pg
from q3stats.database import Base
from q3stats.lib import defs
class Game(Base):
"""The ``Game`` model."""
__tablename__ = 'games'
__table_args__ = (
sa.UniqueConstraint('uuid'),
)
#: Format string for UUID URL string
UUID_UID_FORMAT = 'pl.bthlabs.q3stats.{date}.{time}.{map}'
#: ``id INT PRIMARY KEY``
id = sa.Column(sa.Integer(), primary_key=True)
#: ``uuid VARCHAR(36)``
uuid = sa.Column(sa.String(255))
#: ``map VARCHAR(255)``
map = sa.Column(sa.String(255), index=True)
#: ``date DATE``
date = sa.Column(sa.Date(), index=True)
#: ``time TIME``
time = sa.Column(sa.Time())
#: ``fraglimit INT``
fraglimit = sa.Column(sa.Integer())
#: ``attrs JSON``
attrs = sa.Column(pg.JSON())
#: List of :py:class:`q3stats.models.score.Score` objects
scores = sa.orm.relationship(
'Score', backref=sa.orm.backref('game'), order_by='Score.score.desc()'
)
def update_uuid(self):
self.uuid = str(uuid.uuid3(
uuid.NAMESPACE_OID,
self.UUID_UID_FORMAT.format(
date=self.date.strftime(defs.DATE_FORMAT),
time=self.time.strftime(defs.TIME_FORMAT),
map=self.map
)
))
def to_json(self, recursive=False):
"""Returns dictionary representation suitable for serializing to JSON.
If *recursive* is ``True`` then the associated score objects will be
included."""
result = {
'id': self.id,
'uuid': self.uuid,
'map': self.map,
'date': self.date.strftime(defs.DATE_FORMAT),
'time': self.time.strftime(defs.TIME_FORMAT),
'attrs': dict(self.attrs),
'scores': None
}
if recursive:
result['scores'] = [x.to_json() for x in self.scores]
return result

104
q3stats/models/score.py Normal file
View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.models.score
====================
This module contains the ``Score`` model.
"""
import sqlalchemy as sa
import sqlalchemy.dialects.postgresql as pg
from q3stats.database import Base
class Score(Base):
"""The ``Score`` model."""
__tablename__ = 'scores'
#: ``id INT PRIMARY KEY``
id = sa.Column(sa.Integer(), primary_key=True)
#: ``game_id INT REFERENCES game.id``
game_id = sa.Column(sa.Integer(), sa.ForeignKey('games.id'), index=True)
#: ``player VARCHAR(255)``
player = sa.Column(sa.String(255), index=True)
#: ``score INT``
score = sa.Column(sa.Integer())
#: ``kills INT``
kills = sa.Column(sa.Integer())
#: ``deaths INT``
deaths = sa.Column(sa.Integer())
#: ``sucides INT``
suicides = sa.Column(sa.Integer())
#: ``net INT``
net = sa.Column(sa.Integer())
#: ``damage_taken INT``
damage_taken = sa.Column(sa.Integer())
#: ``damage_given INT``
damage_given = sa.Column(sa.Integer())
#: ``total_health INT``
total_health = sa.Column(sa.Integer())
#: ``total_armor INT``
total_armor = sa.Column(sa.Integer())
#: ``weapons JSON``
weapons = sa.Column(pg.JSON())
#: ``items JSON``
items = sa.Column(pg.JSON())
#: ``powerups JSON``
powerups = sa.Column(pg.JSON())
def to_json(self):
"""Returns dictionary representation suitable for serializing to
JSON."""
return {
'id': self.id,
'game_id': self.game_id,
'player': self.player,
'score': self.score,
'kills': self.kills,
'deaths': self.deaths,
'suicides': self.suicides,
'net': self.net,
'damage_taken': self.damage_taken,
'damage_given': self.damage_given,
'total_health': self.total_health,
'total_armor': self.total_armor,
'weapons': dict(self.weapons),
'items': dict(self.items),
'powerups': dict(self.powerups),
}

View File

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.scripts.import_games
============================
Imports games from a given directory.
"""
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(
os.path.dirname(__file__), '..', '..'
)))
from q3stats.lib.scripts import import_games
if __name__ == '__main__':
sys.exit(import_games.script_main(sys.argv[1:]))

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.scripts.wsgi_app
========================
WSGI app entry point.
"""
from q3stats.lib.config import get_config_path
from q3stats.web_app.application import create_app
config_path = get_config_path()
app = create_app(config_path)

68
q3stats/skel/alembic.ini Normal file
View File

@@ -0,0 +1,68 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# max length of characters to apply to the
# "slug" field
#truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgres://user:pass@localhost/dbname
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

4
q3stats/skel/example.cfg Normal file
View File

@@ -0,0 +1,4 @@
DEBUG = False
TESTING = False
SQLALCHEMY_URL = 'postgres://user:pass@localhost/dbname'

90
q3stats/testing.py Normal file
View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.testing
===============
This module contains testing utilites.
"""
import json
import os
from flask import Response
from q3stats.lib.config import get_config
import q3stats.database
import q3stats.models
from q3stats.web_app.application import create_app
class ResponseWrapper(Response):
@property
def json(self):
if not hasattr(self, '_json_data'):
self._json_data = json.loads(self.get_data().decode('utf-8'))
return self._json_data
class BaseQ3StatsTestCase(object):
"""Base class for Q3Stats test cases."""
CONFIG_PATH = os.path.join(os.getcwd(), 'testing.cfg')
@classmethod
def setUpClass(cls):
cls._config = get_config(cls.CONFIG_PATH)
q3stats.database.db_engine = q3stats.database.create_engine(
cls._config
)
q3stats.database.db_session = q3stats.database.create_session(
q3stats.database.db_engine, cls._config
)
q3stats.database.Base.metadata.drop_all(
bind=q3stats.database.db_engine
)
q3stats.database.Base.metadata.create_all(
bind=q3stats.database.db_engine
)
@classmethod
def tearDownClass(cls):
pass
class BaseQ3StatsWebAppTestCase(BaseQ3StatsTestCase):
"""Base class for Q3Stats Web App test cases."""
@classmethod
def setUpClass(cls):
super(BaseQ3StatsWebAppTestCase, cls).setUpClass()
cls.app = create_app(cls.CONFIG_PATH)
cls.app.response_class = ResponseWrapper
cls.client = cls.app.test_client()

2
q3stats/web_app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
static/
templates/*.html

View File

View File

@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.web_app.application
===========================
This module contains the Web application core.
"""
import flask
from werkzeug.exceptions import HTTPException
from q3stats import __version__ as app_version
import q3stats.database
from q3stats.lib.config import get_config_path
class Q3StatsApplication(flask.Flask):
"""Customized ``flask.Flask`` subclass."""
def init_db(self):
"""Creates the SQLAlchemy engine and session for this instance."""
if q3stats.database.db_engine is None:
q3stats.database.db_engine = q3stats.database.create_engine(
self.config
)
if q3stats.database.db_session is None:
q3stats.database.db_session = q3stats.database.create_session(
q3stats.database.db_engine, self.config
)
@self.teardown_request
def shutdown_session(exception=None):
q3stats.database.db_session.remove()
@property
def db_session(self):
"""The SQLAlchemy session for the current context."""
return q3stats.database.db_session
def create_app(config_path=None):
"""The application factory.
Creates an instance of :py:class:`Q3StatsApplication` and sets its config
from file located at *config_path*."""
app = Q3StatsApplication(__name__)
if config_path is None:
config_path = get_config_path()
app.config.from_pyfile(config_path)
app.init_db()
@app.context_processor
def app_context_processor():
return {
'version': app_version
}
@app.errorhandler(400)
@app.errorhandler(404)
@app.errorhandler(500)
def app_handle_error(error):
error_code = 500
if isinstance(error, HTTPException):
error_code = error.code
return flask.render_template(
'error.html', error_code=error_code
), error_code
from .blueprints.frontend import frontend_blueprint
app.register_blueprint(frontend_blueprint)
from .blueprints.api_v1 import api_v1_blueprint
app.register_blueprint(api_v1_blueprint)
return app

View File

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
from flask import Blueprint
api_v1_blueprint = Blueprint('api_v1', __name__, url_prefix='/api/v1')
from .views import *

View File

@@ -0,0 +1,4 @@
from . import charts
from . import dashboard
from . import players
from . import sessions

View File

@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.web_app.blueprints.api_v1.views.charts
==============================================
This module contains the APIv1 chart views.
"""
from collections import defaultdict
import datetime
from flask import abort, current_app, jsonify
import sqlalchemy as sa
from q3stats.web_app.blueprints.api_v1 import api_v1_blueprint
from q3stats.lib import charts, defs
from q3stats.models import Score, Game
from q3stats.lib import queries
@api_v1_blueprint.route('/charts/day/<day>', methods=['GET'])
def get_api_v1_charts_day(day):
"""Returns day chart data for *day*."""
try:
date = datetime.datetime.strptime(day, defs.DATE_FORMAT)
except (TypeError, ValueError):
abort(400)
maps, scores = charts.get_day_chart(date, current_app.db_session)
return jsonify({
'day': date.strftime(defs.DATE_FORMAT), 'maps': maps, 'scores': scores
})
@api_v1_blueprint.route('/charts/player/<player>/game/<game_uuid>',
methods=['GET'])
def get_api_v1_charts_player_game(player, game_uuid):
"""Returns player charts data for *player* and *game_uuid*."""
score = current_app.db_session.query(Score).\
join(Score.game).\
filter(Game.uuid == game_uuid).\
filter(Score.player == player).\
first()
if not score:
abort(404)
result = {
'score': [
['Frags', score.kills],
['Deaths', score.deaths],
['Suicides', score.suicides],
],
'damage': [
['Taken', score.damage_taken],
['Given', score.damage_given],
],
'totals': [
['Armor', score.total_armor],
['Health', score.total_health],
]
}
return jsonify(result)
@api_v1_blueprint.route('/charts/player/<player>/wins/session',
methods=['GET'])
def get_api_v1_charts_player_wins_session(player):
"""Returns wins chart data aggregated by session for *player*."""
categories, wins_serie, losses_serie = charts.get_player_wins_chart(
current_app.db_session, player, agg_by='date'
)
dates = [
x.strftime(defs.DATE_FORMAT) for x in categories
]
return jsonify({
'dates': dates,
'wins': wins_serie,
'losses': losses_serie
})
@api_v1_blueprint.route('/charts/player/<player>/wins/map',
methods=['GET'])
def get_api_v1_charts_player_wins_map(player):
"""Returns wins chart data aggregated by map for *player*."""
categories, wins_serie, losses_serie = charts.get_player_wins_chart(
current_app.db_session, player, agg_by='map'
)
return jsonify({
'maps': categories,
'wins': wins_serie,
'losses': losses_serie
})
@api_v1_blueprint.route('/charts/player/<player>/accuracy/session',
methods=['GET'])
def get_api_v1_charts_player_accuracy_session(player):
"""Returns avg accuracy chart data aggregated by session for *player*."""
categories, series = charts.get_player_avg_accuracy_chart(
current_app.db_session, player, agg_by='date'
)
dates = [
x.strftime(defs.DATE_FORMAT) for x in categories
]
return jsonify({'dates': dates, 'series': series})
@api_v1_blueprint.route('/charts/player/<player>/accuracy/map',
methods=['GET'])
def get_api_v1_charts_player_accuracy_map(player):
"""Returns avg accuracy chart data aggregated by map for *player*."""
categories, series = charts.get_player_avg_accuracy_chart(
current_app.db_session, player, agg_by='map'
)
return jsonify({'maps': categories, 'series': series})

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.web_app.blueprints.api_v1.views.dashboard
=================================================
This module contains the APIv1 dashboard views.
"""
from flask import abort, current_app, jsonify
from q3stats.web_app.blueprints.api_v1 import api_v1_blueprint
from q3stats.lib import defs, queries
@api_v1_blueprint.route('/dashboard')
def get_api_v1_dashboard():
"""Returns data for the Dashboard view."""
dates = queries.get_game_dates(current_app.db_session)
fotm = queries.get_top_players(current_app.db_session)
eotm = queries.get_top_players(current_app.db_session, agg_by='suicides')
return jsonify({
'day': dates[0].strftime(defs.DATE_FORMAT),
'fotm': fotm,
'eotm': eotm
})

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.web_app.blueprints.api_v1.views.players
===============================================
This module contains the APIv1 players views.
"""
from flask import abort, current_app, jsonify
import sqlalchemy as sa
import six
from q3stats.web_app.blueprints.api_v1 import api_v1_blueprint
from q3stats.lib import queries, stats
from q3stats.models import Game, Score
def _process_weapons(weapons):
"""Returns processed weapon stats."""
result = {}
for weapon, weapon_stats in six.iteritems(weapons):
result[weapon] = dict(weapon_stats)
result[weapon]['accuracy'] = stats.weapon_accuracy(
weapon, weapon_stats
)
return result
def _process_powerups(powerups):
"""Returns processed powerup stats."""
result = {}
for powerup, powerup_stats in six.iteritems(powerups):
result[powerup] = [
powerup_stats[0], stats.powerup_time(powerup_stats[1])
]
return result
@api_v1_blueprint.route('/players/')
def get_api_v1_players():
"""Returns list of known players."""
players_query = sa.sql.expression.select([Score.__table__.c.player]).\
distinct(Score.__table__.c.player).\
order_by(Score.__table__.c.player.asc())
players = [x.player for x in current_app.db_session.execute(players_query)]
return jsonify({"players": players})
@api_v1_blueprint.route('/players/<player>/game/<game_uuid>')
def get_api_v1_player_game(player, game_uuid):
"""Returns data for the Player Game view."""
game = current_app.db_session.query(Game).\
filter_by(uuid=game_uuid).\
first()
if not game:
abort(404)
score = current_app.db_session.query(Score).\
filter_by(game_id=game.id).\
filter_by(player=player).\
first()
if not score:
abort(404)
result = {
"map": game.map,
"items": score.items,
"weapons": _process_weapons(score.weapons),
"powerups": _process_powerups(score.powerups)
}
return jsonify(result)

View File

@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.web_app.blueprints.api_v1.views.sessions
================================================
This module contains the APIv1 sessions views.
"""
from collections import defaultdict
import datetime
from flask import abort, current_app, jsonify
import six
from q3stats.web_app.blueprints.api_v1 import api_v1_blueprint
from q3stats.lib import defs, queries
from q3stats.models import Game
def _get_quad_freaks(games):
"""Returns quad freaks data for *games*."""
intermediate = defaultdict(lambda: defaultdict(int))
for game in games:
for score in game.scores:
if 'Quad' in score.powerups:
intermediate[game.uuid][score.player] +=\
score.powerups['Quad'][0]
result = {}
for game_uuid, players in six.iteritems(intermediate):
sorted_players = sorted(
list(six.iteritems(players)), key=lambda x: x[1], reverse=True
)
result[game_uuid] = sorted_players[0]
return result
@api_v1_blueprint.route('/sessions/')
@api_v1_blueprint.route('/sessions/<day>')
def get_api_v1_sessions(day=None):
"""Returns data for the Sessions view.
If *day* is omitted, the latest session is used."""
date = None
if day is not None:
try:
date = datetime.datetime.strptime(day, defs.DATE_FORMAT).date()
except (TypeError, ValueError):
abort(400)
dates = queries.get_game_dates(current_app.db_session)
if date is None:
date = dates[0]
elif date not in dates:
abort(404)
current_date_idx = dates.index(date)
try:
prev_date = dates[current_date_idx + 1].strftime(defs.DATE_FORMAT)
except IndexError:
prev_date = None
next_date = None
if current_date_idx > 0:
try:
next_date = dates[current_date_idx - 1].strftime(defs.DATE_FORMAT)
except IndexError:
pass
games = current_app.db_session.query(Game).filter_by(date=date).\
order_by(Game.time)
quad_freaks = _get_quad_freaks(games)
result = {
"day": date.strftime(defs.DATE_FORMAT),
"previous_day": prev_date,
"next_day": next_date,
"games": []
}
for game in games:
game_data = {
"uuid": game.uuid,
"map": game.map,
"scores": []
}
game_quad_freaks = quad_freaks.get(game.uuid, [])
for score in game.scores:
weapons = sorted(
score.weapons.keys(), key=lambda x: score.weapons[x]['kills'],
reverse=True
)
try:
favourite_weapon = weapons[0]
except IndexError:
favourite_weapon = None
is_quad_freak = score.player in game_quad_freaks
quad_pickups = 0
if is_quad_freak:
quad_pickups = game_quad_freaks[1]
game_data['scores'].append({
"player": score.player,
"score": score.score,
"kills": score.kills,
"deaths": score.deaths,
"suicides": score.suicides,
"favourite_weapon": favourite_weapon,
"quad_freak": is_quad_freak,
"quad_pickups": quad_pickups
})
result['games'].append(game_data)
return jsonify(result)

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
from flask import Blueprint
frontend_blueprint = Blueprint('frontend', __name__, url_prefix=None)
from .views import *

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Tomek Wójcik <tomek@bthlabs.pl>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""
q3stats.web_app.blueprints.frontend.views
=========================================
This module contains the frontend views.
"""
import os
import shutil
import tarfile
import tempfile
from flask import current_app, abort, redirect, render_template, request,\
url_for
from q3stats.lib.scripts.import_games import script_main as importer
from q3stats.web_app.blueprints.frontend import frontend_blueprint
@frontend_blueprint.route('/', methods=['GET'])
def get_frontend_index():
"""Renders the application."""
return render_template('index.html')
@frontend_blueprint.route('/sessions/', methods=['GET'])
@frontend_blueprint.route('/sessions/<day>', methods=['GET'])
def get_frontend_sessions(day=None):
"""Redirects UA to Sessions view."""
return redirect("/#/sessions/%s" % (day or "",))
@frontend_blueprint.route(
'/players/<player>/game/<game_uuid>', methods=['GET']
)
def get_frontend_player_game(player, game_uuid):
"""Redirects UA to Player Game view."""
return redirect("/#/players/%s/game/%s" % (player, game_uuid))
@frontend_blueprint.route('/players/', methods=['GET'])
@frontend_blueprint.route('/players/<player>', methods=['GET'])
def get_frontend_players(player=None):
"""Redirects UA to Players view."""
return redirect("/#/players/%s" % (player or "",))
@frontend_blueprint.route('/receivestats', methods=['POST'])
def post_frontend_receivestats():
"""Receives a tarball with stats, extracts it and imports the games."""
if 'stats' not in request.files:
abort(400)
pack = request.files['stats']
work_dir = tempfile.mkdtemp()
try:
with tarfile.open('stats.tar', 'r', pack.stream) as pack_f:
pack_f.extractall(work_dir)
importer([work_dir], current_app.config) # h4x0r
finally:
shutil.rmtree(work_dir)
return 'OK', 200

View File