Skip to content

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

Image

Ми розробимо три додатки:

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

20.1. Додаток 1: ініціалізація бази даних

Додаток 1 матиме таку архітектуру:

Image

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

20.1.1. Файл [admindata.json]

Image

Файл [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] це виглядає так:

Image

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

Image

Бази даних створено, але наразі вони не містять жодної таблиці. Ці таблиці будуть створені за допомогою 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)
                             )

Об’єкти, які будуть зіставлені з цими двома таблицями, будуть такими:

Image

Об’єкт [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] буде такою:

Image



    # таблиця констант
    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]

Image

Ми щойно детально розглянули частину конфігурації [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, який потрібно побудувати:

Image

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

Image

Шар [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. Налаштування додатка

Image

Додаток налаштовується за допомогою трьох файлів [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] додатка

Image

Image

Основний скрипт [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):

Image

Image

Image

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

Image

Image

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

Image

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

Повторюємо те саме для таблиці констант [tbconstantes]:

Image

Image

Image

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

Image

20.2.1. Архітектура

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

Image

Рівень [dao] реалізує інтерфейс [InterfaceImpôtsDao]. Ми створили клас, що реалізує цей інтерфейс:

  • [ImpôtsDaoWithAdminDataInJsonFile], який отримував податкові дані з файлу jSON. Це була версія 3;

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

Нова архітектура буде такою:

Image

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

Image

Файл конфігурації [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]

Image

Клас реалізації [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] не змінюється.

Image

Image

Тест [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 забезпечує, щоб цей метод більше не мав жодних параметрів;

Ми створюємо дві конфігурації виконання:

Image

Image

Якщо виконати одну з цих двох конфігурацій, отримаємо такі результати:


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. Головний скрипт

Image

Image

Головний скрипт [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: обробка можливої помилки;

Для виконання скрипта створюються дві |конфігурації виконання|:

Image

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

Image

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

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

Image

Image

  • скрипт [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.