diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..8001d1a --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app \ No newline at end of file diff --git a/README.md b/README.md index 8d0860b..5f80c44 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ ## Resources +### Login + + ### Users ###### User Endpoints diff --git a/models.py b/__init__.py similarity index 100% rename from models.py rename to __init__.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/api.py b/api/api.py new file mode 100644 index 0000000..0cbe65c --- /dev/null +++ b/api/api.py @@ -0,0 +1,23 @@ +from flask import Blueprint, request, jsonify, session +from .users.api_users import api_users +from .rooms.api_rooms import api_rooms +from .games.api_games import api_games + +from auth.auth import auth + +api = Blueprint('api', __name__, url_prefix='/api') + +def register_api_endpoints(app): + app.register_blueprint(api_users) + app.register_blueprint(api_rooms) + app.register_blueprint(api_games) + app.register_blueprint(api) + app.register_blueprint(auth) + return app + +@api.route('/home', methods=['GET']) +def api_home(): + response = {"message": "home page"} + return jsonify(response) + + diff --git a/api/decorators.py b/api/decorators.py new file mode 100644 index 0000000..96d6865 --- /dev/null +++ b/api/decorators.py @@ -0,0 +1,18 @@ +from flask import Blueprint, request, jsonify, session, abort +import os +import jwt + +def jwt_required(): + def decorator(func): + def authorized(*args, **kwargs): + auth_header = request.headers.get('Authorization') or None + if auth_header: + auth_token = auth_header.split(" ")[1] + if jwt.decode(auth_token, os.environ.get('SECRET_KEY')): + return func(*args, **kwargs) + else: + abort(401) + else: + abort(401) + return authorized + return decorator \ No newline at end of file diff --git a/api/games/__init__.py b/api/games/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/games/api_games.py b/api/games/api_games.py new file mode 100644 index 0000000..a4853bb --- /dev/null +++ b/api/games/api_games.py @@ -0,0 +1,59 @@ +from flask import Blueprint, request, jsonify, session +from models.User import User, user_schema, users_schema +from models.GameRoom import GameRoom, rooms_schema, room_schema +from models.Game import Game, game_schema +from database import db +from ..decorators import jwt_required +import jwt +import os +import json +from websockets.roomSocket import new_game_notice, join_game_notice + +api_games = Blueprint('api_games', __name__, url_prefix='/api/games') + +@api_games.route('/', methods=['GET']) +def get_room(game_id): + print(game_id) + game = Game.query.filter_by(id=game_id).first() + response = game_schema.dumps(game) + # TODO create decorator that returns user from header + auth_header = request.headers.get('Authorization') + user = jwt.decode(auth_header.split(" ")[1], os.environ.get('SECRET_KEY'))['user'] + print(user) + join_game_notice(game_id, user) + return jsonify(response) + +@api_games.route('/', methods=['POST']) +@jwt_required() +def post_game(): + data = request.get_json() + # TODO create decorator that returns user from header + auth_header = request.headers.get('Authorization') + user = jwt.decode(auth_header.split(" ")[1], os.environ.get('SECRET_KEY'))['user'] + user_id = json.loads(user)['id'] + try: + game = Game( + name = data['name'], + description = data['description'], + board_size = data['boardSize'], + game_room = data['gameRoom'], + player_white = user_id + ) + db.session.add(game) + db.session.commit() + new_game_notice(room=game.game_room, game=game_schema.dumps(game)) + response = { + 'status': 'success', + 'message': 'Game created', + 'game': game.id + } + return jsonify(response), 201 + except Exception as e: + print('error') + print(e) + print(e.__dict__) + response = { + 'status': 'fail', + 'message': 'There was an error. Please try again.' + } + return jsonify(response), 401 diff --git a/api/home/__init__.py b/api/home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/home/home.py b/api/home/home.py new file mode 100644 index 0000000..22fa366 --- /dev/null +++ b/api/home/home.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +home = Blueprint('home', __name__) + +@home.route('/home') +def func(): + pass \ No newline at end of file diff --git a/api/rooms/__init__.py b/api/rooms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/rooms/api_rooms.py b/api/rooms/api_rooms.py new file mode 100644 index 0000000..8f942fd --- /dev/null +++ b/api/rooms/api_rooms.py @@ -0,0 +1,55 @@ +from flask import Blueprint, request, jsonify, session +from models.User import User, user_schema, users_schema +from models.GameRoom import GameRoom, rooms_schema, room_schema +from models.Game import Game, games_schema +from database import db +from ..decorators import jwt_required +from websockets.roomSocket import new_room_notice, join_room_notice + +api_rooms = Blueprint('api_rooms', __name__, url_prefix='/api/rooms') + +@api_rooms.route('/', methods=['GET']) +def get_room(room_id): + print(room_id) + games = Game.query.filter_by(game_room=room_id).all() + response = games_schema.dumps(games) + join_room_notice(room_id) + return jsonify(response) + +@api_rooms.route('/', methods=['GET']) +def get_rooms(): + rooms = GameRoom.query.all() + response = rooms_schema.dumps(rooms) + return jsonify(response) + +# protected route +@api_rooms.route('/', methods=['POST']) +@jwt_required() +def post_room(): + print('Hey it\'s a POST request') + data = request.get_json() + print(data) + try: + room = GameRoom( + name = data['name'], + description = data['description'], + # TODO add support for private rooms and multiple languages + # private = data['private'], + # language = data['language'] + ) + db.session.add(room) + db.session.commit() + response = { + 'status': 'success', + 'message': 'Succesfully registered.', + } + new_room_notice(room_schema.dumps(room)) + return jsonify(response), 201 + except Exception as e: + print(e) + print(e.__dict__) + response = { + 'status': 'fail', + 'message': 'There was an error. Please try again.' + } + return jsonify(response), 401 \ No newline at end of file diff --git a/api/users/__init__.py b/api/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/users/api_users.py b/api/users/api_users.py new file mode 100644 index 0000000..7a77709 --- /dev/null +++ b/api/users/api_users.py @@ -0,0 +1,28 @@ +from models.User import User, user_schema, users_schema +from flask import Blueprint, request, json, session, jsonify +from ..decorators import jwt_required + +api_users = Blueprint('api_users', __name__, url_prefix='/api') + +@api_users.route('/users/', methods=['GET']) +def api_get_users(): + print('called one') + users = User.query.all() + response = users_schema.dumps(users) + return jsonify(response), 200 + + +@api_users.route('/users/account', methods=['GET']) +@jwt_required() +def api_get_user(): + print('called') + auth_header = request.headers.get('Authorization') or None + if auth_header: + auth_token = auth_header.split(" ")[1] + user = User.decode_auth_token(auth_token) or None + response = json.dumps(user) + else: + response = { + 'status': 'failed', + 'message': 'Please Log In'} + return jsonify(response) diff --git a/app.py b/app.py index b99c362..c5f1062 100644 --- a/app.py +++ b/app.py @@ -1,15 +1,29 @@ import os +from database import db, ma from flask import Flask +from flask_bcrypt import Bcrypt +from flask_cors import CORS +from flask_socketio import SocketIO + app = Flask(__name__) +bcrypt = Bcrypt(app) +app.config['CORS_HEADERS'] = 'Content-Type' -DEBUG = True -PORT = 8000 +# ! Environment Variable +# TODO export CONFIGURATION_OBJECT='configuration.config.ProductionConfig' +app.config.from_object(os.getenv('CONFIGURATION_OBJECT')) -@app.route('/') -def hello_world(): - return 'Hello World' +# ! Environment Variable +# TODO export ALLOWED_ORIGIN= whatever the react server is +socketio = SocketIO(app, cors_allowed_origins=os.getenv('ALLOWED_ORIGIN')) -if __name__ == '__main__': - app.run(debug=DEBUG, port=PORT) \ No newline at end of file +def create_app(): + CORS(app, resources={ + r"/api/*": {"origins": os.getenv('ALLOWED_ORIGIN')}, + r"/auth/*": {"origins": os.getenv('ALLOWED_ORIGIN')}, + }) + db.init_app(app) + ma.init_app(app) + return app diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/auth.py b/auth/auth.py new file mode 100644 index 0000000..c263c6e --- /dev/null +++ b/auth/auth.py @@ -0,0 +1,73 @@ +from flask import Blueprint, request, jsonify, session +from database import db +from models.User import User + +auth = Blueprint('auth', __name__, url_prefix='/auth') + +@auth.route('/signup', methods=['POST']) +def auth_signup(): + data = request.get_json() + user = User.query.filter_by(email=data.get('email')).first() + if not user: + user = User.query.filter_by(username=data.get('username')).first() + if not user: + try: + user = User( + username = data['username'], + email = data['email'], + password = data['password'], + ) + db.session.add(user) + db.session.commit() + auth_token = user.encode_auth_token(user.id) + response = { + 'status': 'success', + 'message': 'Succesfully registered.', + 'token': auth_token.decode() + } + return jsonify(response), 201 + except Exception as e: + print(e.__dict__) + response = { + 'status': 'fail', + 'message': 'There was an error. Please try again.' + } + return jsonify(response), 401 + else: # username is taken + response = { + 'status': 'fail', + 'message': 'User already exists. Please login.' + } + else: # email is taken + response = { + 'status': 'fail', + 'message': 'User already exists. Please login.' + } + return jsonify(response), 202 + +@auth.route('/login', methods=['POST']) +def auth_login(): + # get the post data + data = request.get_json() + try: + # fetch the user data + print('getting here') + print(data) + user = User.query.filter_by(email=data['email']).first() + print(user.username) + auth_token = user.encode_auth_token(user.id) + print(auth_token) + if auth_token: + response = { + 'status': 'success', + 'message': 'Successfully logged in.', + 'token': auth_token.decode() + } + return jsonify(response), 200 + except Exception as e: + print(e) + response = { + 'status': 'fail', + 'message': 'Try again' + } + return jsonify(response), 500 \ No newline at end of file diff --git a/configuration/__init__.py b/configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/configuration/config.py b/configuration/config.py new file mode 100644 index 0000000..efdd88e --- /dev/null +++ b/configuration/config.py @@ -0,0 +1,34 @@ +import os +# local db +# ! Environment Variable +DATABASE = 'postgresql://localhost/browser-go' + +class BaseConfig: + """Base configuration.""" + SECRET_KEY = os.getenv('SECRET_KEY') + DEBUG = False + BCRYPT_LOG_ROUNDS = 13 + SQLALCHEMY_TRACK_MODIFICATIONS = False + +class DevelopmentConfig(BaseConfig): + """Development configuration.""" + DEBUG = True + BCRYPT_LOG_ROUNDS = 4 + SQLALCHEMY_DATABASE_URI = DATABASE + PORT = 5000 + + +class TestingConfig(BaseConfig): + """Testing configuration.""" + DEBUG = True + TESTING = True + BCRYPT_LOG_ROUNDS = 4 + SQLALCHEMY_DATABASE_URI = DATABASE + PRESERVE_CONTEXT_ON_EXCEPTION = False + + +class ProductionConfig(BaseConfig): + """Production configuration.""" + SECRET_KEY = '' + DEBUG = False + SQLALCHEMY_DATABASE_URI = 'postgresql:///' diff --git a/configuration/models_mount.py b/configuration/models_mount.py new file mode 100644 index 0000000..a5299f7 --- /dev/null +++ b/configuration/models_mount.py @@ -0,0 +1,6 @@ +if __name__ == '__main__': + from ..models.User import User + from ..models.GameRoom import GameRoom + from ..models.Game import Game + from ..models.Move import Move + from ..models.Message import Message \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..093d45e --- /dev/null +++ b/database.py @@ -0,0 +1,10 @@ + +# ! SQLAlchemy > Marshmallow - these must be imported in this order +from flask_sqlalchemy import SQLAlchemy +from flask_marshmallow import Marshmallow + +# init database +db = SQLAlchemy() + +# init marshmallow +ma = Marshmallow() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..e990a80 --- /dev/null +++ b/manage.py @@ -0,0 +1,47 @@ +import os +import unittest + +from flask_script import Manager +from flask_migrate import Migrate, MigrateCommand + +from database import db +from app import create_app +app = create_app() + +migrate = Migrate(app, db) +manager = Manager(app) + +from models.Game import Game +from models.GameRoom import GameRoom +from models.Message import Message +from models.Move import Move +from models.User import User + +# migrations +manager.add_command('db', MigrateCommand) + + +@manager.command +def test(): + """Runs the unit tests without test coverage.""" + tests = unittest.TestLoader().discover('browser-go-api/tests', pattern='test*.py') + result = unittest.TextTestRunner(verbosity=2).run(tests) + if result.wasSuccessful(): + return 0 + return 1 + + +@manager.command +def create_db(): + """Creates the db tables.""" + db.create_all() + + +@manager.command +def drop_db(): + """Drops the db tables.""" + db.drop_all() + + +if __name__ == '__main__': + manager.run() \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# 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 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..79b8174 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,96 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app +config.set_main_option( + 'sqlalchemy.url', current_app.config.get( + 'SQLALCHEMY_DATABASE_URI').replace('%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/45f01fb15e26_.py b/migrations/versions/45f01fb15e26_.py new file mode 100644 index 0000000..29db1f3 --- /dev/null +++ b/migrations/versions/45f01fb15e26_.py @@ -0,0 +1,122 @@ +"""empty message + +Revision ID: 45f01fb15e26 +Revises: +Create Date: 2019-10-10 17:50:40.846864 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '45f01fb15e26' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('game_rooms', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=40), nullable=False), + sa.Column('description', sa.String(length=200), nullable=False), + sa.Column('private', sa.Boolean(), nullable=False), + sa.Column('language', sa.Enum('EN', name='languages'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('username', sa.String(length=255), autoincrement=True, nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('password', sa.String(length=255), nullable=False), + sa.Column('registered_on', sa.DateTime(), nullable=False), + sa.Column('admin', sa.Boolean(), nullable=False), + sa.Column('rank', sa.Enum('D7', 'D6', 'D5', 'D4', 'D3', 'D2', 'D1', 'K1', 'K2', 'K3', 'K4', 'K5', 'K6', 'K7', 'K8', 'K9', 'K10', 'K11', 'K12', 'K13', 'K14', 'K15', 'K16', 'K17', 'K18', 'K19', 'K20', 'K21', 'K22', 'K23', 'K24', 'K25', 'K26', 'K27', 'K28', 'K29', 'K30', 'UR', name='ranks'), nullable=True), + sa.Column('elo', sa.Integer(), nullable=True), + sa.Column('rank_certainty', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + op.create_table('game_rooms_users', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('game_rooms_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['game_rooms_id'], ['game_rooms.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('user_id', 'game_rooms_id') + ) + op.create_table('games', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('date', sa.DateTime(), nullable=True), + sa.Column('komi', sa.Numeric(precision=2, scale=1), nullable=False), + sa.Column('handicap', sa.Integer(), nullable=False), + sa.Column('board_size', sa.Integer(), nullable=False), + sa.Column('win_type', sa.Enum('DRAW', 'RESIGN', 'SCORE', 'TIME', 'VOID', name='wintype'), nullable=True), + sa.Column('winner', sa.Enum('BLACK', 'WHITE', 'VOID', name='players'), nullable=True), + sa.Column('score', sa.Numeric(precision=2, scale=1), nullable=True), + sa.Column('white_captures', sa.Integer(), nullable=True), + sa.Column('black_captures', sa.Integer(), nullable=True), + sa.Column('application', sa.String(length=40), nullable=True), + sa.Column('application_version', sa.String(length=20), nullable=True), + sa.Column('event', sa.String(length=40), nullable=True), + sa.Column('name', sa.String(length=40), nullable=True), + sa.Column('description', sa.String(length=200), nullable=True), + sa.Column('round', sa.Integer(), nullable=True), + sa.Column('main_time', sa.Enum('BYOYOMI', 'ABSOLUTE', 'HOURGLASS', 'NONE', name='timetypes'), nullable=False), + sa.Column('time_period', sa.Integer(), nullable=True), + sa.Column('period_length', sa.Integer(), nullable=True), + sa.Column('overtime', sa.Enum('BYOYOMI', 'ABSOLUTE', 'HOURGLASS', 'NONE', name='timetypes'), nullable=False), + sa.Column('overtime_period', sa.Integer(), nullable=True), + sa.Column('overtime_length', sa.Integer(), nullable=True), + sa.Column('game_room', sa.Integer(), nullable=True), + sa.Column('player_black', sa.Integer(), nullable=True), + sa.Column('player_white', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['game_room'], ['game_rooms.id'], ), + sa.ForeignKeyConstraint(['player_black'], ['users.id'], ), + sa.ForeignKeyConstraint(['player_white'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('games_users', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('game_rooms_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['game_rooms_id'], ['games.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('user_id', 'game_rooms_id') + ) + op.create_table('moves', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('player', sa.Enum('BLACK', 'WHITE', name='players'), nullable=True), + sa.Column('x_point', sa.Integer(), nullable=True), + sa.Column('y_point', sa.Integer(), nullable=True), + sa.Column('move_number', sa.Integer(), nullable=True), + sa.Column('is_pass', sa.Boolean(), nullable=False), + sa.Column('is_main', sa.Boolean(), nullable=False), + sa.Column('game', sa.Integer(), nullable=False), + sa.Column('preceding_move', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['game'], ['games.id'], ), + sa.ForeignKeyConstraint(['preceding_move'], ['moves.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('messages', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('content', sa.String(length=200), nullable=False), + sa.Column('move', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['move'], ['moves.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('messages') + op.drop_table('moves') + op.drop_table('games_users') + op.drop_table('games') + op.drop_table('game_rooms_users') + op.drop_table('users') + op.drop_table('game_rooms') + # ### end Alembic commands ### diff --git a/models/Game.py b/models/Game.py new file mode 100644 index 0000000..16ac68c --- /dev/null +++ b/models/Game.py @@ -0,0 +1,85 @@ +from app import db, ma +from marshmallow import fields +import enum +from models.User import user_schema + +# ! Games >-< Users join table +games_users = db.Table('games_users', + db.Column('user_id', db.Integer, db.ForeignKey('users.id'), primary_key=True), + db.Column('game_rooms_id', db.Integer, db.ForeignKey('games.id'), primary_key=True) +) + +class Game(db.Model): + __tablename__ = "games" + __table_args__ = {'extend_existing': True} + + class Players(enum.Enum): + BLACK = "The player taking black stones" + WHITE = "The player taking white stones" + VOID = "The game was a draw or voided" + + class WinType(enum.Enum): + DRAW = "The game is a draw" + RESIGN = "The game ended in resignation" + SCORE = "The game ended by counting points" + TIME = "The game ended in loss by time out" + VOID = "The game was suspended" + + class TimeTypes(enum.Enum): + BYOYOMI = "Counting by time period" + ABSOLUTE = "One period to use time" + HOURGLASS = "Absolute time for both players" + NONE = "Untimed" + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + date = db.Column(db.DateTime()) + komi = db.Column(db.Numeric(2,1), nullable=False) + handicap = db.Column(db.Integer, nullable=False) + board_size = db.Column(db.Integer, nullable=False) + win_type = db.Column(db.Enum(WinType)) + winner = db.Column(db.Enum(Players)) + score = db.Column(db.Numeric(2,1)) + white_captures = db.Column(db.Integer) + black_captures = db.Column(db.Integer) + application = db.Column(db.String(40)) + application_version = db.Column(db.String(20)) + event = db.Column(db.String(40)) + name = db.Column(db.String(40)) + description = db.Column(db.String(200)) + round = db.Column(db.Integer) + main_time = db.Column(db.Enum(TimeTypes), nullable=False) + time_period = db.Column(db.Integer) # number of periods + period_length = db.Column(db.Integer) # seconds + overtime = db.Column(db.Enum(TimeTypes), nullable=False) + overtime_period = db.Column(db.Integer) # number of overtime periods + overtime_length = db.Column(db.Integer) # seconds + + # foreign keys + game_room = db.Column(db.Integer, db.ForeignKey("game_rooms.id")) + player_black = db.Column(db.Integer, db.ForeignKey("users.id")) + player_white = db.Column(db.Integer, db.ForeignKey("users.id")) + + def __init__( + self, name, description, board_size, game_room, player_white, + komi=0.5, handicap=0, main_time=TimeTypes.NONE, overtime=TimeTypes.NONE + ): + self.name = name + self.description = description + self.board_size = board_size + self.game_room = game_room + self.player_white = player_white + self.komi = komi + self.handicap = handicap + self.main_time = main_time + self.overtime = overtime + +class GameSchema(ma.ModelSchema): + id = fields.Int() + name = fields.Str() + description = fields.Str() + board_size = fields.Int() + player = fields.Nested(user_schema) + game_room = fields.Int() + +game_schema = GameSchema() +games_schema = GameSchema(many=True) \ No newline at end of file diff --git a/models/GameRoom.py b/models/GameRoom.py new file mode 100644 index 0000000..084f04b --- /dev/null +++ b/models/GameRoom.py @@ -0,0 +1,40 @@ +from app import db, ma +from marshmallow import fields +import enum +# TODO User >---< GameRoom + +# ! Game Rooms >-< Users join table +game_rooms_users = db.Table('game_rooms_users', + db.Column('user_id', db.Integer, db.ForeignKey('users.id'), primary_key=True), + db.Column('game_rooms_id', db.Integer, db.ForeignKey('game_rooms.id'), primary_key=True) +) + +class GameRoom(db.Model): + __tablename__ = "game_rooms" + __table_args__ = {'extend_existing': True} + + class Languages(enum.Enum): + EN = "English" + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String(40), nullable=False) + description = db.Column(db.String(200), nullable=False) + private = db.Column(db.Boolean(), nullable=False, default=False) + language = db.Column(db.Enum(Languages), nullable=False, default=Languages.EN) + + def __init__(self, name, description, private=False, language=Languages.EN): + self.name = name + self.description = description + self.private = private + self.language = language + +class RoomSchema(ma.ModelSchema): + id = fields.Int() + name = fields.Str() + description = fields.Str() + private = fields.Bool() + language = fields.Str() + + +room_schema = RoomSchema() +rooms_schema = RoomSchema(many=True) \ No newline at end of file diff --git a/models/Message.py b/models/Message.py new file mode 100644 index 0000000..c093ba9 --- /dev/null +++ b/models/Message.py @@ -0,0 +1,21 @@ +from app import db, ma +import enum + + +class Message(db.Model): + __tablename__ = "messages" + __table_args__ = {'extend_existing': True} + + class Players(enum.Enum): + BLACK = "The player taking black stones" + WHITE = "The player taking white stones" + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + date = db.Column(db.DateTime(), nullable=False) + content = db.Column(db.String(200), nullable=False) + + # foreign key + move = db.Column(db.Integer, db.ForeignKey("moves.id"), nullable=False) + + def __init__(self): + pass \ No newline at end of file diff --git a/models/Move.py b/models/Move.py new file mode 100644 index 0000000..351b38e --- /dev/null +++ b/models/Move.py @@ -0,0 +1,32 @@ +from app import db, ma +import enum + + +class Move(db.Model): + __tablename__ = "moves" + __table_args__ = {'extend_existing': True} + + class Players(enum.Enum): + BLACK = "The player taking black stones" + WHITE = "The player taking white stones" + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + player = db.Column(db.Enum(Players)) + x_point = db.Column(db.Integer) + y_point = db.Column(db.Integer) + move_number = db.Column(db.Integer) + is_pass = db.Column(db.Boolean, nullable=False, default=False) + is_main = db.Column(db.Boolean, nullable=False, default=True) + + # foreign keys + game = db.Column(db.Integer, db.ForeignKey("games.id"), nullable=False) + preceding_move = db.Column(db.Integer, db.ForeignKey("moves.id")) + + succeeding_moves = db.relationship( + 'Move', + lazy='subquery', + backref=db.backref('moves', lazy=True) + ) + + def __init__(self): + pass \ No newline at end of file diff --git a/models/User.py b/models/User.py new file mode 100644 index 0000000..5ee165b --- /dev/null +++ b/models/User.py @@ -0,0 +1,121 @@ +from database import db, ma +from marshmallow import fields +from app import bcrypt +from configuration import config +import datetime +import enum +import json +import jwt +import os + + +class User(db.Model): + __tablename__ = "users" + + class Ranks(enum.Enum): # with minimal Elo rating + D7 = "Seven Dan" # Elo 2700+ + D6 = "Six Dan" + D5 = "Five Dan" # Elo 2500 + D4 = "Four Dan" + D3 = "Three Dan" + D2 = "Two Dan" + D1 = "One Dan" + K1 = "One Kyu" # Elo 2000 + K2 = "Two Kyu" + K3 = "Three Kyu" + K4 = "Four Kyu" + K5 = "Five Kyu" + K6 = "Six Kyu" # Elo 1500 + K7 = "Seven Kyu" + K8 = "Eight Kyu" + K9 = "Nine Kyu" + K10 = "Ten Kyu" + K11 = "Eleven Kyu" # ELo 1000 + K12 = "Twelve Kyu" + K13 = "Thirteen Kyu" + K14 = "Fourteen Kyu" + K15 = "Fifteen Kyu" + K16 = "Sixteen Kyu" # Elo 500 + K17 = "Seventeen Kyu" + K18 = "Eighteen Kyu" + K19 = "Nineteen Kyu" + K20 = "Twenty Kyu" + K21 = "Twenty-One Kyu" # Elo 0 + K22 = "Twenty-Two Kyu" + K23 = "Twenty-Three Kyu" + K24 = "Twenty-Four Kyu" + K25 = "Twenty-Five Kyu" + K26 = "Twenty-Six Kyu" # Elo -500 + K27 = "Twenty-Seven Kyu" + K28 = "Twenty-Eight Kyu" + K29 = "Twenty-Nine Kyu" + K30 = "Thirty Kyu" # Elo -900 + UR = "Unknown Rank" + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + username = db.Column(db.String(255), unique=True, nullable=False, autoincrement=True) + email = db.Column(db.String(255), unique=True, nullable=False) + password = db.Column(db.String(255), nullable=False) + registered_on = db.Column(db.DateTime, nullable=False) + admin = db.Column(db.Boolean, nullable=False, default=False) + rank = db.Column(db.Enum(Ranks)) + elo = db.Column(db.Integer) + rank_certainty = db.Column(db.Boolean, nullable=False, default=False) + + + def __init__(self, username, email, password, rank=Ranks.K1, admin=False): + self.username = username + self.email = email + self.password = bcrypt.generate_password_hash( + password, 13 + ).decode() + self.rank = rank + self.registered_on = datetime.datetime.now() + self.admin = admin + + def encode_auth_token(self, user_id): + """ + Generates the Auth Token + :return: string + """ + try: + payload = { + 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=4), + 'iat': datetime.datetime.utcnow(), + 'user': user_schema.dumps(self) + } + return jwt.encode( + payload, + os.environ.get('SECRET_KEY'), + algorithm='HS256' + ) + except Exception as e: + return e + + @staticmethod + def decode_auth_token(auth_token): + """ + Decodes the auth token + :param auth_token: + :return: integer|string + """ + try: + payload = jwt.decode(auth_token, os.environ.get('SECRET_KEY')) + return payload['user'] + except jwt.ExpiredSignatureError: + return 'Signature expired. Please log in again.' + except jwt.InvalidTokenError: + return 'Invalid token. Please log in again.' + +class UserSchema(ma.ModelSchema): + id = fields.Int() + username = fields.Str() + email = fields.Str() + registered_on = fields.Date() + rank = fields.Str() + rank_certainty = fields.Bool() + elo = fields.Int() + + +user_schema = UserSchema() +users_schema = UserSchema(many=True) \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index 56ac562..432ba18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,41 @@ +alembic==1.2.1 +astroid==2.3.1 +bcrypt==3.1.7 +cffi==1.12.3 Click==7.0 +dnspython==1.16.0 +eventlet==0.25.1 Flask==1.1.1 +Flask-Bcrypt==0.7.1 Flask-Cors==3.0.8 flask-marshmallow==0.10.1 +Flask-Migrate==2.5.2 +Flask-Script==2.0.6 +Flask-SocketIO==4.2.1 Flask-SQLAlchemy==2.4.1 +Flask-Testing==0.7.1 +greenlet==0.4.15 +isort==4.3.21 itsdangerous==1.1.0 Jinja2==2.10.1 +lazy-object-proxy==1.4.2 +Mako==1.1.0 MarkupSafe==1.1.1 marshmallow==3.2.0 marshmallow-sqlalchemy==0.19.0 +mccabe==0.6.1 +monotonic==1.5 +numpy==1.17.2 +psycopg2==2.8.3 +pycparser==2.19 +PyJWT==1.7.1 +pylint==2.4.2 +python-dateutil==2.8.0 +python-editor==1.0.4 +python-engineio==3.9.3 +python-socketio==4.3.1 six==1.12.0 SQLAlchemy==1.3.8 +typed-ast==1.4.0 Werkzeug==0.16.0 +wrapt==1.11.2 diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..07261fe --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.7.4 \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..a67d0ec --- /dev/null +++ b/server.py @@ -0,0 +1,17 @@ +from app import create_app, db + +# Blueprints +from api.api import register_api_endpoints +from auth.auth import auth + +# Web sockets +from websockets.socket import socketio + +import configuration.models_mount +from flask_migrate import Migrate + +if __name__ == '__main__': + app = create_app() + register_api_endpoints(app) + migrate = Migrate(app, db) + socketio.run(app, debug=True) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 0000000..1bed7a2 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,14 @@ +from flask_testing import TestCase +from ..app import app, db + +class BaseTestCase(TestCase): + """ Base Tests """ + + def create_app(self): + pass + + def setUp(self): + pass + + def tearDown(self): + pass \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..df01f5a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,14 @@ +import unittest + +from flask import current_app +from flask_testing import TestCase + +from ..app import app + +class TestDevelopmentConfig(TestCase): + def create_app(self): + app.config.from_object('browser-go-api.config.DevelopmentConfig') + return app + + def test_app_is_development(self): + self.assertFalse(app.config['SECRET_KEY'] is 'my_precious') \ No newline at end of file diff --git a/websockets/__init__.py b/websockets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/websockets/roomSocket.py b/websockets/roomSocket.py new file mode 100644 index 0000000..a707602 --- /dev/null +++ b/websockets/roomSocket.py @@ -0,0 +1,24 @@ +from app import socketio +from flask_socketio import send, emit, join_room, leave_room +import json + +def join_room_notice(room): + @socketio.on('join room', namespace=f'/{room}') + def connect_room(message): + print(f'connected with ${message}') + emit('connected', {'roomspace': f'/{room}'}) + +def new_game_notice(room, game): + print('sending new game notice') + socketio.emit('new game', game, broadcast=True, namespace=f'/{room}') + + +def new_room_notice(room): + socketio.emit('new room', room, broadcast=True) + +def join_game_notice(game_id, user): + @socketio.on('join game') + def return_join_game_notice(data): + game = data['game'] + join_room(game) + emit('join game', data, room=f'game') diff --git a/websockets/socket.py b/websockets/socket.py new file mode 100644 index 0000000..af5e8c0 --- /dev/null +++ b/websockets/socket.py @@ -0,0 +1,27 @@ +from app import socketio +from flask_socketio import send, emit, join_room, leave_room +import json + +# ! Basic Connection +@socketio.on('connect') +def handle_connection(): + print(''' + + wow, there was a socketio connection! + + cool + ''') + emit('message', {'data':'connection'}, broadcast=True) + +@socketio.on('send message') +def handle_message(message): + print(message) + emit('init namespace', {'namespace':'newroom'}) + +@socketio.on('connect', namespace='/newroom') +def handle_connection(): + print(''' + + look cool a namespaced socketio connection! + + ''')