23. Практичне завдання: версія 6
23.1. Вступ
Тепер повернемося до нашого додатка для розрахунку податку. На його основі ми створимо різні веб-додатки.
У версії 5 нашого практичного завдання дані податкової адміністрації зберігалися в базі даних. Ця версія 5 складалася з двох окремих додатків, які, однак, мали спільні рівні:
- додаток, що розраховував податок у режимі |batch| для платників податків, зареєстрованих у текстовому файлі;
- додаток, що розраховував податок в |інтерактивному| режимі для платників податків, інформацію про яких вводили з клавіатури;
Версія 5 програми для розрахунку податку у пакетному режимі (batch) мала таку архітектуру:

Зрештою, веб-версія цієї програми матиме таку архітектуру:

- веб-клієнт [1] звертається до веб-сервера [2], який, у свою чергу, взаємодіє з SGBD та [3];
- веб-сервер [2] зберігає рівні [métier], [8] та [dao], [9] початкового додатка;
- Початковий додаток зберігає свій головний скрипт [4] та свої шари [métier] і [15]. Рівні [métier], [8] та [15] є ідентичними;
- для взаємодії клієнт/сервер потрібні два додаткові рівні:
- шар [web] [7], який реалізує веб-додаток;
- рівень [dao] [5], що є клієнтом веб-додатку [7];
У остаточній версії розрахунок податку пакетним способом може здійснюватися двома способами:
- бізнес-розрахунок податку виконується на серверному рівні [métier]. Скрипт [main] використовуватиме цей метод;
- бізнес-розрахунок податку здійснюється на рівні [métier] на стороні клієнта. Цей метод використовуватиме скрипт [main2];
Відтепер ми будемо розробляти кілька клієнт-серверних додатків вищезазначеного типу, кожен з яких ілюструватиме одну або кілька нових технологій веб-розробки.
23.2. Веб-сервер для розрахунку податку
23.2.1. Версія 1

Скрипт [server_01] є наступним веб-додатком:

- у [1] використовується налаштований URL, у який передаються три значення:
- [marié] (так / ні) для вказівки, чи перебуває платник податків у шлюбі;
- [enfants]: кількість дітей платника податків;
- [salaire] — річний дохід платника податків;
- у [2] веб-сервер повертає рядок jSON, який вказує суму податку до сплати з розбивкою на окремі складові;
Архітектура додатка така:

- браузер [1] надсилає запит до сервера [2]. Скрипт [server_01] реалізує рівні [web] та [2] сервера;
- шари [3-8] — це ті, що вже використовувалися у |версії 5| програми для розрахунку податку. Ми використовуємо їх без змін;
- шар [métier] [3] визначено |тут|;
- шар [dao] [4] визначено |тут|;
Веб-додаток [server_01] налаштовується за допомогою трьох скриптів:
- [config], який налаштовує весь додаток;
- [config_database], який налаштовує доступ до бази даних. Ми будемо працювати зі скриптами SGBD, MySQL та PostgreSQL;
- [config_layers], що налаштовує рівні додатка;
Скрипт [config] має такий вигляд:
def configure(config: dict) -> dict:
import os
# крок 1 ------
# папка, в якій знаходиться цей файл
script_dir = os.path.dirname(os.path.abspath(__file__))
# кореневий шлях
root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"
# абсолютні залежності
absolute_dependencies = [
# папки проекту
# BaseEntity, MyException
f"{root_dir}/classes/02/entities",
# InterfaceImpôtsDao, InterfaceImpôtsMétier, InterfaceImpôtsUi
f"{root_dir}/impots/v04/interfaces",
# AbstractImpôtsdao, ImpôtsConsole, ImpôtsMétier
f"{root_dir}/impots/v04/services",
# ImpotsDaoWithAdminDataInDatabase
f"{root_dir}/impots/v05/services",
# AdminData, ImpôtsError, TaxPayer
f"{root_dir}/impots/v04/entities",
# Константи, діапазони
f"{root_dir}/impots/v05/entities",
# IndexController
f"{script_dir}/../controllers",
# скрипти [config_database, config_layers]
script_dir,
]
# встановлюємо syspath
from myutils import set_syspath
set_syspath(absolute_dependencies)
# крок 2 ------
# налаштування програми
# список користувачів, яким дозволено користуватися додатком
config['users'] = [
{
"login": "admin",
"password": "admin"
}
]
# крок 3 ------
# налаштування бази даних
import config_database
config["database"] = config_database.configure(config)
# крок 4 ------
# інстанціювання шарів додатка
import config_layers
config['layers'] = config_layers.configure(config)
# виконується конфігурація
return config
- Функція [configure] отримує словник [config] як аргумент (рядок 1) і повертає його як результат (рядок 54) після розширення його вмісту. Вже давно можна було б сказати, що немає необхідності повертати результат [config]. Адже [config] — це посилання на словник, яке викличний код ділить із викликаним кодом. Отже, викликаючий код уже має цю посилання (рядок 1), і немає сенсу повертати її йому ще раз (рядок 54). Тому слід написати:
config=[module].configure(config) (1)
є зайвим. Достатньо написати:
[module].configure(config) (2)
Проте я залишив перший варіант запису, оскільки вважав, що він, можливо, краще ілюструє те, що викликаний код змінює словник [config].
- рядок 1: словник [config], отриманий функцією [configure], має ключ «sgbd», значення якого береться зі списку [‘mysql’, ‘pgres’]. [mysql] означає, що використовувана база даних управляється MySQL, тоді як «pgres» означає, що використовувана база даних управляється PostgreSQL;
- рядки 4–27: перелічено всі папки, що містять елементи, необхідні для веб-додатка. Вони стануть частиною Python Path додатка (рядки 30–31);
- рядки 33–40: доступ до додатка буде надано лише певним користувачам. Тут наведено список з одним користувачем;
- рядки 43–46: саме скрипт [config_database] формує конфігурацію використовуваної бази даних;
- рядок 46: конфігурація, створена скриптом [config_database], є словником, який ми зберігаємо в загальній конфігурації під ключем «database»;
- рядки 48–51: скрипт [config_layers] створює екземпляри шарів веб-додатку. Він повертає словник, який зберігається в загальній конфігурації, пов’язаній із ключем «layers»;
Скрипт [config_database] — це той самий, що вже використовувався у |версії 5|. Наводимо його ще раз для нагадування:
def configure(config: dict) -> dict:
# конфігурація SQLAlchemy
from sqlalchemy import create_engine, Table, Column, Integer, MetaData, Float
from sqlalchemy.orm import mapper, sessionmaker
# рядки підключення до використовуваних баз даних
connection_strings = {
'mysql': "mysql+mysqlconnector://admimpots:mdpimpots@localhost/dbimpots-2019",
'pgres': "postgresql+psycopg2://admimpots:mdpimpots@localhost/dbimpots-2019"
}
# рядок підключення до використовуваної бази даних
engine = create_engine(connection_strings[config['sgbd']])
# метадані
metadata = MetaData()
# таблиця констант
constantes_table = Table("tbconstantes", metadata,
Column('id', Integer, primary_key=True),
Column('plafond_qf_demi_part', Float, nullable=False),
Column('plafond_revenus_celibataire_pour_reduction', Float, nullable=False),
Column('plafond_revenus_couple_pour_reduction', Float, nullable=False),
Column('valeur_reduc_demi_part', Float, nullable=False),
Column('plafond_decote_celibataire', Float, nullable=False),
Column('plafond_decote_couple', Float, nullable=False),
Column('plafond_impot_celibataire_pour_decote', Float, nullable=False),
Column('plafond_impot_couple_pour_decote', Float, nullable=False),
Column('abattement_dixpourcent_max', Float, nullable=False),
Column('abattement_dixpourcent_min', Float, nullable=False)
)
# таблиця податкових шкал
tranches_table = Table("tbtranches", metadata,
Column('id', Integer, primary_key=True),
Column('limite', Float, nullable=False),
Column('coeffr', Float, nullable=False),
Column('coeffn', Float, nullable=False)
)
# маппінги
from Tranche import Tranche
mapper(Tranche, tranches_table)
from Constantes import Constantes
mapper(Constantes, constantes_table)
# фабрика сесій
session_factory = sessionmaker()
session_factory.configure(bind=engine)
# сесія
session = session_factory()
# зберігаємо певну інформацію та повертаємо її у вигляді словника
return {"engine": engine, "metadata": metadata, "tranches_table": tranches_table,
"constantes_table": constantes_table, "session": session}
Скрипт [config_layers] налаштовує шари веб-сервера. Ми використовуємо |скрипт|, з яким вже знайомі:
def configure(config: dict) -> dict:
# інстанціювання рівнів додатка
# DAO
from ImpotsDaoWithAdminDataInDatabase import ImpotsDaoWithAdminDataInDatabase
dao = ImpotsDaoWithAdminDataInDatabase(config)
# бізнес-логіка
from ImpôtsMétier import ImpôtsMétier
métier = ImpôtsMétier()
# інстанції шарів поміщаються у словник, який повертається коду, що викликає
return {
"dao": dao,
"métier": métier
}
- рядок 6: рівень [dao] реалізовано з використанням бази даних;
- [ImpotsDaoWithAdminDataInDatabase] було визначено |тут|;
- [ImpôtsMétier] було визначено |тут|;
Основний скрипт [server_01] має такий вигляд:
# очікується параметр mysql або pgres
import sys
syntaxe = f"{sys.argv[0]} mysql / pgres"
erreur = len(sys.argv) != 2
if not erreur:
sgbd = sys.argv[1].lower()
erreur = sgbd != "mysql" and sgbd != "pgres"
if erreur:
print(f"syntaxe : {syntaxe}")
sys.exit()
# конфігуруємо додаток
import config
config = config.configure({'sgbd': sgbd})
# залежності
from ImpôtsError import ImpôtsError
from TaxPayer import TaxPayer
import re
from flask import request
from myutils import json_response
from flask import Flask
from flask_api import status
# отримання даних від податкової служби
try:
# admindata буде даними на рівні додатка, доступними лише для читання
admindata = config["layers"]["dao"].get_admindata()
except ImpôtsError as erreur:
print(f"L'erreur suivante s'est produite : {erreur}")
sys.exit(1)
# додаток Flask
app = Flask(__name__)
# Головна сторінка URL: /?marié=xx&enfants=yy&salaire=zz
@app.route('/', methods=['GET'])
def index():
# спочатку помилок немає
erreurs = []
# запит повинен містити три параметри в URL
if len(request.args) != 3:
erreurs.append("Méthode GET requise avec les seuls paramètres [marié, enfants, salaire]")
# отримуємо сімейний стан у URL
marié = request.args.get('marié')
if marié is None:
erreurs.append("paramètre [marié] manquant")
else:
marié = marié.strip().lower()
erreur = marié != "oui" and marié != "non"
if erreur:
erreurs.append(f"paramétre marié [{marié}] invalide")
# кількість дітей отримується з URL
enfants = request.args.get('enfants')
if enfants is None:
erreurs.append("paramètre [enfants] manquant")
else:
enfants = enfants.strip()
match = re.match(r"^\d+", enfants)
if not match:
erreurs.append(f"paramétre enfants [{enfants}] invalide")
else:
enfants = int(enfants)
# отримуємо інформацію про заробітну плату з файлу URL
salaire = request.args.get('salaire')
if salaire is None:
erreurs.append("paramètre [salaire] manquant")
else:
salaire = salaire.strip()
match = re.match(r"^\d+", salaire)
if not match:
erreurs.append(f"paramétre salaire [{salaire}] invalide")
else:
salaire = int(salaire)
# неправильні параметри у URL?
for key in request.args.keys():
if key not in ['marié', 'enfants', 'salaire']:
erreurs.append(f"paramètre [{key}] invalide")
# Чи є помилки?
if erreurs:
# надсилається відповідь про помилку клієнту
résultats = {"réponse": {"erreurs": erreurs}}
return json_response(résultats, status.HTTP_400_BAD_REQUEST)
# помилок немає, можна працювати
# розрахунок податку
taxpayer = TaxPayer().fromdict({'marié': marié, 'enfants': enfants, 'salaire': salaire})
config["layers"]["métier"].calculate_tax(taxpayer, admindata)
# відправляється відповідь клієнту
return json_response({"réponse": {"result": taxpayer.asdict()}}, status.HTTP_200_OK)
# тільки для тестування
if __name__ == '__main__':
# запускаємо сервер Flask
app.config.update(ENV="development", DEBUG=True)
app.run()
- рядки 1–10: отримуємо параметр, який вказує, який SGBD використовувати;
- рядки 12–14: маючи цю інформацію, можна налаштувати додаток. Зокрема, формується Python Path;
- рядки 16–23: маючи новий Python Path, імпортуємо необхідні елементи;
- рядки 25–31: отримуємо дані від податкової служби, які дозволяють розрахувати податок;
- рядки 33–34: створюється екземпляр додатка Flask;
- рядок 38: додаток Flask обслуговує лише URL [/]. Він очікує на URL, налаштований наступним чином [/ ?marié=xx&enfants=yy&salaire=zz] із:
- xx: так / ні;
- yy: кількість дітей;
- zz: річний оклад;
- рядки 40–89: перевіряється правильність параметрів URL;
- рядок 41: помилки накопичуються у списку [erreurs];
- рядок 43: можливо, варто нагадати, що параметри налаштованого об’єкта URL містяться в [request.args] (див. |тут|):
- об’єкт [request] — це об’єкт Flask, імпортований у рядку 20;
- об’єкт [request.args] поводиться як словник;
- рядки 43–44: перевіряється, чи є саме три параметри (ні менше, ні більше);
- рядки 46–49: перевіряється, чи параметр [marié] присутній у URL;
- рядки 50–54: якщо він присутній, перевіряється, чи його значення, очищене від пробілів на початку та в кінці, є «yes» або «no»;
- рядки 56–59: перевіряється, чи параметр [enfants] міститься у URL;
- рядки 60–66: якщо він присутній, перевіряється, чи його значення є додатним цілим числом;
- рядок 66: не слід забувати, що параметри URL та їхні значення є символьними рядками. Значення параметра [enfants] перетворюється на «int»;
- рядки 68–78: для параметра [salaire] виконують ті самі перевірки, що й для параметра [enfants];
- рядки 81–83: перевіряється, чи в URL немає інших параметрів, крім [‘marié, ‘enfants’, ‘salaire’];
- рядки 85–89: якщо після всіх цих перевірок список [erreurs] не є порожнім, то цей список помилок надсилається клієнту у вигляді рядка jSON та коду статусу [400 Bad Request];
Оскільки надалі нам часто доведеться надсилати клієнту у відповідь рядок jSON, кілька рядків, необхідних для цього, було винесено в модуль [myutils.py], який ми вже використовували:

Скрипт [myutils.py] набуває такого вигляду:
# імпорт
import json
import os
import sys
from flask import make_response
def set_syspath(absolute_dependencies: list):
# absolute_dependencies: список абсолютних імен папок
….
# генерація відповіді HTTP jSON
def json_response(réponse: dict, status_code: int) -> tuple:
# тіло відповіді HTTP
response = make_response(json.dumps(réponse, ensure_ascii=False))
# тіло відповіді HTTP є частиною jSON
response.headers['Content-Type'] = 'application/json; charset=utf-8'
# надсилається відповідь HTTP
return response, status_code
- рядок 16: функція [json_response] очікує два параметри:
- [réponse]: словник, з якого потрібно надіслати рядок jSON веб-клієнту;
- [status_code] — код статусу HTTP відповіді;
- рядок 18: встановлюємо тіло відповіді jSON;
- рядок 20: додається заголовок HTTP, який повідомляє веб-клієнту, що він отримає jSON;
- рядок 22: відправляємо відповідь HTTP коду-викликувачу. Він повинен надіслати її веб-клієнту;
Файл [__init__.py] змінюється наступним чином:
from .myutils import set_syspath, json_response
Нова версія [myutils] встановлюється серед модулів машинного рівня за допомогою команди [pip install .] у терміналі Pycharm:
(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\packages>pip install .
Processing c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\packages
Using legacy setup.py install for myutils, since package 'wheel' is not installed.
Installing collected packages: myutils
Attempting uninstall: myutils
Found existing installation: myutils 0.1
Uninstalling myutils-0.1:
Successfully uninstalled myutils-0.1
Running setup.py install for myutils ... done
Successfully installed myutils-0.1
- рядок 1: щоб ввести цю команду, потрібно перебувати в папці [packages];
Код скрипта [server_01] продовжується наступним чином:
…
# помилки?
if erreurs:
# надсилається відповідь про помилку клієнту
résultats = {"réponse": {"erreurs": erreurs}}
return json_response(résultats, status.HTTP_400_BAD_REQUEST)
# помилок немає, можна працювати
# розрахунок податку
taxpayer = TaxPayer().fromdict({'id': 0, 'marié': marié, 'enfants': enfants, 'salaire': salaire})
config["layers"]["métier"].calculate_tax(taxpayer, admindata)
# відправляється відповідь клієнту
return json_response({"réponse": {"result": taxpayer.asdict()}}, status.HTTP_200_OK)
- рядок 10: коли ви опинилися тут, очікувані параметри у файлі URL присутні та є правильними;
- рядок 10: створюється об’єкт [TaxPayer], який моделює платника податків;
- рядок 11: шару [métier] надається запит на обчислення податку. Нагадаємо, що елементи, обчислені шаром [métier], вставляються в об’єкт [taxpayer], переданий як параметр;
- рядок 13: відповідь надсилається веб-клієнту у вигляді рядка jSON. Це рядок jSON зі словника. У зв’язку з ключем [result] туди поміщається словник об’єкта [taxpayer]. Сам об’єкт [taxpayer] розмістити не вдалося, оскільки його неможливо серіалізувати у jSON;
Створюємо дві конфігурації виконання: одну для MySQL, іншу — для PostgreSQL:

Ось кілька прикладів виконання (ви запустили програму [server_01] та використану програму SGBD, а потім завантажуєте URL http://localhost:5000/ у браузері):


Ось приклад виконання в консолі Postman:

GET /?mari%C3%A9=xx&enfants=yy&salaire=zz HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: e4c5df8c-4bd6-4250-b789-b7b164db4eff
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json; charset=utf-8
Content-Length: 134
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Fri, 17 Jul 2020 06:15:44 GMT
{"réponse": {"erreurs": ["paramètre marié [xx] invalide", "paramètre enfants [yy] invalide", "paramètre salaire [zz] invalide"]}}
- рядок 1: запитується неправильний URL;
- рядок 10: сервер відповідає зі статусом 400 BAD REQUEST;
23.2.2. Версія 2

У версії 2 сервера обробка URL виділена в окремий модуль [index_controller] [5]:
# імпорт залежностей
import re
from flask_api import status
from werkzeug.local import LocalProxy
# URL з параметрами: /?одружений=xx&діти=yy&зарплата=zz
def execute(request: LocalProxy, config: dict) -> tuple:
# додаткові дані
from TaxPayer import TaxPayer
# спочатку помилок немає
erreurs = []
# запит повинен містити три параметри
if len(request.args) != 3:
erreurs.append("Méthode GET requise avec les seuls paramètres [marié, enfants, salaire]")
# отримуємо сімейний стан з URL
marié = request.args.get('marié')
if marié is None:
erreurs.append("paramètre [marié] manquant")
else:
marié = marié.strip().lower()
erreur = marié != "oui" and marié != "non"
if erreur:
erreurs.append(f"paramétre marié [{marié}] invalide")
# отримуємо кількість дітей у URL
enfants = request.args.get('enfants')
if enfants is None:
erreurs.append("paramètre [enfants] manquant")
else:
enfants = enfants.strip()
match = re.match(r"^\d+", enfants)
if not match:
erreurs.append(f"paramétre enfants {enfants} invalide")
else:
enfants = int(enfants)
# отримуємо заробітну плату з URL
salaire = request.args.get('salaire')
if salaire is None:
erreurs.append("paramètre [salaire] manquant")
else:
salaire = salaire.strip()
match = re.match(r"^\d+", salaire)
if not match:
erreurs.append(f"paramétre salaire {salaire} invalide")
else:
salaire = int(salaire)
# інші параметри для URL?
for key in request.args.keys():
if not key in ['marié', 'enfants', 'salaire']:
erreurs.append(f"paramètre [{key}] invalide")
# чи є помилки?
if erreurs:
# надсилаємо клієнту відповідь про помилку
résultats = {"réponse": {"erreurs": erreurs}}
return résultats, status.HTTP_400_BAD_REQUEST
# помилок немає, можна працювати
# розрахунок податку
taxpayer = TaxPayer().fromdict({'marié': marié, 'enfants': enfants, 'salaire': salaire})
config["layers"]["métier"].calculate_tax(taxpayer, config["admindata"])
# відправляється відповідь клієнту
return {"réponse": {"result": taxpayer.asdict()}}, status.HTTP_200_OK
- рядок 9: функція [execute] отримує два параметри:
- [request]: запит HTTP від клієнта;
- [config]: словник конфігурації додатка;
Скрипт [server_02] має такий вигляд:
# очікується параметр mysql або pgres
import sys
syntaxe = f"{sys.argv[0]} mysql / pgres"
erreur = len(sys.argv) != 2
if not erreur:
sgbd = sys.argv[1].lower()
erreur = sgbd != "mysql" and sgbd != "pgres"
if erreur:
print(f"syntaxe : {syntaxe}")
sys.exit()
# налагоджуємо додаток
import config
config = config.configure({'sgbd': sgbd})
# залежності
from ImpôtsError import ImpôtsError
from flask import request
from myutils import json_response
from flask import Flask
import index_controller
# отримання даних від податкової служби
try:
# admindata буде даними на рівні додатка, доступними лише для читання
config['admindata'] = config["layers"]["dao"].get_admindata()
except ImpôtsError as erreur:
print(f"L'erreur suivante s'est produite : {erreur}")
sys.exit(1)
# додаток Flask
app = Flask(__name__)
# Головна сторінка URL: /?marié=xx&enfant=yy&salaire=zz
@app.route('/', methods=['GET'])
def index():
# виконується запит
résultat, statusCode = index_controller.execute(request, config)
# надсилається відповідь
return json_response(résultat, statusCode)
# лише main
if __name__ == '__main__':
# запускається сервер
app.config.update(ENV="development", DEBUG=True)
app.run()
- рядки 36–41: обробка символу «/»;
- рядок 39: використання функції [IndexController.execute];
Відтепер ми будемо використовувати таку техніку: кожна дорога оброблятиметься власним модулем.
Результати виконання такі самі, як і у версії 1.
23.2.3. Версія 3

У версії 3 вводиться поняття автентифікації.
Скрипт [server_03] виглядає так:
# очікуємо параметр mysql або pgres
import sys
syntaxe = f"{sys.argv[0]} mysql / pgres"
erreur = len(sys.argv) != 2
if not erreur:
sgbd = sys.argv[1].lower()
erreur = sgbd != "mysql" and sgbd != "pgres"
if erreur:
print(f"syntaxe : {syntaxe}")
sys.exit()
# налагоджується додаток
import config
config = config.configure({'sgbd': sgbd})
# залежності
from ImpôtsError import ImpôtsError
from flask import request
from myutils import json_response
from flask import Flask
from flask_httpauth import HTTPBasicAuth
import index_controller
# отримання даних від податкової служби
try:
# config[‘admindata’] буде даними на рівні додатка, доступними лише для читання
config["admindata"] = config["layers"]["dao"].get_admindata()
except ImpôtsError as erreur:
print(f"L'erreur suivante s'est produite : {erreur}")
sys.exit(1)
# менеджер автентифікації
auth = HTTPBasicAuth()
# метод автентифікації
@auth.verify_password
def verify_credentials(login: str, password: str) -> bool:
# список користувачів
users = config['users']
# перегляд цього списку
for user in users:
if user['login'] == login and user['password'] == password:
return True
# не знайдено
return False
# додаток Flask
app = Flask(__name__)
# Головна сторінка URL: /?marié=xx&enfant=yy&salaire=zz
@app.route('/', methods=['GET'])
@auth.login_required
def index():
# виконуємо запит
résultat, statusCode = index_controller.execute(request, config)
# надсилається відповідь
return json_response(résultat, statusCode)
# лише main
if __name__ == '__main__':
# запускається сервер
app.config.update(ENV="development", DEBUG=True)
app.run()
- рядок 21: імпортується менеджер автентифікації. Існують різні типи автентифікації на веб-сервері. Той, який ми використовуємо тут, називається [HTTP Basic]. Кожен тип автентифікації дотримується певного діалогу «клієнт-сервер»;
- рядок 33: створюється екземпляр модуля автентифікації;
- рядок 37: анотація [@auth.verify_password] позначає функцію, яку слід виконати, коли менеджер автентифікації хоче перевірити логін та пароль, надіслані клієнтом, згідно з протоколом [HTTP Basic];
- рядок 55: анотація [@auth.login_required] позначає маршрут, для якого веб-клієнт повинен пройти автентифікацію. Якщо веб-клієнт ще не надіслав свої облікові дані, веб-сервер автоматично запросить їх у нього згідно з протоколом HTTP basic;
Необхідно встановити модуль [flask_httpauth]:
(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\impots\http-servers\01\flask>pip install flask_httpauth
Collecting flask_httpauth
Downloading Flask_HTTPAuth-4.1.0-py2.py3-none-any.whl (5.8 kB)
Requirement already satisfied: Flask in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from flask_httpauth) (1.1.2)
Requirement already satisfied: itsdangerous>=0.24 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Flask->flask_httpauth) (1.1.0)
Requirement already satisfied: click>=5.1 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Flask->flask_httpauth) (7.1.2)
Requirement already satisfied: Jinja2>=2.10.1 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Flask->flask_httpauth) (2.11.2)
Requirement already satisfied: Werkzeug>=0.15 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Flask->flask_httpauth) (1.0.1)
Requirement already satisfied: MarkupSafe>=0.23 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Jinja2>=2.10.1->Flask->flask_httpauth) (1.1.1
)
Installing collected packages: flask-httpauth
Successfully installed flask-httpauth-4.1.0
Давайте подивимося, що відбувається в консолі Postman. Вам потрібно:
- створюєте конфігурацію виконання;
- запускаєте веб-додаток;
- запускаєте обраний модуль SGBD;
- надсилаєте запит на URL [/] за допомогою Postman;
Діалог «клієнт/сервер» у консолі Postman виглядає так:
- рядок 10: сервер відповідає, що ми не маємо дозволу на доступ до URL [/];
- рядок 13: сервер вказує, який протокол автентифікації слід використовувати, в даному випадку — протокол «Basic»;
Можна налаштувати Postman так, щоб він надсилав облікові дані користувача за протоколом Auth Basic:

- у [6-7] ми вказуємо облікові дані, що містяться у скрипті [config]:
config['users'] = [
{
"login": "admin",
"password": "admin"
}
]
Діалог «клієнт/сервер» у консолі Postman виглядає наступним чином:
GET / HTTP/1.1
Authorization: Basic YWRtaW46YWRtaW4=
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: 5ce20822-e87c-4eef-a2f4-b9eaec38d881
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json; charset=utf-8
Content-Length: 203
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Fri, 17 Jul 2020 07:20:01 GMT
{"réponse": {"erreurs": ["Méthode GET requise avec les seuls paramètres [marié, enfants, salaire]", "paramètre [marié] manquant", "paramètre [enfants] manquant", "paramètre [salaire] manquant"]}}
- рядок 2: клієнт Postman надсилає у зашифрованому вигляді ідентифікатори користувача [admin / admin];
- рядок 17: сервер відповідає правильно. Він повідомляє про помилки, оскільки не були надіслані параметри [marié, enfants, salaire] (рядок 1), але не повідомляє про помилку автентифікації;
Тепер спробуємо отримати URL за допомогою браузера (нижче — Firefox):

- як і в Postman, Firefox отримав від сервера відповідь HTTP із заголовками HTTP:
Firefox, як і інші браузери, не припиняє діалог при отриманні цих заголовків. Він запитує у користувача облікові дані, які вимагає сервер. Достатньо ввести вище admin / admin, щоб отримати відповідь від сервера:

23.3. Веб-клієнт сервера розрахунку податків
23.3.1. Вступ
У попередньому розділі веб-клієнтом сервера розрахунку податків був браузер. У цій частині веб-клієнтом буде консольний скрипт. Архітектура виглядає наступним чином:

- веб-клієнт складається з шарів [1-2];
- веб-сервер складається з шарів [3-9]. Про це йшлося в попередньому розділі;
Отже, нам потрібно написати шари [1-2].
Рівень [dao] [2] повинен вміти взаємодіяти з веб-сервером [3]. Тепер ми знаємо протокол HTTP і могли б, наприклад, за допомогою вже розглянутого модуля [pycurl], написати скрипт, що взаємодіє з веб-сервером [3]. Однак існують модулі, спеціалізовані на клієнт-серверних діалогах HTTP. Ми використаємо один із них — модуль [requests]:
(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\impots\http-servers\01\flask>pip install requests
Collecting requests
Downloading requests-2.24.0-py2.py3-none-any.whl (61 kB)
|| 61 kB 137 kB/s
Collecting idna<3,>=2.5
Downloading idna-2.10-py2.py3-none-any.whl (58 kB)
|| 58 kB 692 kB/s
Collecting chardet<4,>=3.0.2
Downloading chardet-3.0.4-py2.py3-none-any.whl (133 kB)
|| 133 kB 1.3 MB/s
Collecting urllib3!=1.25.0,!=1.25.1,<1.26,>=1.21.1
Downloading urllib3-1.25.9-py2.py3-none-any.whl (126 kB)
|| 126 kB 1.1 MB/s
Collecting certifi>=2017.4.17
Downloading certifi-2020.6.20-py2.py3-none-any.whl (156 kB)
|| 156 kB 1.1 MB/s
Installing collected packages: idna, chardet, urllib3, certifi, requests
Successfully installed certifi-2020.6.20 chardet-3.0.4 idna-2.10 requests-2.24.0 urllib3-1.25.9
Структура скриптів веб-клієнта така:

Скрипт реалізує додаток для розрахунку податку в пакетному режимі, описаний у |версії 1|. Остання версія цього додатка — |версія 5|. Нагадаємо, як він працює:
- платники податків, для яких буде розраховано податок, зібрані у текстовому файлі [taxpayersdata.txt]:
- результати записуються у два файли:
- текстовий файл [errors.txt] містить помилки, виявлені у файлі платників податків:
Analyse du fichier C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\impots\http-clients\01\main/../data/input/taxpayersdata.txt
Ligne 15, not enough values to unpack (expected 4, got 2)
Ligne 17, MyException[1, L'identifiant d'une entité <class 'TaxPayer.TaxPayer'> doit être un entier >=0]
- (продовження)
- Файл jSON [résultats.json] містить результати розрахунків податку для різних платників податків:
[
{
"id": 0,
"marié": "oui",
"enfants": 2,
"salaire": 55555,
"impôt": 2814,
"surcôte": 0,
"taux": 0.14,
"décôte": 0,
"réduction": 0
},
{
"id": 1,
"marié": "oui",
"enfants": 2,
"salaire": 50000,
"impôt": 1384,
"surcôte": 0,
"taux": 0.14,
"décôte": 384,
"réduction": 347
},
…
]
23.3.2. Налаштування веб-клієнта

Налаштування здійснюється за допомогою двох скриптів:
- [config], який забезпечує повну конфігурацію поза рівнями архітектури;
- [config_layers], який забезпечує налаштування рівнів архітектури;
Скрипт [config] має такий вигляд:
def configure(config: dict) -> dict:
import os
# етап 1 ------
# папка цього файлу
script_dir = os.path.dirname(os.path.abspath(__file__))
# кореневий шлях
root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"
# абсолютні залежності
absolute_dependencies = [
# папки проєкту
# BaseEntity, MyException
f"{root_dir}/classes/02/entities",
# InterfaceImpôtsDao, InterfaceImpôtsMétier, InterfaceImpôtsUi
f"{root_dir}/impots/v04/interfaces",
# AbstractImpôtsdao, ImpôtsConsole, ImpôtsMétier
f"{root_dir}/impots/v04/services",
# ImpotsDaoWithAdminDataInDatabase
f"{root_dir}/impots/v05/services",
# AdminData, ImpôtsError, TaxPayer
f"{root_dir}/impots/v04/entities",
# Константи, діапазони
f"{root_dir}/impots/v05/entities",
# ImpôtsDaoWithHttpClient
f"{script_dir}/../services",
# скрипти конфігурації
script_dir,
]
# встановлюємо syspath
from myutils import set_syspath
set_syspath(absolute_dependencies)
# крок 2 ------
# налаштування програми за допомогою констант
config.update({
"taxpayersFilename": f"{script_dir}/../data/input/taxpayersdata.txt",
"resultsFilename": f"{script_dir}/../data/output/résultats.json",
"errorsFilename": f"{script_dir}/../data/output/errors.txt",
"server": {
"urlServer": "http://127.0.0.1:5000/",
"authBasic": True,
"user": {
"login": "admin",
"password": "admin"
}
}
}
)
# крок 3 ------
# інстанціювання шарів
import config_layers
config['layers'] = config_layers.configure(config)
# застосовуємо конфігурацію
return config
- рядок 1: функція [configure] отримує як параметр словник, який потрібно заповнити інформацією про конфігурацію. Він може бути вже заповненим або порожнім. У даному випадку він буде порожнім;
- рядки 40–42: абсолютні імена трьох текстових файлів, що обробляються шаром [dao];
- рядки 43–50: пов’язані з ключем [server], інформація, яку повинен знати рівень [dao] про веб-сервер, з яким він має взаємодіяти:
- рядок 44: URL веб-сервісу;
- рядок 45: ключ [authBasic] має значення True, якщо доступ до URL вимагає автентифікації типу Basic;
- рядки 46–49: ідентифікатори користувача, який буде проходити автентифікацію, якщо вона буде потрібна;
- рядки 56–57: створюються екземпляри шарів, у даному випадку єдиного шару [dao], і посилання на шари вводяться в [config], пов’язаний із ключем [layers];
Скрипт [config_layers] має такий вигляд:
def configure(config: dict) -> dict:
# інстанціювання шарів додатка
# шар DAO
from ImpôtsDaoWithHttpClient import ImpôtsDaoWithHttpClient
dao = ImpôtsDaoWithHttpClient(config)
# виконується конфігурація шарів
return {
"dao": dao
}
- рядок 1: функція [configure] отримує словник, який налаштовує додаток;
- рядки 4–6: створюється екземпляр шару [dao]. У рядку 6 йому передається конфігурація додатка, в якій він знайде необхідну інформацію;
- рядки 8–11: повертається словник, до якого додано посилання на шар [dao];
23.3.3. Головний скрипт [main]
Головний скрипт [main] є варіантом скрипта з |версії 5|:
# конфігуруємо додаток
import config
config = config.configure({})
# залежності
from ImpôtsError import ImpôtsError
# код
try:
# отримання шару [dao]
dao = config["layers"]["dao"]
# читання даних платників податків
taxpayers = dao.get_taxpayers_data()["taxpayers"]
# платників податків?
if not taxpayers:
raise ImpôtsError(f"Pas de contribuables valides dans le fichier {config['taxpayersFilename']}")
# розрахунок податку платників податків
for taxpayer in taxpayers:
# «taxpayer» є одночасно вхідним і вихідним параметром
# «taxpayer» буде змінено
dao.calculate_tax(taxpayer)
# запис результатів у текстовий файл
dao.write_taxpayers_results(taxpayers)
except ImpôtsError as erreur:
# виведення повідомлення про помилку
print(f"L'erreur suivante s'est produite : {erreur}")
finally:
# завершено
print("Travail terminé...")
- рядки 2–3: налаштовано програму;
- рядок 13: шар [dao] надає список платників податків, для яких потрібно розрахувати податок;
- рядок 21: шар [dao] обчислює податок для кожного з них;
- рядок 23: результати записуються у файл jSON;
23.3.4. Реалізація шару [dao]

Повернемося до використовуваної архітектури «клієнт/сервер»:

- у [2, 6] бачимо, що рівень [dao] виконує дві функції:
- вона звертається до файлової системи як для зчитування даних платників податків, так і для запису результатів розрахунків податку. У нас уже є клас |AbstractImpôtsDao|, який це вміє робити. Він використовувався ще з |версії 4|;
- він взаємодіє з веб-сервером [3];
У |версії 5| головний скрипт [main] [1] безпосередньо взаємодіяв із рівнем [métier] [4]. Ми не хочемо змінювати цей скрипт. Для цього ми зробимо так, щоб рівень [dao] [2] реалізував інтерфейс рівня [métier] [4]. Таким чином, головний скрипт [main] буде «відчувати», що взаємодіє безпосередньо з шаром [métier] [4], і зможе повністю ігнорувати той факт, що цей шар розташований на іншій машині.
Визначення класу, що реалізує рівень [dao] [2], може виглядати так:
class ImpôtsDaoWithHttpClient(AbstractImpôtsDao, InterfaceImpôtsMétier):
- клас [ImpôtsDaoWithHttpClient]:
- успадковує клас [AbstractImpôtsDao], що дозволить йому керувати взаємодією з файловою системою [6];
- реалізує інтерфейс [InterfaceImpôtsMétier], щоб не доводилося змінювати головний скрипт [main] |версії 5|;
Повний код класу [ImpôtsDaoWithHttpClient] виглядає наступним чином:
# імпорт
import requests
from flask_api import status
from AbstractImpôtsDao import AbstractImpôtsDao
from AdminData import AdminData
from ImpôtsError import ImpôtsError
from InterfaceImpôtsMétier import InterfaceImpôtsMétier
from TaxPayer import TaxPayer
class ImpôtsDaoWithHttpClient(AbstractImpôtsDao, InterfaceImpôtsMétier):
# конструктор
def __init__(self, config: dict):
# ініціалізація батьківського об’єкта
AbstractImpôtsDao.__init__(self, config)
# збереження параметрів
self.__config_server = config["server"]
# невикористаний метод з [AbstractImpôtsDao]
def get_admindata(self) -> AdminData:
pass
# розрахунок податку
def calculate_tax(self: object, taxpayer: TaxPayer, admindata: AdminData = None):
# винятки передаються нагору
# параметри get
params = {"marié": taxpayer.marié, "enfants": taxpayer.enfants, "salaire": taxpayer.salaire}
# підключення з аутентифікацією Auth Basic?
if self.__config_server['authBasic']:
response = requests.get(
# URL запитуваного сервера
self.__config_server['urlServer'],
# параметри URL
params=params,
# аутентифікація Basic
auth=(
self.__config_server["user"]["login"],
self.__config_server["user"]["password"]))
else:
# підключення без аутентифікації Auth Basic
response = requests.get(self.__config_server['urlServer'], params=params)
# перевірка
print(response.text)
# код статусу відповіді HTTP
status_code = response.status_code
# відповідь jSON додається до словника
résultat = response.json()
# помилка, якщо код статусу відмінний від 200 OK
if status_code != status.HTTP_200_OK:
# відомо, що помилки були пов’язані з ключем [erreurs] відповіді
raise ImpôtsError(87, résultat['réponse']['erreurs'])
# відомо, що результат пов'язано з ключем [result] у відповіді
# вхідний параметр змінюється з урахуванням цього результату
taxpayer.fromdict(résultat["réponse"]["result"])
- рядки 21–23: клас [AbstractImpôtsDao] (рядок 12) має абстрактний метод [get_admindata]. Ми змушені його реалізувати, навіть якщо ним не користуємося (admindata обробляється сервером, а не клієнтом);
- рядок 26: метод [calculate_tax] належить до інтерфейсу [InterfaceImpôtsMétier] (рядок 12). Ми повинні його реалізувати;
- рядок 15: конструктор отримує як єдиний параметр словник конфігурації додатка;
- рядки 16–17: батьківський клас [AbstractImpôtsDao] ініціалізується шляхом передачі йому, також, конфігурації додатка. У ній він знайде імена трьох текстових файлів, якими йому доведеться керувати;
- рядки 18–19: у класі локально зберігається інформація щодо веб-сервера для розрахунку податку;
- рядок 26: метод [calculate_tax] отримує як параметр об’єкт типу |Taxpayer|. Щоб відповідати сигнатурі методу [InterfaceImpôtsMétier.calculate_tax], він також отримує параметр [admindata], який повинен інкапсулювати дані податкової адміністрації. На стороні клієнта цих даних немає. Цей параметр завжди залишатиметься рівним [None]. Ця хитрощі свідчить про те, що клас [ImpôtsMétier] спочатку був написаний неправильно:
- сигнатура [calculate_tax] мала б бути просто:
def calculate_tax(self, taxpayer: TaxPayer)
а параметр [admindata : AdminData] мав би бути переданий у конструктор класу;
- рядок 27: код методу [calculate_tax] не був інкапсульований у блок try / catch / finally. Це означає, що можливі винятки не будуть оброблені й будуть передані до коду, що викликає, а саме до скрипта [main]. Останній успішно перехоплює всі винятки, що надходять із рівня [dao];
- рядок 28: розрахунок податку відбувається на стороні сервера. Тому потрібно встановити зв’язок із ним. Це робиться за допомогою модуля [requests], імпортованого у рядку 2;
- рядки 31–43: щоб надіслати запит GET на веб-сервер, використовується метод [requests.get]:
- рядки 33–34: першим параметром методу є URL, до якого слід звернутися;
- рядки 35–40: два інші параметри — це іменовані параметри, порядок яких не має значення;
- рядки 35–36: значення іменованого параметра [params] має бути словником, що містить інформацію, яку потрібно внести в URL у форматі [/url ?param1=valeur1¶m2=valeur2&…];
- рядок 29: словник, що містить три параметри [marié, enfants, salaire], яких очікує веб-сервер. Не потрібно турбуватися про кодування (так зване urlencoded), якому мають піддаватися ці параметри. Цим займається [requests];
- рядки 37–40: параметр із назвою [auth] є кортежем із двох елементів (login, password). Він представляє облікові дані для автентифікації типу Basic;
- рядки 44–45: ці два рядки мають лише навчальну мету (їх буде вилучено у коментарі після завершення налагодження):
- [response] представляє відповідь HTTP від сервера;
- [response.text] — це текст документа, вкладений у цю відповідь. На етапі налагодження корисно перевірити, що саме сервер нам надіслав;
- рядок 47: [response.status_code] — це код статусу HTTP отриманої відповіді. Наш сервер надсилає лише три таких коди:
- 200 OK
- 400 BAD REQUEST
- 500 INTERNAL SERVER ERROR
- рядок 49: наш сервер завжди надсилає jSON, навіть у разі помилки. Функція [response.json()] створює словник на основі отриманого рядка jSON. Нагадаємо про дві можливі форми рядка jSON:
{"réponse": {"erreurs": ["Méthode GET requise avec les seuls paramètres [marié, enfants, salaire]", "paramètre [marié] manquant", "paramètre [enfants] manquant", "paramètre [salaire] manquant"]}}
{"réponse": {"result": {"id": 0, "marié": "oui", "enfants": 3, "salaire": 200000, "impôt": 42842, "surcôte": 17283, "taux": 0.41, "décôte": 0, "réduction": 0}}}
- рядки 51–53: якщо код статусу не дорівнює 200, то генерується виняток із повідомленнями про помилки, вкладеними у відповідь;
- рядок 56: отримуємо словник, сформований під час розрахунку податку, і використовуємо його для оновлення вхідного параметра [taxpayer];
23.3.5. Виконання
Щоб запустити клієнт:
- запустіть сервер [server_03] із вибраним вами SGBD;
- запустіть скрипт клієнта [main];
Результати можна знайти в папці [data/output]. Вони такі самі, як і для версії 5.
23.4. Тестування рівня [dao]
Повернемося до архітектури клієнт-серверного додатка:
- У клієнтському коді нам вдалося забезпечити, щоб рівень [dao] [1] мав той самий інтерфейс, що й рівень [métier] [3]. Отже, у [4] ми будемо використовувати вже розглянутий тестовий клас |TestDaoMétier| для тестування шару [métier] [3];
Клас тестів буде виконано в такому середовищі:

- конфігурація [2] ідентична конфігурації [1], яку ми щойно розглянули;
Тестовий клас [TestHttpClientDao] має такий вигляд:
import unittest
class TestHttpClientDao(unittest.TestCase):
def test_1(self) -> None:
from TaxPayer import TaxPayer
# {'одружений': 'так', 'діти': 2, 'зарплата': 55555,
# 'податок': 2814, 'надбавка': 0, 'знижка': 0, 'зниження': 0, 'ставка': 0.14}
taxpayer = TaxPayer().fromdict({"marié": "oui", "enfants": 2, "salaire": 55555})
dao.calculate_tax(taxpayer)
# перевірка
self.assertAlmostEqual(taxpayer.impôt, 2815, delta=1)
self.assertEqual(taxpayer.décôte, 0)
self.assertEqual(taxpayer.réduction, 0)
self.assertAlmostEqual(taxpayer.taux, 0.14, delta=0.01)
self.assertEqual(taxpayer.surcôte, 0)
…
def test_11(self) -> None:
from TaxPayer import TaxPayer
# {'одружений': 'так', 'діти': 3, 'зарплата': 200000,
# 'податок': 42842, 'надбавка': 17283, 'знижка': 0, 'зниження': 0, 'ставка': 0,41}
taxpayer = TaxPayer().fromdict({'marié': 'oui', 'enfants': 3, 'salaire': 200000})
dao.calculate_tax(taxpayer)
# перевірки
self.assertAlmostEqual(taxpayer.impôt, 42842, 1)
self.assertEqual(taxpayer.décôte, 0)
self.assertEqual(taxpayer.réduction, 0)
self.assertAlmostEqual(taxpayer.taux, 0.41, delta=0.01)
self.assertAlmostEqual(taxpayer.surcôte, 17283, delta=1)
if __name__ == '__main__':
# налаштування програми
import config
config = config.configure({})
# рівень DAO
dao = config['layers']['dao']
# виконуємо тестові методи
print("tests en cours...")
unittest.main()
Цей клас аналогічний тому, що вже розглядався у версії 4 додатка.
- рядки 40–41: налаштовується тестове середовище;
- рядок 44: отримуємо посилання на шар [dao];
- рядки 47–48: виконуємо тести;
Для виконання тестів створюється |конфігурація виконання|:

- створюється конфігурація виконання для консольного скрипта, а не для тесту UnitTest;
Під час виконання цієї конфігурації отримуємо такі результати:
Усі 11 тестів пройшли успішно.