├── .github └── workflows │ └── publish-to-pypi.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── saleor_gql_loader ├── __init__.py ├── data_loader.py ├── example.ipynb └── utils.py ├── setup.cfg └── setup.py /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: push 3 | 4 | jobs: 5 | build-n-publish: 6 | name: Publish to PyPI 7 | runs-on: ubuntu-18.04 8 | steps: 9 | - uses: actions/checkout@master 10 | - name: Set up Python 3.7 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.7 14 | - name: Install pypa/build 15 | run: >- 16 | python -m 17 | pip install 18 | build 19 | --user 20 | - name: Build a binary wheel and a source tarball 21 | run: >- 22 | python -m 23 | build 24 | --sdist 25 | --wheel 26 | --outdir dist/ 27 | . 28 | - name: Publish distribution to PyPI 29 | if: startsWith(github.ref, 'refs/tags') 30 | uses: pypa/gh-action-pypi-publish@master 31 | with: 32 | password: ${{ secrets.PYPI_API_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .ipynb_checkpoints 3 | __pycache__ 4 | dist 5 | MANIFEST 6 | .vscode -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2018 YOUR NAME 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saleor GraphQL Loader 2 | 3 | `saleor-gql-loader` is small python package that allows you to quickly and easily 4 | create entities such as categories, warehouses, products on your saleor website 5 | using the graphQL endpoint exposed by saleor. 6 | 7 | As of now, `saleor-gql-loader` allows to create the following entities: 8 | 9 | - [x] warehouse 10 | - [x] shipping_zone 11 | - [x] attribute 12 | - [x] attribute_value 13 | - [x] product_type 14 | - [x] category 15 | - [x] product 16 | - [x] product_variant 17 | - [x] product_image 18 | - [x] customers 19 | 20 | and update the following entities: 21 | 22 | - [x] shop 23 | - [x] private meta 24 | 25 | PR for supporting more graphQL mutations and/or queries are more than welcome. 26 | 27 | In it's current state, the project is in very early alpha, might be unstable 28 | and no tests are provided. 29 | 30 | _disclaimer: This project is not connected nor it has been endorsed by saleor 31 | team/community._ 32 | 33 | ## installation 34 | 35 | using Pypi: 36 | 37 | ```bash 38 | pip install saleor-gql-loader 39 | ``` 40 | 41 | Or cloning the repo: 42 | 43 | ```bash 44 | git clone https://github.com/grll/saleor-gql-loader.git 45 | ``` 46 | 47 | ## usage 48 | 49 | ### prerequisities 50 | 51 | The first requirement is to have a running saleor installation with the latest 52 | version installed (2.10). 53 | 54 | Before being able to use the package to create entities you need to create a 55 | saleor app with the necessary permissions to create the entities you need. 56 | 57 | One way of doing that is to use the specific django cli custom command `create_app`: 58 | 59 | ```bash 60 | python manage.py create_app etl --permission account.manage_users \ 61 | --permission account.manage_staff \ 62 | --permission app.manage_apps \ 63 | --permission discount.manage_discounts \ 64 | --permission plugins.manage_plugins \ 65 | --permission giftcard.manage_gift_card \ 66 | --permission menu.manage_menus \ 67 | --permission order.manage_orders \ 68 | --permission page.manage_pages \ 69 | --permission product.manage_products \ 70 | --permission shipping.manage_shipping \ 71 | --permission site.manage_settings \ 72 | --permission site.manage_translations \ 73 | --permission webhook.manage_webhooks \ 74 | --permission checkout.manage_checkouts 75 | ``` 76 | 77 | > This command will return a token. Keep it somewhere as it will be need to use the 78 | > loader. 79 | 80 | ### loading data 81 | 82 | `saleor-gql-loader` package exposes a single class that needs to be initialized 83 | with the authentication token generated in the section above. Then for each entity 84 | that you want to create there is a corresponding method on the class. 85 | 86 | ```python 87 | from saleor_gql_loader import ETLDataLoader 88 | 89 | # initialize the data_loader (optionally provide an endpoint url as second parameter) 90 | etl_data_loader = ETLDataLoader("LcLNVgUt8mu8yKJ0Wrh3nADnTT21uv") 91 | 92 | # create a warehouse 93 | warehouse_id = etl_data_loader.create_warehouse() 94 | ``` 95 | 96 | by default the `ETLDataLoader` will create a warehouse with sensible default values 97 | in order to not make the query fail. You can override any parameter from the graphQL 98 | type corresponding to the input of the underlying mutation. 99 | 100 | For example, to set the name and email of my warehouse: 101 | 102 | ```python 103 | # create a warehouse with specified name and email 104 | warehouse_id = etl_data_loader.create_warehouse(name="my warehouse name", email="email@example.com") 105 | ``` 106 | 107 | When a input field is mandatory it will need to be passed as first argument for example 108 | you can't create an attribute_value without specifying on which attribute id: 109 | 110 | ```python 111 | # create a year attribute 112 | year_attribute_id = etl_data_loader.create_attribute(name="year") 113 | 114 | # add the following year value to the year attribute 115 | possible_year_values = [2020, 2019, 2018, 2017] 116 | for year in possible_year_values: 117 | etl_data_loader.create_attribute_value(year_attribute_id, name=year) 118 | ``` 119 | 120 | That's all there is to it. I added a jupyter notebook as an example with more usage [here](https://github.com/grll/saleor-gql-loader/blob/master/saleor_gql_loader/example.ipynb) where you will find a full 121 | example that I used to populate my data. 122 | 123 | For more details, I recommend you to check out the [code](https://github.com/grll/saleor-gql-loader/blob/master/saleor_gql_loader/data_loader.py), I tried to document it as much 124 | as possible (it's only one module with one class). 125 | 126 | once again for any new features additions comments, feel free to open an issue or 127 | even better make a Pull Request. 128 | -------------------------------------------------------------------------------- /saleor_gql_loader/__init__.py: -------------------------------------------------------------------------------- 1 | from .data_loader import ETLDataLoader 2 | -------------------------------------------------------------------------------- /saleor_gql_loader/data_loader.py: -------------------------------------------------------------------------------- 1 | """Implements a data loader that load data into Saleor through graphQL. 2 | 3 | Notes 4 | ----- 5 | This module is designed and working with Saleor 2.9. Update will be necessary 6 | for futur release if the data models changes. 7 | 8 | No tests has been implemented as testing would need to create a fake db, which 9 | requires a lot of dev better redo the project as a django app inside saleor 10 | project for easier testing. 11 | 12 | """ 13 | from .utils import graphql_request, graphql_multipart_request, override_dict, handle_errors, get_payload 14 | 15 | 16 | class ETLDataLoader: 17 | """abstraction around several graphQL query to load data into Saleor. 18 | 19 | Notes 20 | ----- 21 | This class requires a valid `auth_token` to be provided during 22 | initialization. An `app` must be first created for example using django cli 23 | 24 | ```bash 25 | python manage.py create_app etl --permission account.manage_users \ 26 | --permission account.manage_staff \ 27 | --permission app.manage_apps \ 28 | --permission app.manage_apps \ 29 | --permission discount.manage_discounts \ 30 | --permission plugins.manage_plugins \ 31 | --permission giftcard.manage_gift_card \ 32 | --permission menu.manage_menus \ 33 | --permission order.manage_orders \ 34 | --permission page.manage_pages \ 35 | --permission product.manage_products \ 36 | --permission shipping.manage_shipping \ 37 | --permission site.manage_settings \ 38 | --permission site.manage_translations \ 39 | --permission webhook.manage_webhooks \ 40 | --permission checkout.manage_checkouts 41 | ``` 42 | 43 | Attributes 44 | ---------- 45 | headers : dict 46 | the headers used to make graphQL queries. 47 | endpoint_url : str 48 | the graphQL endpoint url to query to. 49 | 50 | Methods 51 | ------- 52 | 53 | """ 54 | 55 | def __init__(self, auth_token, endpoint_url="http://localhost:8000/graphql/"): 56 | """initialize the `DataLoader` with an auth_token and an url endpoint. 57 | 58 | Parameters 59 | ---------- 60 | auth_token : str 61 | token used to identify called to the graphQL endpoint. 62 | endpoint_url : str, optional 63 | the graphQL endpoint to be used , by default "http://localhost:8000/graphql/" 64 | """ 65 | self.headers = {"Authorization": "Bearer {}".format(auth_token)} 66 | self.endpoint_url = endpoint_url 67 | 68 | def update_shop_settings(self, **kwargs): 69 | """update shop settings. 70 | 71 | Parameters 72 | ---------- 73 | **kwargs : dict, optional 74 | overrides the default value set to update the shop settings refer to the 75 | ShopSettingsInput graphQL type to know what can be overriden. 76 | 77 | Raises 78 | ------ 79 | Exception 80 | when shopErrors is not an empty list 81 | """ 82 | 83 | variables = { 84 | "input": kwargs 85 | } 86 | 87 | query = """ 88 | mutation ShopSettingsUpdate($input: ShopSettingsInput!) { 89 | shopSettingsUpdate(input: $input) { 90 | shop { 91 | headerText 92 | description 93 | includeTaxesInPrices 94 | displayGrossPrices 95 | chargeTaxesOnShipping 96 | trackInventoryByDefault 97 | defaultWeightUnit 98 | automaticFulfillmentDigitalProducts 99 | defaultDigitalMaxDownloads 100 | defaultDigitalUrlValidDays 101 | defaultMailSenderName 102 | defaultMailSenderAddress 103 | customerSetPasswordUrl 104 | } 105 | shopErrors { 106 | field 107 | message 108 | code 109 | } 110 | } 111 | } 112 | """ 113 | 114 | response = graphql_request( 115 | query, variables, self.headers, self.endpoint_url) 116 | 117 | errors = response["data"]["shopSettingsUpdate"]["shopErrors"] 118 | handle_errors(errors) 119 | 120 | return response["data"]["shopSettingsUpdate"]["shop"] 121 | 122 | def update_shop_domain(self, **kwargs): 123 | """update shop domain. 124 | 125 | Parameters 126 | ---------- 127 | **kwargs : dict, optional 128 | overrides the default value set to update the shop domain refer to the 129 | SiteDomainInput graphQL type to know what can be overriden. 130 | 131 | Raises 132 | ------ 133 | Exception 134 | when shopErrors is not an empty list 135 | """ 136 | 137 | variables = { 138 | "siteDomainInput": kwargs 139 | } 140 | 141 | query = """ 142 | mutation ShopDomainUpdate($siteDomainInput: SiteDomainInput!) { 143 | shopDomainUpdate(input: $siteDomainInput) { 144 | shop { 145 | domain { 146 | host 147 | sslEnabled 148 | url 149 | } 150 | } 151 | shopErrors { 152 | field 153 | message 154 | code 155 | } 156 | } 157 | } 158 | """ 159 | 160 | response = graphql_request( 161 | query, variables, self.headers, self.endpoint_url) 162 | 163 | errors = response["data"]["shopDomainUpdate"]["shopErrors"] 164 | handle_errors(errors) 165 | 166 | return response["data"]["shopSettingsUpdate"]["shop"]["domain"] 167 | 168 | def update_shop_address(self, **kwargs): 169 | """update shop address. 170 | 171 | Parameters 172 | ---------- 173 | **kwargs : dict, optional 174 | overrides the default value set to update the shop address refer to the 175 | AddressInput graphQL type to know what can be overriden. 176 | 177 | Raises 178 | ------ 179 | Exception 180 | when shopErrors is not an empty list 181 | """ 182 | 183 | variables = { 184 | "addressInput": kwargs 185 | } 186 | 187 | query = """ 188 | mutation ShopAddressUpdate($addressInput: AddressInput!) { 189 | shopAddressUpdate(input: $addressInput) { 190 | shop { 191 | companyAddress { 192 | id 193 | firstName 194 | lastName 195 | companyName 196 | streetAddress1 197 | streetAddress2 198 | city 199 | cityArea 200 | postalCode 201 | country { 202 | code 203 | country 204 | } 205 | countryArea 206 | phone 207 | isDefaultShippingAddress 208 | isDefaultBillingAddress 209 | } 210 | } 211 | shopErrors { 212 | field 213 | message 214 | code 215 | } 216 | } 217 | } 218 | """ 219 | 220 | response = graphql_request( 221 | query, variables, self.headers, self.endpoint_url) 222 | 223 | errors = response["data"]["shopAddressUpdate"]["shopErrors"] 224 | handle_errors(errors) 225 | 226 | return response["data"]["shopAddressUpdate"]["shop"]["companyAddress"] 227 | 228 | def create_warehouse(self, **kwargs): 229 | """create a warehouse. 230 | 231 | Parameters 232 | ---------- 233 | **kwargs : dict, optional 234 | overrides the default value set to create the warehouse refer to the 235 | WarehouseCreateInput graphQL type to know what can be overriden. 236 | 237 | Returns 238 | ------- 239 | id : str 240 | the id of the warehouse created 241 | 242 | Raises 243 | ------ 244 | Exception 245 | when warehouseErrors is not an empty list 246 | """ 247 | default_kwargs = { 248 | "companyName": "The Fake Company", 249 | "email": "fake@example.com", 250 | "name": "fake warehouse", 251 | "address": { 252 | "streetAddress1": "a fake street adress", 253 | "city": "Fake City", 254 | "postalCode": "1024", 255 | "country": "CH" 256 | } 257 | } 258 | 259 | override_dict(default_kwargs, kwargs) 260 | 261 | variables = { 262 | "input": default_kwargs 263 | } 264 | 265 | query = """ 266 | mutation createWarehouse($input: WarehouseCreateInput!) { 267 | createWarehouse(input: $input) { 268 | warehouse { 269 | id 270 | } 271 | warehouseErrors { 272 | field 273 | message 274 | code 275 | } 276 | } 277 | } 278 | """ 279 | 280 | response = graphql_request( 281 | query, variables, self.headers, self.endpoint_url) 282 | 283 | errors = response["data"]["createWarehouse"]["warehouseErrors"] 284 | handle_errors(errors) 285 | 286 | return response["data"]["createWarehouse"]["warehouse"]["id"] 287 | 288 | def create_shipping_zone(self, **kwargs): 289 | """create a shippingZone. 290 | 291 | Parameters 292 | ---------- 293 | **kwargs : dict, optional 294 | overrides the default value set to create the shippingzone refer to 295 | the shippingZoneCreateInput graphQL type to know what can be 296 | overriden. 297 | 298 | Returns 299 | ------- 300 | id : str 301 | the id of the shippingZone created. 302 | 303 | Raises 304 | ------ 305 | Exception 306 | when shippingErrors is not an empty list. 307 | """ 308 | default_kwargs = { 309 | "name": "CH", 310 | "countries": [ 311 | "CH" 312 | ], 313 | "default": False, 314 | } 315 | 316 | override_dict(default_kwargs, kwargs) 317 | 318 | variables = { 319 | "input": default_kwargs 320 | } 321 | 322 | query = """ 323 | mutation createShippingZone($input: ShippingZoneCreateInput!) { 324 | shippingZoneCreate(input: $input) { 325 | shippingZone { 326 | id 327 | } 328 | shippingErrors { 329 | field 330 | message 331 | code 332 | } 333 | } 334 | } 335 | """ 336 | 337 | response = graphql_request( 338 | query, variables, self.headers, self.endpoint_url) 339 | 340 | errors = response["data"]["shippingZoneCreate"]["shippingErrors"] 341 | handle_errors(errors) 342 | 343 | return response["data"]["shippingZoneCreate"]["shippingZone"]["id"] 344 | 345 | def create_attribute(self, **kwargs): 346 | """create a product attribute. 347 | 348 | Parameters 349 | ---------- 350 | **kwargs : dict, optional 351 | overrides the default value set to create the attribute refer to 352 | the AttributeCreateInput graphQL type to know what can be 353 | overriden. 354 | 355 | Returns 356 | ------- 357 | id : str 358 | the id of the attribute created. 359 | 360 | Raises 361 | ------ 362 | Exception 363 | when productErrors is not an empty list. 364 | """ 365 | default_kwargs = { 366 | "inputType": "DROPDOWN", 367 | "name": "default" 368 | } 369 | 370 | override_dict(default_kwargs, kwargs) 371 | 372 | variables = { 373 | "input": default_kwargs 374 | } 375 | 376 | query = """ 377 | mutation createAttribute($input: AttributeCreateInput!) { 378 | attributeCreate(input: $input) { 379 | attribute { 380 | id 381 | } 382 | productErrors { 383 | field 384 | message 385 | code 386 | } 387 | } 388 | } 389 | """ 390 | 391 | response = graphql_request( 392 | query, variables, self.headers, self.endpoint_url) 393 | 394 | errors = response["data"]["attributeCreate"]["productErrors"] 395 | handle_errors(errors) 396 | 397 | return response["data"]["attributeCreate"]["attribute"]["id"] 398 | 399 | def create_attribute_value(self, attribute_id, **kwargs): 400 | """create a product attribute value. 401 | 402 | Parameters 403 | ---------- 404 | attribute_id : str 405 | id of the attribute on which to add the value. 406 | **kwargs : dict, optional 407 | overrides the default value set to create the attribute refer to 408 | the AttributeValueCreateInput graphQL type to know what can be 409 | overriden. 410 | 411 | Returns 412 | ------- 413 | id : str 414 | the id of the attribute on which the value was created. 415 | 416 | Raises 417 | ------ 418 | Exception 419 | when productErrors is not an empty list. 420 | """ 421 | default_kwargs = { 422 | "name": "default" 423 | } 424 | 425 | override_dict(default_kwargs, kwargs) 426 | 427 | variables = { 428 | "attribute": attribute_id, 429 | "input": default_kwargs 430 | } 431 | 432 | query = """ 433 | mutation createAttributeValue($input: AttributeValueCreateInput!, $attribute: ID!) { 434 | attributeValueCreate(input: $input, attribute: $attribute) { 435 | attribute{ 436 | id 437 | } 438 | productErrors { 439 | field 440 | message 441 | code 442 | } 443 | } 444 | } 445 | """ 446 | 447 | response = graphql_request( 448 | query, variables, self.headers, self.endpoint_url) 449 | 450 | errors = response["data"]["attributeValueCreate"]["productErrors"] 451 | handle_errors(errors) 452 | 453 | return response["data"]["attributeValueCreate"]["attribute"]["id"] 454 | 455 | def create_product_type(self, **kwargs): 456 | """create a product type. 457 | 458 | Parameters 459 | ---------- 460 | **kwargs : dict, optional 461 | overrides the default value set to create the type refer to 462 | the ProductTypeInput graphQL type to know what can be 463 | overriden. 464 | 465 | Returns 466 | ------- 467 | id : str 468 | the id of the productType created. 469 | 470 | Raises 471 | ------ 472 | Exception 473 | when productErrors is not an empty list. 474 | """ 475 | default_kwargs = { 476 | "name": "default", 477 | "hasVariants": False, 478 | "productAttributes": [], 479 | "variantAttributes": [], 480 | "isDigital": "false", 481 | } 482 | 483 | override_dict(default_kwargs, kwargs) 484 | 485 | variables = { 486 | "input": default_kwargs 487 | } 488 | 489 | query = """ 490 | mutation createProductType($input: ProductTypeInput!) { 491 | productTypeCreate(input: $input) { 492 | productType { 493 | id 494 | } 495 | productErrors { 496 | field 497 | message 498 | code 499 | } 500 | } 501 | } 502 | """ 503 | 504 | response = graphql_request( 505 | query, variables, self.headers, self.endpoint_url) 506 | 507 | errors = response["data"]["productTypeCreate"]["productErrors"] 508 | handle_errors(errors) 509 | 510 | return response["data"]["productTypeCreate"]["productType"]["id"] 511 | 512 | def create_category(self, **kwargs): 513 | """create a category. 514 | 515 | Parameters 516 | ---------- 517 | **kwargs : dict, optional 518 | overrides the default value set to create the category refer to 519 | the productTypeCreateInput graphQL type to know what can be 520 | overriden. 521 | 522 | Returns 523 | ------- 524 | id : str 525 | the id of the productType created. 526 | 527 | Raises 528 | ------ 529 | Exception 530 | when productErrors is not an empty list. 531 | """ 532 | default_kwargs = { 533 | "name": "default" 534 | } 535 | 536 | override_dict(default_kwargs, kwargs) 537 | 538 | variables = { 539 | "input": default_kwargs 540 | } 541 | 542 | query = """ 543 | mutation createCategory($input: CategoryInput!) { 544 | categoryCreate(input: $input) { 545 | category { 546 | id 547 | } 548 | productErrors { 549 | field 550 | message 551 | code 552 | } 553 | } 554 | } 555 | """ 556 | 557 | response = graphql_request( 558 | query, variables, self.headers, self.endpoint_url) 559 | 560 | errors = response["data"]["categoryCreate"]["productErrors"] 561 | handle_errors(errors) 562 | 563 | return response["data"]["categoryCreate"]["category"]["id"] 564 | 565 | def create_product(self, product_type_id, **kwargs): 566 | """create a product. 567 | 568 | Parameters 569 | ---------- 570 | product_type_id : str 571 | product type id required to create the product. 572 | **kwargs : dict, optional 573 | overrides the default value set to create the product refer to 574 | the ProductCreateInput graphQL type to know what can be 575 | overriden. 576 | 577 | Returns 578 | ------- 579 | id : str 580 | the id of the product created. 581 | 582 | Raises 583 | ------ 584 | Exception 585 | when productErrors is not an empty list. 586 | """ 587 | default_kwargs = { 588 | "name": "default", 589 | "description": "default", 590 | "productType": product_type_id, 591 | "basePrice": 0.0, 592 | "sku": "default" 593 | } 594 | 595 | override_dict(default_kwargs, kwargs) 596 | 597 | variables = { 598 | "input": default_kwargs 599 | } 600 | 601 | query = """ 602 | mutation createProduct($input: ProductCreateInput!) { 603 | productCreate(input: $input) { 604 | product { 605 | id 606 | } 607 | productErrors { 608 | field 609 | message 610 | code 611 | } 612 | } 613 | } 614 | """ 615 | 616 | response = graphql_request( 617 | query, variables, self.headers, self.endpoint_url) 618 | 619 | errors = response["data"]["productCreate"]["productErrors"] 620 | handle_errors(errors) 621 | 622 | return response["data"]["productCreate"]["product"]["id"] 623 | 624 | def create_product_variant(self, product_id, **kwargs): 625 | """create a product variant. 626 | 627 | Parameters 628 | ---------- 629 | product_id : str 630 | id for which the product variant will be created. 631 | **kwargs : dict, optional 632 | overrides the default value set to create the product variant refer 633 | to the ProductVariantCreateInput graphQL type to know what can be 634 | overriden. 635 | 636 | Returns 637 | ------- 638 | id : str 639 | the id of the product variant created. 640 | 641 | Raises 642 | ------ 643 | Exception 644 | when productErrors is not an empty list. 645 | """ 646 | default_kwargs = { 647 | "product": product_id, 648 | "sku": "0", 649 | "attributes": [] 650 | } 651 | 652 | override_dict(default_kwargs, kwargs) 653 | 654 | variables = { 655 | "input": default_kwargs 656 | } 657 | 658 | query = """ 659 | mutation createProductVariant($input: ProductVariantCreateInput!) { 660 | productVariantCreate(input: $input) { 661 | productVariant { 662 | id 663 | } 664 | productErrors { 665 | field 666 | message 667 | code 668 | } 669 | } 670 | } 671 | """ 672 | 673 | response = graphql_request( 674 | query, variables, self.headers, self.endpoint_url) 675 | 676 | errors = response["data"]["productVariantCreate"]["productErrors"] 677 | handle_errors(errors) 678 | 679 | return response["data"]["productVariantCreate"]["productVariant"]["id"] 680 | 681 | def create_product_image(self, product_id, file_path): 682 | """create a product image. 683 | 684 | Parameters 685 | ---------- 686 | product_id : str 687 | id for which the product image will be created. 688 | file_path : str 689 | path to the image to upload. 690 | 691 | Returns 692 | ------- 693 | id : str 694 | the id of the product image created. 695 | 696 | Raises 697 | ------ 698 | Exception 699 | when productErrors is not an empty list. 700 | """ 701 | body = get_payload(product_id, file_path) 702 | 703 | response = graphql_multipart_request( 704 | body, self.headers, self.endpoint_url) 705 | 706 | errors = response["data"]["productImageCreate"]["productErrors"] 707 | handle_errors(errors) 708 | 709 | return response["data"]["productImageCreate"]["image"]["id"] 710 | 711 | def create_customer_account(self, **kwargs): 712 | """ 713 | Creates a customer (as an admin) 714 | Parameters 715 | ---------- 716 | kwargs: customer 717 | 718 | Returns 719 | ------- 720 | 721 | """ 722 | default_kwargs = { 723 | "firstName": "default", 724 | "lastName": "default", 725 | "email": "default@default.com", 726 | "isActive": False, 727 | } 728 | 729 | override_dict(default_kwargs, kwargs) 730 | 731 | variables = {"input": default_kwargs} 732 | 733 | query = """ 734 | mutation customerCreate($input: UserCreateInput !) { 735 | customerCreate(input: $input) { 736 | user { 737 | id 738 | } 739 | accountErrors { 740 | field 741 | message 742 | code 743 | } 744 | } 745 | } 746 | """ 747 | 748 | response = graphql_request(query, variables, self.headers, self.endpoint_url) 749 | 750 | errors = response["data"]["customerCreate"]["accountErrors"] 751 | handle_errors(errors) 752 | 753 | return response["data"]["customerCreate"]["user"]["id"] 754 | 755 | def update_private_meta(self, item_id, input_list): 756 | """ 757 | 758 | Parameters 759 | ---------- 760 | item_id: ID of the item to update. Model need to work with private metadata 761 | input_list: an input dict to which to set the private meta 762 | Returns 763 | ------- 764 | 765 | """ 766 | 767 | variables = {"id": item_id, "input": input_list} 768 | 769 | query = """ 770 | mutation updatePrivateMetadata($id: ID!, $input: [MetadataInput!]!) { 771 | updatePrivateMetadata(id: $id, input: $input) { 772 | item { 773 | privateMetadata { 774 | key 775 | value 776 | } 777 | } 778 | metadataErrors { 779 | field 780 | message 781 | code 782 | } 783 | } 784 | } 785 | """ 786 | 787 | response = graphql_request(query, variables, self.headers, self.endpoint_url) 788 | 789 | if ( 790 | len(response["data"]["updatePrivateMetadata"]["item"]["privateMetadata"]) 791 | > 0 792 | ): 793 | return item_id 794 | else: 795 | return None 796 | -------------------------------------------------------------------------------- /saleor_gql_loader/example.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# A Simple Example of usage\n", 8 | "\n", 9 | "In this example I will go through creating the following entities in order:\n", 10 | "\n", 11 | "- warehouse\n", 12 | "- shipping zone\n", 13 | "- product attributes with their values.\n", 14 | "- categories\n", 15 | "- product type\n", 16 | "- products with variants and their stocks.\n", 17 | "\n", 18 | "I will use Tea product as a fictive example." 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": 7, 24 | "metadata": {}, 25 | "outputs": [], 26 | "source": [ 27 | "from saleor_gql_loader import ETLDataLoader" 28 | ] 29 | }, 30 | { 31 | "cell_type": "code", 32 | "execution_count": 8, 33 | "metadata": {}, 34 | "outputs": [], 35 | "source": [ 36 | "# I generated a token for my app as explained in the README.md\n", 37 | "# https://github.com/grll/saleor-gql-loader/blob/master/README.md\n", 38 | "etl_data_loader = ETLDataLoader(\"LcLNVgUt8mu8yKJ0Wrh3nADnTT21uv\")" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 10, 44 | "metadata": {}, 45 | "outputs": [ 46 | { 47 | "data": { 48 | "text/plain": [ 49 | "'V2FyZWhvdXNlOmI1MzVhMjUxLTE0NWMtNGRkYy05YmMzLWE3ODYwZmI2ZDVmNg=='" 50 | ] 51 | }, 52 | "execution_count": 10, 53 | "metadata": {}, 54 | "output_type": "execute_result" 55 | } 56 | ], 57 | "source": [ 58 | "# create a default warehouse\n", 59 | "warehouse_id = etl_data_loader.create_warehouse()\n", 60 | "warehouse_id" 61 | ] 62 | }, 63 | { 64 | "cell_type": "code", 65 | "execution_count": 11, 66 | "metadata": {}, 67 | "outputs": [ 68 | { 69 | "data": { 70 | "text/plain": [ 71 | "'U2hpcHBpbmdab25lOjI='" 72 | ] 73 | }, 74 | "execution_count": 11, 75 | "metadata": {}, 76 | "output_type": "execute_result" 77 | } 78 | ], 79 | "source": [ 80 | "# create a default shipping zone associated\n", 81 | "shipping_zone_id = etl_data_loader.create_shipping_zone(addWarehouses=[warehouse_id])\n", 82 | "shipping_zone_id" 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": 13, 88 | "metadata": {}, 89 | "outputs": [], 90 | "source": [ 91 | "# define my products usually extracted from csv or scraped...\n", 92 | "products = [\n", 93 | " {\n", 94 | " \"name\": \"tea a\",\n", 95 | " \"description\": \"description for tea a\",\n", 96 | " \"category\": \"green tea\",\n", 97 | " \"price\": 5.5,\n", 98 | " \"strength\": \"medium\"\n", 99 | " },\n", 100 | " {\n", 101 | " \"name\": \"tea b\",\n", 102 | " \"description\": \"description for tea b\",\n", 103 | " \"category\": \"black tea\",\n", 104 | " \"price\": 10.5,\n", 105 | " \"strength\": \"strong\"\n", 106 | " },\n", 107 | " {\n", 108 | " \"name\": \"tea c\",\n", 109 | " \"description\": \"description for tea c\",\n", 110 | " \"category\": \"green tea\",\n", 111 | " \"price\": 9.5,\n", 112 | " \"strength\": \"light\"\n", 113 | " }\n", 114 | "]" 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": 14, 120 | "metadata": {}, 121 | "outputs": [], 122 | "source": [ 123 | "# add basic sku to products\n", 124 | "for i, product in enumerate(products):\n", 125 | " product[\"sku\"] = \"{:05}-00\".format(i)" 126 | ] 127 | }, 128 | { 129 | "cell_type": "code", 130 | "execution_count": 16, 131 | "metadata": {}, 132 | "outputs": [], 133 | "source": [ 134 | "# create the strength attribute\n", 135 | "strength_attribute_id = etl_data_loader.create_attribute(name=\"strength\")\n", 136 | "unique_strength = set([product['strength'] for product in products])\n", 137 | "for strength in unique_strength:\n", 138 | " etl_data_loader.create_attribute_value(strength_attribute_id, name=strength)" 139 | ] 140 | }, 141 | { 142 | "cell_type": "code", 143 | "execution_count": 17, 144 | "metadata": {}, 145 | "outputs": [], 146 | "source": [ 147 | "# create another quantity attribute used as variant:\n", 148 | "qty_attribute_id = etl_data_loader.create_attribute(name=\"qty\")\n", 149 | "unique_qty = {\"100g\", \"200g\", \"300g\"}\n", 150 | "for qty in unique_qty:\n", 151 | " etl_data_loader.create_attribute_value(qty_attribute_id, name=qty)" 152 | ] 153 | }, 154 | { 155 | "cell_type": "code", 156 | "execution_count": 18, 157 | "metadata": {}, 158 | "outputs": [], 159 | "source": [ 160 | "# create a product type: tea\n", 161 | "product_type_id = etl_data_loader.create_product_type(name=\"tea\",\n", 162 | " hasVariants=True, \n", 163 | " productAttributes=[strength_attribute_id],\n", 164 | " variantAttributes=[qty_attribute_id])" 165 | ] 166 | }, 167 | { 168 | "cell_type": "code", 169 | "execution_count": 19, 170 | "metadata": {}, 171 | "outputs": [ 172 | { 173 | "data": { 174 | "text/plain": [ 175 | "{'black tea': 'Q2F0ZWdvcnk6NQ==', 'green tea': 'Q2F0ZWdvcnk6Ng=='}" 176 | ] 177 | }, 178 | "execution_count": 19, 179 | "metadata": {}, 180 | "output_type": "execute_result" 181 | } 182 | ], 183 | "source": [ 184 | "# create categories\n", 185 | "unique_categories = set([product['category'] for product in products])\n", 186 | "\n", 187 | "cat_to_id = {}\n", 188 | "for category in unique_categories:\n", 189 | " cat_to_id[category] = etl_data_loader.create_category(name=category)\n", 190 | "\n", 191 | "cat_to_id" 192 | ] 193 | }, 194 | { 195 | "cell_type": "code", 196 | "execution_count": 20, 197 | "metadata": {}, 198 | "outputs": [], 199 | "source": [ 200 | "# create products and store id\n", 201 | "for i, product in enumerate(products):\n", 202 | " product_id = etl_data_loader.create_product(product_type_id,\n", 203 | " name=product[\"name\"],\n", 204 | " description=product[\"description\"],\n", 205 | " basePrice=product[\"price\"],\n", 206 | " sku=product[\"sku\"],\n", 207 | " category=cat_to_id[product[\"category\"]],\n", 208 | " attributes=[{\"id\": strength_attribute_id, \"values\": [product[\"strength\"]]}],\n", 209 | " isPublished=True)\n", 210 | " products[i][\"id\"] = product_id" 211 | ] 212 | }, 213 | { 214 | "cell_type": "code", 215 | "execution_count": 23, 216 | "metadata": {}, 217 | "outputs": [], 218 | "source": [ 219 | "# create some variant for each product:\n", 220 | "for product in products:\n", 221 | " for i, qty in enumerate(unique_qty):\n", 222 | " variant_id = etl_data_loader.create_product_variant(product_id,\n", 223 | " sku=product[\"sku\"].replace(\"-00\", \"-1{}\".format(i+1)),\n", 224 | " attributes=[{\"id\": qty_attribute_id, \"values\": [qty]}],\n", 225 | " costPrice=product[\"price\"],\n", 226 | " weight=0.75,\n", 227 | " stocks=[{\"warehouse\": warehouse_id, \"quantity\": 15}])" 228 | ] 229 | }, 230 | { 231 | "cell_type": "code", 232 | "execution_count": null, 233 | "metadata": {}, 234 | "outputs": [], 235 | "source": [] 236 | } 237 | ], 238 | "metadata": { 239 | "kernelspec": { 240 | "display_name": "Python 3", 241 | "language": "python", 242 | "name": "python3" 243 | }, 244 | "language_info": { 245 | "codemirror_mode": { 246 | "name": "ipython", 247 | "version": 3 248 | }, 249 | "file_extension": ".py", 250 | "mimetype": "text/x-python", 251 | "name": "python", 252 | "nbconvert_exporter": "python", 253 | "pygments_lexer": "ipython3", 254 | "version": "3.8.2" 255 | } 256 | }, 257 | "nbformat": 4, 258 | "nbformat_minor": 4 259 | } 260 | -------------------------------------------------------------------------------- /saleor_gql_loader/utils.py: -------------------------------------------------------------------------------- 1 | """Module to define some utils non related to business logic. 2 | 3 | Notes 4 | ----- 5 | The function defined here must be context and implementation independant, for 6 | easy reusability 7 | """ 8 | import requests 9 | import json 10 | from pathlib import Path 11 | from requests_toolbelt import MultipartEncoder 12 | from django.core.serializers.json import DjangoJSONEncoder 13 | 14 | GQL_DEFAULT_ENDPOINT = "http://localhost:8000/graphql/" 15 | 16 | 17 | def graphql_request(query, variables={}, headers={}, 18 | endpoint=GQL_DEFAULT_ENDPOINT): 19 | """Execute the graphQL `query` provided on the `endpoint`. 20 | 21 | Parameters 22 | ---------- 23 | query : str 24 | docstring representing a graphQL query. 25 | variables : dict, optional 26 | dictionary corresponding to the input(s) of the `query` must be 27 | serializable by requests into a JSON object. 28 | headers : dict, optional 29 | headers added to the request (important for authentication). 30 | endpoint : str, optional 31 | the graphQL endpoint url that will be queried, default is 32 | `GQL_DEFAULT_ENDPOINT`. 33 | 34 | Returns 35 | ------- 36 | response : dict 37 | a dictionary corresponding to the parsed JSON graphQL response. 38 | 39 | Raises 40 | ------ 41 | Exception 42 | when `response.status_code` is not 200. 43 | """ 44 | response = requests.post( 45 | endpoint, 46 | headers=headers, 47 | json={ 48 | 'query': query, 49 | 'variables': variables 50 | } 51 | ) 52 | 53 | parsed_response = json.loads(response.text) 54 | if response.status_code != 200: 55 | raise Exception("{message}\n extensions: {extensions}".format( 56 | **parsed_response["errors"][0])) 57 | else: 58 | return parsed_response 59 | 60 | 61 | def graphql_multipart_request(body, headers, endpoint=GQL_DEFAULT_ENDPOINT): 62 | """Execute a multipart graphQL query with `body` provided on the `endpoint`. 63 | 64 | Parameters 65 | ---------- 66 | body : str 67 | payloads of graphQL query. 68 | headers : dict, optional 69 | headers added to the request (important for authentication). 70 | endpoint : str, optional 71 | the graphQL endpoint url that will be queried, default is 72 | `GQL_DEFAULT_ENDPOINT`. 73 | 74 | Returns 75 | ------- 76 | response : dict 77 | a dictionary corresponding to the parsed JSON graphQL response. 78 | 79 | Raises 80 | ------ 81 | Exception 82 | when `response.status_code` is not 200. 83 | """ 84 | bodyEncoder = MultipartEncoder(body) 85 | base_headers = { 86 | "Content-Type": bodyEncoder.content_type, 87 | } 88 | override_dict(base_headers, headers) 89 | 90 | response = requests.post(endpoint, data=bodyEncoder, headers=base_headers, timeout=90) 91 | 92 | parsed_response = json.loads(response.text) 93 | if response.status_code != 200: 94 | raise Exception("{message}\n extensions: {extensions}".format( 95 | **parsed_response["errors"][0])) 96 | else: 97 | return parsed_response 98 | 99 | 100 | def override_dict(a, overrides): 101 | """Override a dict with another one **only first non nested keys**. 102 | 103 | Notes 104 | ----- 105 | This works only with non-nested dict. If dictionarries are nested then the 106 | nested dict needs to be completly overriden. 107 | The Operation is performed inplace. 108 | 109 | Parameters 110 | ---------- 111 | a : dict 112 | a dictionary to merge. 113 | overrides : dict 114 | another dictionary to merge. 115 | """ 116 | for key, val in overrides.items(): 117 | try: 118 | if type(a[key]) == dict: 119 | print( 120 | "**warning**: key '{}' contained a dict make sure to override each value in the nested dict.".format(key)) 121 | except KeyError: 122 | pass 123 | a[key] = val 124 | 125 | 126 | def handle_errors(errors): 127 | """Handle a list of errors as dict with keys message and field. 128 | 129 | Parameters 130 | ---------- 131 | error : list 132 | a list of errors each error must be a dict with at least the following 133 | keys: `field` and `message` 134 | 135 | Raises 136 | ------ 137 | Exception 138 | when the list is not empty and display {field} : {message} errors. 139 | """ 140 | if len(errors) > 0: 141 | txt_list = [ 142 | "{field} : {message}".format(**error) for error in errors] 143 | raise Exception("\n".join(txt_list)) 144 | 145 | def get_operations(product_id): 146 | """Get ProductImageCreate operations 147 | 148 | Parameters 149 | ---------- 150 | product_id : str 151 | id for which the product image will be created. 152 | 153 | Returns 154 | ------- 155 | query : str 156 | variables: dict 157 | """ 158 | query = """ 159 | mutation ProductImageCreate($product: ID!, $image: Upload!, $alt: String) { 160 | productImageCreate(input: {alt: $alt, image: $image, product: $product}) { 161 | image{ 162 | id 163 | } 164 | productErrors { 165 | field 166 | message 167 | } 168 | } 169 | } 170 | """ 171 | variables = { 172 | "product": product_id, 173 | "image": "0", 174 | "alt": '' 175 | } 176 | return {"query": query, "variables": variables} 177 | 178 | def get_payload(product_id, file_path): 179 | """Get ProductImageCreate operations 180 | 181 | Parameters 182 | ---------- 183 | product_id : str 184 | id for which the product image will be created. 185 | 186 | Returns 187 | ------- 188 | query : str 189 | variables: dict 190 | """ 191 | return { 192 | "operations": json.dumps( 193 | get_operations(product_id), cls=DjangoJSONEncoder 194 | ), 195 | "map": json.dumps({'0': ["variables.image"]}, cls=DjangoJSONEncoder), 196 | "0": (Path(file_path).name, open(file_path, 'rb'), 'image/png') 197 | } 198 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Inside of setup.cfg 2 | [metadata] 3 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | setup( 3 | name='saleor-gql-loader', 4 | packages=['saleor_gql_loader'], 5 | version='0.0.5', 6 | license='MIT', 7 | description='A simple gql loader class to create some entities in Saleor', 8 | author='Guillaume Raille', 9 | author_email='guillaume.raille@gmail.com', 10 | url='https://github.com/grll/saleor-gql-loader', 11 | download_url='https://github.com/grll/saleor-gql-loader/archive/0.0.5.tar.gz', 12 | keywords=['graphql', 'saleor', 'loader'], 13 | install_requires=['requests', 'Django', 'requests-toolbelt'], 14 | classifiers=[ 15 | 'Development Status :: 3 - Alpha', 16 | 'Intended Audience :: Developers', 17 | 'Topic :: Software Development :: Build Tools', 18 | 'License :: OSI Approved :: MIT License', 19 | 'Programming Language :: Python :: 3', 20 | 'Programming Language :: Python :: 3.4', 21 | 'Programming Language :: Python :: 3.5', 22 | 'Programming Language :: Python :: 3.6', 23 | ], 24 | ) 25 | --------------------------------------------------------------------------------