diff --git a/.gitignore b/.gitignore index 8fd89ef..871ffb8 100644 --- a/.gitignore +++ b/.gitignore @@ -144,4 +144,5 @@ cython_debug/ # the-hunt specific: logs/ -app.db \ No newline at end of file +app.db +uploads/ \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 2d0a3ad..ef7cdc8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,6 +10,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager from flask_moment import Moment +from flask_wtf import CSRFProtect from config import Config @@ -20,6 +21,7 @@ login = LoginManager() login.login_view = 'auth.login' login.login_message = 'Please log in to access this page.' moment = Moment() +csrf = CSRFProtect() def create_app(config_class=Config): # pylint: disable=no-member @@ -31,10 +33,11 @@ def create_app(config_class=Config): migrate.init_app(app, db) login.init_app(app) moment.init_app(app) + csrf.init_app(app) from app.errors import bp as errors_bp app.register_blueprint(errors_bp) - + from app.auth import bp as auth_bp app.register_blueprint(auth_bp, url_prefix='/auth') diff --git a/app/main/forms.py b/app/main/forms.py index ec6206e..dcb43d5 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -1,8 +1,10 @@ from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed, FileRequired from wtforms import StringField, SubmitField, DateTimeField, BooleanField, HiddenField, FloatField, SelectField from wtforms.validators import InputRequired, DataRequired, ValidationError, Length, NumberRange from pytz import timezone from app.models import Objective +from app import Config class CreateGameForm(FlaskForm): game_name = StringField('Game Name', validators=[InputRequired(), Length(min=0, max=64)]) @@ -34,14 +36,14 @@ class ObjectiveForm(FlaskForm): submit = SubmitField('Save') def validate_objective_name(self, objective_name): - if objective_name.data == '': + 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=[InputRequired()]) + role = SelectField('Player Role', coerce=int, validators=[InputRequired()]) submit = SubmitField('Update') class PlayerAddForm(FlaskForm): @@ -56,5 +58,5 @@ class UserCreateForm(FlaskForm): class CatchBunnyForm(FlaskForm): bunny = SelectField('Bunny Name', coerce=int, validators=[InputRequired()]) - #photo + photo = FileField('Upload Photo', validators=[FileRequired(), FileAllowed(Config.ALLOWED_PHOTO_EXTENSIONS, 'Images only!')]) submit = SubmitField('Send') diff --git a/app/main/routes.py b/app/main/routes.py index 180372f..1f3b3ad 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -1,9 +1,14 @@ +import fnmatch import json +from os import listdir +from pathlib import Path from datetime import datetime from io import BytesIO import qrcode -from flask import render_template, flash, redirect, url_for, request, abort, send_file +from flask import render_template, flash, redirect, url_for, request, abort, send_file, current_app, send_from_directory from flask_login import current_user, login_required +from werkzeug.utils import secure_filename +from werkzeug.security import safe_join from sqlalchemy import and_ from app import db @@ -11,6 +16,9 @@ from app.main import bp from app.models import User, Game, Role, GamePlayer, Objective, ObjectiveMinimalEncoder, LocationEncoder, PlayerCaughtPlayer from app.main.forms import CreateGameForm, ObjectiveForm, PlayerAddForm, UserCreateForm, PlayerUpdateForm, CatchBunnyForm + +import os + @bp.before_app_request def before_request(): if current_user.is_authenticated: @@ -80,29 +88,75 @@ def catch_bunny(game_name): if current_user.role_in_game(game) is not Role.hunter: flash('Only hunters can catch bunnies!') abort(403) - - game_bunnies = [gameplayer for gameplayer in game.players if gameplayer.role == Role.bunny] - + + game_bunnies = game.bunnies() form = CatchBunnyForm() form.bunny.choices = [(player.user.id, player.user.name) for player in game_bunnies] - parsed_bunny_name = request.args.get('bunny_name', default='', type=str) - if parsed_bunny_name: - bunny = [gameplayer for gameplayer in game_bunnies if gameplayer.user.name == parsed_bunny_name] - if bunny: - # pylint: disable=no-member - form.bunny.default = bunny[0].user.id - form.process() + if request.method == 'GET': + parsed_bunny_name = request.args.get('bunny_name', default='', type=str) + if parsed_bunny_name: + bunny = [gameplayer for gameplayer in game_bunnies if gameplayer.user.name == parsed_bunny_name] + if bunny: + # pylint: disable=no-member + form.bunny.default = bunny[0].user.id + form.process() if form.validate_on_submit(): bunny = [gameplayer for gameplayer in game_bunnies if gameplayer.user.id == form.bunny.data] - pcp = PlayerCaughtPlayer(catching_player=current_user.player_in(game), caught_player=bunny[0], photo_reference='To be Impemented') + if not bunny: + flash('Please choose a bunny') + return request.url + bunny = bunny[0] + + pcp = PlayerCaughtPlayer(catching_player=current_user.player_in(game), caught_player=bunny, timestamp=datetime.utcnow()) + save_player_caught_player_photo(form.photo.data, game, pcp) db.session.add(pcp) db.session.commit() - flash(f"You caught '{bunny[0].user.name}'! The submitted photo will be reviewd by a game owner.") + flash(f"You caught {bunny.user.name}! The submitted photo will be reviewed by a game owner.") return redirect(url_for('main.game_dashboard', game_name=game.name)) return render_template('catch_bunny.html', title='Catch Bunny', form=form, game=game) +def save_player_caught_player_photo(file_storage, game, pcp): + timestamp = pcp.timestamp.strftime('%Y%m%d%H%M%S') + extension = Path(file_storage.filename).suffix + filename = secure_filename(f'{timestamp}_{pcp.catching_player.user.name}_caught_{pcp.caught_player.user.name}{extension}') + path = get_caught_bunny_photo_directory(game) + path.mkdir(parents=True, exist_ok=True) + file_storage.save(path / filename) + +@bp.route('/game//caught_bunny_photo', methods=['GET']) +@login_required +def caught_bunny_photo(game_name): + game = Game.query.filter_by(name=game_name).first_or_404() + timestamp = request.args['timestamp'] + bunny_name = request.args['bunny_name'] + hunter_name = request.args['hunter_name'] + hunter = GamePlayer.query.join(User).filter( + (GamePlayer.user_id == User.id) & + (GamePlayer.game_id == game.id) & + (User.name == hunter_name) & + (GamePlayer.role == Role.hunter)).first_or_404() + if not (game.owned_by(current_user) or current_user.player_in(game) == hunter): + abort(403) + directory = get_caught_bunny_photo_directory(game) + filename = get_bunny_photo_filename(directory, timestamp, hunter_name, bunny_name) + photo_path = safe_join(directory, filename) + #TODO: Implement switch between serve self and serve by webserver + return send_file(photo_path, conditional=True, as_attachment=False) + +def get_bunny_photo_filename(directory, timestamp, hunter_name, bunny_name): + filename = secure_filename(f'{timestamp}_{hunter_name}_caught_{bunny_name}') + '.*' + matches = fnmatch.filter(listdir(directory), filename) + return matches[0] if matches else '' + +def get_caught_bunny_photo_directory(game): + return Path(current_app.root_path).parent / \ + current_app.config['UPLOAD_FOLDER'] / \ + secure_filename(game.name) / \ + current_app.config['PLAYER_CAUGHT_PLAYER_PHOTO_DIR_NAME'] + + @bp.route('/game//adduser', methods=['GET', 'POST']) @bp.route('/game//addplayer', methods=['GET', 'POST']) @login_required @@ -149,15 +203,20 @@ def game_player(game_name, username): game = Game.query.filter_by(name=game_name).first_or_404() if not game.owned_by(current_user): abort(403) - user = game.users - user = User.query.filter(and_(User.name == username, User.games.contains(game))).first_or_404() + user = User.query.filter((User.name == username) & (User.games.contains(game))).first_or_404() player = user.player_in(game) + + # pylint: disable=no-member form = PlayerUpdateForm(role=player.role.name) + form.role.choices = [(role.value, role.name) for role in Role] + form.role.default = player.role.value + form.process() + if form.validate_on_submit(): player.role = Role[form.role.data] db.session.commit() return redirect(url_for('main.game_dashboard', game_name=game.name)) - return render_template('game_player.html', title=f'{user.name} in {game_name}', game=game, user=user, form=form, json=json, location_encoder=LocationEncoder) + return render_template('game_player.html', title=f'{user.name} in {game_name}', player=player, form=form, json=json, location_encoder=LocationEncoder) @bp.route('/user//qrcode.png') @login_required diff --git a/app/models/player_caught_player.py b/app/models/player_caught_player.py index d096973..9e9fb8a 100644 --- a/app/models/player_caught_player.py +++ b/app/models/player_caught_player.py @@ -8,7 +8,6 @@ class PlayerCaughtPlayer(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True, server_default='-1') catching_player_id = db.Column(db.Integer, db.ForeignKey('game_player.id'), nullable=False) caught_player_id = db.Column(db.Integer, db.ForeignKey('game_player.id'), nullable=False) - photo_reference = db.Column(db.String(128), unique=True, nullable=False) timestamp = db.Column(db.DateTime, server_default=func.now(), nullable=False) catching_player = db.relationship('GamePlayer', primaryjoin=(catching_player_id == GamePlayer.id), backref=db.backref('player_caught_players', cascade='save-update, merge, delete, delete-orphan')) @@ -20,7 +19,7 @@ This relation doesn't work as well as the others, and must be used as folowed: g = Game.query.first() p1 = User.query[2].player_in(g) p2 = User.query[3].player_in(g) -pc = PlayerCaughtPlayer(caught_player=p2, catching_player=p1, photo_reference='reference') +pc = PlayerCaughtPlayer(caught_player=p2, catching_player=p1) db.session.add(pc) db.session.commit() diff --git a/app/templates/game_hunter_dashboard.html b/app/templates/game_hunter_dashboard.html index b826707..7e19ae4 100644 --- a/app/templates/game_hunter_dashboard.html +++ b/app/templates/game_hunter_dashboard.html @@ -33,7 +33,7 @@ {{ location }} {% endwith %} - + diff --git a/app/templates/game_player.html b/app/templates/game_player.html index ad08e89..924dfc5 100644 --- a/app/templates/game_player.html +++ b/app/templates/game_player.html @@ -8,55 +8,86 @@ {% endblock %} {% block app_content %} -

Player: {{ user.name }}

+

Player: {{ player.user.name }}


-
- {{ form.hidden_tag() }} - {% for gameplayer in user.user_games if gameplayer.game == game %} - {{ wtf.form_field(form.role, class='form-control') }} - {% endfor %} - {{ wtf.form_field(form.submit, class='btn btn-primary', value='Update') }} -
+ {{ wtf.quick_form(form, button_map={'submit': 'primary'}) }} +
+ + {% if player.user.auth_hash and not player.user.last_login %} +
+ qr_code_failed +
+ {% elif not player.user.last_login %} +
+ - - {% if user.auth_hash and not user.last_login %} -
- qr_code_failed -
- {% elif not user.last_login %} -
- {% endif %}
-
+
+
+{% if player.caught_players %} +
+

Caught Players:

+
+ + + + + + + + + + {% for pcp in player.player_caught_players %} + + + + + + {% endfor %} + +
Player NameTime
{{ pcp.caught_player.user.name }} + {{ moment(pcp.timestamp).fromNow() }} + +
+
+{% endif %} {% endblock %} {% block scripts %} {{ super() }} {{ moment.include_moment() }} - {% endblock %} \ No newline at end of file diff --git a/config.py b/config.py index bcbe2ff..a08246b 100644 --- a/config.py +++ b/config.py @@ -20,3 +20,7 @@ class Config(object): ADMINS = ['your-email@example.com'] BOOTSTRAP_SERVE_LOCAL = True + + UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'uploads' + PLAYER_CAUGHT_PLAYER_PHOTO_DIR_NAME = os.environ.get('PLAYER_CAUGHT_PLAYER_PHOTO_DIR_NAME') or 'caught_bunny_photos' + ALLOWED_PHOTO_EXTENSIONS = os.environ.get('ALLOWED_EXTENSIONS') or {'png', 'jpg', 'jpeg', 'gif', 'tiff', 'heif', 'heic'} \ No newline at end of file diff --git a/migrations/versions/c2c2f3cf0c14_reset_migrations.py b/migrations/versions/c2c2f3cf0c14_reset_migrations.py deleted file mode 100644 index 5f53e13..0000000 --- a/migrations/versions/c2c2f3cf0c14_reset_migrations.py +++ /dev/null @@ -1,121 +0,0 @@ -"""reset migrations - -Revision ID: c2c2f3cf0c14 -Revises: -Create Date: 2020-07-19 04:46:04.972644 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'c2c2f3cf0c14' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('game', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=64), nullable=False), - sa.Column('state', sa.Enum('initiated', 'published', 'started', 'interrupted', 'finished', name='gamestate'), server_default='initiated', nullable=False), - sa.Column('start_time', sa.DateTime(), nullable=True), - sa.Column('end_time', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_game_name'), 'game', ['name'], unique=True) - op.create_table('user', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=64), nullable=False), - sa.Column('auth_hash', sa.String(length=32), nullable=True), - sa.Column('password_hash', sa.String(length=128), nullable=True), - sa.Column('last_login', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('auth_hash'), - sa.UniqueConstraint('name') - ) - op.create_table('game_player', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('game_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('role', sa.Enum('none', 'owner', 'hunter', 'bunny', name='role'), server_default='none', nullable=False), - sa.ForeignKeyConstraint(['game_id'], ['game.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('game_id', 'user_id', name='_game_user_uc') - ) - op.create_table('location', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('longitude', sa.Numeric(precision=15, scale=10, asdecimal=False), nullable=False), - sa.Column('latitude', sa.Numeric(precision=15, scale=10, asdecimal=False), nullable=False), - sa.Column('timestamp', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('notification', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('game_id', sa.Integer(), nullable=False), - sa.Column('message', sa.Text(), nullable=False), - sa.Column('type', sa.String(length=64), nullable=False), - sa.Column('timestamp', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['game_id'], ['game.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('objective', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=64), nullable=True), - sa.Column('game_id', sa.Integer(), nullable=False), - sa.Column('hash', sa.String(length=32), nullable=False), - sa.Column('longitude', sa.Numeric(precision=15, scale=10, asdecimal=False), nullable=True), - sa.Column('latitude', sa.Numeric(precision=15, scale=10, asdecimal=False), nullable=True), - sa.ForeignKeyConstraint(['game_id'], ['game.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('hash') - ) - op.create_table('notification_player', - sa.Column('notification_id', sa.Integer(), nullable=False), - sa.Column('game_player_id', sa.Integer(), nullable=False), - sa.Column('been_shown', sa.Boolean(), server_default='True', nullable=False), - sa.ForeignKeyConstraint(['game_player_id'], ['game_player.id'], ), - sa.ForeignKeyConstraint(['notification_id'], ['notification.id'], ), - sa.PrimaryKeyConstraint('notification_id', 'game_player_id') - ) - op.create_table('player_caught_player', - sa.Column('id', sa.Integer(), server_default='-1', autoincrement=True, nullable=False), - sa.Column('catching_player_id', sa.Integer(), nullable=False), - sa.Column('caught_player_id', sa.Integer(), nullable=False), - sa.Column('photo_reference', sa.String(length=128), nullable=False), - sa.Column('timestamp', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['catching_player_id'], ['game_player.id'], ), - sa.ForeignKeyConstraint(['caught_player_id'], ['game_player.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('photo_reference') - ) - op.create_table('player_found_objective', - sa.Column('objective_id', sa.Integer(), server_default='-1', nullable=False), - sa.Column('game_player_id', sa.Integer(), server_default='-1', nullable=False), - sa.Column('timestamp', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['game_player_id'], ['game_player.id'], ), - sa.ForeignKeyConstraint(['objective_id'], ['objective.id'], ), - sa.PrimaryKeyConstraint('objective_id', 'game_player_id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('player_found_objective') - op.drop_table('player_caught_player') - op.drop_table('notification_player') - op.drop_table('objective') - op.drop_table('notification') - op.drop_table('location') - op.drop_table('game_player') - op.drop_table('user') - op.drop_index(op.f('ix_game_name'), table_name='game') - op.drop_table('game') - # ### end Alembic commands ### diff --git a/uploads/TestGame/caught_bunny_photos/20200719225209_Rogier_caught_Emma b/uploads/TestGame/caught_bunny_photos/20200719225209_Rogier_caught_Emma deleted file mode 100644 index b35b1b0..0000000 Binary files a/uploads/TestGame/caught_bunny_photos/20200719225209_Rogier_caught_Emma and /dev/null differ