Compare commits

...

116 Commits

Author SHA1 Message Date
Burathar 029f86f6c7 set new tileserver 3 years ago
Burathar 104126c7bf add tests and move methods from routes to models 4 years ago
Burathar ef29816a96 test if game objectives are deleted recursively 4 years ago
Burathar 1a176890c0 Make map markers hyperlinks (resole issue #33) 4 years ago
Burathar 9b8d818391 Change objective.html old markers to green, and add tooltip to objectivemarker 4 years ago
Burathar 271941ac85 Add exsisting markers to addObjective, fix map zooming on all pages 4 years ago
Burathar 1922464ca1 Resolve error when player with Role.none requests a dashboard 4 years ago
Burathar 2098b80a7b show a warning and return when a player is added twice to a game 4 years ago
Burathar 1c48c6942f Update dashboard tables when new locations get pulled 4 years ago
Burathar fc037da2e6 Actively fetch new locations and display own location on hunter dashboard 4 years ago
Burathar f11c05ff1f auto update own location on bunny and owner dashboard 4 years ago
Burathar 982a220bc2 add own location to owner dashboard 4 years ago
Burathar 1cad73b72b fix update marker when no playerlocation is registered. Fix situaton when no game endtime is defined 4 years ago
Burathar 8dda257a6f auto update players on map owner dashboard 4 years ago
Burathar 6df123372e change pollLocations timeout 4 years ago
Burathar 48b7431c74 make game_player map self-updating 4 years ago
Burathar cfa23156ae Merge branch 'master' of git.sciuro.org:Burathar/The-Hunt 4 years ago
Burathar 7b3755655d resole issue #19: make sure game keeps at least one owner 4 years ago
Burathar 4d2f85d288 move player routes to routes_player.py 4 years ago
Burathar 482bc7aeea move objective routes to separate file 4 years ago
Burathar 6a3f42ea09 Move game settings into separate blueprint 4 years ago
Burathar 0341543371 Remove database_dump from repo 4 years ago
Burathar 4089777f88 Implement hide/publish/pause/resume game, fix database boolean default 4 years ago
Burathar c5488d4aa3 rectify test objective longitudes 4 years ago
Burathar 3f9b3f0650 split create_game and edit_game. replace game state with hidden and paused in database (issue #34) 4 years ago
Burathar 7c6494cd5e remove state selectfield from create and modify game 4 years ago
Burathar c735463f95 change game.state references to game.get_state 4 years ago
Burathar 9f86805787 implement update_state and rename GameState.initiated to GameState.hidden 4 years ago
Burathar f53f5b948c Change GameState.started to GameState.active 4 years ago
Burathar 520847904e fix some map zooming issues 4 years ago
Burathar 010e31670b remove trailing whitespace 4 years ago
Burathar 7b6326dc46 fix player is null error on game_owner_dashboard 4 years ago
Burathar b36766cbac fix Create_Game/Change Game Settings timezone issues 4 years ago
Burathar 77c341f3f6 add debug lines 4 years ago
Burathar d9eb2f7ff9 stuff 4 years ago
Burathar 0a4b324e29 remove unused import 4 years ago
Burathar 46ea068900 change copy-pasted labels 4 years ago
Burathar 733c80e371 fix create game game exists coding error, fix timepicker default value 4 years ago
Burathar b4e508e020 enforce game start,end, and state, add been found to gameplayerinfo 4 years ago
Burathar a8f50d9e13 fix delete user 4 years ago
Burathar 2cd3ecd079 set person markers for players, style game_player polyline 4 years ago
Burathar c4e1785718 add person markers 4 years ago
Burathar f1c425d2a9 Merge branch 'master' of git.sciuro.org:Burathar/The-Hunt 4 years ago
Burathar 6121584d2f set map bounds 4 years ago
Rogier Neeleman 05efb236ce Give no location in tables 4 years ago
Burathar 62dcb62dfe fix offset None error 4 years ago
Burathar d35a40a415 add wtforms-validators to requerements 4 years ago
Burathar db9dfd0964 test javascript 4 years ago
Burathar 65ab689c3f Merge branch 'master' of git.sciuro.org:Burathar/The-Hunt 4 years ago
Burathar bf75b07418 hide bunny locations younger than 5 minutes from hunter dashboard 4 years ago
Rogier Neeleman 9e25d8c5e4 Upload picture has a better design. 4 years ago
Burathar db31f8e842 Add confirmation before deleting game 4 years ago
Burathar 326fa3e926 Add Delete Game Button 4 years ago
Burathar 27dbedeae8 fix error in last_locaion 4 years ago
Burathar bfe6d3d6ea set maxZoom to 18, because current tiles don't support zoomlvl 19 4 years ago
Burathar 00a6b9ded3 fix changePasswordForm 4 years ago
Burathar 1502d74973 add new files for set and change password 4 years ago
Burathar d22d07f935 implement set and change password 4 years ago
Burathar 76399f9750 dont ask for location on index page 4 years ago
Burathar 886a67e27c make it possible to get login qr code after first login, as long as password is not set. 4 years ago
Burathar d3beaec1a5 warn user when location permission is denied 4 years ago
Burathar d812afb59c Merge branch 'master' of git.sciuro.org:Burathar/The-Hunt 4 years ago
Burathar 4b03e1f7b7 console log geolocation errors 4 years ago
Rogier Neeleman 3bd6bda6e1 Update pages for mobile use 4 years ago
Rogier Neeleman 90109fabc6 Make the login pages a mobile aware page. 4 years ago
Rogier Neeleman eedc985a2e Put username and gamename in the navbar. Remove it on a mobile device. 4 years ago
Burathar 81bd506e1c fix change player role, dynamicly load addplayer roles 4 years ago
Burathar 5d58700661 implement player location update 4 years ago
Burathar 79f9d503bb insert player_base.html between base.html and player templates 4 years ago
Burathar 2bc24a47c2 implement photoreview 4 years ago
Burathar 599bd52047 update database to 1.4.3 4 years ago
Burathar 8ddc6d5d81 update documentation to database 1.4.3 4 years ago
Burathar 433820c6c8 update requirements.txt 4 years ago
Burathar 4161ae5d40 implement find objective 4 years ago
Burathar 021fee02ee remove unused import 4 years ago
Burathar 554b8d4ec5 do lost of things and work on bunny dashboard 4 years ago
Burathar 50961ff708 fix bunny_dashboard.html table 4 years ago
Burathar b290c8b55f clarify owned_by docstring 4 years ago
Burathar 2e5f1a2c2f move qr helpers to utils.py, implement user hash login 4 years ago
Burathar e09e73d340 fix player.last_location() 4 years ago
Burathar 713d4ddee3 implement hunter dashboard, inc catch bunny 4 years ago
Burathar bc71ae42a6 work on issue #15, app is not in functioning state 4 years ago
Burathar bdc61af997 fix Times Caught column 4 years ago
Burathar 8776302c2a filter None results from game.last_locations() 4 years ago
Burathar 7540cbf9db replace game.game_players with game.players 4 years ago
Burathar 5624f737bf fix users association 4 years ago
Burathar 70171fefa1 Add catch_bunny 4 years ago
Burathar 9b30c334b1 Replace game.game_player with game.player 4 years ago
Burathar 7ff8023442 Replace DataRequired with InputRequired, add CatchBunnyForm 4 years ago
Burathar f387cd5b9b rename game_players to players, add bunnies method 4 years ago
Burathar 88eb7194d7 Add location methods to game_player 4 years ago
Burathar 70942e82d8 update player table calculated values 4 years ago
Burathar 6c9e363dfc finally get player_catch_player to work... rip my life 4 years ago
Burathar 703af9b4e3 Resolve issue #17 and implement deletegame 4 years ago
Burathar ea4d5189cd resolve issue #20 4 years ago
Burathar b57d91d37c remove players section from game_bunny_dashboard 4 years ago
Burathar cecb664149 hide hash field if empty on objective.html 4 years ago
Burathar 182eebbc35 fix issues that arise from updating to database model 1.4 4 years ago
Burathar bbb5504dad make model modules itself import each other with relative imports 4 years ago
Burathar db33cf8f20 Replace relation between user and player_caught_player with game_player and player_caught_player 4 years ago
Burathar fea0d1dc2f Replace relation between user and player_found_objective with game_player and player_found_objective 4 years ago
Burathar 15c27bbfdc Replace relation between user and notification_player with game_player and notification_player 4 years ago
Burathar 6ae6cb2755 Add id to Game_Player 4 years ago
Burathar 7e4f2b67dc Rename Player table to User, split models into separate files 4 years ago
Burathar dd2fe9a207 Update database documentation to 1.4, see issue #22 4 years ago
Burathar 688b0432f3 reset migrations 4 years ago
Burathar 0a9e3d4539 add method to the_hunt.py to create testing objects in database 4 years ago
Burathar 4a6f6bcb0a fix kadaster copyright, work on issue #15 4 years ago
Burathar 3ac8ea660a add pylint ignore comment 4 years ago
Burathar 9acedfc2f5 fix renamed method role_in_game 4 years ago
Burathar be04fb68f8 fix redirects 4 years ago
Burathar 75d82025e0 Split game_dashboard into separate files per player type 4 years ago
Burathar a0474524a3 Move relevant tests to test_models.py 4 years ago
Burathar 8aea63ab90 Move owned_by methods to models file 4 years ago
Burathar d8948e5fe5 update requirements.txt 4 years ago
Burathar a7793b340c change import order 4 years ago
  1. 2
      .gitignore
  2. 12
      app/__init__.py
  3. 27
      app/auth/forms.py
  4. 111
      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. 282
      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. 18
      app/templates/auth/login.html
  44. 10
      app/templates/auth/register.html
  45. 20
      app/templates/auth/user_hash_login.html
  46. 19
      app/templates/base.html
  47. 19
      app/templates/catch_bunny.html
  48. 82
      app/templates/game_bunny_dashboard.html
  49. 137
      app/templates/game_dashboard.html
  50. 141
      app/templates/game_hunter_dashboard.html
  51. 183
      app/templates/game_owner_dashboard.html
  52. 164
      app/templates/game_player.html
  53. 47
      app/templates/game_settings/create_game.html
  54. 96
      app/templates/game_settings/edit_game.html
  55. 24
      app/templates/index.html
  56. 39
      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/7a3183ce450a_replace_gamestate_with_hidden_and_paused.py
  77. 13
      requirements.txt
  78. 47
      the_hunt.py

2
.gitignore vendored

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

12
app/__init__.py

@ -10,6 +10,7 @@ from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_login import LoginManager from flask_login import LoginManager
from flask_moment import Moment from flask_moment import Moment
from flask_wtf import CSRFProtect
from config import Config from config import Config
@ -20,6 +21,7 @@ login = LoginManager()
login.login_view = 'auth.login' login.login_view = 'auth.login'
login.login_message = 'Please log in to access this page.' login.login_message = 'Please log in to access this page.'
moment = Moment() moment = Moment()
csrf = CSRFProtect()
def create_app(config_class=Config): def create_app(config_class=Config):
# pylint: disable=no-member # pylint: disable=no-member
@ -31,6 +33,7 @@ def create_app(config_class=Config):
migrate.init_app(app, db) migrate.init_app(app, db)
login.init_app(app) login.init_app(app)
moment.init_app(app) moment.init_app(app)
csrf.init_app(app)
from app.errors import bp as errors_bp from app.errors import bp as errors_bp
app.register_blueprint(errors_bp) app.register_blueprint(errors_bp)
@ -38,6 +41,9 @@ def create_app(config_class=Config):
from app.auth import bp as auth_bp from app.auth import bp as auth_bp
app.register_blueprint(auth_bp, url_prefix='/auth') 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 from app.main import bp as main_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
@ -53,7 +59,7 @@ def create_app(config_class=Config):
mail_handler = SMTPHandler( mail_handler = SMTPHandler(
mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
fromaddr='no-reply@' + app.config['MAIL_SERVER'], fromaddr='no-reply@' + app.config['MAIL_SERVER'],
toaddrs=app.config['ADMINS'], subject='Microblog Failure', toaddrs=app.config['ADMINS'], subject='The-Hunt Failure',
credentials=auth, secure=secure) credentials=auth, secure=secure)
mail_handler.setLevel(logging.ERROR) mail_handler.setLevel(logging.ERROR)
app.logger.addHandler(mail_handler) app.logger.addHandler(mail_handler)
@ -65,7 +71,7 @@ def create_app(config_class=Config):
else: else:
if not os.path.exists('logs'): if not os.path.exists('logs'):
os.mkdir('logs') os.mkdir('logs')
file_handler = RotatingFileHandler('logs/microblog.log', file_handler = RotatingFileHandler('logs/the-hunt.log',
maxBytes=10240, backupCount=10) maxBytes=10240, backupCount=10)
file_handler.setFormatter(logging.Formatter( file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s ' '%(asctime)s %(levelname)s: %(message)s '
@ -74,7 +80,7 @@ def create_app(config_class=Config):
app.logger.addHandler(file_handler) app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO) app.logger.setLevel(logging.INFO)
app.logger.info('Microblog startup') app.logger.info('The-Hunt startup')
return app return app

27
app/auth/forms.py

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

111
app/auth/routes.py

@ -1,9 +1,12 @@
from flask import render_template, flash, redirect, url_for from datetime import datetime
from flask import render_template, flash, redirect, request, url_for, abort, Markup, escape
from flask_login import login_user, logout_user, current_user, login_required from flask_login import login_user, logout_user, current_user, login_required
from app import db from app import db
from app.auth import bp from app.auth import bp
from app.models import Player from app.utils import generate_qr_code, serve_pil_image
from app.auth.forms import LoginForm, RegistrationForm from app.models import User
from app.auth.forms import LoginForm, RegistrationForm, ChangePasswordForm, SetPasswordForm
@bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
def login(): def login():
@ -11,13 +14,17 @@ def login():
return redirect(url_for('main.index')) return redirect(url_for('main.index'))
form = LoginForm() form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
player = Player.query.filter_by(name=form.username.data).first() user = User.query.filter_by(name=form.username.data).first()
if player is None or not player.check_password(form.password.data): if user is None or not user.check_password(form.password.data):
flash('Invalid username or password') flash('Invalid username or password')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
login_user(player, remember=form.remember_me.data) 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')) return redirect(url_for('main.index'))
return render_template('login.html', title='Sign In', form=form)
@bp.route('/logout') @bp.route('/logout')
@login_required @login_required
@ -31,11 +38,91 @@ def register():
return redirect(url_for('main.index')) return redirect(url_for('main.index'))
form = RegistrationForm() form = RegistrationForm()
if form.validate_on_submit(): if form.validate_on_submit():
player = Player(name=form.username.data) user = User(name=form.username.data)
player.set_password(form.password.data) user.set_password(form.password.data)
player.set_auth_hash() user.set_auth_hash()
db.session.add(player) user.last_login = datetime.utcnow()
db.session.add(user)
db.session.commit() db.session.commit()
flash('Congratulations, you are now a registered user!') flash('Congratulations, you are now a registered user!')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
return render_template('register.html', title='Register', form=form) 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)

5
app/game_settings/__init__.py

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

45
app/game_settings/forms.py

@ -0,0 +1,45 @@
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

@ -0,0 +1,105 @@
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
bp = Blueprint('main', __name__) bp = Blueprint('main', __name__)
from app.main import routes from app.main import routes, routes_objective, routes_player

53
app/main/forms.py

@ -1,54 +1,41 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, DateTimeField, BooleanField, HiddenField, FloatField, SelectField from flask_wtf.file import FileField, FileAllowed, FileRequired
from wtforms.validators import DataRequired, ValidationError, Length, NumberRange from wtforms import StringField, SubmitField, FloatField, SelectField
from pytz import timezone from wtforms.validators import InputRequired, DataRequired, ValidationError, Length, NumberRange
from app.models import Objective 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): class ObjectiveForm(FlaskForm):
objective_name = StringField('Objective Name', validators=[Length(min=0, max=64)]) objective_name = StringField('Objective Name', validators=[Length(min=0, max=64)])
latitude = FloatField('Latitude', validators=[DataRequired(), NumberRange(min=-90, max=90)]) latitude = FloatField('Latitude', validators=[DataRequired(), NumberRange(min=-90, max=90)])
longitude = FloatField('Longitude', validators=[DataRequired(), NumberRange(min=-180, max=180)]) longitude = FloatField('Longitude', validators=[DataRequired(), NumberRange(min=-180, max=180)])
submit = SubmitField('Save') submit = SubmitField('Save')
old_name = ''
def validate_objective_name(self, objective_name): def validate_objective_name(self, objective_name):
if objective_name.data == '': return if objective_name.data == '':
return
if objective_name.data == self.old_name:
return
objective = Objective.query.filter_by(name=objective_name.data).first() objective = Objective.query.filter_by(name=objective_name.data).first()
if objective is not None: if objective is not None:
raise ValidationError('Please use a different name.') raise ValidationError('Please use a different name.')
class PlayerUpdateForm(FlaskForm): class PlayerUpdateForm(FlaskForm):
role = SelectField('Player Role', choices=[('none', 'none'), ('owner', 'owner'), ('hunter', 'hunter'), ('bunny', 'bunny')], validators=[DataRequired()]) role = SelectField('Player Role', coerce=int, validators=[InputRequired()])
submit = SubmitField('Update') submit = SubmitField('Update')
class PlayerAddForm(FlaskForm): class PlayerAddForm(FlaskForm):
name = StringField('Player Name', validators=[DataRequired(), Length(min=0, max=64)]) name = StringField('Username', validators=[InputRequired(), Length(min=0, max=64)])
role = SelectField('Player Role', choices=[('none', 'none'), ('owner', 'owner'), ('hunter', 'hunter'), ('bunny', 'bunny')], validators=[DataRequired()]) role = SelectField('Player Role', coerce=int, validators=[InputRequired()])
submit_add = SubmitField('Create') submit_add = SubmitField('Create')
class PlayerCreateForm(FlaskForm): class UserCreateForm(FlaskForm):
name = StringField('Player Name', validators=[DataRequired(), Length(min=0, max=64)]) name = StringField('Username', validators=[InputRequired(), Length(min=0, max=64)])
role = SelectField('Player Role', choices=[('none', 'none'), ('owner', 'owner'), ('hunter', 'hunter'), ('bunny', 'bunny')], validators=[DataRequired()]) role = SelectField('Player Role', coerce=int, validators=[InputRequired()])
submit_create = SubmitField('Create') 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')

282
app/main/routes.py

@ -1,187 +1,163 @@
import json import json
import qrcode from datetime import datetime, timedelta
from flask import render_template, flash, redirect, url_for, request, abort, send_file from flask import render_template, redirect, url_for, request, abort, send_file, current_app, flash
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import and_ from werkzeug.security import safe_join
from io import BytesIO
from app import db from app import db
from app.main import bp from app.main import bp
from app.models import Player, Game, Role, GamePlayer, Objective, ObjectiveMinimalEncoder, LocationEncoder from app.utils import get_game_if_owner, get_caught_bunny_photo_directory, get_bunny_photo_filename
from app.main.forms import CreateGameForm, ObjectiveForm, PlayerAddForm, PlayerCreateForm, PlayerUpdateForm 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()
@bp.route('/') @bp.route('/')
@bp.route('/index') @bp.route('/index')
@login_required @login_required
def index(): 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') 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') @bp.route('/game/<game_name>/dashboard')
@login_required @login_required
def game_dashboard(game_name): 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() game = Game.query.filter_by(name=game_name).first_or_404()
if not is_game_owner(game): 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:
abort(403) 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>/addplayer', methods=['GET', 'POST']) @bp.route('/game/<game_name>/caught_bunny_photo', methods=['GET'])
@login_required @login_required
def add_player(game_name): def caught_bunny_photo(game_name):
game = Game.query.filter_by(name=game_name).first_or_404() game = Game.query.filter_by(name=game_name).first_or_404()
if not is_game_owner(game): timestamp = request.args['timestamp']
bunny_name = request.args['bunny_name']
hunter_name = request.args['hunter_name']
hunter = GamePlayer.query.join(User).filter(
(GamePlayer.user_id == User.id) &
(GamePlayer.game_id == game.id) &
(User.name == hunter_name) &
(GamePlayer.role == Role.hunter)).first_or_404()
if not (game.owned_by(current_user) or current_user.player_in(game) == hunter):
abort(403) abort(403)
form_add = PlayerAddForm() directory = get_caught_bunny_photo_directory(game)
form_create = PlayerCreateForm() filename = get_bunny_photo_filename(directory, timestamp, hunter_name, bunny_name)
photo_path = safe_join(directory, filename)
if form_add.submit_add.data and form_add.validate_on_submit(): #TODO: Implement switch between serve self and serve by webserver
player = Player.query.filter_by(form_add.name.data).first_or_404() return send_file(photo_path, conditional=True, as_attachment=False)
game.game_players.append(GamePlayer(player=player, role=Role[form_create.role.data]))
return redirect(url_for('main.game_dashboard', game_name=game.name))
if form_create.submit_create.data and form_create.validate_on_submit():
player = Player(name=form_create.name.data)
player.set_auth_hash()
game.game_players.append(GamePlayer(player=player, role=Role[form_create.role.data]))
db.session.commit()
return redirect(url_for('main.game_dashboard', game_name=game.name))
return render_template('add_player.html', title=f'Add Player for {game_name}', form_add=form_add, form_create=form_create, game=game)
@bp.route('/game/<game_name>/removeplayer/<player_name>') @bp.route('/game/<game_name>/review')
@login_required @login_required
def remove_player(game_name, player_name): def review_caught_bunny_photos(game_name):
game = Game.query.filter_by(name=game_name).first_or_404() game = get_game_if_owner(game_name)
if not is_game_owner(game): pcp_id = request.args.get('pcp_id', default=-1, type=int)
abort(403) action = request.args.get('action', default='none', type=str).lower()
player = Player.query.filter(and_(Player.name == player_name, Player.games.contains(game))).first_or_404() if pcp_id != -1:
game.players.remove(player) pcp = PlayerCaughtPlayer.query.filter_by(id=pcp_id).first_or_404()
review = Review.parse_string(action)
pcp.review = review
db.session.commit() db.session.commit()
return redirect(url_for('main.game_dashboard', game_name=game.name)) return render_template('review_caught_bunny_photos.html', game=game)
@bp.route('/game/<game_name>/player/<player_name>', methods=['GET', 'POST']) @bp.route('/user/<username>/send_location', methods=['POST'])
@login_required @login_required
def game_player(game_name, player_name): def send_location(username):
game = Game.query.filter_by(name=game_name).first_or_404() user = User.query.filter_by(name=username).first_or_404()
if not is_game_owner(game): last_location = user.last_location()
abort(403)
player = Player.query.filter(and_(Player.name == player_name, Player.games.contains(game))).first_or_404() latitude = request.form.get('lat', default=None, type=float)
gameplayer = [gameplayer for gameplayer in player.player_games if gameplayer.game == game][0] longitude = request.form.get('long', default=None, type=float)
form = PlayerUpdateForm(role=gameplayer.role.name) if latitude is None or longitude is None:
if form.validate_on_submit(): return '', 400
gameplayer.role = Role[form.role.data]
# 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() db.session.commit()
return redirect(url_for('main.game_dashboard', game_name=game.name)) return '', 204
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>') user.locations.append(Location(longitude=longitude, latitude=latitude))
@login_required
def player(auth_hash):
player = Player.query.filter_by(auth_hash=auth_hash).first_or_404()
return render_template('player.html',title=f'Player: {player.name}', player=player)
'''given player is an owner of the given game'''
def is_game_owner(game, owner=current_user):
return owner in [gameplayer.player for gameplayer in game.game_players if gameplayer.role == Role.owner]
'''given player is an owner of a game the subject_player participates in'''
def is_player_game_owner(subject_player, owner=current_user):
return owner in [gameplayer.player for gameplayers in
[game.game_players for game in subject_player.games]
for gameplayer in gameplayers if gameplayer.role == Role.owner]
'''given player is an owner of a game the given object is part of'''
def is_objective_owner(objective, owner=current_user):
return owner in [gameplayer.player for gameplayer in objective.game.game_players if gameplayer.role == Role.owner]
@bp.route('/game/<game_name>/add_objective', methods=['GET', 'POST'])
@login_required
def add_objective(game_name):
game = Game.query.filter_by(name=game_name).first_or_404()
if not is_game_owner(game):
abort(403)
form = ObjectiveForm()
objective = Objective(name='', latitude=52.0932, longitude=5.12405)
if form.validate_on_submit():
objective = Objective(name=form.objective_name.data, longitude=form.longitude.data, latitude=form.latitude.data)
objective.set_hash()
game.objectives.append(objective)
db.session.commit() db.session.commit()
flash(f"Objective has been added!") return '', 204
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']) @bp.route('/game/<game_name>/get_locations', methods=['POST'])
@login_required @login_required
def delete_objective(objective_hash): def poll_locations(game_name):
objective = Objective.query.filter_by(hash=objective_hash).first_or_404() game = Game.query.filter_by(name=game_name).first_or_404()
if not is_objective_owner(objective): role = current_user.role_in_game(game)
if role is None or role == Role.none:
abort(403) abort(403)
if is_objective_owner(objective):
db.session.delete(objective)
db.session.commit()
return redirect(url_for('main.game_dashboard', game_name=objective.game.name))
def generate_qr_code(url):
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=30,
border=4,
)
qr.add_data(url)
qr.make(fit=True)
return qr.make_image(fill_color='black', back_color='white')
# Source: https://stackoverflow.com/questions/7877282/how-to-send-image-generated-by-pil-to-browser
def serve_pil_image(pil_img):
img_io = BytesIO()
pil_img.save(img_io, 'PNG', quality=70)
img_io.seek(0)
return send_file(img_io, mimetype='image/png')
@bp.route('/objective/<objective_hash>/qrcode.png') 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>')
@login_required @login_required
def objective_qrcode(objective_hash): def user_profile(username):
objective = Objective.query.filter_by(hash=objective_hash).first_or_404() user = User.query.filter_by(name=username).first_or_404()
if not is_objective_owner(objective): if current_user != user:
abort(403) abort(403)
img = generate_qr_code(url_for('main.objective', objective_hash=objective.hash, _external=True)) return render_template('user_profile.html', user=user)
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

@ -0,0 +1,88 @@
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

@ -0,0 +1,118 @@
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 @@
import unittest import unittest
from app import create_app, db from app import create_app, db
from app.models import Player, Game, Role, GamePlayer, Objective, ObjectiveMinimalEncoder, LocationEncoder from app.models import User, Game, Role, GamePlayer, Objective, ObjectiveEncoder, LocationEncoder
import app.main.routes as routes import app.main.routes as routes
from config import Config from config import Config
@ -24,63 +24,5 @@ class RoutesCase(unittest.TestCase):
db.drop_all() db.drop_all()
self.app_context.pop() 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__': if __name__ == '__main__':
unittest.main(verbosity=2) unittest.main(verbosity=2)

237
app/models.py

@ -1,237 +0,0 @@
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

@ -0,0 +1,12 @@
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

@ -0,0 +1,115 @@
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

@ -0,0 +1,75 @@
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

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

24
app/models/location.py

@ -0,0 +1,24 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,10 @@
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

@ -0,0 +1,39 @@
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

@ -0,0 +1,28 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,14 @@
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

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

98
app/models/tests/test_game.py

@ -0,0 +1,98 @@
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

@ -0,0 +1,61 @@
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

@ -0,0 +1,76 @@
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

@ -0,0 +1,76 @@
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

@ -0,0 +1,128 @@
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

@ -0,0 +1,26 @@
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.

After

Width:  |  Height:  |  Size: 21 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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

@ -0,0 +1,174 @@
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

@ -0,0 +1,37 @@
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

@ -0,0 +1,42 @@
<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

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

18
app/templates/auth/change_password.html

@ -0,0 +1,18 @@
{% 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 %}

18
app/templates/auth/login.html

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

10
app/templates/auth/register.html

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

20
app/templates/auth/user_hash_login.html

@ -0,0 +1,20 @@
{% 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 %}

19
app/templates/base.html

@ -1,5 +1,10 @@
{% extends 'bootstrap/base.html' %} {% extends 'bootstrap/base.html' %}
{% block styles %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}">
{% endblock %}
{% block title %} {% block title %}
{% if title %}{{ title }} - The Hunt{% else %}Welcome to The Hunt{% endif %} {% if title %}{{ title }} - The Hunt{% else %}Welcome to The Hunt{% endif %}
{% endblock %} {% endblock %}
@ -8,7 +13,8 @@
<nav class="navbar navbar-default"> <nav class="navbar navbar-default">
<div class="container"> <div class="container">
<div class="navbar-header"> <div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false"> <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="sr-only">Toggle navigation</span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
@ -20,13 +26,17 @@
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
<li><a href="{{ url_for('main.index') }}">Home</a></li> <li><a href="{{ url_for('main.index') }}">Home</a></li>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li><a href="{{ url_for('main.create_game') }}">Create Game</a></li> <li><a href="{{ url_for('game.create_game') }}">Create Game</a></li>
{% endif %} {% endif %}
</ul> </ul>
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %} {% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">Login</a></li> <li><a href="{{ url_for('auth.login') }}">Login</a></li>
{% else %} {% 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> <li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
{% endif %} {% endif %}
</ul> </ul>
@ -50,4 +60,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{% endblock %}

19
app/templates/catch_bunny.html

@ -0,0 +1,19 @@
{% 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 %}

82
app/templates/game_bunny_dashboard.html

@ -0,0 +1,82 @@
{% 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

@ -1,137 +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>
{% 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

@ -0,0 +1,141 @@
{% 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

@ -0,0 +1,183 @@
{% 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

@ -0,0 +1,164 @@
{% 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 %}

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

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

96
app/templates/game_settings/edit_game.html

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

39
app/templates/objective.html

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

88
app/templates/player.html

@ -1,88 +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 %}
<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

@ -0,0 +1,41 @@
{% 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

@ -0,0 +1,14 @@
{% 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

@ -0,0 +1,6 @@
{% 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

@ -0,0 +1,62 @@
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,3 +20,9 @@ class Config(object):
ADMINS = ['your-email@example.com'] ADMINS = ['your-email@example.com']
BOOTSTRAP_SERVE_LOCAL = True 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

@ -0,0 +1 @@
<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.

After

Width:  |  Height:  |  Size: 51 KiB

1
documentation/database/database_schema1.4.2.drawio

@ -0,0 +1 @@
<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.

After

Width:  |  Height:  |  Size: 53 KiB

1
documentation/database/database_schema1.4.3.drawio

@ -0,0 +1 @@
<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.

After

Width:  |  Height:  |  Size: 53 KiB

1
documentation/database/entity_relationship_diagram_v1.4.1.drawio

@ -0,0 +1 @@
<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">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</diagram></mxfile>

BIN
documentation/database/entity_relationship_diagram_v1.4.1.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

1
documentation/database/entity_relationship_diagram_v1.4.2.drawio

@ -0,0 +1 @@
<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.

After

Width:  |  Height:  |  Size: 80 KiB

1
documentation/database/entity_relationship_diagram_v1.4.3.drawio

@ -0,0 +1 @@
<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.

After

Width:  |  Height:  |  Size: 81 KiB

6
documentation/gamestate.txt

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

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

@ -1,8 +1,8 @@
"""updated to database documentation 1.3 """replace gamestate with hidden and paused
Revision ID: 3ef4c34115fd Revision ID: 7a3183ce450a
Revises: Revises:
Create Date: 2020-07-09 14:43:14.149353 Create Date: 2020-07-29 10:55:07.589462
""" """
from alembic import op from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '3ef4c34115fd' revision = '7a3183ce450a'
down_revision = None down_revision = None
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@ -21,36 +21,40 @@ def upgrade():
op.create_table('game', op.create_table('game',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False), sa.Column('name', sa.String(length=64), nullable=False),
sa.Column('state', sa.Enum('initiated', 'published', 'started', 'interrupted', 'finished', name='gamestate'), server_default='initiated', nullable=False), sa.Column('hidden', sa.Boolean(), server_default='1', nullable=False),
sa.Column('paused', sa.Boolean(), server_default='0', nullable=False),
sa.Column('start_time', sa.DateTime(), nullable=True), sa.Column('start_time', sa.DateTime(), nullable=True),
sa.Column('end_time', sa.DateTime(), nullable=True), sa.Column('end_time', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_game_name'), 'game', ['name'], unique=True) op.create_index(op.f('ix_game_name'), 'game', ['name'], unique=True)
op.create_table('player', op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False), sa.Column('name', sa.String(length=64), nullable=False),
sa.Column('auth_hash', sa.String(length=32), nullable=True), sa.Column('auth_hash', sa.String(length=32), nullable=True),
sa.Column('password_hash', sa.String(length=128), nullable=True), sa.Column('password_hash', sa.String(length=128), nullable=True),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('auth_hash'), sa.UniqueConstraint('auth_hash'),
sa.UniqueConstraint('name') sa.UniqueConstraint('name')
) )
op.create_table('game_player', op.create_table('game_player',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('game_id', sa.Integer(), nullable=False), sa.Column('game_id', sa.Integer(), nullable=False),
sa.Column('player_id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role', sa.Enum('none', 'owner', 'hunter', 'bunny', name='role'), server_default='none', nullable=False), sa.Column('role', sa.Enum('none', 'owner', 'hunter', 'bunny', name='role'), server_default='none', nullable=False),
sa.ForeignKeyConstraint(['game_id'], ['game.id'], ), sa.ForeignKeyConstraint(['game_id'], ['game.id'], ),
sa.ForeignKeyConstraint(['player_id'], ['player.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('game_id', 'player_id') sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('game_id', 'user_id', name='_game_user_uc')
) )
op.create_table('location', op.create_table('location',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('player_id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('longitude', sa.Numeric(precision=15, scale=10, asdecimal=False), nullable=False), sa.Column('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('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.Column('timestamp', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['player_id'], ['player.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_table('notification', op.create_table('notification',
@ -73,32 +77,31 @@ def upgrade():
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('hash') 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', op.create_table('player_caught_player',
sa.Column('id', sa.Integer(), server_default='-1', autoincrement=True, nullable=False), sa.Column('id', sa.Integer(), server_default='-1', autoincrement=True, nullable=False),
sa.Column('catching_player_id', sa.Integer(), nullable=False), sa.Column('catching_player_id', sa.Integer(), nullable=False),
sa.Column('caught_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('timestamp', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['catching_player_id'], ['player.id'], ), sa.Column('review', sa.Enum('none', 'denied', 'accepted', name='review'), server_default='none', nullable=False),
sa.ForeignKeyConstraint(['caught_player_id'], ['player.id'], ), sa.ForeignKeyConstraint(['catching_player_id'], ['game_player.id'], ),
sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['caught_player_id'], ['game_player.id'], ),
sa.UniqueConstraint('photo_reference') sa.PrimaryKeyConstraint('id')
)
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', op.create_table('player_found_objective',
sa.Column('objective_id', sa.Integer(), server_default='-1', nullable=False), sa.Column('objective_id', sa.Integer(), server_default='-1', nullable=False),
sa.Column('player_id', sa.Integer(), server_default='-1', nullable=False), sa.Column('game_player_id', sa.Integer(), server_default='-1', nullable=False),
sa.Column('timestamp', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), sa.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.ForeignKeyConstraint(['objective_id'], ['objective.id'], ),
sa.ForeignKeyConstraint(['player_id'], ['player.id'], ), sa.PrimaryKeyConstraint('objective_id', 'game_player_id')
sa.PrimaryKeyConstraint('objective_id', 'player_id')
) )
# ### end Alembic commands ### # ### end Alembic commands ###
@ -106,13 +109,13 @@ def upgrade():
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('player_found_objective') op.drop_table('player_found_objective')
op.drop_table('notification_player')
op.drop_table('player_caught_player') op.drop_table('player_caught_player')
op.drop_table('notification_player')
op.drop_table('objective') op.drop_table('objective')
op.drop_table('notification') op.drop_table('notification')
op.drop_table('location') op.drop_table('location')
op.drop_table('game_player') op.drop_table('game_player')
op.drop_table('player') op.drop_table('user')
op.drop_index(op.f('ix_game_name'), table_name='game') op.drop_index(op.f('ix_game_name'), table_name='game')
op.drop_table('game') op.drop_table('game')
# ### end Alembic commands ### # ### end Alembic commands ###

13
requirements.txt

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

47
the_hunt.py

@ -1,12 +1,51 @@
from app import create_app, db from app import create_app, db
from app.models import Game, Player, Objective, Location, Notification, GamePlayer, \ from app.models import Game, User, Objective, Location, Notification, GamePlayer, \
PlayerFoundObjective, NotificationPlayer, PlayerCaughtPlayer, Role, GameState PlayerFoundObjective, NotificationPlayer, PlayerCaughtPlayer, Role, GameState, Review
app = create_app() app = create_app()
@app.shell_context_processor @app.shell_context_processor
def make_shell_context(): def make_shell_context():
return {'db': db, 'Game' : Game, 'Player' : Player, 'Objective' : Objective, return {'db': db, 'Game' : Game, 'User' : User, 'Objective' : Objective,
'Location' : Location, 'Notification' : Notification, 'GamePlayer' : GamePlayer, 'Location' : Location, 'Notification' : Notification, 'GamePlayer' : GamePlayer,
'PlayerFoundObjective' : PlayerFoundObjective, 'NotificationPlayer' : NotificationPlayer, 'PlayerFoundObjective' : PlayerFoundObjective, 'NotificationPlayer' : NotificationPlayer,
'PlayerCaughtPlayer' : PlayerCaughtPlayer, 'Role' : Role, 'GameState' : GameState} '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()

Loading…
Cancel
Save