From e80c818fb7ba025111de41660fe78906590a4075 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Mon, 8 Dec 2025 15:36:10 +0000 Subject: [PATCH 1/2] Update seeding for Catalog --- mpt_api_client/resources/catalog/products.py | 12 +- pyproject.toml | 5 +- seed/accounts/api_tokens.py | 14 +- seed/accounts/buyer.py | 16 +- seed/accounts/licensee.py | 14 +- seed/accounts/module.py | 12 +- seed/accounts/seller.py | 12 +- seed/accounts/user_group.py | 14 +- .../FIL-9920-4780-9379.png | Bin seed/assets/__init__.py | 0 seed/assets/assets.py | 7 + seed/assets/empty.pdf | Bin 0 -> 3840 bytes seed/{data => assets}/logo.png | Bin seed/catalog/authorization.py | 38 +++ seed/catalog/catalog.py | 32 +- seed/catalog/item.py | 36 +-- seed/catalog/item_group.py | 87 ----- seed/catalog/listing.py | 45 +++ seed/catalog/price_list.py | 29 ++ seed/catalog/product.py | 303 ++++++++++++------ seed/catalog/product_parameters.py | 99 ------ seed/catalog/product_parameters_group.py | 82 ----- seed/container.py | 24 +- seed/context.py | 4 +- seed/defaults.py | 11 - seed/helper.py | 83 +++++ seed/seed_api.py | 19 +- tests/seed/catalog/test_authorization.py | 40 +++ tests/seed/catalog/test_catalog.py | 44 +-- tests/seed/catalog/test_item_group.py | 98 ------ tests/seed/catalog/test_listing.py | 53 +++ tests/seed/catalog/test_price_list.py | 47 +++ tests/seed/catalog/test_product.py | 190 +++++++---- tests/seed/catalog/test_product_parameters.py | 110 ------- .../catalog/test_product_parameters_group.py | 103 ------ tests/seed/test_context.py | 17 + tests/seed/test_helper.py | 50 +++ 37 files changed, 830 insertions(+), 920 deletions(-) rename seed/{catalog => assets}/FIL-9920-4780-9379.png (100%) create mode 100644 seed/assets/__init__.py create mode 100644 seed/assets/assets.py create mode 100644 seed/assets/empty.pdf rename seed/{data => assets}/logo.png (100%) create mode 100644 seed/catalog/authorization.py delete mode 100644 seed/catalog/item_group.py create mode 100644 seed/catalog/listing.py create mode 100644 seed/catalog/price_list.py delete mode 100644 seed/catalog/product_parameters.py delete mode 100644 seed/catalog/product_parameters_group.py delete mode 100644 seed/defaults.py create mode 100644 seed/helper.py create mode 100644 tests/seed/catalog/test_authorization.py delete mode 100644 tests/seed/catalog/test_item_group.py create mode 100644 tests/seed/catalog/test_listing.py create mode 100644 tests/seed/catalog/test_price_list.py delete mode 100644 tests/seed/catalog/test_product_parameters.py delete mode 100644 tests/seed/catalog/test_product_parameters_group.py create mode 100644 tests/seed/test_context.py create mode 100644 tests/seed/test_helper.py diff --git a/mpt_api_client/resources/catalog/products.py b/mpt_api_client/resources/catalog/products.py index 73f868b8..cfe04dde 100644 --- a/mpt_api_client/resources/catalog/products.py +++ b/mpt_api_client/resources/catalog/products.py @@ -65,9 +65,9 @@ class ProductsServiceConfig: class ProductsService( - CreateFileMixin[Model], - UpdateFileMixin[Model], - PublishableMixin[Model], + CreateFileMixin[Product], + UpdateFileMixin[Product], + PublishableMixin[Product], GetMixin[Product], DeleteMixin, CollectionMixin[Product], @@ -128,9 +128,9 @@ def update_settings(self, product_id: str, settings: ResourceData) -> Product: class AsyncProductsService( - AsyncCreateFileMixin[Model], - AsyncUpdateFileMixin[Model], - AsyncPublishableMixin[Model], + AsyncCreateFileMixin[Product], + AsyncUpdateFileMixin[Product], + AsyncPublishableMixin[Product], AsyncGetMixin[Product], AsyncDeleteMixin, AsyncCollectionMixin[Product], diff --git a/pyproject.toml b/pyproject.toml index 15df09c5..aed35e18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,8 +131,11 @@ per-file-ignores = [ "tests/e2e/accounts/*.py: WPS430 WPS202", "tests/e2e/catalog/*.py: WPS202 WPS421", "tests/e2e/catalog/items/*.py: WPS110 WPS202", + "tests/seed/catalog/test_product.py: WPS202 WPS204 WPS219", "tests/*: WPS432 WPS202", - "seed/accounts/*.py: WPS453", + "seed/*: WPS404", + "seed/accounts/*.py: WPS204 WPS404 WPS453", + "seed/catalog/product.py: WPS202 WPS204 WPS217 WPS201 WPS213 WPS404" ] [tool.ruff] diff --git a/seed/accounts/api_tokens.py b/seed/accounts/api_tokens.py index 9e21e4a0..4dfd984f 100644 --- a/seed/accounts/api_tokens.py +++ b/seed/accounts/api_tokens.py @@ -1,20 +1,20 @@ import logging import os -from dependency_injector.wiring import inject +from dependency_injector.wiring import Provide, inject from mpt_api_client import AsyncMPTClient from mpt_api_client.resources.accounts.api_tokens import ApiToken +from seed.container import Container from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_OPERATIONS logger = logging.getLogger(__name__) @inject async def get_api_token( - context: Context = DEFAULT_CONTEXT, - mpt_ops: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_ops: AsyncMPTClient = Provide[Container.mpt_operations], ) -> ApiToken | None: """Get API token from context or fetch from API.""" api_token_id = context.get_string("accounts.api_token.id") @@ -34,7 +34,7 @@ async def get_api_token( @inject def build_api_token_data( - context: Context = DEFAULT_CONTEXT, + context: Context = Provide[Container.context], ) -> dict[str, object]: """Get API token data dictionary for creation.""" account_id = os.getenv("CLIENT_ACCOUNT_ID") @@ -50,8 +50,8 @@ def build_api_token_data( @inject async def init_api_token( - context: Context = DEFAULT_CONTEXT, - mpt_ops: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_ops: AsyncMPTClient = Provide[Container.mpt_operations], ) -> ApiToken: """Get or create API token.""" api_token = await get_api_token(context=context, mpt_ops=mpt_ops) diff --git a/seed/accounts/buyer.py b/seed/accounts/buyer.py index 44b95f9f..f0c44407 100644 --- a/seed/accounts/buyer.py +++ b/seed/accounts/buyer.py @@ -2,22 +2,22 @@ import os import pathlib -from dependency_injector.wiring import inject +from dependency_injector.wiring import Provide, inject from mpt_api_client import AsyncMPTClient from mpt_api_client.resources.accounts.buyers import Buyer +from seed.container import Container from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_OPERATIONS logger = logging.getLogger(__name__) -icon = pathlib.Path("seed/data/logo.png").resolve() +icon = pathlib.Path(__file__).parent.parent / "data/logo.png" @inject async def get_buyer( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Buyer | None: """Get buyer from context or fetch from API.""" buyer_id = context.get_string("accounts.buyer.id") @@ -36,7 +36,7 @@ async def get_buyer( @inject -def build_buyer_data(context: Context = DEFAULT_CONTEXT) -> dict[str, object]: +def build_buyer_data(context: Context = Provide[Container.context]) -> dict[str, object]: """Build buyer data dictionary for creation.""" buyer_account_id = os.getenv("CLIENT_ACCOUNT_ID") if not buyer_account_id: @@ -65,8 +65,8 @@ def build_buyer_data(context: Context = DEFAULT_CONTEXT) -> dict[str, object]: @inject async def init_buyer( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Buyer: """Get or create buyer.""" buyer = await get_buyer(context=context, mpt_operations=mpt_operations) diff --git a/seed/accounts/licensee.py b/seed/accounts/licensee.py index ebb3e416..faf54521 100644 --- a/seed/accounts/licensee.py +++ b/seed/accounts/licensee.py @@ -2,12 +2,12 @@ import os import pathlib -from dependency_injector.wiring import inject +from dependency_injector.wiring import Provide, inject from mpt_api_client import AsyncMPTClient from mpt_api_client.resources.accounts.licensees import Licensee +from seed.container import Container from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_CLIENT logger = logging.getLogger(__name__) @@ -16,8 +16,8 @@ @inject async def get_licensee( - context: Context = DEFAULT_CONTEXT, - mpt_client: AsyncMPTClient = DEFAULT_MPT_CLIENT, + context: Context = Provide[Container.context], + mpt_client: AsyncMPTClient = Provide[Container.mpt_client], ) -> Licensee | None: """Get licensee from context or fetch from API.""" licensee_id = context.get_string("accounts.licensee.id") @@ -37,7 +37,7 @@ async def get_licensee( @inject def build_licensee_data( # noqa: WPS238 - context: Context = DEFAULT_CONTEXT, + context: Context = Provide[Container.context], ) -> dict[str, object]: """Get licensee data dictionary for creation.""" account_id = os.getenv("CLIENT_ACCOUNT_ID") @@ -76,8 +76,8 @@ def build_licensee_data( # noqa: WPS238 @inject async def init_licensee( - context: Context = DEFAULT_CONTEXT, - mpt_client: AsyncMPTClient = DEFAULT_MPT_CLIENT, + context: Context = Provide[Container.context], + mpt_client: AsyncMPTClient = Provide[Container.mpt_client], ) -> Licensee: """Get or create licensee.""" licensee = await get_licensee(context=context, mpt_client=mpt_client) diff --git a/seed/accounts/module.py b/seed/accounts/module.py index 7fc705b4..2c0cd8d0 100644 --- a/seed/accounts/module.py +++ b/seed/accounts/module.py @@ -1,20 +1,20 @@ import logging -from dependency_injector.wiring import inject +from dependency_injector.wiring import Provide, inject from mpt_api_client import AsyncMPTClient from mpt_api_client.resources.accounts.modules import Module from mpt_api_client.rql.query_builder import RQLQuery +from seed.container import Container from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_OPERATIONS logger = logging.getLogger(__name__) @inject async def get_module( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Module | None: """Get module from context or fetch from API.""" module_id = context.get_string("accounts.module.id") @@ -34,8 +34,8 @@ async def get_module( @inject async def refresh_module( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Module | None: """Refresh module in context (always fetch).""" module = await get_module(context=context, mpt_operations=mpt_operations) diff --git a/seed/accounts/seller.py b/seed/accounts/seller.py index 2194f6a5..0533c83f 100644 --- a/seed/accounts/seller.py +++ b/seed/accounts/seller.py @@ -2,20 +2,20 @@ import logging import uuid -from dependency_injector.wiring import inject +from dependency_injector.wiring import Provide, inject from mpt_api_client import AsyncMPTClient from mpt_api_client.resources.accounts.sellers import Seller +from seed.container import Container from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_OPERATIONS logger = logging.getLogger(__name__) @inject async def get_seller( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Seller | None: """Get seller from context or fetch from API.""" seller_id = context.get_string("accounts.seller.id") @@ -54,8 +54,8 @@ def build_seller_data(external_id: str | None = None) -> dict[str, object]: @inject async def init_seller( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Seller | None: """Get or create seller. Returns Seller if successful, None otherwise.""" seller = await get_seller(context=context, mpt_operations=mpt_operations) diff --git a/seed/accounts/user_group.py b/seed/accounts/user_group.py index 203498cb..db064ca3 100644 --- a/seed/accounts/user_group.py +++ b/seed/accounts/user_group.py @@ -2,20 +2,20 @@ import logging import os -from dependency_injector.wiring import inject +from dependency_injector.wiring import Provide, inject from mpt_api_client import AsyncMPTClient from mpt_api_client.resources.accounts.user_groups import UserGroup +from seed.container import Container from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_OPERATIONS logger = logging.getLogger(__name__) @inject async def get_user_group( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> UserGroup | None: """Get user group from context or fetch from API.""" user_group_id = context.get_string("accounts.user_group.id") @@ -35,7 +35,7 @@ async def get_user_group( @inject def build_user_group_data( - context: Context = DEFAULT_CONTEXT, + context: Context = Provide[Container.context], ) -> dict[str, object]: """Get user group data dictionary for creation.""" account_id = os.getenv("CLIENT_ACCOUNT_ID") @@ -54,8 +54,8 @@ def build_user_group_data( @inject async def init_user_group( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> UserGroup | None: """Get or create user group.""" user_group = await get_user_group(context=context, mpt_operations=mpt_operations) diff --git a/seed/catalog/FIL-9920-4780-9379.png b/seed/assets/FIL-9920-4780-9379.png similarity index 100% rename from seed/catalog/FIL-9920-4780-9379.png rename to seed/assets/FIL-9920-4780-9379.png diff --git a/seed/assets/__init__.py b/seed/assets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/seed/assets/assets.py b/seed/assets/assets.py new file mode 100644 index 00000000..6b3258b7 --- /dev/null +++ b/seed/assets/assets.py @@ -0,0 +1,7 @@ +import pathlib + +ICON = pathlib.Path(__file__).parent / "FIL-9920-4780-9379.png" + +LOGO = pathlib.Path(__file__).parent / "FIL-9920-4780-9379.png" + +PDF = pathlib.Path(__file__).parent / "empty.pdf" diff --git a/seed/assets/empty.pdf b/seed/assets/empty.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5abbd77a4955ff1b3736f45e405394c832c35750 GIT binary patch literal 3840 zcmai%c{r5s8pj7y7$ud6RBy5tV>Vma2a_m7WvtE0*hXXQ$&w{ovL#AF5waAOXvmT! z9g}RSkQfq@$S&vAuYRX_XyY65I9Q2h7S`rU=a$(Hx^tt(7eGJ zap5dOM*?ogqlx7)HWskh9xicHPuM*|FI^9S02f7*VH9`rPkV5*z9r;uTeI}@0QX`> zaQyMlptv&u6!`fBpmc)bN%vxqD4qc3F9Az;XM*27f%T(-6OK{HG=d)87qCHqg0i|Y z9Hj;x2MJeGw}XLa`WbC~^{+&jG3X=<3KOsaL+Tp>c7PI=?n-A^co0YwU_Db6 zUxmyXYt8iyIDN4${8L5BGyUnSP56P{(MLVKj#Gg>l@PO)P-q>~iYpkCWhMN1yw-Nx z_SS}Z?wvwhf>vBl2dPvjZb6^BbA_dumUibzW7*ticrdx@twN2NjA!>+P=cf?R%#;z zX3(Xgab8B0!<}qV_Dl@527Nx8B9DqdqC~Rzv+qg{HI%r{<&7MyYPp&!Gn^%kZxefh zxe;4s)}Jmtm=g=lvb`$(-ZAGKFNI}kln&v{%XM@tTsMIrgwq^ejLKxWMxhLZ#Xd)U z<%aMjH>f{h2*xa`JKccz<~j~XL!*p{xm$a#(l{TRsY}yk7K=pW!Q}d5gBN{G*t6xE zE+IsNOXyb&xTNuJX-)}~MG}*f`0f#>#hTuMJ?AY}HJkmHPfp8Buy5$b-oOvIPTW;u zi9qJ0tgr1Zt4=uyDR^IthrWkN?^Ky;#U%c2Pn93%v&I_##JyoauqorQq zdgfEn>F|#y%|9mdhebJ6g=>UZU51lkVL@GkK|57sdBx6WxClFWIY0u#)y*o5c$?{( z5bS;)DR*su?!a*yAtbE1`iWCfLWyTQ%K!W5(p~}EX!k)AT}g|PxlQq8hIF=qSk6G0 zYc2P-%e>5+FiZ#>!L4v+vqUi3gQp*WgascF;<>^DKf+DZf!^2EdJTc;B42ZPUvTz- zbcXo9gdPu7@z`h!J^ggEhrp&_oDM9a))>LnA41~`{UWp@Tesk{$gM4Ddfh_1toi(~ zx@Ky^JkPLEmxU5_7GKKT*?HhXK^E3bb9O&dypqrE!qQFNmjGttDpz3G)~nndgw3xZ z4}*J44@>aj>RR9H`|h~TEBf^9;L!>ZEx|{1qJz7>Kor6q>XP_$n)!Iz4XyZgP3!Aj zwvyu7y3Zv+@)mc#LHcFETe}{|9Z5Jl%(F2vi*R+PNTb+LBtCYKc#6dWu|?f9OxIG3f)rc`Gt6HUo|X9B52P*{NBbI*~|RaP?yjx z`>p^|u2Pml;(Vigt6^K~Fy}lD^aGCuD&dmQsWYiVEk#L%2`(w)sR7ca$vvq)sSfZ=S%-Z=N5AK; zRB)Tu>eO0<XyqlgziC_gioev%lH_-LDHzRp~?HYY*lRh3})mpdx-s3rG#d`_C!8q@qqh*b19N3)D*6i_yUuY^e*A9i~_d;(@uo5 zj5EQx*Lk@MUKq#DWBc4-XEtCyjomL{G@m)rcqfe z-95b}os&L_X)1TJ7I*-)=2(wDut%h%eSTp{l`e}>&nhaO%u{UFy46>xSwy;1X62Z3 zysT}zT_W5@RritYBMTorQ)4A7C5IC8sVpt?%%aR&HMkm^pyy9t0&mX5=w7S6lNFs+ zL%2c686@p5NGNPSkYk;5xKWMt@ND$7){u=%X;g+$qETGSF^kuo{g!7c+2!m_E+bAd zm_p^MrtH#pc>G<%?wjiM8qMc&{H`q?6wr*Zipdb^6LCFoI(CRxHbC}Xhf&@@2EI+cZF5_Wz$VczqMeF68SQ5WD@-Hl$@6|D$W+LznEHb zI^lH5qr`dQl=D=c)*-DZtvIdi)!x;Gfl3R6Rqj>qrM{V-FQZF6%cf8@=w1Gguv<_= z=qX+;0TlnX4PMatTCUov(7j5p9HeYJMY#1=u-@Ss{K}i`b}V`Bgezd~%1roGob)~K z>jPCJ8t7vis*c1rziP(C#EBS-_!zd}o*8uEju`|Pq#78fS}EYmh7Dt_V}V>vk-fWi z1W0)^^5?ne+XAM;s;dx%rE&BeTQl!|tuSTV`68&nr`veY_m2y}w7S)GB{= z5iAPtF1CI9__&WhX9t!0kxymsZ2pILfX8_vrpk^PA?S)n}Z_rbZg|9_yJ8 zvJ~5F1YMuG?r@##qm~spzfG02Z~sPY{Sf`ZRQg@w_y9ie^kUzOxv)vr?YY~_KJUHx zeBSkqRnAzY5%KoDFN@#X6>(mC&HnnR>hwhQDT2+}Gh1l!V0+=189L|j0#t3E z`t*mUg}qZZHfi+wMF$p)$*yM3J$z{$ZJpKqt~=`CEfrdTNx;BZa7V`Ow%v-_*VfL> z#?GasmpykhW+$}mxPDDN)93tGy~gnAm?+W6L#LTqmF2^)GKx!!`&&a&OYEl~GxFcE zmRC-u7CIKI+Z_(f2xLu0tj2Zksk(8+Vd6Y(?7@^9Ys{l;^~zAurD~90m+Y36`sXSG zIhWQlcJah)T>1>J4p_5)r#TkbzuL~5zr|krg}>UW(<*6VC|ee0df-NZcF}6rLJu{c zH9K*$=M1~-qGNnS1y*S-WwFGxegiw7ebBJn@Dr{G$Btc$?eo7k`|L|=Y--0)c#Zt` zhw~}zg_-AlmRXL(mWD4b?c-_MNBi$ot1orWxs8%P4tMy_rgHP5`wOeJ=R#JqmPxZs z31btc3#KWVaoRrT_*cIA%D7QS2Ij+Nmj5K_byQtvTaagsuvk5UCxr~Gv+4=J?iaeQ z)ATR={TFO|{%?F<=VC7q#gTxLF^%jA^71;fgRZ|ZvDzQTSOSyaN_YGV=RJRN{J$Vt z<>!K5W`L3v-Wt^YLUwTQ_ny^%x27>&DS(^@nF=TZ6gLm1pF9|VK_`2Wz?MeNjX*NB z08UXDo;12UfKq`Y;7Gt$PS1LkUcvRT zK6rIBDC$TuhKNFuRLCl-R5djcfq*6 None: + """Seed authorization.""" + await init_resource("catalog.authorization.id", create_authorization) + + +async def create_authorization( + operations: AsyncMPTClient = Provide[Container.mpt_operations], + context: Context = Provide[Container.context], +) -> Authorization: + """Creates an authorization.""" + product_id = require_context_id(context, "catalog.product.id", "Create authorization") + seller_id = require_context_id(context, "accounts.seller.id", "Create authorization") + account_id = require_context_id(context, "accounts.account.id", "Create authorization") + short_uuid = uuid.uuid4().hex[:8] + + authorization_data = { + "externalIds": {"operations": f"e2e-seeded-{short_uuid}"}, + "product": {"id": product_id}, + "owner": {"id": seller_id}, + "journal": {"firstInvoiceDate": "2025-12-01", "frequency": "1m"}, + "eligibility": {"client": True, "partner": True}, + "currency": "USD", + "notes": "E2E Seeded", + "name": "E2E Seeded", + "vendor": {"id": account_id}, + } + return await operations.catalog.authorizations.create(authorization_data) diff --git a/seed/catalog/catalog.py b/seed/catalog/catalog.py index 1624a71a..9117b2d5 100644 --- a/seed/catalog/catalog.py +++ b/seed/catalog/catalog.py @@ -1,39 +1,19 @@ -import asyncio import logging -from typing import Any -from seed.catalog.item import seed_items -from seed.catalog.item_group import seed_item_group +from seed.catalog.authorization import seed_authorization +from seed.catalog.listing import seed_listing +from seed.catalog.price_list import seed_price_list from seed.catalog.product import seed_product -from seed.catalog.product_parameters import seed_parameters -from seed.catalog.product_parameters_group import seed_parameter_group logger = logging.getLogger(__name__) -async def seed_groups_and_group_params() -> None: - """Seed parallel tasks for item groups and parameter groups.""" - tasks: list[asyncio.Task[Any]] = [ - asyncio.create_task(seed_item_group()), - asyncio.create_task(seed_parameter_group()), - ] - await asyncio.gather(*tasks) - - -async def seed_items_and_params() -> None: - """Seed final tasks for items and parameters.""" - tasks: list[asyncio.Task[Any]] = [ - asyncio.create_task(seed_items()), - asyncio.create_task(seed_parameters()), - ] - await asyncio.gather(*tasks) - - async def seed_catalog() -> None: """Seed catalog data including products, item groups, and parameters.""" logger.debug("Seeding catalog ...") await seed_product() - await seed_groups_and_group_params() - await seed_items_and_params() + await seed_authorization() + await seed_price_list() + await seed_listing() logger.debug("Seeded catalog completed.") diff --git a/seed/catalog/item.py b/seed/catalog/item.py index 19d74a9a..eda24e4a 100644 --- a/seed/catalog/item.py +++ b/seed/catalog/item.py @@ -1,24 +1,20 @@ import logging from typing import Any -from dependency_injector.wiring import inject +from dependency_injector.wiring import Provide, inject from mpt_api_client import AsyncMPTClient from mpt_api_client.resources.catalog.items import Item +from seed.container import Container from seed.context import Context -from seed.defaults import ( - DEFAULT_CONTEXT, - DEFAULT_MPT_OPERATIONS, - DEFAULT_MPT_VENDOR, -) logger = logging.getLogger(__name__) @inject async def refresh_item( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Item | None: """Refresh item in context (always fetch).""" item_id = context.get_string("catalog.item.id") @@ -32,8 +28,8 @@ async def refresh_item( @inject async def get_item( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Item | None: """Get item from context or fetch from API if not cached.""" item_id = context.get_string("catalog.item.id") @@ -53,7 +49,7 @@ async def get_item( @inject -def build_item(context: Context = DEFAULT_CONTEXT) -> dict[str, Any]: +def build_item(context: Context = Provide[Container.context]) -> dict[str, Any]: """Build item data dictionary for creation.""" product_id = context.get("catalog.product.id") item_group_id = context.get("catalog.item_group.id") @@ -78,8 +74,8 @@ def build_item(context: Context = DEFAULT_CONTEXT) -> dict[str, Any]: @inject async def create_item( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Item: """Create item and cache in context.""" item_data = build_item(context=context) @@ -91,8 +87,8 @@ async def create_item( @inject async def review_item( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Item | None: """Review item if in draft status and cache result.""" logger.debug("Reviewing catalog.item ...") @@ -106,8 +102,8 @@ async def review_item( @inject async def publish_item( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Item | None: """Publish item if in reviewing status and cache result.""" logger.debug("Publishing catalog.item ...") @@ -121,9 +117,9 @@ async def publish_item( @inject async def seed_items( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], + mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> None: """Seed catalog items (create/review/publish).""" logger.debug("Seeding catalog.item ...") diff --git a/seed/catalog/item_group.py b/seed/catalog/item_group.py deleted file mode 100644 index c8922cb9..00000000 --- a/seed/catalog/item_group.py +++ /dev/null @@ -1,87 +0,0 @@ -import logging -from typing import Any - -from dependency_injector.wiring import inject - -from mpt_api_client import AsyncMPTClient -from mpt_api_client.resources.catalog.products_item_groups import ItemGroup -from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_VENDOR - -logger = logging.getLogger(__name__) - - -@inject -async def get_item_group( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, -) -> ItemGroup | None: - """Get item group from context or fetch from API.""" - item_group_id = context.get_string("catalog.item_group.id") - if not item_group_id: - return None - try: - item_group = context.get_resource("catalog.item_group", item_group_id) - except ValueError: - item_group = None - if not isinstance(item_group, ItemGroup): - logger.debug("Refreshing item group: %s", item_group_id) - product_id = context.get_string("catalog.product.id") - item_group = await mpt_vendor.catalog.products.item_groups(product_id).get(item_group_id) - return set_item_group(item_group, context=context) - return item_group - - -@inject -def set_item_group( - item_group: ItemGroup, - context: Context = DEFAULT_CONTEXT, -) -> ItemGroup: - """Set item group in context.""" - context["catalog.item_group.id"] = item_group.id - context.set_resource("catalog.item_group", item_group) - return item_group - - -@inject -def build_item_group(context: Context = DEFAULT_CONTEXT) -> dict[str, Any]: - """Build item group data dictionary.""" - product_id = context.get("catalog.product.id") - return { - "product": {"id": product_id}, - "name": "Items", - "label": "Items", - "description": "Default item group", - "displayOrder": 100, - "default": True, - "multiple": True, - "required": True, - } - - -@inject -async def init_item_group( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, -) -> ItemGroup: - """Get or create item group.""" - item_group = await get_item_group() - - if not item_group: - logger.debug("Creating item group ...") - product_id = context.get_string("catalog.product.id") - item_group_data = build_item_group() - item_group = await mpt_vendor.catalog.products.item_groups(product_id).create( - item_group_data - ) - logger.debug("Item group created: %s", item_group.id) - return set_item_group(item_group) - logger.debug("Item group found: %s", item_group.id) - return item_group - - -async def seed_item_group() -> None: - """Seed item group.""" - logger.debug("Seeding catalog.item_group ...") - await init_item_group() - logger.debug("Seeded catalog.item_group completed.") diff --git a/seed/catalog/listing.py b/seed/catalog/listing.py new file mode 100644 index 00000000..cd73f3c7 --- /dev/null +++ b/seed/catalog/listing.py @@ -0,0 +1,45 @@ +from dependency_injector.wiring import Provide + +from mpt_api_client import AsyncMPTClient +from mpt_api_client.resources.catalog.listings import Listing +from seed.container import Container +from seed.context import Context +from seed.helper import init_resource, require_context_id + + +async def seed_listing(context: Context = Provide[Container.context]) -> None: + """Seed listing.""" + await init_resource("catalog.listing.id", create_listing, context) + + +async def create_listing( # noqa: WPS210 + operations: AsyncMPTClient = Provide[Container.mpt_operations], + context: Context = Provide[Container.context], +) -> Listing: + """Creates a listing.""" + product_id = require_context_id(context, "catalog.product.id", "Create listing") + seller_id = require_context_id(context, "accounts.seller.id", "Create listing") + authorization_id = require_context_id(context, "catalog.authorization.id", "Create listing") + account_id = require_context_id(context, "accounts.account.id", "Create listing") + price_list_id = require_context_id(context, "catalog.price_list.id", "Create listing") + + listing_data = { + "name": "e2e - please delete", + "authorization": { + "id": authorization_id, + }, + "product": { + "id": product_id, + }, + "vendor": { + "id": account_id, + }, + "seller": { + "id": seller_id, + }, + "priceList": {"id": price_list_id}, + "primary": False, + "notes": "", + "eligibility": {"client": True, "partner": False}, + } + return await operations.catalog.listings.create(listing_data) diff --git a/seed/catalog/price_list.py b/seed/catalog/price_list.py new file mode 100644 index 00000000..00dca5b6 --- /dev/null +++ b/seed/catalog/price_list.py @@ -0,0 +1,29 @@ +from dependency_injector.wiring import Provide + +from mpt_api_client import AsyncMPTClient +from mpt_api_client.resources.catalog.price_lists import PriceList +from seed.container import Container +from seed.context import Context +from seed.helper import init_resource, require_context_id + + +async def seed_price_list(context: Context = Provide[Container.context]) -> None: + """Seed price list.""" + await init_resource("catalog.price_list.id", create_price_list, context) + + +async def create_price_list( + operations: AsyncMPTClient = Provide[Container.mpt_operations], + context: Context = Provide[Container.context], +) -> PriceList: + """Creates a price list.""" + product_id = require_context_id(context, "catalog.product.id", "Create price list") + + price_list_data = { + "notes": "E2E Seeded", + "defaultMarkup": "20.0", + "product": {"id": product_id}, + "currency": "USD", + "default": False, + } + return await operations.catalog.price_lists.create(price_list_data) diff --git a/seed/catalog/product.py b/seed/catalog/product.py index 471061b0..0739af7c 100644 --- a/seed/catalog/product.py +++ b/seed/catalog/product.py @@ -1,110 +1,227 @@ import logging -import pathlib +import uuid -from dependency_injector.wiring import inject +from dependency_injector.wiring import Provide, inject from mpt_api_client import AsyncMPTClient +from mpt_api_client.resources.catalog.items import Item +from mpt_api_client.resources.catalog.product_term_variants import TermVariant +from mpt_api_client.resources.catalog.product_terms import Term from mpt_api_client.resources.catalog.products import Product +from mpt_api_client.resources.catalog.products_documents import Document +from mpt_api_client.resources.catalog.products_item_groups import ItemGroup +from mpt_api_client.resources.catalog.products_parameter_groups import ParameterGroup +from mpt_api_client.resources.catalog.products_parameters import Parameter +from mpt_api_client.resources.catalog.products_templates import Template +from mpt_api_client.resources.catalog.units_of_measure import UnitOfMeasure +from seed.assets.assets import ICON, PDF +from seed.container import Container from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_OPERATIONS, DEFAULT_MPT_VENDOR - -icon = pathlib.Path(__file__).parent / "FIL-9920-4780-9379.png" +from seed.helper import init_resource, require_context_id logger = logging.getLogger(__name__) -namespace = "catalog.product" - - -@inject -async def get_product( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, -) -> Product | None: - """Get product from context or fetch from API.""" - product_id = context.get_string(f"{namespace}.id") - logger.debug("Getting product: %s", product_id) - result: Product | None = None - if product_id: - try: - maybe = context.get_resource(namespace, product_id) - except ValueError: - maybe = None - if isinstance(maybe, Product): - result = maybe - else: - logger.debug("Refreshing product: %s", product_id) - refreshed = await mpt_vendor.catalog.products.get(product_id) - if isinstance(refreshed, Product): - context.set_resource(namespace, refreshed) - context[f"{namespace}.id"] = refreshed.id - result = refreshed - return result - @inject -async def init_product( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, +async def create_product( + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Product: - """Get or create product.""" - product = await get_product(context=context, mpt_vendor=mpt_vendor) - if product is None: - logger.debug("Creating product ...") - with open(str(icon), "rb") as icon_file: # noqa: PTH123 - created = await mpt_vendor.catalog.products.create( - {"name": "E2E Seeded", "website": "https://www.example.com"}, file=icon_file - ) - if isinstance(created, Product): - context.set_resource(namespace, created) - context[f"{namespace}.id"] = created.id - logger.info("Product created: %s", created.id) - return created - logger.warning("Product creation failed") - raise ValueError("Product creation failed") - logger.info("Product found: %s", product.id) - return product - - -@inject -async def review_product( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, -) -> Product | None: - """Review product if in draft status.""" - product = await get_product(context=context, mpt_vendor=mpt_vendor) - if not isinstance(product, Product) or product.status != "Draft": - return product - logger.debug("Reviewing product: %s", product.id) - reviewed = await mpt_vendor.catalog.products.review(product.id) - if isinstance(reviewed, Product): - context.set_resource(namespace, reviewed) - return reviewed - logger.warning("Product review failed") - return None + """Creates a product.""" + logger.debug("Creating product ...") + with ICON.open("rb") as icon_fd: + return await mpt_vendor.catalog.products.create( + {"name": "E2E Seeded", "website": "https://www.example.com"}, file=icon_fd + ) -@inject -async def publish_product( - context: Context = DEFAULT_CONTEXT, - mpt_operations: AsyncMPTClient = DEFAULT_MPT_OPERATIONS, -) -> Product | None: - """Publish product if in reviewing status.""" - product = await get_product() - if not isinstance(product, Product) or product.status != "Reviewing": - return product - logger.debug("Publishing product: %s", product.id) - published = await mpt_operations.catalog.products.publish(product.id) - if isinstance(published, Product): - context.set_resource(namespace, published) - return published - logger.warning("Product publish failed") - return None - - -async def seed_product() -> None: +async def seed_product( + context: Context = Provide[Container.context], +) -> None: """Seed product data.""" logger.debug("Seeding catalog.product ...") - await init_product() - await review_product() - await publish_product() + await init_resource("catalog.product.id", create_product) + await init_resource("catalog.unit.id", create_unit_of_measure) + await init_resource("catalog.product.item_group.id", create_item_group) + await init_resource("catalog.product.item.id", create_product_item) + await init_resource("catalog.product.document.id", create_document) + await init_resource("catalog.product.parameter_group.id", create_parameter_group) + await init_resource("catalog.product.parameter.id", create_parameter) + await init_resource("catalog.product.template.id", create_template) + await init_resource("catalog.product.terms.id", create_terms) + await init_resource("catalog.product.terms.variant.id", create_terms_variant) logger.debug("Seeded catalog.product completed.") + + +async def create_terms_variant( + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], +) -> TermVariant: + """Creates a product terms variant.""" + term_variant_data = { + "name": "E2E seeding", + "description": "Test variant description", + "languageCode": "en-gb", + "type": "File", + "assetUrl": "", + } + product_id = require_context_id(context, "catalog.product.id", "creating product terms variant") + terms_id = require_context_id( + context, "catalog.product.terms.id", "creating product terms variant" + ) + with PDF.open("rb") as pdf_fd: + return ( + await mpt_vendor.catalog.products.terms(product_id) + .variants(terms_id) + .create(term_variant_data, file=pdf_fd) + ) + + +async def create_template( + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], +) -> Template: + """Creates a product template.""" + template_data = { + "name": "E2E Seeding", + "description": "A template for testing", + "content": "template content", + "type": "OrderProcessing", + } + product_id = require_context_id(context, "catalog.product.id", "creating product template") + return await mpt_vendor.catalog.products.templates(product_id).create(template_data) + + +async def create_terms( + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], +) -> Term: + """Creates a product terms.""" + product_id = require_context_id(context, "catalog.product.id", "creating product terms") + return await mpt_vendor.catalog.products.terms(product_id).create({ + "name": "E2E seeded", + "description": "E2E seeded", + }) + + +async def create_parameter_group( + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], +) -> ParameterGroup: + """Creates a product parameter group.""" + product_id = require_context_id( + context, "catalog.product.id", "creating product parameter group" + ) + return await mpt_vendor.catalog.products.parameter_groups(product_id).create({ + "name": "E2E Seeded", + "label": "E2E Seeded", + "displayOrder": 100, + }) + + +async def create_parameter( + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], +) -> Parameter: + """Creates a product parameter.""" + parameter_group_id = require_context_id( + context, "catalog.product.parameter_group.id", "creating product parameter" + ) + parameter_data = { + "constraints": {"hidden": False, "readonly": False, "required": False}, + "description": "E2E seeded", + "displayOrder": 100, + "name": "E2E seeded", + "phase": "Order", + "scope": "Order", + "type": "SingleLineText", + "context": "Purchase", + "options": { + "hintText": "e2e seeded", + "defaultValue": "default value", + "placeholderText": "Place holder text", + }, + "group": {"id": parameter_group_id}, + } + product_id = require_context_id(context, "catalog.product.id", "creating product parameter") + return await mpt_vendor.catalog.products.parameters(product_id).create(parameter_data) + + +async def create_document( + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], +) -> Document: + """Creates a product document.""" + product_id = require_context_id(context, "catalog.product.id", "creating product document") + document_data = { + "name": "E2E Seeded", + "description": "E2E Seeded", + "language": "en-gb", + "url": "", + "documenttype": "File", + } + with PDF.open("rb") as pdf_fd: + return await mpt_vendor.catalog.products.documents(product_id).create( + document_data, file=pdf_fd + ) + + +async def create_item_group( + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], +) -> ItemGroup: + """Creates a product item group.""" + product_id = require_context_id(context, "catalog.product.id", "creating product item group") + item_group_data = { + "product": {"id": product_id}, + "name": "E2E Seeded", + "label": "E2E Seeded", + "description": "E2E Seeded", + "displayOrder": 100, + "default": False, + "multiple": True, + "required": True, + } + + return await mpt_vendor.catalog.products.item_groups(product_id).create(item_group_data) + + +async def create_unit_of_measure( + operations: AsyncMPTClient = Provide[Container.mpt_operations], +) -> UnitOfMeasure: + """Creates a new unit of measure in the vendor's catalog.""" + short_uuid = uuid.uuid4().hex[:8] + return await operations.catalog.units_of_measure.create({ + "name": f"e2e seeded {short_uuid}", + "description": "e2e seeded", + }) + + +async def create_product_item( + context: Context = Provide[Container.context], + mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], +) -> Item: + """Creates a product item.""" + short_uuid = uuid.uuid4().hex[:8] + + unit_id = require_context_id(context, "catalog.unit.id", "creating product item") + item_group_id = require_context_id( + context, "catalog.product.item_group.id", "creating product item" + ) + product_id = require_context_id(context, "catalog.product.id", "creating product item") + + product_item_data = { + "name": "e2e - please delete", + "description": "e2e - please delete", + "unit": { + "id": unit_id, + }, + "group": { + "id": item_group_id, + }, + "product": { + "id": product_id, + }, + "terms": {"model": "quantity", "period": "1m", "commitment": "1m"}, + "externalIds": {"vendor": f"e2e-delete-{short_uuid}"}, + } + return await mpt_vendor.catalog.items.create(product_item_data) diff --git a/seed/catalog/product_parameters.py b/seed/catalog/product_parameters.py deleted file mode 100644 index e8a36a20..00000000 --- a/seed/catalog/product_parameters.py +++ /dev/null @@ -1,99 +0,0 @@ -import logging -from typing import Any - -from dependency_injector.wiring import inject - -from mpt_api_client import AsyncMPTClient -from mpt_api_client.resources.catalog.products_parameters import Parameter -from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_VENDOR - -logger = logging.getLogger(__name__) - -namespace = "catalog.product.parameter" - - -@inject -async def get_parameter( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, -) -> Parameter | None: - """Get parameter from context or fetch from API.""" - parameter_id = context.get_string(f"{namespace}.id") - if not parameter_id: - return None - try: - return context.get_resource(namespace, parameter_id) # type: ignore[return-value] - except ValueError: - logger.debug("Loading parameter: %s", parameter_id) - product_id = context.get_string("catalog.product.id") - parameter = await mpt_vendor.catalog.products.parameters(product_id).get(parameter_id) - context.set_resource(namespace, parameter) - return parameter - - -@inject -def build_parameter(context: Context = DEFAULT_CONTEXT) -> dict[str, Any]: - """Build parameter data dictionary.""" - parameter_group_id = context.get_string("catalog.product.parameter_group.id") - if not parameter_group_id: - raise ValueError("Parameter group id is required.") - return { - "name": "e2e - seed", - "scope": "Order", - "phase": "Order", - "description": "e2e - seeded parameter", - "externalId": "e2e-seed-parameter", - "displayOrder": 100, - "context": "Purchase", - "constraints": {"hidden": True, "readonly": True, "required": False}, - "type": "SingleLineText", - "options": { - "name": "Agreement Id", - "placeholderText": "AGR-xxx-xxx-xxx", - "hintText": "Add agreement id", - "minChar": 15, - "maxChar": 15, - "defaultValue": None, - }, - "group": {"id": parameter_group_id}, - "status": "active", - } - - -@inject -async def create_parameter( - context: Context = DEFAULT_CONTEXT, mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR -) -> Parameter: - """Create parameter and stores it in the context.""" - product_id = context.get_string("catalog.product.id") - if not product_id: - raise ValueError("Product id is required.") - parameter_data = build_parameter(context=context) - parameter = await mpt_vendor.catalog.products.parameters(product_id).create(parameter_data) - logger.info("Parameter created: %s", parameter.id) - context[f"{namespace}.id"] = parameter.id - context.set_resource(namespace, parameter) - return parameter - - -@inject -async def init_parameter( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, -) -> Parameter: - """Get or create parameter.""" - parameter = await get_parameter() - - if not parameter: - logger.debug("Creating parameter ...") - return await create_parameter(context=context, mpt_vendor=mpt_vendor) - logger.debug("Parameter found: %s", parameter.id) - return parameter - - -async def seed_parameters() -> None: - """Seed catalog parameters.""" - logger.debug("Seeding catalog.parameter ...") - await init_parameter() - logger.debug("Seeded catalog.parameter completed.") diff --git a/seed/catalog/product_parameters_group.py b/seed/catalog/product_parameters_group.py deleted file mode 100644 index 9b6bba75..00000000 --- a/seed/catalog/product_parameters_group.py +++ /dev/null @@ -1,82 +0,0 @@ -import logging -from typing import Any - -from dependency_injector.wiring import inject - -from mpt_api_client import AsyncMPTClient -from mpt_api_client.resources.catalog.products_parameter_groups import ParameterGroup -from seed.context import Context -from seed.defaults import DEFAULT_CONTEXT, DEFAULT_MPT_VENDOR - -logger = logging.getLogger(__name__) - -namespace = "catalog.product.parameter_group" - - -@inject -async def get_parameter_group( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, -) -> ParameterGroup | None: - """Get parameter group from context or fetch from API.""" - parameter_group_id = context.get_string(f"{namespace}.id") - if not parameter_group_id: - return None - try: - return context.get_resource(namespace, parameter_group_id) # type: ignore[return-value] - except ValueError: - logger.debug("Loading parameter group: %s", parameter_group_id) - product_id = context.get_string("catalog.product.id") - parameter_group = await mpt_vendor.catalog.products.parameter_groups(product_id).get( - parameter_group_id - ) - context[f"{namespace}.id"] = parameter_group.id - context.set_resource(namespace, parameter_group) - return parameter_group - - -@inject -def build_parameter_group(context: Context = DEFAULT_CONTEXT) -> dict[str, Any]: - """Build parameter group data dictionary.""" - return { - "name": "e2e - seed", - "label": "Parameter group label", - "displayOrder": 100, - } - - -@inject -async def init_parameter_group( - context: Context = DEFAULT_CONTEXT, - mpt_vendor: AsyncMPTClient = DEFAULT_MPT_VENDOR, -) -> ParameterGroup: - """Get or create parameter group.""" - parameter_group = await get_parameter_group() - - if not parameter_group: - return await create_parameter_group(context, mpt_vendor) - logger.debug("Parameter group found: %s", parameter_group.id) - return parameter_group - - -async def create_parameter_group(context: Context, mpt_vendor: AsyncMPTClient) -> ParameterGroup: - """Create parameter group.""" - logger.debug("Creating parameter group ...") - product_id = context.get_string("catalog.product.id") - if not product_id: - raise ValueError("Product id is required.") - parameter_group_data = build_parameter_group() - parameter_group = await mpt_vendor.catalog.products.parameter_groups(product_id).create( - parameter_group_data - ) - logger.debug("Parameter group created: %s", parameter_group.id) - context[f"{namespace}.id"] = parameter_group.id - context.set_resource(namespace, parameter_group) - return parameter_group - - -async def seed_parameter_group() -> None: - """Seed parameter group.""" - logger.debug("Seeding %s ...", namespace) - await init_parameter_group() - logger.debug("Seeded %s completed.", namespace) diff --git a/seed/container.py b/seed/container.py index 378a2393..b335d0b1 100644 --- a/seed/container.py +++ b/seed/container.py @@ -44,26 +44,4 @@ class Container(containers.DeclarativeContainer): def wire_container() -> None: """Wire the dependency injection container.""" - container.wire( - modules=[ - "seed", - "seed.context", - "seed.defaults", - "seed.seed_api", - "seed.catalog", - "seed.catalog.catalog", - "seed.catalog.product", - "seed.catalog.item", - "seed.catalog.item_group", - "seed.catalog.product_parameters", - "seed.catalog.product_parameters_group", - "seed.accounts", - "seed.accounts.accounts", - "seed.accounts.api_tokens", - "seed.accounts.buyer", - "seed.accounts.licensee", - "seed.accounts.module", - "seed.accounts.seller", - "seed.accounts.user_group", - ] - ) + container.wire(packages=["seed"]) diff --git a/seed/context.py b/seed/context.py index d086a2ef..6c98e47d 100644 --- a/seed/context.py +++ b/seed/context.py @@ -33,7 +33,7 @@ def set_resource(self, namespace: str, resource: Model) -> None: # noqa: WPS615 self[f"{namespace}[{resource.id}]"] = resource -def load_context(json_file: pathlib.Path, context: Context | None = None) -> Context: +def load_context(json_file: pathlib.Path, context: Context) -> None: """Load context from JSON file. Args: @@ -44,12 +44,10 @@ def load_context(json_file: pathlib.Path, context: Context | None = None) -> Con Context instance. """ - context = context or Context() with json_file.open("r", encoding="utf-8") as fd: existing_data = json.load(fd) context.update(existing_data) logger.info("Context loaded: %s", context.items()) - return context def save_context(context: Context, json_file: pathlib.Path) -> None: diff --git a/seed/defaults.py b/seed/defaults.py deleted file mode 100644 index 3e9e23db..00000000 --- a/seed/defaults.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Default dependency providers to avoid WPS404 violations.""" - -from dependency_injector.wiring import Provide - -from seed.container import Container - -# Constants for dependency injection to avoid WPS404 violations -DEFAULT_CONTEXT = Provide[Container.context] -DEFAULT_MPT_CLIENT = Provide[Container.mpt_client] -DEFAULT_MPT_VENDOR = Provide[Container.mpt_vendor] -DEFAULT_MPT_OPERATIONS = Provide[Container.mpt_operations] diff --git a/seed/helper.py b/seed/helper.py new file mode 100644 index 00000000..2bf4c4c0 --- /dev/null +++ b/seed/helper.py @@ -0,0 +1,83 @@ +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from dependency_injector.wiring import Provide + +from seed.container import Container +from seed.context import Context + +logger = logging.getLogger(__name__) + + +class ResourceRequiredError(Exception): + """Raised when a resource is required but not found.""" + + def __init__(self, context: Context, key: str, action: str): + super().__init__(f"Missing required resource '{key}' before {action}.") + self.context = context + self.key = key + self.action = action + + +def require_context_id(context: Context, key: str, action: str) -> str: + """Fetch an ID from context, ensuring it exists and is non-empty. + + Args: + context: The seeding context. + key: The expected context key where the id is stored. + action: A short description of what we are creating (for error message). + + Returns: + The id string stored in the context under the provided key. + + Raises: + ResourceRequiredError: If the id is missing or empty in context. + """ + resource_id = context.get_string(key) + if not resource_id: + raise ResourceRequiredError(context, key, action) + return resource_id + + +async def init_resource( + namespace: str, + resource_factory: Callable[[], Awaitable[Any]], + context: Context = Provide[Container.context], +) -> str: + """Initialize a resource on demand and cache its id in the Context. + + This helper reads an id from the provided Context at the given namespace. If the id + is missing or empty, it will call the provided async ``resource_factory`` to create + the resource, extract the ``id`` from the returned object, store it back into the + Context under ``namespace``, and return it. If the id already exists in the Context, + the factory is not called and the existing id is returned. + + Args: + namespace: The Context key where the resource id is stored (e.g., + "catalog.product.id"). + resource_factory: A zero-argument async callable that creates the resource when + needed. It must return an object with an ``id`` attribute (string-like). + context: The seeding Context used to read and persist the id. Defaults to + ``DEFAULT_CONTEXT``. + + Returns: + The resource id string, either retrieved from the Context or obtained from the + newly created resource. + + Notes: + - The factory is invoked only if the id is not already present in the Context. + - Any exceptions thrown by ``resource_factory`` will propagate to the caller. + - No additional validation of the returned id is performed beyond attribute access. + + Example: + In an async function/context you can do: + id_value = await init_resource("catalog.product.id", create_product, context) + """ + logger.debug("Initializing resource: %s", namespace) + id_value = context.get_string(namespace) + if not id_value: + resource = await resource_factory() + id_value = resource.id + context[namespace] = id_value + return id_value diff --git a/seed/seed_api.py b/seed/seed_api.py index 49ffe523..5175f3b4 100644 --- a/seed/seed_api.py +++ b/seed/seed_api.py @@ -1,13 +1,12 @@ -import asyncio import logging import pathlib -from dependency_injector.wiring import inject +from dependency_injector.wiring import Provide, inject from seed.accounts.accounts import seed_accounts from seed.catalog.catalog import seed_catalog +from seed.container import Container from seed.context import Context, load_context, save_context -from seed.defaults import DEFAULT_CONTEXT logger = logging.getLogger(__name__) @@ -15,18 +14,12 @@ @inject -async def seed_api(context: Context = DEFAULT_CONTEXT) -> None: +async def seed_api(context: Context = Provide[Container.context]) -> None: """Seed API.""" - tasks: list[asyncio.Task[object]] = [] - load_context(context_file, context) - - catalog_task = asyncio.create_task(seed_catalog()) - accounts_task = asyncio.create_task(seed_accounts()) - tasks.extend([catalog_task, accounts_task]) - - try: - await asyncio.gather(*tasks) + try: # noqa: WPS229 + await seed_accounts() + await seed_catalog() except Exception: logger.exception("Exception occurred during seeding.") finally: diff --git a/tests/seed/catalog/test_authorization.py b/tests/seed/catalog/test_authorization.py new file mode 100644 index 00000000..1b43c474 --- /dev/null +++ b/tests/seed/catalog/test_authorization.py @@ -0,0 +1,40 @@ +import pytest + +from seed.catalog.authorization import create_authorization, seed_authorization +from seed.context import Context + + +@pytest.fixture +def context_with_data() -> Context: + ctx = Context() + ctx["catalog.product.id"] = "prod-123" + ctx["accounts.seller.id"] = "seller-456" + ctx["accounts.account.id"] = "acct-321" + return ctx + + +async def test_create_authorization(mocker, operations_client, context_with_data): + create_mock = mocker.AsyncMock(return_value={"id": "auth-1"}) + operations_client.catalog.authorizations.create = create_mock + fake_uuid = mocker.Mock(hex="cafebabe12345678") + mocker.patch("uuid.uuid4", return_value=fake_uuid) + + result = await create_authorization(operations_client, context_with_data) + + assert result == {"id": "auth-1"} + args, _ = create_mock.await_args + payload = args[0] + assert payload["product"]["id"] == "prod-123" + assert payload["owner"]["id"] == "seller-456" + assert payload["vendor"]["id"] == "acct-321" + + +async def test_seed_authorization(mocker): + init_resource = mocker.patch( + "seed.catalog.authorization.init_resource", + new_callable=mocker.AsyncMock, + ) + + await seed_authorization() + + init_resource.assert_called_once_with("catalog.authorization.id", create_authorization) diff --git a/tests/seed/catalog/test_catalog.py b/tests/seed/catalog/test_catalog.py index 4cce3e8c..c5dc0e99 100644 --- a/tests/seed/catalog/test_catalog.py +++ b/tests/seed/catalog/test_catalog.py @@ -1,44 +1,20 @@ from unittest.mock import AsyncMock, patch -from seed.catalog.catalog import seed_catalog, seed_groups_and_group_params, seed_items_and_params - - -async def test_seed_catalog_stage1() -> None: - with ( - patch("seed.catalog.catalog.seed_item_group", new_callable=AsyncMock) as mock_item_group, - patch( - "seed.catalog.catalog.seed_parameter_group", new_callable=AsyncMock - ) as mock_param_group, - ): - await seed_groups_and_group_params() # act - - mock_item_group.assert_called_once() - mock_param_group.assert_called_once() - - -async def test_seed_catalog_stage2() -> None: - with ( - patch("seed.catalog.catalog.seed_items", new_callable=AsyncMock) as mock_items, - patch("seed.catalog.catalog.seed_parameters", new_callable=AsyncMock) as mock_params, - ): - await seed_items_and_params() # act - - mock_items.assert_called_once() - mock_params.assert_called_once() +from seed.catalog.catalog import seed_catalog async def test_seed_catalog() -> None: with ( - patch("seed.catalog.catalog.seed_product", new_callable=AsyncMock) as mock_product, - patch( - "seed.catalog.catalog.seed_items_and_params", new_callable=AsyncMock - ) as seed_items_and_params, + patch("seed.catalog.catalog.seed_product", new_callable=AsyncMock) as seed_product, patch( - "seed.catalog.catalog.seed_groups_and_group_params", new_callable=AsyncMock - ) as seed_groups_and_group_params, + "seed.catalog.catalog.seed_authorization", new_callable=AsyncMock + ) as seed_authorization, + patch("seed.catalog.catalog.seed_price_list", new_callable=AsyncMock) as seed_price_list, + patch("seed.catalog.catalog.seed_listing", new_callable=AsyncMock) as seed_listing, ): await seed_catalog() # act - mock_product.assert_called_once() - seed_items_and_params.assert_called_once() - seed_groups_and_group_params.assert_called_once() + seed_product.assert_called_once() + seed_authorization.assert_called_once() + seed_price_list.assert_called_once() + seed_listing.assert_called_once() diff --git a/tests/seed/catalog/test_item_group.py b/tests/seed/catalog/test_item_group.py deleted file mode 100644 index c043ce7c..00000000 --- a/tests/seed/catalog/test_item_group.py +++ /dev/null @@ -1,98 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from mpt_api_client import AsyncMPTClient -from mpt_api_client.resources.catalog.products_item_groups import AsyncItemGroupsService, ItemGroup -from seed.catalog.item_group import ( - build_item_group, - get_item_group, - init_item_group, - seed_item_group, - set_item_group, -) - - -@pytest.fixture -def item_group(): - return ItemGroup({ - "id": "group-123", - }) - - -@pytest.fixture -def item_group_service(): - return AsyncMock(spec=AsyncItemGroupsService) - - -@pytest.fixture -def vendor_client() -> AsyncMock: - return MagicMock(spec=AsyncMPTClient) - - -@pytest.fixture -def mock_set_item_group(mocker): - return mocker.patch("seed.catalog.item_group.set_item_group", spec=set_item_group) - - -async def test_get_item_group(context, vendor_client, item_group, mock_set_item_group) -> None: - context["catalog.item_group.id"] = item_group.id - context["catalog.product.id"] = "product-123" - service = AsyncMock(spec=AsyncItemGroupsService) - service.get.return_value = item_group - vendor_client.catalog.products.item_groups.return_value = service - mock_set_item_group.return_value = item_group - - result = await get_item_group(context=context, mpt_vendor=vendor_client) - - assert result == item_group - service.get.assert_called_once_with(context["catalog.item_group.id"]) - service.create.assert_not_called() - - -async def test_get_item_group_without_id(context) -> None: - result = await get_item_group(context=context) - - assert result is None - - -def test_set_item_group(context, item_group) -> None: - result = set_item_group(item_group, context=context) - - assert result == item_group - assert context.get("catalog.item_group.id") == "group-123" - assert context.get("catalog.item_group[group-123]") == item_group - - -def test_build_item_group(context) -> None: - context["catalog.product.id"] = "product-123" - - result = build_item_group(context=context) - - assert result["product"]["id"] == "product-123" - - -async def test_get_or_create_item_group_create_new( - context, vendor_client, item_group_service, item_group -) -> None: - context["catalog.product.id"] = "product-123" - item_group_service.create.return_value = item_group - vendor_client.catalog.products.item_groups.return_value = item_group_service - - with ( - patch("seed.catalog.item_group.get_item_group", return_value=None), - patch("seed.catalog.item_group.build_item_group", return_value=item_group), - patch("seed.catalog.item_group.set_item_group", return_value=item_group) as set_item_group, - ): - result = await init_item_group(context=context, mpt_vendor=vendor_client) - - assert result == item_group - set_item_group.assert_called_once_with(item_group) - item_group_service.create.assert_called_once() - - -async def test_seed_item_group() -> None: - with patch("seed.catalog.item_group.init_item_group", new_callable=AsyncMock) as mock_create: - await seed_item_group() # act - - mock_create.assert_called_once() diff --git a/tests/seed/catalog/test_listing.py b/tests/seed/catalog/test_listing.py new file mode 100644 index 00000000..211e314a --- /dev/null +++ b/tests/seed/catalog/test_listing.py @@ -0,0 +1,53 @@ +import pytest + +from seed.catalog.listing import create_listing, seed_listing +from seed.context import Context + + +@pytest.fixture +def context_with_data() -> Context: + ctx = Context() + ctx["catalog.product.id"] = "prod-123" + ctx["accounts.seller.id"] = "seller-456" + ctx["catalog.authorization.id"] = "auth-789" + ctx["accounts.account.id"] = "acct-321" + ctx["catalog.price_list.id"] = "pl-654" + return ctx + + +async def test_create_listing(mocker, operations_client, context_with_data): # noqa: WPS218 + create_mock = mocker.AsyncMock(return_value={"id": "lst-1"}) + operations_client.catalog.listings.create = create_mock + + result = await create_listing(operations_client, context_with_data) + + assert result == {"id": "lst-1"} + args, _ = create_mock.await_args + payload = args[0] + assert payload["product"]["id"] == "prod-123" + assert payload["seller"]["id"] == "seller-456" + assert payload["authorization"]["id"] == "auth-789" + assert payload["vendor"]["id"] == "acct-321" + assert payload["priceList"]["id"] == "pl-654" + + +async def test_seed_listing_skips(mocker, context_with_data): + context_with_data["catalog.listing.id"] = "lst-existing" + create_mock = mocker.patch("seed.catalog.listing.create_listing", new_callable=mocker.AsyncMock) + + await seed_listing(context_with_data) + + create_mock.assert_not_called() + + +async def test_seed_listing_creates(mocker, context_with_data): + create_mock = mocker.patch( + "seed.catalog.listing.create_listing", + new_callable=mocker.AsyncMock, + return_value=mocker.Mock(id="lst-999"), + ) + + await seed_listing(context_with_data) + + create_mock.assert_awaited_once() + assert context_with_data.get_string("catalog.listing.id") == "lst-999" diff --git a/tests/seed/catalog/test_price_list.py b/tests/seed/catalog/test_price_list.py new file mode 100644 index 00000000..bc088c55 --- /dev/null +++ b/tests/seed/catalog/test_price_list.py @@ -0,0 +1,47 @@ +import pytest + +from seed.catalog.price_list import create_price_list, seed_price_list +from seed.context import Context + + +@pytest.fixture +def context_with_product(): + ctx = Context() + ctx["catalog.product.id"] = "prod-123" + return ctx + + +async def test_create_price_list(mocker, operations_client, context_with_product): + create_mock = mocker.AsyncMock(return_value={"id": "pl-1"}) + operations_client.catalog.price_lists.create = create_mock + + result = await create_price_list(operations_client, context_with_product) + + assert result == {"id": "pl-1"} + args, _ = create_mock.await_args + payload = args[0] + assert payload["product"]["id"] == "prod-123" + + +async def test_seed_price_list_skips(mocker, context_with_product): + context_with_product["catalog.price_list.id"] = "pl-123" + create_mock = mocker.patch( + "seed.catalog.price_list.create_price_list", new_callable=mocker.AsyncMock + ) + + await seed_price_list(context_with_product) + + create_mock.assert_not_called() + + +async def test_seed_price_list_create(mocker, context_with_product): + create_mock = mocker.patch( + "seed.catalog.price_list.create_price_list", + new_callable=mocker.AsyncMock, + return_value=mocker.Mock(id="pl-999"), + ) + + await seed_price_list(context_with_product) + + create_mock.assert_awaited_once() + assert context_with_product.get_string("catalog.price_list.id") == "pl-999" diff --git a/tests/seed/catalog/test_product.py b/tests/seed/catalog/test_product.py index 7703093e..f694da5e 100644 --- a/tests/seed/catalog/test_product.py +++ b/tests/seed/catalog/test_product.py @@ -1,12 +1,17 @@ import pytest -from mpt_api_client.resources.catalog.products import AsyncProductsService, Product -from seed.catalog.product import ( - get_product, - init_product, - publish_product, - review_product, - seed_product, +from mpt_api_client.resources.catalog.products import Product +from seed.catalog.product import ( # noqa: WPS235 + create_document, + create_item_group, + create_parameter, + create_parameter_group, + create_product, + create_product_item, + create_template, + create_terms, + create_terms_variant, + create_unit_of_measure, ) from seed.context import Context @@ -17,86 +22,131 @@ def product(): @pytest.fixture -def products_service(mocker): - return mocker.Mock(spec=AsyncProductsService) +def context_with_product(): + context = Context() + context["catalog.product.id"] = "prod-123" + return context -async def test_get_product(context: Context, vendor_client, product, products_service): - context["catalog.product.id"] = product.id - products_service.get.return_value = product - vendor_client.catalog.products = products_service +async def test_create_product( # noqa: WPS211 + mocker, context: Context, vendor_client, product +): + mpt_vendor = vendor_client + mpt_vendor.catalog.products.create = mocker.AsyncMock(return_value=product) - result = await get_product(context=context, mpt_vendor=vendor_client) + result = await create_product(mpt_vendor) assert result == product - assert context.get_resource("catalog.product", product.id) == product -async def test_get_product_without_id(context: Context): - result = await get_product(context=context) +async def test_create_template(mocker, vendor_client, context_with_product): + context = context_with_product - assert result is None + create_mock = mocker.AsyncMock(return_value={"id": "tmpl-1"}) + vendor_client.catalog.products.templates.return_value.create = create_mock + result = await create_template(context, vendor_client) -async def test_get_or_create_product_create_new( # noqa: WPS211 - context: Context, vendor_client, products_service, product, fs, mocker -): - products_service.create.return_value = product - vendor_client.catalog.products = products_service - fs.create_file( - "/mpt_api_client/seed/catalog/FIL-9920-4780-9379.png", contents=b"fake_icon_bytes" - ) - mock_get_product = mocker.patch( - "seed.catalog.product.get_product", new_callable=mocker.AsyncMock - ) - mock_get_product.return_value = None - result = await init_product(context=context, mpt_vendor=vendor_client) - assert result == product - products_service.create.assert_called_once() + assert result == {"id": "tmpl-1"} + create_mock.assert_awaited_once() -async def test_review_product_draft_status( - context, products_service, vendor_client, product, mocker -): - product.status = "Draft" - products_service.review.return_value = product - vendor_client.catalog.products = products_service - mocker.patch("seed.catalog.product.get_product", return_value=product) - result = await review_product(context, mpt_vendor=vendor_client) - assert result == product - products_service.review.assert_called_once() +async def test_create_terms(mocker, vendor_client, context_with_product): + context = context_with_product + create_mock = mocker.AsyncMock(return_value={"id": "term-1"}) + vendor_client.catalog.products.terms.return_value.create = create_mock -async def test_review_product_non_draft_status(product, mocker): - product.status = "Published" - mocker.patch("seed.catalog.product.get_product", return_value=product) - result = await review_product() - assert result == product + result = await create_terms(context, vendor_client) + assert result == {"id": "term-1"} + create_mock.assert_awaited_once() -async def test_publish_product_reviewing_status(context, operations_client, product, mocker): - product.status = "Reviewing" - operations_client.catalog.products.publish = mocker.AsyncMock(return_value=product) - mocker.patch("seed.catalog.product.get_product", return_value=product) - result = await publish_product(context, mpt_operations=operations_client) - assert result == product - operations_client.catalog.products.publish.assert_called_once() +async def test_create_terms_variant(mocker, vendor_client, context_with_product): + context = context_with_product + context["catalog.product.terms.id"] = "terms-123" -async def test_publish_product_non_reviewing_status(product, mocker): - product.status = "Draft" - mocker.patch("seed.catalog.product.get_product", return_value=product) - result = await publish_product() - assert result == product + create_mock = mocker.AsyncMock(return_value={"id": "variant-1"}) + vendor_client.catalog.products.terms.return_value.variants.return_value.create = create_mock + + result = await create_terms_variant(context, vendor_client) + + assert result == {"id": "variant-1"} + + +async def test_create_parameter_group(mocker, vendor_client, context_with_product): + context = context_with_product + + create_mock = mocker.AsyncMock(return_value={"id": "pg-1"}) + vendor_client.catalog.products.parameter_groups.return_value.create = create_mock + + result = await create_parameter_group(context, vendor_client) + + assert result == {"id": "pg-1"} + create_mock.assert_awaited_once() + + +async def test_create_parameter(mocker, vendor_client, context_with_product): + context = context_with_product + context["catalog.product.parameter_group.id"] = "pg-1" + + create_mock = mocker.AsyncMock(return_value={"id": "param-1"}) + vendor_client.catalog.products.parameters.return_value.create = create_mock + + result = await create_parameter(context, vendor_client) + + assert result == {"id": "param-1"} + create_mock.assert_awaited_once() + + +async def test_create_document(mocker, vendor_client, context_with_product): + context = context_with_product + + create_mock = mocker.AsyncMock(return_value={"id": "doc-1"}) + vendor_client.catalog.products.documents.return_value.create = create_mock + + result = await create_document(context, vendor_client) + + assert result == {"id": "doc-1"} + + +async def test_create_item_group(mocker, vendor_client, context_with_product): + context = context_with_product + + create_mock = mocker.AsyncMock(return_value={"id": "ig-1"}) + vendor_client.catalog.products.item_groups.return_value.create = create_mock + + result = await create_item_group(context, vendor_client) + + assert result == {"id": "ig-1"} + args, _ = create_mock.await_args + payload = args[0] + assert payload["product"]["id"] == "prod-123" + + +async def test_create_unit_of_measure(mocker, vendor_client): + create_mock = mocker.AsyncMock(return_value={"id": "uom-1"}) + vendor_client.catalog.units_of_measure.create = create_mock + + result = await create_unit_of_measure(vendor_client) + + assert result == {"id": "uom-1"} + create_mock.assert_awaited_once() + + +async def test_create_product_item(mocker, vendor_client, context_with_product): + context = context_with_product + context["catalog.unit.id"] = "unit-1" + context["catalog.product.item_group.id"] = "ig-1" + create_mock = mocker.AsyncMock(return_value={"id": "item-1"}) + vendor_client.catalog.items.create = create_mock + result = await create_product_item(context, vendor_client) -async def test_seed_product_sequence(mocker): - mock_create = mocker.patch("seed.catalog.product.init_product", new_callable=mocker.AsyncMock) - mock_review = mocker.patch("seed.catalog.product.review_product", new_callable=mocker.AsyncMock) - mock_publish = mocker.patch( - "seed.catalog.product.publish_product", new_callable=mocker.AsyncMock - ) - await seed_product() # act - mock_create.assert_called_once() - mock_review.assert_called_once() - mock_publish.assert_called_once() + assert result == {"id": "item-1"} + args, _ = create_mock.await_args + payload = args[0] + assert payload["unit"]["id"] == "unit-1" + assert payload["group"]["id"] == "ig-1" + assert payload["product"]["id"] == "prod-123" diff --git a/tests/seed/catalog/test_product_parameters.py b/tests/seed/catalog/test_product_parameters.py deleted file mode 100644 index d603db3c..00000000 --- a/tests/seed/catalog/test_product_parameters.py +++ /dev/null @@ -1,110 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from mpt_api_client import AsyncMPTClient -from mpt_api_client.resources.catalog.products_parameters import AsyncParametersService, Parameter -from seed.catalog.product_parameters import ( - build_parameter, - create_parameter, - get_parameter, - init_parameter, - seed_parameters, -) -from seed.context import Context - -namespace = "catalog.product.parameter" - - -@pytest.fixture -def parameter() -> Parameter: - return Parameter({"id": "param-123"}) - - -@pytest.fixture -def parameters_service() -> AsyncMock: - return AsyncMock(spec=AsyncParametersService) - - -@pytest.fixture -def vendor_client() -> AsyncMock: - return MagicMock(spec=AsyncMPTClient) - - -async def test_get_parameter( - context: Context, vendor_client: AsyncMock, parameter: Parameter -) -> None: - context[f"{namespace}.id"] = parameter.id - context["catalog.product.id"] = "product-123" - service = AsyncMock(spec=AsyncParametersService) - service.get.return_value = parameter - vendor_client.catalog.products.parameters.return_value = service - - result = await get_parameter(context=context, mpt_vendor=vendor_client) - - assert result == parameter - assert context.get(f"{namespace}.id") == parameter.id - assert context.get(f"{namespace}[{parameter.id}]") == parameter - - -async def test_get_parameter_without_id(context: Context) -> None: - result = await get_parameter(context=context) - - assert result is None - - -def test_build_parameter(context: Context) -> None: - context["catalog.product.parameter_group.id"] = "group-123" - - result = build_parameter(context=context) - - assert result["group"]["id"] == "group-123" - - -async def test_get_or_create_parameter_create_new( - context: Context, vendor_client: AsyncMock, parameters_service: AsyncMock, parameter: Parameter -) -> None: - context["catalog.product.id"] = "product-123" - parameters_service.create.return_value = parameter - vendor_client.catalog.products.parameters.return_value = parameters_service - - with ( - patch("seed.catalog.product_parameters.get_parameter", return_value=None), - patch("seed.catalog.product_parameters.build_parameter", return_value=parameter), - ): - result = await init_parameter(context=context, mpt_vendor=vendor_client) - - assert result == parameter - assert context.get(f"{namespace}.id") == parameter.id - assert context.get(f"{namespace}[{parameter.id}]") == parameter - - -async def test_seed_parameters() -> None: - with patch( - "seed.catalog.product_parameters.init_parameter", new_callable=AsyncMock - ) as mock_create: - await seed_parameters() - - mock_create.assert_called_once() - - -async def test_create_parameter_success( - context: Context, vendor_client: AsyncMock, parameter: Parameter -) -> None: - context["catalog.product.id"] = "product-123" - context["catalog.product.parameter_group.id"] = "group-123" - service = AsyncMock(spec=AsyncParametersService) - service.create.return_value = parameter - vendor_client.catalog.products.parameters.return_value = service - - result = await create_parameter(context=context, mpt_vendor=vendor_client) - - assert result == parameter - assert context.get("catalog.product.parameter.id") == parameter.id - assert context.get(f"catalog.product.parameter[{parameter.id}]") == parameter - - -async def test_create_parameter_missing_product(context: Context, vendor_client: AsyncMock) -> None: - context["catalog.parameter_group.id"] = "group-123" - with pytest.raises(ValueError): - await create_parameter(context=context, mpt_vendor=vendor_client) diff --git a/tests/seed/catalog/test_product_parameters_group.py b/tests/seed/catalog/test_product_parameters_group.py deleted file mode 100644 index d3dd5cc7..00000000 --- a/tests/seed/catalog/test_product_parameters_group.py +++ /dev/null @@ -1,103 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest - -from mpt_api_client.resources.catalog.products_parameter_groups import ( - AsyncParameterGroupsService, - ParameterGroup, -) -from seed.catalog.product_parameters_group import ( - build_parameter_group, - create_parameter_group, - get_parameter_group, - init_parameter_group, - seed_parameter_group, -) -from seed.context import Context - - -@pytest.fixture -def parameter_group() -> ParameterGroup: - return ParameterGroup({"id": "param-group-123"}) - - -@pytest.fixture -def parameter_groups_service(): - return AsyncMock(spec=AsyncParameterGroupsService) - - -async def test_get_parameter_group( - context: Context, vendor_client, parameter_groups_service, parameter_group -) -> None: - context["catalog.product.parameter_group.id"] = parameter_group.id - context["catalog.product.id"] = "product-123" - parameter_groups_service.get.return_value = parameter_group - vendor_client.catalog.products.parameter_groups.return_value = parameter_groups_service - - result = await get_parameter_group(context=context, mpt_vendor=vendor_client) - - assert result == parameter_group - assert context.get("catalog.product.parameter_group.id") == parameter_group.id - assert context.get(f"catalog.product.parameter_group[{parameter_group.id}]") == parameter_group - - -async def test_get_parameter_group_without_id(context: Context) -> None: - result = await get_parameter_group(context=context) - - assert result is None - - -def test_build_parameter_group(context: Context) -> None: - parameter_group_payload = build_parameter_group(context=context) - - result = isinstance(parameter_group_payload, dict) - - assert result is True - - -async def test_get_or_create_parameter_group_create_new( - context: Context, vendor_client, parameter_groups_service, parameter_group -) -> None: - context["catalog.product.id"] = "product-123" - - with ( - patch( - "seed.catalog.product_parameters_group.get_parameter_group", return_value=None - ) as get_mock, - patch( - "seed.catalog.product_parameters_group.create_parameter_group", - return_value=parameter_group, - ) as create_mock, - ): - created_parameter_group = await init_parameter_group( - context=context, mpt_vendor=vendor_client - ) - - assert created_parameter_group == parameter_group - get_mock.assert_called_once() - create_mock.assert_called_once() - - -async def test_create_parameter_group_success( - context: Context, vendor_client, parameter_group -) -> None: - context["catalog.product.id"] = "product-123" - service = AsyncMock(spec=AsyncParameterGroupsService) - service.create.return_value = parameter_group - vendor_client.catalog.products.parameter_groups.return_value = service - - created = await create_parameter_group(context=context, mpt_vendor=vendor_client) - - assert created == parameter_group - assert context.get("catalog.product.parameter_group.id") == parameter_group.id - assert context.get(f"catalog.product.parameter_group[{parameter_group.id}]") == parameter_group - - -async def test_seed_parameter_group() -> None: - with patch( - "seed.catalog.product_parameters_group.init_parameter_group", - new_callable=AsyncMock, - ) as mock_create: - await seed_parameter_group() - - mock_create.assert_called_once() diff --git a/tests/seed/test_context.py b/tests/seed/test_context.py new file mode 100644 index 00000000..a9daf6b7 --- /dev/null +++ b/tests/seed/test_context.py @@ -0,0 +1,17 @@ +import json +from pathlib import Path + +from seed.context import Context, load_context + + +def test_load_context(tmp_path): + context = Context({"keep": "yes", "overwrite": "old"}) + json_data = {"overwrite": "new", "added": 123} + json_file: Path = tmp_path / "context.json" + json_file.write_text(json.dumps(json_data), encoding="utf-8") + + load_context(json_file, context) # act + + assert context["keep"] == "yes" + assert context["overwrite"] == "new" + assert context["added"] == 123 diff --git a/tests/seed/test_helper.py b/tests/seed/test_helper.py new file mode 100644 index 00000000..21d23e30 --- /dev/null +++ b/tests/seed/test_helper.py @@ -0,0 +1,50 @@ +import pytest + +from seed.context import Context +from seed.helper import ResourceRequiredError, init_resource, require_context_id + + +def test_require_context_id_returns_value(): + context = Context() + context["catalog.product.id"] = "prod-123" + + result = require_context_id(context, "catalog.product.id", "creating product") + + assert result == "prod-123" + + +def test_require_context_id_raises_when_missing(): + context = Context() + key = "catalog.product.id" + action = "creating product" + + with pytest.raises(ResourceRequiredError) as exc_info: + require_context_id(context, key, action) + + assert exc_info.value.key == key + assert exc_info.value.action == action + assert exc_info.value.context is context + assert str(exc_info.value) == f"Missing required resource '{key}' before {action}." + + +async def test_init_resource_existing_id(mocker): + context = Context() + context["catalog.product.id"] = "prod-123" + factory = mocker.AsyncMock() + + result = await init_resource("catalog.product.id", factory, context) + + assert result == "prod-123" + factory.assert_not_called() + + +async def test_init_resource_creates(mocker): + context = Context() + resource = mocker.Mock(id="new-456") + factory = mocker.AsyncMock(return_value=resource) + + result = await init_resource("catalog.product.id", factory, context) + + assert result == "new-456" + factory.assert_awaited_once() + assert context.get_string("catalog.product.id") == "new-456" From 1c9ca9d4c499cc6b5e291d3a1254cb96eeacc9f2 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:08:10 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`MPT?= =?UTF-8?q?-16255/Seeding-for-catalog`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @albertsola. * https://github.com/softwareone-platform/mpt-api-python-client/pull/152#issuecomment-3628417568 The following files were modified: * `seed/accounts/api_tokens.py` * `seed/accounts/buyer.py` * `seed/accounts/licensee.py` * `seed/accounts/module.py` * `seed/accounts/seller.py` * `seed/accounts/user_group.py` * `seed/catalog/authorization.py` * `seed/catalog/catalog.py` * `seed/catalog/item.py` * `seed/catalog/listing.py` * `seed/catalog/price_list.py` * `seed/catalog/product.py` * `seed/context.py` * `seed/helper.py` * `seed/seed_api.py` * `tests/seed/catalog/test_authorization.py` * `tests/seed/catalog/test_listing.py` * `tests/seed/catalog/test_price_list.py` * `tests/seed/catalog/test_product.py` --- seed/accounts/api_tokens.py | 34 ++++++++-- seed/accounts/buyer.py | 37 +++++++++-- seed/accounts/licensee.py | 38 +++++++++-- seed/accounts/module.py | 20 +++++- seed/accounts/seller.py | 20 +++++- seed/accounts/user_group.py | 34 ++++++++-- seed/catalog/authorization.py | 15 ++++- seed/catalog/catalog.py | 8 ++- seed/catalog/item.py | 53 ++++++++++++++-- seed/catalog/listing.py | 20 +++++- seed/catalog/price_list.py | 18 +++++- seed/catalog/product.py | 81 ++++++++++++++++++++---- seed/context.py | 36 ++++++----- seed/helper.py | 15 ++++- seed/seed_api.py | 11 +++- tests/seed/catalog/test_authorization.py | 11 +++- tests/seed/catalog/test_listing.py | 13 +++- tests/seed/catalog/test_price_list.py | 8 ++- tests/seed/catalog/test_product.py | 8 ++- 19 files changed, 406 insertions(+), 74 deletions(-) diff --git a/seed/accounts/api_tokens.py b/seed/accounts/api_tokens.py index 4dfd984f..b08ad22f 100644 --- a/seed/accounts/api_tokens.py +++ b/seed/accounts/api_tokens.py @@ -16,7 +16,14 @@ async def get_api_token( context: Context = Provide[Container.context], mpt_ops: AsyncMPTClient = Provide[Container.mpt_operations], ) -> ApiToken | None: - """Get API token from context or fetch from API.""" + """ + Retrieve the ApiToken from context if present; otherwise fetch it from the API and store it in context. + + If the token is fetched, it is saved in the context resources under "accounts.api_token" and "accounts.api_token.id" is updated. + + Returns: + `ApiToken` if found or fetched, `None` if no token id is present in context. + """ api_token_id = context.get_string("accounts.api_token.id") if not api_token_id: return None @@ -36,7 +43,19 @@ async def get_api_token( def build_api_token_data( context: Context = Provide[Container.context], ) -> dict[str, object]: - """Get API token data dictionary for creation.""" + """ + Builds the dictionary of fields required to create an API token used for end-to-end testing. + + The returned mapping includes: + - `account`: `{"id": }` + - `name`: token display name + - `description`: token description + - `icon`: token icon (empty string when not set) + - `modules`: list containing a module object with `id` read from the context key "accounts.module.id" + + Returns: + dict[str, object]: The payload suitable for API token creation. + """ account_id = os.getenv("CLIENT_ACCOUNT_ID") module_id = context.get_string("accounts.module.id") return { @@ -53,7 +72,14 @@ async def init_api_token( context: Context = Provide[Container.context], mpt_ops: AsyncMPTClient = Provide[Container.mpt_operations], ) -> ApiToken: - """Get or create API token.""" + """ + Ensure an API token exists for the account, creating and persisting one if necessary. + + If an API token is not already available, creates a new token and stores it in the context resource "accounts.api_token" and updates "accounts.api_token.id". + + Returns: + ApiToken: The existing or newly created API token instance. + """ api_token = await get_api_token(context=context, mpt_ops=mpt_ops) if api_token is None: logger.debug("Creating API token ...") @@ -72,4 +98,4 @@ async def seed_api_token() -> None: """Seed API token.""" logger.debug("Seeding API token ...") await init_api_token() - logger.debug("Seeding API token completed.") + logger.debug("Seeding API token completed.") \ No newline at end of file diff --git a/seed/accounts/buyer.py b/seed/accounts/buyer.py index f0c44407..cabc94e8 100644 --- a/seed/accounts/buyer.py +++ b/seed/accounts/buyer.py @@ -19,7 +19,14 @@ async def get_buyer( context: Context = Provide[Container.context], mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Buyer | None: - """Get buyer from context or fetch from API.""" + """ + Retrieve the buyer identified by "accounts.buyer.id" from the context or fetch it from the API and cache it. + + If the context does not contain "accounts.buyer.id", returns `None`. If the id exists but no valid Buyer instance is cached, fetches the buyer from the API, stores it in the context under "accounts.buyer", updates "accounts.buyer.id" with the fetched buyer's id, and returns the Buyer. + + Returns: + Buyer or `None` if "accounts.buyer.id" is not set in the context. + """ buyer_id = context.get_string("accounts.buyer.id") if not buyer_id: return None @@ -37,7 +44,21 @@ async def get_buyer( @inject def build_buyer_data(context: Context = Provide[Container.context]) -> dict[str, object]: - """Build buyer data dictionary for creation.""" + """ + Builds the payload dictionary used to create a buyer in the MPT API. + + Reads CLIENT_ACCOUNT_ID from the environment and `accounts.seller.id` from the provided context to populate required account and seller references. + + Parameters: + context (Context): Application context used to read `accounts.seller.id`. + + Returns: + dict[str, object]: Buyer data payload including name, account, sellers, contact, and address. + + Raises: + ValueError: If CLIENT_ACCOUNT_ID environment variable is missing. + ValueError: If `accounts.seller.id` is not found in the context. + """ buyer_account_id = os.getenv("CLIENT_ACCOUNT_ID") if not buyer_account_id: raise ValueError("CLIENT_ACCOUNT_ID environment variable is required") @@ -68,7 +89,15 @@ async def init_buyer( context: Context = Provide[Container.context], mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Buyer: - """Get or create buyer.""" + """ + Ensure a Buyer exists in the provided context, creating one via the API if none is present. + + Returns: + Buyer: The existing or newly created Buyer instance. + + Raises: + ValueError: If buyer creation via the API fails. + """ buyer = await get_buyer(context=context, mpt_operations=mpt_operations) if buyer is None: buyer_data = build_buyer_data(context=context) @@ -91,4 +120,4 @@ async def seed_buyer() -> None: """Seed buyer.""" logger.debug("Seeding buyer ...") await init_buyer() - logger.debug("Seeding buyer completed.") + logger.debug("Seeding buyer completed.") \ No newline at end of file diff --git a/seed/accounts/licensee.py b/seed/accounts/licensee.py index faf54521..41c616e6 100644 --- a/seed/accounts/licensee.py +++ b/seed/accounts/licensee.py @@ -19,7 +19,14 @@ async def get_licensee( context: Context = Provide[Container.context], mpt_client: AsyncMPTClient = Provide[Container.mpt_client], ) -> Licensee | None: - """Get licensee from context or fetch from API.""" + """ + Retrieve the current licensee from the context or fetch it from the MPT API and cache it in the context. + + If a licensee is not present in the context but a licensee id exists, the function fetches the licensee from the MPT API, stores the licensee as the context resource "accounts.licensee", and updates "accounts.licensee.id" in the context. + + Returns: + Licensee | None: `Licensee` instance if found, `None` if no licensee id is present. + """ licensee_id = context.get_string("accounts.licensee.id") if not licensee_id: return None @@ -39,7 +46,20 @@ async def get_licensee( def build_licensee_data( # noqa: WPS238 context: Context = Provide[Container.context], ) -> dict[str, object]: - """Get licensee data dictionary for creation.""" + """ + Constructs a licensee payload dictionary used to create a licensee. + + Parameters: + context (Context): Context used to read required values: `accounts.seller.id`, `accounts.buyer.id`, and the `accounts.user_group` resource. + + Returns: + dict[str, object]: A dictionary containing licensee fields required by the API (name, address, seller, buyer, account, eligibility, groups, type, status, and defaultLanguage). + + Raises: + ValueError: If the environment variable `CLIENT_ACCOUNT_ID` is not set. + ValueError: If `accounts.seller.id` or `accounts.buyer.id` is missing from the context. + ValueError: If the `accounts.user_group` resource is missing from the context. + """ account_id = os.getenv("CLIENT_ACCOUNT_ID") if not account_id: raise ValueError("CLIENT_ACCOUNT_ID environment variable is required") @@ -79,7 +99,17 @@ async def init_licensee( context: Context = Provide[Container.context], mpt_client: AsyncMPTClient = Provide[Container.mpt_client], ) -> Licensee: - """Get or create licensee.""" + """ + Ensure a licensee exists for the current context, creating one if none is present. + + If no licensee is found in the provided context, attempts to create one using the MPT client and the prepared licensee data, then stores the created licensee in the context. + + Returns: + The Licensee instance associated with the context. + + Raises: + ValueError: If licensee creation fails. + """ licensee = await get_licensee(context=context, mpt_client=mpt_client) if licensee is None: licensee_data = build_licensee_data(context=context) @@ -102,4 +132,4 @@ async def seed_licensee() -> None: """Seed licensee.""" logger.debug("Seeding licensee ...") await init_licensee() - logger.info("Seeding licensee completed.") + logger.info("Seeding licensee completed.") \ No newline at end of file diff --git a/seed/accounts/module.py b/seed/accounts/module.py index 2c0cd8d0..91a9f228 100644 --- a/seed/accounts/module.py +++ b/seed/accounts/module.py @@ -16,7 +16,14 @@ async def get_module( context: Context = Provide[Container.context], mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Module | None: - """Get module from context or fetch from API.""" + """ + Retrieve the Module stored in the context or fetch it from the API if not cached. + + If the context does not contain "accounts.module.id", returns None. When a cached resource is absent or not a Module, the function fetches the module from the API and stores it in the context. + + Returns: + Module if found or fetched, `None` if "accounts.module.id" is not set. + """ module_id = context.get_string("accounts.module.id") if not module_id: return None @@ -37,7 +44,14 @@ async def refresh_module( context: Context = Provide[Container.context], mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Module | None: - """Refresh module in context (always fetch).""" + """ + Ensure the context contains a current "Access Management" Module by fetching it if missing. + + If the context has no module, query the API for modules named "Access Management". Cache the first found Module in the context under "accounts.module" and store its id at "accounts.module.id". Logs a warning and returns None if no suitable Module is found. + + Returns: + Module if available, None otherwise. + """ module = await get_module(context=context, mpt_operations=mpt_operations) if module is None: filtered_modules = mpt_operations.accounts.modules.filter( @@ -69,4 +83,4 @@ async def seed_module() -> Module: raise ValueError("Could not seed module: no valid Module found.") return refreshed logger.debug("Seeding module completed.") - return existing_module + return existing_module \ No newline at end of file diff --git a/seed/accounts/seller.py b/seed/accounts/seller.py index 0533c83f..603f1e32 100644 --- a/seed/accounts/seller.py +++ b/seed/accounts/seller.py @@ -17,7 +17,14 @@ async def get_seller( context: Context = Provide[Container.context], mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Seller | None: - """Get seller from context or fetch from API.""" + """ + Retrieve the Seller stored in the provided context or fetch it from the API and cache it in context. + + If the context does not contain a seller id ("accounts.seller.id"), returns `None`. If a seller id exists but the context has no matching Seller resource, fetches the Seller using the provided MPT client, stores the Seller in the context under "accounts.seller" and updates "accounts.seller.id" with the Seller's id before returning it. + + Returns: + Seller | None: The Seller retrieved from context or API, or `None` if no seller id is present. + """ seller_id = context.get_string("accounts.seller.id") if not seller_id: return None @@ -57,7 +64,14 @@ async def init_seller( context: Context = Provide[Container.context], mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Seller | None: - """Get or create seller. Returns Seller if successful, None otherwise.""" + """ + Ensure a seller exists in the provided context by retrieving it or creating a new one. + + If no seller is found in context, a seller will be created via the MPT API. On successful creation the function sets the created Seller in the context under "accounts.seller" and its id under "accounts.seller.id". + + Returns: + `Seller` if the seller was retrieved or created, `None` otherwise. + """ seller = await get_seller(context=context, mpt_operations=mpt_operations) if seller is None: logger.debug("Creating seller ...") @@ -79,4 +93,4 @@ async def seed_seller() -> None: """Seed seller.""" logger.debug("Seeding seller ...") await init_seller() - logger.debug("Seeding seller completed.") + logger.debug("Seeding seller completed.") \ No newline at end of file diff --git a/seed/accounts/user_group.py b/seed/accounts/user_group.py index db064ca3..a5998697 100644 --- a/seed/accounts/user_group.py +++ b/seed/accounts/user_group.py @@ -17,7 +17,12 @@ async def get_user_group( context: Context = Provide[Container.context], mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> UserGroup | None: - """Get user group from context or fetch from API.""" + """ + Retrieve a UserGroup from the context or fetch it from the API when not present in the context. + + Returns: + The retrieved `UserGroup` if available (from context or fetched), `None` if no user group ID is set in the context. + """ user_group_id = context.get_string("accounts.user_group.id") if not user_group_id: return None @@ -37,7 +42,21 @@ async def get_user_group( def build_user_group_data( context: Context = Provide[Container.context], ) -> dict[str, object]: - """Get user group data dictionary for creation.""" + """ + Builds the payload dictionary used to create a UserGroup. + + Returns: + dict: Payload with keys: + - name: display name for the user group + - account: dict containing the account `id` from the CLIENT_ACCOUNT_ID environment variable + - buyers: currently `None` + - logo: string (empty by default) + - description: textual description + - modules: list containing a dict with the module `id` read from context ("accounts.module.id") + + Raises: + ValueError: If the CLIENT_ACCOUNT_ID environment variable is not set. + """ account_id = os.getenv("CLIENT_ACCOUNT_ID") if not account_id: raise ValueError("CLIENT_ACCOUNT_ID environment variable is required") @@ -57,7 +76,14 @@ async def init_user_group( context: Context = Provide[Container.context], mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> UserGroup | None: - """Get or create user group.""" + """ + Ensure a UserGroup exists for the current context, creating and storing one if it does not. + + If an existing UserGroup is present in the context it is returned unchanged. When creation succeeds the new UserGroup is saved in the context under "accounts.user_group" and "accounts.user_group.id". + + Returns: + UserGroup | None: The retrieved or newly created `UserGroup`, or `None` if creation failed or no group could be obtained. + """ user_group = await get_user_group(context=context, mpt_operations=mpt_operations) if user_group is not None: logger.info("User group already exists: %s", user_group.id) @@ -82,4 +108,4 @@ async def seed_user_group() -> UserGroup | None: logger.debug("Seeding user group ...") user_group = await init_user_group() logger.debug("Seeding user group completed.") - return user_group + return user_group \ No newline at end of file diff --git a/seed/catalog/authorization.py b/seed/catalog/authorization.py index faad99b9..8b8e181b 100644 --- a/seed/catalog/authorization.py +++ b/seed/catalog/authorization.py @@ -10,7 +10,11 @@ async def seed_authorization() -> None: - """Seed authorization.""" + """ + Ensure the catalog authorization resource is seeded. + + Creates the authorization resource if it does not exist and stores its identifier under the context key "catalog.authorization.id". + """ await init_resource("catalog.authorization.id", create_authorization) @@ -18,7 +22,12 @@ async def create_authorization( operations: AsyncMPTClient = Provide[Container.mpt_operations], context: Context = Provide[Container.context], ) -> Authorization: - """Creates an authorization.""" + """ + Create an authorization in the catalog using required IDs from the provided context. + + Returns: + Authorization: The created Authorization object returned by the catalog service. + """ product_id = require_context_id(context, "catalog.product.id", "Create authorization") seller_id = require_context_id(context, "accounts.seller.id", "Create authorization") account_id = require_context_id(context, "accounts.account.id", "Create authorization") @@ -35,4 +44,4 @@ async def create_authorization( "name": "E2E Seeded", "vendor": {"id": account_id}, } - return await operations.catalog.authorizations.create(authorization_data) + return await operations.catalog.authorizations.create(authorization_data) \ No newline at end of file diff --git a/seed/catalog/catalog.py b/seed/catalog/catalog.py index 9117b2d5..228c2707 100644 --- a/seed/catalog/catalog.py +++ b/seed/catalog/catalog.py @@ -9,11 +9,15 @@ async def seed_catalog() -> None: - """Seed catalog data including products, item groups, and parameters.""" + """ + Seed catalog data in a defined sequence. + + Seeds products first, then authorization data, then price lists, and finally listings to ensure dependent catalog data is created in order. + """ logger.debug("Seeding catalog ...") await seed_product() await seed_authorization() await seed_price_list() await seed_listing() - logger.debug("Seeded catalog completed.") + logger.debug("Seeded catalog completed.") \ No newline at end of file diff --git a/seed/catalog/item.py b/seed/catalog/item.py index eda24e4a..3ecdb173 100644 --- a/seed/catalog/item.py +++ b/seed/catalog/item.py @@ -16,7 +16,14 @@ async def refresh_item( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Item | None: - """Refresh item in context (always fetch).""" + """ + Fetch the catalog item from the vendor API and store it in the context. + + If the context does not contain a catalog item id under "catalog.item.id", no fetch is performed and the function returns `None`. When an item is fetched, its id is written back to "catalog.item.id" and the item resource is cached under "catalog.item" in the context. + + Returns: + Item | None: The fetched `Item` cached in the context, or `None` if no item id was present. + """ item_id = context.get_string("catalog.item.id") if not item_id: return None @@ -31,7 +38,12 @@ async def get_item( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Item | None: - """Get item from context or fetch from API if not cached.""" + """ + Retrieve the catalog item from the context cache or fetch it from the API and cache it if not present or invalid. + + Returns: + Item if found in context or successfully fetched and cached, `None` if the context does not contain a catalog item id. + """ item_id = context.get_string("catalog.item.id") if not item_id: return None @@ -50,7 +62,17 @@ async def get_item( @inject def build_item(context: Context = Provide[Container.context]) -> dict[str, Any]: - """Build item data dictionary for creation.""" + """ + Builds a payload dictionary for creating a catalog item. + + Reads "catalog.product.id" and "catalog.item_group.id" from the provided context to populate the product and group references used in the payload. + + Parameters: + context (Context): Context used to retrieve "catalog.product.id" and "catalog.item_group.id". + + Returns: + dict[str, Any]: A dictionary suitable as a create-item request payload containing product and group references, parameters, name, description, unit, terms, quantityNotApplicable flag, and externalIds. + """ product_id = context.get("catalog.product.id") item_group_id = context.get("catalog.item_group.id") return { @@ -77,7 +99,14 @@ async def create_item( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Item: - """Create item and cache in context.""" + """ + Create a catalog item from context data and cache it in the context. + + Builds the item payload from values stored in the context, creates the item via the vendor API, stores the new item's id and resource in the context, and returns the created item. + + Returns: + The created Item. + """ item_data = build_item(context=context) catalog_item = await mpt_vendor.catalog.items.create(item_data) context["catalog.item.id"] = catalog_item.id @@ -90,7 +119,12 @@ async def review_item( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Item | None: - """Review item if in draft status and cache result.""" + """ + Review the cached catalog item if its status is `Draft` and cache the updated item. + + Returns: + The cached or updated `Item`, or `None` if no catalog item is present. + """ logger.debug("Reviewing catalog.item ...") catalog_item = context.get_resource("catalog.item") if catalog_item.status != "Draft": @@ -105,7 +139,12 @@ async def publish_item( context: Context = Provide[Container.context], mpt_operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> Item | None: - """Publish item if in reviewing status and cache result.""" + """ + Publish the catalog item when its status is Reviewing and update the context cache. + + Returns: + The published Item if a publish occurred, the unchanged cached Item if it was not in Reviewing status, or `None` if no catalog item existed in the context. + """ logger.debug("Publishing catalog.item ...") catalog_item = context.get_resource("catalog.item") if catalog_item.status != "Reviewing": @@ -128,4 +167,4 @@ async def seed_items( await create_item(context=context, mpt_vendor=mpt_vendor) await review_item(context=context, mpt_vendor=mpt_vendor) await publish_item(context=context, mpt_operations=mpt_operations) - logger.debug("Seeded catalog.item completed.") + logger.debug("Seeded catalog.item completed.") \ No newline at end of file diff --git a/seed/catalog/listing.py b/seed/catalog/listing.py index cd73f3c7..094d7ae9 100644 --- a/seed/catalog/listing.py +++ b/seed/catalog/listing.py @@ -8,7 +8,14 @@ async def seed_listing(context: Context = Provide[Container.context]) -> None: - """Seed listing.""" + """ + Ensure a catalog listing exists in the seed context. + + Calls the initialization helper to create or retrieve the listing identified by the key "catalog.listing.id" and stores its id in the provided context. + + Parameters: + context (Context): Dependency-injected context used to read and persist seeded resource ids. + """ await init_resource("catalog.listing.id", create_listing, context) @@ -16,7 +23,14 @@ async def create_listing( # noqa: WPS210 operations: AsyncMPTClient = Provide[Container.mpt_operations], context: Context = Provide[Container.context], ) -> Listing: - """Creates a listing.""" + """ + Create a catalog listing using IDs taken from the provided context. + + Requires the following context IDs: "catalog.product.id", "accounts.seller.id", "catalog.authorization.id", "accounts.account.id", and "catalog.price_list.id". Builds a listing payload with those IDs and default metadata, then submits it to the operations client's listings create endpoint. + + Returns: + The created Listing object. + """ product_id = require_context_id(context, "catalog.product.id", "Create listing") seller_id = require_context_id(context, "accounts.seller.id", "Create listing") authorization_id = require_context_id(context, "catalog.authorization.id", "Create listing") @@ -42,4 +56,4 @@ async def create_listing( # noqa: WPS210 "notes": "", "eligibility": {"client": True, "partner": False}, } - return await operations.catalog.listings.create(listing_data) + return await operations.catalog.listings.create(listing_data) \ No newline at end of file diff --git a/seed/catalog/price_list.py b/seed/catalog/price_list.py index 00dca5b6..cda9f4a9 100644 --- a/seed/catalog/price_list.py +++ b/seed/catalog/price_list.py @@ -8,7 +8,12 @@ async def seed_price_list(context: Context = Provide[Container.context]) -> None: - """Seed price list.""" + """ + Ensure a catalog price list resource exists, creating it if absent. + + Parameters: + context (Context): Runtime context used to resolve and persist resource identifiers. + """ await init_resource("catalog.price_list.id", create_price_list, context) @@ -16,7 +21,14 @@ async def create_price_list( operations: AsyncMPTClient = Provide[Container.mpt_operations], context: Context = Provide[Container.context], ) -> PriceList: - """Creates a price list.""" + """ + Create a price list for the product identified in the provided context. + + The function obtains the product id from `context` using the key "catalog.product.id" and sends a request to create a PriceList with seeded fields (notes, default markup, currency, and default flag). + + Returns: + The created `PriceList` instance. + """ product_id = require_context_id(context, "catalog.product.id", "Create price list") price_list_data = { @@ -26,4 +38,4 @@ async def create_price_list( "currency": "USD", "default": False, } - return await operations.catalog.price_lists.create(price_list_data) + return await operations.catalog.price_lists.create(price_list_data) \ No newline at end of file diff --git a/seed/catalog/product.py b/seed/catalog/product.py index 0739af7c..02686497 100644 --- a/seed/catalog/product.py +++ b/seed/catalog/product.py @@ -26,7 +26,12 @@ async def create_product( mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Product: - """Creates a product.""" + """ + Create a product in the vendor catalog. + + Returns: + Product: The created product object. + """ logger.debug("Creating product ...") with ICON.open("rb") as icon_fd: return await mpt_vendor.catalog.products.create( @@ -56,7 +61,11 @@ async def create_terms_variant( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> TermVariant: - """Creates a product terms variant.""" + """ + Create a product terms variant. + + @returns The created TermVariant object. + """ term_variant_data = { "name": "E2E seeding", "description": "Test variant description", @@ -80,7 +89,12 @@ async def create_template( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Template: - """Creates a product template.""" + """ + Create a template for the product referenced in the provided context. + + Returns: + Template: The created template object. + """ template_data = { "name": "E2E Seeding", "description": "A template for testing", @@ -95,7 +109,14 @@ async def create_terms( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Term: - """Creates a product terms.""" + """ + Create product terms for the product referenced by `catalog.product.id` in the provided context. + + Uses the product id stored at `catalog.product.id` in the context to create a terms resource with a default name and description. + + Returns: + Term: The created product terms object. + """ product_id = require_context_id(context, "catalog.product.id", "creating product terms") return await mpt_vendor.catalog.products.terms(product_id).create({ "name": "E2E seeded", @@ -107,7 +128,12 @@ async def create_parameter_group( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> ParameterGroup: - """Creates a product parameter group.""" + """ + Create a parameter group for the product referenced in the provided context. + + Returns: + ParameterGroup: The created parameter group object. + """ product_id = require_context_id( context, "catalog.product.id", "creating product parameter group" ) @@ -122,7 +148,14 @@ async def create_parameter( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Parameter: - """Creates a product parameter.""" + """ + Create a product parameter associated with the parameter group referenced in the context. + + Creates a parameter resource for the product whose id is stored in the context and associates it with the parameter group id from the context. + + Returns: + Parameter: The created product parameter object. + """ parameter_group_id = require_context_id( context, "catalog.product.parameter_group.id", "creating product parameter" ) @@ -150,7 +183,14 @@ async def create_document( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Document: - """Creates a product document.""" + """ + Create a document resource for the product referenced in the seeding context. + + Requires the context key "catalog.product.id" to identify the target product. + + Returns: + Document: The created product document. + """ product_id = require_context_id(context, "catalog.product.id", "creating product document") document_data = { "name": "E2E Seeded", @@ -169,7 +209,14 @@ async def create_item_group( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> ItemGroup: - """Creates a product item group.""" + """ + Create a product item group in the vendor catalog using the product id from context. + + Requires the product id at context key "catalog.product.id". Creates an item group with predefined name, label, description, display order, and flags (default: False, multiple: True, required: True). + + Returns: + ItemGroup: The created item group. + """ product_id = require_context_id(context, "catalog.product.id", "creating product item group") item_group_data = { "product": {"id": product_id}, @@ -188,7 +235,12 @@ async def create_item_group( async def create_unit_of_measure( operations: AsyncMPTClient = Provide[Container.mpt_operations], ) -> UnitOfMeasure: - """Creates a new unit of measure in the vendor's catalog.""" + """ + Create a unit of measure in the vendor catalog. + + Returns: + UnitOfMeasure: The created unit of measure. + """ short_uuid = uuid.uuid4().hex[:8] return await operations.catalog.units_of_measure.create({ "name": f"e2e seeded {short_uuid}", @@ -200,7 +252,14 @@ async def create_product_item( context: Context = Provide[Container.context], mpt_vendor: AsyncMPTClient = Provide[Container.mpt_vendor], ) -> Item: - """Creates a product item.""" + """ + Create a product item in the vendor catalog linked to the product, unit, and item group stored in context. + + The created item uses a short random suffix in its vendor external ID and default placeholder name/description suitable for end-to-end testing. It also sets basic terms (quantity, 1 month period and commitment). + + Returns: + Item: The created catalog item. + """ short_uuid = uuid.uuid4().hex[:8] unit_id = require_context_id(context, "catalog.unit.id", "creating product item") @@ -224,4 +283,4 @@ async def create_product_item( "terms": {"model": "quantity", "period": "1m", "commitment": "1m"}, "externalIds": {"vendor": f"e2e-delete-{short_uuid}"}, } - return await mpt_vendor.catalog.items.create(product_item_data) + return await mpt_vendor.catalog.items.create(product_item_data) \ No newline at end of file diff --git a/seed/context.py b/seed/context.py index 6c98e47d..ffdca8c4 100644 --- a/seed/context.py +++ b/seed/context.py @@ -29,20 +29,23 @@ def get_resource(self, namespace: str, resource_id: str | None = None) -> Model: return resource def set_resource(self, namespace: str, resource: Model) -> None: # noqa: WPS615 - """Save resource to context.""" + """ + Save a Model instance into the context under a namespaced key. + + Parameters: + namespace (str): Namespace prefix used to build the storage key. + resource (Model): Resource to store; its `id` attribute is used to form the key. + """ self[f"{namespace}[{resource.id}]"] = resource def load_context(json_file: pathlib.Path, context: Context) -> None: - """Load context from JSON file. - - Args: - json_file: JSON file path. - context: Context instance. - - Returns: - Context instance. - + """ + Load JSON data from a file and update the given Context in place. + + Parameters: + json_file (pathlib.Path): Path to the JSON file to read (UTF-8). + context (Context): Context instance to be updated with the loaded data. """ with json_file.open("r", encoding="utf-8") as fd: existing_data = json.load(fd) @@ -51,11 +54,12 @@ def load_context(json_file: pathlib.Path, context: Context) -> None: def save_context(context: Context, json_file: pathlib.Path) -> None: - """Save context to JSON file. - - Args: - json_file: JSON file path. - context: Context instance. + """ + Write the context's data to the given JSON file using UTF-8 encoding. + + Parameters: + context (Context): Context whose data will be serialized. + json_file (pathlib.Path): Path to the output JSON file. """ with json_file.open("w", encoding="utf-8") as fd: - json.dump(context.data, fd, indent=4, default=str) + json.dump(context.data, fd, indent=4, default=str) \ No newline at end of file diff --git a/seed/helper.py b/seed/helper.py index 2bf4c4c0..8e3c92ed 100644 --- a/seed/helper.py +++ b/seed/helper.py @@ -14,6 +14,19 @@ class ResourceRequiredError(Exception): """Raised when a resource is required but not found.""" def __init__(self, context: Context, key: str, action: str): + """ + Initialize the ResourceRequiredError with the context, missing key, and the action that required it. + + Parameters: + context (Context): The context instance where the resource was expected. + key (str): The context key identifying the missing resource. + action (str): Description of the action that required the resource; used in the exception message. + + Attributes: + context (Context): Same as the `context` parameter. + key (str): Same as the `key` parameter. + action (str): Same as the `action` parameter. + """ super().__init__(f"Missing required resource '{key}' before {action}.") self.context = context self.key = key @@ -80,4 +93,4 @@ async def init_resource( resource = await resource_factory() id_value = resource.id context[namespace] = id_value - return id_value + return id_value \ No newline at end of file diff --git a/seed/seed_api.py b/seed/seed_api.py index 5175f3b4..a1133d98 100644 --- a/seed/seed_api.py +++ b/seed/seed_api.py @@ -15,7 +15,14 @@ @inject async def seed_api(context: Context = Provide[Container.context]) -> None: - """Seed API.""" + """ + Initialize and seed application data using the provided Context. + + Loads the persisted context from the module's context file, seeds accounts and then catalog data, logs any exception raised during seeding, and always saves the updated context back to the context file. + + Parameters: + context (Context): Context object used for seeding; provided by the dependency injection container by default. + """ load_context(context_file, context) try: # noqa: WPS229 await seed_accounts() @@ -23,4 +30,4 @@ async def seed_api(context: Context = Provide[Container.context]) -> None: except Exception: logger.exception("Exception occurred during seeding.") finally: - save_context(context, context_file) + save_context(context, context_file) \ No newline at end of file diff --git a/tests/seed/catalog/test_authorization.py b/tests/seed/catalog/test_authorization.py index 1b43c474..5b3c8be1 100644 --- a/tests/seed/catalog/test_authorization.py +++ b/tests/seed/catalog/test_authorization.py @@ -6,6 +6,15 @@ @pytest.fixture def context_with_data() -> Context: + """ + Create a Context populated with sample catalog/product and account identifiers. + + Returns: + ctx (Context): A Context containing: + - "catalog.product.id": "prod-123" + - "accounts.seller.id": "seller-456" + - "accounts.account.id": "acct-321" + """ ctx = Context() ctx["catalog.product.id"] = "prod-123" ctx["accounts.seller.id"] = "seller-456" @@ -37,4 +46,4 @@ async def test_seed_authorization(mocker): await seed_authorization() - init_resource.assert_called_once_with("catalog.authorization.id", create_authorization) + init_resource.assert_called_once_with("catalog.authorization.id", create_authorization) \ No newline at end of file diff --git a/tests/seed/catalog/test_listing.py b/tests/seed/catalog/test_listing.py index 211e314a..648283a7 100644 --- a/tests/seed/catalog/test_listing.py +++ b/tests/seed/catalog/test_listing.py @@ -6,6 +6,17 @@ @pytest.fixture def context_with_data() -> Context: + """ + Create a Context pre-populated with product, seller, authorization, account, and price list IDs for tests. + + Returns: + Context: A Context containing the following keys set to test IDs: + - "catalog.product.id": "prod-123" + - "accounts.seller.id": "seller-456" + - "catalog.authorization.id": "auth-789" + - "accounts.account.id": "acct-321" + - "catalog.price_list.id": "pl-654" + """ ctx = Context() ctx["catalog.product.id"] = "prod-123" ctx["accounts.seller.id"] = "seller-456" @@ -50,4 +61,4 @@ async def test_seed_listing_creates(mocker, context_with_data): await seed_listing(context_with_data) create_mock.assert_awaited_once() - assert context_with_data.get_string("catalog.listing.id") == "lst-999" + assert context_with_data.get_string("catalog.listing.id") == "lst-999" \ No newline at end of file diff --git a/tests/seed/catalog/test_price_list.py b/tests/seed/catalog/test_price_list.py index bc088c55..da2cc27f 100644 --- a/tests/seed/catalog/test_price_list.py +++ b/tests/seed/catalog/test_price_list.py @@ -6,6 +6,12 @@ @pytest.fixture def context_with_product(): + """ + Create a Context pre-populated with a product identifier for tests. + + Returns: + Context: A Context instance with "catalog.product.id" set to "prod-123". + """ ctx = Context() ctx["catalog.product.id"] = "prod-123" return ctx @@ -44,4 +50,4 @@ async def test_seed_price_list_create(mocker, context_with_product): await seed_price_list(context_with_product) create_mock.assert_awaited_once() - assert context_with_product.get_string("catalog.price_list.id") == "pl-999" + assert context_with_product.get_string("catalog.price_list.id") == "pl-999" \ No newline at end of file diff --git a/tests/seed/catalog/test_product.py b/tests/seed/catalog/test_product.py index f694da5e..2370b9ad 100644 --- a/tests/seed/catalog/test_product.py +++ b/tests/seed/catalog/test_product.py @@ -23,6 +23,12 @@ def product(): @pytest.fixture def context_with_product(): + """ + Create a Context with its catalog.product.id set to "prod-123". + + Returns: + Context: A context pre-populated with catalog.product.id == "prod-123". + """ context = Context() context["catalog.product.id"] = "prod-123" return context @@ -149,4 +155,4 @@ async def test_create_product_item(mocker, vendor_client, context_with_product): payload = args[0] assert payload["unit"]["id"] == "unit-1" assert payload["group"]["id"] == "ig-1" - assert payload["product"]["id"] == "prod-123" + assert payload["product"]["id"] == "prod-123" \ No newline at end of file