Pull to refresh

«Flaskr» — введение во Flask, разработка через тестирование (TDD) и jQuery

Reading time 20 min
Views 19K
Original author: mjhea0

Flask – это замечательный микро веб фреймворк, основанный на Python. Flaskr – это миниблог, который описан в официальном руководстве по Flask. Я продирался через это руководство больше раз, чем могу в этом признаться. Тем не менее, я хотел бы взять это руководство для следующего шага, добавив в него разработку через тестирование (test driven development) и немножко jQuery.


Если вы новичок во Flask и/или в веб-разработке в целом, важно понимать эти основные фундаментальные понятия:


  1. Разницу между Get и Post запросом, и какие функции в приложении их обрабатывают.
  2. Что такое request (Запрос).
  3. Как HTML-страницы отображаются и возвращаются посетителю в браузере.

**Примечание**: этот руководство представлено проектом https://realpython.com. Пожалуйста, поддержите этот проект с открытым исходным кодом, приобретая наши курсы http://www.realpython.com/courses с обучением Python и веб-разработке с Django и flask!

Содержание


  1. Разработка через тестирование?
  2. Скачиваем Python
  3. Установка проекта
  4. Первый тест
  5. Установка Flaskr
  6. Второй тест
  7. Установка базы данных
  8. Шаблоны и представления(Views)
  9. Добавляем цвета
  10. Тест
  11. jQuery
  12. Развёртывание
  13. Еще тест!
  14. Bootstrap
  15. SQLAlchemy
  16. Заключение

Требования


Руководство использует следующее ПО:


  1. Python v3.5.1
  2. Flask v0.10.1
  3. Flask-SQLAlchemy v2.1
  4. gunicorn v19.4.5

Разработка через тестирование?


tdd


Разработка через тестирование (tdd) — это вид разработки, который предусматривает написание автоматических тестов перед написанием самой функции. Иными словами, это комбинация испытания и написания кода. Этот процесс не только помогает обеспечить корректность кода, но также позволяет развивать дизайн и архитектуру проекта под постоянным контролем.


TDD обычно следует схеме "Красный-Зеленый-Рефакторинг" как показано на картинке выше:


  1. Написать тест
  2. Выполнить тест (и потерпеть неудачу)
  3. Написать код, чтобы тест был пройден
  4. Рефакторинг кода и повторное испытания снова и снова (при необходимости)

Скачиваем Python


Перед началом убедитесь, что у вас установлена последняя версия Питон 3.5, который вы можете скачать с http://www.python.org/download/


Примечание: это руководство использует Python V 3.5.1.

Вместе с Python также надо поставить:


  • pip — это система управления пакетами в Python, похожая на gem или npm в Ruby или Node, соответственно.
  • pyvenv — используется для создания изолированной среды в разработке. Это стандартная практика. Всегда, всегда и еще раз всегда используйте виртуальные среды. Если вы этого не сделаете, то в конечном итоге столкнетесь с проблемами совместимости между различными зависимостями.

Установка проекта


  1. Создайте новую папку для сохранения проекта:


    $ mkdir flaskr-tdd
    $ cd flaskr-tdd

  2. Создайте и активируйте виртуальное окружение:


    $ pyvenv-3.5 env
    $ source env/bin/activate

    Примечание:
    Когда вы находитесь внутри виртуального окружения, в терминале до значка $ показывается надпись (env). Для выхода из виртуальной среды используйте команду deactivate, а при необходимости ее активировать перейдите в нужный каталог и запустите команду source env/bin/activate.

  3. Установка Flask с помощью pip:


    $ pip3 install Flask


Первый тест


Давайте начнем с простой программы "hello, world".


  1. Создаем тестовый файл:


    $ touch app-test.py

    Откройте этот файл в вашем любимом текстовом редакторе. И добавьте в файл app-test.py следующие строки:


    from app import app
    
    import unittest
    
    class BasicTestCase(unittest.TestCase):
    
      def test_index(self):
          tester = app.test_client(self)
          response = tester.get('/', content_type='html/text')
          self.assertEqual(response.status_code, 200)
          self.assertEqual(response.data, b'Hello, World!')
    
    if __name__ == '__main__':
      unittest.main()


По сути, мы проверяем, придет ли к нам ответ с кодом "200" и отображается ли "hello, world" .


  1. Запустим тест:


    $ python app-test.py

    Если все хорошо, то тест будет провален (fail).


  2. Теперь добавим следующие строки в файл app.py, чтобы успешно пройти тест.


    $ touch app.py

    Код:


    from flask import Flask
    
    app = Flask(__name__)
    
    @app.route("/")
    def hello():
      return "Hello, World!"
    
    if __name__ == "__main__":
      app.run()

  3. Запустим наш app:


    $ python app.py

    Обратимся по адресу http://localhost:5000/. Вы увидите строку "Hello, World!" на вашем экране.


    Вернемся в терминал и остановим сервер разработки с помощью Ctrl+C.


  4. Запустим наш тест вновь:


    $ python app-test.py
    .
    ----------------------------------------------------------------------
    Ran 1 test in 0.016s
    
    OK

    Отлично, то что надо.



Установка Flaskr


  1. Добавим структуру


    Добавим пару папок, "static" и "templates", в корень нашего проекта. Должна получится вот такая структура:


    ├── app-test.py
    ├── app.py
    ├── static
    └── templates

  2. SQL схема


    Создайте новый файл с именем "schema.sql" и добавьте в него следующий код:


    drop table if exists entries;
    create table entries (
    id integer primary key autoincrement,
    title text not null,
    text text not null
    );

    Это позволит создать таблицу с тремя полями — "id", "title" и "text". SQLite будет использоваться для наших СУБД, поскольку SQLite встроен в стандартную библиотеку Python и не требует настройки.



Второй тест


Давайте создадим базовый файл для запуска нашего приложения. Однако сначала нам нужно написать тест для него.


  1. Просто изменим наш app-test.py из первого теста:


    from app import app
    
    import unittest
    
    class BasicTestCase(unittest.TestCase):
    
      def test_index(self):
          tester = app.test_client(self)
          response = tester.get('/', content_type='html/text')
          self.assertEqual(response.status_code, 404)
    
    if __name__ == '__main__':
      unittest.main()

    Итак, мы ожидаем получить код 404 (error). Запустите тест. Тест не прошел. Почему тест был провален? Все просто. Мы ожидали 404, но на самом деле мы получаем назад код 200 с этого маршрута.


  2. Изменим app.py:


    # imports
    import sqlite3
    from flask import Flask, request, session, g, redirect, url_for, \
       abort, render_template, flash, jsonify
    
    # configuration
    DATABASE = 'flaskr.db'
    DEBUG = True
    SECRET_KEY = 'my_precious'
    USERNAME = 'admin'
    PASSWORD = 'admin'
    
    # create and initialize app
    app = Flask(__name__)
    app.config.from_object(__name__)
    
    if __name__ == '__main__':
      app.run()

    Здесь мы импортируем необходимые модули, создаем раздел конфигурации для глобальных переменных, инициализируем и затем запускаем приложение.


  3. Итак, запустим его:


    $ python app.py

    Запускаем сервер. Вы должны увидеть сообщение об ошибке 404, при обращении к маршруту "/", так как маршрута нет и его представления не существует. Вернемся к терминалу. Остановим сервер разработки. Теперь запустим модульный тест. Он должен пройти без ошибок.



Установка базы данных


Наша цель — создать подключение к базе данных, создать базу данных на основе схемы, если она еще не существует, затем закрывать соединение каждый раз после запуска теста.


  1. Как мы можем проверить существование файла базы данных? Обновим наш app-test.py:


    import unittest
    import os
    from app import app
    
    class BasicTestCase(unittest.TestCase):
    
      def test_index(self):
          tester = app.test_client(self)
          response = tester.get('/', content_type='html/text')
          self.assertEqual(response.status_code, 404)
    
      def test_database(self):
          tester = os.path.exists("flaskr.db")
          self.assertTrue(tester)
    
    if __name__ == '__main__':
      unittest.main()

    Запустите его, чтобы убедиться, что тест терпит неудачу, показывая, что база данных не существует.


  2. Теперь добавьте следующий код в app.py:


    # connect to database
    def connect_db():
      """Connects to the database."""
      rv = sqlite3.connect(app.config['DATABASE'])
      rv.row_factory = sqlite3.Row
      return rv
    
    # create the database
    def init_db():
      with app.app_context():
          db = get_db()
          with app.open_resource('schema.sql', mode='r') as f:
              db.cursor().executescript(f.read())
          db.commit()
    
    # open database connection
    def get_db():
      if not hasattr(g, 'sqlite_db'):
          g.sqlite_db = connect_db()
      return g.sqlite_db
    
    # close database connection
    @app.teardown_appcontext
    def close_db(error):
      if hasattr(g, 'sqlite_db'):
          g.sqlite_db.close()

    И добавьте функцию init_db () в конец app.py, чтобы быть уверенным в том, что мы будем запускать сервер каждый раз с новой базой данных:


    if __name__ == '__main__':
      init_db()
      app.run()

    Теперь можно создать базу данных с помощью Python Shell и импорта и вызова init_db():


    >>> from app import init_db
    >>> init_db()

    Закроем оболочку и запустим тест еще раз. Тест пройден? Теперь мы убедились, что база данных была создана.



Шаблоны и представления (Templates and Views )


Далее нам нужно настроить шаблоны и соответствующие представления, которые определяют маршруты. Подумайте об этом с точки зрения пользователя. Нам нужно, чтобы пользователи могли иметь возможность войти в блог и выйти из него. После входа в систему, пользователь должен иметь возможность создать пост. И наконец, мы должны иметь возможность показывать эти посты.


В первую очередь напишем несколько тестов для этого.


Юнит тестирование


Взгляните на окончательный код. Я добавил комментарии для объяснений.


import unittest
import os
import tempfile
import app

class BasicTestCase(unittest.TestCase):

    def test_index(self):
        """Начальный тест. Убедимся, что фласк установлен корректно"""
        tester = app.app.test_client(self)
        response = tester.get('/', content_type='html/text')
        self.assertEqual(response.status_code, 200)

    def test_database(self):
        """Начальный тест, убеждаемся, что база данных существует"""
        tester = os.path.exists("flaskr.db")
        self.assertEqual(tester, True)

class FlaskrTestCase(unittest.TestCase):

    def setUp(self):
        """Создаем пустую тестовую базу данных"""
        self.db_fd, app.app.config['DATABASE'] = tempfile.mkstemp()
        app.app.config['TESTING'] = True
        self.app = app.app.test_client()
        app.init_db()

    def tearDown(self):
        """Уничтожаем базу данных после всех тестов"""
        os.close(self.db_fd)
        os.unlink(app.app.config['DATABASE'])

    def login(self, username, password):
        """Вспомогательная функция авторизации"""
        return self.app.post('/login', data=dict(
            username=username,
            password=password
        ), follow_redirects=True)

    def logout(self):
        """Вспомогательная функция выхода из блога"""
        return self.app.get('/logout', follow_redirects=True)

    # Функции с утверждениями (assert)

    def test_empty_db(self):
        """Убедимся, что база данных пуста"""
        rv = self.app.get('/')
        assert b'No entries here so far' in rv.data

    def test_login_logout(self):
        """Протестируем вход и выход юзера"""
        rv = self.login(
            app.app.config['USERNAME'],
            app.app.config['PASSWORD']
        )
        assert b'You were logged in' in rv.data
        rv = self.logout()
        assert b'You were logged out' in rv.data
        rv = self.login(
            app.app.config['USERNAME'] + 'x',
            app.app.config['PASSWORD']
        )
        assert b'Invalid username' in rv.data
        rv = self.login(
            app.app.config['USERNAME'],
            app.app.config['PASSWORD'] + 'x'
        )
        assert b'Invalid password' in rv.data

    def test_messages(self):
        """Убеждаемся, что юзер может оставить сообщение в блоге"""
        self.login(
            app.app.config['USERNAME'],
            app.app.config['PASSWORD']
        )
        rv = self.app.post('/add', data=dict(
            title='<Hello>',
            text='<strong>HTML</strong> allowed here'
        ), follow_redirects=True)
        assert b'No entries here so far' not in rv.data
        assert b'&lt;Hello&gt;' in rv.data
        assert b'<strong>HTML</strong> allowed here' in rv.data

if __name__ == '__main__':
    unittest.main()

Если сейчас запустить тесты, все рухнет кроме test_database()`:


python app-test.py
.FFFF
======================================================================
FAIL: test_index (__main__.BasicTestCase)
initial test. ensure flask was set up correctly
----------------------------------------------------------------------
Traceback (most recent call last):
  File "app-test.py", line 13, in test_index
    self.assertEqual(response.status_code, 200)
AssertionError: 404 != 200

======================================================================
FAIL: test_empty_db (__main__.FlaskrTestCase)
Ensure database is blank
----------------------------------------------------------------------
Traceback (most recent call last):
  File "app-test.py", line 51, in test_empty_db
    assert b'No entries here so far' in rv.data
AssertionError

======================================================================
FAIL: test_login_logout (__main__.FlaskrTestCase)
Test login and logout using helper functions
----------------------------------------------------------------------
Traceback (most recent call last):
  File "app-test.py", line 59, in test_login_logout
    assert b'You were logged in' in rv.data
AssertionError

======================================================================
FAIL: test_messages (__main__.FlaskrTestCase)
Ensure that user can post messages
----------------------------------------------------------------------
Traceback (most recent call last):
  File "app-test.py", line 84, in test_messages
    assert b'&lt;Hello&gt;' in rv.data
AssertionError

----------------------------------------------------------------------
Ran 5 tests in 0.088s

FAILED (failures=4)

Давайте сделаем так, чтобы тесты были пройдены...


Показ записей


  1. Во-первых, добавим представление для отображения записей в app.py:


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

  2. Далее зайдем в папку "templates" и добавим в нее файл index.html такого содержания:


    <!DOCTYPE html>
    <html>
    <head>
    <title>Flaskr</title>
    <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='style.css') }}">
    </head>
    <body>
    
    <div class="page">
    
      <h1>Flaskr-TDD</h1>
      <div class="metanav">
        {% if not session.logged_in %}
          <a href="{{ url_for('login') }}">log in</a>
        {% else %}
          <a href="{{ url_for('logout') }}">log out</a>
        {% endif %}
      </div>
      {% for message in get_flashed_messages() %}
        <div class="flash">{{ message }}</div>
      {% endfor %}
      {% block body %}{% endblock %}
    
      {% if session.logged_in %}
        <form action="{{ url_for('add_entry') }}" method="post" class="add-entry">
          <dl>
            <dt>Title:</dt>
            <dd><input type="text" size="30" name="title"></dd>
            <dt>Text:</dt>
            <dd><textarea name="text" rows="5" cols="40"></textarea></dd>
            <dd><input type="submit" value="Share"></dd>
          </dl>
        </form>
      {% endif %}
      <ul class="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>

  3. Запустим тест. Должны увидеть следующее:


    Ran 5 tests in 0.131s
    
    FAILED (failures=2, errors=2)


Авторизация юзеров


  1. Добавим в файл app.py:


    @app.route('/login', methods=['GET', 'POST'])
    def login():
      """User login/authentication/session management."""
      error = None
      if request.method == 'POST':
          if request.form['username'] != app.config['USERNAME']:
              error = 'Invalid username'
          elif request.form['password'] != app.config['PASSWORD']:
              error = 'Invalid password'
          else:
              session['logged_in'] = True
              flash('You were logged in')
              return redirect(url_for('index'))
      return render_template('login.html', error=error)
    
    @app.route('/logout')
    def logout():
      """User logout/authentication/session management."""
      session.pop('logged_in', None)
      flash('You were logged out')
      return redirect(url_for('index'))

    В приведенной выше функции login() есть декоратор, который указывает на то, что маршрут может принять или Get или Post запрос. Проще говоря, запрос на авторизацию начинается от пользователя при доступе к url /login. Разница между этими типами запросов проста: Get используется для доступа к сайту, а POST используется для отправки информации на сервер. Таким образом, когда пользователь просто обращается к /login, он использует Get-запрос, но при попытке входа в систему используется Post-запрос.


  2. Добавим в папку template файл "login.html":


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

  3. Запустим тест еще раз.


    Вы все равно должны увидеть некоторые ошибки! Рассмотрим одну из ошибок — werkzeug.routing.BuildError: Could not build url for endpoint 'index'. Did you mean 'login' instead?


    По сути, мы пытаемся обратится к функции index(), которая не существует. Переименуйте функцию show_entries() в функцию index() в файле app.py и запустите тест по новой:


    Ran 5 tests in 0.070s
    
    FAILED (failures=1, errors=2)

  4. Далее, добавьте в представление функцию добавления записи:


    @app.route('/add', methods=['POST'])
    def add_entry():
      """Add new post to database."""
      if not session.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')
      return redirect(url_for('index'))

  5. Повторный тест:


    Теперь вы должны увидеть это:


    ======================================================================
    FAIL: test_empty_db (__main__.FlaskrTestCase)
    Ensure database is blank
    ----------------------------------------------------------------------
    Traceback (most recent call last):
    File "app-test.py", line 49, in test_empty_db
      assert b'No entries here so far' in rv.data
    AssertionError
    
    ----------------------------------------------------------------------
    Ran 5 tests in 0.072s
    
    FAILED (failures=1)

    Эта ошибка утверждает, что при обращении к маршруту / сообщение "No entries here so far" возвращается. Проверьте шаблон index.html. Текст на самом деле гласит: "No entries yet. Add some!". Так обновите же тест и запустите тест вновь:


    Ran 5 tests in 0.156s
    
    OK

    Великолепно.



Добавляем цвета


Сохраните следующие стили в новый файл с именем style.css в папку "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;
}

.entries li {
  margin: 0.8em 1.2em;
}

.entries li h2 {
  margin-left: -1em;
}

.add-entry {
  font-size: 0.9em;
  border-bottom: 1px solid #ccc;
}

.add-entry dl {
  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;
}

Тест


Запустите приложение, войдите в систему (логин/пароль = "admin"), создайте какой-нибудь пост, и выйдете из блога. Затем выполните тесты, чтобы убедиться, что до сих пор все работает.


jQuery


Теперь добавим немного jQuery, чтобы сделать сайт немного более интерактивным.


  1. Откройте index.html и измените первый <li> как-то так:


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

    Теперь мы можем использовать jQuery для каждого <li>. Во-первых, нам нужно добавить следующий скрипт в документ непосредственно перед закрывающим тегом Body:


    <script src="//code.jquery.com/jquery-1.10.2.min.js"></script>
    <script src="//netdna.bootstrapcdn.com/bootstrap/3.0.0/js/bootstrap.min.js"></script>
    <script type="text/javascript" src="{{url_for('static', filename='main.js') }}"></script>

  2. Создадим файл main.js в директории "static" и запишем в него следующий код:


    $(function() {
    
    console.log( "ready!" ); // sanity check
    
    $('.entry').on('click', function() {
      var entry = this;
      var post_id = $(this).find('h2').attr('id');
      $.ajax({
        type:'GET',
        url: '/delete' + '/' + post_id,
        context: entry,
        success:function(result) {
          if(result.status === 1) {
            $(this).remove();
            console.log(result);
          }
        }
      });
    });
    
    });

  3. Добавим новую функцию в app.py, чтобы иметь возможность удалять сообщения из базы данных:


    @app.route('/delete/<post_id>', methods=['GET'])
    def delete_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"}
      except Exception as e:
          result = {'status': 0, 'message': repr(e)}
    
      return jsonify(result)

  4. Наконец, напишем новый тест:


    def test_delete_message(self):
      """Ensure the messages are being deleted"""
      rv = self.app.get('/delete/1')
      data = json.loads((rv.data).decode('utf-8'))
      self.assertEqual(data['status'], 1)

    Убедитесь, что вы добавили импорт import json


    Проверьте это вручную, запустив сервер и добавив две новые записи. Нажмите на одну из них. Запись должна быть удалена из dom, а также из базы данных. Дважды проверьте это.


    Затем запустите тестирование. Результат тестов должен быть таким:


    $ python app-test.py
    ......
    ----------------------------------------------------------------------
    Ran 6 tests in 0.132s
    
    OK


Развёртывание


Приложение в рабочем состоянии, давайте не будем на этом останавливаться и развернем приложение на Heroku.


  1. Для этого сначала зарегистрируйтесь, а затем установите Heroku Toolbelt.


  2. Далее, установите веб-сервер под названием gunicorn:


    $ pip install gunicorn

  3. Создайте Procfile в корне вашего проекта:


    $ touch Procfile

    Добавьте следующий код:


    web: gunicorn app:app

  4. Создайте файл requirements.txt, чтобы указать внешние зависимости, которые должны быть установлены для приложения, чтобы оно работало:


    $ pip freeze > requirements.txt

  5. Создайте файл .gitignore:


    $ touch .gitignore

    И добавьте файлы и папки, которые не должны быть включены в систему контроля версиями:


    env
    *.pyc
    *.DS_Store
    __pycache__

  6. Добавим локальный репозитарий:


    $ git init
    $ git add -A
    $ git commit -m "initial"

  7. Развернем Heroku:


    $ heroku create
    $ git push heroku master
    $ heroku open


Тест (Опять!)


Запустим тест в облаке. Команда heroku open откроет приложение в вашем браузере.


Bootstrap


Давайте обновим стили из Bootstrap 3.


  1. Удалите style.css и ссылку на него в index.html и login.html.Затем добавьте этот стиль в обоих файлах


    <link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css">

    Теперь мы имеем полный доступ ко всем вспомогательным классам Bootstrap.


  2. Замените код в файле login.html на:


    <!DOCTYPE html>
    <html>
    <head>
    <title>Flaskr-TDD | Login</title>
    <link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css">
    </head>
    <body>
    
    <div class="container">
    
      <h1>Flaskr</h1>
    
      {% for message in get_flashed_messages() %}
        <div class="flash">{{ message }}</div>
      {% endfor %}
    
      <h3>Login</h3>
    
      {% if error %}<p class="error"><strong>Error:</strong> {{ error }}{% endif %}</p>
      <form action="{{ url_for('login') }}" method="post">
        <dl>
          <dt>Username:</dt>
          <dd><input type="text" name="username"></dd>
          <dt>Password:</dt>
          <dd><input type="password" name="password"></dd>
          <br><br>
          <dd><input type="submit" class="btn btn-default" value="Login"></dd>
          <span>Use "admin" for username and password</span>
        </dl>
      </form>
    
    </div>
    
    <script src="//code.jquery.com/jquery-1.10.2.min.js"></script>
    <script src="//netdna.bootstrapcdn.com/bootstrap/3.0.0/js/bootstrap.min.js"></script>
    <script type="text/javascript" src="{{url_for('static', filename='main.js') }}"></script>
    
    </body>
    </html>

  3. И измените код в index.html:


    <!DOCTYPE html>
    <html>
    <head>
    <title>Flaskr</title>
    <link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css">
    </head>
    <body>
    
    <div class="container">
    
      <h1>Flaskr-TDD</h1>
    
      {% if not session.logged_in %}
        <a href="{{ url_for('login') }}">log in</a>
      {% else %}
        <a href="{{ url_for('logout') }}">log out</a>
      {% endif %}
    
      {% for message in get_flashed_messages() %}
        <div class="flash">{{ message }}</div>
      {% endfor %}
    
      {% if session.logged_in %}
        <form action="{{ url_for('add_entry') }}" method="post" class="add-entry">
          <dl>
            <dt>Title:</dt>
            <dd><input type="text" size="30" name="title"></dd>
            <dt>Text:</dt>
            <dd><textarea name="text" rows="5" cols="40"></textarea></dd>
            <br><br>
            <dd><input type="submit" class="btn btn-default" value="Share"></dd>
          </dl>
        </form>
      {% endif %}
    
      <br>
    
      <ul class="entries">
        {% for entry in entries %}
          <li class="entry"><h2 id={{ entry.id }}>{{ entry.title }}</h2>{{ entry.text|safe }}</li>
        {% else %}
          <li><em>No entries yet. Add some!</em></li>
        {% endfor %}
      </ul>
    
    </div>
    
    <script src="//code.jquery.com/jquery-1.10.2.min.js"></script>
    <script src="//netdna.bootstrapcdn.com/bootstrap/3.0.0/js/bootstrap.min.js"></script>
    <script type="text/javascript" src="{{url_for('static', filename='main.js') }}"></script>
    
    </body>
    </html>

    Проверьте внесенные вами изменения!



SQLAlchemy


Давайте попробуем Flask-SQLAlchemy для лучшего управления нашей базой данных


Установка SQLAlchemy


  1. Запустите установку Flask-SQLAlchemy:


    $ pip install Flask-SQLAlchemy

  2. Создайте файл create_db.py и внесите в него нижеследующий код:


    # create_db.py
    
    from app import db
    from models import Flaskr
    
    # create the database and the db table
    db.create_all()
    
    # commit the changes
    db.session.commit()

    Этот файл будет использован для создания нашей новой базы. Едем дальше, удалим старый (flaskr.db) и schema.sql


  3. Далее добавим в новый файл models.py следующее содержимое, которое генерирует новую схему :


    from app import db
    
    class Flaskr(db.Model):
    
      __tablename__ = "flaskr"
    
      post_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 = title
          self.text = text
    
      def __repr__(self):
          return '<title {}>'.format(self.body)


Обновим app.py


# imports
from flask import Flask, request, session, g, redirect, url_for, \
     abort, render_template, flash, jsonify
from flask.ext.sqlalchemy import SQLAlchemy
import os

# grabs the folder where the script runs
basedir = os.path.abspath(os.path.dirname(__file__))

# configuration
DATABASE = 'flaskr.db'
DEBUG = True
SECRET_KEY = 'my_precious'
USERNAME = 'admin'
PASSWORD = 'admin'

# defines the full path for the database
DATABASE_PATH = os.path.join(basedir, DATABASE)

# the database uri
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + DATABASE_PATH

# create app
app = Flask(__name__)
app.config.from_object(__name__)
db = SQLAlchemy(app)

import models

@app.route('/')
def index():
    """Searches the database for entries, then displays them."""
    entries = db.session.query(models.Flaskr)
    return render_template('index.html', entries=entries)

@app.route('/add', methods=['POST'])
def add_entry():
    """Adds new post to the database."""
    if not session.get('logged_in'):
        abort(401)
    new_entry = models.Flaskr(request.form['title'], request.form['text'])
    db.session.add(new_entry)
    db.session.commit()
    flash('New entry was successfully posted')
    return redirect(url_for('index'))

@app.route('/login', methods=['GET', 'POST'])
def login():
    """User login/authentication/session management."""
    error = None
    if request.method == 'POST':
        if request.form['username'] != app.config['USERNAME']:
            error = 'Invalid username'
        elif request.form['password'] != app.config['PASSWORD']:
            error = 'Invalid password'
        else:
            session['logged_in'] = True
            flash('You were logged in')
            return redirect(url_for('index'))
    return render_template('login.html', error=error)

@app.route('/logout')
def logout():
    """User logout/authentication/session management."""
    session.pop('logged_in', None)
    flash('You were logged out')
    return redirect(url_for('index'))

@app.route('/delete/<int:post_id>', methods=['GET'])
def delete_entry(post_id):
    """Deletes post from database"""
    result = {'status': 0, 'message': 'Error'}
    try:
        new_id = post_id
        db.session.query(models.Flaskr).filter_by(post_id=new_id).delete()
        db.session.commit()
        result = {'status': 1, 'message': "Post Deleted"}
        flash('The entry was deleted.')
    except Exception as e:
        result = {'status': 0, 'message': repr(e)}
    return jsonify(result)

if __name__ == '__main__':
    app.run()

Обратите внимание на изменения в конфиге в верхней части, а также на средства, через которые мы теперь имеем доступ и управляем базой данных в каждой функции представления — через с SQLAlchemy вместо встроенного SQL.


Создаем базу данных


Запустим команду для создания и инициализации базы данных:


$ python create_db.py

Обновим index.html


Обновим эту строку:


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

Обратите внимание на post_id. Проверьте базу данных, чтобы убедиться в наличии соответствующего поля.


Тесты


Наконец, обновим наши тесты:


import unittest
import os
from flask import json

from app import app, db

TEST_DB = 'test.db'

class BasicTestCase(unittest.TestCase):

    def test_index(self):
        """initial test. ensure flask was set up correctly"""
        tester = app.test_client(self)
        response = tester.get('/', content_type='html/text')
        self.assertEqual(response.status_code, 200)

    def test_database(self):
        """initial test. ensure that the database exists"""
        tester = os.path.exists("flaskr.db")
        self.assertTrue(tester)

class FlaskrTestCase(unittest.TestCase):

    def setUp(self):
        """Set up a blank temp database before each test"""
        basedir = os.path.abspath(os.path.dirname(__file__))
        app.config['TESTING'] = True
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + \
            os.path.join(basedir, TEST_DB)
        self.app = app.test_client()
        db.create_all()

    def tearDown(self):
        """Destroy blank temp database after each test"""
        db.drop_all()

    def login(self, username, password):
        """Login helper function"""
        return self.app.post('/login', data=dict(
            username=username,
            password=password
        ), follow_redirects=True)

    def logout(self):
        """Logout helper function"""
        return self.app.get('/logout', follow_redirects=True)

    # assert functions

    def test_empty_db(self):
        """Ensure database is blank"""
        rv = self.app.get('/')
        self.assertIn(b'No entries yet. Add some!', rv.data)

    def test_login_logout(self):
        """Test login and logout using helper functions"""
        rv = self.login(app.config['USERNAME'], app.config['PASSWORD'])
        self.assertIn(b'You were logged in', rv.data)
        rv = self.logout()
        self.assertIn(b'You were logged out', rv.data)
        rv = self.login(app.config['USERNAME'] + 'x', app.config['PASSWORD'])
        self.assertIn(b'Invalid username', rv.data)
        rv = self.login(app.config['USERNAME'], app.config['PASSWORD'] + 'x')
        self.assertIn(b'Invalid password', rv.data)

    def test_messages(self):
        """Ensure that user can post messages"""
        self.login(app.config['USERNAME'], app.config['PASSWORD'])
        rv = self.app.post('/add', data=dict(
            title='<Hello>',
            text='<strong>HTML</strong> allowed here'
        ), follow_redirects=True)
        self.assertNotIn(b'No entries here so far', rv.data)
        self.assertIn(b'&lt;Hello&gt;', rv.data)
        self.assertIn(b'<strong>HTML</strong> allowed here', rv.data)

    def test_delete_message(self):
        """Ensure the messages are being deleted"""
        rv = self.app.get('/delete/1')
        data = json.loads(rv.data)
        self.assertEqual(data['status'], 1)

if __name__ == '__main__':
    unittest.main()

Мы в основном просто обновили setUp() и tearDown() методы.


Запустите тесты, а потом проверьте вручную путем запуска сервера и входа и выхода, добавления новых записей и удаления старых записей.


Если все хорошо, обновите требования командой (pip freeze > requirements.txt), закоммитьте ваш код и затем отправьте новую версию на heroku!


Заключение


  1. Где взять данный код? Можно здесь.
  2. Гляньте мое приложение на Heroku. Ура!
  3. Хотите больше от Flask? Посмотрите на Real Python.
  4. Хотите что-нибудь еще добавить в это руководство? Добавьте вопрос к репозиторию. Ура!

* От переводчика: Цикл статей на Хабре о Flask
Tags:
Hubs:
+12
Comments 19
Comments Comments 19

Articles