Перейти к содержанию

Тестовое задание с неожиданным результатом

Всем привет! Сейчас я нахожусь в поиске работы, поэтому откликаюсь на вакансии Python разработчика. Иногда hr-менеджеры находят меня сами (да, такое случилось целых два раза). Именно так произошло на прошлой неделе.

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

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

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

Нет, я ни в коем случае ни на кого не обижаюсь. Более того, я получил классный опыт, полезную обратную связь от коллеги по ремеслу с опытом и уверенность в своих силах! Теперь только вперед.

Маленькая ремарка

Кстати, если у вас есть предложение по работе, готов его рассмотреть и при необходимости выполнить ваше тестовое задание. Со мной можно связаться в Telegram или написать мне на почту: bks2408@mail.ru

Познакомиться с моими проектами, в том числе и с тестовым проектом, о котором идет речь, вы можете в моем профиле на GitHub.

Постановка задачи

Во вторник, 9 апреля со мной связался HR с предложением выполнить тестовое задание. Кроме самого тестового задания мне также дали ссылку на описание вакансии.

Согласно тестового задания требовалось разработать небольшое web-приложение для учета товаров на складе. Стек технологий, который необходимо было использовать, выглядел так:

  • MySQL и SQLAlchemy – для работы с данными;
  • Flask – для создания web-приложения;
  • Bootstrap – для оформления Frontend главной и единственной страницы приложения.

Подробно описывать сейчас все функциональные требования к приложению не буду, по ходу описания будет все понятно.

Результатом моей работы должно быть приложение, которое может быть запущено локально и работает с базой данных MySQL.

Срок выполнения тестового задания – до семи дней. Ну что, задание получил и вперед, за работу! Цель — сдать тестовый проект раньше срока, но без потерь по качеству.

На старт, внимание… стопаньки!

С точки зрения функциональности разрабатываемого web-приложения, которое я назвал product_management_app, вопросов у меня не было. В ТЗ все описано достаточно подробно. А вот к стеку вопросики были.

Дело в том, что во время учебы в Яндекс Практикуме мы изучали такой фреймворк, как Django, а в ТЗ было требование реализовать проект на фреймворке Flask. Но и это еще не все. В качестве ORM необходимо было использовать SQLAlchemy.  Ну и в дополнении ко всему в качестве СУБД следовало взять MySQL. Ах да, еще необходимо было сделать так, чтобы информация на странице обновлялась без перезагрузки.

Нет, понимание, как работают ORM, конечно, было, так как в рамках проектов на Django я изучал Django ORM, а в своих проектах я использовал Peewee ORM. Еще было какое-то общее представление о Flask. Да и опыт подключения Bootstrap тоже был, хоть и совсем небольшой.

В общем, я подумал, что это хороший повод познакомиться с новыми для себя технологиями и реализовать с их использованием тестовый проект. Ну, а что про трудности, когда они нас останавливали?

Описание моделей данных

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

  • products – для хранения информации о товарах (наименование, описание и цена);
  • locations – для хранения информации о месте расположения склада;
  • inventory – для хранения записей о товарах, добавленных пользователем на склад.

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

Устанавливаем SQLAlchemy и вперед, к описанию моделей. Модели буду описывать в файле models.py модуля database.

SQLAlchemy — это набор инструментов Python SQL и реляционный преобразователь объектов, который предоставляет разработчикам приложений всю мощь и гибкость SQL.

При создании моделей для таблиц буду использовать декларативный стиль, так как он мне более менее понятен в текущей ситуации.

На примере двух моделей рассмотрим что и как.

Модель Product

Код модели

class Product(BaseModel):
    """Модель для хранения данных о товарах."""

    __tablename__ = 'products'

    id = Column(Integer, primary_key=True)
    name = Column(
        String(length=50),
        comment='Наименование продукции',
        unique=True,
    )
    description = Column(Text, comment='Описание продукции')
    price = Column(Numeric(precision=18, scale=2), comment='Цена продукции')
    unit_id = Column(Integer, ForeignKey('units.id'))
    units = relationship(
        'Unit', back_populates='products', lazy='joined'
    )
    inventories = relationship('Inventory', back_populates='products')

    def __repr__(self) -> str:
        return f'Товар: {self.name} по цене: {self.price}'

Все классы буду наследовать от базовой модели BaseModel, которая, в свою очередь, наследуется от DeclarativeBase (базовый класс, используемый при декларативном определении моделей).

Поле id

Тут ничего особенного нет. Тип поля Integer, соответствует целочисленному типу int. primary_key=True указывает на то, что это поле является первичным ключом, позволяющим однозначно идентифицировать каждую запись в таблице. Такое поле будет у каждой модели. Кстати, его можно указать один раз в базовой модели.

Поле name

Поле name будет содержать информацию о наименовании товара. Тип используемых данных String (строковый тип). В соответствии с ТЗ длина поля не может превышать 50 символов. За это отвечает параметр length. Также устанавливаю на поле ограничения уникальности. Товаров с идентичным именем в таблице быть не должно.

Поле description

Description – поле для описания товара. Тип данных Text. Данный тип наследуется от типа String. Количество символов не ограничено.

Поле price

Вот тут я немного ошибся. Дело в том, что изначально я выбрал для этого поля тип данных  Numeric. Numeric представляет собой базовый тип для нецелочисленных типов данных.

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

Решение в итоге такое. Изначально при объявлении поля price в Numeric я определил два атрибута:

price = Column(Numeric(precision=10, scale=2), comment='Цена продукции')

Как раз, precision=10 и ограничивало меня. 

При этом  максимальное значение, которое в него может быть записано составляет 99 999 999.99. Цифра, то есть 10 цифр. Сумма, конечно, внушительная, но с ростом инфляции у нас цены могут улететь в космос, а в космосе нужно использовать числа большего порядка.

Поля unit_id и units

Как я указал выше, добавление информации об используемых единицах измерения – это уже моя инициатива.

Поле unit_id определяется как ForeignKey (внешний ключ), которое связано с соответствующим полем таблицы units.

Но для определения связи «один ко многим» этого недостаточно. Для того, чтобы все это заработало, мне понадобится в таблице products определить поле units, а в таблице units определить поле products.

Функция relationship() обеспечивает связь между двумя связанными классами моделей. В качестве аргументов я передаю наименование модели, с которой мне необходимо настроить связь, указываю обратную ссылку, по которой я могу обратиться из таблицы units к связанным объектам из таблицы products, а также указываю на способ загрузки связанных данных.

Конфигурация lazy=’joined’ (объединенная загрузка), согласно документации, применяет JOIN к оператору SELECT, чтобы связанные строки загружались в один и тот же набор результатов.

Поле inventories

Кроме модели Product поле inventories есть еще в модели Location. Это поле в связке с двумя полями модели Inventory (product_id и products) по принципу, описанному выше, помогает настроить связь «один ко многим» между моделями.

Метод __repr__()

Данный метод позволяет настроить строковое отображение объекта, что удобно при отладке и журналировании событий. Из данного представления можно, при необходимости, восстановить сам объект.

Визуальное представление

Модель описана, предлагаю взглянуть на то, как это будет выглядеть для пользователя. После нажатия кнопки «Добавить новый товар» пользователю будет показано модальное окно с формой добавления товара.

Скриншот формы добавления нового товара

Модель Inventory

 class Inventory(BaseModel):
    """Модель для хранения данных о товарах, находящихся на складах."""

    __tablename__ = 'inventory'
    __table_args__ = (UniqueConstraint('product_id', 'location_id'),)

    id = Column(Integer, primary_key=True)
    product_id = Column(Integer, ForeignKey('products.id'))
    products = relationship(
        'Product', back_populates='inventories', lazy='joined'
    )
    location_id = Column(Integer, ForeignKey('locations.id'))
    locations = relationship(
        'Location', back_populates='inventories', lazy='joined'
    )
    quantity = Column(
        Numeric(precision=18, scale=3), comment='Количество товара'
    )

    def __repr__(self) -> str:
        return (
            f'Количество продукции {self.product_id}, расположенной '
            f'в  {self.location_id} составляет {self.quantity}'
        )

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

Таблица inventory — это основная таблица в проекте, представление которой видит пользователь и через которую мы получаем связанные данные.

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

__table_args__ = (UniqueConstraint('product_id', 'location_id'),)

В моем случае ограничение установлено на пару из полей связанных моделей Product и Location.

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

Скриншот формы добавления товара на склад

Под “капотом” при добавлении товара на склад

Давайте добавим картошку на склад в Ижевске, в количестве 100 кг и посмотрим, как этот процесс выглядит на стороне backend.

После того, как мы нажмем кнопку “Добавить на склад”, сервер получит вот такой словарь:

ImmutableMultiDict(
    [
        ('product_id', '1'),
        ('location_id', '1'),
        ('quantity', '100')
    ]
)

Для того чтобы перехватить именно этот словарь и передать его далее для обработки, валидации и записи в БД, используем проверку по ключам с помощью свойств такого типа, как set (множества):

if set(['location_id', 'quantity', 'product_id']) == set(
    request.form.keys()
):
    processing_request_add_product_to_inventory(request.form)

Словарь с нужными ключами перехвачен. Передаем данные из формы в функцию processing_request_add_product_to_inventory(request.form), где и будет реализована основная логика работы с полученными данными.

Код функции processing_request_add_product_to_inventory(request.form):

def processing_request_add_product_to_inventory(
    form_data: ImmutableMultiDict,
) -> None:
    """
    Обработка запроса пользователя на добавление
    товара на склад в конкретной локации.
    """
    location_id = form_data.get('location_id')
    quantity = request.form.get('quantity')
    product_id = request.form.get('product_id')
    result = validation_inventory(location_id, quantity, product_id)
    if result:
        product_instance = get_object_by_id(
            model=Product, id=int(product_id)
        )
        location_instance = get_object_by_id(
            model=Location, id=int(location_id)
        )
        if product_instance and location_instance:
            query_result = add_product_to_inventory(
                product_instance,
                location_instance,
                Decimal(quantity.replace(',', '.'))
            )
            if query_result.get('status'):
                inventory_instance = query_result.get('instance')
                flash(
                    f'Товар {inventory_instance.products.name} '
                    'успешно добавлен!'
                )
            else:
                flash(
                    f'Товар: {product_instance.name} ранее уже '
                    'был добавлен на склад'
                )
        else:
            flash(
                'При обработке данных произошла ошибка, '
                'проверьте правильность указания товара или локации.'
            )
    else:
        flash(
            'При валидации введенных данных произошла ошибка, '
            'проверьте корректность указания товара или локации.'
        )

Перед записью данных о товаре, добавленном на склад пользователем, значения полученного словаря передаю на небольшую валидацию:

def validation_inventory(
    location_id: str, quantity: str, product_id: str
) -> bool:
    """Функция валидации данных о новом товаре при добавлении на склад."""
    return (
        isinstance(location_id, str)
        and isinstance(quantity, str)
        and isinstance(product_id, str)
        and location_id.isdigit()
        and product_id.isdigit()
        and float(quantity) > 0
    )

Если данные валидны, идем дальше. Если нет, то пользователь получит сообщение:

При валидации введенных данных произошла ошибка, проверьте корректность указания товара или локации.

Для того чтобы записать данные о товаре на складе, необходимо получить по id добавляемый товар и локацию склада, куда товар добавляется. В этом мне поможет функция get_object_by_id(), которой необходимо передать класс модели и id запрашиваемого объекта:

def get_object_by_id(model: BaseModel, id: int):
    """Получение объекта по его id."""
    with Session(autoflush=False, bind=engine) as session:
        instance = session.get(model, id)
    return instance

Проверив, что функция не вернула None, переходим к процессу записи данных о товаре на складе:

def add_product_to_inventory(
    product: Product,
    location: Location,
    quantity: Decimal,
):
    """Функция добавления товара на склад."""
    with Session(autoflush=False, bind=engine) as session:
        try:
            inventory_record = Inventory(
                product_id=product.id,
                location_id=location.id,
                quantity=quantity,
            )
            session.add(inventory_record)
            session.commit()
        except IntegrityError as error:
            return {
                'status': False,
                'error_message': error,
            }
        instance = session.get(Inventory, inventory_record.id)
    return {
        'status': True,
        'instance': instance,
    }

Ну, а дальше все максимально просто, в зависимости от результата с помощью функции flash() отправляем пользователю информацию в модальное окно. Например, результат добавления новой записи о товаре, добавленном на склад, выглядит вот так:

Скриншот сообщения об успешном добавлении товара на склад

После добавления товара на склад, соответствующая запись появляется в нашей таблице:

Скриншот записи информации о товаре, добавленном на склад

В остальном все работает примерно по такой же логике. Если интересно, добро пожаловать на мой GitHub, репозиторий с проектом вы можете посмотреть вот тут!

Подводя итог

Самое главное, за время работы над этим проектом я познакомился с новыми для себя технологиями, прокачал уже существующие навыки работы с кодом. Определил, какие места и направления стоит изучить более углубленно. Работы предстоит много, как над этим проектом, так и над остальными pet-проектами.

Что я еще хочу сделать:

  • настроить сортировку товаров по конкретным складам;
  • настроить регистрацию пользователей;
  • реализовать возможность выгрузки в Excel данных о товарах, добавленных на склад;
  • подумать над реализацией многопользовательского режима работы проекта.

Вперёд!