From 4cc7a53bc932b7899fe40d3907408d6d1a038430 Mon Sep 17 00:00:00 2001 From: Pavel Sinitsin Date: Sun, 23 Jun 2024 17:18:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4?= =?UTF-8?q?=20get=5Ftree=5Fcategories.=20=D0=92=D0=BE=D0=B7=D0=B2=D1=80?= =?UTF-8?q?=D0=B0=D1=89=D0=B0=D0=B5=D1=82=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B9?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bpiek/api.py | 145 ++++++++++++++++++++++++------ bpiek/models.py | 188 --------------------------------------- bpiek/models/category.py | 13 +++ bpiek/models/product.py | 167 ++++++++++++++++++++++++++++++++++ bpiek/models/response.py | 39 ++++++++ bpiek/utils.py | 1 + pyproject.toml | 2 +- 7 files changed, 338 insertions(+), 217 deletions(-) delete mode 100644 bpiek/models.py create mode 100644 bpiek/models/category.py create mode 100644 bpiek/models/product.py create mode 100644 bpiek/models/response.py create mode 100644 bpiek/utils.py diff --git a/bpiek/api.py b/bpiek/api.py index 308f9a5..7993868 100644 --- a/bpiek/api.py +++ b/bpiek/api.py @@ -1,5 +1,14 @@ import requests -from bpiek import models +from bpiek.models.product import Product +from bpiek.models.category import Category +from bpiek.models.response import ( + ParentCategoriesResponse, + CategoriesAndProductsBySlugParentCategory, + NewProductsResponse, + RemainsAndPlanresiduesResponse, + TreeCategoriesResponse, +) + AUTH_URL = "https://bp.iek.ru/oauth/login" API_URL = "https://bp.iek.ru/api/catalog/v1/" @@ -9,7 +18,7 @@ class BPIekApi: def __init__(self) -> None: self.session = requests.Session() - def _instance(self, endpoint, params: dict = {}): + def _instance(self, endpoint: str, params: dict = {}): response = self.session.get( url=API_URL + endpoint, headers={"Content-Type": "application/json"}, @@ -17,53 +26,54 @@ class BPIekApi: ) return response.json() - def login(self, username, password) -> None: + def login(self, username: str, password: str) -> None: auth = self.session.post( url=f"{AUTH_URL}", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={"username": username, "password": password}, ) - def get_parent_categories(self) -> list[models.Category] | models.Error: + if len(auth.cookies) == 0: + raise Exception("Invalid username or password") + + def get_parent_categories(self) -> list[Category]: response = self._instance("client/catalog") try: - result: models.ParentCategoriesResponse = ( - models.ParentCategoriesResponse.model_validate(response) + result: ParentCategoriesResponse = ParentCategoriesResponse.model_validate( + response ) return result.categories except Exception as e: - return models.Error(code=400, message=str(e)) + raise Exception(e) - def get_product_by_article(self, article: str) -> models.Product | models.Error: + def get_product_by_article(self, article: str) -> Product: response = self._instance(f"client/products/{article}") try: - result: models.Product = models.Product.model_validate(response) + result: Product = Product.model_validate(response) return result except Exception as e: - return models.Error(code=400, message=str(e)) + raise Exception(e) def get_categories_and_products_by_slug_parent_category( - self, slug - ) -> models.CategoriesAndProductsBySlugParentCategory | models.Error: + self, slug: str + ) -> CategoriesAndProductsBySlugParentCategory: response = self._instance(f"client/category/{slug}/json") try: - result: models.CategoriesAndProductsBySlugParentCategory = ( - models.CategoriesAndProductsBySlugParentCategory.model_validate( - response - ) + result: CategoriesAndProductsBySlugParentCategory = ( + CategoriesAndProductsBySlugParentCategory.model_validate(response) ) return result except Exception as e: - return models.Error(code=400, message=str(e)) + raise Exception(e) def get_new_products( self, @@ -71,33 +81,112 @@ class BPIekApi: sortOrder: str = "asc", pageSize: int = 10, page: int = 1, - ) -> models.NewProductsResponse | models.Error: + ) -> NewProductsResponse: response = self._instance( "new-products", {sortBy: sortBy, sortOrder: sortOrder, pageSize: pageSize, page: page}, ) try: - result: models.NewProductsResponse = ( - models.NewProductsResponse.model_validate(response) - ) + result: NewProductsResponse = NewProductsResponse.model_validate(response) return result except Exception as e: - return models.Error(code=400, message=str(e)) + raise Exception(e) - def get_remains_and_planresidues( - self, slug - ) -> models.RemainsAndPlanresiduesResponse | models.Error: + def get_remains_and_planresidues(self, slug: str) -> RemainsAndPlanresiduesResponse: response = self._instance(f"client/category/{slug}/balances-json") try: - result: models.RemainsAndPlanresiduesResponse = ( - models.RemainsAndPlanresiduesResponse.model_validate(response) + result: RemainsAndPlanresiduesResponse = ( + RemainsAndPlanresiduesResponse.model_validate(response) ) return result except Exception as e: - return models.Error(code=400, message=str(e)) + raise Exception(e) + + def get_tree_categories(self) -> list[Category]: + tree = {} + + def convert_to_nested_list(nested_dict: dict) -> list: + result = [] + for key, value in nested_dict.items(): + if isinstance(value, dict): + try: + result.append( + { + "name": value["name"], + "slug": value["slug"], + "url": value["url"], + "childs": convert_to_nested_list(value["childs"]), + } + ) + except: + continue + else: + result.append(value) + return result + + def convert_list_to_nested_dict(_list: list): + def update_nested(d, keys, value): + for key in keys[:-1]: + d = d.setdefault(key, {}) + d[keys[-1]] = value + + tmp_categories = _list.copy() + + for idx, category in enumerate(tmp_categories): + paths = category.url[1:][:-1].split("/") + + _str = "" + for _idx, path in enumerate(paths): + if len(paths) == _idx + 1: + _str += f"{path}" + break + + _str += f"{path},childs," + + try: + update_nested( + tree, + _str.split(","), + { + "name": category.name, + "slug": category.slug, + "url": category.url, + "childs": {}, + }, + ) + + tmp_categories.pop(idx) + except Exception as e: + print(e) + continue + + return tmp_categories + + for parent_category in self.get_parent_categories(): + tree[parent_category.slug] = { + "name": parent_category.name, + "slug": parent_category.slug, + "url": parent_category.url, + "childs": {}, + } + categories_and_products = ( + self.get_categories_and_products_by_slug_parent_category( + slug=parent_category.slug + ) + ) + tmp_categories = convert_list_to_nested_dict( + categories_and_products.categories + ) + while len(tmp_categories) != 0: + tmp_categories = convert_list_to_nested_dict(tmp_categories) + break + + return TreeCategoriesResponse.model_validate( + {"tree": convert_to_nested_list(tree)} + ).tree diff --git a/bpiek/models.py b/bpiek/models.py deleted file mode 100644 index 260572b..0000000 --- a/bpiek/models.py +++ /dev/null @@ -1,188 +0,0 @@ -from typing import Any -from pydantic import BaseModel, ConfigDict, Field -from pydantic.types import UUID1 - - -class Category(BaseModel): - slug: str # Слаг категории - name: str # Название категории - url: str # Относительный адрес категории - apiUrl: str # Ссылка на скачивание файла с содержимым категории - - -class ProductShort(BaseModel): - class WarehouseData(BaseModel): - class Incoming(BaseModel): - dateBegan: str | None - dateEnd: str | None - amount: int - type: str # Enum: "production" "shipping" Тип поступления, production - поступление после производства, shipping - доставка на склад - - warehouseId: UUID1 - warehouseName: str - availableAmount: int - incoming: list[Incoming] - - article: str # Артикул товара - name: str # Полное наименование товара - multiplicity: int | None # Кратность продажи - priceBase: float | None # Базовая цена с НДС - priceRrc: float | None # Рекомендованная розничная цена (РРЦ) с НДС - available: int | None # Значение остатка - units: str | None # Единицы измерения - warehouseData: list[WarehouseData] - - -class Product(ProductShort): - class ImageVariant(BaseModel): - url: str # Ссылка - ext: str # Расширение - width: int # Ширина - - class Etim(BaseModel): - class EtimClass(BaseModel): - id: str - name: str # Название класса - - class EtimFeatures(BaseModel): - id: str - name: str # Название свойства - sort: int | None # Порядок сортировки по умолчанию - unit: str | None # Единицы измерения - value: str # Значение свойства - value_union: str # Код значения - - etim_class: EtimClass = Field(alias="class") - features: list[EtimFeatures] - - class Complects(BaseModel): - article: str # Артикул - name: str # Наименование - quantity: int # Количество - - class LeftPeriod(BaseModel): - name: str # Название характеристики - value: str # Значение характеристики - - class LeftPeriodRaw(BaseModel): - class Lifespan(BaseModel): - limit: str | None - value: str | None - units: str | None - - class Warranty(BaseModel): - value: str | None - units: str | None - - lifespan: Lifespan - warranty: Warranty - - class LogisticParams(BaseModel): - class Value(BaseModel): - group: str | None - individual: str | None - transport: str | None - - name: str - nameOrig: str - value: Value - - class LogisticParamsData(BaseModel): - class SinglePackage(BaseModel): - multiplicity: int | None - unit: str | None - - singlePackage: SinglePackage - - class DesignFeatures(BaseModel): - imageUrl: str - description: str - - class Videos(BaseModel): - name: str - description: str - url: str - type: str # Enum: "url" "file" - - class Software(BaseModel): - name: str - description: str - url: str - size: str - - shortName: str # Краткое название - description: str | None # Описание - categoryName: str | None # Название категории - category: str # Относительный путь до категории в каталоге - slug: str # Слаг товара - tm: str # Торговая марка - url: str # Ссылка на товар - isArchived: bool # Архивный или нет - imageUrl: str # Фото товара (основное) - imageUrls: list[str] # Все фото товара - imageVariants: list[ImageVariant] # Все вариации изображений - advantages: str | None # Преимущества - etim: Etim # EIM характеристики товара - complects: list[Complects] # Комплектация и сопутствующие товары - complectations: str | None # Комплектация - files: list[Any] # Список файлов, относящихся к товару (ГЧ, КД, CAD-модели и т.д.) - leftPeriod: list[LeftPeriod] | None # Характеристики срока службы - leftPeriodRaw: LeftPeriodRaw # Гарантийные показатели - logisticParams: list[LogisticParams] # Логистические характеристики - logisticParamsData: ( - LogisticParamsData | None - ) # Подробные логистические характеристики - novelty: bool # Новинка или нет - designFeatures: list[DesignFeatures] # Отличительные особенности - videos: list[Videos] # Видео по товару - software: list[Software] # ПО по товару - banner: str | None # Текст баннера - lastModified: str | None # Дата последнего изменения - countryOfProduction: str | None # Страна производства - firstSaleDate: str | None # Дата начала продаж - feacn: str | None # Код ТН ВЭД - family: str | Any | None - series: str | Any | None - indPacking: list[str] # Ссылки на фото упаковки - analogs: list["Product"] # Аналоги - related: list["Product"] # Совместно применяемые изделия - qrCode: str | None = Field(default=None) - isOutOfAssortment: bool - isOutOfProduction: bool - - -class ParentCategoriesResponse(BaseModel): - categories: list[Category] - - -class CategoriesAndProductsBySlugParentCategory(BaseModel): - date: str - slug: str - name: str - url: str - categories: list[Category] - products: list[Product] - - -class NewProductsResponse(BaseModel): - class Data(BaseModel): - products: list[Product] - - class Meta(BaseModel): - page: str - totalPages: int - totalCount: int - pageSize: int - - data: Data - _meta: Meta - - -class RemainsAndPlanresiduesResponse(BaseModel): - date: str - products: list[ProductShort] - - -class Error(BaseModel): - code: int - message: str diff --git a/bpiek/models/category.py b/bpiek/models/category.py new file mode 100644 index 0000000..69c2193 --- /dev/null +++ b/bpiek/models/category.py @@ -0,0 +1,13 @@ +from typing import Any +from pydantic import BaseModel, ConfigDict, Field +from pydantic.types import UUID1 + + +class Category(BaseModel): + slug: str # Слаг категории + name: str # Название категории + url: str # Относительный адрес категории + apiUrl: str | None = Field( + default=None + ) # Ссылка на скачивание файла с содержимым категории + childs: list["Category"] | None = Field(default=[]) diff --git a/bpiek/models/product.py b/bpiek/models/product.py new file mode 100644 index 0000000..bd28e52 --- /dev/null +++ b/bpiek/models/product.py @@ -0,0 +1,167 @@ +from typing import Any +from pydantic import BaseModel, ConfigDict, Field +from pydantic.types import UUID1 + + +class WarehouseData(BaseModel): + class Incoming(BaseModel): + dateBegan: str | None + dateEnd: str | None + amount: int | float + type: str # Enum: "production" "shipping" Тип поступления, production - поступление после производства, shipping - доставка на склад + + warehouseId: UUID1 + warehouseName: str + availableAmount: int + incoming: list[Incoming] + + +class ImageVariant(BaseModel): + url: str # Ссылка + ext: str # Расширение + width: int # Ширина + + +class Etim(BaseModel): + class EtimClass(BaseModel): + id: str + name: str # Название класса + + class EtimFeatures(BaseModel): + id: str + name: str # Название свойства + sort: int | None # Порядок сортировки по умолчанию + unit: str | None # Единицы измерения + value: str # Значение свойства + value_union: str # Код значения + + etim_class: EtimClass = Field(alias="class") + features: list[EtimFeatures] + + +class Complects(BaseModel): + article: str # Артикул + name: str # Наименование + quantity: int # Количество + + +class LeftPeriod(BaseModel): + name: str # Название характеристики + value: str | None # Значение характеристики + + +class LeftPeriodRaw(BaseModel): + class Lifespan(BaseModel): + limit: str | None + value: str | None + units: str | None + + class Warranty(BaseModel): + value: str | None + units: str | None + + lifespan: Lifespan + warranty: Warranty + + +class LogisticParams(BaseModel): + class Value(BaseModel): + group: str | None + individual: str | None + transport: str | None + + name: str + nameOrig: str + value: Value + + +class LogisticParamsData(BaseModel): + class SinglePackage(BaseModel): + multiplicity: int | None + unit: str | None + + singlePackage: SinglePackage + + +class DesignFeatures(BaseModel): + imageUrl: str + description: str + + +class Videos(BaseModel): + name: str + description: str | None + url: str + type: str # Enum: "url" "file" + + +class Software(BaseModel): + name: str + description: str + url: str + size: int + + +class AnalogProduct(BaseModel): + article: str # Артикул товара + name: str # Полное наименование товара + shortName: str # Краткое название + description: str | None # Описание + imageUrl: str # Фото товара (основное) + imageUrls: list[str] # Все фото товара + imageVariants: list[ImageVariant] # Все вариации изображений + isArchived: bool # Архивный или нет + tm: str # Торговая марка + + +class ShortProduct(BaseModel): + article: str # Артикул товара + name: str # Полное наименование товара + multiplicity: int | None # Кратность продажи + priceBase: float | None # Базовая цена с НДС + priceRrc: float | None # Рекомендованная розничная цена (РРЦ) с НДС + available: int | None # Значение остатка + units: str | None # Единицы измерения + warehouseData: list[WarehouseData] + + +class Product(ShortProduct): + shortName: str # Краткое название + description: str | None # Описание + categoryName: str | None # Название категории + category: str # Относительный путь до категории в каталоге + slug: str # Слаг товара + tm: str # Торговая марка + url: str # Ссылка на товар + isArchived: bool # Архивный или нет + imageUrl: str # Фото товара (основное) + imageUrls: list[str] # Все фото товара + imageVariants: list[ImageVariant] # Все вариации изображений + advantages: str | None # Преимущества + etim: Etim | None # EIM характеристики товара + complects: list[Complects] # Комплектация и сопутствующие товары + complectations: str | None # Комплектация + files: list[Any] # Список файлов, относящихся к товару (ГЧ, КД, CAD-модели и т.д.) + leftPeriod: list[LeftPeriod] | None # Характеристики срока службы + leftPeriodRaw: LeftPeriodRaw | None # Гарантийные показатели + logisticParams: list[LogisticParams] # Логистические характеристики + logisticParamsData: ( + LogisticParamsData | None + ) # Подробные логистические характеристики + novelty: bool # Новинка или нет + designFeatures: list[DesignFeatures] # Отличительные особенности + videos: list[Videos] # Видео по товару + software: list[Software] # ПО по товару + banner: str | None # Текст баннера + lastModified: str | None # Дата последнего изменения + countryOfProduction: str | None # Страна производства + firstSaleDate: str | None # Дата начала продаж + feacn: str | None # Код ТН ВЭД + family: str | Any | None = Field(default=None) + series: str | Any | None = Field(default=None) + indPacking: list[str] # Ссылки на фото упаковки + analogs: list[AnalogProduct] # Аналоги + related: list[AnalogProduct] # Совместно применяемые изделия + qrCode: str | None = Field(default=None) + isOutOfAssortment: bool + isOutOfProduction: bool diff --git a/bpiek/models/response.py b/bpiek/models/response.py new file mode 100644 index 0000000..8fe8b81 --- /dev/null +++ b/bpiek/models/response.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel, ConfigDict, Field +from .category import Category +from .product import Product, ShortProduct + + +class ParentCategoriesResponse(BaseModel): + categories: list[Category] + + +class CategoriesAndProductsBySlugParentCategory(BaseModel): + date: str + slug: str + name: str + url: str + categories: list[Category] + products: list[Product] + + +class NewProductsResponse(BaseModel): + class Data(BaseModel): + products: list[Product] + + class Meta(BaseModel): + page: str + totalPages: int + totalCount: int + pageSize: int + + data: Data + _meta: Meta + + +class RemainsAndPlanresiduesResponse(BaseModel): + date: str + products: list[ShortProduct] + + +class TreeCategoriesResponse(BaseModel): + tree: list[Category] diff --git a/bpiek/utils.py b/bpiek/utils.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/bpiek/utils.py @@ -0,0 +1 @@ + diff --git a/pyproject.toml b/pyproject.toml index af51e46..136f235 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bpiek" -version = "1.0.0" +version = "1.1.0" description = "API клиент для Бизнес-платформа IEK" authors = ["Pavel Sinitsin "] readme = "README.md"