diff --git a/.flaskenv b/.flaskenv index c3ebe18..e0bf6dc 100644 --- a/.flaskenv +++ b/.flaskenv @@ -1,2 +1 @@ -FLASK_ENV=development FLASK_APP=watchlist diff --git a/.github/workflows/master_helloflask-watchlist.yml b/.github/workflows/main_helloflask-watchlist.yml similarity index 91% rename from .github/workflows/master_helloflask-watchlist.yml rename to .github/workflows/main_helloflask-watchlist.yml index 7677d17..43e83ab 100644 --- a/.github/workflows/master_helloflask-watchlist.yml +++ b/.github/workflows/main_helloflask-watchlist.yml @@ -6,7 +6,7 @@ name: Build and deploy Python app to Azure Web App - helloflask-watchlist on: push: branches: - - master + - main jobs: build-and-deploy: @@ -18,13 +18,13 @@ jobs: - name: Set up Python version uses: actions/setup-python@v1 with: - python-version: '3.6' + python-version: '3.13' - name: Build using AppService-Build uses: azure/appservice-build@v1 with: platform: python - platform-version: '3.6' + platform-version: '3.13' - name: 'Deploy to Azure Web App' uses: azure/webapps-deploy@v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1eb34d1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: Tests +on: + push: + branches: + - main + paths-ignore: + - '*.md' + pull_request: + branches: + - main + paths-ignore: + - '*.md' +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -r requirements.txt + - name: Test with pytest + run: | + python test_watchlist.py diff --git a/.gitignore b/.gitignore index 4c2a715..e9beadd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ __pycache__ *.db htmlcov/ .coverage +.venv venv env diff --git a/README.md b/README.md index 462d225..2c1e646 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,42 @@ # Watchlist -Example application for flask tutorial "[Flask 入门教程](https://helloflask.com/book/3)". +Example application for flask tutorial "[Flask 入门教程 / Flask for Beginners](https://helloflask.com/book/3)". Demo: http://watchlist.helloflask.com -![Screenshot](https://helloflask.com/screenshots/watchlist.png) +![Screenshot](demo.png) ## Installation -clone: +Clone the repository: + ``` $ git clone https://github.com/helloflask/watchlist.git $ cd watchlist ``` -create & active virtual enviroment then install dependencies: + +Create & active virtual enviroment and install dependencies: + ``` -$ python3 -m venv env # use `python ...` on Windows -$ source env/bin/activate # use `env\Scripts\activate` on Windows -(env) $ pip install -r requirements.txt +$ python3 -m venv .venv # use `python ...` on Windows +$ source .venv/bin/activate # use `.venv\Scripts\activate` on Windows +(.venv) $ pip install -r requirements.txt ``` -generate fake data then run: +Generate fake data then run the app: + ``` -(env) $ flask forge -(env) $ flask run +(.venv) $ flask forge +(.venv) $ flask run * Running on http://127.0.0.1:5000/ ``` +Test account: + +- username: `admin` +- password: `helloflask` + ## License diff --git a/demo.png b/demo.png new file mode 100644 index 0000000..f900a21 Binary files /dev/null and b/demo.png differ diff --git a/requirements.txt b/requirements.txt index 3e76274..5a385c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,37 +1,44 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: # # pip-compile requirements.in # -click==8.1.3 +blinker==1.9.0 # via flask -coverage==6.4.2 +click==8.1.8 + # via flask +coverage==7.10.3 # via -r requirements.in -flask==2.1.3 +flask==3.1.1 # via # -r requirements.in # flask-login # flask-sqlalchemy -flask-login==0.6.1 +flask-login==0.6.3 # via -r requirements.in -flask-sqlalchemy==2.5.1 +flask-sqlalchemy==3.1.1 # via -r requirements.in -importlib-metadata==4.12.0 +importlib-metadata==8.7.0 # via flask -itsdangerous==2.1.2 +itsdangerous==2.2.0 # via flask -jinja2==3.1.2 +jinja2==3.1.6 # via flask -markupsafe==2.1.1 - # via jinja2 -python-dotenv==0.20.0 +markupsafe==3.0.2 + # via + # flask + # jinja2 + # werkzeug +python-dotenv==1.1.1 # via -r requirements.in -sqlalchemy==1.4.39 +sqlalchemy==2.0.43 # via flask-sqlalchemy -werkzeug==2.1.2 +typing-extensions==4.14.1 + # via sqlalchemy +werkzeug==3.1.3 # via # flask # flask-login -zipp==3.8.1 +zipp==3.23.0 # via importlib-metadata diff --git a/test_watchlist.py b/test_watchlist.py index a462441..0922902 100644 --- a/test_watchlist.py +++ b/test_watchlist.py @@ -1,31 +1,31 @@ import unittest -from watchlist import app, db +from watchlist import create_app +from watchlist.extensions import db from watchlist.models import Movie, User -from watchlist.commands import forge, initdb class WatchlistTestCase(unittest.TestCase): def setUp(self): - app.config.update( - TESTING=True, - SQLALCHEMY_DATABASE_URI='sqlite:///:memory:' - ) - db.create_all() + self.app = create_app('testing') + self.context = self.app.app_context() + self.context.push() + db.create_all() user = User(name='Test', username='test') user.set_password('123') movie = Movie(title='Test Movie Title', year='2019') db.session.add_all([user, movie]) db.session.commit() - self.client = app.test_client() - self.runner = app.test_cli_runner() + self.client = self.app.test_client() + self.runner = self.app.test_cli_runner() def tearDown(self): db.session.remove() db.drop_all() + self.context.pop() def login(self): self.client.post('/login', data=dict( @@ -34,10 +34,10 @@ def login(self): ), follow_redirects=True) def test_app_exist(self): - self.assertIsNotNone(app) + self.assertIsNotNone(self.app) def test_app_is_testing(self): - self.assertTrue(app.config['TESTING']) + self.assertTrue(self.app.config['TESTING']) def test_404_page(self): response = self.client.get('/nothing') @@ -211,12 +211,12 @@ def test_delete_item(self): self.assertNotIn('Test Movie Title', data) def test_forge_command(self): - result = self.runner.invoke(forge) + result = self.runner.invoke(args=['forge']) self.assertIn('Done.', result.output) self.assertNotEqual(Movie.query.count(), 0) def test_initdb_command(self): - result = self.runner.invoke(initdb) + result = self.runner.invoke(args=['init-db']) self.assertIn('Initialized database.', result.output) def test_admin_command(self): diff --git a/watchlist/__init__.py b/watchlist/__init__.py index 5da0582..89dca16 100644 --- a/watchlist/__init__.py +++ b/watchlist/__init__.py @@ -1,42 +1,31 @@ -import os -import sys - from flask import Flask -from flask_sqlalchemy import SQLAlchemy -from flask_login import LoginManager - -# SQLite URI compatible -WIN = sys.platform.startswith('win') -if WIN: - prefix = 'sqlite:///' -else: - prefix = 'sqlite:////' - -app = Flask(__name__) -app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev') -app.config['SQLALCHEMY_DATABASE_URI'] = prefix + os.path.join(os.path.dirname(app.root_path), os.getenv('DATABASE_FILE', 'data.db')) -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -db = SQLAlchemy(app) -login_manager = LoginManager(app) +from sqlalchemy import select +from watchlist.settings import config +from watchlist.blueprints.main import main_bp +from watchlist.blueprints.auth import auth_bp +from watchlist.models import User +from watchlist.extensions import db, login_manager +from watchlist.errors import register_errors +from watchlist.commands import register_commands -@login_manager.user_loader -def load_user(user_id): - from watchlist.models import User - user = User.query.get(int(user_id)) - return user +def create_app(config_name='development'): + app = Flask(__name__) + app.config.from_object(config[config_name]) -login_manager.login_view = 'login' -# login_manager.login_message = 'Your custom message' + app.register_blueprint(main_bp) + app.register_blueprint(auth_bp) + db.init_app(app) + login_manager.init_app(app) -@app.context_processor -def inject_user(): - from watchlist.models import User - user = User.query.first() - return dict(user=user) + register_errors(app) + register_commands(app) + @app.context_processor + def inject_user(): + user = db.session.execute(select(User)).scalar() + return dict(user=user) -from watchlist import views, errors, commands + return app diff --git a/watchlist/blueprints/__init__.py b/watchlist/blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watchlist/blueprints/auth.py b/watchlist/blueprints/auth.py new file mode 100644 index 0000000..1561956 --- /dev/null +++ b/watchlist/blueprints/auth.py @@ -0,0 +1,39 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_user, login_required, logout_user +from sqlalchemy import select + +from watchlist.models import User +from watchlist.extensions import db + +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + + if not username or not password: + flash('Invalid input.') + return redirect(url_for('auth.login')) + + user = db.session.execute(select(User).filter_by(username=username)).scalar() + + if user is not None and user.validate_password(password): + login_user(user) + flash('Login success.') + return redirect(url_for('main.index')) + + flash('Invalid username or password.') + return redirect(url_for('auth.login')) + + return render_template('login.html') + + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + flash('Goodbye.') + return redirect(url_for('main.index')) diff --git a/watchlist/blueprints/main.py b/watchlist/blueprints/main.py new file mode 100644 index 0000000..bfa7903 --- /dev/null +++ b/watchlist/blueprints/main.py @@ -0,0 +1,82 @@ +from flask import Blueprint, render_template, request, url_for, redirect, flash +from flask_login import login_required, current_user +from sqlalchemy import select + +from watchlist.models import User, Movie +from watchlist.extensions import db + +main_bp = Blueprint('main', __name__) + + +@main_bp.route('/', methods=['GET', 'POST']) +def index(): + if request.method == 'POST': + if not current_user.is_authenticated: + return redirect(url_for('main.index')) + + title = request.form['title'] + year = request.form['year'] + + if not title or not year or len(year) != 4 or len(title) > 60: + flash('Invalid input.') + return redirect(url_for('main.index')) + + movie = Movie(title=title, year=year) + db.session.add(movie) + db.session.commit() + flash('Item created.') + return redirect(url_for('main.index')) + + movies = db.session.execute(select(Movie)).scalars().all() + return render_template('index.html', movies=movies) + + +@main_bp.route('/movie/edit/', methods=['GET', 'POST']) +@login_required +def edit(movie_id): + movie = db.get_or_404(Movie, movie_id) + + if request.method == 'POST': + title = request.form['title'] + year = request.form['year'] + + if not title or not year or len(year) != 4 or len(title) > 60: + flash('Invalid input.') + return redirect(url_for('main.edit', movie_id=movie_id)) + + movie.title = title + movie.year = year + db.session.commit() + flash('Item updated.') + return redirect(url_for('main.index')) + + return render_template('edit.html', movie=movie) + + +@main_bp.route('/movie/delete/', methods=['POST']) +@login_required +def delete(movie_id): + movie = db.get_or_404(Movie, movie_id) + db.session.delete(movie) + db.session.commit() + flash('Item deleted.') + return redirect(url_for('main.index')) + + +@main_bp.route('/settings', methods=['GET', 'POST']) +@login_required +def settings(): + if request.method == 'POST': + name = request.form['name'] + + if not name or len(name) > 20: + flash('Invalid input.') + return redirect(url_for('main.settings')) + + user = db.session.get(User, current_user.id) + user.name = name + db.session.commit() + flash('Settings updated.') + return redirect(url_for('main.index')) + + return render_template('settings.html') diff --git a/watchlist/commands.py b/watchlist/commands.py index 0de3e5c..8b82457 100644 --- a/watchlist/commands.py +++ b/watchlist/commands.py @@ -1,65 +1,68 @@ import click -from watchlist import app, db +from watchlist.extensions import db from watchlist.models import User, Movie +def register_commands(app): -@app.cli.command() -@click.option('--drop', is_flag=True, help='Create after drop.') -def initdb(drop): - """Initialize the database.""" - if drop: - db.drop_all() - db.create_all() - click.echo('Initialized database.') + @app.cli.command('init-db') + @click.option('--drop', is_flag=True, help='Create after drop.') + def initdb(drop): + """Initialize the database.""" + if drop: + db.drop_all() + db.create_all() + click.echo('Initialized database.') -@app.cli.command() -def forge(): - """Generate fake data.""" - db.create_all() + @app.cli.command() + def forge(): + """Generate fake data.""" + db.drop_all() + db.create_all() - name = 'Grey Li' - movies = [ - {'title': 'My Neighbor Totoro', 'year': '1988'}, - {'title': 'Dead Poets Society', 'year': '1989'}, - {'title': 'A Perfect World', 'year': '1993'}, - {'title': 'Leon', 'year': '1994'}, - {'title': 'Mahjong', 'year': '1996'}, - {'title': 'Swallowtail Butterfly', 'year': '1996'}, - {'title': 'King of Comedy', 'year': '1999'}, - {'title': 'Devils on the Doorstep', 'year': '1999'}, - {'title': 'WALL-E', 'year': '2008'}, - {'title': 'The Pork of Music', 'year': '2012'}, - ] + name = 'Grey Li' + movies = [ + {'title': 'My Neighbor Totoro', 'year': '1988'}, + {'title': 'Dead Poets Society', 'year': '1989'}, + {'title': 'A Perfect World', 'year': '1993'}, + {'title': 'Leon', 'year': '1994'}, + {'title': 'Mahjong', 'year': '1996'}, + {'title': 'Swallowtail Butterfly', 'year': '1996'}, + {'title': 'King of Comedy', 'year': '1999'}, + {'title': 'Devils on the Doorstep', 'year': '1999'}, + {'title': 'WALL-E', 'year': '2008'}, + {'title': 'The Pork of Music', 'year': '2012'}, + ] - user = User(name=name) - db.session.add(user) - for m in movies: - movie = Movie(title=m['title'], year=m['year']) - db.session.add(movie) + user = User(name=name, username='admin') + user.set_password('helloflask') + db.session.add(user) + for m in movies: + movie = Movie(title=m['title'], year=m['year']) + db.session.add(movie) - db.session.commit() - click.echo('Done.') + db.session.commit() + click.echo('Done.') -@app.cli.command() -@click.option('--username', prompt=True, help='The username used to login.') -@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='The password used to login.') -def admin(username, password): - """Create user.""" - db.create_all() + @app.cli.command() + @click.option('--username', prompt=True, help='The username used to login.') + @click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='The password used to login.') + def admin(username, password): + """Create user.""" + db.create_all() - user = User.query.first() - if user is not None: - click.echo('Updating user...') - user.username = username - user.set_password(password) - else: - click.echo('Creating user...') - user = User(username=username, name='Admin') - user.set_password(password) - db.session.add(user) + user = User.query.first() + if user is not None: + click.echo('Updating user...') + user.username = username + user.set_password(password) + else: + click.echo('Creating user...') + user = User(username=username, name='Admin') + user.set_password(password) + db.session.add(user) - db.session.commit() - click.echo('Done.') + db.session.commit() + click.echo('Done.') diff --git a/watchlist/errors.py b/watchlist/errors.py index 30c6d34..57ae053 100644 --- a/watchlist/errors.py +++ b/watchlist/errors.py @@ -1,18 +1,18 @@ from flask import render_template -from watchlist import app +def register_errors(app): -@app.errorhandler(400) -def bad_request(e): - return render_template('errors/400.html'), 400 + @app.errorhandler(400) + def bad_request(error): + return render_template('errors/400.html'), 400 -@app.errorhandler(404) -def page_not_found(e): - return render_template('errors/404.html'), 404 + @app.errorhandler(404) + def page_not_found(error): + return render_template('errors/404.html'), 404 -@app.errorhandler(500) -def internal_server_error(e): - return render_template('errors/500.html'), 500 + @app.errorhandler(500) + def internal_server_error(error): + return render_template('errors/500.html'), 500 diff --git a/watchlist/extensions.py b/watchlist/extensions.py new file mode 100644 index 0000000..22bc6a2 --- /dev/null +++ b/watchlist/extensions.py @@ -0,0 +1,22 @@ +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +db = SQLAlchemy(model_class=Base) +login_manager = LoginManager() + + +@login_manager.user_loader +def load_user(user_id): + from watchlist.models import User + user = db.session.get(User, int(user_id)) + return user + + +login_manager.login_view = 'login' +# login_manager.login_message = 'Your custom message' diff --git a/watchlist/models.py b/watchlist/models.py index 182670c..f36f4a3 100644 --- a/watchlist/models.py +++ b/watchlist/models.py @@ -1,14 +1,19 @@ +from typing import Optional + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash -from watchlist import db +from watchlist.extensions import db class User(db.Model, UserMixin): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(20)) - username = db.Column(db.String(20)) - password_hash = db.Column(db.String(128)) + __tablename__ = 'user' + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(20)) + username: Mapped[str] = mapped_column(String(20)) + password_hash: Mapped[Optional[str]] = mapped_column(String(128)) def set_password(self, password): self.password_hash = generate_password_hash(password) @@ -18,6 +23,7 @@ def validate_password(self, password): class Movie(db.Model): - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(60)) - year = db.Column(db.String(4)) + __tablename__ = 'movie' + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(60)) + year: Mapped[str] = mapped_column(String(4)) diff --git a/watchlist/settings.py b/watchlist/settings.py new file mode 100644 index 0000000..d7a063a --- /dev/null +++ b/watchlist/settings.py @@ -0,0 +1,30 @@ +import os +import sys +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +SQLITE_PREFIX = 'sqlite:///' if sys.platform.startswith('win') else 'sqlite:////' + + +class BaseConfig: + SECRET_KEY = os.getenv('SECRET_KEY', 'dev') + + +class DevelopmentConfig(BaseConfig): + SQLALCHEMY_DATABASE_URI = SQLITE_PREFIX + str(BASE_DIR / 'data-dev.db') + + +class TestingConfig(BaseConfig): + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + + +class ProductionConfig(BaseConfig): + SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', SQLITE_PREFIX + str(BASE_DIR / 'data.db')) + + +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, +} diff --git a/watchlist/templates/base.html b/watchlist/templates/base.html index 2ae10bd..815f3b9 100644 --- a/watchlist/templates/base.html +++ b/watchlist/templates/base.html @@ -19,18 +19,18 @@

{% block content %}{% endblock %} diff --git a/watchlist/templates/errors/400.html b/watchlist/templates/errors/400.html index 64b8030..cdf09ff 100644 --- a/watchlist/templates/errors/400.html +++ b/watchlist/templates/errors/400.html @@ -5,7 +5,7 @@
  • Bad Request - 400 - Go Back + Go Back
  • diff --git a/watchlist/templates/errors/404.html b/watchlist/templates/errors/404.html index 45b7a80..c33726c 100644 --- a/watchlist/templates/errors/404.html +++ b/watchlist/templates/errors/404.html @@ -5,7 +5,7 @@
  • Page Not Found - 404 - Go Back + Go Back
  • diff --git a/watchlist/templates/errors/500.html b/watchlist/templates/errors/500.html index 10f0fde..245cb47 100644 --- a/watchlist/templates/errors/500.html +++ b/watchlist/templates/errors/500.html @@ -5,7 +5,7 @@
  • Internal Server Error - 500 - Go Back + Go Back
  • diff --git a/watchlist/templates/index.html b/watchlist/templates/index.html index 0306dc3..6830a72 100644 --- a/watchlist/templates/index.html +++ b/watchlist/templates/index.html @@ -14,8 +14,8 @@
  • {{ movie.title }} - {{ movie.year }} {% if current_user.is_authenticated %} - Edit -
    + Edit +
    {% endif %} diff --git a/watchlist/views.py b/watchlist/views.py deleted file mode 100644 index 1ff49f8..0000000 --- a/watchlist/views.py +++ /dev/null @@ -1,110 +0,0 @@ -from flask import render_template, request, url_for, redirect, flash -from flask_login import login_user, login_required, logout_user, current_user - -from watchlist import app, db -from watchlist.models import User, Movie - - -@app.route('/', methods=['GET', 'POST']) -def index(): - if request.method == 'POST': - if not current_user.is_authenticated: - return redirect(url_for('index')) - - title = request.form['title'] - year = request.form['year'] - - if not title or not year or len(year) != 4 or len(title) > 60: - flash('Invalid input.') - return redirect(url_for('index')) - - movie = Movie(title=title, year=year) - db.session.add(movie) - db.session.commit() - flash('Item created.') - return redirect(url_for('index')) - - movies = Movie.query.all() - return render_template('index.html', movies=movies) - - -@app.route('/movie/edit/', methods=['GET', 'POST']) -@login_required -def edit(movie_id): - movie = Movie.query.get_or_404(movie_id) - - if request.method == 'POST': - title = request.form['title'] - year = request.form['year'] - - if not title or not year or len(year) != 4 or len(title) > 60: - flash('Invalid input.') - return redirect(url_for('edit', movie_id=movie_id)) - - movie.title = title - movie.year = year - db.session.commit() - flash('Item updated.') - return redirect(url_for('index')) - - return render_template('edit.html', movie=movie) - - -@app.route('/movie/delete/', methods=['POST']) -@login_required -def delete(movie_id): - movie = Movie.query.get_or_404(movie_id) - db.session.delete(movie) - db.session.commit() - flash('Item deleted.') - return redirect(url_for('index')) - - -@app.route('/settings', methods=['GET', 'POST']) -@login_required -def settings(): - if request.method == 'POST': - name = request.form['name'] - - if not name or len(name) > 20: - flash('Invalid input.') - return redirect(url_for('settings')) - - user = User.query.first() - user.name = name - db.session.commit() - flash('Settings updated.') - return redirect(url_for('index')) - - return render_template('settings.html') - - -@app.route('/login', methods=['GET', 'POST']) -def login(): - if request.method == 'POST': - username = request.form['username'] - password = request.form['password'] - - if not username or not password: - flash('Invalid input.') - return redirect(url_for('login')) - - user = User.query.first() - - if username == user.username and user.validate_password(password): - login_user(user) - flash('Login success.') - return redirect(url_for('index')) - - flash('Invalid username or password.') - return redirect(url_for('login')) - - return render_template('login.html') - - -@app.route('/logout') -@login_required -def logout(): - logout_user() - flash('Goodbye.') - return redirect(url_for('index')) diff --git a/wsgi.py b/wsgi.py index 16aade3..b94aef1 100644 --- a/wsgi.py +++ b/wsgi.py @@ -6,4 +6,6 @@ if os.path.exists(dotenv_path): load_dotenv(dotenv_path) -from watchlist import app +from watchlist import create_app + +app = create_app(config_name='production')