diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..a2f4ac9 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,6 @@ + +from flask import Blueprint + +bp = Blueprint('auth', __name__) + +from app.auth import routes \ No newline at end of file diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 0000000..8afe482 --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,24 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField, BooleanField +from wtforms.validators import DataRequired, EqualTo, ValidationError, Length +from pytz import timezone +from app.models import Player + + +class LoginForm(FlaskForm): + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Remember Me', default=True) + submit = SubmitField('Sign In') + +class RegistrationForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(min=0, max=64)]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=0, max=128)]) + password2 = PasswordField( + 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('Register') + + def validate_username(self, username): + player = Player.query.filter_by(name=username.data).first() + if player is not None: + raise ValidationError('Please use a different username.') \ No newline at end of file diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..568b34e --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,41 @@ +from flask import render_template, flash, redirect, url_for +from flask_login import login_user, logout_user, current_user, login_required +from app import db +from app.auth import bp +from app.models import Player +from app.auth.forms import LoginForm, RegistrationForm + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = LoginForm() + if form.validate_on_submit(): + player = Player.query.filter_by(name=form.username.data).first() + if player is None or not player.check_password(form.password.data): + flash('Invalid username or password') + return redirect(url_for('auth.login')) + login_user(player, remember=form.remember_me.data) + return redirect(url_for('main.index')) + return render_template('login.html', title='Sign In', form=form) + +@bp.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('main.index')) + +@bp.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = RegistrationForm() + if form.validate_on_submit(): + player = Player(name=form.username.data) + player.set_password(form.password.data) + player.set_auth_hash() + db.session.add(player) + db.session.commit() + flash('Congratulations, you are now a registered user!') + return redirect(url_for('auth.login')) + return render_template('register.html', title='Register', form=form) diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 0000000..5701c1d --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('errors', __name__) + +from app.errors import handlers diff --git a/app/errors/handlers.py b/app/errors/handlers.py new file mode 100644 index 0000000..062dc71 --- /dev/null +++ b/app/errors/handlers.py @@ -0,0 +1,20 @@ +from flask import render_template +from app import db +from app.errors import bp + +@bp.app_errorhandler(403) +def forbidden_error(error): + return render_template('errors/403.html'), 403 + +@bp.app_errorhandler(404) +def not_found_error(error): + return render_template('errors/404.html'), 404 + +@bp.app_errorhandler(405) +def method_not_allowed_error(error): + return render_template('errors/405.html'), 405 + +@bp.app_errorhandler(500) +def internal_error(error): + db.session.rollback() + return render_template('errors/500.html'), 500 \ No newline at end of file diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 0000000..f1c69e2 --- /dev/null +++ b/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('main', __name__) + +from app.main import routes \ No newline at end of file diff --git a/app/main/forms.py b/app/main/forms.py new file mode 100644 index 0000000..2718e18 --- /dev/null +++ b/app/main/forms.py @@ -0,0 +1,54 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, DateTimeField, BooleanField, HiddenField, FloatField, SelectField +from wtforms.validators import DataRequired, ValidationError, Length, NumberRange +from pytz import timezone +from app.models import Objective + +class CreateGameForm(FlaskForm): + game_name = StringField('Game Name', validators=[DataRequired(), Length(min=0, max=64)]) + start_time_disabled = BooleanField('No start time') + start_time = DateTimeField(id='datetimepicker_start', format="%d-%m-%Y %H:%M") + end_time_disabled = BooleanField('No end time') + end_time = DateTimeField(id='datetimepicker_end', format="%d-%m-%Y %H:%M") + timezone = HiddenField(validators=[DataRequired()]) + submit = SubmitField('Create') + + def validate_start_time(self, start_time): + self.date_time_validator(self.start_time_disabled, start_time) + + def validate_end_time(self, end_time): + self.date_time_validator(self.end_time_disabled, end_time) + + def date_time_validator(self, disabled, date_time): + if disabled.data: + date_time.data = None + return + clientzone = timezone(self.timezone.data) + date_time_utc = clientzone.localize(date_time.data).astimezone(timezone('UTC')) + date_time.data = date_time_utc + +class ObjectiveForm(FlaskForm): + objective_name = StringField('Objective Name', validators=[Length(min=0, max=64)]) + latitude = FloatField('Latitude', validators=[DataRequired(), NumberRange(min=-90, max=90)]) + longitude = FloatField('Longitude', validators=[DataRequired(), NumberRange(min=-180, max=180)]) + submit = SubmitField('Save') + + def validate_objective_name(self, objective_name): + if objective_name.data == '': return + objective = Objective.query.filter_by(name=objective_name.data).first() + if objective is not None: + raise ValidationError('Please use a different name.') + +class PlayerUpdateForm(FlaskForm): + role = SelectField('Player Role', choices=[('none', 'none'), ('owner', 'owner'), ('hunter', 'hunter'), ('bunny', 'bunny')], validators=[DataRequired()]) + submit = SubmitField('Update') + +class PlayerAddForm(FlaskForm): + name = StringField('Player Name', validators=[DataRequired(), Length(min=0, max=64)]) + role = SelectField('Player Role', choices=[('none', 'none'), ('owner', 'owner'), ('hunter', 'hunter'), ('bunny', 'bunny')], validators=[DataRequired()]) + submit_add = SubmitField('Create') + +class PlayerCreateForm(FlaskForm): + name = StringField('Player Name', validators=[DataRequired(), Length(min=0, max=64)]) + role = SelectField('Player Role', choices=[('none', 'none'), ('owner', 'owner'), ('hunter', 'hunter'), ('bunny', 'bunny')], validators=[DataRequired()]) + submit_create = SubmitField('Create') \ No newline at end of file diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 0000000..5ee120f --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,187 @@ +import json +import qrcode +from flask import render_template, flash, redirect, url_for, request, abort, send_file +from flask_login import current_user, login_required +from sqlalchemy import and_ +from io import BytesIO +from app import db +from app.main import bp +from app.models import Player, Game, Role, GamePlayer, Objective, ObjectiveMinimalEncoder, LocationEncoder +from app.main.forms import CreateGameForm, ObjectiveForm, PlayerAddForm, PlayerCreateForm, PlayerUpdateForm + +@bp.route('/') +@bp.route('/index') +@login_required +def index(): + return render_template("index.html", title='Home') + +@bp.route('/create_game', methods=['GET', 'POST']) +@login_required +def create_game(): + print(current_user.is_authenticated) + form = CreateGameForm() + if form.validate_on_submit(): + game = Game(name=form.game_name.data, start_time=form.start_time.data, end_time=form.end_time.data) + game.game_players.append(GamePlayer(player=current_user, role=Role['owner'])) + db.session.add(game) + db.session.commit() + flash(f"'{game.name}' had been created!") + return redirect(url_for('main.game_dashboard', game_name=game.name)) + return render_template('create_game.html', title='Create Game', form=form) + +@bp.route('/game//dashboard') +@login_required +def game_dashboard(game_name): + #game = Game.query.filter(Game.game_players.any(and_(GamePlayer.player.has(Player.name == current_user.name), GamePlayer.role == 'owner'))).first_or_404() + game = Game.query.filter_by(name=game_name).first_or_404() + if not is_game_owner(game): + abort(403) + return render_template('game_dashboard.html', title='Game Dashboard', game=game, json=json, objective_encoder=ObjectiveMinimalEncoder, location_encoder=LocationEncoder) + +@bp.route('/game//addplayer', methods=['GET', 'POST']) +@login_required +def add_player(game_name): + game = Game.query.filter_by(name=game_name).first_or_404() + if not is_game_owner(game): + abort(403) + form_add = PlayerAddForm() + form_create = PlayerCreateForm() + + if form_add.submit_add.data and form_add.validate_on_submit(): + player = Player.query.filter_by(form_add.name.data).first_or_404() + game.game_players.append(GamePlayer(player=player, role=Role[form_create.role.data])) + return redirect(url_for('main.game_dashboard', game_name=game.name)) + + if form_create.submit_create.data and form_create.validate_on_submit(): + player = Player(name=form_create.name.data) + player.set_auth_hash() + game.game_players.append(GamePlayer(player=player, role=Role[form_create.role.data])) + db.session.commit() + return redirect(url_for('main.game_dashboard', game_name=game.name)) + + return render_template('add_player.html', title=f'Add Player for {game_name}', form_add=form_add, form_create=form_create, game=game) + +@bp.route('/game//removeplayer/') +@login_required +def remove_player(game_name, player_name): + game = Game.query.filter_by(name=game_name).first_or_404() + if not is_game_owner(game): + abort(403) + player = Player.query.filter(and_(Player.name == player_name, Player.games.contains(game))).first_or_404() + game.players.remove(player) + db.session.commit() + return redirect(url_for('main.game_dashboard', game_name=game.name)) + +@bp.route('/game//player/', methods=['GET', 'POST']) +@login_required +def game_player(game_name, player_name): + game = Game.query.filter_by(name=game_name).first_or_404() + if not is_game_owner(game): + abort(403) + player = Player.query.filter(and_(Player.name == player_name, Player.games.contains(game))).first_or_404() + gameplayer = [gameplayer for gameplayer in player.player_games if gameplayer.game == game][0] + form = PlayerUpdateForm(role=gameplayer.role.name) + if form.validate_on_submit(): + gameplayer.role = Role[form.role.data] + db.session.commit() + return redirect(url_for('main.game_dashboard', game_name=game.name)) + return render_template('player.html', title=f'{player.name} in {game_name}', game=game, player=player, form=form, json=json, location_encoder=LocationEncoder) + +@bp.route('/player//qrcode.png') +@login_required +def player_qrcode(auth_hash): + player = Player.query.filter_by(auth_hash=auth_hash).first_or_404() + if not is_player_game_owner(player): + abort(403) + img = generate_qr_code(url_for('main.player', auth_hash=auth_hash, _external=True)) + return serve_pil_image(img) + +@bp.route('/player/') +@login_required +def player(auth_hash): + player = Player.query.filter_by(auth_hash=auth_hash).first_or_404() + return render_template('player.html',title=f'Player: {player.name}', player=player) + +'''given player is an owner of the given game''' +def is_game_owner(game, owner=current_user): + return owner in [gameplayer.player for gameplayer in game.game_players if gameplayer.role == Role.owner] + +'''given player is an owner of a game the subject_player participates in''' +def is_player_game_owner(subject_player, owner=current_user): + return owner in [gameplayer.player for gameplayers in + [game.game_players for game in subject_player.games] + for gameplayer in gameplayers if gameplayer.role == Role.owner] + +'''given player is an owner of a game the given object is part of''' +def is_objective_owner(objective, owner=current_user): + return owner in [gameplayer.player for gameplayer in objective.game.game_players if gameplayer.role == Role.owner] + +@bp.route('/game//add_objective', methods=['GET', 'POST']) +@login_required +def add_objective(game_name): + game = Game.query.filter_by(name=game_name).first_or_404() + if not is_game_owner(game): + abort(403) + form = ObjectiveForm() + objective = Objective(name='', latitude=52.0932, longitude=5.12405) + if form.validate_on_submit(): + objective = Objective(name=form.objective_name.data, longitude=form.longitude.data, latitude=form.latitude.data) + objective.set_hash() + game.objectives.append(objective) + db.session.commit() + flash(f"Objective has been added!") + return redirect(url_for('main.game_dashboard', game_name=game.name)) + return render_template('objective.html', title=f'Add Objective for {game_name}', form=form, objective=objective, owner=True) + +@bp.route('/objective//delete', methods=['GET']) +@login_required +def delete_objective(objective_hash): + objective = Objective.query.filter_by(hash=objective_hash).first_or_404() + if not is_objective_owner(objective): + abort(403) + if is_objective_owner(objective): + db.session.delete(objective) + db.session.commit() + return redirect(url_for('main.game_dashboard', game_name=objective.game.name)) + +def generate_qr_code(url): + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=30, + border=4, + ) + qr.add_data(url) + qr.make(fit=True) + return qr.make_image(fill_color='black', back_color='white') + +# Source: https://stackoverflow.com/questions/7877282/how-to-send-image-generated-by-pil-to-browser +def serve_pil_image(pil_img): + img_io = BytesIO() + pil_img.save(img_io, 'PNG', quality=70) + img_io.seek(0) + return send_file(img_io, mimetype='image/png') + +@bp.route('/objective//qrcode.png') +@login_required +def objective_qrcode(objective_hash): + objective = Objective.query.filter_by(hash=objective_hash).first_or_404() + if not is_objective_owner(objective): + abort(403) + img = generate_qr_code(url_for('main.objective', objective_hash=objective.hash, _external=True)) + return serve_pil_image(img) + +@bp.route('/objective/', methods=['GET', 'POST']) +@login_required +def objective(objective_hash): + objective = Objective.query.filter_by(hash=objective_hash).first_or_404() + owner = is_objective_owner(objective) + qrcode = generate_qr_code(objective) if owner else None + form = ObjectiveForm() + if form.submit.data and form.validate() and owner: + objective.name = form.objective_name.data + objective.longitude = form.longitude.data + objective.latitude = form.latitude.data + db.session.commit() + return redirect(url_for('main.game_dashboard', game_name=objective.game.name)) + return render_template('objective.html', title='Objective view', objective=objective, owner=owner, form=form, qrcode=qrcode) diff --git a/app/main/tests/test_routes.py b/app/main/tests/test_routes.py new file mode 100644 index 0000000..6dfd53e --- /dev/null +++ b/app/main/tests/test_routes.py @@ -0,0 +1,86 @@ + +import unittest +from app import create_app, db +from app.models import Player, Game, Role, GamePlayer, Objective, ObjectiveMinimalEncoder, LocationEncoder +import app.main.routes as routes +from config import Config + +class TestConfig(Config): + TESTING = True + WTF_CSRF_ENABLED = False + DEBUG = False + SQLALCHEMY_DATABASE_URI = 'sqlite://' + +class RoutesCase(unittest.TestCase): + # implement this: https://stackoverflow.com/questions/47294304/how-to-mock-current-user-in-flask-templates + def setUp(self): + self.app = create_app(TestConfig) + self.app_context = self.app.app_context() + self.app_context.push() + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_is_game_owner(self): + g1 = Game(name='TestGame') + p1 = Player(name='Henk') + p2 = Player(name='Alfred') + g1.game_players.append(GamePlayer(player=p1, role=Role.owner)) + g1.game_players.append(GamePlayer(player=p2, role=Role.bunny)) + db.session.add(g1) + db.session.commit() + self.assertTrue(routes.is_game_owner(g1, p1)) + self.assertFalse(routes.is_game_owner(g1, p2)) + + def test_is_player_game_owner(self): + g1 = Game(name='TestGame') + g2 = Game(name='AnotherGame') + + p1 = Player(name='Henk') + p2 = Player(name='Alfred') + p3 = Player(name='Sasha') + + g1.game_players.append(GamePlayer(player=p1, role=Role.owner)) + g1.game_players.append(GamePlayer(player=p2, role=Role.bunny)) + + g2.game_players.append(GamePlayer(player=p1, role=Role.hunter)) + g2.game_players.append(GamePlayer(player=p3, role=Role.bunny)) + + + db.session.add(g1) + db.session.add(g2) + db.session.commit() + + self.assertTrue(routes.is_player_game_owner(subject_player=p2, owner=p1), "owner owns subject_player's game") + self.assertFalse(routes.is_player_game_owner(subject_player=p3, owner=p1), "owner doesn't own subject_player's game") + self.assertTrue(routes.is_player_game_owner(subject_player=p1, owner=p1), "owner owns it own's game") + + + def test_is_objective_owner(self): + g1 = Game(name='TestGame') + g2 = Game(name='AnotherGame') + p1 = Player(name='Henk') + + g1.game_players.append(GamePlayer(player=p1, role=Role.owner)) + g2.game_players.append(GamePlayer(player=p1, role=Role.bunny)) + + o1 = Objective(name='o1') + o1.set_hash() + o2 = Objective(name='o2') + o2.set_hash() + + g1.objectives.append(o1) + g2.objectives.append(o2) + + db.session.add(g1) + db.session.add(g2) + db.session.commit() + + self.assertTrue(routes.is_objective_owner(o1, p1)) + self.assertFalse(routes.is_objective_owner(o2, p1)) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..c345890 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Sign In

+
+
+ {{ wtf.quick_form(form, button_map={'submit': 'primary'}) }} +
+
+
+

New User? Click to Register!

+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..af31b5c --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Register

+
+
+ {{ wtf.quick_form(form, button_map={'submit': 'primary'}) }} +
+
+
+

Already have an account? Click to Sign In!

+{% endblock %} \ No newline at end of file diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..f52ab1d --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,3 @@ +#! /bin/bash + +nose2 -v