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