Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Flaskr: Intro to Flask, Test-Driven Development (TDD), and JavaScript

License

NotificationsYou must be signed in to change notification settings

mjhea0/flaskr-tdd

Repository files navigation

As many of you know, Flaskr -- a mini-blog-like-app -- is the app that you build for the official Flasktutorial. I've gone through the tutorial more times than I care to admit. Anyway, I wanted to take the tutorial a step further by addingTest-Driven Development (TDD), a bit of JavaScript, and deployment. This article is that tutorial. Enjoy.

Also, if you're completely new to Flask and/or web development in general, it's important to grasp these basic fundamental concepts:

  1. The difference between HTTP GET and POST requests and how functions within the app handle each.
  2. What HTTP "requests" and "responses" are.
  3. How HTML pages are rendered and/or returned to the end user.

This project is powered byTestDriven.io. Please support this open source project by purchasing one of our Flask courses. Learn how to build, test, and deploy microservices powered by Docker, Flask, and React!

What you're building

You'll be building a simple blogging app in this tutorial:

flaskr app

Changelog

This tutorial was last updated on October 17th, 2023:

  • 10/17/2023:
    • Updated to Python 3.12.0 and bumped all other dependencies.
  • 06/03/2022:
    • Updated to Python 3.10.4 and bumped all other dependencies.
  • 10/14/2020:
    • Renamedapp.test.py toapp_test.py. (Fixed issue #58.)
    • Updated to Python 3.9 and bumped all other dependencies.
    • Added pytest v7.1.2. (Fixed issue #60)
    • Migrated fromos.path topathlib.
  • 11/05/2019:
    • Updated to Python 3.8.0, Flask 1.1.1, and Bootstrap 4.3.1.
    • Replaced jQuery with vanilla JavaScript.
    • Added Black and Flake8.
    • Used Postgres in production.
    • Restricted post delete requests.
  • 10/07/2018: Updated to Python 3.7.0.
  • 05/10/2018: Updated to Python 3.6.5, Flask 1.0.2, Bootstrap 4.1.1.
  • 10/16/2017:
    • Updated to Python 3.6.2.
    • Updated to Bootstrap 4.
  • 10/10/2017: Added a search feature.
  • 07/03/2017: Updated to Python 3.6.1.
  • 01/24/2016: Updated to Python 3 (v3.5.1)!
  • 08/24/2014: PEP8 updates.
  • 02/25/2014: Upgraded to SQLAlchemy.
  • 02/20/2014: Completed AJAX.
  • 12/06/2013: Added Bootstrap 3 styles
  • 11/29/2013: Updated unit tests.
  • 11/19/2013: Fixed typo. Updated unit tests.
  • 11/11/2013: Added information on requests.

Contents

  1. Test Driven Development?
  2. Download Python
  3. Project Setup
  4. First Test
  5. Flaskr Setup
  6. Second Test
  7. Database Setup
  8. Templates and Views
  9. Add Some Style
  10. JavaScript
  11. Deployment
  12. Bootstrap
  13. SQLAlchemy
  14. Search Page
  15. Login Required
  16. Postgres Heroku
  17. Linting and Code Formatting
  18. Conclusion

Requirements

This tutorial utilizes the following requirements:

  1. Python v3.12.0
  2. Flask v3.0.0
  3. Flask-SQLAlchemy v3.1.1
  4. Gunicorn v21.2.0
  5. Psycopg2 v2.9.9
  6. Flake8 v6.1.0
  7. Black v23.10.0
  8. pytest v7.4.2

Test Driven Development?

tdd

Test-Driven Development (TDD) is an iterative development cycle that emphasizes writing automated tests before writing the actual feature or function. Put another way, TDD combines building and testing. This process not only helps ensure correctness of the code -- but also helps to indirectly evolve the design and architecture of the project at hand.

TDD usually follows the "Red-Green-Refactor" cycle, as shown in the image above:

  1. Write a test
  2. Run the test (it should fail)
  3. Write just enough code for the test to pass
  4. Refactor code and retest, again and again (if necessary)

For more, check outWhat is Test-Driven Development?.

Download Python

Before beginning make sure you have the latest version ofPython 3.12 installed, which you can download fromhttp://www.python.org/download/.

This tutorial uses Python v3.12.0.

Along with Python, the following tools are also installed:

  • pip - apackage management system for Python, similar to gem or npm for Ruby and Node, respectively.
  • venv - used to create isolated environments for development. This is standard practice. Always, always, ALWAYS utilize virtual environments. If you don't, you'll eventually run into problems with dependency conflicts.

Feel free to swap out virtualenv and Pip forPoetry orPipenv. For more, reviewModern Python Environments.

Project Setup

Create a new directory to store the project:

$ mkdir flaskr-tdd$cd flaskr-tdd

Create and activate a virtual environment:

$ python3.12 -m venv env$source env/bin/activate(env)$

You know that you're in a virtual environment whenenv is displayed before the$ in your terminal:(env)$. To exit the virtual environment, use the commanddeactivate. You can reactivate by navigating back to the project directory and runningsource env/bin/activate.

Install Flask with pip:

(env)$ pip install flask==3.0.0

First Test

Let's start with a simple "hello, world" app.

Create the following files and folders:

├── project│   ├── __init__.py│   ├── app.py└── tests    ├── __init__.py    └── app_test.py

While the Python standard library comes with a unit testing framework called ùnittest,pytest is the go-to testing framework for testing Python code.

For more on pytest, check outPytest for Beginners.

Install it:

(env)$ pip install pytest==7.4.2

Opentests/app_test.py in your favorite text editor -- likeVisual Studio Code,Sublime Text, orPyCharm -- and then add the following code:

fromproject.appimportappdeftest_index():tester=app.test_client()response=tester.get("/",content_type="html/text")assertresponse.status_code==200assertresponse.data==b"Hello, World!"

Essentially, we're testing whether the response that we get back has a status code of "200" and that "Hello, World!" is displayed.

Run the test:

(env)$ python -m pytest

If all goes well, this test will fail:

ImportError: cannot import name'app' from'project.app

Now add the code for this to pass toproject/app.py:

fromflaskimportFlask# create and initialize a new Flask appapp=Flask(__name__)@app.route("/")defhello():return"Hello, World!"if__name__=="__main__":app.run()

Run the app:

(env)$ FLASK_APP=project/app.py python -m flask run -p 5001

TheFLASK_APP environment variable is used to tell Flask to look for the application in a different module.

Then, navigate tohttp://localhost:5001/ in your browser of choice. You should see "Hello, World!" on your screen.

Return to the terminal. Kill the server with Ctrl+C.

Run the test again:

(env)$ python -m pytest===============================test session starts ===============================platform darwin -- Python 3.10.4, pytest-7.4.2, pluggy-1.0.0rootdir: /Users/michael/repos/github/flaskr-tddcollected 1 itemtests/app_test.py.                                                         [100%]================================ 1 passedin 0.10s ================================

Nice.

Database Setup

Essentially, we want to open a database connection, create the database based on a defined schema if it doesn't already exist, and then close the connection each time a test is ran.

Create a new file calledschema.sql in "project" and add the following code:

droptable if exists entries;createtableentries (  idintegerprimary key autoincrement,  titletextnot null,texttextnot null);

This will set up a single table with three fields: "id", "title", and "text". SQLite will be used for our RDMS since it's part of the standard Python library and requires no configuration.

Updateapp.py:

fromflaskimportFlask# configurationDATABASE="flaskr.db"# create and initialize a new Flask appapp=Flask(__name__)# load the configapp.config.from_object(__name__)@app.route("/")defhello():return"Hello, World!"if__name__=="__main__":app.run()

Here, we created a configuration section for config variables (with the name of the future SQLite database) and loaded the config after app initialization.

How do we test for the existence of a file? Updateapp_test.py like so:

frompathlibimportPathfromproject.appimportappdeftest_index():tester=app.test_client()response=tester.get("/",content_type="html/text")assertresponse.status_code==200assertresponse.data==b"Hello, World!"deftest_database():assertPath("flaskr.db").is_file()

Run it to make sure it fails, indicating that the database does not exist.

Now add the following code toapp.py, just before thehello view function:

# connect to databasedefconnect_db():"""Connects to the database."""rv=sqlite3.connect(app.config["DATABASE"])rv.row_factory=sqlite3.Rowreturnrv# create the databasedefinit_db():withapp.app_context():db=get_db()withapp.open_resource("schema.sql",mode="r")asf:db.cursor().executescript(f.read())db.commit()# open database connectiondefget_db():ifnothasattr(g,"sqlite_db"):g.sqlite_db=connect_db()returng.sqlite_db# close database connection@app.teardown_appcontextdefclose_db(error):ifhasattr(g,"sqlite_db"):g.sqlite_db.close()

Add the imports:

importsqlite3fromflaskimportFlask,g

Curious aboutg object? Check out theUnderstanding the Application and Request Contexts in Flask for more.

You should now have:

importsqlite3fromflaskimportFlask,g# configurationDATABASE="flaskr.db"# create and initialize a new Flask appapp=Flask(__name__)# load the configapp.config.from_object(__name__)# connect to databasedefconnect_db():"""Connects to the database."""rv=sqlite3.connect(app.config["DATABASE"])rv.row_factory=sqlite3.Rowreturnrv# create the databasedefinit_db():withapp.app_context():db=get_db()withapp.open_resource("schema.sql",mode="r")asf:db.cursor().executescript(f.read())db.commit()# open database connectiondefget_db():ifnothasattr(g,"sqlite_db"):g.sqlite_db=connect_db()returng.sqlite_db# close database connection@app.teardown_appcontextdefclose_db(error):ifhasattr(g,"sqlite_db"):g.sqlite_db.close()@app.route("/")defhello():return"Hello, World!"if__name__=="__main__":app.run()

Now, create a database by starting up a Python shell and importing and then calling theinit_db function:

>>>fromproject.appimportinit_db>>>init_db()

Close the shell, then run the test again. Does it pass? It should. Now we know that the database has been created.

You can also callinit_db within the test, to ensure that the test can be ran independently:

frompathlibimportPathfromproject.appimportapp,init_dbdeftest_index():tester=app.test_client()response=tester.get("/",content_type="html/text")assertresponse.status_code==200assertresponse.data==b"Hello, World!"deftest_database():init_db()assertPath("flaskr.db").is_file()

Updated structure:

├── flaskr.db├── project│   ├── __init__.py│   ├── app.py│   └── schema.sql└── tests    ├── __init__.py    └── app_test.py

Templates and Views

Next, we need to set up the templates and the associated views, which define the routes. Think about this from a user's standpoint:

  1. Users should be able to log in and out.
  2. Once logged in, users should be able to post new messages.
  3. Finally, users should be able to view the messages.

Write some tests for this first.

Tests

Take a look at the final code below. I added docstrings for explanation.

importosimportpytestfrompathlibimportPathfromproject.appimportapp,init_dbTEST_DB="test.db"@pytest.fixturedefclient():BASE_DIR=Path(__file__).resolve().parent.parentapp.config["TESTING"]=Trueapp.config["DATABASE"]=BASE_DIR.joinpath(TEST_DB)init_db()# setupyieldapp.test_client()# tests run hereinit_db()# teardowndeflogin(client,username,password):"""Login helper function"""returnclient.post("/login",data=dict(username=username,password=password),follow_redirects=True,    )deflogout(client):"""Logout helper function"""returnclient.get("/logout",follow_redirects=True)deftest_index(client):response=client.get("/",content_type="html/text")assertresponse.status_code==200deftest_database(client):"""initial test. ensure that the database exists"""tester=Path("test.db").is_file()asserttesterdeftest_empty_db(client):"""Ensure database is blank"""rv=client.get("/")assertb"No entries yet. Add some!"inrv.datadeftest_login_logout(client):"""Test login and logout using helper functions"""rv=login(client,app.config["USERNAME"],app.config["PASSWORD"])assertb"You were logged in"inrv.datarv=logout(client)assertb"You were logged out"inrv.datarv=login(client,app.config["USERNAME"]+"x",app.config["PASSWORD"])assertb"Invalid username"inrv.datarv=login(client,app.config["USERNAME"],app.config["PASSWORD"]+"x")assertb"Invalid password"inrv.datadeftest_messages(client):"""Ensure that user can post messages"""login(client,app.config["USERNAME"],app.config["PASSWORD"])rv=client.post("/add",data=dict(title="<Hello>",text="<strong>HTML</strong> allowed here"),follow_redirects=True,    )assertb"No entries here so far"notinrv.dataassertb"&lt;Hello&gt;"inrv.dataassertb"<strong>HTML</strong> allowed here"inrv.data

Take note of theclient function. This is a pytestfixture, which sets up a known state for each test function before the test runs.

Run the tests now:

(env)$ python -m pytest

Three tests should fail:

===============================test session starts ===============================platform darwin -- Python 3.10.4, pytest-7.4.2, pluggy-1.0.0rootdir: /Users/michael/repos/github/flaskr-tddcollected 5 itemstests/app_test.py ..FFF                                                     [100%]==================================== FAILURES =====================================__________________________________ test_empty_db __________________________________client =<FlaskClient<Flask'project.app'>>    def test_empty_db(client):"""Ensure database is blank"""        rv = client.get("/")>       assert b"No entries yet. Add some!"in rv.dataE       AssertionError: assert b'No entries yet. Add some!'in b'Hello, World!'E        +  where b'Hello, World!' =<WrapperTestResponse 13 bytes [200 OK]>.datatests/app_test.py:49: AssertionError________________________________ test_login_logout ________________________________client =<FlaskClient<Flask'project.app'>>    def test_login_logout(client):"""Test login and logout using helper functions""">       rv = login(client, app.config["USERNAME"], app.config["PASSWORD"])E       KeyError:'USERNAME'tests/app_test.py:54: KeyError__________________________________ test_messages __________________________________client =<FlaskClient<Flask'project.app'>>    def test_messages(client):"""Ensure that user can post messages""">       login(client, app.config["USERNAME"], app.config["PASSWORD"])E       KeyError:'USERNAME'tests/app_test.py:66: KeyError============================= shorttest summary info =============================FAILED tests/app_test.py::test_empty_db -    AssertionError: assert b'No entries yet. Add some!'in b'Hello, World!'FAILED tests/app_test.py::test_login_logout - KeyError:'USERNAME'FAILED tests/app_test.py::test_messages - KeyError:'USERNAME'=========================== 3 failed, 2 passedin 0.17s ==========================

Let's get these all green, one at a time...

Show Entries

First, replace thehello view with the following view function for displaying the entries toapp.py:

@app.route('/')defindex():"""Searches the database for entries, then displays them."""db=get_db()cur=db.execute('select * from entries order by id desc')entries=cur.fetchall()returnrender_template('index.html',entries=entries)

Import inrender_template:

fromflaskimportFlask,g,render_template

Then, create a new folder called "templates" inside of "project", and add anindex.html template file to it:

<!DOCTYPE html><html><head><title>Flaskr</title><linkrel="stylesheet"type="text/css"href="{{ url_for('static', filename='style.css') }}"/></head><body><divclass="page"><h1>Flaskr-TDD</h1><divclass="metanav">        {% if not session.logged_in %}<ahref="{{ url_for('login') }}">log in</a>        {% else %}<ahref="{{ url_for('logout') }}">log out</a>        {% endif %}</div>      {% for message in get_flashed_messages() %}<divclass="flash">{{ message }}</div>      {% endfor %} {% block body %}{% endblock %} {% if session.logged_in %}<formaction="{{ url_for('add_entry') }}"method="post"class="add-entry"><dl><dt>Title:</dt><dd><inputtype="text"size="30"name="title"/></dd><dt>Text:</dt><dd><textareaname="text"rows="5"cols="40"></textarea></dd><dd><inputtype="submit"value="Share"/></dd></dl></form>      {% endif %}<ulclass="entries">        {% for entry in entries %}<li><h2>{{ entry.title }}</h2>          {{ entry.text|safe }}</li>        {% else %}<li><em>No entries yet. Add some!</em></li>        {% endfor %}</ul></div></body></html>

User Login and Logout

Updateapp.py:

@app.route('/login',methods=['GET','POST'])deflogin():"""User login/authentication/session management."""error=Noneifrequest.method=='POST':ifrequest.form['username']!=app.config['USERNAME']:error='Invalid username'elifrequest.form['password']!=app.config['PASSWORD']:error='Invalid password'else:session['logged_in']=Trueflash('You were logged in')returnredirect(url_for('index'))returnrender_template('login.html',error=error)@app.route('/logout')deflogout():"""User logout/authentication/session management."""session.pop('logged_in',None)flash('You were logged out')returnredirect(url_for('index'))

In the abovelogin function, the decorator indicates that the route can accept either a GET or POST request. Put simply, a request is initiated by the end user when they access the/login URL. The difference between these requests is simple: GET is used for accessing a webpage, while POST is used when information is sent to the server. Thus, when a user accesses the/login URL, they are using a GET request, but when they attempt to log in, a POST request is used.

Update the config as well:

# configurationDATABASE="flaskr.db"USERNAME="admin"PASSWORD="admin"SECRET_KEY="change_me"

Add the appropriate imports:

fromflaskimportFlask,g,render_template,request,session,flash,redirect,url_for

Add thelogin.html template:

<!DOCTYPE html><html><head><title>Flaskr-TDD | Login</title><linkrel="stylesheet"type="text/css"href="{{ url_for('static', filename='style.css') }}"/></head><body><divclass="page"><h1>Flaskr</h1><divclass="metanav">        {% if not session.logged_in %}<ahref="{{ url_for('login') }}">log in</a>        {% else %}<ahref="{{ url_for('logout') }}">log out</a>        {% endif %}</div>      {% for message in get_flashed_messages() %}<divclass="flash">{{ message }}</div>      {% endfor %} {% block body %}{% endblock %}<h2>Login</h2>      {% if error %}<pclass="error"><strong>Error:</strong> {{ error }}</p>      {% endif %}<formaction="{{ url_for('login') }}"method="post"><dl><dt>Username:</dt><dd><inputtype="text"name="username"/></dd><dt>Password:</dt><dd><inputtype="password"name="password"/></dd><dd><inputtype="submit"value="Login"/></dd></dl></form></div></body></html>

Run the tests again. You should see two errors:

E           werkzeug.routing.BuildError: Could not build urlfor endpoint'add_entry'. Did you mean'login' instead?

Next, add in a view for adding entries:

@app.route('/add',methods=['POST'])defadd_entry():"""Add new post to database."""ifnotsession.get('logged_in'):abort(401)db=get_db()db.execute('insert into entries (title, text) values (?, ?)',        [request.form['title'],request.form['text']]    )db.commit()flash('New entry was successfully posted')returnredirect(url_for('index'))

Add the appropriate imports:

fromflaskimportFlask,g,render_template,request,session,flash,redirect,url_for,abort

Retest:

===============================test session starts ===============================platform darwin -- Python 3.10.4, pytest-7.4.2, pluggy-1.0.0rootdir: /Users/michael/repos/github/flaskr-tddcollected 5 itemstests/app_test.py .....                                                     [100%]================================ 5 passedin 0.16s ================================

Perfect.

Add Some Style

Save the following styles to a new file calledstyle.css in a new folder called "project/static":

body {font-family: sans-serif;background:#eee;}a,h1,h2 {color:#377ba8;}h1,h2 {font-family:"Georgia", serif;margin:0;}h1 {border-bottom:2px solid#eee;}h2 {font-size:1.2em;}.page {margin:2em auto;width:35em;border:5px solid#ccc;padding:0.8em;background: white;}.entries {list-style: none;margin:0;padding:0;}.entriesli {margin:0.8em1.2em;}.entrieslih2 {margin-left:-1em;}.add-entry {font-size:0.9em;border-bottom:1px solid#ccc;}.add-entrydl {font-weight: bold;}.metanav {text-align: right;font-size:0.8em;padding:0.3em;margin-bottom:1em;background:#fafafa;}.flash {background:#cee5f5;padding:0.5em;border:1px solid#aacbe2;}.error {background:#f0d6d6;padding:0.5em;}

Run your app, log in (username/password = "admin"), add a post, log out.

JavaScript

Next, let's add some JavaScript to make the app slightly more interactive.

Openindex.html and update the first<li> like so:

<liclass="entry"><h2id="{{ entry.id }}">{{ entry.title }}</h2>  {{ entry.text|safe }}</li>

Now, we can use JavaScript to target each<li>. First, we need to add the following script to the document just before the closing body tag:

<scripttype="text/javascript"src="{{url_for('static', filename='main.js') }}"></script>

Create amain.js file in your "static" directory and add the following code:

(function(){console.log("ready!");// sanity check})();constpostElements=document.getElementsByClassName("entry");for(vari=0;i<postElements.length;i++){postElements[i].addEventListener("click",function(){constpostId=this.getElementsByTagName("h2")[0].getAttribute("id");constnode=this;fetch(`/delete/${postId}`).then(function(resp){returnresp.json();}).then(function(result){if(result.status===1){node.parentNode.removeChild(node);console.log(result);}location.reload();}).catch(function(err){console.log(err);});});}

Add a new function inapp.py to remove the post from the database:

@app.route('/delete/<post_id>',methods=['GET'])defdelete_entry(post_id):"""Delete post from database"""result= {'status':0,'message':'Error'}try:db=get_db()db.execute('delete from entries where id='+post_id)db.commit()result= {'status':1,'message':"Post Deleted"}exceptExceptionase:result= {'status':0,'message':repr(e)}returnjsonify(result)

Update the imports:

fromflaskimportFlask,g,render_template,request,session,flash,redirect,url_for,abort,jsonify

Finally, add a new test:

deftest_delete_message(client):"""Ensure the messages are being deleted"""rv=client.get('/delete/1')data=json.loads(rv.data)assertdata["status"]==1

Make sure to add the following import as well:import json.

Manually test this out by running the server and adding two new entries. Click on one of them. It should be removed from the DOM as well as the database. Double check this.

Then run your automated test suite. It should pass:

(env)$ python -m pytest===============================test session starts ===============================platform darwin -- Python 3.10.4, pytest-7.4.2, pluggy-1.0.0rootdir: /Users/michael/repos/github/flaskr-tddcollected 6 itemstests/app_test.py ......                                                    [100%]================================ 6 passedin 0.17s ================================

Deployment

With the app in a working state, let's shift gears and deploy the app toHeroku. To do this, firstsign up, and then install theHeroku CLI.

Next, install a production-grade WSGI web server calledGunicorn:

(env)$ pip install gunicorn==21.2.0

Create aProcfile in the project root:

(env)$ touch Procfile

And add the following code:

web: gunicorn project.app:app

Create arequirements.txt file to specify the external dependencies that need to be installed for the app to work:

(env)$ touch requirements.txt

Add the requirements:

Flask==3.0.0gunicorn==21.2.0pytest==7.4.2

Create a.gitignore file in the project root:

(env)$ touch .gitignore

And include the following files and folders (so they are not included in version control):

env*.pyc*.DS_Store__pycache__test.db

To specify the correct Python runtime, add a new file to the project root calledruntime.txt:

python-3.12.0

Add a local Git repo:

(env)$ git init(env)$ git add -A(env)$ git commit -m"initial"

Deploy to Heroku:

(env)$ heroku create(env)$ git push heroku main

Let's test this in the cloud. Runheroku open to open the app in your default web browser.

Bootstrap

Let's update the styles withBootstrap.

First, remove thestyle.css stylesheet from bothindex.html andlogin.html. Then add this stylesheet to both files:

<linkrel="stylesheet"type="text/css"href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"/>

Now, we have full access to all of the Bootstrap helper classes.

Replace the code inlogin.html with:

<!DOCTYPE html><html><head><title>Flaskr-TDD | Login</title><linkrel="stylesheet"type="text/css"href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"/></head><body><divclass="container"><br/><br/><h1>Flaskr</h1><br/><br/>      {% for message in get_flashed_messages() %}<divclass="flash alert alert-success col-sm-4"role="success">        {{ message }}</div>      {% endfor %}<h3>Login</h3>      {% if error %}<pclass="alert alert-danger col-sm-4"role="danger"><strong>Error:</strong> {{ error }}</p>      {% endif %}<formaction="{{ url_for('login') }}"method="post"class="form-group"><dl><dt>Username:</dt><dd><inputtype="text"name="username"class="form-control col-sm-4"/></dd><dt>Password:</dt><dd><inputtype="password"name="password"class="form-control col-sm-4"/></dd><br/><br/><dd><inputtype="submit"class="btn btn-primary"value="Login"/></dd><span>Use "admin" for username and password</span></dl></form></div><scripttype="text/javascript"src="{{url_for('static', filename='main.js') }}"></script></body></html>

And replace the code inindex.html with:

<!DOCTYPE html><html><head><title>Flaskr</title><linkrel="stylesheet"type="text/css"href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"/></head><body><divclass="container"><br/><br/><h1>Flaskr</h1><br/><br/>      {% if not session.logged_in %}<aclass="btn btn-success"role="button"href="{{ url_for('login') }}">log in</a>      {% else %}<aclass="btn btn-warning"role="button"href="{{ url_for('logout') }}">log out</a>      {% endif %}<br/><br/>      {% for message in get_flashed_messages() %}<divclass="flash alert alert-success col-sm-4"role="success">        {{ message }}</div>      {% endfor %} {% if session.logged_in %}<formaction="{{ url_for('add_entry') }}"method="post"class="add-entry form-group"><dl><dt>Title:</dt><dd><inputtype="text"size="30"name="title"class="form-control col-sm-4"/></dd><dt>Text:</dt><dd><textareaname="text"rows="5"cols="40"class="form-control col-sm-4"></textarea></dd><br/><br/><dd><inputtype="submit"class="btn btn-primary"value="Share"/></dd></dl></form>      {% endif %}<br/><ulclass="entries">        {% for entry in entries %}<liclass="entry"><h2id="{{ entry.id }}">{{ entry.title }}</h2>          {{ entry.text|safe }}</li>        {% else %}<li><em>No entries yet. Add some!</em></li>        {% endfor %}</ul></div><scripttype="text/javascript"src="{{url_for('static', filename='main.js') }}"></script></body></html>

Run the app locally:

(env)$ FLASK_APP=project/app.py python -m flask run -p 5001

Check out the changes in the browser!

SQLAlchemy

Let's upgrade toFlask-SQLAlchemy, in order to better manage the database.

Setup

Start by installing Flask-SQLAlchemy:

(env)$ pip install Flask-SQLAlchemy==3.1.1

Make sure to add it to your requirements file as well.

Next, add acreate_db.py file to the project root. Then, add the following code:

# create_db.pyfromproject.appimportapp,dbfromproject.modelsimportPostwithapp.app_context():# create the database and the db tabledb.create_all()# commit the changesdb.session.commit()

This file will be used to create our new database. Go ahead and delete the old database file (flaskr.db) along with theproject/schema.sql file.

Next, add aproject/models.py file, which will be used to generate the new schema:

fromproject.appimportdbclassPost(db.Model):id=db.Column(db.Integer,primary_key=True)title=db.Column(db.String,nullable=False)text=db.Column(db.String,nullable=False)def__init__(self,title,text):self.title=titleself.text=textdef__repr__(self):returnf'<title{self.title}>'

Updateapp.py

importsqlite3frompathlibimportPathfromflaskimportFlask,g,render_template,request,session, \flash,redirect,url_for,abort,jsonifyfromflask_sqlalchemyimportSQLAlchemybasedir=Path(__file__).resolve().parent# configurationDATABASE="flaskr.db"USERNAME="admin"PASSWORD="admin"SECRET_KEY="change_me"SQLALCHEMY_DATABASE_URI=f'sqlite:///{Path(basedir).joinpath(DATABASE)}'SQLALCHEMY_TRACK_MODIFICATIONS=False# create and initialize a new Flask appapp=Flask(__name__)# load the configapp.config.from_object(__name__)# init sqlalchemydb=SQLAlchemy(app)fromprojectimportmodels@app.route('/')defindex():"""Searches the database for entries, then displays them."""entries=db.session.query(models.Post)returnrender_template('index.html',entries=entries)@app.route('/add',methods=['POST'])defadd_entry():"""Adds new post to the database."""ifnotsession.get('logged_in'):abort(401)new_entry=models.Post(request.form['title'],request.form['text'])db.session.add(new_entry)db.session.commit()flash('New entry was successfully posted')returnredirect(url_for('index'))@app.route('/login',methods=['GET','POST'])deflogin():"""User login/authentication/session management."""error=Noneifrequest.method=='POST':ifrequest.form['username']!=app.config['USERNAME']:error='Invalid username'elifrequest.form['password']!=app.config['PASSWORD']:error='Invalid password'else:session['logged_in']=Trueflash('You were logged in')returnredirect(url_for('index'))returnrender_template('login.html',error=error)@app.route('/logout')deflogout():"""User logout/authentication/session management."""session.pop('logged_in',None)flash('You were logged out')returnredirect(url_for('index'))@app.route('/delete/<int:post_id>',methods=['GET'])defdelete_entry(post_id):"""Deletes post from database."""result= {'status':0,'message':'Error'}try:db.session.query(models.Post).filter_by(id=post_id).delete()db.session.commit()result= {'status':1,'message':"Post Deleted"}flash('The entry was deleted.')exceptExceptionase:result= {'status':0,'message':repr(e)}returnjsonify(result)if__name__=="__main__":app.run()

Notice the changes in the config at the top as well since the means in which we're now accessing and manipulating the database in each view function -- via SQLAlchemy instead of vanilla SQL.

Create the DB

Run the following command to create the initial database:

(env)$ python create_db.py

Tests

Finally, update theclient fixture in the tests:

@pytest.fixturedefclient():BASE_DIR=Path(__file__).resolve().parent.parentapp.config["TESTING"]=Trueapp.config["DATABASE"]=BASE_DIR.joinpath(TEST_DB)app.config["SQLALCHEMY_DATABASE_URI"]=f"sqlite:///{BASE_DIR.joinpath(TEST_DB)}"withapp.app_context():db.create_all()# setupyieldapp.test_client()# tests run heredb.drop_all()# teardown

Update the imports as well:

fromproject.appimportapp,db

Ensure the tests pass:

(env)$ python -m pytest===============================test session starts ===============================platform darwin -- Python 3.10.4, pytest-7.4.2, pluggy-1.0.0rootdir: /Users/michael/repos/github/flaskr-tddcollected 6 itemstests/app_test.py ......                                                    [100%]================================ 6 passedin 0.34s ================================

Manually test the app as well by running the server and logging in and out, adding new entries, and deleting old entries.

If all is well, Update the requirements file:

Flask==3.0.0Flask-SQLAlchemy==3.1.1gunicorn==21.2.0pytest==7.4.2

Commit your code, and then push the new version to Heroku!

Search Page

Let's add a search page. It will be a nice feature that will come in handy after we have a number of posts.

Updateapp.py

@app.route('/search/',methods=['GET'])defsearch():query=request.args.get("query")entries=db.session.query(models.Post)ifquery:returnrender_template('search.html',entries=entries,query=query)returnrender_template('search.html')

Be sure to write a test for this on your own!

Addsearch.html

In the "templates" folder create a new file calledsearch.html:

(env)$ touch search.html

Now add the following code tosearch.html:

<!DOCTYPE html><html><head><title>Flaskr</title><linkrel="stylesheet"type="text/css"href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"/></head><body><divclass="container"><br/><br/><h1>Flaskr</h1><br/><br/><aclass="btn btn-primary"role="button"href="{{ url_for('index') }}">        Home</a>      {% if not session.logged_in %}<aclass="btn btn-success"role="button"href="{{ url_for('login') }}">log in</a>      {% else %}<aclass="btn btn-warning"role="button"href="{{ url_for('logout') }}">log out</a>      {% endif %}<br/><br/>      {% for message in get_flashed_messages() %}<divclass="flash alert alert-success col-sm-4"role="success">        {{ message }}</div>      {% endfor %}<formaction="{{ url_for('search') }}"method="get"class="from-group"><dl><dt>Search:</dt><dd><inputtype="text"name="query"class="form-control col-sm-4"/></dd><br/><dd><inputtype="submit"class="btn btn-info"value="Search"/></dd></dl></form><ulclass="entries">        {% for entry in entries %} {% if query.lower() in entry.title.lower() or        query.lower() in entry.text.lower() %}<liclass="entry"><h2id="{{ entry.post_id }}">{{ entry.title }}</h2>          {{ entry.text|safe }}</li>        {% endif %} {% endfor %}</ul></div><scripttype="text/javascript"src="{{url_for('static', filename='main.js') }}"></script></body></html>

Updateindex.html

Add a search button for better navigation just below<h1>Flaskr</h1>:

<aclass="btn btn-info"role="button"href="{{ url_for('search') }}">Search</a>

Test it out locally. If all is well, commit your code and update the version on Heroku.

Login Required

Currently, posts can be deleted by anyone. Let's change that so one has to be logged in before they can delete a post.

Add the following decorator toapp.py:

deflogin_required(f):@wraps(f)defdecorated_function(*args,**kwargs):ifnotsession.get('logged_in'):flash('Please log in.')returnjsonify({'status':0,'message':'Please log in.'}),401returnf(*args,**kwargs)returndecorated_function

Don't forget the import:

fromfunctoolsimportwraps

Be sure to write tests for this on your own!

Next, add the decorator to thedelete_entry view:

@app.route('/delete/<int:post_id>',methods=['GET'])@login_requireddefdelete_entry(post_id):"""Deletes post from database."""result= {'status':0,'message':'Error'}try:new_id=post_iddb.session.query(models.Post).filter_by(id=new_id).delete()db.session.commit()result= {'status':1,'message':"Post Deleted"}flash('The entry was deleted.')exceptExceptionase:result= {'status':0,'message':repr(e)}returnjsonify(result)

Update the test:

deftest_delete_message(client):"""Ensure the messages are being deleted"""rv=client.get("/delete/1")data=json.loads(rv.data)assertdata["status"]==0login(client,app.config["USERNAME"],app.config["PASSWORD"])rv=client.get("/delete/1")data=json.loads(rv.data)assertdata["status"]==1

Test it out locally again. If all is well, commit your code and update the version on Heroku.

Postgres Heroku

SQLite is a great database to use in order to get an app up and running quickly. That said, it's not intended to be used as a production grade database. So, let's move to using Postgres on Heroku.

Start by provisioning a newmini plan Postgres database:

(env)$ heroku addons:create heroku-postgresql:mini

Once created, the database URL can be access via theDATABASE_URL environment variable:

(env)$ heroku config

You should see something similar to:

=== glacial-savannah-72166 Config VarsDATABASE_URL: postgres://zebzwxlootewbx:da5c19a66cd4765dd39aed40abb06dff10682c3213501695c4b98612de0dfac9@ec2-54-208-11-146.compute-1.amazonaws.com:5432/d77tnmeavvasm0

Next, update theSQLALCHEMY_DATABASE_URI variable inapp.py like so:

url=os.getenv('DATABASE_URL',f'sqlite:///{Path(basedir).joinpath(DATABASE)}')ifurl.startswith("postgres://"):url=url.replace("postgres://","postgresql://",1)SQLALCHEMY_DATABASE_URI=url

So,SQLALCHEMY_DATABASE_URI now uses the value of theDATABASE_URL environment variable if it's available. Otherwise, it will use the SQLite URL.

Make sure to importos:

importos

Run the tests to ensure they still pass:

(env)$ python -m pytest===============================test session starts ===============================platform darwin -- Python 3.10.4, pytest-7.4.2, pluggy-1.0.0rootdir: /Users/michael/repos/github/flaskr-tddcollected 6 itemstests/app_test.py ......                                                    [100%]================================ 6 passedin 0.32s ================================

Try logging in and out, adding a few new entries, and deleting old entries locally.

Before updating Heroku, addPsycopg2 -- a Postgres database adapter for Python -- to the requirements file:

Flask==3.0.0Flask-SQLAlchemy==3.1.1gunicorn==21.2.0psycopg2-binary==2.9.9pytest==7.4.2

Commit and push your code up to Heroku.

Snce we're using a new database on Heroku, you'll need to run the following commandonce to create the tables:

(env)$ heroku run python create_db.py

Test things out.

Linting and Code Formatting

Finally, we can lint and auto format our code withFlake8 andBlack, respectively:

(env)$ pip install flake8==6.1.0(env)$ pip install black==23.10.0

Run Flake8 and correct any issues:

(env)$ python -m flake8 --exclude env --ignore E402,E501../create_db.py:5:1: F401'project.models.Post' imported but unused./tests/app_test.py:2:1: F401'os' imported but unused./project/app.py:2:1: F401'sqlite3' imported but unused./project/app.py:6:1: F401'flask.g' imported but unused./project/app.py:7:19: E126 continuation line over-indentedfor hanging indent

Update the code formatting per Black:

$ python -m black --exclude=env.reformatted /Users/michael/repos/github/flaskr-tdd/project/models.pyreformatted /Users/michael/repos/github/flaskr-tdd/project/app.pyAll done! ✨ 🍰 ✨2 files reformatted, 4 files left unchanged.

Test everything out once last time!

Conclusion

  1. Want my code? Grab ithere.
  2. Want more Flask fun? Check outTestDriven.io. Learn how to build, test, and deploy microservices powered by Docker, Flask, and React!
  3. Want something else added to this tutorial? Add an issue to the repo.

About

Flaskr: Intro to Flask, Test-Driven Development (TDD), and JavaScript

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

[8]ページ先頭

©2009-2025 Movatter.jp