diff --git a/.gitignore b/.gitignore index 0be62d7..d9b38bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ etc/faceoff.yml +environment diff --git a/environment b/environment deleted file mode 120000 index 6cd5de7..0000000 --- a/environment +++ /dev/null @@ -1 +0,0 @@ -virtualenv/bin/activate \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 25d460a..2f88c0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Flask==0.8.1 WTForms==0.6.3 trueskill==0.4.1 +flake8==2.1.0 diff --git a/src/faceoff/__init__.py b/src/faceoff/__init__.py index 71cbeb5..423c495 100644 --- a/src/faceoff/__init__.py +++ b/src/faceoff/__init__.py @@ -16,4 +16,4 @@ fixtures.init_app(app) tpl.init_app(app) -import faceoff.views +import faceoff.views # flake8: noqa diff --git a/src/faceoff/cache.py b/src/faceoff/cache.py index 8b04966..715a7fa 100644 --- a/src/faceoff/cache.py +++ b/src/faceoff/cache.py @@ -5,6 +5,7 @@ from werkzeug.contrib.cache import NullCache, MemcachedCache + def init_app(app): """ Configures the provided app cache services. diff --git a/src/faceoff/config.py b/src/faceoff/config.py index d9ec5e2..8cd559f 100644 --- a/src/faceoff/config.py +++ b/src/faceoff/config.py @@ -9,10 +9,10 @@ # flask LOGGER_NAME = 'faceoff' -DEBUG = os.getenv('FACEOFF_DEBUG') == '1' +DEBUG = os.getenv('FACEOFF_DEBUG') == '1' SERVER_NAME = os.getenv('FACEOFF_HOST') + ':' + os.getenv('FACEOFF_PORT') SECRET_KEY = '89a1100b5a62059021260a75738e6e61' if DEBUG else os.urandom(24) -PERMANENT_SESSION_LIFETIME = 60*60*24*365 +PERMANENT_SESSION_LIFETIME = 60 * 60 * 24 * 365 SESSION_COOKIE_DOMAIN = '' if DEBUG else None # logging @@ -28,6 +28,7 @@ DB_PATH = os.getenv('FACEOFF_DB_PATH') DB_FIXTURES = os.getenv('FACEOFF_DB_FIXTURES') + def init_app(app): app.config.from_object(__name__) app.config.from_envvar('FACEOFF_CONFIG', silent=True) diff --git a/src/faceoff/db.py b/src/faceoff/db.py index a8d8354..78480d3 100644 --- a/src/faceoff/db.py +++ b/src/faceoff/db.py @@ -5,9 +5,9 @@ import os import re -import sqlite3 +import sqlite3 import atexit -import string # pylint:disable=W0402 +import string import logging from math import ceil from threading import current_thread @@ -20,19 +20,19 @@ from time import time from natsort import natsort from functools import wraps -from faceoff.debug import debug _curdir = os.path.dirname(__file__) _schema = os.path.join(_curdir, 'schema') _global_factory = None + def init_app(app): """ - Configures the provided app's database connection layer. Attaches a `db` + Configures the provided app's database connection layer. Attaches a `db` property to the app object that will act as a database connection factory for the application's threads and subprocesses to use. """ - db_path = app.config['DB_PATH'] + db_path = app.config['DB_PATH'] if db_path: db_path = os.path.expanduser(db_path) if not os.path.exists(db_path): @@ -42,25 +42,27 @@ def init_app(app): app.db = Factory(db_path) set_global_factory(app.db) + def make_temp_db(): - """ + """ Creates a temporary database file and deletes when the process is ends. - Returns the path to the temporary file. + Returns the path to the temporary file. """ db_link = os.path.expanduser(os.path.join('~', '.faceoff.tmp.db')) if not os.path.exists(db_link): pid, tid = os.getpid(), current_thread().ident db_path = os.path.join(gettempdir(), 'faceoff%s%s.tmp.db' % (pid, tid)) build_schema(db_path, _schema) - os.symlink(db_path, db_link) + os.symlink(db_path, db_link) atexit.register(clean_temp_db(db_path, db_link)) logger().info('created tmp db at %s linked by %s' % (db_path, db_link)) return db_link + def clean_temp_db(db_path, db_link): - """ - Returns a closed function that deletes the temporary database file at - the given path. + """ + Returns a closed function that deletes the temporary database file at + the given path. """ def cleaner(): logger().info('cleanup temp db file %s' % db_path) @@ -69,6 +71,7 @@ def cleaner(): os.remove(db_link) return cleaner + def make_new_db(path): """ Creates a new database at the given location. @@ -77,49 +80,55 @@ def make_new_db(path): logger().info('created fresh db %s' % path) return path + def build_schema(db_path, schema_path): - """ - Rebuilds the database using the given schema directory. + """ + Rebuilds the database using the given schema directory. """ with closing(sqlite3.connect(db_path)) as conn: conn.executescript(get_schema_sql(schema_path)) + def get_schema_files(schema_path): - """ - Returns a list of schema SQL files, sorted by version number. + """ + Returns a list of schema SQL files, sorted by version number. """ files = glob(os.path.join(schema_path, '*.sql')) natsort(files) return files + def get_schema_sql(schema_path): - """ - Concatenates all schema files into a single script for running. """ - scripts = [] + Concatenates all schema files into a single script for running. + """ + scripts = [] for path in get_schema_files(schema_path): with open(path) as f: scripts.append(f.read().strip()) return '\n'.join(scripts) + def logger(name='db.general'): - """ - Log into the internal database logger. + """ + Log into the internal database logger. """ return logging.getLogger(name) + def use_db(f): """ - Decorator that wraps functions and inserts a global database object as first - parameter. + Decorator that wraps functions and inserts a global database object as + first parameter. """ varnames = f.func_code.co_varnames has_self = len(varnames) > 0 and varnames[0] == 'self' + @wraps(f) def decorator(*args, **kwargs): if len(args) > 0 and isinstance(args[0], Connection): return f(*args, **kwargs) - if kwargs.has_key('db'): + if 'db' in kwargs: db = get_connection(kwargs['db']) del kwargs['db'] else: @@ -131,19 +140,21 @@ def decorator(*args, **kwargs): return f(*args, **kwargs) return decorator + def get_connection(conn=None): """ - Attempts to load a database connection object. If a valid connection object + Attempts to load a database connection object. If a valid connection object is provided explicitly, it is used. If not, an attempt is made to load the connection from the flask global registry. """ if conn: return conn - from flask import g + from flask import g if not hasattr(g, 'db'): g.db = get_global_factory().connect() return g.db + def get_global_factory(): """ Returns the global connection factory. Use `set_global_factory` to set this @@ -151,6 +162,7 @@ def get_global_factory(): """ return _global_factory + def set_global_factory(factory): """ Sets the global database connection factory. This factory is used to create @@ -159,14 +171,15 @@ def set_global_factory(factory): global _global_factory _global_factory = factory + class Factory(object): - """ + """ Database connection factory. Stores database configuration settings and - creates new database connections. + creates new database connections. """ def __init__(self, db_path): - self.db_path = db_path + self.db_path = db_path def connect(self, **options): opts = self.default_options() @@ -178,9 +191,10 @@ def connect(self, **options): def default_options(self): return {'isolation_level': None} + class Connection(sqlite3.Connection): """ - Wraps the native database connection object to provide logging ability as + Wraps the native database connection object to provide logging ability as well as some helper functions to make querying more concise. """ @@ -191,18 +205,18 @@ def __init__(self, *args, **kwargs): sqlite3.Connection.__init__(self, *args, **kwargs) def find(self, table, pk=None, sort=None, order=None, **where): - """ - Searches `table` for the given criteria and returns the first available + """ + Searches `table` for the given criteria and returns the first available record. If no records found, None is returned. """ - if pk: + if pk: where['id'] = pk rows = self.search(table, sort=sort, order=order, limit=1, **where) return rows[0] if rows else None def search(self, table, sort=None, order=None, limit=None, **where): - """ - Searches `table` for the given criteria and returns all matching + """ + Searches `table` for the given criteria and returns all matching records. If no records found, an empty list is returned. """ query = 'SELECT * FROM "%s"' % self.clean(table) @@ -220,9 +234,9 @@ def search(self, table, sort=None, order=None, limit=None, **where): query += ' WHERE ' + ' AND '.join(fields) if sort is not None: if not isinstance(sort, Expr): - sort ='"%s"' % self.clean(sort) + sort = '"%s"' % self.clean(sort) sort = str(sort) - if order != 'asc': + if order != 'asc': order = 'desc' query += ' ORDER BY %s %s' % (sort, order) if limit is not None: @@ -234,7 +248,7 @@ def search(self, table, sort=None, order=None, limit=None, **where): return rows def insert(self, table, pk=None, **fields): - """ + """ Creates a record with primary key `pk` in `table` with the given data. If no `pk` is specified, it is uniquely generated. """ @@ -243,25 +257,24 @@ def insert(self, table, pk=None, **fields): query = 'INSERT INTO "%(table)s" ("%(names)s") VALUES (%(param)s)' % { 'table': self.clean(table), 'names': '","'.join(map(self.clean, fields.keys())), - 'param': ','.join(['?'] * len(fields)) - } + 'param': ','.join(['?'] * len(fields))} self.execute(query, fields.values()) - return fields['id'] if fields.has_key('id') else True + return fields['id'] if 'id' in fields else True def update(self, table, pk, **fields): - """ + """ Updates a record with primary key `pk` in `table` with the given data. """ - if not len(fields): + if not len(fields): return query = 'UPDATE "%(table)s" SET %(pairs)s WHERE "id"=?' % { 'table': self.clean(table), - 'pairs': ','.join(['"%s"=?' % self.clean(n) for n in fields.keys()]) - } + 'pairs': ','.join([ + '"%s"=?' % self.clean(n) for n in fields.keys()])} self.execute(query, fields.values() + [pk]) def delete(self, table, pk): - """ + """ Deletes a record by primary key `pk` on the given `table`. """ self.execute('DELETE FROM "%s" WHERE "id"=?' % table, [pk]) @@ -284,7 +297,7 @@ def truncate_table(self, table): def generate_pk(self, table): """ - Returns a new primary key that is guaranteed to be unique. A version 5 + Returns a new primary key that is guaranteed to be unique. A version 5 UUID is generated and returned as a 32 character hex string. """ return uuid5(uuid4(), table).hex @@ -303,8 +316,8 @@ def generate_slug(self, table, field='slug', length=4): def clean(self, string): """ - Enforces a strict naming convention to prevent SQL injection. This - should be used when SQL identifiers (eg: table name, column name, etc) + Enforces a strict naming convention to prevent SQL injection. This + should be used when SQL identifiers (eg: table name, column name, etc) come from an untrusted source. """ return re.sub(r'[^\w]', '', string) @@ -330,23 +343,23 @@ def paginate(self, cols, sql, params, page, limit): result_query = "SELECT %s %s LIMIT %d, %d" % (cols, sql, offset, limit) total_rows = self.execute(totals_query, params).fetchone()['count'] result = self.select(result_query, params) - next_page = page+1 - total_pages = int(ceil(total_rows/float(limit))) + next_page = page + 1 + total_pages = int(ceil(total_rows / float(limit))) return { - 'row_data': result, + 'row_data': result, 'total_rows': total_rows, 'total_pages': total_pages, 'rows_per_page': limit, - 'prev_page': None if page is 1 else page-1, - 'next_page': None if next_page > total_pages else next_page - } + 'prev_page': None if page is 1 else page - 1, + 'next_page': None if next_page > total_pages else next_page} def _ident(self, path): """ - Generates a unique connection ID that will help identify connection + Generates a unique connection ID that will help identify connection patterns in log files. """ - tid , pid, rand, ts = current_thread().ident, os.getpid(), random(), time() + tid, pid, rand, ts = ( + current_thread().ident, os.getpid(), random(), time()) ident = '%s_%s_%s_%s_%s' % (tid, pid, rand, ts, path) return sha1(ident).hexdigest() @@ -354,6 +367,7 @@ def _log(self, message, level='debug'): message = '[%s] %s' % (self.ident, message) getattr(logger('db.query'), level)(message) + class Cursor(sqlite3.Cursor): """ Wraps the native database cursor object to provide logging ability. @@ -385,11 +399,12 @@ def fetchall(self): def _log(self, *args, **kwargs): """ Proxy all logs to the connection logger. """ - self.connection._log(*args, **kwargs) # pylint: disable=E1101 + self.connection._log(*args, **kwargs) # pylint: disable=E1101 + class Expr(object): """ - Represents a raw SQL expression. Useful when trying to differentiate user + Represents a raw SQL expression. Useful when trying to differentiate user input from internally generated SQL code. """ diff --git a/src/faceoff/debug.py b/src/faceoff/debug.py index 582c6fd..4aa97fb 100644 --- a/src/faceoff/debug.py +++ b/src/faceoff/debug.py @@ -3,6 +3,7 @@ License: MIT, see LICENSE for details """ + def debug(): """ Raises an error that will trigger the Flask debugger in the browser. diff --git a/src/faceoff/fixtures.py b/src/faceoff/fixtures.py index 7c2af4c..5a3ebb4 100644 --- a/src/faceoff/fixtures.py +++ b/src/faceoff/fixtures.py @@ -6,53 +6,51 @@ """ import os -import json -from math import ceil +from logging import getLogger from time import mktime from datetime import datetime, timedelta -from logging import getLogger, debug -from random import choice, shuffle, randint +from random import shuffle, randint from jinja2.utils import generate_lorem_ipsum from faceoff.models.user import create_user, get_active_users -from faceoff.models.league import create_league, get_all_leagues, get_active_leagues +from faceoff.models.league import ( + create_league, get_all_leagues, get_active_leagues) from faceoff.models.match import create_match, rebuild_rankings from faceoff.models.setting import set_setting _logger = None HUMAN_NAMES = [ - ['Wayne','Gretzky'], ['Bobby','Orr'], ['Gordie','Howe'], - ['Mario','Lemieux'],['Maurice','Richard'], ['Doug','Harvey'], - ['Jean','Beliveau'],['Bobby','Hull'], ['Terry','Sawchuk'], - ['Eddie','Shore'], ['Guy','Lafleur'], ['Mark','Messier'], - ['Jacques','Plante'], ['Ray','Bourque'],['Howie','Morenz'], - ['Glenn','Hall'], ['Stan','Mikita'], ['Phil','Esposito'],['Denis','Potvin'], - ['Mike','Bossy'], ['Ted','Lindsay'], ['Patrick','Roy'],['Red','Kelly'], - ['Bobby','Clarke'], ['Larry','Robinson'], ['Ken','Dryden'], - ['Frank','Mahovlich'], ['Milt','Schmidt'], ['Paul','Coffey'], - ['Henri','Richard'], ['Bryan','Trottier'], ['Dickie','Moore'], - ['Newsy','Lalonde'], ['Syl','Apps'], ['Bill','Durnan'], - ['Charlie','Conacher'],['Jaromir','Jagr'], ['Marcel','Dionne'], - ['Joe','Malone'], ['Chris','Chelios'],['Dit','Clapper'], - ['Bernie','Geoffrion'], ['Tim','Horton'], ['Bill','Cook'], - ['Johnny','Bucyk'], ['George','Hainsworth'], ['Gilbert','Perreault'], - ['Max','Bentley'], ['Brad','Park'], ['Jari','Kurri'] - ] + ['Wayne', 'Gretzky'], ['Bobby', 'Orr'], ['Gordie', 'Howe'], + ['Mario', 'Lemieux'], ['Maurice', 'Richard'], ['Doug', 'Harvey'], + ['Jean', 'Beliveau'], ['Bobby', 'Hull'], ['Terry', 'Sawchuk'], + ['Eddie', 'Shore'], ['Guy', 'Lafleur'], ['Mark', 'Messier'], + ['Jacques', 'Plante'], ['Ray', 'Bourque'], ['Howie', 'Morenz'], + ['Glenn', 'Hall'], ['Stan', 'Mikita'], ['Phil', 'Esposito'], + ['Denis', 'Potvin'], ['Mike', 'Bossy'], ['Ted', 'Lindsay'], + ['Patrick', 'Roy'], ['Red', 'Kelly'], ['Bobby', 'Clarke'], + ['Larry', 'Robinson'], ['Ken', 'Dryden'], ['Frank', 'Mahovlich'], + ['Milt', 'Schmidt'], ['Paul', 'Coffey'], ['Henri', 'Richard'], + ['Bryan', 'Trottier'], ['Dickie', 'Moore'], ['Newsy', 'Lalonde'], + ['Syl', 'Apps'], ['Bill', 'Durnan'], ['Charlie', 'Conacher'], + ['Jaromir', 'Jagr'], ['Marcel', 'Dionne'], ['Joe', 'Malone'], + ['Chris', 'Chelios'], ['Dit', 'Clapper'], ['Bernie', 'Geoffrion'], + ['Tim', 'Horton'], ['Bill', 'Cook'], ['Johnny', 'Bucyk'], + ['George', 'Hainsworth'], ['Gilbert', 'Perreault'], ['Max', 'Bentley'], + ['Brad', 'Park'], ['Jari', 'Kurri']] GAME_NAMES = [ - 'Table Tennis', 'Chess', 'Thumb Wrestling', 'Foosball', 'Boxing', - 'Checkers', 'Scrabble', 'Poker', 'Billiards', 'Basketball', 'Flag Football', - 'Horseshoes', 'Backgammon', 'Shuffleboard', 'Archery', 'Air Hockey', - 'Bowling', 'Tetris', 'Street Fighter' - ] + 'Table Tennis', 'Chess', 'Thumb Wrestling', 'Foosball', 'Boxing', + 'Checkers', 'Scrabble', 'Poker', 'Billiards', 'Basketball', + 'Flag Football', 'Horseshoes', 'Backgammon', 'Shuffleboard', 'Archery', + 'Air Hockey', 'Bowling', 'Tetris', 'Street Fighter'] COMPANY_NAMES = [ - ["Aperture Science"], ["BiffCo Enterprises"],["Bluth Company"], - ["Dunder Mifflin"], ["Globo Gym"],["InGen"],["Kramerica"], - ["Oceanic Airlines"], ["Omni Consumer Products"],["Oscorp Industries"], + ["Aperture Science"], ["BiffCo Enterprises"], ["Bluth Company"], + ["Dunder Mifflin"], ["Globo Gym"], ["InGen"], ["Kramerica"], + ["Oceanic Airlines"], ["Omni Consumer Products"], ["Oscorp Industries"], ["Rekall Incorporated"], ["Sterling Cooper Draper Pryce"], - ["Tyrell Corporation"], ["Umbrella Corporation"] - ] + ["Tyrell Corporation"], ["Umbrella Corporation"]] + def init_app(app): """ @@ -61,9 +59,10 @@ def init_app(app): if app.config['DB_FIXTURES'] and not os.getenv('WERKZEUG_RUN_MAIN'): generate_full_db(app.db.connect(), truncate=True) + def generate_full_db(db, truncate=False): """ - Generates a complete database of data. Requires a valid database connection + Generates a complete database of data. Requires a valid database connection object. If truncate is set to True, all existing data will be removed. """ logger().info('generating full db') @@ -78,10 +77,11 @@ def generate_full_db(db, truncate=False): [rebuild_rankings(db, league['id']) for league in get_all_leagues(db)] db.is_building = False + def generate_users(db, min_count=4, max_count=12, truncate=False): """ Generates a random amount of users into the given database connection - object. The amount of users will fall between `min_count` and `max_count`. + object. The amount of users will fall between `min_count` and `max_count`. If `truncate` is True, all existing users will be deleted. """ logger().info('creating users') @@ -93,11 +93,12 @@ def generate_users(db, min_count=4, max_count=12, truncate=False): logger().info('created %d users' % len(users)) return users + def generate_leagues(db, min_count=2, max_count=6, truncate=False): """ Generates a random amount of leagues into the given database connection - object. The amount of leagues will fall between `min_count` and `max_count`. - If `truncate` is True, all existing leagues will be deleted. + object. The amount of leagues will fall between `min_count` and + `max_count`. If `truncate` is True, all existing leagues will be deleted. """ logger().info('creating leagues') if truncate: @@ -109,12 +110,14 @@ def generate_leagues(db, min_count=2, max_count=6, truncate=False): logger().info('created %d leagues' % len(leagues)) return leagues + def generate_settings(db): """ Generates default application settings. """ set_setting('access_code', 'letmeplay', db=db) + def generate_matches(db, truncate=False): """ Generates new matches between players in a league. @@ -136,7 +139,9 @@ def generate_matches(db, truncate=False): for match in matches: match_date = match_date + timedelta(hours=diff_hours*24) match_time = mktime(match_date.timetuple()) - create_match(db, match[0]['id'], match[1], match[2], match_date=match_time) + create_match( + db, match[0]['id'], match[1], match[2], match_date=match_time) + def rand_users(min_count, max_count): """ @@ -153,9 +158,11 @@ def rand_users(min_count, max_count): rank = 'admin' if n < 2 else 'member' yield {'nickname': nickname, 'password': 'faceoff!', 'rank': rank} + def rand_leagues(min_count, max_count): """ - Returns a list of random objects that map the properties of a league record. + Returns a list of random objects that map the properties of a league + record. """ games = GAME_NAMES shuffle(games) @@ -166,12 +173,14 @@ def rand_leagues(min_count, max_count): active = True if randint(0, 3) else False yield {'name': name, 'description': desc, 'active': active} + def rand_text(min_count, max_count): """ Returns randomly generated text with the given paragraph count. """ return generate_lorem_ipsum(n=randint(min_count, max_count), html=False) + def logger(): """ Returns a global logger object used for debugging. diff --git a/src/faceoff/forms.py b/src/faceoff/forms.py index 272bde8..66fbcb0 100644 --- a/src/faceoff/forms.py +++ b/src/faceoff/forms.py @@ -3,34 +3,35 @@ License: MIT, see LICENSE for details """ -import re from wtforms import Form, TextField, SelectField, RadioField from wtforms.widgets import PasswordInput -from wtforms.validators import \ - Optional, Required, Length, Regexp, EqualTo, AnyOf, NoneOf +from wtforms.validators import ( + Optional, Required, Length, Regexp, EqualTo, AnyOf) from faceoff.helpers.validators import UniqueNickname -from faceoff.db import get_connection + class PasswordField(TextField): widget = PasswordInput(hide_value=False) + class LoginForm(Form): nickname = TextField('Nickname', [Required()]) password = PasswordField('Password', [Required()]) + class JoinForm(Form): nickname = TextField( - label='Nickname', - id='join_nickname', + label='Nickname', + id='join_nickname', validators=[ - Required(), - Length(2, 20), - Regexp(r'^[a-zA-Z0-9_]+$'), + Required(), + Length(2, 20), + Regexp(r'^[a-zA-Z0-9_]+$'), UniqueNickname() ] ) password = PasswordField( - label='Password', + label='Password', id='join_password', validators=[Required(), Length(4), EqualTo('confirm')] ) @@ -39,7 +40,7 @@ class JoinForm(Form): id='join_confirm' ) access_code = PasswordField( - label='Access Code', + label='Access Code', id='join_access_code', validators=[Required()], description='Someone should have given you the code.' @@ -47,13 +48,13 @@ class JoinForm(Form): def __init__(self, *args, **kwargs): """ - Allow passing an 'access_code' keyword arg. Without this arg, the access - code field is removed. With the access_code, an additional validator is - created that includes the code. + Allow passing an 'access_code' keyword arg. Without this arg, the + access code field is removed. With the access_code, an additional + validator is created that includes the code. """ access_code = None - if kwargs.has_key('access_code'): - access_code = kwargs['access_code'] + if 'access_code' in kwargs: + access_code = kwargs['access_code'] del kwargs['access_code'] super(JoinForm, self).__init__(*args, **kwargs) if access_code is None: @@ -62,6 +63,7 @@ def __init__(self, *args, **kwargs): validator = AnyOf([access_code], message='that code is wrong') self.access_code.validators.append(validator) + class ReportForm(Form): opponent = SelectField('I played against', choices=[]) result = SelectField('and', choices=[('1', 'Won'), ('0', 'Lost')]) @@ -70,37 +72,41 @@ def __init__(self, users, *args, **kwargs): super(ReportForm, self).__init__(*args, **kwargs) self.opponent.choices = [(u['id'], u['nickname']) for u in users] + class NewLeagueForm(Form): name = TextField( - label='League Name', + label='League Name', id='name', validators=[Required(), Length(2)] ) + class SettingsForm(Form): name = TextField( - label='League Name', + label='League Name', validators=[Required(), Length(2)] ) active = RadioField(label='Active?', choices=[('1', 'Yes'), ('0', 'No')]) + class ProfileForm(Form): nickname = TextField( label='Nickname', validators=[ - Required(), - Length(2, 20), + Required(), + Length(2, 20), Regexp(r'^[a-zA-Z0-9_]+$'), ] ) password = PasswordField( - label='New Password', + label='New Password', validators=[Optional(), Length(4)] ) + class AdminForm(Form): access_code = TextField( - label='Access Code', + label='Access Code', id='access_code', description='This code is used to create a new Faceoff account.' ) diff --git a/src/faceoff/helpers/decorators.py b/src/faceoff/helpers/decorators.py index fe4daf7..69fc6dc 100644 --- a/src/faceoff/helpers/decorators.py +++ b/src/faceoff/helpers/decorators.py @@ -7,10 +7,11 @@ from flask import g, request, session, render_template, url_for, redirect from faceoff.models.user import find_user + def templated(template_name=None): """ - Automatically renders a template named after the current endpoint. Will also - render the name provided if given. + Automatically renders a template named after the current endpoint. Will + also render the name provided if given. """ def closure(f): @wraps(f) @@ -27,10 +28,11 @@ def decorator(*args, **kwargs): return decorator return closure + def authenticated(f): """ - Asserts that an existing logged-in user session is active. If not, redirects - to the authenticate gate. + Asserts that an existing logged-in user session is active. If not, + redirects to the authenticate gate. """ @wraps(f) def decorator(*args, **kwargs): diff --git a/src/faceoff/helpers/validators.py b/src/faceoff/helpers/validators.py index 44212ed..fc9c79a 100644 --- a/src/faceoff/helpers/validators.py +++ b/src/faceoff/helpers/validators.py @@ -8,6 +8,7 @@ from wtforms.validators import ValidationError from faceoff.db import use_db + class UniqueNickname(object): """ Validates that a value is a unique user nickname. diff --git a/src/faceoff/log.py b/src/faceoff/log.py index 2c1f339..a78fb02 100644 --- a/src/faceoff/log.py +++ b/src/faceoff/log.py @@ -5,16 +5,18 @@ from logging import FileHandler, StreamHandler, Filter, Formatter, getLogger + def init_app(app, name=''): """ Configures the provided app's logger. - + :param app: the application object to configure the logger :param name: the name of the logger to create and configure """ - # flask app object automatically registers its own debug logger if app.debug - # is True. Remove it becuase debug logging is handled here instead. + # flask app object automatically registers its own debug logger if + # app.debug is True. Remove it becuase debug logging is handled here + # instead. del app.logger.handlers[:] log_path = app.config['LOG_PATH'] @@ -22,17 +24,18 @@ def init_app(app, name=''): log_filter = app.config['LOG_FILTER'] log_ignore = app.config['LOG_IGNORE'] - handler = FileHandler(log_path) if log_path else StreamHandler() - handler.setLevel(log_level.upper() or ('DEBUG' if app.debug else 'WARNING')) + handler = FileHandler(log_path) if log_path else StreamHandler() + handler.setLevel(log_level.upper() or ('DEBUG' if app.debug else 'WARNING')) # noqa handler.addFilter(MultiNameFilter(log_filter, log_ignore)) handler.setFormatter(Formatter( - '%(asctime)s %(process)s %(thread)-15s %(name)-10s %(levelname)-8s %(message)s', + '%(asctime)s %(process)s %(thread)-15s %(name)-10s %(levelname)-8s %(message)s', # noqa '%H:%M:%S' if app.debug else '%Y-%m-%d %H:%M:%S%z')) logger = getLogger(name) logger.setLevel(handler.level) logger.addHandler(handler) + class MultiNameFilter(Filter): def __init__(self, allow, deny): @@ -42,7 +45,7 @@ def __init__(self, allow, deny): def filter(self, record): return (not self.allow or record.name in self.allow) and \ - (not self.deny or record.name not in self.deny) - + (not self.deny or record.name not in self.deny) + def format_list(self, value): return value.split(',') if value is str else (value or []) diff --git a/src/faceoff/models/league.py b/src/faceoff/models/league.py index 77373f9..faface2 100644 --- a/src/faceoff/models/league.py +++ b/src/faceoff/models/league.py @@ -7,37 +7,43 @@ from time import time from faceoff.db import use_db + @use_db def find_league(db, **kwargs): return db.find('league', **kwargs) + @use_db def search_leagues(db, **kwargs): return db.search('league', **kwargs) + @use_db def get_all_leagues(db): return search_leagues(db) + @use_db def get_active_leagues(db): return search_leagues(db, active=1) + @use_db def get_inactive_leagues(db): return search_leagues(db, active=0) + @use_db def create_league(db, name, description=None, active=True): name = name.strip() return db.insert( 'league', - name = name, - slug = generate_league_slug(db, name), - description = description, - active = '1' if active else '0', - date_created = int(time()) - ) + name=name, + slug=generate_league_slug(db, name), + description=description, + active='1' if active else '0', + date_created=int(time())) + @use_db def update_league(db, league_id, name=None, active=None): @@ -56,6 +62,7 @@ def update_league(db, league_id, name=None, active=None): db.update('league', league_id, **fields) return find_league(db, id=league_id) + @use_db def generate_league_slug(db, name): short = re.sub(r'[^0-9a-zA-Z]+', '-', name.lower()).strip('-') diff --git a/src/faceoff/models/match.py b/src/faceoff/models/match.py index 53feda9..82542f7 100644 --- a/src/faceoff/models/match.py +++ b/src/faceoff/models/match.py @@ -3,34 +3,31 @@ License: MIT, see LICENSE for details """ -import logging -from time import time, mktime +from time import time from datetime import datetime -from math import ceil -from faceoff.debug import debug from faceoff.db import use_db -from faceoff.debug import debug from trueskill import TrueSkill + @use_db def find_match(db, **kwargs): return db.find('match', **kwargs) + @use_db -def search_matches(db, league_id, user_id=None, time_start=None, - time_end=None, page=None, per_page=10, - sort='date_created', order='desc'): +def search_matches(db, league_id, user_id=None, time_start=None, time_end=None, + page=None, per_page=10, sort='date_created', order='desc'): params = [league_id] fields = """ - match.*, winner.id AS winner_id, winner.nickname AS winner_nickname, - loser.id AS loser_id, loser.nickname AS loser_nickname + match.*, winner.id AS winner_id, winner.nickname AS winner_nickname, + loser.id AS loser_id, loser.nickname AS loser_nickname """ query = """ - FROM match + FROM match INNER JOIN user AS winner ON winner.id = match.winner_id INNER JOIN user AS loser ON loser.id = match.loser_id - WHERE match.league_id=? - """ + WHERE match.league_id=? + """ if user_id is not None: query += " AND (winner.id=? OR loser.id=?) " params.extend([user_id, user_id]) @@ -46,6 +43,7 @@ def search_matches(db, league_id, user_id=None, time_start=None, else: return db.select("SELECT %s %s" % (fields, query), params) + @use_db def find_older_match(db, league_id, user_id, timestamp): result = search_matches( @@ -62,10 +60,11 @@ def find_older_match(db, league_id, user_id, timestamp): } return match + @use_db def find_newer_match(db, league_id, user_id, timestamp): result = search_matches( - db, league_id, user_id=user_id, time_start=timestamp, page=1, + db, league_id, user_id=user_id, time_start=timestamp, page=1, per_page=1, order='asc') if not len(result['row_data']): return None @@ -78,40 +77,45 @@ def find_newer_match(db, league_id, user_id, timestamp): } return match + @use_db -def create_match(db, league_id, winner_user_id, loser_user_id, match_date=None, norebuild=False): +def create_match(db, league_id, winner_user_id, loser_user_id, match_date=None, + norebuild=False): match_id = db.insert( 'match', - league_id = league_id, - winner_id = winner_user_id, - winner_rank = get_user_rank(db, league_id, winner_user_id), - loser_id = loser_user_id, - loser_rank = get_user_rank(db, league_id, loser_user_id), - date_created = int(time()) if match_date is None else match_date - ) + league_id=league_id, + winner_id=winner_user_id, + winner_rank=get_user_rank(db, league_id, winner_user_id), + loser_id=loser_user_id, + loser_rank=get_user_rank(db, league_id, loser_user_id), + date_created=int(time()) if match_date is None else match_date) if not norebuild: rebuild_rankings(db, league_id) return match_id + @use_db def get_league_ranking(db, league_id): return db.select(""" SELECT ranking.*, user.nickname - FROM ranking + FROM ranking INNER JOIN user ON user.id=ranking.user_id - WHERE ranking.league_id=? + WHERE ranking.league_id=? ORDER BY ranking.rank ASC """, [league_id]) + @use_db def get_user_rank(db, league_id, user_id): rank = db.find('ranking', league_id=league_id, user_id=user_id) return None if rank is None else rank['rank'] + @use_db def get_user_standing(db, league_id, user_id): return db.find('ranking', league_id=league_id, user_id=user_id) + @use_db def rebuild_rankings(db, league_id): # exclusive lock is needed to prevent race conditions when multiple people @@ -124,8 +128,9 @@ def rebuild_rankings(db, league_id): skill = TrueSkill() - # generate a local player ranking profile based on user id. all matches will - # be traversed and this object will be populated to build the rankings. + # generate a local player ranking profile based on user id. all matches + # will be traversed and this object will be populated to build the + # rankings. players = {} for match in db.search('match', league_id=league_id): w = match['winner_id'] @@ -133,9 +138,9 @@ def rebuild_rankings(db, league_id): # create ranking profile if hasn't been added yet for p in [w, l]: - if not players.has_key(p): + if not p in players: players[p] = { - 'id': p, 'win': 0, 'loss': 0, 'win_streak': 0, + 'id': p, 'win': 0, 'loss': 0, 'win_streak': 0, 'loss_streak': 0, 'games': 0, 'rating': skill.Rating()} # define ranking profile properties, this will go into the db and will @@ -166,7 +171,7 @@ def rebuild_rankings(db, league_id): fields = { 'league_id': league_id, 'user_id': r['id'], 'rank': (i+1), 'mu': r['rating'].mu, 'sigma': r['rating'].sigma, 'wins': r['win'], - 'losses': r['loss'], 'win_streak': r['win_streak'], + 'losses': r['loss'], 'win_streak': r['win_streak'], 'loss_streak': r['loss_streak'], 'games': r['games'] } db.insert('ranking', pk=False, **fields) diff --git a/src/faceoff/models/setting.py b/src/faceoff/models/setting.py index edd50a7..6d083f9 100644 --- a/src/faceoff/models/setting.py +++ b/src/faceoff/models/setting.py @@ -5,6 +5,7 @@ from faceoff.db import use_db + @use_db def get_setting(db, name, default=None): setting = db.find('setting', name=name) @@ -12,6 +13,7 @@ def get_setting(db, name, default=None): return default return setting['value'] + @use_db def set_setting(db, name, value=None): setting = db.find('setting', name=name) @@ -21,10 +23,12 @@ def set_setting(db, name, value=None): query = 'UPDATE setting SET value=? WHERE name=?' db.execute(query, (value, name)) + @use_db def del_setting(db, name): db.execute('DELETE FROM setting WHERE name=?', (name,)) + @use_db def set_access_code(db, code): if code == '': diff --git a/src/faceoff/models/user.py b/src/faceoff/models/user.py index bbcd752..c2dcda3 100644 --- a/src/faceoff/models/user.py +++ b/src/faceoff/models/user.py @@ -3,8 +3,7 @@ License: MIT, see LICENSE for details """ -import re -import string # pylint: disable=W0402 +import string from random import choice from time import time from hashlib import sha1 @@ -13,27 +12,33 @@ RANK_ADMIN = 'admin' RANK_MEMBER = 'member' + @use_db def find_user(db, **kwargs): return db.find('user', **kwargs) + @use_db def find_user_id(db, nickname): user = db.find('user', nickname=nickname) return None if user is None else user['id'] + @use_db def search_users(db, **kwargs): return db.search('user', **kwargs) + @use_db def get_all_users(db): return search_users(db) + @use_db def get_active_users(db): return search_users(db, sort=Expr('UPPER(nickname)'), order='asc') + @use_db def create_user(db, nickname, password, rank=None): """ @@ -44,12 +49,13 @@ def create_user(db, nickname, password, rank=None): rank = rank if rank in [RANK_MEMBER, RANK_ADMIN] else RANK_MEMBER return db.insert( 'user', - nickname = nickname, - password = password, - salt = salt, - rank = rank, - date_created = int(time()) - ) + nickname=nickname, + password=password, + salt=salt, + rank=rank, + date_created=int(time())) + + @use_db def update_user(db, user_id, nickname=None, password=None): user = find_user(db, id=user_id) @@ -64,11 +70,12 @@ def update_user(db, user_id, nickname=None, password=None): db.update('user', user_id, **fields) return find_user(db, id=user_id) + @use_db def auth_login(db, session, nickname, password): """ - Attempts to the authenticate a faceoff user with the given nickname and - password. If successful, the provided session object is changed to an + Attempts to the authenticate a faceoff user with the given nickname and + password. If successful, the provided session object is changed to an authenticated state. NOTE: If the application grows, this will likely need to be moved into a @@ -80,10 +87,11 @@ def auth_login(db, session, nickname, password): password = sha1(password + user['salt']).hexdigest() if password != user['password']: return False - session['user_id'] = user['id'] - session.permanent = True + session['user_id'] = user['id'] + session.permanent = True return True + def auth_logout(session): """ Removes an authenticated state from the given session. @@ -91,12 +99,13 @@ def auth_logout(session): NOTE: If the application grows, this will likely need to be moved into a model that is exclusively responsible for auth management. """ - if session.has_key('user_id'): + if 'user_id' in session: session.pop('user_id') + def generate_salt(): """ - Generates a random user salt that will protect against hash table attacks + Generates a random user salt that will protect against hash table attacks should the database be compromised. """ pool = string.ascii_letters + string.digits diff --git a/src/faceoff/tpl.py b/src/faceoff/tpl.py index 16c734c..689365b 100644 --- a/src/faceoff/tpl.py +++ b/src/faceoff/tpl.py @@ -3,15 +3,16 @@ License: MIT, see LICENSE for details """ -from faceoff.debug import debug from datetime import datetime from time import localtime, strftime, mktime _filters = {} + def init_app(app): app.jinja_env.filters.update(_filters) + def template_filter(f): """ Marks the decorated function as a template filter. @@ -19,16 +20,19 @@ def template_filter(f): _filters[f.__name__] = f return f + @template_filter def date_format(s, f): if isinstance(s, datetime): s = mktime(s.timetuple()) return strftime(f, localtime(int(s))) + @template_filter def player_rank(r): return '---' if r is None else '%03d' % int(r) + @template_filter def full_date(s, with_month=True, with_date=True, with_year=True): if isinstance(s, datetime): @@ -45,6 +49,7 @@ def full_date(s, with_month=True, with_date=True, with_year=True): date += ', ' + d.strftime('%Y') return date + @template_filter def human_date(s): if isinstance(s, datetime): @@ -58,10 +63,12 @@ def human_date(s): return 'yesterday @ %s' % d.strftime('%-I:%M %p').lower() if d.year == n.year: return d.strftime('%a, %b %-d'+num_suffix(d.day)) + \ - d.strftime(' @ %-I%p').lower() - return d.strftime('%b %-d'+num_suffix(d.day)+', %Y')+ \ - d.strftime(' @ %-I%p').lower() + d.strftime(' @ %-I%p').lower() + return d.strftime('%b %-d'+num_suffix(d.day)+', %Y') + \ + d.strftime(' @ %-I%p').lower() + @template_filter def num_suffix(d): - return 'th' if 11 <= d <= 13 else {1:'st', 2:'nd', 3:'rd'}.get(d % 10, 'th') + return 'th' if 11 <= d <= 13 else { + 1: 'st', 2: 'nd', 3: 'rd'}.get(d % 10, 'th') diff --git a/src/faceoff/views.py b/src/faceoff/views.py index e636056..789eece 100644 --- a/src/faceoff/views.py +++ b/src/faceoff/views.py @@ -4,21 +4,18 @@ """ import os -import logging from datetime import datetime, date from time import mktime -from flask import \ - g, request, session, flash, abort, redirect, url_for, send_from_directory, \ - jsonify +from flask import ( + g, request, session, flash, abort, redirect, url_for, send_from_directory) from faceoff import app -from faceoff.debug import debug -from faceoff.forms import \ - LoginForm, JoinForm, ReportForm, NewLeagueForm, SettingsForm, ProfileForm, \ - AdminForm +from faceoff.forms import ( + LoginForm, JoinForm, ReportForm, NewLeagueForm, SettingsForm, ProfileForm, + AdminForm) from faceoff.helpers.decorators import authenticated, templated -from faceoff.models.user import \ - find_user, get_active_users, create_user, update_user, auth_login, \ - auth_logout, find_user_id +from faceoff.models.user import ( + get_active_users, create_user, update_user, auth_login, auth_logout, + find_user_id) from faceoff.models.league import \ find_league, get_active_leagues, get_inactive_leagues, create_league, \ update_league @@ -27,11 +24,13 @@ rebuild_rankings, find_older_match, find_newer_match from faceoff.models.setting import get_setting, set_access_code + @app.teardown_request -def db_close(exception): # pylint:disable=W0613 +def db_close(exception): if hasattr(g, 'db'): g.db.close() + @app.url_value_preprocessor def get_league_from_url(endpoint, view_args): if not view_args or 'league' not in view_args: @@ -41,13 +40,15 @@ def get_league_from_url(endpoint, view_args): abort(404) g.current_league = league + @app.url_defaults def add_league_to_url(endpoint, view_args): - if ('league' not in view_args and - app.url_map.is_endpoint_expecting(endpoint, 'league') and - hasattr(g, 'current_league')): + if ('league' not in view_args and + app.url_map.is_endpoint_expecting(endpoint, 'league') and + hasattr(g, 'current_league')): view_args['league'] = g.current_league['slug'] + @app.context_processor def inject_template_data(): d = {} @@ -57,17 +58,20 @@ def inject_template_data(): d['current_league'] = g.current_league return d + @app.route('/favicon.ico') def favicon(): path = os.path.join(app.root_path, 'static') return send_from_directory(path, 'favicon.ico') + @app.route('/gate') @templated() def gate(): join_form = JoinForm(access_code=get_setting('access_code')) return dict(login_form=LoginForm(), join_form=join_form) + @app.route('/login', methods=('GET', 'POST')) @templated() def login(): @@ -79,11 +83,13 @@ def login(): else: return redirect(url_for('login', fail=1)) + @app.route('/logout') def logout(): auth_logout(session) return redirect(url_for('gate')) + @app.route('/join', methods=('GET', 'POST')) @templated() def join(): @@ -94,12 +100,14 @@ def join(): session['user_id'] = user_id return redirect(url_for('landing')) + @app.route('/') @templated() @authenticated def landing(): return dict(active_leagues=get_active_leagues()) + @app.route('/profile', methods=('GET', 'POST')) @templated() @authenticated @@ -108,20 +116,21 @@ def profile(): if request.method == 'POST' and form.validate(): update_user( g.current_user['id'], - nickname = form.nickname.data, - password = form.password.data - ) + nickname=form.nickname.data, + password=form.password.data) flash('Profile updated successfully!') return redirect(url_for('landing')) form.nickname.data = g.current_user['nickname'] return dict(profile_form=form) + @app.route('/inactive') @templated() @authenticated def inactive(): return dict(inactive_leagues=get_inactive_leagues()) + @app.route('/new', methods=('GET', 'POST')) @templated() @authenticated @@ -129,9 +138,10 @@ def new_league(): form = NewLeagueForm(request.form) if request.method != 'POST' or not form.validate(): return dict(new_league_form=form) - create_league(form.name.data) + create_league(form.name.data) return redirect(url_for('landing')) + @app.route('/admin', methods=('GET', 'POST')) @templated() @authenticated @@ -144,6 +154,7 @@ def admin(): flash('Settings saved') return redirect(url_for('admin')) + @app.route('//') @templated() @authenticated @@ -151,11 +162,11 @@ def dashboard(): user = g.current_user league = g.current_league return dict( - report_form = ReportForm(get_active_users()), - current_ranking = get_user_standing(league['id'], user['id']), + report_form=ReportForm(get_active_users()), + current_ranking=get_user_standing(league['id'], user['id']), ranking=get_league_ranking(league['id']), - history=search_matches(league['id'], user_id=user['id']) - ) + history=search_matches(league['id'], user_id=user['id'])) + @app.route('//report', methods=('POST',)) @templated() @@ -173,19 +184,21 @@ def report(): create_match(g.current_league['id'], winner, loser) return redirect(url_for('dashboard')) + @app.route('//standings/') @templated() @authenticated def standings(): return dict(ranking=get_league_ranking(g.current_league['id'])) -@app.route('//history/', defaults={'nickname': None, 'year': None, 'month': None, 'day': None}) -@app.route('//history//', defaults={'nickname': None, 'month': None, 'day': None}) -@app.route('//history///', defaults={'nickname': None, 'day': None}) -@app.route('//history///', defaults={'nickname': None}) -@app.route('//history//', defaults={'year': None, 'month': None, 'day': None}) -@app.route('//history///', defaults={'month': None, 'day': None}) -@app.route('//history////', defaults={'day': None}) + +@app.route('//history/', defaults={'nickname': None, 'year': None, 'month': None, 'day': None}) # noqa +@app.route('//history//', defaults={'nickname': None, 'month': None, 'day': None}) # noqa +@app.route('//history///', defaults={'nickname': None, 'day': None}) # noqa +@app.route('//history///', defaults={'nickname': None}) # noqa +@app.route('//history//', defaults={'year': None, 'month': None, 'day': None}) # noqa +@app.route('//history///', defaults={'month': None, 'day': None}) # noqa +@app.route('//history////', defaults={'day': None}) # noqa @app.route('//history////') @templated() @authenticated @@ -204,10 +217,12 @@ def history(nickname, year, month, day): day = today.day if month == today.strftime('%b').lower() else 1 refresh = True if refresh: - kwargs = {'nickname': nickname, 'year': year, 'month': month, 'day': day} + kwargs = { + 'nickname': nickname, 'year': year, 'month': month, 'day': day} return redirect(url_for('history', **kwargs)) try: - date_start = datetime.strptime('%s-%s-%s' % (year, month, day), '%Y-%b-%d') + date_start = datetime.strptime( + '%s-%s-%s' % (year, month, day), '%Y-%b-%d') date_end = date_start.replace(hour=23, minute=59, second=59) time_start = mktime(date_start.timetuple()) time_end = mktime(date_end.timetuple()) @@ -219,6 +234,7 @@ def history(nickname, year, month, day): return dict(matches=matches, time_start=time_start, time_end=time_end, newer_match=newer, older_match=older, nickname=nickname) + @app.route('//settings/', methods=('GET', 'POST')) @templated() @authenticated @@ -231,14 +247,14 @@ def settings(): return dict(settings_form=form) if form.validate(): league = update_league( - league['id'], - name = form.name.data, - active = True if form.active.data == '1' else False - ) + league['id'], + name=form.name.data, + active=True if form.active.data == '1' else False) flash('League settings updated') return redirect(url_for('settings', league=league['slug'])) return dict(settings_form=form) + @app.route('//rebuild/', methods=('POST',)) @templated() @authenticated