20. Практичне завдання: версія 5

Ми розробимо три додатки:
- додаток 1 ініціалізує базу даних, яка замінить файл [admindata.json] з версії 4;
- додаток 2 виконає розрахунок податків у пакетному режимі;
- додаток 3 здійснюватиме розрахунок податків в інтерактивному режимі;
20.1. Додаток 1: ініціалізація бази даних
Додаток 1 матиме таку архітектуру:

Це вдосконалення архітектури версії 4 (параграф |Версія 4|): податкові дані будуть зберігатися в базі даних, а не у файлі jSON. Рівень [dao] буде вдосконалено для впровадження цієї зміни.
20.1.1. Файл [admindata.json]

Файл [admindata.json] залишився таким самим, як у версії 4:
{
"limites": [9964, 27519, 73779, 156244, 0],
"coeffr": [0, 0.14, 0.3, 0.41, 0.45],
"coeffn": [0, 1394.96, 5798, 13913.69, 20163.45],
"plafond_qf_demi_part": 1551,
"plafond_revenus_celibataire_pour_reduction": 21037,
"plafond_revenus_couple_pour_reduction": 42074,
"valeur_reduc_demi_part": 3797,
"plafond_decote_celibataire": 1196,
"plafond_decote_couple": 1970,
"plafond_impot_couple_pour_decote": 2627,
"plafond_impot_celibataire_pour_decote": 1595,
"abattement_dixpourcent_max": 12502,
"abattement_dixpourcent_min": 437
}
Ми будемо використовувати ключі цього словника як стовпці бази даних.
20.1.2. Створення баз даних
Як було показано в розділі |Створення бази даних MySQL|, ми створюємо базу даних MySQL з назвою [dbimpots-2019], що належить користувачеві [admimpots] з паролем [mdpimpots]. У [phpMyAdmin] це виглядає так:

Так само, як було показано в розділі |Створення бази даних PostgreSQL|, ми створюємо базу даних PostgreSQL з іменем [dbimpots-2019], що належить користувачеві [admimpots] з паролем [mdpimpots]. У [pgAdmin] це виглядає так:

Бази даних створено, але наразі вони не містять жодної таблиці. Ці таблиці будуть створені за допомогою ORM та [sqlalchemy].
20.1.3. Ентітети, що відображаються за допомогою [sqlalchemy]
Ми створимо дві таблиці для інкапсуляції даних з [admindata.json]:
Визначена [sqlalchemy] таблиця [tbtranches] об’єднає дані масивів [limites, coeffr, coeffn] зі словника [admindata.json]:
# таблиця податкових шкал
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)
)
Таблиця [tbconstantes], визначена кодом [sqlalchemy], міститиме константи зі словника [admindata.json]:
# таблиця констант
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)
)
Об’єкти, які будуть зіставлені з цими двома таблицями, будуть такими:

Об’єкт [Constantes] містить константи зі словника [admindata.json]:
from BaseEntity import BaseEntity
# клас-контейнер даних податкової адміністрації
class Constantes(BaseEntity):
# ключі, виключені зі стану класу
excluded_keys = ["_sa_instance_state"]
# дозволені ключі
@staticmethod
def get_allowed_keys() -> list:
return ["id",
"plafond_qf_demi_part",
"plafond_revenus_celibataire_pour_reduction",
"plafond_revenus_couple_pour_reduction",
"valeur_reduc_demi_part",
"plafond_decote_celibataire",
"plafond_decote_couple",
"plafond_decote_couple",
"plafond_impot_celibataire_pour_decote",
"plafond_impot_couple_pour_decote",
"abattement_dixpourcent_max",
"abattement_dixpourcent_min"]
- рядок 5: клас [Constantes] успадковує клас [BaseEntity];
- рядок 7: завдяки зіставленню [sqlalchemy] клас [Constante] отримає властивість [_sa_instance_state]. Ми виключаємо її зі словника [asdict] сутності;
- рядки 11–23: властивості сутності. Ми використали назви, що застосовуються у словнику [admindata.json], щоб полегшити написання коду;
Елемент [Tranche] інкапсулює рядок із трьох таблиць [limites, coeffr, coeffn] зі словника [admindata.json]:
from BaseEntity import BaseEntity
# клас-контейнер даних податкової адміністрації
class Tranche(BaseEntity):
# ключі, виключені зі стану класу
excluded_keys = ["_sa_instance_state"]
# дозволені ключі
@staticmethod
def get_allowed_keys() -> list:
return ["id", "limite", "coeffr", "coeffn"]
- рядок 5: клас [Tranche] успадковує клас [BaseEntity];
- рядок 7: з властивостей словника [asdict] об’єкта виключається властивість [_sa_instance_state], додана [sqlalchemy];
- рядки 10–12: властивості класу;
Відповідність між сутностями [Constantes, Tranche] та таблицями [constantes, tranches] буде такою:

…
# таблиця констант
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)
- відповідності вказано у рядках 24–29. Там не вказано відповідності між властивостями об’єктів, що відображаються, та таблицями бази даних. Це можливо, коли імена стовпців таблиць збігаються з іменами властивостей, з якими вони мають бути пов’язані. З цієї причини ми перенесли в таблиці імена властивостей об’єктів, що відображаються. Це полегшує написання коду та його розуміння;
20.1.4. Файл конфігурації [sqlalchemy]

Ми щойно детально розглянули частину конфігурації [sqlalchemy]. Повний файл [config_database] має такий вигляд:
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()
# зберігається певна інформація
config['database'] = {"engine": engine, "metadata": metadata, "tranches_table": tranches_table,
"constantes_table": constantes_table, "session": session}
# результат
return config
- рядок 1: функція [configure] отримує як параметр словник, ключ якого [sgbd] вказує, який SGBD слід використовувати: MySQL (mysql) або PostgreSQL (pgres);
- рядки 6–12: вибирається база даних, яка вимагається конфігурацією;
- рядки 14–44: зіставлення сутностей і таблиць. Ці зіставлення є простими, оскільки між таблицями [tranches] та [constantes] немає жодних зв’язків. Вони є незалежними. Отже, немає жодних зовнішніх ключів, які б пов’язували одну таблицю з іншою;
- рядки 46–51: створюється робоча сесія додатка [session];
- рядки 53–58: необхідна інформація заноситься до словника конфігурації, який потім повертається;
20.1.5. Рівень [dao]
Повернемося до архітектури додатка 1, який потрібно побудувати:

Рівень [dao] [1] повинен зчитати файл [admindata.json] [2] і перенести його вміст в одну з баз [3, 4];

Шар [dao] має інтерфейс [1] і реалізований класом [2].
Інтерфейс [InterfaceDao4TransferAdminData2Database] має такий вигляд:
# імпорт
from abc import ABC, abstractmethod
# інтерфейс InterfaceImpôtsUI
class InterfaceDao4TransferAdminData2Database(ABC):
# передача податкових даних до бази даних
@abstractmethod
def transfer_admindata_in_database(self:object):
pass
- рядки 8–10: інтерфейс містить лише один метод [transfer_admindata_in_database] без параметрів. Оскільки цей метод потребує параметрів (який файл?, яка база даних?), це означає, що вони будуть передані конструктору класів, що реалізують цей інтерфейс;
Клас [DaoTransferAdminDataFromJsonFile2Database] реалізує інтерфейс [InterfaceDao4TransferAdminData2Database] наступним чином:
# імпорт
import codecs
import json
from sqlalchemy.exc import DatabaseError, IntegrityError, InterfaceError
from Constantes import Constantes
from ImpôtsError import ImpôtsError
from InterfaceDao4TransferAdminData2Database import InterfaceDao4TransferAdminData2Database
from Tranche import Tranche
class DaoTransferAdminDataFromJsonFile2Database(InterfaceDao4TransferAdminData2Database):
# виробник
def __init__(self, config: dict):
self.config = config
# передача
def transfer_admindata_in_database(self) -> None:
# ініціалізації
session = None
config = self.config
try:
# отримання даних від податкової адміністрації
with codecs.open(config["admindataFilename"], "r", "utf8") as fd:
# перенесення вмісту до словника
admindata = json.load(fd)
# отримання конфігурації бази даних
database = config["database"]
# видалення двох таблиць із бази даних
# checkfirst=True: спочатку перевіряє, чи існує таблиця
database["tranches_table"].drop(database["engine"], checkfirst=True)
database["constantes_table"].drop(database["engine"], checkfirst=True)
# відновлення таблиць на основі маппінгів
database["metadata"].create_all(database["engine"])
# поточна сесія [sqlalchemy]
session = database["session"]
# заповнюється таблиця податкових шкал
limites = admindata["limites"]
coeffr = admindata["coeffr"]
coeffn = admindata["coeffn"]
for i in range(len(limites)):
session.add(Tranche().fromdict(
{"limite": limites[i], "coeffr": coeffr[i], "coeffn": coeffn[i]}))
# заповнення таблиці констант
session.add(Constantes().fromdict({
'plafond_qf_demi_part': admindata["plafond_qf_demi_part"],
'plafond_revenus_celibataire_pour_reduction': admindata["plafond_revenus_celibataire_pour_reduction"],
'plafond_revenus_couple_pour_reduction': admindata["plafond_revenus_couple_pour_reduction"],
'valeur_reduc_demi_part': admindata["valeur_reduc_demi_part"],
'plafond_decote_celibataire': admindata["plafond_decote_celibataire"],
'plafond_decote_couple': admindata["plafond_decote_couple"],
'plafond_impot_celibataire_pour_decote': admindata["plafond_impot_celibataire_pour_decote"],
'plafond_impot_couple_pour_decote': admindata["plafond_impot_couple_pour_decote"],
'abattement_dixpourcent_max': admindata["abattement_dixpourcent_max"],
'abattement_dixpourcent_min': admindata["abattement_dixpourcent_min"]
}))
# перевірка сеансу [sqlalchemy]
session.commit()
except (IntegrityError, DatabaseError, InterfaceError) as erreur:
# виняток перезапускається в іншій формі
raise ImpôtsError(17, f"{erreur}")
finally:
# звільняються ресурси сеансу
if session:
session.close()
- рядок 13: клас [DaoTransferAdminDataFromJsonFile2Database] реалізує інтерфейс [InterfaceDao4TransferAdminData2Database];
- рядки 15–17: конструктор класу отримує як параметр словник конфігурації. Будуть використовуватися такі ключі:
- [admindataFilename] (рядок 27): ім’я файлу jSON, що містить дані податкової адміністрації, які потрібно перенести до бази даних;
- [database], рядок 32: конфігурація [sqlalchemy] додатка;
- рядки 34–37: видалення таблиць [constantes] та [tranches], якщо вони існують;
- рядки 39–40: повторне створення обох таблиць;
- рядок 43: отримуємо сесію [sqlalchemy], що міститься в конфігурації;
- рядки 45–51: таблиці [limites, coeffr, coeffn] зі словника [admindata] додаються до сесії. Для цього до сесії додаються екземпляри сутності [Tranche];
- рядки 52–64: екземпляр сутності [Constantes] додається до сесії;
- рядки 66–67: сесія підтверджується. Якщо дані сесії ще не були внесені до бази, вони вносяться саме в цей момент;
- рядки 68–70: обробка можливої помилки;
- рядки 71–74: сесія закривається. Це можна зробити, оскільки рівень [dao] використовується лише один раз;
20.1.6. Налаштування додатка

Додаток налаштовується за допомогою трьох файлів [1]:
- [config] — це файл загальної конфігурації. Саме він налаштовує додаток [main]. У цьому йому допомагають два інші файли:
- [config_database], який ми вже розглядали і який налаштовує ORM та [sqlalchemy];
- [config_layers], який налаштовує рівні додатка;
Файл [config] має такий вигляд:
def configure(config: dict) -> dict:
# [config] має ключ [sgbd], значення якого становить:
# [mysql] для управління базою даних MySQL
# [pgres] для управління базою даних PostgreSQL
import os
# крок 1 ---
# встановлюємо Python Path для додатка
# абсолютний шлях до папки цього скрипта
script_dir = os.path.dirname(os.path.abspath(__file__))
# root_dir (за необхідності змінити)
root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"
# абсолютні шляхи до залежностей
absolute_dependencies = [
# 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",
# AdminData, ImpôtsError, TaxPayer
f"{root_dir}/impots/v04/entities",
# BaseEntity, MyException
f"{root_dir}/classes/02/entities",
# локальні папки
f"{script_dir}",
f"{script_dir}/../../interfaces",
f"{script_dir}/../../services",
f"{script_dir}/../../entities",
]
# встановлюємо syspath
from myutils import set_syspath
set_syspath(absolute_dependencies)
# крок 2 ------
# завершуємо налаштування програми
config.update({
# абсолютні шляхи до файлів даних
"admindataFilename": f"{script_dir}/../../data/input/admindata.json"
})
# крок 3 ------
# налаштування бази даних
import config_database
config = config_database.configure(config)
# крок 4 ------
# інстанціювання шарів додатка
import config_layers
config = config_layers.configure(config)
# застосовуємо конфігурацію
return config
- рядки 8–36: формується Python Path додатка;
- рядки 38–43: у конфігурацію вноситься шлях до файлу [admindata.json];
- рядки 45–48: конфігурація [sqlalchemy];
- рядки 50–53: створюються екземпляри шарів додатка;
- рядок 56: повертаємо загальну конфігурацію;
Файл [config_layers] має такий вигляд:
def configure(config: dict) -> dict:
# інстанціювання шару [dao]
from DaoTransferAdminDataFromJsonFile2Database import DaoTransferAdminDataFromJsonFile2Database
config['dao'] = DaoTransferAdminDataFromJsonFile2Database(config)
# повертаємо конфігурацію
return config
- рядки 3–4: створення екземпляра шару [dao]. Ми бачили, що конструктор класу [DaoTransferAdminDataFromJsonFile2Database] очікує як параметр словник загальної конфігурації додатка;
- рядок 4: посилання на шар [dao] додається до конфігурації;
- рядок 7: конфігурація повертається;
20.1.7. Скрипт [main] додатка


Основний скрипт [main] має такий вигляд:
# очікується параметр 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
# отримуємо шар [dao]
dao = config["dao"]
# код
try:
# передача даних у базу
dao.transfer_admindata_in_database()
except ImpôtsError as ex1:
# виводиться повідомлення про помилку
print(f"L'erreur 1 suivante s'est produite : {ex1}")
except BaseException as ex2:
# виводиться повідомлення про помилку
print(f"L'erreur 2 suivante s'est produite : {ex2}")
finally:
# кінець
print("Terminé...")
- рядки 1–10: очікується параметр. Перевіряється його наявність та правильність;
- рядки 12–14: налаштовується додаток (загальні параметри, SQLAlchemy, шари) шляхом передачі в якості параметра обраного типу SGBD;
- рядки 19–20: нам знадобиться шар [dao]. Його отримуємо;
- рядок 25: виконується передача даних до бази. Уся необхідна інформація для методу [transfer_admindata_in_database] міститься у властивостях шару [dao] із рядка 20. Саме звідти метод її отримає;
Після виконання з базою MySQL вона містить такі елементи (phpMyAdmin):



У стовпці [3] видно значення, присвоєні MySQL первинному ключу [id]. Нумерація починається з 1. Наведений вище знімок екрана було отримано після декількох запусків скрипта.


Для бази даних PostgreSQL результати такі:

- клацніть правою кнопкою миші на [1], а потім на [2-3];
- у [4] дійсно є дані про податкові шкали;
Повторюємо те саме для таблиці констант [tbconstantes]:



20.2. Застосування 2: розрахунок податку в пакетному режимі

20.2.1. Архітектура
Додаток для розрахунку податку у версії 4 використовував таку архітектуру:

Рівень [dao] реалізує інтерфейс [InterfaceImpôtsDao]. Ми створили клас, що реалізує цей інтерфейс:
- [ImpôtsDaoWithAdminDataInJsonFile], який отримував податкові дані з файлу jSON. Це була версія 3;
Ми реалізуємо інтерфейс [InterfaceImpôtsDao] за допомогою нового класу [ImpotsDaoWithTaxAdminDataInDatabase], який буде отримувати дані податкової адміністрації з бази даних. Рівень [dao], як і раніше, записуватиме результати у файл jSON та знаходитиме дані платників податків у текстовому файлі. Ми знаємо, що якщо ми й надалі дотримуватимемося інтерфейсу [InterfaceImpôtsDao], шар [métier] не потребуватиме змін.
Нова архітектура буде такою:

20.2.2. Конфігурація додатка

Файл конфігурації [config_database] залишається таким самим, як і в додатку 1. Конфігурація [config] містить нові елементи:
# етап 2 ------
# завершується налаштування програми
config.update({
# абсолютні шляхи до файлів даних
"admindataFilename": f"{script_dir}/../../data/input/admindata.json",
"taxpayersFilename": f"{script_dir}/../../data/input/taxpayersdata.txt",
"errorsFilename": f"{script_dir}/../../data/output/errors.txt",
"resultsFilename": f"{script_dir}/../../data/output/résultats.json"
})
- рядки 6–8: абсолютні шляхи до текстових файлів, що використовуються додатком 2;
Конфігурація шарів [config_layers] змінюється наступним чином:
def configure(config: dict) -> dict:
# інстанціювання шару DAO
from ImpotsDaoWithAdminDataInDatabase import ImpotsDaoWithAdminDataInDatabase
config["dao"] = ImpotsDaoWithAdminDataInDatabase(config)
# створення екземпляра шару [métier]
from ImpôtsMétier import ImpôtsMétier
config['métier'] = ImpôtsMétier()
# повертаємо конфігурацію
return config
- рядки 3–4: шар [dao] тепер реалізовано класом [ImpotsDaoWithAdminDataInDatabase]. Цей клас є новим, але реалізує той самий інтерфейс [InterfaceDao], що й у версії 4 вправи;
- рядки 7–8: рівень [métier] реалізовано класом [ImpôtsMétier]. Цей клас використовується у версії 4 практичного завдання;
20.2.3. Рівень [dao]

Клас реалізації [ImpotsDaoWithAdminDataInDatabase] інтерфейсу [InterfaceImpôtsDao] матиме такий вигляд:
# імпортування
from sqlalchemy.exc import DatabaseError, IntegrityError, InterfaceError
from AbstractImpôtsDao import AbstractImpôtsDao
from AdminData import AdminData
from Constantes import Constantes
from ImpôtsError import ImpôtsError
from Tranche import Tranche
class ImpotsDaoWithAdminDataInDatabase(AbstractImpôtsDao):
# конструктор
def __init__(self, config: dict):
# конфігурація["taxPayersFilename"]: ім’я текстового файлу з даними платників податків
# config["taxPayersResultsFilename"] : ім’я файлу jSON з результатами
# config["errorsFilename"] : записує помилки, виявлені у файлі taxPayersFilename
# config["database"] : конфігурація бази даних
# ініціалізація класу Parent
AbstractImpôtsDao.__init__(self, config)
# збереження параметра
self.__config = config
# admindata
self.__admindata = None
# реалізація інтерфейсу
def get_admindata(self):
# Чи було збережено admindata?
if self.__admindata:
return self.__admindata
# здійснюється запит у BD
session = None
config = self.__config
try:
# сесія
database_config = config["database"]
session = database_config["session"]
# зчитується таблиця податкових шкал
tranches = session.query(Tranche).all()
# зчитується таблиця констант (лише 1 рядок)
constantes = session.query(Constantes).first()
# створюється екземпляр admindata
admindata = AdminData()
# у ньому створюються таблиці меж: coeffR, coeffN
limites = admindata.limites = []
coeffr = admindata.coeffr = []
coeffn = admindata.coeffn = []
for tranche in tranches:
limites.append(float(tranche.limite))
coeffr.append(float(tranche.coeffr))
coeffn.append(float(tranche.coeffn))
# додаємо до нього константи
admindata.fromdict(constantes.asdict())
# зберігаємо admindata
self.__admindata = admindata
# повертається значення
return self.__admindata
except (IntegrityError, DatabaseError, InterfaceError) as erreur:
# викликаємо виняток у іншій формі
raise ImpôtsError(27, f"{erreur}")
finally:
# закриваємо сесію
if session:
session.close()
Примітки
- рядок 11: клас [ImpotsDaoWithAdminDataInDatabase] успадковує клас [AbstractImpôtsDao], представлений у версії 4. Відомо, що останній реалізує інтерфейс [InterfaceDao], представлений у цій же версії. Саме завдяки дотриманню цього інтерфейсу ми можемо не змінювати рівень [métier];
- рядок 13: конструктор класу отримує як параметр словник конфігурації додатка;
- рядок 20: ініціалізується батьківський клас []. Він частково реалізує інтерфейс [InterfaceDao]:
- [get_taxpayers_data] зчитує файл [taxpayersdata.txt], що містить дані платників податків;
- [write_taxpayers_results] записує результати у файл jSON [résultats.json];
- [get_admindata] не реалізовано;
- рядок 22: зберігається конфігурація, передана у параметрах;
- рядок 27: реалізація методу [get_admindata] інтерфейсу [InterfaceDao]:
- рядки 28–30: метод [get_admindata] отримує дані від податкової адміністрації в об’єкті типу [AdminData] і зберігає цей об’єкт у [self.__admindata]. Якщо метод [get_admindata] викликається кілька разів, запит до бази даних не виконується кілька разів. Він виконується лише вперше. Наступні рази повертається об’єкт [self.__admindata];
- рядки 36–37: отримується сесія [sqlalchemy], яка була створена під час налаштування додатка методом [config_database];
- рядок 40: отримуємо шкали податку у вигляді списку;
- рядки 43: отримуємо константи для розрахунку податку;
- рядок 46: створюється екземпляр класу [AdminData]. Нагадаємо, що він походить від класу [BaseEntity];
- рядки 48–54: ініціалізуються масиви [limites, coeffr, coeffn] екземпляра [AdminData];
- рядки 55–56: ініціалізуються інші властивості [AdminData] за допомогою констант для розрахунку податку. Ми подбали про те, щоб властивості класів [AdminData] та [Constantes] мали однакові імена, що спрощує код;
- рядки 57–58: екземпляр [AdminData] зберігається у шарі [dao] для його повернення під час наступних викликів методу [get_admindata];
- рядок 60: повертається значення, запитуване кодом, що викликає;
- рядки 61–63: обробка можливої помилки;
- рядки 64–67: до бази даних надсилається лише один запит. Отже, сесію [sqlalchemy] можна закрити;
20.2.4. Тестування шару [dao]
У версії 4 цього додатка ми створили клас для тестування шару [métier]. Точніше кажучи, він тестував одночасно шари [métier] та [dao]. Ми використовуємо цей тест, щоб перевірити, чи рівень [dao] працює так, як очікується. Адже рівень [métier] не змінюється.


Тест [TestDaoMétier] виглядає наступним чином:
import unittest
class TestDaoMétier(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})
métier.calculate_tax(taxpayer, admindata)
# перевірка
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})
métier.calculate_tax(taxpayer, admindata)
# перевірки
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__':
# очікується параметр 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})
# бізнес-шар
métier = config['métier']
try:
# admindata
admindata = config['dao'].get_admindata()
except BaseException as ex:
# відображення
print((f"L'erreur suivante s'est produite : {ex}"))
# кінець
sys.exit()
# передаємо параметр, отриманий скриптом
sys.argv.pop()
# виконуються методи тестування
print("tests en cours...")
unittest.main()
- ми не будемо повертатися до 11 тестів, описаних у розділі |тест рівня [métier], версія 4|;
- рядки 37–66: ми запустимо скрипт тестів як звичайний додаток, а не як тест UnitTest. Саме рядок 66 задіє фреймворк UnitTest. У попередніх тестах ми використовували метод [setUp] для налаштування виконання кожного тесту. Ми повторювали одну й ту саму конфігурацію 11 разів, оскільки функція [setUp] виконується перед кожним тестом. Тут ми виконуємо налаштування один раз. Воно полягає у визначенні глобальних змінних [métier] (рядок 53) та [admindata] (рядок 56), які потім використовуватимуться методами, наприклад, [TestDaoMétier] (рядок 12);
- рядки 39–47: тестовий скрипт очікує на параметр [mysql / pgres], який вказує, чи використовується база MySQL чи PostgreSQL;
- рядки 50–51: тест налаштовано;
- рядок 53: з конфігурації отримується шар [métier];
- рядок 56: те саме робиться з шаром [dao]. Після цього отримується екземпляр [admindata], який містить дані, необхідні для розрахунку податку;
- тестування показало, що метод [unittest.main()] у рядку 66 не ігнорував параметр [mysql / pgres], отриманий скриптом, а надавав йому інше значення. Рядок 63 забезпечує, щоб цей метод більше не мав жодних параметрів;
Ми створюємо дві конфігурації виконання:


Якщо виконати одну з цих двох конфігурацій, отримаємо такі результати:
C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/impots/v05/tests/TestDaoMétier.py mysql
tests en cours...
...........
----------------------------------------------------------------------
Ran 11 tests in 0.001s
OK
Process finished with exit code 0
- рядки 5 та 7: 11 тестів пройшли успішно;
Нагадаємо, що ці тести перевіряють лише 11 випадків розрахунку податку. Проте їх успішне виконання може бути достатнім, щоб ми могли довіряти шару [dao].
20.2.5. Головний скрипт


Головний скрипт [main] такий самий, як і у версії 4:
# очікується параметр 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})
# syspath встановлено — можна виконувати імпорт
from ImpôtsError import ImpôtsError
# отримуємо шари додатка (вони вже інстанційовані)
dao = config["dao"]
métier = config["métier"]
try:
# отримання податкових шкал
admindata = dao.get_admindata()
# зчитування даних про платників податків
taxpayers = dao.get_taxpayers_data()["taxpayers"]
# платників податків?
if not taxpayers:
raise ImpôtsError(57, f"Pas de contribuables valides dans le fichier {config['taxpayersFilename']}")
# розрахунок податку платників податків
for taxPayer in taxpayers:
# taxPayer є одночасно параметром введення та виведення
# taxPayer буде змінено
métier.calculate_tax(taxPayer, admindata)
# запис результатів у текстовий файл
dao.write_taxpayers_results(taxpayers)
except ImpôtsError as erreur:
# виведення повідомлення про помилку
print(f"L'erreur suivante s'est produite : {erreur}")
finally:
# завершено
print("Travail terminé...")
Примітки
- рядки 1–10: отримується параметр [mysql / pgres], який вказує на SGBD, що має бути використаний;
- рядки 12–14: налаштовується додаток;
- рядки 16–17: імпортується клас [ImpôtsError]. Він знадобиться в рядку 38;
- рядки 19–21: отримуються посилання на шари додатка;
- рядок 25: з шару [dao] запитуються дані податкової адміністрації. Шар [métier] потребує їх для розрахунку податку;
- рядок 27: отримуємо зі списку дані (ідентифікатор, сімейний стан, діти, заробітна плата) платників податків;
- рядки 29–30: якщо цей список порожній, генерується виняток;
- рядки 32–35: розрахунок податку за елементами списку [taxpayers];
- рядок 37: запис результатів у файл jSON[résultats.json];
- рядки 38–40: обробка можливої помилки;
Для виконання скрипта створюються дві |конфігурації виконання|:

Результати, отримані у файлі [résultats.json], відповідають версії 4.

20.3. Застосування 3: розрахунок податку в інтерактивному режимі
Тепер ми представляємо додаток, що дозволяє розраховувати податок в інтерактивному режимі. Це адаптована версія додатка 2 з версії 4.


- скрипт [main] запускає діалог з користувачем за допомогою методу [ui.run] з шару [ui];
- шар [ui]:
- використовує рівень [dao] для отримання даних, необхідних для розрахунку податку;
- запитує у користувача дані про платника податків, для якого потрібно розрахувати податок;
- використовує шар [métier] для виконання цього розрахунку;
Файл [config_layers] створює екземпляр додаткового шару:
def configure(config: dict) -> dict:
# інстанціювання шару DAO
from ImpotsDaoWithAdminDataInDatabase import ImpotsDaoWithAdminDataInDatabase
config["dao"] = ImpotsDaoWithAdminDataInDatabase(config)
# інстанціювання шару [métier]
from ImpôtsMétier import ImpôtsMétier
config['métier'] = ImpôtsMétier()
# інтерфейс користувача
from ImpôtsConsole import ImpôtsConsole
config['ui'] = ImpôtsConsole(config)
# повертаємо конфігурацію
return config
Клас [ImpôtsConsole], рядки 11–12, є таким самим, як у |версії 4|.
Основний скрипт [main] має такий вигляд:
# очікуємо параметр 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})
# syspath налаштовано — можна виконувати імпорт
from ImpôtsError import ImpôtsError
# отримуємо шар [ui]
ui = config["ui"]
# код
try:
# виконання шару [ui]
ui.run()
except ImpôtsError as ex1:
# виводиться повідомлення про помилку
print(f"L'erreur 1 suivante s'est produite : {ex1}")
except BaseException as ex2:
# виводиться повідомлення про помилку
print(f"L'erreur 2 suivante s'est produite : {ex2}")
finally:
# виконується в усіх випадках
print("Travail terminé...")
- у рядках 1–10 скрипт очікує на параметр [mysql / pgres], який вказує, який саме SGBD слід використовувати;
- рядки 12–14: налаштовується додаток;
- рядки 19–20: з конфігурації отримується шар [ui];
- рядок 25: виконується;
Результати ідентичні результатам |версії 4|. Інакше й бути не могло, оскільки у версії 5 були збережені всі інтерфейси версії 4.