diff --git a/app/__init__.py b/app/__init__.py index 272a812abdce3d9224e6c09f1e7b6d104896c61f..4a689dcf949cd8c63a320307d33d3998155d1fda 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,11 +2,13 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_bootstrap import Bootstrap +from flask_wtf.csrf import CSRFProtect from flask_mail import Mail # global variable initialization db = SQLAlchemy() mail = Mail() +csrf = CSRFProtect() def create_app(): # initialize flask app and configure @@ -16,10 +18,12 @@ def create_app(): # initialize Mail mail.init_app(app) + # initialize CSRF protection + csrf.init_app(app) + # initialize ORM db.init_app(app) - # add database migration from flask-migrate Migrate(app, db) @@ -39,9 +43,4 @@ def create_app(): from .bouncer import bouncer_bp app.register_blueprint(bouncer_bp) - # @app.route('/') - # def hello_world(): - # print(app.config.get('TOTAL_SPOTS')) - # return 'Hello, World!' - return app diff --git a/app/auth.py b/app/auth.py index 813dfaa7061aecafe3c1bb4e71653fb463b6e77a..2dc776afd3f92cca6637da9d3ba195571d423ce8 100644 --- a/app/auth.py +++ b/app/auth.py @@ -8,13 +8,10 @@ from .models import User def login_required(f): """ Requires that the user is logged in. - - This is a wrapper for the @login_required decorator. - - Error 403: shwon if trying to access with an api key (Authorization header). """ @wraps(f) def wrapped(*args, **kwargs): + print('test') if not is_authenticated(): abort(403) @@ -23,15 +20,19 @@ def login_required(f): def is_authenticated(): - return 'userID' in session and User.query.get(session['userID']) is not None + return 'userID' in session and session['userID'] is not None and User.query.get(session['userID']) is not None def get_authenticated_user(): - return User.query.get(session['userID']) if 'userID' in session else None + return User.query.get(session['userID']) if 'userID' in session and session['userID'] is not None else None def login(email, password): + if email is None or password is None: + return False + user = User.query.filter(User.email == email).first() + if user.check_password(password): session['userID'] = user._id return True diff --git a/app/bouncer/views.py b/app/bouncer/views.py index 8825957194a00076259291c959fbcdb485556b38..befea768ccf8da1e33c6133762012eb8e5aa7df0 100644 --- a/app/bouncer/views.py +++ b/app/bouncer/views.py @@ -3,8 +3,9 @@ from flask import flash, redirect, render_template, url_for, request, abort, mak from . import bouncer_bp from ..auth import login_required, is_authenticated, get_authenticated_user from ..controllers import FreeSpotController, UserController, ReservationController, RecordController +from ..exceptions import NoFreeSpotError -@bouncer_bp.route('/') +@bouncer_bp.route('/', methods=['GET', 'POST']) def home(): free_spots = FreeSpotController.get_free_spots() @@ -12,6 +13,95 @@ def home(): user = get_authenticated_user() reservation = ReservationController.get_active_reservation(user) record = RecordController.get_active_record(user) - - return make_response(render_template('bouncer/home_authenticated.html', title='Home', free_spots=free_spots, name=user.name, email=user.email)) + + if request.method == 'POST': + if request.form['action'] == 'enter': + try: + UserController.start_record(user) + flash('You are allowed to enter.') + return redirect(url_for('bouncer.home')) + except NoFreeSpotError: + flash('No free spots available! Try again later.', 'error') + if request.form['action'] == 'reserve': + try: + UserController.reserve(user) + flash('Your reservation has been recorded. Your request for entry will be granted immediately as long as your reservation is valid.') + return redirect(url_for('bouncer.home')) + except NoFreeSpotError: + flash('No free spots available! Try again later.', 'error') + + return make_response(render_template('bouncer/home_authenticated.html', title='Home', free_spots=free_spots, user=user, reservation=reservation, record=record)) return make_response(render_template('bouncer/home_anonymous.html', title='Home', free_spots=free_spots)) + +@bouncer_bp.route('/confirm/leave', methods=['GET', 'POST']) +@login_required +def confirm_leave(): + free_spots = FreeSpotController.get_free_spots() + user = get_authenticated_user() + record = RecordController.get_active_record(user) + + if not user.is_confirmed: + flash('Please confirm your email address first!', 'warning') + return redirect(url_for('bouncer.home')) + + if record is None: + flash('You didn\'t report that you were there.', 'warning') + return redirect(url_for('bouncer.home')) + + if request.method == 'POST': + if request.form['action'] == 'leave': + RecordController.terminate(record) + flash('You have been checked out successfully.') + return redirect(url_for('bouncer.home')) + + return make_response(render_template('bouncer/confirm_leave.html', title='Confirm: leave', user=user, free_spots=free_spots)) + +@bouncer_bp.route('/confirm/enter', methods=['GET', 'POST']) +@login_required +def confirm_enter(): + free_spots = FreeSpotController.get_free_spots() + user = get_authenticated_user() + + if not user.is_confirmed: + flash('Please confirm your email address first!', 'warning') + return redirect(url_for('bouncer.home')) + + if RecordController.has_active_record(user): + flash('You already reported to be there!', 'warning') + return redirect(url_for('bouncer.home')) + + if request.method == 'POST': + if request.form['action'] == 'enter': + try: + UserController.start_record(user) + flash('You are cleared for entry.') + return redirect(url_for('bouncer.home')) + except NoFreeSpotError: + flash ('No free spots available! Try again later.', 'error') + return redirect(url_for('bouncer.home')) + + return make_response(render_template('bouncer/confirm_enter.html', title='Confirm: enter', user=user, free_spots=free_spots)) + + +@bouncer_bp.route('/confirm/cancel', methods=['GET', 'POST']) +@login_required +def confirm_cancel(): + free_spots = FreeSpotController.get_free_spots() + user = get_authenticated_user() + reservation = ReservationController.get_active_reservation(user) + + if not user.is_confirmed: + flash('Please confirm your email address first!', 'warning') + return redirect(url_for('bouncer.home')) + + if not reservation: + flash('You do not have any reservations!', 'warning') + return redirect(url_for('bouncer.home')) + + if request.method == 'POST': + if request.form['action'] == 'cancel': + ReservationController.cancel(reservation) + flash('Your reservation has been cancelled.') + return redirect(url_for('bouncer.home')) + + return make_response(render_template('bouncer/confirm_cancel.html', title='Confirm: cancel reservation', user=user, free_spots=free_spots)) diff --git a/app/controllers/free_spot.py b/app/controllers/free_spot.py index 0b4ddc016e1e2a2606455cd44710a6df780111b3..bbd65ee9b14433533aaec80a57fba6e82826ce9f 100644 --- a/app/controllers/free_spot.py +++ b/app/controllers/free_spot.py @@ -1,8 +1,8 @@ import threading from datetime import datetime -from sqlalchemy import DateTime, cast, func +from sqlalchemy import DateTime, cast, func, or_ from flask import current_app -from app import models +from app.models import Record, Reservation from app import db from .lock import Lock from ..exceptions import NoFreeSpotError @@ -36,14 +36,14 @@ class FreeSpotController(): total_spots = current_app.config.get('TOTAL_SPOTS') now = datetime.now() - active_records_count = db.session.query(func.count(models.Record._id)) \ - .filter(cast(models.Record.time_start,DateTime) <= now) \ - .filter(cast(models.Record.time_end,DateTime) >= now) \ + active_records_count = db.session.query(func.count(Record._id)) \ + .filter(cast(Record.time_start,DateTime) <= now) \ + .filter(or_(cast(Record.time_end,DateTime) >= now, Record.time_end == None)) \ .scalar() - valid_reservations_count = db.session.query(func.count(models.Reservation._id)) \ - .filter(cast(models.Reservation.time_start,DateTime) <= now) \ - .filter(cast(models.Reservation.time_end,DateTime) >= now) \ + valid_reservations_count = db.session.query(func.count(Reservation._id)) \ + .filter(cast(Reservation.time_start,DateTime) <= now) \ + .filter(cast(Reservation.time_end,DateTime) >= now) \ .scalar() diff --git a/app/controllers/record.py b/app/controllers/record.py index 3a0d840bc71ce050e409733ffbe5f68d7f795779..59702025923db82e4646d567ea01711729ed957d 100644 --- a/app/controllers/record.py +++ b/app/controllers/record.py @@ -1,6 +1,6 @@ import threading from datetime import datetime -from sqlalchemy import DateTime, cast, func +from sqlalchemy import DateTime, cast, func, or_ from app import db from app.models import Record from .lock import Lock @@ -22,7 +22,6 @@ class RecordController(): record.user = user record.time_start = datetime.now() record.time_end = None - record.name = reservation.name db.session.add(record) db.session.commit() @@ -31,18 +30,17 @@ class RecordController(): @classmethod def create_from_reservation(cls, reservation): with cls.spot_lock: - if not user.is_confirmed: + if not reservation.user.is_confirmed: raise UserNotConfirmedError if reservation.is_valid: time_start = datetime.now() reservation.time_end = time_start - record = models.Record() + record = Record() record.user = reservation.user record.time_start = time_start record.time_end = None - record.name = reservation.name db.session.add(record) db.session.commit() @@ -60,7 +58,8 @@ class RecordController(): now = datetime.now() return Record.query \ .filter(cast(Record.time_start,DateTime) <= now) \ - .filter(cast(Record.time_end,DateTime) > now) + .filter(or_(cast(Record.time_end,DateTime) > now, Record.time_end == None)) \ + .first() @classmethod diff --git a/app/controllers/reservation.py b/app/controllers/reservation.py index 30518fd206a835cbe6b1fb341c173e8397df74ba..1047050ae1d8c1dc145becd8d6a4d951828206c8 100644 --- a/app/controllers/reservation.py +++ b/app/controllers/reservation.py @@ -5,6 +5,7 @@ from flask import current_app from app import db from app.models import Reservation from .lock import Lock +from .free_spot import FreeSpotController from ..exceptions import ReservationExpiredError, NoFreeSpotError, UserNotConfirmedError class ReservationController(): @@ -14,16 +15,15 @@ class ReservationController(): @classmethod def create(cls, user): with cls.spot_lock: - cls.check_free_spots() + FreeSpotController.check_free_spots() if not user.is_confirmed: raise UserNotConfirmedError record = Reservation() record.user = user - record.name = name record.time_start = datetime.now() - record.time_end = datetime.now() + current_app.config('RESERVATION_DURATION', timedelta(hours=1)) + record.time_end = datetime.now() + current_app.config.get('RESERVATION_DURATION', timedelta(hours=1)) db.session.add(record) db.session.commit() @@ -39,7 +39,8 @@ class ReservationController(): now = datetime.now() return Reservation.query \ .filter(cast(Reservation.time_start,DateTime) <= now) \ - .filter(cast(Reservation.time_end,DateTime) > now) + .filter(cast(Reservation.time_end,DateTime) > now) \ + .first() @classmethod diff --git a/app/login/views.py b/app/login/views.py index a6aeec81bab0e76c28f8b79e7b008e9043dc59a4..e387707956ad688e71628aaf596bd810e072bc6e 100644 --- a/app/login/views.py +++ b/app/login/views.py @@ -2,11 +2,14 @@ from flask import flash, redirect, render_template, url_for, request, abort, mak from ..controllers.user import UserController from ..exceptions import UserRegistrationInvalidDataError -from ..auth import login as auth_login, logout as auth_logout +from ..auth import login as auth_login, logout as auth_logout, is_authenticated from . import login_bp @login_bp.route('/register', methods=['GET', 'POST']) def register(): + if is_authenticated(): + return redirect(url_for('bouncer.home')) + errors = None if request.method == 'POST': try: @@ -28,7 +31,7 @@ def register(): return make_response(render_template('login/register.html', title='Registration', errors=errors)) -@login_bp.route('/confirm_email') +@login_bp.route('/confirm/email') def confirm_email(): email = request.args.get('email') token = request.args.get('token') @@ -45,6 +48,9 @@ def confirm_email(): @login_bp.route('/login', methods=['GET', 'POST']) def login(): + if is_authenticated(): + return redirect(url_for('bouncer.home')) + if request.method == 'POST': if auth_login(request.form['email'], request.form['password']): return redirect(url_for('bouncer.home')) diff --git a/app/models/reservation.py b/app/models/reservation.py index f435b20aa19901e1139bac5bd7292b2bf3c0a94d..6c994b9611635e018866a72b819a9a80fafee438 100644 --- a/app/models/reservation.py +++ b/app/models/reservation.py @@ -19,4 +19,4 @@ class Reservation(db.Model): @property def is_valid(self): - return self.time_end - datetime.now() < timedelta(seconds=0) + return self.time_end - datetime.now() > timedelta(seconds=0) diff --git a/app/templates/base_authenticated.html b/app/templates/base_authenticated.html new file mode 100644 index 0000000000000000000000000000000000000000..f10bdb3b2b907e0f11e090d6f40d17f45235409a --- /dev/null +++ b/app/templates/base_authenticated.html @@ -0,0 +1,21 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block body %} +<div class="content-section"> + <br/> + {{ utils.flashed_messages() }} + <br/> + <div class="center-narrow"> + <h1>Bastli Bouncer</h1> + <br/> + <p>We currently have <span class="free_spots">{{ free_spots }} free spots</span> in our workshop.</p> + <p> + Welcome, {{ user.name }} ({{ user.email }}). + <a class="btn btn-default" role="button" href="{{ url_for('login.logout') }}">Logout</a> + </p> + <br/> + {% block content %} + {% endblock %} + </div> +</div> +{% endblock %} diff --git a/app/templates/bouncer/confirm_cancel.html b/app/templates/bouncer/confirm_cancel.html new file mode 100644 index 0000000000000000000000000000000000000000..f067fa26405744dd2a4982faede8d0164f4f4aa8 --- /dev/null +++ b/app/templates/bouncer/confirm_cancel.html @@ -0,0 +1,12 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base_authenticated.html" %} +{% block content %} + <h2>Cancel Reservation</h2> + <p>Do you really want to cancel your reservation?</p> + <form action="" method="post"> + <input type="hidden" name="action" value="cancel"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> + <input class="btn btn-primary" type="submit" value="Cancel reservation now"> or + <a class="btn btn-default" role="button" href="{{ url_for('bouncer.home') }}">Go back</a> + </form> +{% endblock %} diff --git a/app/templates/bouncer/confirm_enter.html b/app/templates/bouncer/confirm_enter.html new file mode 100644 index 0000000000000000000000000000000000000000..f3b9c4e9c872d1304d14a94607393ecb8c02b57e --- /dev/null +++ b/app/templates/bouncer/confirm_enter.html @@ -0,0 +1,12 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base_authenticated.html" %} +{% block content %} + <h2>Enter Bastli</h2> + <p>You request entry by clicking below.</p> + <form action="" method="post"> + <input type="hidden" name="action" value="enter"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> + <input class="btn btn-primary" type="submit" value="Apply for entry now"> or + <a class="btn btn-default" role="button" href="{{ url_for('bouncer.home') }}">Go back</a> + </form> +{% endblock %} diff --git a/app/templates/bouncer/confirm_leave.html b/app/templates/bouncer/confirm_leave.html new file mode 100644 index 0000000000000000000000000000000000000000..5946c7b8d29f99d793031f6347aae290b853e4a8 --- /dev/null +++ b/app/templates/bouncer/confirm_leave.html @@ -0,0 +1,12 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base_authenticated.html" %} +{% block content %} + <h2>Leave Bastli</h2> + <p>You confirm that you have left the workshop by clicking below.</p> + <form action="" method="post"> + <input type="hidden" name="action" value="leave"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> + <input class="btn btn-primary" type="submit" value="Leave now"> or + <a class="btn btn-default" role="button" href="{{ url_for('bouncer.home') }}">Go back</a> + </form> +{% endblock %} diff --git a/app/templates/bouncer/home_authenticated.html b/app/templates/bouncer/home_authenticated.html index a4dd68ccef2078348f4f4a60726343f01410dbfa..28b527f2cac3ea59b5707c0416bae23109859188 100644 --- a/app/templates/bouncer/home_authenticated.html +++ b/app/templates/bouncer/home_authenticated.html @@ -1,19 +1,29 @@ {% import "bootstrap/utils.html" as utils %} -{% extends "base.html" %} -{% block body %} -<div class="content-section"> - <br/> - {{ utils.flashed_messages() }} - <br/> - <div class="center-narrow"> - <h1>Bastli Bouncer</h1> - <br/> - <p>We currently have <span class="free_spots">{{ free_spots }} free spots</span> in our workshop.</p> - <p> - Welcome, {{ name }} ({{ email }}). - <a class="btn btn-default" role="button" href="{{ url_for('login.logout') }}">Logout</a> - </p> - <br/> - </div> -</div> +{% extends "base_authenticated.html" %} +{% block content %} + {% if record %} + <div class="active_record"> + You reported to be at Bastli. + <a class="btn btn-default" role="button" href="{{ url_for('bouncer.confirm_leave') }}">Leave now</a> + </div> + {% elif reservation %} + <div class="active_reservation"> + You have a valid reservation expiring at {{ reservation.time_end.strftime("%H:%M") }}. + <a class="btn btn-default" role="button" href="{{ url_for('bouncer.confirm_enter') }}">Enter now</a> + or + <a class="btn btn-default" role="button" href="{{ url_for('bouncer.confirm_cancel') }}">Cancel reservation</a> + </div> + {% else %} + <form action="" method="post"> + <input type="hidden" name="action" value="reserve"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> + <input class="btn btn-primary" type="submit" value="Reserve a spot"> + </form> + or + <form action="" method="post"> + <input type="hidden" name="action" value="enter"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> + <input class="btn btn-primary" type="submit" value="Apply for entry"> + </form> + {% endif %} {% endblock %} diff --git a/app/templates/login/confirm_email.html b/app/templates/login/confirm_email.html index 0e2604d532dad3357af0c7bed805a6f0bbed13d9..29904308ee896bffb0af1f4dee694706aa57ab36 100644 --- a/app/templates/login/confirm_email.html +++ b/app/templates/login/confirm_email.html @@ -16,6 +16,7 @@ <input type="hidden" name="email" value="{{ email }}"> <input type="hidden" name="token" value="{{ token }}"> <input type="hidden" name="action" value="confirm"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input class="btn btn-primary" type="submit" value="Confirm"> <a class="btn btn-default" role="button" href="{{ url_for('bouncer.home') }}">Abort</a> </form> diff --git a/app/templates/login/login.html b/app/templates/login/login.html index 025203cd40d56255a339e96d07a4916b6662eba2..5fc08ef1a0db0806d7088bf46844134075ca8c63 100644 --- a/app/templates/login/login.html +++ b/app/templates/login/login.html @@ -15,6 +15,7 @@ <input type="email" id="email" name="email" value="{{ request.form.email }}"><br/> <label for="name">Password:</label><br> <input type="password" id="password" name="password" value="{{ request.form.password }}"><br/> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input class="btn btn-primary" type="submit" value="Login"> or <a class="btn btn-default" role="button" href="{{ url_for('bouncer.home') }}">Go back</a> </form> diff --git a/app/templates/login/register.html b/app/templates/login/register.html index ac13ea7c94b3fd652c9a6a72d41725f3e699d9b7..d924a6f810e60a4b3a63678c19f3764793072ee5 100644 --- a/app/templates/login/register.html +++ b/app/templates/login/register.html @@ -19,6 +19,7 @@ <input type="password" id="password" name="password" value="{{ request.form.password }}"><br/> <label for="name">Repeat Password:</label><br> <input type="password" id="password2" name="password2" value="{{ request.form.password2 }}"><br/> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input class="btn btn-primary" type="submit" value="Register"> or <a class="btn btn-default" role="button" href="{{ url_for('bouncer.home') }}">Go back</a> </form> diff --git a/docker-compose.yml b/docker-compose.yml index 7e8e628adb3733fa9cf4058041053999124d607b..1696f2a8177d7862dca9291282b6229f13bbe15c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - MYSQL_RANDOM_ROOT_PASSWORD=yes volumes: - ./.data/:/var/lib/mysql + - /etc/localtime:/etc/localtime smtp: build: dockerfile: Dockerfile.smtp-mock @@ -21,6 +22,8 @@ services: restart: always networks: - backend + volumes: + - /etc/localtime:/etc/localtime networks: backend: diff --git a/instance/config.dev.py b/instance/config.dev.py index ab0915c147a8caa89389a07eadd54385a2ba6edf..6e2b8034eba72c208d6da43e98ef266167726cf7 100644 --- a/instance/config.dev.py +++ b/instance/config.dev.py @@ -4,7 +4,7 @@ from datetime import timedelta TOTAL_SPOTS = 3 RESERVATION_DURATION = timedelta(hours=1) RECORD_TIMEOUT = timedelta(hours=24) -USER_TOKEN_TIMEOUT = timedelta(hours=12) +USER_TOKEN_TIMEOUT = timedelta(hours=4) SERVER_NAME = 'localhost:5000' diff --git a/manage.sh b/manage.sh index 436cbcdd1a77f08dc4038ef896f0e5da0e11229b..8a09d0b23979a9950a7e0e7d7f48b9bb731950b7 100755 --- a/manage.sh +++ b/manage.sh @@ -11,6 +11,7 @@ BASE_DOCKER_RUN_COMMAND="${SUDO_COMMAND} docker run -it \ --network bastli-bouncer_backend \ -v ${PWD}:/bastlibouncer \ -v ${PWD}/instance/config.dev.py:/bastlibouncer/instance/config.py \ + -v /etc/localtime:/etc/localtime bastlibouncer-dev" case $1 in @@ -32,7 +33,7 @@ case $1 in $SUDO_COMMAND docker-compose stop ;; *) - echo "Unknown sub-command for command \"db\"." + echo "Unknown sub-command for command \"services\"." exit 1 ;; esac diff --git a/requirements.in b/requirements.in index 31cc083d70ae2e56fbcb4cee44b516a2cb322b73..ff5bbfac1a31e4f4a1b73d86cb738d032d8af2b9 100644 --- a/requirements.in +++ b/requirements.in @@ -6,6 +6,7 @@ flask-bootstrap flask-migrate flask-sqlalchemy Flask-Mail +flask-wtf py3-validate-email diff --git a/requirements.txt b/requirements.txt index 4393a99fe0493e4562b9523c3aa7ddab9b60c031..c2f9e5871439e050f945a99df06c954810d040ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,12 +14,13 @@ flask-bootstrap==3.3.7.1 # via -r requirements.in flask-mail==0.9.1 # via -r requirements.in flask-migrate==2.5.3 # via -r requirements.in flask-sqlalchemy==2.4.3 # via -r requirements.in, flask-migrate -flask==1.1.2 # via -r requirements.in, flask-bootstrap, flask-mail, flask-migrate, flask-sqlalchemy +flask-wtf==0.14.3 # via -r requirements.in +flask==1.1.2 # via -r requirements.in, flask-bootstrap, flask-mail, flask-migrate, flask-sqlalchemy, flask-wtf idna==2.10 # via py3-validate-email -itsdangerous==1.1.0 # via flask +itsdangerous==1.1.0 # via flask, flask-wtf jinja2==2.11.2 # via flask mako==1.1.3 # via alembic -markupsafe==1.1.1 # via jinja2, mako +markupsafe==1.1.1 # via jinja2, mako, wtforms py3-validate-email==0.2.9 # via -r requirements.in pymysql==0.9.3 # via -r requirements.in python-dateutil==2.8.1 # via alembic @@ -28,3 +29,4 @@ six==1.15.0 # via python-dateutil sqlalchemy==1.3.18 # via alembic, flask-sqlalchemy visitor==0.1.3 # via flask-bootstrap werkzeug==1.0.1 # via flask +wtforms==2.3.1 # via flask-wtf