Compare commits

..

No commits in common. 'master' and 'feature_tests' have entirely different histories.

  1. 2
      .gitignore
  2. 12
      app/__init__.py
  3. 27
      app/auth/forms.py
  4. 113
      app/auth/routes.py
  5. 5
      app/game_settings/__init__.py
  6. 45
      app/game_settings/forms.py
  7. 105
      app/game_settings/routes.py
  8. 2
      app/main/__init__.py
  9. 53
      app/main/forms.py
  10. 286
      app/main/routes.py
  11. 88
      app/main/routes_objective.py
  12. 118
      app/main/routes_player.py
  13. 60
      app/main/tests/test_routes.py
  14. 237
      app/models.py
  15. 12
      app/models/__init__.py
  16. 115
      app/models/game.py
  17. 75
      app/models/game_player.py
  18. 9
      app/models/game_state.py
  19. 24
      app/models/location.py
  20. 20
      app/models/notification.py
  21. 10
      app/models/notification_player.py
  22. 39
      app/models/objective.py
  23. 28
      app/models/player_caught_player.py
  24. 11
      app/models/player_found_objective.py
  25. 14
      app/models/review.py
  26. 8
      app/models/role.py
  27. 98
      app/models/tests/test_game.py
  28. 61
      app/models/tests/test_game_player.py
  29. 76
      app/models/tests/test_objective.py
  30. 76
      app/models/tests/test_user.py
  31. 128
      app/models/user.py
  32. 26
      app/static/assets/geolocation_utils.js
  33. BIN
      app/static/assets/leaflet/images/person-marker-icon-2x-blue.png
  34. BIN
      app/static/assets/leaflet/images/person-marker-icon-2x-gold.png
  35. BIN
      app/static/assets/leaflet/images/person-marker-icon-2x-green.png
  36. 174
      app/static/assets/leaflet/utils.js
  37. 37
      app/static/assets/utils.js
  38. 0
      app/static/css/default.css
  39. 42
      app/templates/_game_player_info.html
  40. 37
      app/templates/_review_photo.html
  41. 4
      app/templates/add_player.html
  42. 18
      app/templates/auth/change_password.html
  43. 22
      app/templates/auth/login.html
  44. 16
      app/templates/auth/register.html
  45. 20
      app/templates/auth/user_hash_login.html
  46. 83
      app/templates/base.html
  47. 19
      app/templates/catch_bunny.html
  48. 47
      app/templates/create_game.html
  49. 82
      app/templates/game_bunny_dashboard.html
  50. 137
      app/templates/game_dashboard.html
  51. 141
      app/templates/game_hunter_dashboard.html
  52. 183
      app/templates/game_owner_dashboard.html
  53. 164
      app/templates/game_player.html
  54. 96
      app/templates/game_settings/edit_game.html
  55. 24
      app/templates/index.html
  56. 41
      app/templates/objective.html
  57. 88
      app/templates/player.html
  58. 41
      app/templates/player_base.html
  59. 14
      app/templates/review_caught_bunny_photos.html
  60. 6
      app/templates/user_profile.html
  61. 62
      app/utils.py
  62. 6
      config.py
  63. 1
      documentation/database/database_schema1.4.1.drawio
  64. BIN
      documentation/database/database_schema1.4.1.png
  65. 1
      documentation/database/database_schema1.4.2.drawio
  66. BIN
      documentation/database/database_schema1.4.2.png
  67. 1
      documentation/database/database_schema1.4.3.drawio
  68. BIN
      documentation/database/database_schema1.4.3.png
  69. 1
      documentation/database/entity_relationship_diagram_v1.4.1.drawio
  70. BIN
      documentation/database/entity_relationship_diagram_v1.4.1.png
  71. 1
      documentation/database/entity_relationship_diagram_v1.4.2.drawio
  72. BIN
      documentation/database/entity_relationship_diagram_v1.4.2.png
  73. 1
      documentation/database/entity_relationship_diagram_v1.4.3.drawio
  74. BIN
      documentation/database/entity_relationship_diagram_v1.4.3.png
  75. 6
      documentation/gamestate.txt
  76. 61
      migrations/versions/3ef4c34115fd_updated_to_database_documentation_1_3.py
  77. 13
      requirements.txt
  78. 47
      the_hunt.py

2
.gitignore vendored

@ -145,5 +145,3 @@ cython_debug/ @@ -145,5 +145,3 @@ cython_debug/
# the-hunt specific:
logs/
app.db
uploads/
database_dump.txt

12
app/__init__.py

@ -10,7 +10,6 @@ from flask_sqlalchemy import SQLAlchemy @@ -10,7 +10,6 @@ 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
@ -21,7 +20,6 @@ login = LoginManager() @@ -21,7 +20,6 @@ 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
@ -33,7 +31,6 @@ def create_app(config_class=Config): @@ -33,7 +31,6 @@ 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)
@ -41,9 +38,6 @@ def create_app(config_class=Config): @@ -41,9 +38,6 @@ def create_app(config_class=Config):
from app.auth import bp as auth_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
from app.game_settings import bp as game_settings_bp
app.register_blueprint(game_settings_bp)
from app.main import bp as main_bp
app.register_blueprint(main_bp)
@ -59,7 +53,7 @@ def create_app(config_class=Config): @@ -59,7 +53,7 @@ def create_app(config_class=Config):
mail_handler = SMTPHandler(
mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
fromaddr='no-reply@' + app.config['MAIL_SERVER'],
toaddrs=app.config['ADMINS'], subject='The-Hunt Failure',
toaddrs=app.config['ADMINS'], subject='Microblog Failure',
credentials=auth, secure=secure)
mail_handler.setLevel(logging.ERROR)
app.logger.addHandler(mail_handler)
@ -71,7 +65,7 @@ def create_app(config_class=Config): @@ -71,7 +65,7 @@ def create_app(config_class=Config):
else:
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler('logs/the-hunt.log',
file_handler = RotatingFileHandler('logs/microblog.log',
maxBytes=10240, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s '
@ -80,7 +74,7 @@ def create_app(config_class=Config): @@ -80,7 +74,7 @@ def create_app(config_class=Config):
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('The-Hunt startup')
app.logger.info('Microblog startup')
return app

27
app/auth/forms.py

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, BooleanField
from wtforms.validators import DataRequired, EqualTo, ValidationError, Length
from wtforms_validators import NotEqualTo
from app.models import User
from pytz import timezone
from app.models import Player
class LoginForm(FlaskForm):
@ -15,27 +15,10 @@ class RegistrationForm(FlaskForm): @@ -15,27 +15,10 @@ 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', 'Field must be equal to Password')])
'Repeat Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_username(self, username):
user = User.query.filter_by(name=username.data).first()
if user is not None:
player = Player.query.filter_by(name=username.data).first()
if player is not None:
raise ValidationError('Please use a different username.')
class ChangePasswordForm(FlaskForm):
old_password = PasswordField('Old Password',
validators=[DataRequired(), Length(min=0, max=128)])
new_password = PasswordField('New Password',
validators=[DataRequired(), Length(min=0, max=128),
NotEqualTo('old_password', 'Please choose a new password')])
new_password2 = PasswordField(
'Repeat New Password', validators=[DataRequired(), EqualTo('new_password', 'Field must be equal to New Password')])
submit = SubmitField('Apply')
class SetPasswordForm(FlaskForm):
new_password = PasswordField('New Password', validators=[DataRequired(),
Length(min=0, max=128)])
new_password2 = PasswordField(
'Repeat New Password', validators=[DataRequired(), EqualTo('new_password', 'Field must be equal to New Password')])
submit = SubmitField('Set Password')

113
app/auth/routes.py

@ -1,12 +1,9 @@ @@ -1,12 +1,9 @@
from datetime import datetime
from flask import render_template, flash, redirect, request, url_for, abort, Markup, escape
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.utils import generate_qr_code, serve_pil_image
from app.models import User
from app.auth.forms import LoginForm, RegistrationForm, ChangePasswordForm, SetPasswordForm
from app.models import Player
from app.auth.forms import LoginForm, RegistrationForm
@bp.route('/login', methods=['GET', 'POST'])
def login():
@ -14,17 +11,13 @@ def login(): @@ -14,17 +11,13 @@ def login():
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(name=form.username.data).first()
if user is None or not user.check_password(form.password.data):
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'))
return user_login(user, form.remember_me.data)
return render_template('auth/login.html', title='Sign In', form=form)
def user_login(user, remember):
login_user(user, remember)
user.last_login = datetime.utcnow()
return redirect(url_for('main.index'))
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
@ -38,91 +31,11 @@ def register(): @@ -38,91 +31,11 @@ def register():
return redirect(url_for('main.index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(name=form.username.data)
user.set_password(form.password.data)
user.set_auth_hash()
user.last_login = datetime.utcnow()
db.session.add(user)
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('auth/register.html', title='Register', form=form)
@bp.route('/generate_auth_hash/<username>')
def generate_auth_hash(username):
user = User.query.filter(User.name == username).first_or_404()
if not current_user.owns_game_played_by(user):
abort(403)
if user.auth_hash:
flash('Auth hash is already generated')
abort(403)
if user.password_hash:
flash('After a player has created a password, it is no longer possible to generate a QR code.')
abort(403)
user.set_auth_hash()
db.session.commit()
return 'nothing'
@bp.route('/user/<auth_hash>')
def user_hash_login(auth_hash):
user = User.query.filter_by(auth_hash=auth_hash).first_or_404()
if current_user.is_authenticated:
if current_user.owns_game_played_by(user):
safe_username = escape(user.name)
flash(Markup(f"The <a href='{request.url}'>login link</a> for {safe_username} works! "
"However, logged in users like you can't access it"))
return redirect(url_for('main.game_player', game_name=current_user.owned_game_played_by(user).name, username=user.name))
flash(f'You are aleady logged in as {current_user.name}!')
return redirect(url_for('main.index'))
if user.password_hash:
flash('Please login with your username and password!')
abort(404)
if user.last_login:
return user_login(user, True)
if request.args.get('login', default='false', type=str).lower() == 'true':
return user_login(user, True)
return render_template('auth/user_hash_login.html', title=f'User: {user.name}', user=user)
@bp.route('/user/<auth_hash>/qrcode.png')
@login_required
def user_qrcode(auth_hash):
user = User.query.filter_by(auth_hash=auth_hash).first_or_404()
if not current_user.owns_game_played_by(user):
abort(403)
if user.password_hash:
flash('After a player has created a password, it is no longer possible to request their QR code.')
abort(403)
img = generate_qr_code(url_for('auth.user_hash_login', auth_hash=auth_hash, _external=True))
return serve_pil_image(img)
@bp.route('/set_password', methods=['GET', 'POST'])
@bp.route('/change_password', methods=['GET', 'POST'])
def change_password():
auth_hash = request.args.get('auth_hash', default=None, type=str)
if auth_hash:
user = User.query.filter_by(auth_hash=auth_hash).first_or_404()
login_user(user, True)
user.last_login = datetime.utcnow()
else:
if not current_user.is_authenticated:
abort(403)
user = User.query.filter_by(name=current_user.name).first_or_404()
no_old_password = not user.password_hash
if no_old_password:
form = SetPasswordForm()
else:
form = ChangePasswordForm()
if form.validate_on_submit():
if not no_old_password:
if not user.check_password(form.old_password.data):
flash('Invalid password')
return redirect(url_for('auth.change_password'))
user.set_password(form.new_password.data)
db.session.commit()
if no_old_password:
flash('Your password was set')
else:
flash('Your password was changed!')
return redirect(url_for('main.index'))
return render_template('auth/change_password.html', form=form)
return render_template('register.html', title='Register', form=form)

5
app/game_settings/__init__.py

@ -1,5 +0,0 @@ @@ -1,5 +0,0 @@
from flask import Blueprint
bp = Blueprint('game', __name__)
from app.game_settings import routes

45
app/game_settings/forms.py

@ -1,45 +0,0 @@ @@ -1,45 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, DateTimeField, BooleanField, HiddenField
from wtforms.validators import InputRequired, DataRequired, ValidationError, Length
from pytz import timezone
from app.models import Game
class CreateGameForm(FlaskForm):
game_name = StringField('Game Name', validators=[InputRequired(), 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')
old_name = ''
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)
if self.start_time.data and end_time.data:
if self.start_time.data > end_time.data:
raise ValidationError('Start Time must be before End Time.')
def date_time_validator(self, disabled, date_time):
print(1)
if disabled.data:
date_time.data = None
return
clientzone = timezone(self.timezone.data)
print(clientzone)
print(date_time.data)
date_time_utc = clientzone.localize(date_time.data).astimezone(timezone('UTC'))
date_time.data = date_time_utc
print(date_time.data)
def validate_game_name(self, game_name):
if game_name.data == '':
return
if game_name.data == self.old_name:
return
game = Game.query.filter_by(name=game_name.data).first()
if game is not None:
raise ValidationError('Please use a different name.')

105
app/game_settings/routes.py

@ -1,105 +0,0 @@ @@ -1,105 +0,0 @@
from flask import render_template, flash, redirect, url_for, request
from flask_login import login_required, current_user
from app import db
from app.models import Game, GamePlayer, Role
from app.utils import flash_errors, get_game_if_owner
from app.game_settings import bp
from app.game_settings.forms import CreateGameForm
@bp.route('/create_game', methods=['GET', 'POST'])
@login_required
def create_game():
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.players.append(GamePlayer(user=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))
else:
flash_errors(form)
return render_template('game_settings/create_game.html', title='Create Game', form=form)
@bp.route('/game/<game_name>/change_settings', methods=['GET', 'POST'])
@login_required
def change_game_settings(game_name):
game = get_game_if_owner(game_name)
form = CreateGameForm()
form.old_name = game.name
if request.method == 'GET':
# pylint: disable=no-member
form.process()
form.game_name.data = game.name
if game.start_time:
form.start_time.data = game.start_time
else:
form.start_time_disabled.data = True
form.start_time.data = None
if game.end_time:
form.end_time.data = game.end_time
else:
form.end_time_disabled.data = True
form.end_time.data = None
if form.validate_on_submit():
game.name = form.game_name.data
game.start_time = form.start_time.data
game.end_time = form.end_time.data
db.session.commit()
flash(f"'{game.name}' had been updated!")
return redirect(url_for('main.game_dashboard', game_name=game.name))
flash_errors(form)
return render_template('game_settings/edit_game.html', title='Chage Game Settings', form=form, game=game)
@bp.route('/game/<game_name>/delete')
@login_required
def delete_game(game_name):
game = get_game_if_owner(game_name)
game.delete()
flash(f"Game '{game.name}' has been deleted!")
return redirect(url_for('main.index'))
@bp.route('/game/<game_name>/unhide')
@bp.route('/game/<game_name>/publish')
@login_required
def publish_game(game_name):
game = get_game_if_owner(game_name)
game.hidden = False
db.session.commit()
flash(f"Game '{game.name}' has been published!")
return redirect(url_for('main.game_dashboard', game_name=game.name))
@bp.route('/game/<game_name>/hide')
@login_required
def hide_game(game_name):
game = get_game_if_owner(game_name)
game.hidden = True
db.session.commit()
flash(f"Game '{game.name}' has been hidden!")
return redirect(url_for('main.game_dashboard', game_name=game.name))
@bp.route('/game/<game_name>/pause')
@login_required
def pause_game(game_name):
game = get_game_if_owner(game_name)
game.paused = True
db.session.commit()
flash(f"Game '{game.name}' has been paused!")
return redirect(url_for('main.game_dashboard', game_name=game.name))
@bp.route('/game/<game_name>/unpause')
@bp.route('/game/<game_name>/resume')
@login_required
def resume_game(game_name):
game = get_game_if_owner(game_name)
game.paused = False
db.session.commit()
flash(f"Game '{game.name}' has been resumed!")
return redirect(url_for('main.game_dashboard', game_name=game.name))

2
app/main/__init__.py

@ -2,4 +2,4 @@ from flask import Blueprint @@ -2,4 +2,4 @@ from flask import Blueprint
bp = Blueprint('main', __name__)
from app.main import routes, routes_objective, routes_player
from app.main import routes

53
app/main/forms.py

@ -1,41 +1,54 @@ @@ -1,41 +1,54 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired
from wtforms import StringField, SubmitField, FloatField, SelectField
from wtforms.validators import InputRequired, DataRequired, ValidationError, Length, NumberRange
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
from app import Config
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')
old_name = ''
def validate_objective_name(self, objective_name):
if objective_name.data == '':
return
if objective_name.data == self.old_name:
return
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', coerce=int, validators=[InputRequired()])
role = SelectField('Player Role', choices=[('none', 'none'), ('owner', 'owner'), ('hunter', 'hunter'), ('bunny', 'bunny')], validators=[DataRequired()])
submit = SubmitField('Update')
class PlayerAddForm(FlaskForm):
name = StringField('Username', validators=[InputRequired(), Length(min=0, max=64)])
role = SelectField('Player Role', coerce=int, validators=[InputRequired()])
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 UserCreateForm(FlaskForm):
name = StringField('Username', validators=[InputRequired(), Length(min=0, max=64)])
role = SelectField('Player Role', coerce=int, validators=[InputRequired()])
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')
class CatchBunnyForm(FlaskForm):
bunny = SelectField('Bunny Name', coerce=int, validators=[InputRequired()])
photo = FileField('Upload Photo', validators=[FileRequired(), FileAllowed(Config.ALLOWED_PHOTO_EXTENSIONS, 'Images only!')])
submit = SubmitField('Send')

286
app/main/routes.py

@ -1,163 +1,187 @@ @@ -1,163 +1,187 @@
import json
from datetime import datetime, timedelta
from flask import render_template, redirect, url_for, request, abort, send_file, current_app, flash
import qrcode
from flask import render_template, flash, redirect, url_for, request, abort, send_file
from flask_login import current_user, login_required
from werkzeug.security import safe_join
from sqlalchemy import and_
from io import BytesIO
from app import db
from app.main import bp
from app.utils import get_game_if_owner, get_caught_bunny_photo_directory, get_bunny_photo_filename
from app.models import User, Game, Role, GamePlayer, ObjectiveEncoder, LocationEncoder, \
PlayerCaughtPlayer, Review, Location
@bp.before_app_request
def before_request():
if current_user.is_authenticated:
current_user.last_login = datetime.utcnow()
db.session.commit()
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():
if len(current_user.games) == 1:
return redirect(url_for('main.game_dashboard', game_name=current_user.games[0].name))
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/<game_name>/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()
role = current_user.role_in_game(game)
if role == Role.owner:
return render_template('game_owner_dashboard.html', title='Game Dashboard', game=game,
json=json, objective_encoder=ObjectiveEncoder,
location_encoder=LocationEncoder)
if role == Role.bunny:
return render_template('game_bunny_dashboard.html', title='Game Dashboard', game=game,
json=json, location_encoder=LocationEncoder)
if role == Role.hunter:
hunter_delay = current_app.config['HUNTER_LOCATION_DELAY']
return render_template('game_hunter_dashboard.html', title='Game Dashboard', game=game,
hunter_delay=hunter_delay, json=json,
location_encoder=LocationEncoder)
if role == Role.none:
flash('Please ask your game owner for a role to join this game')
return abort(403)
if role is None:
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/<game_name>/caught_bunny_photo', methods=['GET'])
@bp.route('/game/<game_name>/addplayer', methods=['GET', 'POST'])
@login_required
def caught_bunny_photo(game_name):
def add_player(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):
if not is_game_owner(game):
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)
form_add = PlayerAddForm()
form_create = PlayerCreateForm()
@bp.route('/game/<game_name>/review')
@login_required
def review_caught_bunny_photos(game_name):
game = get_game_if_owner(game_name)
pcp_id = request.args.get('pcp_id', default=-1, type=int)
action = request.args.get('action', default='none', type=str).lower()
if pcp_id != -1:
pcp = PlayerCaughtPlayer.query.filter_by(id=pcp_id).first_or_404()
review = Review.parse_string(action)
pcp.review = review
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 render_template('review_caught_bunny_photos.html', game=game)
return redirect(url_for('main.game_dashboard', game_name=game.name))
@bp.route('/user/<username>/send_location', methods=['POST'])
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/<game_name>/removeplayer/<player_name>')
@login_required
def send_location(username):
user = User.query.filter_by(name=username).first_or_404()
last_location = user.last_location()
latitude = request.form.get('lat', default=None, type=float)
longitude = request.form.get('long', default=None, type=float)
if latitude is None or longitude is None:
return '', 400
# Check if previous two locations are exactly the same,
# if so, only update timestamp of last location
if last_location:
if datetime.utcnow() - last_location.timestamp < timedelta(seconds=30):
return '', 204
if (latitude == last_location.latitude and
longitude == last_location.longitude and
len(user.locations) >= 2):
before_last_location = user.locations[-2]
if before_last_location:
if (latitude == before_last_location.latitude and
longitude == before_last_location.longitude):
last_location.timestamp = datetime.utcnow()
db.session.commit()
return '', 204
user.locations.append(Location(longitude=longitude, latitude=latitude))
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 '', 204
return redirect(url_for('main.game_dashboard', game_name=game.name))
@bp.route('/game/<game_name>/player/<player_name>', 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/<auth_hash>/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/<auth_hash>')
@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]
@bp.route('/game/<game_name>/get_locations', methods=['POST'])
'''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/<game_name>/add_objective', methods=['GET', 'POST'])
@login_required
def poll_locations(game_name):
def add_objective(game_name):
game = Game.query.filter_by(name=game_name).first_or_404()
role = current_user.role_in_game(game)
if role is None or role == Role.none:
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/<objective_hash>/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')
payload = request.get_json()
if payload is None:
abort(400)
mode = get_value_if_key_exists(payload, 'mode', 'last')
last_update = get_value_if_key_exists(payload, 'last_update', 'none')
last_update = datetime.strptime(last_update, '%Y-%m-%d %H:%M:%S:%f') if last_update != 'none' else datetime.min
requested_users = get_value_if_key_exists(payload, 'requested_users', 'none')
#print(f'mode: {mode}\nlast_request: {last_update}\nrequested_users: {requested_users}')
response_objects = []
if role in (Role.owner, Role.hunter):
for username in requested_users:
locations = get_user_locations(game, username, mode, last_update, role == Role.hunter)
#print(locations)
if locations:
response_objects.append(locations)
response_objects = [obj for obj_list in response_objects for obj in obj_list]
return json.dumps(response_objects, cls=LocationEncoder)
def get_value_if_key_exists(dictionary, key, default=None):
return dictionary[key] if key in dictionary else default
def get_user_locations(game, username, mode, last_update, hunter=False):
user = User.query.filter_by(name=username).first()
if user is None:
return None
if hunter and user.role_in_game(game) != Role.bunny:
return None
if mode == 'accumulative':
if game.end_time or datetime.max < last_update: # Don't return locations when the game is finished
return []
offset = current_app.config['HUNTER_LOCATION_DELAY'] if hunter else 0
locations = user.locations_during_game(game, offset)
if not locations:
return None
return [location for location in locations if location.timestamp - last_update > timedelta(milliseconds=1)]
@bp.route('/user/<username>')
# 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/<objective_hash>/qrcode.png')
@login_required
def user_profile(username):
user = User.query.filter_by(name=username).first_or_404()
if current_user != user:
def objective_qrcode(objective_hash):
objective = Objective.query.filter_by(hash=objective_hash).first_or_404()
if not is_objective_owner(objective):
abort(403)
return render_template('user_profile.html', user=user)
img = generate_qr_code(url_for('main.objective', objective_hash=objective.hash, _external=True))
return serve_pil_image(img)
@bp.route('/objective/<objective_hash>', 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)

88
app/main/routes_objective.py

@ -1,88 +0,0 @@ @@ -1,88 +0,0 @@
import json
from flask import render_template, flash, redirect, url_for, abort
from flask_login import current_user, login_required
from app import db
from app.main import bp
from app.utils import generate_qr_code, serve_pil_image, get_game_if_owner
from app.models import Objective, Role, ObjectiveEncoder
from app.main.forms import ObjectiveForm
@bp.route('/game/<game_name>/add_objective', methods=['GET', 'POST'])
@login_required
def add_objective(game_name):
game = get_game_if_owner(game_name)
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("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, game=game, objective=_objective, owner=True, json=json,
objective_encoder=ObjectiveEncoder)
@bp.route('/objective/<objective_hash>/delete', methods=['GET'])
@login_required
def delete_objective(objective_hash):
_objective = Objective.query.filter_by(hash=objective_hash).first_or_404()
if not _objective.owned_by(current_user):
abort(403)
else:
db.session.delete(_objective)
db.session.commit()
return redirect(url_for('main.game_dashboard', game_name=_objective.game.name))
@bp.route('/objective/<objective_hash>/qrcode.png')
@login_required
def objective_qrcode(objective_hash):
_objective = Objective.query.filter_by(hash=objective_hash).first_or_404()
if not _objective.owned_by(current_user):
abort(403)
img = generate_qr_code(url_for('main.objective', objective_hash=_objective.hash,
_external=True))
return serve_pil_image(img)
@bp.route('/objective/<objective_hash>', methods=['GET', 'POST'])
@login_required
def objective(objective_hash):
_objective = Objective.query.filter_by(hash=objective_hash).first_or_404()
if current_user.role_in_game(_objective.game) == Role.bunny:
if not _objective.game.is_active():
flash("It's not possible to find an objective before or after a game, "
"or if the game is not in 'active' mode.")
return redirect(url_for('main.game_dashboard', game_name=_objective.game.name))
player = current_user.player_in(_objective.game)
if not _objective in player.found_objectives:
player.found_objectives.append(_objective)
db.session.commit()
if _objective.name:
flash(f'You found objective: {_objective.name}!')
else:
flash('You found an objective!')
elif _objective.name:
flash(f"You have already found objective '{_objective.name}'")
else:
flash('You have already found this objective')
return redirect(url_for('main.game_dashboard', game_name=_objective.game.name))
if not _objective.owned_by(current_user):
flash("Only bunnies in an objective's game can find objectives!")
abort(403)
qrcode = generate_qr_code(_objective)
form = ObjectiveForm()
form.old_name = _objective.name
if form.submit.data and form.validate():
_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',
form=form, game=_objective.game, objective=_objective, owner=True,
qrcode=qrcode, json=json, objective_encoder=ObjectiveEncoder)

118
app/main/routes_player.py

@ -1,118 +0,0 @@ @@ -1,118 +0,0 @@
import json
from datetime import datetime
from flask import render_template, flash, redirect, url_for, request, abort
from flask_login import current_user, login_required
from sqlalchemy import and_
from app import db
from app.main import bp
from app.utils import get_game_if_owner, save_player_caught_player_photo
from app.models import User, Game, Role, GamePlayer, PlayerCaughtPlayer, LocationEncoder
from app.main.forms import PlayerAddForm, UserCreateForm, CatchBunnyForm, PlayerUpdateForm
@bp.route('/game/<game_name>/adduser', methods=['GET', 'POST'])
@bp.route('/game/<game_name>/addplayer', methods=['GET', 'POST'])
@login_required
def add_player(game_name):
game = get_game_if_owner(game_name)
form_add = PlayerAddForm()
form_add.role.choices = [(role.value, role.name) for role in Role]
form_create = UserCreateForm()
form_create.role.choices = [(role.value, role.name) for role in Role]
if form_add.submit_add.data and form_add.validate_on_submit():
user = User.query.filter_by(name=form_add.name.data).first_or_404()
if user.player_in(game):
flash(f'{user.name} is already a player in this game!')
return redirect(url_for('main.game_dashboard', game_name=game.name))
game.players.append(GamePlayer(user=user, role=Role(form_add.role.data)))
db.session.commit()
return redirect(url_for('main.game_dashboard', game_name=game.name))
if form_create.submit_create.data and form_create.validate_on_submit():
user = User(name=form_create.name.data)
user.set_auth_hash()
game.players.append(GamePlayer(user=user, 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 User for {game_name}',
form_add=form_add, form_create=form_create, game=game)
@bp.route('/game/<game_name>/removeuser/<username>')
@bp.route('/game/<game_name>/removeplayer/<username>')
@login_required
def remove_player(game_name, username):
game = get_game_if_owner(game_name)
user = User.query.filter(and_(User.name == username, User.games.contains(game))).first_or_404()
warning = game.remove_player(user)
if warning:
flash(warning)
return redirect(url_for('main.game_dashboard', game_name=game.name))
@bp.route('/game/<game_name>/user/<username>', methods=['GET', 'POST'])
@bp.route('/game/<game_name>/player/<username>', methods=['GET', 'POST'])
@login_required
def game_player(game_name, username):
game = get_game_if_owner(game_name)
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]
if request.method == 'GET':
form.role.default = player.role.value
form.process()
if form.validate_on_submit():
player.role = Role(form.role.data)
print(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}',
player=player, form=form, json=json, location_encoder=LocationEncoder)
@bp.route('/game/<game_name>/catch_bunny', methods=['GET', 'POST'])
@login_required
def catch_bunny(game_name):
game = Game.query.filter_by(name=game_name).first_or_404()
if current_user.role_in_game(game) is not Role.hunter:
flash('Only hunters can catch bunnies!')
abort(403)
if not game.is_active():
flash("Its not possible to catch a bunny before or after a game, "
"or if the game is not in 'active' mode.")
return redirect(url_for('main.game_dashboard', game_name=game.name))
game_bunnies = game.bunnies()
form = CatchBunnyForm()
form.bunny.choices = [(player.user.id, player.user.name) for player in game_bunnies]
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]
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.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)

60
app/main/tests/test_routes.py

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import unittest
from app import create_app, db
from app.models import User, Game, Role, GamePlayer, Objective, ObjectiveEncoder, LocationEncoder
from app.models import Player, Game, Role, GamePlayer, Objective, ObjectiveMinimalEncoder, LocationEncoder
import app.main.routes as routes
from config import Config
@ -24,5 +24,63 @@ class RoutesCase(unittest.TestCase): @@ -24,5 +24,63 @@ class RoutesCase(unittest.TestCase):
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)

237
app/models.py

@ -0,0 +1,237 @@ @@ -0,0 +1,237 @@
from enum import Enum
from werkzeug.security import generate_password_hash, check_password_hash
from . import db, login
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.sql import func
from secrets import token_hex
from flask_login import UserMixin
import json
from json import JSONEncoder
from datetime import datetime
import pytz
from flask_moment import Moment
moment = Moment()
class Role(Enum):
none = 0
owner = 1
hunter = 2
bunny = 3
class GameState(Enum):
initiated = 1
published = 2
started = 3
interrupted = 4
finished = 5
class GamePlayer(db.Model):
__tablename__ = 'game_player'
game_id = db.Column(db.Integer, db.ForeignKey('game.id'), primary_key=True, nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), primary_key=True, nullable=False)
role = db.Column(db.Enum(Role), server_default=Role(0).name, nullable=False)
game = db.relationship('Game', back_populates='game_players')
player = db.relationship('Player', back_populates='player_games')
class PlayerFoundObjective(db.Model):
__tablename__ = 'player_found_objective'
objective_id = db.Column(db.Integer, db.ForeignKey('objective.id'), primary_key=True, nullable=False, server_default='-1')
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), primary_key=True, nullable=False, server_default='-1')
timestamp = db.Column(db.DateTime, server_default=func.now(), nullable=False)
player = db.relationship('Player', back_populates='player_found_objectives')
objective = db.relationship('Objective', back_populates='objective_found_by')
class NotificationPlayer(db.Model):
__tablename__ = 'notification_player'
notification_id = db.Column(db.Integer, db.ForeignKey('notification.id'), primary_key=True, nullable=False)
player_id= db.Column(db.Integer, db.ForeignKey('player.id'), primary_key=True, nullable=False)
been_shown = db.Column(db.Boolean, server_default='True', nullable=False)
notification = db.relationship('Notification', back_populates='notification_recipients')
recipient = db.relationship('Player', back_populates='player_notifications')
class PlayerCaughtPlayer(db.Model):
__tablename__ = 'player_caught_player'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, server_default='-1')
catching_player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=False)
caught_player_id = db.Column(db.Integer, db.ForeignKey('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('Player', back_populates='player_caught_by_players', foreign_keys=[catching_player_id])
caught_player = db.relationship('Player', back_populates='player_caught_players', foreign_keys=[caught_player_id])
class Game(db.Model):
__tablename__ = 'game'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), index=True, unique=True, nullable=False)
state = db.Column(db.Enum(GameState), server_default=GameState(1).name, nullable=False)
start_time = db.Column(db.DateTime)
end_time = db.Column(db.DateTime)
game_players = db.relationship(
'GamePlayer',
back_populates='game',
cascade="save-update, merge, delete, delete-orphan")
players = association_proxy('game_players', 'player',
creator=lambda player: GamePlayer(player=player)) # to enable game.players.append(player)
objectives = db.relationship(
'Objective',
lazy='select',
backref=db.backref('game', lazy='joined'))
notifications = db.relationship(
'Notification',
lazy='select',
backref=db.backref('game', lazy='joined'))
def last_player_locations(self):
return [player.last_location(self) for player in self.players if player.locations]
class Player(UserMixin, db.Model):
""" !Always call set_auth_hash() after creating new instance! """
__tablename__ = 'player'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
auth_hash = db.Column(db.String(32), unique=True, nullable=True)
password_hash = db.Column(db.String(128))
player_games = db.relationship(
'GamePlayer',
back_populates='player',
cascade='save-update, merge, delete, delete-orphan')
games = association_proxy('player_games', 'game',
creator=lambda game: GamePlayer(game=game))
player_found_objectives = db.relationship(
'PlayerFoundObjective',
back_populates='player',
cascade='save-update, merge, delete, delete-orphan')
found_objectives = association_proxy('player_found_objectives', 'objective',
creator=lambda objective: PlayerFoundObjective(objective=objective))
player_notifications = db.relationship(
'NotificationPlayer',
back_populates='recipient',
cascade='save-update, merge, delete, delete-orphan')
notifications = association_proxy('player_notifications', 'notification',
creator=lambda notification: NotificationPlayer(notification=notification))
player_caught_players = db.relationship(
'PlayerCaughtPlayer',
back_populates='catching_player',
cascade='save-update, merge, delete, delete-orphan',
foreign_keys=[PlayerCaughtPlayer.catching_player_id])
caught_players = association_proxy('player_caught_players', 'player',
creator=lambda player: PlayerCaughtPlayer(caught_player=player))
player_caught_by_players = db.relationship(
'PlayerCaughtPlayer',
back_populates='caught_player',
cascade='save-update, merge, delete, delete-orphan',
foreign_keys=[PlayerCaughtPlayer.caught_player_id])
caught_by_players = association_proxy('player_caught_by_players', 'player',
creator=lambda player: PlayerCaughtPlayer(catching_player=player))
locations = db.relationship(
'Location',
lazy='select',
backref=db.backref('player', lazy='joined'))
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def set_auth_hash(self):
self.auth_hash = token_hex(16)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def locations_game(self, game):
# pylint: disable=not-an-iterable
if not self.locations:
return None
if game is None:
return self.locations
game_start = game.start_time or datetime.min
game_end = game.end_time or datetime.max
return (location for location in self.locations if location.timestamp > game_start and location.timestamp < game_end)
def last_location(self, game=None):
# pylint: disable=not-an-iterable
if not self.locations:
return None
if game is None:
return max(self.locations, key=lambda location: location.timestamp)
return max(self.locations_game(game), key=lambda location: location.timestamp)
@staticmethod
def delete_orphans():
Player.query.filter(~Player.player_games.any()).delete()
db.session.commit()
@login.user_loader
def load_user(id):
return Player.query.get(int(id))
class Objective(db.Model):
""" !Always call set_hash after() creating new instance! """
__tablename__ = 'objective'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64))
game_id = db.Column(db.Integer, db.ForeignKey('game.id'), nullable=False)
hash = db.Column(db.String(32), unique=True, nullable=False)
longitude = db.Column(db.Numeric(precision=15, scale=10, asdecimal=False, decimal_return_scale=None)) # maybe check asdecimal and decimal_return_scale later?
latitude = db.Column(db.Numeric(precision=15, scale=10, asdecimal=False, decimal_return_scale=None))
objective_found_by = db.relationship(
'PlayerFoundObjective',
back_populates='objective',
cascade='save-update, merge, delete, delete-orphan')
found_by = association_proxy('objective_found_by', 'player',
creator=lambda player: PlayerFoundObjective(player=player))
def set_hash(self):
self.hash = token_hex(16)
class ObjectiveMinimalEncoder(JSONEncoder):
def default(self, objective):
return {
'name' : objective.name,
'hash' : objective.hash,
'longitude' : objective.longitude,
'latitude' : objective.latitude
}
class Location(db.Model):
__tablename__ = 'location'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=False)
longitude = db.Column(db.Numeric(precision=15, scale=10, asdecimal=False, decimal_return_scale=None),nullable=False) # maybe check asdecimal and decimal_return_scale later?
latitude = db.Column(db.Numeric(precision=15, scale=10, asdecimal=False, decimal_return_scale=None),nullable=False)
timestamp = db.Column(db.DateTime, server_default=func.now(), nullable=False)
def __str__(self):
return f'{self.longitude}, {self.latitude}'
class LocationEncoder(JSONEncoder):
def default(self, location):
return {
'player_name' : location.player.name,
'longitude' : location.longitude,
'latitude' : location.latitude,
'timestamp_utc' : str(location.timestamp)
}
class Notification(db.Model):
__tablename__ = 'notification'
id = db.Column(db.Integer, primary_key=True)
game_id = db.Column(db.Integer, db.ForeignKey('game.id'), nullable=False)
message = db.Column(db.Text, nullable=False)
type = db.Column(db.String(64), nullable=False, default='General')
timestamp = db.Column(db.DateTime, server_default=func.now(), nullable=False)
notification_recipients = db.relationship(
'NotificationPlayer',
back_populates='notification',
cascade='save-update, merge, delete, delete-orphan')
recipients = association_proxy('notification_recipients', 'player',
creator=lambda player: NotificationPlayer(recipient=player))
#start_time = db.Column(db.DateTime, server_default=func.now())

12
app/models/__init__.py

@ -1,12 +0,0 @@ @@ -1,12 +0,0 @@
from .game import Game
from .game_player import GamePlayer
from .game_state import GameState
from .location import Location, LocationEncoder
from .notification import Notification
from .notification_player import NotificationPlayer
from .objective import Objective, ObjectiveEncoder
from .player_caught_player import PlayerCaughtPlayer
from .player_found_objective import PlayerFoundObjective
from .review import Review
from .role import Role
from .user import User

115
app/models/game.py

@ -1,115 +0,0 @@ @@ -1,115 +0,0 @@
from datetime import datetime
from sqlalchemy.ext.associationproxy import association_proxy
from app import db
from .game_state import GameState
from .game_player import GamePlayer
from .role import Role
from .review import Review
class Game(db.Model):
__tablename__ = 'game'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), index=True, unique=True, nullable=False)
#state = db.Column(db.Enum(GameState), server_default=GameState(1).name, nullable=False)
hidden = db.Column(db.Boolean, server_default='1', nullable=False)
paused = db.Column(db.Boolean, server_default='0', nullable=False)
start_time = db.Column(db.DateTime)
end_time = db.Column(db.DateTime)
players = db.relationship(
'GamePlayer',
back_populates='game',
cascade="save-update, merge, delete, delete-orphan")
users = association_proxy('players', 'user',
creator=lambda user: GamePlayer(user=user))
objectives = db.relationship(
'Objective',
lazy='select',
backref=db.backref('game', lazy='joined'),
cascade="save-update, merge, delete, delete-orphan")
notifications = db.relationship(
'Notification',
lazy='select',
backref=db.backref('game', lazy='joined'),
cascade="save-update, merge, delete, delete-orphan")
def usernames(self):
return [user.name for user in self.users]
def last_player_locations(self, offset=None):
# pylint: disable=not-an-iterable
return [location for location in
[player.last_location(offset=offset) for player in self.players if player.user.locations]
if location is not None]
def last_locations(self, players, offset=None):
'''
Returns last locations for given players within time boundaries of game
Parameters:
players (Player or User list): players for whom the last location is returned
offset (int): Offset in minutes. Only locations older than this amount of minutes will be returned.
'''
locations = []
for player in players:
if isinstance(player, GamePlayer):
player = player.user
location = player.last_location(self, offset=offset)
if location:
locations.append(location)
return locations
def bunnies(self):
# pylint: disable=not-an-iterable
return [gameplayer for gameplayer in self.players if gameplayer.role == Role.bunny]
def owned_by(self, user):
'''given user is an owner of this game'''
# pylint: disable=not-an-iterable
return user in [gameplayer.user for gameplayer in self.players if gameplayer.role == Role.owner]
def unreviewed_bunny_photos(self):
# pylint: disable=not-an-iterable
return [pcp for pcps in
[player.player_caught_players for player in self.players]
for pcp in pcps if pcp.review == Review.none]
def is_active(self):
return self.get_state() == GameState.active
def get_state(self):
now = datetime.utcnow()
start = (self.start_time or datetime.min).replace(tzinfo=None)
if now < start: # Before Game
if self.hidden:
return GameState.hidden
return GameState.published
end = (self.end_time or datetime.max).replace(tzinfo=None)
if start < now < end: # During Game
if self.paused:
return GameState.paused
return GameState.active
if now > end: # After Game
if self.hidden:
return GameState.hidden
return GameState.finished
def delete(self):
db.session.delete(self)
for user in self.users:
if not user.last_login:
db.session.delete(user)
db.session.commit()
def remove_player(self, user):
# pylint: disable=not-an-iterable
if user.role_in_game(self) == Role.owner:
if len([player for player in self.players if player.role == Role.owner]) < 2:
return "Can't remove only owner from game"
self.users.remove(user)
if not user.last_login:
db.session.delete(user)
db.session.commit()
return False

75
app/models/game_player.py

@ -1,75 +0,0 @@ @@ -1,75 +0,0 @@
import json
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.schema import UniqueConstraint
from app import db
from .role import Role
from .notification_player import NotificationPlayer
from .player_found_objective import PlayerFoundObjective
from .review import Review
class GamePlayer(db.Model):
__tablename__ = 'game_player'
id = db.Column(db.Integer, primary_key=True)
game_id = db.Column(db.Integer, db.ForeignKey('game.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
role = db.Column(db.Enum(Role), server_default=Role(0).name, nullable=False)
game = db.relationship('Game', back_populates='players')
user = db.relationship('User', back_populates='user_games')
__table_args__ = (UniqueConstraint('game_id', 'user_id', name='_game_user_uc'),
)
player_notifications = db.relationship(
'NotificationPlayer',
back_populates='recipient',
cascade='save-update, merge, delete, delete-orphan')
notifications = association_proxy('player_notifications', 'notification',
creator=lambda notification: NotificationPlayer(notification=notification))
player_found_objectives = db.relationship(
'PlayerFoundObjective',
back_populates='game_player',
cascade='save-update, merge, delete, delete-orphan')
found_objectives = association_proxy('player_found_objectives', 'objective',
creator=lambda objective: PlayerFoundObjective(objective=objective))
caught_by_players = association_proxy('player_caught_by_players', 'catching_player')
caught_players = association_proxy('player_caught_players', 'caught_player')
def last_location(self, offset=None):
'''
Returns game_player's last recorded location within game start- and end time.
Parameters:
offset (int): Offset in minutes. Only locations older than this amount of minutes will be returned.
'''
# pylint: disable=no-member
return self.user.last_location(self.game, offset)
def locations_during_game(self):
# pylint: disable=no-member
return self.user.locations_during_game(self.game)
def encode_objectives(self):
# pylint: disable=no-member
objectives = ['[']
for objective in self.game.objectives:
obj = {
'name' : objective.name,
'longitude' : objective.longitude,
'latitude' : objective.latitude,
'found' : objective in self.found_objectives}
objectives.append(json.dumps(obj))
objectives.append(',')
return ''.join(objectives)[:-1] + ']'
def accepted_caught_players(self):
return [pcp.caught_player
for pcp in self.player_caught_players
if pcp.review == Review.accepted]
def accepted_caught_by_players(self):
return [pcp.catching_player
for pcp in self.player_caught_by_players
if pcp.review == Review.accepted]

9
app/models/game_state.py

@ -1,9 +0,0 @@ @@ -1,9 +0,0 @@
from enum import Enum
class GameState(Enum):
hidden = 1
published = 2
active = 3
paused = 4
finished = 5

24
app/models/location.py

@ -1,24 +0,0 @@ @@ -1,24 +0,0 @@
from json import JSONEncoder
from sqlalchemy.sql import func
from app import db
class Location(db.Model):
__tablename__ = 'location'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
longitude = db.Column(db.Numeric(precision=15, scale=10, asdecimal=False, decimal_return_scale=None), nullable=False) # maybe check asdecimal and decimal_return_scale later?
latitude = db.Column(db.Numeric(precision=15, scale=10, asdecimal=False, decimal_return_scale=None), nullable=False)
timestamp = db.Column(db.DateTime, server_default=func.now(), nullable=False)
def __str__(self):
return f'{self.longitude}, {self.latitude}'
class LocationEncoder(JSONEncoder):
def default(self, location):
return {
'username' : location.user.name,
'longitude' : location.longitude,
'latitude' : location.latitude,
'timestamp_utc' : str(location.timestamp)
}

20
app/models/notification.py

@ -1,20 +0,0 @@ @@ -1,20 +0,0 @@
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.sql import func
from app import db
from .notification_player import NotificationPlayer
class Notification(db.Model):
__tablename__ = 'notification'
id = db.Column(db.Integer, primary_key=True)
game_id = db.Column(db.Integer, db.ForeignKey('game.id'), nullable=False)
message = db.Column(db.Text, nullable=False)
type = db.Column(db.String(64), nullable=False, default='General')
timestamp = db.Column(db.DateTime, server_default=func.now(), nullable=False)
notification_recipients = db.relationship(
'NotificationPlayer',
back_populates='notification',
cascade='save-update, merge, delete, delete-orphan')
recipients = association_proxy('notification_recipients', 'game_player',
creator=lambda game_player: NotificationPlayer(recipient=game_player))

10
app/models/notification_player.py

@ -1,10 +0,0 @@ @@ -1,10 +0,0 @@
from app import db
class NotificationPlayer(db.Model):
__tablename__ = 'notification_player'
notification_id = db.Column(db.Integer, db.ForeignKey('notification.id'), primary_key=True, nullable=False)
game_player_id = db.Column(db.Integer, db.ForeignKey('game_player.id'), primary_key=True, nullable=False)
been_shown = db.Column(db.Boolean, server_default='0', nullable=False)
notification = db.relationship('Notification', back_populates='notification_recipients')
recipient = db.relationship('GamePlayer', back_populates='player_notifications')

39
app/models/objective.py

@ -1,39 +0,0 @@ @@ -1,39 +0,0 @@
from secrets import token_hex
from json import JSONEncoder
from sqlalchemy.ext.associationproxy import association_proxy
from app import db
from .player_found_objective import PlayerFoundObjective
from .role import Role
class Objective(db.Model):
""" !Always call set_hash after() creating new instance! """
__tablename__ = 'objective'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64))
game_id = db.Column(db.Integer, db.ForeignKey('game.id'), nullable=False)
hash = db.Column(db.String(32), unique=True, nullable=False)
longitude = db.Column(db.Numeric(precision=15, scale=10, asdecimal=False, decimal_return_scale=None)) # maybe check asdecimal and decimal_return_scale later?
latitude = db.Column(db.Numeric(precision=15, scale=10, asdecimal=False, decimal_return_scale=None))
objective_found_by = db.relationship(
'PlayerFoundObjective',
back_populates='objective',
cascade='save-update, merge, delete, delete-orphan')
found_by = association_proxy('objective_found_by', 'game_player',
creator=lambda game_player: PlayerFoundObjective(game_player=game_player))
def set_hash(self):
self.hash = token_hex(16)
def owned_by(self, user):
'''Returns True if given user is an owner of a game object is part of'''
return user in [gameplayer.user for gameplayer in self.game.players if gameplayer.role == Role.owner]
class ObjectiveEncoder(JSONEncoder):
def default(self, objective):
return {
'name' : objective.name,
'hash' : objective.hash,
'longitude' : objective.longitude,
'latitude' : objective.latitude
}

28
app/models/player_caught_player.py

@ -1,28 +0,0 @@ @@ -1,28 +0,0 @@
from sqlalchemy.sql import func
from app import db
from .game_player import GamePlayer
from .review import Review
class PlayerCaughtPlayer(db.Model):
__tablename__ = 'player_caught_player'
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)
timestamp = db.Column(db.DateTime, server_default=func.now(), nullable=False)
review = db.Column(db.Enum(Review), server_default=Review(0).name, 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'))
caught_player = db.relationship('GamePlayer', primaryjoin=(caught_player_id == GamePlayer.id),
backref=db.backref('player_caught_by_players', cascade='save-update, merge, delete, delete-orphan'))
'''
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)
db.session.add(pc)
db.session.commit()
'''

11
app/models/player_found_objective.py

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
from sqlalchemy.sql import func
from app import db
class PlayerFoundObjective(db.Model):
__tablename__ = 'player_found_objective'
objective_id = db.Column(db.Integer, db.ForeignKey('objective.id'), primary_key=True, nullable=False, server_default='-1')
game_player_id = db.Column(db.Integer, db.ForeignKey('game_player.id'), primary_key=True, nullable=False, server_default='-1')
timestamp = db.Column(db.DateTime, server_default=func.now(), nullable=False)
game_player = db.relationship('GamePlayer', back_populates='player_found_objectives')
objective = db.relationship('Objective', back_populates='objective_found_by')

14
app/models/review.py

@ -1,14 +0,0 @@ @@ -1,14 +0,0 @@
from enum import Enum
class Review(Enum):
none = 0
denied = 1
accepted = 2
@classmethod
def parse_string(cls, string):
if string == 'accept' or string == 'accepted':
return cls.accepted
if string == 'deny' or string == 'denied':
return cls.denied
return cls.none

8
app/models/role.py

@ -1,8 +0,0 @@ @@ -1,8 +0,0 @@
from enum import Enum
class Role(Enum):
none = 0
owner = 1
hunter = 2
bunny = 3

98
app/models/tests/test_game.py

@ -1,98 +0,0 @@ @@ -1,98 +0,0 @@
import unittest
from datetime import datetime
from app import create_app, db
from app.models import User, Game, Role, GamePlayer
from config import Config
class TestConfig(Config):
TESTING = True
WTF_CSRF_ENABLED = False
DEBUG = False
SQLALCHEMY_DATABASE_URI = 'sqlite://'
class GameCase(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')
u1 = User(name='Henk')
u2 = User(name='Alfred')
g1.players.append(GamePlayer(user=u1, role=Role.owner))
g1.players.append(GamePlayer(user=u2, role=Role.bunny))
db.session.add(g1)
db.session.commit()
self.assertTrue(g1.owned_by(u1))
self.assertFalse(g1.owned_by(u2))
def test_delete(self):
g1 = Game(name='TestGame')
u1 = User(name='Henk')
u2 = User(name='Karel')
p1 = GamePlayer(user=u1, role=Role.bunny)
p2 = GamePlayer(user=u2, role=Role.bunny)
g1.players.append(p1)
g1.players.append(p2)
db.session.add(g1)
db.session.commit()
u2.last_login = datetime.now()
db.session.commit()
self.assertNotEqual(User.query.filter_by(name='Henk').first(), None)
self.assertNotEqual(User.query.filter_by(name='Karel').first(), None)
g1.delete()
self.assertEqual(User.query.filter_by(name='Henk').first(), None)
self.assertNotEqual(User.query.filter_by(name='Karel').first(), None)
self.assertEqual(Game.query.filter_by(name='TestGame').first(), None)
def test_remove_player(self):
g1 = Game(name='TestGame')
g2 = Game(name='TestGame2')
u1 = User(name='Henk')
u2 = User(name='Karel')
p1_1 = GamePlayer(user=u1, role=Role.owner)
p1_2 = GamePlayer(user=u2, role=Role.bunny)
p2_1 = GamePlayer(user=u1, role=Role.owner)
p2_2 = GamePlayer(user=u2, role=Role.owner)
g1.players.append(p1_1)
g1.players.append(p1_2)
g2.players.append(p2_1)
g2.players.append(p2_2)
db.session.add(g1)
db.session.add(g2)
db.session.commit()
self.assertTrue(g1.remove_player(u1))
self.assertFalse(g1.remove_player(u2))
self.assertFalse(g2.remove_player(u1))
self.assertTrue(g2.remove_player(u2))
db.session.commit()
self.assertTrue(p1_1 in g1.players)
self.assertTrue(p1_2 not in g1.players)
self.assertTrue(p2_1 not in g2.players)
self.assertTrue(p2_2 in g2.players)
if __name__ == '__main__':
unittest.main(verbosity=2)

61
app/models/tests/test_game_player.py

@ -1,61 +0,0 @@ @@ -1,61 +0,0 @@
import unittest
import json
from app import create_app, db
from app.models import User, Game, Role, GamePlayer, Objective
from config import Config
class TestConfig(Config):
TESTING = True
WTF_CSRF_ENABLED = False
DEBUG = False
SQLALCHEMY_DATABASE_URI = 'sqlite://'
class GamePlayerCase(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_default(self):
g1 = Game(name='TestGame')
u1 = User(name='Henk')
p1 = GamePlayer(user=u1, role=Role.bunny)
o1 = Objective(name='Objective 1')
o2 = Objective(name='Objective 2')
o3 = Objective(name='Objective 3')
o1.set_hash()
o2.set_hash()
o3.set_hash()
g1.players.append(p1)
g1.objectives.append(o1)
g1.objectives.append(o2)
g1.objectives.append(o3)
p1.found_objectives.append(o1)
db.session.add(g1)
db.session.commit()
# Check if test initiaion succeeded
self.assertEqual(len(Game.query.first().objectives), 3)
self.assertEqual(User.query.first().user_games[0].found_objectives[0], o1)
# The actual Test
player_objectives = ('[{"name": "Objective 1", "longitude": null, "latitude": null, "found": true},'
'{"name": "Objective 2", "longitude": null, "latitude": null, "found": false},'
'{"name": "Objective 3", "longitude": null, "latitude": null, "found": false}]')
self.assertEqual(p1.encode_objectives(), player_objectives)
if __name__ == '__main__':
unittest.main(verbosity=2)

76
app/models/tests/test_objective.py

@ -1,76 +0,0 @@ @@ -1,76 +0,0 @@
import unittest
from app import create_app, db
from app.models import User, Game, Role, GamePlayer, Objective
from config import Config
class TestConfig(Config):
TESTING = True
WTF_CSRF_ENABLED = False
DEBUG = False
SQLALCHEMY_DATABASE_URI = 'sqlite://'
class ObjectiveCase(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_objective_owner(self):
g1 = Game(name='TestGame')
g2 = Game(name='AnotherGame')
u1 = User(name='Henk')
g1.players.append(GamePlayer(user=u1, role=Role.owner))
g2.players.append(GamePlayer(user=u1, 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(o1.owned_by(u1))
self.assertFalse(o2.owned_by(u1))
def test_delete_objectives_recursively(self):
g1 = Game(name='TestGame')
u1 = User(name = 'Henk')
p1 = GamePlayer(user=u1, role=Role.bunny)
o1 = Objective(name='o1')
o1.set_hash()
o2 = Objective(name='o2')
o2.set_hash()
g1.players.append(p1)
g1.objectives.append(o1)
g1.objectives.append(o2)
p1.found_objectives.append(o2)
db.session.add(g1)
db.session.commit()
self.assertNotEqual(Objective.query.filter_by(name='o1').first(), None)
self.assertNotEqual(Objective.query.filter_by(name='o2').first(), None)
db.session.delete(g1)
db.session.commit()
self.assertEqual(Objective.query.filter_by(name='o1').first(), None)
self.assertEqual(Objective.query.filter_by(name='o2').first(), None)
if __name__ == '__main__':
unittest.main(verbosity=2)

76
app/models/tests/test_user.py

@ -1,76 +0,0 @@ @@ -1,76 +0,0 @@
import unittest
from datetime import datetime
from app import create_app, db
from app.models import User, Game, GamePlayer, Role
from config import Config
class TestConfig(Config):
TESTING = True
WTF_CSRF_ENABLED = False
DEBUG = False
SQLALCHEMY_DATABASE_URI = 'sqlite://'
class UserCase(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_player_game_owner(self):
g1 = Game(name='TestGame')
g2 = Game(name='AnotherGame')
u1 = User(name='Henk')
u2 = User(name='Alfred')
u3 = User(name='Sasha')
g1.players.append(GamePlayer(user=u1, role=Role.owner))
g1.players.append(GamePlayer(user=u2, role=Role.bunny))
g2.players.append(GamePlayer(user=u1, role=Role.hunter))
g2.players.append(GamePlayer(user=u3, role=Role.bunny))
db.session.add(g1)
db.session.add(g2)
db.session.commit()
self.assertTrue(u1.owns_game_played_by(user=u2), "owner owns subject_player's game")
self.assertFalse(u1.owns_game_played_by(user=u3), "owner doesn't own subject_player's game")
self.assertTrue(u1.owns_game_played_by(user=u1), "owner owns it own's game")
def test_role_in_game(self):
g1 = Game(name='TestGame')
u1 = User(name='Henk')
u2 = User(name='Alfred')
u3 = User(name='Sasha')
u4 = User(name='Demian')
u5 = User(name='Karl')
g1.players.append(GamePlayer(user=u1, role=Role.owner))
g1.players.append(GamePlayer(user=u2, role=Role.bunny))
g1.players.append(GamePlayer(user=u3, role=Role.hunter))
g1.players.append(GamePlayer(user=u4, role=Role.none))
db.session.add(g1)
db.session.add(u5)
db.session.commit()
self.assertEqual(u1.role_in_game(g1), Role.owner)
self.assertEqual(u2.role_in_game(g1), Role.bunny)
self.assertEqual(u3.role_in_game(g1), Role.hunter)
self.assertEqual(u4.role_in_game(g1), Role.none)
self.assertEqual(u5.role_in_game(g1), None)
with self.assertRaises(AttributeError):
g1.get_role_for_game(None)
if __name__ == '__main__':
unittest.main(verbosity=2)

128
app/models/user.py

@ -1,128 +0,0 @@ @@ -1,128 +0,0 @@
from secrets import token_hex
from datetime import datetime, timedelta
from flask_login import UserMixin
from sqlalchemy.ext.associationproxy import association_proxy
from werkzeug.security import generate_password_hash, check_password_hash
from app import db, login
from app.models import GamePlayer, Role
class User(UserMixin, db.Model):
""" !Always call set_auth_hash() after creating new instance! """
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
auth_hash = db.Column(db.String(32), unique=True, nullable=True)
password_hash = db.Column(db.String(128))
last_login = db.Column(db.DateTime)
user_games = db.relationship(
'GamePlayer',
back_populates='user',
cascade='save-update, merge, delete, delete-orphan')
games = association_proxy('user_games', 'game',
creator=lambda game: GamePlayer(game=game))
locations = db.relationship(
'Location',
lazy='select',
backref=db.backref('user', lazy='joined'),
cascade="save-update, merge, delete, delete-orphan")
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def set_auth_hash(self):
self.auth_hash = token_hex(16)
def check_password(self, password):
if not password or not self.password_hash:
return False
return check_password_hash(self.password_hash, password)
def locations_during_game(self, game, offset=None):
'''
Returns users locations during game.
Parameters:
game (Game): If specified, only locations within start- and endtime of game wil be returned
offset (int): Offset in minutes. Only locations older than this amount of minutes will be returned.
'''
# pylint: disable=not-an-iterable
if offset is None or offset == '':
offset = 0
if not self.locations:
return None
if game is None:
if offset == 0:
return self.locations
return [location for location in self.locations
if datetime.utcnow() - location.timestamp > timedelta(minutes=offset)]
game_start = game.start_time or datetime.min
game_end = game.end_time or datetime.max
if offset == 0:
return [location for location in self.locations
if location.timestamp > game_start and location.timestamp < game_end]
return [location for location in self.locations
if location.timestamp > game_start and location.timestamp < game_end
and datetime.utcnow() - location.timestamp > timedelta(minutes=offset)]
def last_location(self, game=None, offset=None):
'''
Returns users last recorded location.
Parameters:
game (Game): If specified, only locations within start- and endtime of game wil be returned
offset (int): Offset in minutes. Only locations older than this amount of minutes will be returned.
'''
# pylint: disable=[not-an-iterable, unsubscriptable-object]
if offset is None or offset == '':
offset = 0
if not self.locations:
return None
if game is None:
locations = self.locations_during_game(game=None, offset=offset)
return locations[-1] if locations else None
locations_during_game = self.locations_during_game(game, offset=offset)
return locations_during_game[-1] if locations_during_game else None
def role_in_game(self, game):
'''Returns the role as Role enum of player in given game. Returns None if player does not participate in game'''
gameplayer = self.player_in(game)
if not gameplayer:
return None
return gameplayer.role
def owns_game_played_by(self, user):
'''Self is an owner of a game the user participates in'''
return self in [gameplayer.user for gameplayers in
[game.players for game in user.games]
for gameplayer in gameplayers if gameplayer.role == Role.owner]
def owned_game_played_by(self, user):
'''Return first game owned by self that the user participates in'''
return next(iter([gameplayer.game for gameplayers in
[game.players for game in user.games]
for gameplayer in gameplayers
if gameplayer.role == Role.owner and gameplayer.user == self]),
None)
def player_in(self, game):
# pylint: disable=not-an-iterable
'''Returns GamePlayer object for given game, or None if user does not participate in given game'''
gameplayers = [gameplayer for gameplayer in self.user_games if gameplayer.game == game]
if not gameplayers:
return None
return gameplayers[0]
@staticmethod
def delete_orphans():
User.query.filter(~User.user_games.any()).delete()
db.session.commit()
@login.user_loader
def load_user(id):
return User.query.get(int(id))

26
app/static/assets/geolocation_utils.js

@ -1,26 +0,0 @@ @@ -1,26 +0,0 @@
function getPosition(locationHandler){
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(locationHandler, showError);
} else {
console.log('Geolocation is not supported by this browser.')
}
}
function showError(error) {
switch(error.code) {
case error.PERMISSION_DENIED:
console.log("User denied the request for Geolocation.")
alert("Please refresh page and allow location sharing, otherwise the game won't work :'(")
break;
case error.POSITION_UNAVAILABLE:
console.log("Location information is unavailable.")
break;
case error.TIMEOUT:
console.log("The request to get user location timed out.")
break;
case error.UNKNOWN_ERROR:
console.log("An unknown error occurred.")
break;
}
}

BIN
app/static/assets/leaflet/images/person-marker-icon-2x-blue.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

BIN
app/static/assets/leaflet/images/person-marker-icon-2x-gold.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

BIN
app/static/assets/leaflet/images/person-marker-icon-2x-green.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

174
app/static/assets/leaflet/utils.js

@ -1,174 +0,0 @@ @@ -1,174 +0,0 @@
var greyIcon = new L.Icon({
iconUrl: '/static/assets/leaflet/images/marker-icon-2x-grey.png',
shadowUrl: '/static/assets/leaflet/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
var greenIcon = new L.Icon({
iconUrl: '/static/assets/leaflet/images/marker-icon-2x-green.png',
shadowUrl: '/static/assets/leaflet/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
var goldIcon = new L.Icon({
iconUrl: '/static/assets/leaflet/images/marker-icon-2x-gold.png',
shadowUrl: '/static/assets/leaflet/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
var bluePlayerIcon = new L.Icon({
iconUrl: '/static/assets/leaflet/images/person-marker-icon-2x-blue.png',
shadowUrl: '/static/assets/leaflet/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
var greenPlayerIcon = new L.Icon({
iconUrl: '/static/assets/leaflet/images/person-marker-icon-2x-green.png',
shadowUrl: '/static/assets/leaflet/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
var goldPlayerIcon = new L.Icon({
iconUrl: '/static/assets/leaflet/images/person-marker-icon-2x-gold.png',
shadowUrl: '/static/assets/leaflet/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
var bluePlayerIconMini = new L.Icon({
iconUrl: '/static/assets/leaflet/images/person-marker-icon-2x-blue.png',
shadowUrl: '/static/assets/leaflet/images/marker-shadow.png',
iconSize: [10, 16.4],
iconAnchor: [5, 16.4],
popupAnchor: [1, -34],
shadowSize: [16.4, 16.4]
});
function addObjectiveMarker(map, objective, icon=greenIcon){
var objectiveMarker = L.marker([
objective['latitude'],
objective['longitude']
], {icon: icon})
if(objective['found']){
objectiveMarker.setIcon(goldIcon)
}
objectiveMarker.addTo(map);
if(objective['hash']){
objectiveMarker.bindTooltip(`<b>${objective['name']}</b><br>
${objective['hash']}`).openPopup();
objectiveMarker.hash = objective['hash'];
} else {
objectiveMarker.bindTooltip(`<b>${objective['name']}</b>`).openPopup();
}
return objectiveMarker;
}
function addPlayerMarker(map, player, icon=bluePlayerIcon){
var playerMarker = L.marker([
player['latitude'],
player['longitude']
], {icon: icon}).addTo(map);
var timestamp_local = toMomentLocal(player['timestamp_utc']).format('YYYY-MM-DD HH:mm')
playerMarker.bindTooltip(`<b>${player['username']}</b><br>
${timestamp_local}`).openPopup();
playerMarker.username = player['username'];
return playerMarker;
}
function toMomentLocal(timestamp_utc_string){
var timestamp_utc = moment.utc(timestamp_utc_string).toDate();
return moment(timestamp_utc).local();
}
var myLocationMarker
function updateMyLocation(position){
if(myLocationMarker == undefined){
myLocationMarker = L.marker([
position.coords.latitude,
position.coords.longitude
], {icon: bluePlayerIcon}).addTo(map)
myLocationMarker.bindTooltip('Your current location').openPopup();
}
else{
var newLocation = new L.LatLng(
position.coords.latitude,
position.coords.longitude
);
myLocationMarker.setLatLng(newLocation);
}
}
function getMap(){
var map = L.map( 'map', {
center: [52.2, 5.3],
minZoom: 6,
maxZoom: 18,
bounds: [[50.5, 3.25], [54, 7.6]],
zoom: 8
});
L.control.scale().addTo(map);
//pioneer,atlas,neighbourhood,transport
L.tileLayer( 'https://tile.thunderforest.com/pioneer/{z}/{x}/{y}.png?apikey=df457ee6c2dd4b6e99b24c853421a1db', {
attribution: 'Kaartgegevens &copy; <a href="https://www.thunderforest.com">Thunderforest</a>'
}).addTo( map );
return map
}
var csrftoken = $('meta[name=csrf-token]').attr('content')
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrftoken)
}
}
})
function pollLocations(url, requestedUsers, mode, playerLocations, responseHandler) {
$.ajax({
type: "POST",
url: url,
data: JSON.stringify({
requested_users: requestedUsers,
mode: mode,
last_update: moment(get_latest_date(playerLocations)).format("YYYY-MM-DD HH:mm:ss:SSSS")
}),
contentType: "application/json; charset=utf-8",
dataType: 'json',
success: function(data){
responseHandler(
data.filter(item => item.latitude && item.longitude && item.timestamp_utc && item.username)
)
},
error: function(error) {
console.log(error);
}
});
}
function get_latest_date(playerLocations){
if (playerLocations.length == 0){
return new Date('0001-01-01T00:00:00Z');
}
return new Date(Math.max.apply(null, playerLocations.map(function(e) {
return new Date(e.timestamp_utc);
})));
}

37
app/static/assets/utils.js

@ -1,37 +0,0 @@ @@ -1,37 +0,0 @@
function createDateTimePicker(datePicker, checkbox, date){
$(datePicker).datetimepicker({
//useCurrent: false, //Important! See issue #1075
locale: 'en-gb',
format: 'DD-MM-YYYY HH:mm',
keepInvalid: true,
sideBySide: true,
defaultDate: null,
timeZone: moment.tz.guess()
});
$(checkbox).change(function() {
updateDateTimePicker(datePicker, checkbox)
});
if (!$(checkbox)[0].checked){
if ($(datePicker)[0].value == ''){
$(datePicker)[0].value = date.format('DD-MM-YYYY HH:mm');
} else if (!$('.alert')[0]) { //Don't convert datetime again after error
$(datePicker)[0].value = moment.utc($(datePicker)[0].value, 'DD-MM-YYYY HH:mm').local().format('DD-MM-YYYY HH:mm');
}
} else {
$(datePicker).data("DateTimePicker").disable();
};
}
function updateDateTimePicker(picker, checkbox){
if ($(checkbox).prop("checked")) {
$(picker).data("DateTimePicker").disable();
}
else {
$(picker).data("DateTimePicker").enable();
if ($(picker)[0].value == ''){
$(picker)[0].value = moment().format('DD-MM-YYYY HH:mm');
}
}
}

0
app/static/css/default.css

42
app/templates/_game_player_info.html

@ -1,42 +0,0 @@ @@ -1,42 +0,0 @@
<h2>Game Info</h2>
<div class="table">
<table class="table">
<tr>
<th>My Game</th>
<td>{{ game.name.title() }}</td>
</tr>
<tr>
<th>My Name</th>
<td>{{ current_user.name.title() }}</td>
</tr>
<tr>
<th>My Role</th>
<td>{{ current_user.role_in_game(game).name.title() }}</td>
</tr>
<tr>
<th>Game State</th>
<td>{{ game.get_state().name.title() }}</td>
</tr>
<tr>
<th>Start Time</th>
<td>{% if game.start_time %}{{ moment(game.start_time).format('DD-MM-YYYY, HH:mm')}}{% else %}-{% endif %}</td>
</tr>
<tr>
<th>End Time</th>
<td>{% if game.end_time %}{{ moment(game.end_time).format('DD-MM-YYYY, HH:mm')}}{% else %}-{% endif %}</td>
</tr>
{% if current_user.role_in_game(game).name == 'bunny' %}
<tr>
<th>Been Found</th>
<td>
<span style="color:green;">{{ current_user.player_caught_by_players | selectattr('catching_player', '==', player) | selectattr('review.name', '==', 'accepted') |list|length}}</span> /
<span style="color:red;">{{ current_user.player_caught_by_players | selectattr('catching_player', '==', player) | selectattr('review.name', '==', 'denied') |list|length}}</span> /
<span style="color:gray;">{{ current_user.player_caught_by_players | selectattr('catching_player', '==', player) | selectattr('review.name', '==', 'none') |list|length}}</span>
<span style="font-size: smaller;">
(<span style="color:green;">Accepted</span>/<span style="color:red;">Denied</span>/<span style="color:gray;">Not reviewed</span>)
</span>
</td>
</tr>
{% endif %}
</table>
</div>

37
app/templates/_review_photo.html

@ -1,37 +0,0 @@ @@ -1,37 +0,0 @@
<div class="row">
<div class="col-md-4">
<div class="row">
<div class="table-responsive">
<table class="table">
<tr>
<th>Hunter</th>
<td>{{ pcp.catching_player.user.name }}</td>
</tr>
<tr>
<th>Bunny</th>
<td>{{ pcp.caught_player.user.name }}</td>
</tr>
<tr>
<th>Time</th>
<td>{{ pcp.timestamp.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
</table>
</div>
</div>
<div class="row">
<a href="{{ url_for('main.review_caught_bunny_photos', game_name=game.name, pcp_id=pcp.id, action='accept') }}">
<button class="btn btn-success">Accept</button>
</a>
<a href="{{ url_for('main.review_caught_bunny_photos', game_name=game.name, pcp_id=pcp.id, action='deny') }}">
<button class="btn btn-danger">Reject</button>
</a>
</div>
</div>
<div class="col-md-8">
<img src="{{ url_for('main.caught_bunny_photo', game_name=game.name,
timestamp=pcp.timestamp.strftime('%Y%m%d%H%M%S'),
bunny_name=pcp.caught_player.user.name,
hunter_name=pcp.catching_player.user.name) }}"
alt="could not load photo", width="100%">
</div>
</div>

4
app/templates/add_player.html

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
{% block app_content %}
<h1>Add Player To Game</h1>
<h2>Add Existing User</h2>
<h2>Add Existing Player</h2>
<div class="row">
<div class="col-md-4">
<form action="" method="post" class="form" role="form">
@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
</form>
</div>
</div>
<h2>Create new User</h2>
<h2>Create new Player</h2>
<div class="row">
<div class="col-md-4">
<form action="" method="post" class="form" role="form">

18
app/templates/auth/change_password.html

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<div class="row">
<div class="col-xs-0 col-md-1"></div>
<div class="col-xs-8 col-md-4">
{% if current_user.password_hash %}
<h1>Change Password</h1>
{% else %}
<h1>Set Password</h1>
{% endif %}
{{ wtf.quick_form(form, button_map={'submit': 'primary'}) }}
<br>
</div>
<div class="col-xs-0 col-md-7"></div>
</div>
{% endblock %}

22
app/templates/auth/login.html

@ -2,20 +2,12 @@ @@ -2,20 +2,12 @@
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<div class="row">
<div class="col-xs-0 col-md-1"></div>
<div class="col-xs-8 col-md-4">
<h1>Sign In</h1>
<form action="" method="post" class="form" role="form">
{{ form.hidden_tag() }}
{{ wtf.form_field(form.username) }}
{{ wtf.form_field(form.password) }}
{{ wtf.form_field(form.remember_me) }}
{{ wtf.form_field(form.submit, class='btn btn-primary') }}
</form>
<br>
<p>New User? <a href="{{ url_for('auth.register') }}">Click to Register!</a></p>
<h1>Sign In</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form, button_map={'submit': 'primary'}) }}
</div>
<div class="col-xs-0 col-md-7"></div>
</div>
</div>
<br>
<p>New User? <a href="{{ url_for('auth.register') }}">Click to Register!</a></p>
{% endblock %}

16
app/templates/auth/register.html

@ -2,14 +2,12 @@ @@ -2,14 +2,12 @@
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<div class="row">
<div class="col-xs-0 col-md-1"></div>
<div class="col-xs-8 col-md-4">
<h1>Register</h1>
{{ wtf.quick_form(form, button_map={'submit': 'primary'}) }}
<br>
<p>Already have an account? <a href="{{ url_for('auth.login') }}">Click to Sign In!</a></p>
<h1>Register</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form, button_map={'submit': 'primary'}) }}
</div>
</div>
<div class="col-xs-0 col-md-7"></div>
</div>
<br>
<p>Already have an account? <a href="{{ url_for('auth.login') }}">Click to Sign In!</a></p>
{% endblock %}

20
app/templates/auth/user_hash_login.html

@ -1,20 +0,0 @@ @@ -1,20 +0,0 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<div class="row">
<div class="col-xs-0 col-md-1"></div>
<div class="col-xs-12 col-md-10">
<h1>Welcome, {{ user.name }}!</h1>
<p>
If you found this page, it probably means someone who is organising a hunt invited you by
sending a link or QR-code to this page. You can start playing right away, if and if you get
logged out just visit this page again. However, if you want to be sure other people can't
steal this account, please set a password.
</p>
<a href="{{ url_for('auth.change_password', auth_hash=user.auth_hash) }}"><button class="btn btn-primary">Set Password</button></a>
<a href="{{ url_for('auth.user_hash_login', auth_hash=user.auth_hash, login='true') }}"><button class="btn btn-primary">Start Playing!</button></a>
</div>
<div class="col-xs-0 col-md-1"></div>
</div>
{% endblock %}

83
app/templates/base.html

@ -1,66 +1,53 @@ @@ -1,66 +1,53 @@
{% extends 'bootstrap/base.html' %}
{% block styles %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}">
{% endblock %}
{% block title %}
{% if title %}{{ title }} - The Hunt{% else %}Welcome to The Hunt{% endif %}
{% endblock %}
{% block navbar %}
<nav class="navbar navbar-default">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse"
data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{ url_for('main.index') }}">The Hunt</a>
</div>
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul class="nav navbar-nav">
<li><a href="{{ url_for('main.index') }}">Home</a></li>
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('game.create_game') }}">Create Game</a></li>
{% endif %}
</ul>
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">Login</a></li>
{% else %}
<li><a href="{{ url_for('main.user_profile', username=current_user.name) }}">
<div class="hidden-xs hidden-sm">
{{ current_user.name }}{% if game is defined %}/{{ game.name }}{% endif %}</div>
</a></li>
<li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
{% endif %}
</ul>
</div>
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{ url_for('main.index') }}">The Hunt</a>
</div>
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul class="nav navbar-nav">
<li><a href="{{ url_for('main.index') }}">Home</a></li>
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('main.create_game') }}">Create Game</a></li>
{% endif %}
</ul>
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">Login</a></li>
{% else %}
<li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
{% endblock %}
{% block content %}
<div class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-info" role="alert">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-info" role="alert">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{# application content needs to be provided in the app_content block #}
{% block app_content %}{% endblock %}
{# application content needs to be provided in the app_content block #}
{% block app_content %}{% endblock %}
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{% endblock %}

19
app/templates/catch_bunny.html

@ -1,19 +0,0 @@ @@ -1,19 +0,0 @@
{% extends 'player_base.html' %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block player_app_content %}
<div class="row">
<div class="col-xs-0 col-md-1"></div>
<div class="col-xs-8 col-md-4">
<h1>Catch Bunny</h1>
<form action="" method="post" class="form" role="form" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ wtf.form_field(form.bunny) }}
{{ wtf.form_field(form.photo) }}
{{ wtf.form_field(form.submit, class='btn btn-primary') }}
<a href="{{ url_for('main.game_dashboard', game_name=game.name) }}"><button type="button" class="btn btn-warning">Back</button></a>
</form>
</div>
<div class="col-xs-0 col-md-7"></div>
</div>
{% endblock %}

47
app/templates/game_settings/create_game.html → app/templates/create_game.html

@ -4,11 +4,10 @@ @@ -4,11 +4,10 @@
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/bootstrap/bootstrap-datetimepicker.min.css') }}">
<script src="{{ url_for('static', filename='assets/utils.js') }}"></script>
{% endblock %}
{% block app_content %}
<h1>{{ title }}</h1>
<h1>Create a new game</h1>
<div class="col-md-4 col-sm-6 col-xs-8">
<hr>
@ -51,19 +50,49 @@ @@ -51,19 +50,49 @@
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.47/js/bootstrap-datetimepicker.min.js"></script>
<script type="text/javascript">
$(function () {
// Datetime pickers
$('#timezone')[0].value = moment.tz.guess();
var date = moment()
createDateTimePicker('#datetimepicker_start', "#start_time_disabled", date)
date.add(1, 'hour');
createDateTimePicker('#datetimepicker_end', "#end_time_disabled", date)
var date = new Date()
$('#datetimepicker_start').datetimepicker({
//useCurrent: false, //Important! See issue #1075
locale: 'en-gb',
format: 'DD-MM-YYYY HH:mm',
keepInvalid: true,
sideBySide: true,
defaultDate: date,
timeZone: moment.tz.guess()
});
date.setDate(date.getDate() + 1)
$('#datetimepicker_end').datetimepicker({
//useCurrent: false, //Important! See issue #1075
locale: 'en-gb',
format: 'DD-MM-YYYY HH:mm',
keepInvalid: true,
sideBySide: true,
defaultDate: date,
timeZone: moment.tz.guess()
});
$("#datetimepicker_start").on("dp.change", function (e) {
$('#datetimepicker_end').data("DateTimePicker").minDate(e.date);
});
$("#datetimepicker_end").on("dp.change", function (e) {
$('#datetimepicker_start').data("DateTimePicker").maxDate(e.date);
});
$("#start_time_disabled").change(function() {
updateDateTimePicker('#datetimepicker_start', '#start_time_disabled')
});
$("#end_time_disabled").change(function() {
updateDateTimePicker('#datetimepicker_end', '#end_time_disabled')
});
function updateDateTimePicker(picker, checkbox){
if ($(checkbox).prop("checked")) {
$(picker).data("DateTimePicker").disable();
}
else {
$(picker).data("DateTimePicker").enable();
}
}
updateDateTimePicker('#datetimepicker_start', '#start_time_disabled');
updateDateTimePicker('#datetimepicker_end', '#end_time_disabled');
$('#timezone')[0].value = moment.tz.guess();
});
</script>
{% endblock %}

82
app/templates/game_bunny_dashboard.html

@ -1,82 +0,0 @@ @@ -1,82 +0,0 @@
{% extends 'player_base.html' %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/leaflet/leaflet.css') }}" />
<script src="{{ url_for('static', filename='assets/leaflet/leaflet.js') }}"></script>
{% endblock %}
{% block player_app_content %}
<div class="row">
<div class="col-xs-0 col-md-1"></div>
<div class="col-xs-12 col-md-7">
<h2>Objective Locations:</h2>
{% if game.objectives %}
<div class="table">
<table class="table">
<thead>
<tr>
<th scope="col">Objective Name</th>
<th scope="col">Latitude</th>
<th scope="col">Longitude</th>
<th scope="col">Found</th>
</tr>
</thead>
<tbody>
{% for objective in game.objectives %}
<tr>
<td>{{ objective.name }}</td>
<td>{{ objective.latitude }}</td>
<td>{{ objective.longitude }}</td>
<td>{{ 'Yes' if objective in current_user.player_in(game).found_objectives else 'No' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<div class="col-xs-12 col-md-3">
{% include '_game_player_info.html' %}
</div>
</div>
{% if game.objectives %}
<div class="row">
<div class="col-xs-1 col-md-1"></div>
<div id="map" style=" height: 500px; border-radius: 10px; " class="col-xs-10 col-md-10"></div>
<div class="col-xs-1 col-md-1"></div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='assets/leaflet/utils.js') }}"></script>
<script type="text/javascript", crossorigin="anonymous">
// Leaflet Map
map = getMap()
markers = []
var objectives = JSON.parse('{{ current_user.player_in(game).encode_objectives() |safe }}')
for (var i = 0; i < objectives.length; i++){
markers.push(addObjectiveMarker(map, objectives[i]))
}
function updateSelf() {
getPosition(updateMyLocation)
}
setInterval(updateSelf, 10 * 1000);
updateSelf()
if (markers.length > 1) {
map.fitBounds(markers.map(m => m.getLatLng()));
} else if (markers.length == 1){
map.setView(markers[0].getLatLng(), 10);
}
</script>
{% endblock %}

137
app/templates/game_dashboard.html

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
{% extends 'base.html' %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/leaflet/leaflet.css') }}" />
<script src="{{ url_for('static', filename='assets/leaflet/leaflet.js') }}"></script>
{% endblock %}
{% block app_content %}
<h1>{{ game.name }} Dashboard</h1>
<h2>Players:</h2>
<p><a href="{{ url_for('main.add_player', game_name = game.name) }}">Add player</a></p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col">Player Name</th>
<th scope="col">Role</th>
<th scope="col">Objectives found</th>
<th scope="col">Bunnies Caught</th>
<th scope="col">Been Caught</th>
<th scope="col">Last location</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for player in game.players %}
<tr>
<td><a href="{{ url_for('main.game_player', game_name = game.name, player_name = player.name) }}">{{ player.name }}</a></td>
{% for gameplayer in player.player_games if gameplayer.game == game %}
<td>{{ gameplayer.role.name }}</td>
{% endfor %}
<td>{{ player.found_objectives | selectattr('game', '==', game)|list|length}}</td>
<td>{{ player.caught_players | selectattr('game', '==', game)|list|length}}</td>
<td>{{ player.caught_by_players | selectattr('game', '==', game)|list|length}}</td>
<td>{% with location = player.last_location(game) %}
{% if location %}{{ moment(location.timestamp).fromNow()}}: {% endif %}
{{ location }}
{% endwith %}</td>
<td><a href="{{ url_for('main.remove_player', game_name=game.name, player_name=player.name) }}">
<button class="btn btn-danger">Delete</button></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2>Objectives:</h2>
<p><a href="{{ url_for('main.add_objective', game_name = game.name) }}">Add new objective</a></p>
{% if game.objectives %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col">Objective Name</th>
<th scope="col">Latitude</th>
<th scope="col">Longitude</th>
<th scope="col">Times found</th>
<th scope="col">Hash</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for objective in game.objectives %}
<tr>
<td>{{ objective.name }}</td>
<td>{{ objective.latitude }}</td>
<td>{{ objective.longitude }}</td>
<td>{{ objective.found_by|length }}</td>
<td><a href="{{ url_for('main.objective', objective_hash = objective.hash) }}">{{ objective.hash }}</a></td>
<td><a href="{{ url_for('main.delete_objective', objective_hash = objective.hash) }}">
<button class="btn btn-danger">Delete</button></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="map" style=" height: 500px; border-radius: 10px; " class="col-md-6 col-xs-12"></div>
{% endif %}
{% endblock %}
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
<script type="text/javascript", crossorigin="anonymous">
// Leaflet Map
var map = L.map( 'map', {
center: [52.2, 5.3],
minZoom: 6,
maxZoom: 19,
bounds: [[50.5, 3.25], [54, 7.6]],
zoom: 8
});
L.control.scale().addTo(map);
L.tileLayer( 'https://geodata.nationaalgeoregister.nl/tiles/service/wmts/brtachtergrondkaartpastel/EPSG:3857/{z}/{x}/{y}.png', {
attribution: 'Kaartgegevens &copy; <a href="kadaster.nl">Kadaster</a>'
}).addTo( map );
var objectives = JSON.parse('{{ json.dumps(game.objectives, cls=objective_encoder)|safe }}')
for (var i = 0; i < objectives.length; i++){
var objectiveMarker = L.marker([
objectives[i]['latitude'],
objectives[i]['longitude']
]).addTo(map);
objectiveMarker.bindTooltip(`<b>${objectives[i]['name']}</b><br>
${objectives[i]['hash']}`).openPopup();
}
var greenIcon = new L.Icon({
iconUrl: "{{ url_for('static', filename='assets/leaflet/images/marker-icon-2x-green.png') }}",
shadowUrl: "{{ url_for('static', filename='assets/leaflet/images/marker-shadow.png') }}",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
var players = JSON.parse('{{ json.dumps(game.last_player_locations(), cls=location_encoder)|safe }}')
for (var i = 0; i < players.length; i++){
var playerMarker = L.marker([
players[i]['latitude'],
players[i]['longitude']
], {icon: greenIcon}).addTo(map);
var timestamp_utc = moment.utc(players[i]['timestamp_utc']).toDate()
var timestamp_local = moment(timestamp_utc).local().format('YYYY-MM-DD HH:mm');
playerMarker.bindTooltip(`<b>${players[i]['player_name']}</b><br>
${timestamp_local}`).openPopup();
}
</script>
{% endblock %}

141
app/templates/game_hunter_dashboard.html

@ -1,141 +0,0 @@ @@ -1,141 +0,0 @@
{% extends 'player_base.html' %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/leaflet/leaflet.css') }}" />
<script src="{{ url_for('static', filename='assets/leaflet/leaflet.js') }}"></script>
{% endblock %}
{% block player_app_content %}
<div class="row">
<div class="col-xs-0 col-md-1"></div>
<div class="col-xs-12 col-md-7">
<h2>Bunnies</h2>
<div class="table">
<table class="table">
<thead>
<tr>
<th scope="col">Player Name</th>
<th scope="col">Times Caught</th>
<th scope="col">Last location</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% set player = current_user.player_in(game) %}
{% for bunny in game.bunnies() %}
<tr>
<td>{{ bunny.user.name }}</td>
<td><span
style="color:green;">{{ bunny.player_caught_by_players | selectattr('catching_player', '==', player) | selectattr('review.name', '==', 'accepted') |list|length}}</span>
/
<span
style="color:red;">{{ bunny.player_caught_by_players | selectattr('catching_player', '==', player) | selectattr('review.name', '==', 'denied') |list|length}}</span>
/
<span
style="color:gray;">{{ bunny.player_caught_by_players | selectattr('catching_player', '==', player) | selectattr('review.name', '==', 'none') |list|length}}</span>
</td>
<td>
<p id='last_location_{{ bunny.user.name }}'>
{% with location = bunny.last_location(offset=hunter_delay) %}
{% if location %}{{ moment(location.timestamp).fromNow()}}
{% else %}
{{ location }}
{% endif %}
{% endwith %}
</p>
</td>
<td>
<a
href="{{ url_for('main.catch_bunny', game_name=game.name, bunny_name=bunny.user.name) }}">
<button class="btn btn-success btn-sm">Catch</button>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<span style="font-size: smaller;">
(<span style="color:green;">Accepted</span>/<span style="color:red;">Denied</span>/<span
style="color:gray;">Not
reviewed</span>)
</span>
</div>
</div>
<div class="col-xs-12 col-md-3">
{% include '_game_player_info.html' %}
</div>
</div>
<div class="row">
<div class="col-xs-1 col-md-1"></div>
<div id="map" style=" height: 500px; border-radius: 10px; " class="col-xs-10 col-md-10"></div>
<div class="col-xs-1 col-md-1"></div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='assets/leaflet/utils.js') }}"></script>
<script type="text/javascript" , crossorigin="anonymous">
// Leaflet Map
var map = getMap()
var bunnieMarkers = []
var bunnies = JSON.parse(
'{{ json.dumps(game.last_locations(game.bunnies(), offset=hunter_delay), cls=location_encoder)|safe }}')
updateBunnieMarkers()
if (bunnieMarkers.length > 1) {
map.fitBounds(bunnieMarkers.map(m => m.getLatLng()));
} else if (bunnieMarkers.length == 1){
map.setView(bunnieMarkers[0].getLatLng(), 10);
}
function updateBunnieMarkers(){
if(bunnieMarkers != undefined){
bunnieMarkers.forEach(function(marker){
marker.remove()
});
}
bunnieMarkers = []
for (var i = 0; i < bunnies.length; i++) {
bunnieMarkers.push(addPlayerMarker(map, bunnies[i], greenPlayerIcon))
// Update table lastlocation column
$('#last_location_' + bunnies[i].username)[0].innerHTML = toMomentLocal(bunnies[i].timestamp_utc).fromNow()
}
}
// Poll Locations
usernames = JSON.parse('{{ json.dumps(game.usernames())|safe }}').filter(name => name != '{{ current_user.name }}')
setInterval(function() {
pollLocations(
"{{ url_for('main.poll_locations', game_name=game.name) }}",
usernames,
'accumulative',
bunnies,
handleResponse
)
getPosition(updateMyLocation)
}, 30 * 1000);
function handleResponse(data){
data.forEach(function (location) {
bunnie = bunnies.filter(function (bunnie) {
return bunnie.username == location.username;
})[0];
if (new Date(location.timestamp_utc) > new Date(bunnie.timestamp_utc)){
//lastLocation = bunnie[bunnie.length-1] Not necesary because there is just one of each bunnie
if (bunnie.latitude == location.latitude && bunnie.longitude == location.longitude){
bunnie.timestamp_utc = location.timestamp_utc
} else{
bunnies = bunnies.filter(p => p !== bunnie)
bunnies.push(location)
}
}
});
updateBunnieMarkers()
}
</script>
{% endblock %}

183
app/templates/game_owner_dashboard.html

@ -1,183 +0,0 @@ @@ -1,183 +0,0 @@
{% extends 'base.html' %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/leaflet/leaflet.css') }}" />
<script src="{{ url_for('static', filename='assets/leaflet/leaflet.js') }}"></script>
<script src="{{ url_for('static', filename='assets/geolocation_utils.js') }}"></script>
{% endblock %}
{% block app_content %}
<meta name="csrf-token" content="{{ csrf_token() }}">
<h1>{{ game.name }} Dashboard</h1>
<a href="{{ url_for('game.change_game_settings', game_name=game.name) }}">
<button class="btn btn-primary">Change Game Settings</button>
</a>
{% if game.unreviewed_bunny_photos() %}
<a href="{{ url_for('main.review_caught_bunny_photos', game_name=game.name) }}">
<button class="btn btn-primary">Review Bunny Photos</button>
</a>
{% endif %}
<br><br>
<p><b>Start Time: </b>{% if game.start_time %}{{ moment(game.start_time).format('DD-MM-YYYY, HH:mm') }}{% else %}None{% endif %}</p>
<p><b>End Time: </b>{% if game.end_time %}{{ moment(game.end_time).format('DD-MM-YYYY, HH:mm') }}{% else %}None{% endif %}</p>
<p><b>State: </b>{{ game.get_state().name.title() }}</p>
<h2>Players:</h2>
<p><a href="{{ url_for('main.add_player', game_name = game.name) }}">Add player</a></p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col">Player Name</th>
<th scope="col">Role</th>
<th scope="col">Objectives found</th>
<th scope="col">Bunnies Caught</th>
<th scope="col">Been Caught</th>
<th scope="col">Last location</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for player in game.players %}
<tr>
<td><a href="{{ url_for('main.game_player', game_name = game.name, username = player.user.name) }}">{{ player.user.name }}</a></td>
<td>{{ player.role.name }}</td>
<td>{{ player.found_objectives | list | length }}</td>
<td>{{ player.accepted_caught_players() | list | length }}</td>
<td>{{ player.accepted_caught_by_players() | list | length }}</td>
<td>
<p id='last_location_{{ player.user.name }}'>
{% with location = player.last_location() %}
{% if location %}{{ moment(location.timestamp).fromNow()}}
{% else %}
{{ location }}
{% endif %}
{% endwith %}
</p>
</td>
<td><a href="{{ url_for('main.remove_player', game_name=game.name, username=player.user.name) }}">
<button class="btn btn-danger">Delete</button></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2>Objectives:</h2>
<p><a href="{{ url_for('main.add_objective', game_name = game.name) }}">Add new objective</a></p>
{% if game.objectives %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col">Objective Name</th>
<th scope="col">Latitude</th>
<th scope="col">Longitude</th>
<th scope="col">Times found</th>
<th scope="col">Hash</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for objective in game.objectives %}
<tr>
<td>{{ objective.name }}</td>
<td>{{ objective.latitude }}</td>
<td>{{ objective.longitude }}</td>
<td>{{ objective.found_by|length }}</td>
<td><a href="{{ url_for('main.objective', objective_hash = objective.hash) }}">{{ objective.hash }}</a></td>
<td><a href="{{ url_for('main.delete_objective', objective_hash = objective.hash) }}">
<button class="btn btn-danger">Delete</button></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="map" style=" height: 500px; border-radius: 10px; " class="col-md-6 col-xs-12"></div>
{% endif %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='assets/leaflet/utils.js') }}"></script>
<script type="text/javascript", crossorigin="anonymous">
// Leaflet Map
var map = getMap()
var objectiveMarkers = []
var playerMarkers = []
var objectives = JSON.parse('{{ json.dumps(game.objectives, cls=objective_encoder)|safe }}')
for (var i = 0; i < objectives.length; i++){
marker = addObjectiveMarker(map, objectives[i])
marker.on("click", function (e) {
var caller = e.target || e.srcElement;
window.location = "{{ url_for('main.objective', objective_hash = 'ObjectiveHash') }}".replace('ObjectiveHash', caller.hash);
});
objectiveMarkers.push(marker)
}
var players = JSON.parse('{{ json.dumps(game.last_player_locations(), cls=location_encoder)|safe }}')
updatePlayerMarkers()
if (objectiveMarkers.length + playerMarkers.length > 1) {
map.fitBounds(objectiveMarkers.concat(playerMarkers).map(m => m.getLatLng()));
}
getPosition(updateMyLocation);
function updatePlayerMarkers(){
if(playerMarkers != undefined){
playerMarkers.forEach(function(marker){
marker.remove()
});
}
playerMarkers = []
for (var i = 0; i < players.length; i++) {
marker = addPlayerMarker(map, players[i], goldPlayerIcon)
marker.on("click", function (e) {
var caller = e.target || e.srcElement;
window.location = "{{ url_for('main.game_player', game_name = game.name, username = 'Username') }}".replace('Username', caller.username);
});
playerMarkers.push(marker);
// Update table lastlocation column
$('#last_location_' + players[i].username)[0].innerHTML = toMomentLocal(players[i].timestamp_utc).fromNow();
}
}
// Poll Locations
usernames = JSON.parse('{{ json.dumps(game.usernames())|safe }}').filter(name => name != '{{ current_user.name }}')
setInterval(function() {
pollLocations(
"{{ url_for('main.poll_locations', game_name=game.name) }}",
usernames,
'accumulative',
players,
handleResponse
)
getPosition(updateMyLocation)
}, 30 * 1000);
function handleResponse(data){
data.forEach(function (location) {
player = players.filter(function (player) {
return player.username == location.username;
})[0];
if (new Date(location.timestamp_utc) > new Date(player.timestamp_utc)){
//lastLocation = player[player.length-1] Not necesary because there is just one of each player
if (player.latitude == location.latitude && player.longitude == location.longitude){
player.timestamp_utc = location.timestamp_utc
} else{
players = players.filter(p => p !== player)
players.push(location)
}
}
});
updatePlayerMarkers()
}
</script>
{% endblock %}

164
app/templates/game_player.html

@ -1,164 +0,0 @@ @@ -1,164 +0,0 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/leaflet/leaflet.css') }}" />
<script src="{{ url_for('static', filename='assets/leaflet/leaflet.js') }}"></script>
{% endblock %}
{% block app_content %}
<meta name="csrf-token" content="{{ csrf_token() }}">
<h1>Player: {{ player.user.name }}</h1>
<hr>
<div class="row">
<div class="col-md-4 col-sm-6 col-xs-8">
<div class="row">
{{ wtf.quick_form(form, button_map={'submit': 'primary'}) }}
</div>
{% if player.user.auth_hash and not player.user.password_hash %}
<div class="row">
<a href="{{ url_for('auth.user_hash_login', auth_hash=player.user.auth_hash) }}">
<img src="{{ url_for('auth.user_qrcode', auth_hash=player.user.auth_hash) }}" alt="qr_code_failed" width="80%" title="login code for {{ player.user.name }}">
</a>
</div>
{% elif not player.user.password_hash %}
<br>
<div class="row">
<a href="#" , id="generate_auth_hash">
<button class="btn btn-success">Generate Login Code</button></a>
</div>
{% endif %}
</div>
<div id="map" style=" height: 400px; border-radius: 10px; " class="col-md-6 col-xs-12"></div>
</div>
{% if player.caught_players %}
<div class="row">
<h2>Caught Players:</h2>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col">Player Name</th>
<th scope="col">Review</th>
<th scope="col">Time</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for pcp in player.player_caught_players %}
<tr>
<td><a
href="{{ url_for('main.game_player', game_name=player.game.name, username = pcp.caught_player.user.name) }}">{{ pcp.caught_player.user.name }}</a>
</td>
<td>{{ pcp.review.name.title() }}</td>
<td>{{ moment(pcp.timestamp).fromNow() }}</td>
<td><a href="{{ url_for('main.caught_bunny_photo',
game_name=player.game.name,
timestamp=pcp.timestamp.strftime('%Y%m%d%H%M%S'),
bunny_name=pcp.caught_player.user.name,
hunter_name=pcp.catching_player.user.name) }}">
<button class="btn btn-primary">Photo</button></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='assets/leaflet/utils.js') }}"></script>
<script type="text/javascript" , crossorigin="anonymous">
// Leaflet Map
map = getMap()
var locations = JSON.parse('{{ json.dumps(player.locations_during_game(), cls=location_encoder)|safe }}')
if (locations == null) {
locations = [];
}
var markers = [];
var lastMarker;
updateMarkers();
var polyline;
updatePolyline();
// zoom the map to the polyline
if(polyline != undefined){
map.fitBounds(polyline.getBounds(), {
maxZoom : 13
});
}
function updateMarkers(){
if(markers != undefined){
markers.forEach(function(marker){
marker.remove()
});
markers = []
}
if(locations.length == 0){ return }
for (var i = 0; i < locations.length -1; i++) {
markers.push(addPlayerMarker(map, locations[i], bluePlayerIconMini))
}
if(lastMarker != undefined){
lastMarker.remove()
}
lastMarker = addPlayerMarker(map, locations[locations.length-1], bluePlayerIcon)
}
function updatePolyline(){
if(polyline != undefined){
map.removeLayer(polyline)
}
if (locations.length == 0) { return }
polyline = L.polyline(locations.map(l => [l.latitude, l.longitude]), {
color: 'blue',
opacity: 0.6,
}).addTo(map);
}
// Poll Locations
setInterval(function() {
pollLocations(
"{{ url_for('main.poll_locations', game_name=player.game.name) }}",
['{{ player.user.name }}'],
'accumulative',
locations,
handleResponse
)}, 30 * 1000);
function handleResponse(data){
data.forEach(function (location) {
if (location.username == '{{ player.user.name }}' && new Date(location.timestamp_utc) > get_latest_date(locations) ){
lastLocation = locations[locations.length-1]
if (lastLocation.latitude == location.latitude && lastLocation.longitude == location.longitude){
lastLocation.timestamp_utc = location.timestamp_utc
} else{
locations.push(location)
}
}
});
updatePolyline()
updateMarkers()
}
//Ajax for Generate Code button
$(function () {
$('a#generate_auth_hash').bind('click', function () {
$.ajax({
url: "{{ url_for('auth.generate_auth_hash', username=player.user.name) }}",
success: function (result) {
location.reload();
}
});
});
});
</script>
{% endblock %}

96
app/templates/game_settings/edit_game.html

@ -1,96 +0,0 @@ @@ -1,96 +0,0 @@
{% extends 'base.html' %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/bootstrap/bootstrap-datetimepicker.min.css') }}">
<script src="{{ url_for('static', filename='assets/utils.js') }}"></script>
{% endblock %}
{% block app_content %}
<h1>{{ title }}</h1>
<div class="col-md-5 col-sm-6 col-xs-8">
<hr>
<form action="" method="post" class="form" role="form">
{{ form.hidden_tag() }}
{{ form.timezone }}
{{ wtf.form_field(form.game_name, class='form-control') }}
{{ form.start_time.label }}
<div class="form-group row">
<div class="col-sm-7">
{{ form.start_time }}
</div>
<div class="col-sm-5 align-self-center">
{{ wtf.form_field(form.start_time_disabled, class='form-control') }}
</div>
</div>
{{ form.end_time.label }}
<div class="form-group row">
<div class="col-sm-7">
{{ form.end_time }}
</div>
<div class="col-sm-5 align-self-center">
{{ wtf.form_field(form.end_time_disabled, class='form-control') }}
</div>
</div>
{{ wtf.form_field(form.submit, class='btn btn-primary', value="Update") }}
</form>
<hr>
{% if game.hidden %}
<a href="{{ url_for('game.publish_game', game_name=game.name) }}">
<button class="btn btn-success">Publish Game</button>
</a>
{% else %}
<a href="{{ url_for('game.hide_game', game_name=game.name) }}">
<button class="btn btn-success">Hide Game</button>
</a>
{% endif %}
{% if game.paused %}
<a href="{{ url_for('game.resume_game', game_name=game.name) }}">
<button class="btn btn-primary">Resume Game</button>
</a>
{% else %}
<a href="{{ url_for('game.pause_game', game_name=game.name) }}">
<button class="btn btn-primary">Pause Game</button>
</a>
{% endif %}
<button class="btn btn-danger" onclick="deleteGame()">Delete Game</button>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<!-- TODO: Scripts downloaden naar repo? -->
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment-with-locales.min.js"></script>
<script type="text/javascript" src="https://momentjs.com/downloads/moment-timezone-with-data.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.47/js/bootstrap-datetimepicker.min.js"></script>
<script type="text/javascript">
// Datetime pickers
$(function () {
$('#timezone')[0].value = moment.tz.guess();
var date = moment()
createDateTimePicker('#datetimepicker_start', "#start_time_disabled", date)
date.add(1, 'hour');
createDateTimePicker('#datetimepicker_end', "#end_time_disabled", date)
$("#datetimepicker_start").on("dp.change", function (e) {
$('#datetimepicker_end').data("DateTimePicker").minDate(e.date);
});
$("#datetimepicker_end").on("dp.change", function (e) {
$('#datetimepicker_start').data("DateTimePicker").maxDate(e.date);
});
});
// Delete Game button
function deleteGame() {
if (confirm("Are you sure you want to delete this game?")) {
window.location.href = "{{ url_for('game.delete_game', game_name=game.name) }}"
}
}
</script>
{% endblock %}

24
app/templates/index.html

@ -1,19 +1,11 @@ @@ -1,19 +1,11 @@
{% extends 'base.html' %}
{% block app_content %}
<div class="row">
<div class="col-xs-0 col-md-1"></div>
<div class="col-xs-12 col-md-10">
<h2>My games:</h2>
</div>
<div class="col-xs-0 col-md-1"></div>
</div>
<h1>Hi, {{ current_user.name }}!</h1>
<h2>My games:</h2>
{% if current_user.games %}
<div class="row">
<div class="col-xs-0 col-md-1"></div>
<div class="col-xs-12 col-md-10">
<div class="table">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
@ -28,12 +20,12 @@ @@ -28,12 +20,12 @@
{% for game in current_user.games %}
<tr>
<td><a href="{{ url_for('main.game_dashboard', game_name = game.name) }}">{{ game.name }}</a></td>
<td>{{ game.get_state().name.title() }}</td>
<td>{{ game.state.name}}</td>
<td>{{ game.start_time }}</td>
<td>{{ game.end_time }}</td>
<td>
{% for gameplayer in current_user.user_games if gameplayer.game == game %}
{{ gameplayer.role.name.title() }}
{% for gameplayer in current_user.player_games if gameplayer.game == game %}
{{ gameplayer.role.name }}
{% endfor %}
</td>
</tr>
@ -41,10 +33,6 @@ @@ -41,10 +33,6 @@
</tbody>
</table>
</div>
</div>
<div class="col-xs-0 col-md-1"></div>
</div>
{% else %}
<div class="alert alert-info" role="alert">
You don't participate in any game yet 😢

41
app/templates/objective.html

@ -27,12 +27,10 @@ @@ -27,12 +27,10 @@
{{ wtf.form_field(form.objective_name, value=objective.name, class='form-control') }}
{{ wtf.form_field(form.latitude, class='form-control', type='number', value=objective.latitude, min='-90', max='90', step='0.00001') }}
{{ wtf.form_field(form.longitude, class='form-control', type='number', value=objective.longitude, min='-180', max='181', step='0.00001') }}
{% if objective.hash %}
<div class="form-group">
<label for="hash">Hash</label>
<p>{{ objective.hash }}</p>
</div>
{% endif %}
{{ wtf.form_field(form.submit, class='btn btn-primary') }}
</form>
{% if objective.hash %}
@ -42,9 +40,7 @@ @@ -42,9 +40,7 @@
</div>
{% if objective.hash %}
<div class="row">
<a href="{{ url_for('main.objective', objective_hash=objective.hash) }}">
<img src="{{ url_for('main.objective_qrcode', objective_hash=objective.hash) }}" alt="qr_code_failed", width="100%">
</a>
<img src="{{ url_for('main.objective_qrcode', objective_hash=objective.hash) }}" alt="qr_code_failed", width="100%">
</div>
{% endif %}
{% else %}
@ -61,21 +57,20 @@ @@ -61,21 +57,20 @@
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='assets/leaflet/utils.js') }}"></script>
<script type="text/javascript", crossorigin="anonymous">
// Leaflet Map
map = getMap()
var otherObjectiveMarkers = []
var otherObjectives = JSON.parse('{{ json.dumps(game.objectives, cls=objective_encoder)|safe }}')
for (var i = 0; i < otherObjectives.length; i++){
objective = otherObjectives[i]
if(objective.hash == '{{ objective.hash }}'){continue;}
var map = L.map( 'map', {
center: ['{{ objective.latitude }}', '{{ objective.longitude }}'],
minZoom: 6,
maxZoom: 19,
bounds: [[50.5, 3.25], [54, 7.6]],
zoom: 10
});
L.control.scale().addTo(map);
otherObjectiveMarkers.push(addObjectiveMarker(map, objective, greenIcon))
}
L.tileLayer( 'https://geodata.nationaalgeoregister.nl/tiles/service/wmts/brtachtergrondkaartpastel/EPSG:3857/{z}/{x}/{y}.png', {
attribution: 'Kaartgegevens &copy; <a href="kadaster.nl">Kadaster</a>'
}).addTo( map );
if ('{{ owner }}' == 'True'){
var objectiveMarker = L.marker([
@ -90,19 +85,8 @@ @@ -90,19 +85,8 @@
'{{ objective.longitude }}'
])
}
'{% if objective.hash %}'
objectiveMarker.bindTooltip(`<b>{{ objective.name }}</b>`).openPopup();
'{% else %}'
objectiveMarker.bindTooltip('New Objective').openPopup();
'{% endif %}'
objectiveMarker.addTo(map);
if (otherObjectiveMarkers.length + 1 > 0) {
map.fitBounds(otherObjectiveMarkers.concat([objectiveMarker]).map(m => m.getLatLng()));
} else{
map.setView(objectiveMarker.getLatLng(), 10);
}
var round = function(value){
return (Math.round(value * 100000) / 100000).toFixed(5);
};
@ -123,6 +107,5 @@ @@ -123,6 +107,5 @@
objectiveMarker.setLatLng(newLatLng);
});
</script>
{% endblock %}

88
app/templates/player.html

@ -0,0 +1,88 @@ @@ -0,0 +1,88 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/leaflet/leaflet.css') }}" />
<script src="{{ url_for('static', filename='assets/leaflet/leaflet.js') }}"></script>
{% endblock %}
{% block app_content %}
<h1>Player: {{ player.name }}</h1>
<hr>
<div class="row">
<div class="col-md-4 col-sm-6 col-xs-8">
<div class="row">
<form action="" method="post" class="form" role="form">
{{ form.hidden_tag() }}
{% for gameplayer in player.player_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') }}
</form>
</div>
{% if player.auth_hash %}
<div class="row">
<img src="{{ url_for('main.player_qrcode', auth_hash=player.auth_hash) }}" alt="qr_code_failed", width="100%">
</div>
{% endif %}
</div>
<div id="map" style=" height: 600px; border-radius: 10px; " class="col-md-6 col-xs-12"></div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
<script type="text/javascript", crossorigin="anonymous">
// Leaflet Map
'{% set last_location = player.last_location(game) %}'
var map = L.map( 'map', {
center: ['{{ last_location.latitude or 52.2 }}', '{{ last_location.longitude or 5.3 }}'],
minZoom: 6,
maxZoom: 19,
bounds: [[50.5, 3.25], [54, 7.6]],
zoom: 9
});
L.control.scale().addTo(map);
L.tileLayer( 'https://geodata.nationaalgeoregister.nl/tiles/service/wmts/brtachtergrondkaartpastel/EPSG:3857/{z}/{x}/{y}.png', {
attribution: 'Kaartgegevens &copy; <a href="kadaster.nl">Kadaster</a>'
}).addTo( map );
var greenIcon = new L.Icon({
iconUrl: "{{ url_for('static', filename='assets/leaflet/images/marker-icon-2x-green.png') }}",
shadowUrl: "{{ url_for('static', filename='assets/leaflet/images/marker-shadow.png') }}",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
var locations = JSON.parse('{{ json.dumps(player.locations, cls=location_encoder)|safe }}')
for (var i = 0; i < locations.length; i++){
var playerMarker = L.marker([
locations[i]['latitude'],
locations[i]['longitude']
], {icon: greenIcon}).addTo(map);
var timestamp_utc = moment.utc(locations[i]['timestamp_utc']).toDate()
var timestamp_local = moment(timestamp_utc).local().format('YYYY-MM-DD HH:mm');
playerMarker.bindTooltip(`<b>${locations[i]['player_name']}</b><br>
${timestamp_local}`).openPopup();
}
var latlngs = [
[[45.51, -122.68],
[37.77, -122.43],
[34.04, -118.2]],
[[40.78, -73.91],
[41.83, -87.62],
[32.76, -96.72]]
];
var polyline = L.polyline(locations.map(l => [l.latitude, l.longitude]), {color: 'red'}).addTo(map);
// zoom the map to the polyline
map.fitBounds(polyline.getBounds());
</script>
{% endblock %}

41
app/templates/player_base.html

@ -1,41 +0,0 @@ @@ -1,41 +0,0 @@
{% extends 'base.html' %}
{% block app_content %}
<meta name="csrf-token" content="{{ csrf_token() }}">
{% block player_app_content %}{% endblock %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='assets/geolocation_utils.js') }}"></script>
<script>
$(document).ready(getPosition(postCoordinates))
setInterval(function() {getPosition(postCoordinates)}, 65 * 1000);
function postCoordinates(position) {
$.ajax({
type: "POST",
url: "{{ url_for('main.send_location', username=current_user.name) }}",
data:{
lat: position.coords.latitude,
long: position.coords.longitude
},
error: function(error) {
console.log(error);
}
});
}
var csrftoken = $('meta[name=csrf-token]').attr('content')
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrftoken)
}
}
})
</script>
{% endblock %}

14
app/templates/review_caught_bunny_photos.html

@ -1,14 +0,0 @@ @@ -1,14 +0,0 @@
{% extends "base.html" %}
{% block app_content %}
<div class="col-md-1"></div>
<div class="col-md-10">
<h1>Bunny Photo Review</h1>
<hr>
{% for pcp in game.unreviewed_bunny_photos() %}
{% include '_review_photo.html' %}
<hr>
{% endfor %}
</div>
<div class="col.md-1"></div>
{% endblock %}

6
app/templates/user_profile.html

@ -1,6 +0,0 @@ @@ -1,6 +0,0 @@
{% extends 'base.html' %}
{% block app_content %}
<h1>My Profile</h1>
<a href="{{ url_for('auth.change_password') }}"><button class="btn btn-primary">Change Password</button></a>
{% endblock %}

62
app/utils.py

@ -1,62 +0,0 @@ @@ -1,62 +0,0 @@
import fnmatch
from os import listdir
from io import BytesIO
from pathlib import Path
import qrcode
from flask import send_file, flash, abort, current_app
from flask_login import current_user
from werkzeug.utils import secure_filename
from app.models import Game
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')
def serve_pil_image(pil_img):
# Source: https://stackoverflow.com/questions/7877282/how-to-send-image-generated-by-pil-to-browser
img_io = BytesIO()
pil_img.save(img_io, 'PNG', quality=70)
img_io.seek(0)
return send_file(img_io, mimetype='image/png')
def flash_errors(form):
"""Flashes form errors"""
print('a')
for field, errors in form.errors.items():
for error in errors:
flash(u"Error in the %s field - %s" % (
getattr(form, field).label.text,
error
), 'error')
def get_game_if_owner(game_name):
game = Game.query.filter_by(name=game_name).first_or_404()
if not game.owned_by(current_user):
abort(403)
return 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)
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']

6
config.py

@ -20,9 +20,3 @@ class Config(object): @@ -20,9 +20,3 @@ 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'}
HUNTER_LOCATION_DELAY = os.environ.get('HUNTER_LOCATION_DELAY') or 5

1
documentation/database/database_schema1.4.1.drawio

@ -1 +0,0 @@ @@ -1 +0,0 @@
<mxfile host="office.sciuro.org" modified="2020-07-18T19:01:28.741Z" agent="Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0" etag="I9I46YIfR01vjbe-FLCA" version="12.8.6" type="device"><diagram id="C5RBs43oDa-KdzZeNtuy" name="Page-1">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</diagram></mxfile>

BIN
documentation/database/database_schema1.4.1.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

1
documentation/database/database_schema1.4.2.drawio

@ -1 +0,0 @@ @@ -1 +0,0 @@
<mxfile host="office.sciuro.org" modified="2020-07-18T22:23:35.254Z" agent="Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0" etag="b1UJ2q3xYko4xSObHrmu" version="12.8.6" type="device"><diagram id="C5RBs43oDa-KdzZeNtuy" name="Page-1">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</diagram></mxfile>

BIN
documentation/database/database_schema1.4.2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

1
documentation/database/database_schema1.4.3.drawio

@ -1 +0,0 @@ @@ -1 +0,0 @@
<mxfile host="office.sciuro.org" modified="2020-07-21T20:35:49.343Z" agent="Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0" etag="G6lIIClnw-Nzaj8fEytL" version="12.8.6" type="device"><diagram id="C5RBs43oDa-KdzZeNtuy" name="Page-1">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</diagram></mxfile>

BIN
documentation/database/database_schema1.4.3.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

1
documentation/database/entity_relationship_diagram_v1.4.1.drawio

@ -1 +0,0 @@ @@ -1 +0,0 @@
<mxfile host="office.sciuro.org" modified="2020-07-18T18:44:20.958Z" agent="Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0" etag="xqK6JFdlF4PMrt82lYA8" version="12.8.6" type="device"><diagram id="C5RBs43oDa-KdzZeNtuy" name="Page-1">7V1bd9q6Ev41rHXOQ7Js+cpjQpOmp2129k5y0jwaLMCtsaltAvTXbxkssCWBL/gisLPygIWQbc3MNxfNSD1pMFt99oz59LtrQrsHBHPVkz71ABBlAHrhv2Cuty2arG0bJp5lRp32Dc/WHxg1ClHrwjKhn+gYuK4dWPNk48h1HDgKEm2G57nLZLexayfvOjcmkGp4Hhk23fpmmcF026oDbd/+AK3JFN9ZVPvbb2YG7hy9iT81THcZa5LuetLAc91g+2m2GkA7nDw8L29f1m/2t1/q5//97f82Xm+/vjz+/2o72H2en+xewYNOUHjo4PFh9ddv8CD9Wf141O8fLN9fX0nboT8MexHNV/SuwRpPIDTRfEaXrhdM3YnrGPbdvvXWcxeOCcPbCOhq3+eb685Ro4gaf8IgWEfMYSwCFzVNg5kdfQsd8yYkNbp0XAduW+4t246GHLtOcG/MLDvkwYG78CzooYd8hMvoy2hkUQ5/urKCH+EPr5Xo6h3fBn3+tIrG3Fyso4uMExwRwkdPMIJHZhXzueFNYHCkXzTZ4QzHmDUi32fozmDgrVEHD9pGYH0kOdqIBGOy67cnPvoQ0T8HL4g0M3w/e27wERECYrhNW2zAn4vZHL/Trovn/oID13a9zXtLwuZv8yxormP8FV7uGCy82HPY5mod47cfsc/vcT7kiSlFvWyujH765FroRYAQqRZZiH4SKRYgq8khtk8a/WrP24iYxjrWbR528HPcRxcIUdmOuBec3TueIEuUKH02ZvDJNtYhrxIylZSY5dQK4PPc2BBziZRyUjrGiG9jfGkaUB+PWByrjnQ4HOeVl3xM9wG9AK6Ockn0rQLYVBCWe6UsClHbNKaQZaEquFMpGn1zR4ijXaeVFBKVBIEksd80gQBTiFpJHElJio+IQa0x6sgyRZ1HN7DGVotFSOeOSAJFh7wGHMM4KmLTFTDKD1lae9uKbWllMfnSbMzD9l+FjJZuwskZbTipz5VnIdNI/kgxJpKxIMk0hm1NHPR5hGYPTbJ0G0oiwhf7JvpiZpnmlmWhb/0xhpuhQiJFNiEaV7ntKZ9Qi20MoX1rjH5NNuwdo+x488cg3lFK05Q9KoAUhuwCHtFT9+IxBRa2XAnXQCV0dDStRQ1x3MUdj30YUFQuwQxWFM7wR4yhzx6L0vAngT57MOrw5yDdZa7wR9c5Y0PhWiuiBzs+zMmHusYVH4q0zfzX8CdSAuGd22gwq33eLGbaUqGhw7atuQ/TiWL48+2KxthahYQkqYTMDjBiUslUh6qi8kIlhXA+JZUODch1EqmfgUjNx6l3uL0LDWfD7SaRNSuwlr5ywY7dqjLBeUo/OURJMWJNJO6j9wkmrSJGLFBs/MqnV3QCNx6V35OdIuFaEHWQIJ12mk+E423JQUViaaI6j4m2EHpAtUMWMK0P9HESfrxdOM4at6PbxL7qzIhQ8UhNx661DKLNk4rK6Vo0qaKUklXUPr6iKIpaCnooQj1qS5ZyqK064z44R4gbh7uL+9QhcngFnxN3W6b9hLaEnbH+KSXsnAw6Y67hOOpMkf1hsSFlG20jcuW4VtsIyl8XgxvzVlYHT+j1tR8A3OxQp15LqEZFkAbyJaMzTtiKwzNz4gWu0Bk/dkxMN/lRJmq7XdOyOnVnw4WfQU7zSB0p1ArUTZkl1DoYSqpapZyqRCgUJxPE5FRniKlaWfpNFu1Js3pRSrAzBI5nSBbNxaxBAI9mNKVKYIzmCoPmuO1UN4WIeu0yJfEQ2zen3BR6IJX0d4iBDvg7ZSl8wIik+dBzWNlimaP3p+AIFBGSaCwc6auaZFSJI6JE5PH1GfperHVJhU6GfTJ8f+l6Zuupw7TG6qUOoKhzs0CPAoQHw592BNIaJ1CWGp1jarikmELR5ay9YgfZcx6S9n5xfS3cv7w8fRXegzfhfRi8/f0+/PIV28HpEcSsBnM96lrEz7NjTYLlsqprEYcjo4EUuZrwJNDV5AOLNayqAdbiMLWCMkDMbViOn3UR5cKdD4VYZ+037HtItEFH+x7+1JiHH23L+UUiHjeR2DrjDsVh8qgkpbs1WbP96sFJMmkAFHVrqOykmt0aiTacGbGzc1D9OZi/MSbmyzcH/ZKYWJIbZmI6o+H0AHC3OHiCNJcsYThVJi5hx8xuTsLP+LHjq0ToJkAYQuiENwxaZwWKStLBkATa963XDKRd384MrNsMxLlv6UVnfGlQkajGxMkx+d3lfspAVWtQlTN92RVxnqQvs2bTiHxl00h0WsXAdT3TcoyA32WW7Td45ydQpfZMKk+gNB04lvireqt+afeCcUM7U9ygc6FfrBlERJnNuYWNmkBCanx1SebNGe+Mi5NAImu4i68CbTnL1nOXmal7CEKKZOoKqqol8OUKbxjBb7Jun7ctarpoYC0AhJ1oThAIPzcVDhyHrEax6KWHAjWygqnpUKDMmzPTwcRJdeZZg4p82SkKnZfQFjsFZ+uXULMNAPdWiUxvDjowFiHMtU0TKESNqQwa1gSKxJkmKBTWSmRStDeopYCseqD0oFahlEYV24lYGvqJlEa6MoLcmSDZv5oUSDyp8YT7qRu4/4EflgmdEfwvLUEXHndTdDlBB4W1u3GtgTeFzos5g+WUOfQs9P7QC5/AciYJcKljlUUn9hFn7hhWLyF580y6COpJGinzMgtnrgm9zNLKAh6VLDpgnDNQKz6ovAU48+ADR3IegyqQC6vKBojSM4pLPkhFJFOFSyrnkbSkZNVxjopCh2Uf21jVK5G1X42jGr1ix9tKcp4ILUc4VzJcqTWhkEQkD4vkEBVnSaqM80g4Y8g8ZTBFS244YmQiMbsivk6PHPFlp+Pnju8BPhotvHCt0WIcm3Ph4WVAKLamK09V3pKty1hnBGeEGmXDROZs60rUZG4bm9itDQjEoa5p/fs1GOUqvXNXS1ZBMTqUsQoq9olcrUgx8LsoqtIhpiImFmEXcGdxdRnnebGTMxNLP+eIWxeRL8yGOJmeEzZU6YKpZ7iBasQnbbfzRY2OYNVq6GvgEkBC7CBiQ0yxMU1VyMwna5OVGqx2PEfHN8b6Dn3fQHOTbV+sSw+7iyBpozcedtfkS0CtLg0ak1M6L9xSk5tj4hh/tbhFrzS9rJEDCe5f0aQ5I3qr8ItHJc7WAjW+M6AyZuQmQKm9Tr+WNf+JU0iS64CkrqT9IBqJauNwROeMPE/dpYMGi/b2+gcaZuvoBATi8PjGtx7X+TZmu0qOPHpDz2rKlp8WdxoT0ublc8BzCn5V+CCTG4U2DuQ6b2vxnV15Aj6c6SGCOh3FZ4TNYuZfFzgLMYHIS2i+3kpnLF63MZOYKqBqHuZpe7293pRO7hBWp+AsDcdejYLFw/Pd79fVk/x6N+5fnV5u3XDKbGr2W3XnNTInNOvxMxXVj+Q+ZJo8BUEsN8JybI6OK907xwyBolO5B1Ruw8ABGgGOffqMlit/plwQOBvhlkl1Eyd7avdktindHahHuvcOHE+/42fmo6Vt310WAtGr5c+hH7YFnJZDi1gjtBw7yThGnH9cu6MLqNNYZJ7xcXrEhvMt2bKpCQLVqz27kNlPPlGbnMQFdH0AXRvKRX1ASj1A5jT/JIEPS8aRcgCqGoCnPdKOsSJx6DJF6KR4pwExkbBqQH08YmGrOtLhMJWM7Jgmg6zZ5PPw2hp5ADYLiIU6gVg5PyAmtnVIC5WnhsFZ5afVAbNMA/PhOrOmgDlLAU/bgFk5KtnhrtrEDoiAd2CmK+G/bKqLTTQR1thCEr55CgqouSpBKB+kZYF0KOsrQWDSiY5706DN8emerDO6C+Nr+mHcB4hb13GcyUiGRDJF1sPEgEKcL4+3Uy377G3igWWl3I0nmQwtssK2Z8TRRZLaS5WC8o6uB01KiyQQ0qLp1/34X0HZIXI9Zaka2ZHEpMI/cdNWdOm5bhDvjlTq9LtrwrDHvw==</diagram></mxfile>

BIN
documentation/database/entity_relationship_diagram_v1.4.1.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

1
documentation/database/entity_relationship_diagram_v1.4.2.drawio

@ -1 +0,0 @@ @@ -1 +0,0 @@
<mxfile host="office.sciuro.org" modified="2020-07-18T22:25:24.207Z" agent="Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0" etag="wvjCfHbN-iwSZYwNJAo7" version="12.8.6" type="device"><diagram id="C5RBs43oDa-KdzZeNtuy" name="Page-1">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</diagram></mxfile>

BIN
documentation/database/entity_relationship_diagram_v1.4.2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

1
documentation/database/entity_relationship_diagram_v1.4.3.drawio

@ -1 +0,0 @@ @@ -1 +0,0 @@
<mxfile host="office.sciuro.org" modified="2020-07-21T20:32:55.492Z" agent="Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0" etag="UGGaq7Di0W6oZH4bH7XM" version="12.8.6" type="device"><diagram id="C5RBs43oDa-KdzZeNtuy" name="Page-1">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</diagram></mxfile>

BIN
documentation/database/entity_relationship_diagram_v1.4.3.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

6
documentation/gamestate.txt

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
Gamestate
This is an attribute of the Game class. It will be implemented as an enum containing the following values:
hidden = 1
initiated = 1
published = 2
active = 3
paused = 4
started = 3
interrupted = 4
finished = 5

61
migrations/versions/7a3183ce450a_replace_gamestate_with_hidden_and_paused.py → migrations/versions/3ef4c34115fd_updated_to_database_documentation_1_3.py

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
"""replace gamestate with hidden and paused
"""updated to database documentation 1.3
Revision ID: 7a3183ce450a
Revision ID: 3ef4c34115fd
Revises:
Create Date: 2020-07-29 10:55:07.589462
Create Date: 2020-07-09 14:43:14.149353
"""
from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa @@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7a3183ce450a'
revision = '3ef4c34115fd'
down_revision = None
branch_labels = None
depends_on = None
@ -21,40 +21,36 @@ def upgrade(): @@ -21,40 +21,36 @@ def upgrade():
op.create_table('game',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.Column('hidden', sa.Boolean(), server_default='1', nullable=False),
sa.Column('paused', sa.Boolean(), server_default='0', 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',
op.create_table('player',
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('player_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')
sa.ForeignKeyConstraint(['player_id'], ['player.id'], ),
sa.PrimaryKeyConstraint('game_id', 'player_id')
)
op.create_table('location',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('player_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.ForeignKeyConstraint(['player_id'], ['player.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('notification',
@ -77,31 +73,32 @@ def upgrade(): @@ -77,31 +73,32 @@ def upgrade():
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='0', 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.Column('review', sa.Enum('none', 'denied', 'accepted', name='review'), server_default='none', nullable=False),
sa.ForeignKeyConstraint(['catching_player_id'], ['game_player.id'], ),
sa.ForeignKeyConstraint(['caught_player_id'], ['game_player.id'], ),
sa.PrimaryKeyConstraint('id')
sa.ForeignKeyConstraint(['catching_player_id'], ['player.id'], ),
sa.ForeignKeyConstraint(['caught_player_id'], ['player.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('photo_reference')
)
op.create_table('notification_player',
sa.Column('notification_id', sa.Integer(), nullable=False),
sa.Column('player_id', sa.Integer(), nullable=False),
sa.Column('been_shown', sa.Boolean(), server_default='True', nullable=False),
sa.ForeignKeyConstraint(['notification_id'], ['notification.id'], ),
sa.ForeignKeyConstraint(['player_id'], ['player.id'], ),
sa.PrimaryKeyConstraint('notification_id', 'player_id')
)
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('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')
sa.ForeignKeyConstraint(['player_id'], ['player.id'], ),
sa.PrimaryKeyConstraint('objective_id', 'player_id')
)
# ### end Alembic commands ###
@ -109,13 +106,13 @@ def upgrade(): @@ -109,13 +106,13 @@ def upgrade():
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('player_caught_player')
op.drop_table('objective')
op.drop_table('notification')
op.drop_table('location')
op.drop_table('game_player')
op.drop_table('user')
op.drop_table('player')
op.drop_index(op.f('ix_game_name'), table_name='game')
op.drop_table('game')
# ### end Alembic commands ###

13
requirements.txt

@ -1,40 +1,27 @@ @@ -1,40 +1,27 @@
alembic==1.4.2
astroid==2.4.2
click==7.1.2
coverage==5.2
dnspython==2.0.0
dominate==2.5.1
Flask==1.1.2
Flask-Bootstrap==3.3.7.1
Flask-Login==0.5.0
Flask-Migrate==2.5.3
Flask-Moment==0.10.0
Flask-SQLAlchemy==2.4.3
Flask-WTF==0.14.3
is-disposable-email==1.0.0
isort==4.3.21
itsdangerous==1.1.0
Jinja2==2.11.2
lazy-object-proxy==1.4.3
Mako==1.1.3
MarkupSafe==1.1.1
mccabe==0.6.1
nose2==0.9.2
Pillow==7.2.0
pylint==2.5.3
pylint-flask-sqlalchemy==0.2.0
python-dateutil==2.8.1
python-dotenv==0.13.0
python-editor==1.0.4
pytz==2020.1
qrcode==6.1
rope==0.17.0
six==1.15.0
SQLAlchemy==1.3.18
toml==0.10.1
typed-ast==1.4.1
visitor==0.1.3
Werkzeug==1.0.1
wrapt==1.12.1
WTForms==2.3.1
wtforms-validators==1.0.0

47
the_hunt.py

@ -1,51 +1,12 @@ @@ -1,51 +1,12 @@
from app import create_app, db
from app.models import Game, User, Objective, Location, Notification, GamePlayer, \
PlayerFoundObjective, NotificationPlayer, PlayerCaughtPlayer, Role, GameState, Review
from app.models import Game, Player, Objective, Location, Notification, GamePlayer, \
PlayerFoundObjective, NotificationPlayer, PlayerCaughtPlayer, Role, GameState
app = create_app()
@app.shell_context_processor
def make_shell_context():
return {'db': db, 'Game' : Game, 'User' : User, 'Objective' : Objective,
return {'db': db, 'Game' : Game, 'Player' : Player, 'Objective' : Objective,
'Location' : Location, 'Notification' : Notification, 'GamePlayer' : GamePlayer,
'PlayerFoundObjective' : PlayerFoundObjective, 'NotificationPlayer' : NotificationPlayer,
'PlayerCaughtPlayer' : PlayerCaughtPlayer, 'Role' : Role, 'GameState' : GameState,
'Review' : Review, 'create_objects' : create_objects}
def create_objects():
g1 = Game(name='TestGame')
g2 = Game(name='MyGame')
u1 = User(name='Marijn')
u1.set_password('123')
u2 = User(name='Rogier')
u2.set_password('123')
u3 = User(name='Henk')
u4 = User(name='Emma')
u5 = User(name='Demi')
o1 = Objective(name='Florin', latitude=52.0932, longitude=5.12405)
o2 = Objective(name='Amsterdam', latitude=52.35547, longitude=4.87518)
o3 = Objective(name='Amersfoort', latitude=52.17056, longitude=5.39154)
o1.set_hash()
o2.set_hash()
o3.set_hash()
g1.players.append(GamePlayer(user=u1, role=Role.owner))
g1.players.append(GamePlayer(user=u2, role=Role.hunter))
g1.players.append(GamePlayer(user=u3, role=Role.hunter))
g1.players.append(GamePlayer(user=u4, role=Role.bunny))
g1.players.append(GamePlayer(user=u5, role=Role.bunny))
g1.objectives.append(o1)
g1.objectives.append(o2)
g1.objectives.append(o3)
g2.players.append(GamePlayer(user=u1, role=Role.bunny))
g2.players.append(GamePlayer(user=u2, role=Role.owner))
g2.players.append(GamePlayer(user=u3, role=Role.hunter))
db.session.add(g1)
db.session.add(g2)
db.session.commit()
'PlayerCaughtPlayer' : PlayerCaughtPlayer, 'Role' : Role, 'GameState' : GameState}
Loading…
Cancel
Save