From 67446e68f800c0941f5a63cd583e71cf548dbad1 Mon Sep 17 00:00:00 2001 From: Samar Hassan <88422175+samar-hassan@users.noreply.github.com> Date: Fri, 23 Feb 2024 10:54:00 +0000 Subject: [PATCH] [FEAT] Delete related models (#16) * chore :recycle: add sorl-thumbnail in test requirements * fix :wrench: resolve lint errors * feat :sparkles: delete related models of product * tests :white_check_mark: add tests checking product related model deletion * refactor :package: try sorl thumbnail version 12.9 * refactor :package: try sorl thumbnail version 12.8 * fix :wrench: update poetry lock * refactor :package: improve deleting related models * fix :wrench: resolve tests failing * lint * lint again * feat :star: categories update based on fields_to_update attribute * tests :white_check_mark: only validate related_models for fields in fields_to_update for bulk_update * lint * fix :wrench: filter related_models based on product before deleting * tests :white_check_mark: test partial deletion of one_to_many related fields * tests :white_check_mark: test fields_to_update on product concrete fields * feat :star: add and update product_class using product resource * feat :star: validate resources before updating * fix :wrench: resolve tests failing * refactor :package: remove continue statements * refactor :package: remove fail fast * refactor :package: rename to instance keys and identifier * remove import --------- Co-authored-by: Viggo de Vries --- oscar_odin/mappings/_model_mapper.py | 10 +- oscar_odin/mappings/catalogue.py | 27 +- oscar_odin/mappings/constants.py | 21 +- oscar_odin/mappings/context.py | 174 ++++++++--- oscar_odin/resources/catalogue.py | 71 ++--- oscar_odin/utils.py | 20 ++ poetry.lock | 15 +- pyproject.toml | 6 +- tests/reverse/test_catalogue.py | 401 ++++++++++++++++++------- tests/reverse/test_deleting_related.py | 389 ++++++++++++++++++++++++ tests/reverse/test_reallifecase.py | 3 +- tests/test_settings.py | 1 + 12 files changed, 917 insertions(+), 221 deletions(-) create mode 100644 tests/reverse/test_deleting_related.py diff --git a/oscar_odin/mappings/_model_mapper.py b/oscar_odin/mappings/_model_mapper.py index af5233b..5fde5dc 100644 --- a/oscar_odin/mappings/_model_mapper.py +++ b/oscar_odin/mappings/_model_mapper.py @@ -107,16 +107,10 @@ def add_related_field_values_to_context(self, parent, related_field_values): ) for relation, instances in related_field_values["m2m_related_values"].items(): - if instances: - self.context.add_instances_to_m2m_relation( - relation, (parent, instances) - ) + self.context.add_instances_to_m2m_relation(relation, (parent, instances)) for relation, instances in related_field_values["o2m_related_values"].items(): - if instances: - self.context.add_instances_to_o2m_relation( - relation, (parent, instances) - ) + self.context.add_instances_to_o2m_relation(relation, (parent, instances)) for field, instance in related_field_values["fk_related_values"].items(): if instance: diff --git a/oscar_odin/mappings/catalogue.py b/oscar_odin/mappings/catalogue.py index 463c030..1fa6dd7 100644 --- a/oscar_odin/mappings/catalogue.py +++ b/oscar_odin/mappings/catalogue.py @@ -6,7 +6,7 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple, Union from django.contrib.auth.models import AbstractUser -from django.db.models import QuerySet, Model, ManyToManyField, ForeignKey +from django.db.models import QuerySet from django.db.models.fields.files import ImageFieldFile from django.http import HttpRequest from oscar.apps.partner.strategy import Default as DefaultStrategy @@ -15,9 +15,9 @@ from datetime import datetime from .. import resources -from ..resources.catalogue import Structure from ._common import map_queryset, OscarBaseMapping from ._model_mapper import ModelMapping +from ..utils import validate_resources from .context import ProductModelMapperContext from .constants import ALL_CATALOGUE_FIELDS, MODEL_IDENTIFIERS_MAPPING @@ -143,11 +143,6 @@ class ProductToResource(OscarBaseMapping): from_obj = ProductModel to_obj = resources.catalogue.Product - @odin.map_field - def structure(self, value: str) -> Structure: - """Map structure to enum.""" - return Structure(value) - @odin.assign_field def title(self) -> str: """Map title field.""" @@ -296,7 +291,7 @@ def stockrecords( @odin.map_field def product_class(self, value) -> ProductClassModel: - if not value and self.source.structure == ProductModel.CHILD: + if not value or self.source.structure == ProductModel.CHILD: return None return ProductClassToModel.apply(value) @@ -391,9 +386,11 @@ def product_queryset_to_resources( def products_to_model( - products: List[resources.catalogue.Product], product_mapper=ProductToModel + products: List[resources.catalogue.Product], + product_mapper=ProductToModel, + delete_related=False, ) -> Tuple[List[ProductModel], Dict]: - context = ProductModelMapperContext(ProductModel) + context = ProductModelMapperContext(ProductModel, delete_related=delete_related) result = product_mapper.apply(products, context=context) @@ -408,6 +405,7 @@ def products_to_db( fields_to_update=ALL_CATALOGUE_FIELDS, identifier_mapping=MODEL_IDENTIFIERS_MAPPING, product_mapper=ProductToModel, + delete_related=False, ) -> Tuple[List[ProductModel], Dict]: """Map mulitple products to a model and store them in the database. @@ -415,7 +413,14 @@ def products_to_db( After that all the products will be bulk saved. At last all related models like images, stockrecords, and related_products can will be saved and set on the product. """ - instances, context = products_to_model(products, product_mapper=product_mapper) + errors = validate_resources(products) + if errors: + return [], errors + instances, context = products_to_model( + products, + product_mapper=product_mapper, + delete_related=delete_related, + ) products, errors = context.bulk_save( instances, fields_to_update, identifier_mapping diff --git a/oscar_odin/mappings/constants.py b/oscar_odin/mappings/constants.py index 4264716..377b1ba 100644 --- a/oscar_odin/mappings/constants.py +++ b/oscar_odin/mappings/constants.py @@ -16,10 +16,14 @@ PRODUCT_DESCRIPTION = "Product.description" PRODUCT_META_TITLE = "Product.meta_title" PRODUCT_META_DESCRIPTION = "Product.meta_description" -PRODUCT_PRODUCT_CLASS = "Product.product_class" PRODUCT_PARENT = "Product.parent" PRODUCT_IS_DISCOUNTABLE = "Product.is_discountable" +PRODUCTCLASS_SLUG = "ProductClass.slug" +PRODUCTCLASS_REQUIRESSHIPPING = "ProductClass.requires_shipping" +PRODUCTCLASS_TRACKSTOCK = "ProductClass.track_stock" +PRODUCTCLASS_NAME = "ProductClass.name" + CATEGORY_NAME = "Category.name" CATEGORY_CODE = "Category.code" CATEGORY_DESCRIPTION = "Category.description" @@ -51,11 +55,17 @@ PRODUCT_DESCRIPTION, PRODUCT_META_TITLE, PRODUCT_META_DESCRIPTION, - PRODUCT_PRODUCT_CLASS, PRODUCT_IS_DISCOUNTABLE, PRODUCT_PARENT, ] +ALL_PRODUCTCLASS_FIELDS = [ + PRODUCTCLASS_SLUG, + PRODUCTCLASS_REQUIRESSHIPPING, + PRODUCTCLASS_TRACKSTOCK, + PRODUCTCLASS_NAME, +] + ALL_CATEGORY_FIELDS = [ CATEGORY_NAME, CATEGORY_CODE, @@ -85,13 +95,16 @@ ALL_CATALOGUE_FIELDS = ( - ALL_PRODUCT_FIELDS + ALL_PRODUCTIMAGE_FIELDS + ALL_STOCKRECORD_FIELDS + ALL_PRODUCT_FIELDS + + ALL_PRODUCTIMAGE_FIELDS + + ALL_STOCKRECORD_FIELDS + + [PRODUCTCLASS_SLUG, CATEGORY_CODE] ) MODEL_IDENTIFIERS_MAPPING = { Category: ("code",), Product: ("upc",), - StockRecord: ("product_id",), + StockRecord: ("partner_id", "partner_sku"), ProductClass: ("slug",), ProductImage: ("code",), Partner: ("slug",), diff --git a/oscar_odin/mappings/context.py b/oscar_odin/mappings/context.py index 4964ec2..3bc8678 100644 --- a/oscar_odin/mappings/context.py +++ b/oscar_odin/mappings/context.py @@ -2,6 +2,7 @@ from operator import attrgetter from django.db import transaction +from django.db.models import Q from django.core.exceptions import ValidationError from oscar_odin.utils import in_bulk @@ -16,16 +17,18 @@ def separate_instances_to_create_and_update(Model, instances, identifier_mapping): instances_to_create = [] instances_to_update = [] + identifiying_keys = [] identifiers = identifier_mapping.get(Model, {}) - if identifiers: + if identifiers and instances: # pylint: disable=protected-access id_mapping = in_bulk(Model._default_manager, instances, identifiers) get_key_values = attrgetter(*identifiers) for instance in instances: key = get_key_values(instance) + identifiying_keys.append(key) if not isinstance(key, tuple): key = (key,) @@ -39,9 +42,9 @@ def separate_instances_to_create_and_update(Model, instances, identifier_mapping else: instances_to_create.append(instance) - return instances_to_create, instances_to_update + return instances_to_create, instances_to_update, identifiying_keys else: - return instances, [] + return instances, [], [] class ModelMapperContext(dict): @@ -51,10 +54,11 @@ class ModelMapperContext(dict): one_to_many_items = None attribute_data = None identifier_mapping = None + instance_keys = None Model = None errors = None - def __init__(self, Model, *args, **kwargs): + def __init__(self, Model, *args, delete_related=False, **kwargs): super().__init__(*args, **kwargs) self.foreign_key_items = defaultdict(list) self.many_to_many_items = defaultdict(list) @@ -64,17 +68,22 @@ def __init__(self, Model, *args, **kwargs): self.identifier_mapping = defaultdict(tuple) self.attribute_data = [] self.errors = [] + self.delete_related = delete_related self.Model = Model def __bool__(self): return True - def validate_instances(self, instances, validate_unique=True): + def validate_instances(self, instances, validate_unique=True, fields=None): validated_instances = [] + exclude = () + if fields and instances: + all_fields = instances[0]._meta.fields + exclude = [f.name for f in all_fields if f.name not in fields] for instance in instances: try: - instance.full_clean(validate_unique=validate_unique) + instance.full_clean(validate_unique=validate_unique, exclude=exclude) except ValidationError as e: self.errors.append(e) else: @@ -109,6 +118,7 @@ def get_fields_to_update(self, Model): def get_create_and_update_relations(self, related_instance_items): to_create = defaultdict(list) to_update = defaultdict(list) + identities = defaultdict(list) for relation in related_instance_items.keys(): all_instances = [] @@ -118,14 +128,16 @@ def get_create_and_update_relations(self, related_instance_items): ( instances_to_create, instances_to_update, + identifying_keys, ) = separate_instances_to_create_and_update( relation.related_model, all_instances, self.identifier_mapping ) to_create[relation].extend(instances_to_create) to_update[relation].extend(instances_to_update) + identities[relation].extend(identifying_keys) - return (to_create, to_update) + return (to_create, to_update, identities) @property def get_all_m2m_relations(self): @@ -144,6 +156,7 @@ def get_fk_relations(self): ( instances_to_create, instances_to_update, + _, ) = separate_instances_to_create_and_update( relation.related_model, instances, self.identifier_mapping ) @@ -167,16 +180,25 @@ def bulk_update_or_create_foreign_keys(self): field.related_model.objects.bulk_create(validated_fk_instances) for field, instances in instances_to_update.items(): - Model = field.related_model - fields = self.get_fields_to_update(Model) - if fields is not None: - validated_instances_to_update = self.validate_instances(instances) - Model.objects.bulk_update(validated_instances_to_update, fields=fields) + # We don't update parent details. If we want this then we will have to + # provide other product fields in the ParentProductResource too along with + # the upc, which is not useful in most cases. + if not field.name == "parent": + Model = field.related_model + fields = self.get_fields_to_update(Model) + if fields is not None: + validated_instances_to_update = self.validate_instances( + instances, fields=fields + ) + Model.objects.bulk_update( + validated_instances_to_update, fields=fields + ) def bulk_update_or_create_instances(self, instances): ( instances_to_create, instances_to_update, + self.instance_keys, ) = separate_instances_to_create_and_update( self.Model, instances, self.identifier_mapping ) @@ -194,7 +216,9 @@ def bulk_update_or_create_instances(self, instances): fields = self.get_fields_to_update(self.Model) if fields is not None: - validated_instances_to_update = self.validate_instances(instances_to_update) + validated_instances_to_update = self.validate_instances( + instances_to_update, fields=fields + ) for instance in validated_instances_to_update: # This should be removed once support for django 3.2 is dropped # pylint: disable=protected-access @@ -206,68 +230,122 @@ def bulk_update_or_create_one_to_many(self): for instance in instances: setattr(instance, relation.field.name, product) - instances_to_create, instances_to_update = self.get_o2m_relations + instances_to_create, instances_to_update, identities = self.get_o2m_relations for relation, instances in instances_to_create.items(): - validated_instances_to_create = self.validate_instances(instances) - relation.related_model.objects.bulk_create(validated_instances_to_create) + fields = self.get_fields_to_update(relation.related_model) + if fields is not None: + validated_instances_to_create = self.validate_instances(instances) + relation.related_model.objects.bulk_create( + validated_instances_to_create + ) for relation, instances in instances_to_update.items(): fields = self.get_fields_to_update(relation.related_model) if fields is not None: - validated_instances_to_update = self.validate_instances(instances) + validated_instances_to_update = self.validate_instances( + instances, fields=fields + ) relation.related_model.objects.bulk_update( validated_instances_to_update, fields=fields ) + if self.delete_related: + for relation, keys in identities.items(): + instance_identifier = self.identifier_mapping.get( + relation.remote_field.related_model + )[0] + fields = self.get_fields_to_update(relation.related_model) + if fields is not None: + conditions = Q() + identifiers = self.identifier_mapping[relation.related_model] + for key in keys: + if isinstance(key, (list, tuple)): + conditions |= Q(**dict(list(zip(identifiers, key)))) + else: + conditions |= Q(**{f"{identifiers[0]}": key}) + field_name = relation.remote_field.attname.replace( + "_", "__" + ).replace("id", instance_identifier) + # Delete all related one_to_many instances where product is in the + # given list of resources and excluding any instances present in + # those resources + relation.related_model.objects.filter( + **{f"{field_name}__in": self.instance_keys} + ).exclude(conditions).delete() + def bulk_update_or_create_many_to_many(self): - m2m_to_create, m2m_to_update = self.get_all_m2m_relations + m2m_to_create, m2m_to_update, _ = self.get_all_m2m_relations # Create many to many's for relation, instances in m2m_to_create.items(): - validated_m2m_instances = self.validate_instances(instances) - relation.related_model.objects.bulk_create(validated_m2m_instances) + fields = self.get_fields_to_update(relation.related_model) + if fields is not None: + validated_m2m_instances = self.validate_instances(instances) + relation.related_model.objects.bulk_create(validated_m2m_instances) # Update many to many's for relation, instances in m2m_to_update.items(): fields = self.get_fields_to_update(relation.related_model) if fields is not None: - validated_instances_to_update = self.validate_instances(instances) + validated_instances_to_update = self.validate_instances( + instances, fields=fields + ) relation.related_model.objects.bulk_update( validated_instances_to_update, fields=fields ) for relation, values in self.many_to_many_items.items(): - Through = getattr(self.Model, relation.name).through - - # Create all through models that are needed for the products and many to many - throughs = defaultdict(Through) - for product, instances in values: - for instance in instances: - throughs[(product.pk, instance.pk)] = Through( - **{ - relation.m2m_field_name(): product, - relation.m2m_reverse_field_name(): instance, - } + fields = self.get_fields_to_update(relation.related_model) + if fields is not None: + Through = getattr(self.Model, relation.name).through + + # Create all through models that are needed for the products and + # many to many + throughs = defaultdict(Through) + to_delete_throughs_product_ids = [] + for product, instances in values: + if not instances: + # Delete throughs if no instances are passed for the field + to_delete_throughs_product_ids.append(product.id) + for instance in instances: + throughs[(product.pk, instance.pk)] = Through( + **{ + relation.m2m_field_name(): product, + relation.m2m_reverse_field_name(): instance, + } + ) + + # Delete throughs if no instances are passed for the field + if self.delete_related: + Through.objects.filter( + product_id__in=to_delete_throughs_product_ids + ).all().delete() + + if throughs: + # Bulk query the through models to see if some already exist + bulk_troughs = in_bulk( + Through.objects, + instances=list(throughs.values()), + field_names=( + relation.m2m_field_name(), + relation.m2m_reverse_field_name(), + ), ) - # Bulk query the through models to see if some already exist - bulk_troughs = in_bulk( - Through.objects, - instances=list(throughs.values()), - field_names=( - relation.m2m_field_name(), - relation.m2m_reverse_field_name(), - ), - ) + # Remove existing through models + for b in bulk_troughs.keys(): + if b in throughs: + throughs.pop(b) - # Remove existing through models - for b in bulk_troughs.keys(): - if b in throughs: - throughs.pop(b) + # Delete remaining non-existing through models + if self.delete_related: + Through.objects.filter( + product_id__in=[item[0] for item in bulk_troughs.keys()] + ).exclude(id__in=bulk_troughs.values()).delete() - # Save only new through models - Through.objects.bulk_create(throughs.values()) + # Save only new through models + Through.objects.bulk_create(throughs.values()) def bulk_save(self, instances, fields_to_update, identifier_mapping): self.fields_to_update = fields_to_update @@ -331,7 +409,7 @@ def bulk_update_or_create_product_attributes(self, instances): fields_to_be_updated.update(update_fields) # now save all the attributes in bulk - if attributes_to_delete: + if attributes_to_delete and self.delete_related: ProductAttributeValue.objects.filter(pk__in=attributes_to_delete).delete() if attributes_to_update: validated_attributes_to_update = self.validate_instances( diff --git a/oscar_odin/resources/catalogue.py b/oscar_odin/resources/catalogue.py index 5a31da9..a731336 100644 --- a/oscar_odin/resources/catalogue.py +++ b/oscar_odin/resources/catalogue.py @@ -1,14 +1,17 @@ """Resources for Oscar categories.""" -import enum from datetime import datetime from decimal import Decimal -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union +from oscar.core.loading import get_model import odin +from odin.fields import StringField from ..fields import DecimalField from ._base import OscarResource +ProductModel = get_model("catalogue", "Product") + class OscarCatalogue(OscarResource, abstract=True): """Base resource for Oscar catalogue application.""" @@ -24,10 +27,10 @@ class Meta: verbose_name = "Product image" verbose_name_plural = "Product images" - id: int + id: Optional[int] code: str - original: Any - caption: str = odin.Options(empty=True) + original: Optional[Any] + caption: Optional[str] = odin.Options(empty=True) display_order: int = odin.Options( default=0, doc_text=( @@ -35,50 +38,42 @@ class Meta: " image for a product" ), ) - date_created: datetime + date_created: Optional[datetime] class Category(OscarCatalogue): """A category within Django Oscar.""" - id: int + id: Optional[int] code: str - name: str - slug: str - description: str + name: Optional[str] + slug: Optional[str] + description: Optional[str] meta_title: Optional[str] meta_description: Optional[str] image: Optional[str] - is_public: bool - ancestors_are_public: bool - depth: int - path: str + is_public: Optional[bool] + ancestors_are_public: Optional[bool] + depth: Optional[int] + path: Optional[str] class ProductClass(OscarCatalogue): """A product class within Django Oscar.""" - name: str + name: Optional[str] slug: str - requires_shipping: bool - track_stock: bool - - -class Structure(str, enum.Enum): - """Structure of product.""" - - STANDALONE = "standalone" - PARENT = "parent" - CHILD = "child" + requires_shipping: Optional[bool] + track_stock: Optional[bool] class StockRecord(OscarCatalogue): - id: int + id: Optional[int] partner_sku: str - num_in_stock: int - num_allocated: int + num_in_stock: Optional[int] + num_allocated: Optional[int] price: Decimal = DecimalField() - currency: str + currency: Optional[str] class ProductAttributeValue(OscarCatalogue): @@ -93,12 +88,12 @@ class ParentProduct(OscarCatalogue): class Product(OscarCatalogue): """A product within Django Oscar.""" - id: int + id: Optional[int] upc: Optional[str] - structure: Structure + structure: str = StringField(choices=ProductModel.STRUCTURE_CHOICES) title: str - slug: str - description: str = "" + slug: Optional[str] + description: Optional[str] = "" meta_title: Optional[str] images: List[Image] = odin.Options(empty=True) rating: Optional[float] @@ -107,17 +102,17 @@ class Product(OscarCatalogue): parent: Optional[ParentProduct] # Price information - price: Decimal = DecimalField() - currency: str + price: Decimal = DecimalField(null=True) + currency: Optional[str] availability: Optional[int] partner: Optional[Any] product_class: Optional[ProductClass] = None - attributes: Dict[str, Any] + attributes: Dict[str, Union[Any, None]] categories: List[Category] - date_created: datetime - date_updated: datetime + date_created: Optional[datetime] + date_updated: Optional[datetime] children: Optional[List["Product"]] = odin.ListOf.delayed( lambda: Product, null=True diff --git a/oscar_odin/utils.py b/oscar_odin/utils.py index 951af03..5158fa8 100644 --- a/oscar_odin/utils.py +++ b/oscar_odin/utils.py @@ -6,6 +6,9 @@ from django.db import connection, connections, reset_queries from django.db.models import Q +from odin.exceptions import ValidationError +from odin.mapping import MappingResult + def get_filters(instances, field_names): for ui in instances: @@ -69,3 +72,20 @@ def querycounter(*labels, print_queries=False): if print_queries: for q in connection.queries: print(" ", q) + + +def validate_resources(resources): + errors = [] + if not resources: + return + if not isinstance(resources, (list, tuple)): + if isinstance(resources, MappingResult): + resources = resources.items + else: + resources = [resources] + for resource in resources: + try: + resource.full_clean() + except ValidationError as error: + errors.append(error) + return errors diff --git a/poetry.lock b/poetry.lock index 785eb75..0487e33 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1797,6 +1797,17 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "sorl-thumbnail" +version = "12.10.0" +description = "Thumbnails for Django" +optional = true +python-versions = ">=3.8" +files = [ + {file = "sorl-thumbnail-12.10.0.tar.gz", hash = "sha256:de95a49217fdfeced222fa3ceaa01d312ee2f8aad56ba34d6c70f2dee9a84938"}, + {file = "sorl_thumbnail-12.10.0-py3-none-any.whl", hash = "sha256:733eb2eee392d4a874f88fb3ed6f0572fa9c361b06e0411b83e435ba69c51f52"}, +] + [[package]] name = "sqlparse" version = "0.4.4" @@ -2006,9 +2017,9 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [extras] dev = ["isort", "pre-commit", "ruff"] -test = ["black", "coverage", "poetry", "pylint", "pylint-django", "responses"] +test = ["black", "coverage", "poetry", "pylint", "pylint-django", "responses", "sorl-thumbnail"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "176698b3afb8ff6e5faba98ad4562bfbccd67f62fe76c1a907ad693a09b32998" +content-hash = "9785bcde89a1e449044d8351cf067df4a8d24b3f454db2e4157e8695049e12d3" diff --git a/pyproject.toml b/pyproject.toml index a11dab7..9ecd6ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,10 +38,14 @@ ruff = {version = "^0.1.8", optional = true} poetry = {version = "^1.7.1", optional = true} pre-commit = {version = "*", optional = true} responses = {version = "^0.24.1", optional = true} +sorl-thumbnail = {version = "^12.8.0", optional = true} [tool.poetry.extras] -test = ["coverage", "pylint", "black", "pylint-django", "poetry", "responses"] +test = [ + "coverage", "pylint", "black", "pylint-django", "poetry", "responses", + "sorl-thumbnail" +] dev = ["ruff", "isort", "pre-commit"] [build-system] diff --git a/tests/reverse/test_catalogue.py b/tests/reverse/test_catalogue.py index d96c993..99ee1f7 100644 --- a/tests/reverse/test_catalogue.py +++ b/tests/reverse/test_catalogue.py @@ -14,7 +14,6 @@ Image as ImageResource, ProductClass as ProductClassResource, Category as CategoryResource, - ProductAttributeValue as ProductAttributeValueResource, ParentProduct as ParentProductResource, ) from oscar_odin.exceptions import OscarOdinException @@ -23,6 +22,9 @@ STOCKRECORD_NUM_IN_STOCK, STOCKRECORD_NUM_ALLOCATED, PRODUCTIMAGE_ORIGINAL, + PRODUCT_TITLE, + PRODUCT_DESCRIPTION, + PRODUCTCLASS_REQUIRESSHIPPING, ) Product = get_model("catalogue", "Product") @@ -31,18 +33,11 @@ ProductImage = get_model("catalogue", "ProductImage") Category = get_model("catalogue", "Category") Partner = get_model("partner", "Partner") -ProductAttributeValue = get_model("catalogue", "ProductAttributeValue") class SingleProductReverseTest(TestCase): - @property - def image(self): - img = PIL.Image.new(mode="RGB", size=(200, 200)) - output = io.BytesIO() - img.save(output, "jpeg") - return output - - def test_create_product_with_related_fields(self): + def setUp(self): + super().setUp() product_class = ProductClass.objects.create( name="Klaas", slug="klaas", requires_shipping=True, track_stock=True ) @@ -59,13 +54,20 @@ def test_create_product_with_related_fields(self): product_class=product_class, ) - product_class = ProductClassResource(slug="klaas", name="Klaas") - - partner = Partner.objects.create(name="klaas") - + Partner.objects.create(name="klaas") Category.add_root(name="Hatsie", slug="batsie", is_public=True, code="1") Category.add_root(name="henk", slug="klaas", is_public=True, code="2") + @property + def image(self): + img = PIL.Image.new(mode="RGB", size=(200, 200)) + output = io.BytesIO() + img.save(output, "jpeg") + return output + + def test_create_product_with_related_fields(self): + partner = Partner.objects.get(name="klaas") + product_resource = ProductResource( upc="1234323-2", title="asdf2", @@ -77,16 +79,18 @@ def test_create_product_with_related_fields(self): availability=2, currency="EUR", partner=partner, - product_class=product_class, + product_class=ProductClassResource(slug="klaas"), images=[ ImageResource( caption="gekke caption", display_order=0, + code="harrie", original=File(self.image, name="harrie.jpg"), ), ImageResource( caption="gekke caption 2", display_order=1, + code="vats", original=File(self.image, name="vats.jpg"), ), ], @@ -94,7 +98,8 @@ def test_create_product_with_related_fields(self): attributes={"henk": "Klaas", "harrie": 1}, ) - prd = products_to_db(product_resource) + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) prd = Product.objects.get(upc="1234323-2") @@ -115,20 +120,24 @@ def test_create_product_with_related_fields(self): product_resource = ProductResource( upc="1234323-2", + title="asdf2", + structure=Product.STANDALONE, price=D("21.50"), availability=3, currency="EUR", partner=partner, - product_class=product_class, + product_class=ProductClassResource(slug="klaas"), images=[ ImageResource( caption="gekke caption", display_order=0, + code="harriebatsie", original=File(self.image, name="harriebatsie.jpg"), ), ImageResource( caption="gekke caption 2", display_order=1, + code="vatsie", original=File(self.image, name="vatsie.jpg"), ), ], @@ -141,26 +150,23 @@ def test_create_product_with_related_fields(self): STOCKRECORD_NUM_ALLOCATED, PRODUCTIMAGE_ORIGINAL, ] - products_to_db(product_resource, fields_to_update=fields_to_update) + _, errors = products_to_db(product_resource, fields_to_update=fields_to_update) + self.assertEqual(len(errors), 0) prd = Product.objects.get(upc="1234323-2") self.assertEqual(prd.stockrecords.count(), 1) stockrecord = prd.stockrecords.first() self.assertEqual(stockrecord.price, D("21.50")) self.assertEqual(stockrecord.num_in_stock, 3) - self.assertEqual(prd.categories.count(), 2) + # Category was not included in fields_to_update + self.assertEqual(prd.categories.count(), 1) + self.assertFalse(prd.categories.filter(code="1").exists()) self.assertEqual(prd.images.count(), 4) def test_create_productclass_with_product(self): - product_class = ProductClassResource( - slug="klaas", name="Klaas", requires_shipping=True, track_stock=True - ) - - partner = Partner.objects.create(name="klaas") - - Category.add_root(name="Hatsie", slug="batsie", is_public=True, code="1") - Category.add_root(name="henk", slug="klaas", is_public=True, code="2") + partner = Partner.objects.get(name="klaas") + product_class = ProductClassResource(slug="klaas", name="Klaas") product_resource = ProductResource( upc="1234323-2", @@ -178,18 +184,21 @@ def test_create_productclass_with_product(self): ImageResource( caption="gekke caption", display_order=0, + code="harrie", original=File(self.image, name="harrie.jpg"), ), ImageResource( caption="gekke caption 2", display_order=1, + code="vats", original=File(self.image, name="vats.jpg"), ), ], categories=[CategoryResource(code="2")], ) - prd = products_to_db(product_resource) + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) prd = Product.objects.get(upc="1234323-2") @@ -207,30 +216,38 @@ def test_create_productclass_with_product(self): self.assertEqual(prd.images.count(), 2) - def test_idempotent(self): - product_class = ProductClass.objects.create( - name="Klaas", slug="klaas", requires_shipping=True, track_stock=True - ) - ProductAttribute.objects.create( - name="Henk", - code="henk", - type=ProductAttribute.TEXT, + def test_resource_default_value(self): + product_class = ProductClassResource(slug="klaas", name="Klaas") + product_resource = ProductResource( + upc="1234", + title="bat", + slug="asdf", + structure=Product.STANDALONE, product_class=product_class, + is_discountable=False, ) - ProductAttribute.objects.create( - name="Harrie", - code="harrie", - type=ProductAttribute.INTEGER, + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) + prd = Product.objects.get(upc="1234") + self.assertEqual(prd.is_discountable, False) + + product_resource = ProductResource( + upc="1234", + title="bat", + slug="asdf", + structure=Product.STANDALONE, product_class=product_class, ) + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) + prd.refresh_from_db() + # Default value of is_discountable is considered from the ProductResource + self.assertEqual(prd.is_discountable, True) + def test_idempotent(self): + partner = Partner.objects.get(name="klaas") product_class = ProductClassResource(slug="klaas", name="Klaas") - partner = Partner.objects.create(name="klaas") - - Category.add_root(name="Hatsie", slug="batsie", is_public=True, code="1") - Category.add_root(name="henk", slug="klaas", is_public=True, code="2") - product_resource = ProductResource( upc="1234323-2", title="asdf2", @@ -261,7 +278,8 @@ def test_idempotent(self): attributes={"henk": "Klaas", "harrie": 1}, ) - prd = products_to_db(product_resource) + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) prd = Product.objects.get(upc="1234323-2") @@ -286,7 +304,7 @@ def test_idempotent(self): slug="asdf-asdfasdf2", description="description", structure=Product.STANDALONE, - is_discountable=True, + # is_discountable=True, price=D("20"), availability=2, currency="EUR", @@ -310,7 +328,8 @@ def test_idempotent(self): attributes={"henk": "Klaas", "harrie": 1}, ) - products_to_db(product_resource) + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) prd = Product.objects.get(upc="1234323-2") @@ -331,6 +350,24 @@ def test_idempotent(self): class MultipleProductReverseTest(TestCase): + def setUp(self): + super().setUp() + product_class = ProductClass.objects.create( + name="Klaas", slug="klaas", requires_shipping=True, track_stock=True + ) + ProductAttribute.objects.create( + name="Henk", + code="henk", + type=ProductAttribute.TEXT, + product_class=product_class, + ) + ProductAttribute.objects.create( + name="Harrie", + code="harrie", + type=ProductAttribute.INTEGER, + product_class=product_class, + ) + @property def image(self): img = PIL.Image.new(mode="RGB", size=(200, 200)) @@ -339,12 +376,7 @@ def image(self): return output def test_create_simple_product(self): - product_class = ProductClass.objects.create( - name="Klaas", slug="klaas", requires_shipping=True, track_stock=True - ) - Product.objects.create(upc="1234323asd", title="") product_class = ProductClassResource(slug="klaas", name="Klaas") - product_resources = [ ProductResource( upc="1234323asd", @@ -366,29 +398,12 @@ def test_create_simple_product(self): ), ] - prd = products_to_db(product_resources) + _, errors = products_to_db(product_resources) + self.assertEqual(len(errors), 0) self.assertEqual(Product.objects.count(), 2) def test_create_product_with_related_fields(self): - product_class = ProductClass.objects.create( - name="Klaas", slug="klaas", requires_shipping=True, track_stock=True - ) - ProductAttribute.objects.create( - name="Henk", - code="henk", - type=ProductAttribute.TEXT, - product_class=product_class, - ) - ProductAttribute.objects.create( - name="Harrie", - code="harrie", - type=ProductAttribute.INTEGER, - product_class=product_class, - ) - - product_class = ProductClassResource(slug="klaas", name="Klaas") - product_resources = [ ProductResource( upc="1234323", @@ -400,16 +415,18 @@ def test_create_product_with_related_fields(self): price=D("20"), availability=2, currency="EUR", - product_class=product_class, + product_class=ProductClassResource(slug="klaas"), images=[ ImageResource( caption="gekke caption", display_order=0, + code="klaas", original=File(self.image, name="klaas.jpg"), ), ImageResource( caption="gekke caption 2", display_order=1, + code="harrie", original=File(self.image, name="harrie.jpg"), ), ], @@ -426,16 +443,18 @@ def test_create_product_with_related_fields(self): availability=2, currency="EUR", partner=Partner.objects.create(name="klaas"), - product_class=product_class, + product_class=ProductClassResource(slug="klaas"), images=[ ImageResource( caption="gekke caption", display_order=0, + code="klass-2", original=File(self.image, name="klaas.jpg"), ), ImageResource( caption="gekke caption 2", display_order=1, + code="harrie-2", original=File(self.image, name="harrie.jpg"), ), ], @@ -443,7 +462,8 @@ def test_create_product_with_related_fields(self): ), ] - products_to_db(product_resources) + _, errors = products_to_db(product_resources) + self.assertEqual(len(errors), 0) self.assertEqual(ProductImage.objects.all().count(), 4) self.assertEqual(ProductClass.objects.all().count(), 1) @@ -463,25 +483,27 @@ def test_create_product_with_related_fields(self): class ParentChildTest(TestCase): - def test_parent_childs(self): + def setUp(self): + super().setUp() Category.add_root(name="henk", slug="klaas", is_public=True, code="2") ProductClass.objects.create( name="Klaas", slug="klaas", requires_shipping=True, track_stock=True ) - product_class = ProductClassResource(slug="klaas") - partner = Partner.objects.create(name="klaas") + Partner.objects.create(name="klaas") - prds = ProductResource( + def test_parent_childs(self): + product_resource = ProductResource( upc="1234323-2", title="asdf2", slug="asdf-asdfasdf2", description="description", structure=Product.PARENT, - product_class=product_class, + product_class=ProductClassResource(slug="klaas"), categories=[CategoryResource(code="2")], ) - products_to_db(prds) + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) prd = Product.objects.get(upc="1234323-2") @@ -500,7 +522,8 @@ def test_parent_childs(self): partner=Partner.objects.create(name="klaas"), ) - products_to_db(child_product) + _, errors = products_to_db(child_product) + self.assertEqual(len(errors), 0) prd = Product.objects.get(upc="1234323-2") @@ -513,24 +536,18 @@ def test_parent_childs(self): self.assertEqual(child.parent.pk, prd.pk) def test_non_existing_parent_childs(self): - Category.add_root(name="henk", slug="klaas", is_public=True, code="2") - ProductClass.objects.create( - name="Klaas", slug="klaas", requires_shipping=True, track_stock=True - ) - product_class = ProductClassResource(slug="klaas") - partner = Partner.objects.create(name="klaas") - - prds = ProductResource( + product_resource = ProductResource( upc="1234323-2", title="asdf2", slug="asdf-asdfasdf2", description="description", structure=Product.PARENT, - product_class=product_class, + product_class=ProductClassResource(slug="klaas"), categories=[CategoryResource(code="2")], ) - products_to_db(prds) + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) prd = Product.objects.get(upc="1234323-2") @@ -554,6 +571,12 @@ def test_non_existing_parent_childs(self): class SingleProductErrorHandlingTest(TestCase): + def setUp(self): + super().setUp() + Partner.objects.create(name="klaas") + Category.add_root(name="Hatsie", slug="batsie", is_public=True, code="1") + Category.add_root(name="henk", slug="klaas", is_public=True, code="2") + @property def image(self): img = PIL.Image.new(mode="RGB", size=(200, 200)) @@ -565,11 +588,7 @@ def test_error_handling_on_product_operations(self): product_class = ProductClassResource( slug="klaas", name="Klaas", requires_shipping=True, track_stock=True ) - - partner = Partner.objects.create(name="klaas") - - Category.add_root(name="Hatsie", slug="batsie", is_public=True, code="1") - Category.add_root(name="henk", slug="klaas", is_public=True, code="2") + partner = Partner.objects.get(name="klaas") # Incorrect data for creating product product_resource = ProductResource( @@ -588,11 +607,13 @@ def test_error_handling_on_product_operations(self): ImageResource( caption="gekke caption", display_order="top", + code="harrie", original=File(self.image, name="harrie.jpg"), ), ImageResource( caption="gekke caption 2", display_order=1, + code="vats", original=File(self.image, name="vats.jpg"), ), ], @@ -601,8 +622,8 @@ def test_error_handling_on_product_operations(self): _, errors = products_to_db(product_resource) self.assertEqual(len(errors), 1) self.assertEqual( - errors[0].message_dict["display_order"][0], - "“top” value must be an integer.", + errors[0].message_dict["images"][0]["0"]["display_order"][0], + "'top' value must be a integer.", ) # Correct Data for creating product @@ -622,11 +643,13 @@ def test_error_handling_on_product_operations(self): ImageResource( caption="gekke caption", display_order=0, + code="harrie", original=File(self.image, name="harrie.jpg"), ), ImageResource( caption="gekke caption 2", display_order=1, + code="vats", original=File(self.image, name="vats.jpg"), ), ], @@ -641,39 +664,201 @@ def test_error_handling_on_product_operations(self): title="asdf2", slug="asdf-asdfasdf2", description="description", - structure=Product.STANDALONE, + structure="new", is_discountable=53, price="expensive", availability=2, currency="EUR", partner=partner, product_class=product_class, + images=[ + ImageResource(code="harrie"), + ImageResource( + caption="gekke caption 2", + display_order="Alphabet", + code="vats", + original=File(self.image, name="vats.jpg"), + ), + ], + categories=[CategoryResource(code="2")], + ) + _, errors = products_to_db(product_resource) + + self.assertEqual(len(errors), 1) + self.assertEqual( + errors[0].message_dict["structure"][0], + "Value 'new' is not a valid choice.", + ) + self.assertEqual( + errors[0].message_dict["is_discountable"][0], + "'53' value must be either True or False.", + ) + self.assertEqual( + errors[0].message_dict["images"][0]["1"]["display_order"][0], + "'Alphabet' value must be a integer.", + ) + self.assertEqual( + errors[0].message_dict["price"][0], + "'expensive' value must be a decimal.", + ) + + +class SingleProductFieldsToUpdateTest(TestCase): + def setUp(self): + super().setUp() + ProductClass.objects.create( + name="Klaas", slug="klaas", requires_shipping=False, track_stock=True + ) + Partner.objects.create(name="klaas") + Category.add_root(name="Hatsie", slug="batsie", is_public=True, code="1") + Category.add_root(name="henk", slug="klaas", is_public=True, code="2") + + @property + def image(self): + img = PIL.Image.new(mode="RGB", size=(200, 200)) + output = io.BytesIO() + img.save(output, "jpeg") + return output + + def test_fields_to_update_on_product_operations(self): + product_class = ProductClassResource(slug="klaas") + partner = Partner.objects.get(name="klaas") + + product_resource = ProductResource( + upc="1234323-2", + title="asdf2", + slug="asdf-asdfasdf2", + description="old description", + structure=Product.STANDALONE, + price=D("20"), + availability=2, + currency="EUR", + partner=partner, + product_class=product_class, images=[ ImageResource( caption="gekke caption", display_order=0, + code="harrie", original=File(self.image, name="harrie.jpg"), ), ImageResource( caption="gekke caption 2", - display_order="Alphabet", + display_order=1, + code="vats", original=File(self.image, name="vats.jpg"), ), ], categories=[CategoryResource(code="2")], ) _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) - self.assertEqual(len(errors), 3) + product_resource = ProductResource( + upc="1234323-2", + title="asdf2", + description="updated description", + structure=Product.STANDALONE, + product_class=product_class, + ) + _, errors = products_to_db(product_resource) + prd = Product.objects.get(upc="1234323-2") + self.assertEqual(len(errors), 1) self.assertEqual( - errors[0].message_dict["is_discountable"][0], - "“53” value must be either True or False.", + errors[0].message_dict["slug"][0], + "This field cannot be null.", + ) + self.assertEqual(prd.categories.count(), 1) + self.assertEqual(prd.images.count(), 2) + self.assertEqual(prd.stockrecords.count(), 1) + + product_resource = ProductResource( + upc="1234323-2", + title="asdf2", + description="This description is not updated", + structure=Product.STANDALONE, + product_class=product_class, + ) + _, errors = products_to_db(product_resource, fields_to_update=[PRODUCT_TITLE]) + # Slug error doesn't appear because it is not included in fields_to_update + self.assertEqual(len(errors), 0) + prd.refresh_from_db() + # Description is not updated it is not included in fields_to_update + self.assertEqual(prd.description, "old description") + + product_resource = ProductResource( + upc="1234323-2", + title="target", + description="This description is updated", + structure=Product.STANDALONE, + product_class=product_class, + ) + _, errors = products_to_db( + product_resource, fields_to_update=[PRODUCT_DESCRIPTION] ) + self.assertEqual(len(errors), 0) + prd.refresh_from_db() + # Description is not updated it is not included in fields_to_update + self.assertEqual(prd.description, "This description is updated") + # Likewise title is also not updated since we did not include + # it in fields_to_update. + self.assertNotEqual(prd.title, "target") + + def test_product_class_fields_to_update(self): + product_resource = ProductResource( + upc="1234323-2", + title="asdf2", + slug="asdf-asdfasdf2", + structure=Product.STANDALONE, + ) + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 1) self.assertEqual( - errors[1].message_dict["display_order"][0], - "“Alphabet” value must be an integer.", + errors[0].message_dict["__all__"][0], + "Your product must have a product class.", + ) + + product_resource = ProductResource( + upc="1234323-2", + title="asdf2", + slug="asdf-asdfasdf2", + structure=Product.STANDALONE, + product_class=ProductClassResource( + name="Better", slug="better", requires_shipping=False, track_stock=True + ), ) + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) + + product_resource = ProductResource( + upc="1234323-2", + title="asdf2", + structure=Product.STANDALONE, + ) + _, errors = products_to_db(product_resource, fields_to_update=[PRODUCT_TITLE]) + # Update fails, removing product_class from product resource produces error + self.assertEqual(len(errors), 1) self.assertEqual( - errors[2].message_dict["price"][0], - "“expensive” value must be a decimal number.", + errors[0].message_dict["__all__"][0], + "Your product must have a product class.", ) + + product_resource = ProductResource( + upc="1234323-2", + title="asdf2", + structure=Product.STANDALONE, + product_class=ProductClassResource(slug="better", requires_shipping=True), + ) + _, errors = products_to_db(product_resource, fields_to_update=[PRODUCT_TITLE]) + self.assertEqual(len(errors), 0) + prd = Product.objects.get(upc="1234323-2") + # Product class is not updated since it wasn't added in fields_to_update + self.assertNotEqual(prd.product_class.requires_shipping, True) + + _, errors = products_to_db( + product_resource, fields_to_update=[PRODUCTCLASS_REQUIRESSHIPPING] + ) + self.assertEqual(len(errors), 0) + prd.refresh_from_db() + # Product class is updated as it was added in fields_to_update + self.assertEqual(prd.product_class.requires_shipping, True) diff --git a/tests/reverse/test_deleting_related.py b/tests/reverse/test_deleting_related.py new file mode 100644 index 0000000..1f5003a --- /dev/null +++ b/tests/reverse/test_deleting_related.py @@ -0,0 +1,389 @@ +import io +import PIL + +from decimal import Decimal as D + +from django.core.files import File +from django.test import TestCase + +from oscar.core.loading import get_model + +from oscar_odin.mappings.catalogue import products_to_db +from oscar_odin.resources.catalogue import ( + Product as ProductResource, + Image as ImageResource, + ProductClass as ProductClassResource, + Category as CategoryResource, +) +from oscar_odin.mappings.constants import ( + CATEGORY_CODE, + ALL_PRODUCTIMAGE_FIELDS, + ALL_STOCKRECORD_FIELDS, +) + +Product = get_model("catalogue", "Product") +ProductClass = get_model("catalogue", "ProductClass") +ProductAttribute = get_model("catalogue", "ProductAttribute") +Category = get_model("catalogue", "Category") +ProductImage = get_model("catalogue", "ProductImage") +Partner = get_model("partner", "Partner") +Stockrecord = get_model("partner", "Stockrecord") + + +class DeleteRelatedModelReverseTest(TestCase): + def setUp(self): + super().setUp() + product_class = ProductClass.objects.create( + name="Klaas", slug="klaas", requires_shipping=True, track_stock=True + ) + ProductAttribute.objects.create( + name="Henk", + code="henk", + type=ProductAttribute.TEXT, + product_class=product_class, + ) + ProductAttribute.objects.create( + name="Harrie", + code="harrie", + type=ProductAttribute.INTEGER, + product_class=product_class, + ) + Partner.objects.create(name="klaas") + Category.add_root(name="Hatsie", slug="batsie", is_public=True, code="1") + Category.add_root(name="henk", slug="klaas", is_public=True, code="2") + + @property + def image(self): + img = PIL.Image.new(mode="RGB", size=(200, 200)) + output = io.BytesIO() + img.save(output, "jpeg") + return output + + def test_deleting_product_related_models(self): + partner = Partner.objects.get(name="klaas") + # If an attribute is present in product class and not included in the product + # resource, oscar's ProductAttributeContainer would require product class name + # in the __str__ method. Hence its important to include name in the following + # product class resource. This is not needed for oscar version greater than + # 3.2.4. + product_class = ProductClassResource(slug="klaas", name="Klaas") + + product_resources = [ + ProductResource( + upc="1234323-2", + title="asdf2", + slug="asdf-asdfasdf2", + description="description", + structure=Product.STANDALONE, + is_discountable=True, + price=D("20"), + availability=2, + currency="EUR", + partner=partner, + product_class=product_class, + images=[ + ImageResource( + caption="gekke caption", + display_order=0, + code="harrie", + original=File(self.image, name="harrie.jpg"), + ), + ImageResource( + caption="gekke caption 2", + display_order=1, + code="vats", + original=File(self.image, name="vats.jpg"), + ), + ], + categories=[CategoryResource(code="1"), CategoryResource(code="2")], + attributes={"henk": "Klaas", "harrie": 1}, + ), + ProductResource( + upc="563-2", + title="bat", + slug="bat", + description="description", + structure=Product.STANDALONE, + is_discountable=True, + price=D("20"), + availability=2, + currency="EUR", + partner=partner, + product_class=product_class, + images=[ + ImageResource( + caption="robin", + display_order=0, + code="robin", + original=File(self.image, name="robin.jpg"), + ), + ], + categories=[CategoryResource(code="1")], + attributes={"henk": "Klaas"}, + ), + ] + + _, errors = products_to_db(product_resources) + self.assertEqual(len(errors), 0) + prd = Product.objects.get(upc="1234323-2") + prd_563 = Product.objects.get(upc="563-2") + + self.assertEqual(prd.images.count(), 2) + self.assertTrue(prd.images.filter(code="harrie").exists()) + self.assertTrue(prd.images.filter(code="vats").exists()) + + self.assertEqual(prd.stockrecords.count(), 1) + self.assertTrue(prd.stockrecords.filter(partner=partner).exists()) + + self.assertEqual(prd.categories.count(), 2) + self.assertTrue(prd.categories.filter(code="1").exists()) + self.assertTrue(prd.categories.filter(code="2").exists()) + self.assertEqual(prd_563.categories.count(), 1) + self.assertTrue(prd_563.categories.filter(code="1").exists()) + + self.assertEqual(prd.attribute_values.count(), 2) + self.assertEqual(prd.attr.henk, "Klaas") + self.assertEqual(prd.attr.harrie, 1) + + # Manually add another stockrecord in product and check if its deleted later + partner_henk = Partner.objects.create(name="henry") + Stockrecord.objects.create( + partner=partner_henk, product=prd, num_in_stock=4, price=D("20.0") + ) + self.assertEqual(prd.stockrecords.count(), 2) + self.assertTrue(prd.stockrecords.filter(partner=partner).exists()) + self.assertTrue(prd.stockrecords.filter(partner=partner_henk).exists()) + + product_resources = [ + ProductResource( + upc="1234323-2", + title="asdf2", + structure=Product.STANDALONE, + price=D("20"), + availability=2, + currency="EUR", + partner=partner, + product_class=product_class, + images=[ + ImageResource( + caption="gekke caption", + display_order=0, + code="harrie", + original=File(self.image, name="harrie.jpg"), + ), + ], + attributes={"henk": "Klaas", "harrie": None}, + ), + ProductResource( + upc="563-2", + title="bat", + structure=Product.STANDALONE, + price=D("20"), + availability=2, + currency="EUR", + partner=partner, + product_class=product_class, + images=[ + ImageResource( + caption="gekke caption", + display_order=0, + code="harrie", + original=File(self.image, name="harrie.jpg"), + ), + ], + categories=[CategoryResource(code="1"), CategoryResource(code="2")], + attributes={"henk": "Klaas", "harrie": None}, + ), + ] + + fields_to_update = ALL_PRODUCTIMAGE_FIELDS + ALL_STOCKRECORD_FIELDS + _, errors = products_to_db( + product_resources, delete_related=True, fields_to_update=fields_to_update + ) + self.assertEqual(len(errors), 0) + + # Related models are successfully deleted + self.assertEqual(prd.images.count(), 1) + self.assertTrue(prd.images.filter(code="harrie").exists()) + + self.assertEqual(prd.stockrecords.count(), 1) + self.assertTrue(prd.stockrecords.filter(partner=partner).exists()) + + self.assertEqual(prd.attribute_values.count(), 1) + self.assertEqual(prd.attr.henk, "Klaas") + + # Since categories were added in the fields_to_update attribute, + # so they remain unaffected + self.assertEqual(prd.categories.count(), 2) + self.assertTrue(prd.categories.filter(code="1").exists()) + self.assertTrue(prd.categories.filter(code="2").exists()) + # Likewise, the new category added in product 563-2 was not updated + self.assertEqual(prd_563.categories.count(), 1) + self.assertTrue(prd_563.categories.filter(code="1").exists()) + + # Adding categories and deleting one + product_resources = [ + ProductResource( + upc="1234323-2", + title="asdf2", + structure=Product.STANDALONE, + categories=[CategoryResource(code="1")], + ), + ProductResource( + upc="563-2", title="bat", structure=Product.STANDALONE, categories=[] + ), + ] + _, errors = products_to_db( + product_resources, delete_related=True, fields_to_update=[CATEGORY_CODE] + ) + self.assertEqual(len(errors), 0) + # Categories of prd_563 are deleted since it is set to an empty array + self.assertEqual(prd_563.categories.count(), 0) + self.assertEqual(prd.categories.count(), 1) + self.assertTrue(prd.categories.filter(code="1").exists()) + self.assertEqual(prd.images.count(), 1) + self.assertEqual(prd.stockrecords.count(), 1) + self.assertEqual(prd.attribute_values.count(), 1) + + def test_deleting_all_related_models(self): + partner = Partner.objects.get(name="klaas") + + product_resource = ProductResource( + upc="1234323-2", + title="asdf2", + slug="asdf-asdfasdf2", + description="description", + structure=Product.STANDALONE, + is_discountable=True, + price=D("20"), + availability=2, + currency="EUR", + partner=partner, + product_class=ProductClassResource(slug="klaas"), + images=[ + ImageResource( + caption="gekke caption", + display_order=0, + code="harrie", + original=File(self.image, name="harrie.jpg"), + ), + ImageResource( + caption="gekke caption 2", + display_order=1, + code="vats", + original=File(self.image, name="vats.jpg"), + ), + ], + categories=[CategoryResource(code="1"), CategoryResource(code="2")], + attributes={"henk": "Klaas", "harrie": 1}, + ) + + _, errors = products_to_db(product_resource) + self.assertEqual(len(errors), 0) + prd = Product.objects.get(upc="1234323-2") + + self.assertEqual(prd.images.count(), 2) + self.assertEqual(prd.stockrecords.count(), 1) + self.assertEqual(prd.categories.count(), 2) + self.assertEqual(prd.attribute_values.count(), 2) + + product_resource = ProductResource( + upc="1234323-2", + title="asdf2", + slug="asdf-asdfasdf2", + structure=Product.STANDALONE, + product_class=ProductClassResource(slug="klaas"), + categories=[], + attributes={"henk": None, "harrie": None}, + ) + _, errors = products_to_db(product_resource, delete_related=True) + self.assertEqual(len(errors), 0) + + self.assertEqual(prd.images.count(), 0) + self.assertEqual(prd.stockrecords.count(), 0) + self.assertEqual(prd.categories.count(), 0) + self.assertEqual(prd.attribute_values.count(), 0) + + def test_partial_deletion_of_one_to_many_related_models(self): + partner = Partner.objects.get(name="klaas") + product_class = ProductClassResource(slug="klaas", name="Klaas") + product_resources = [ + ProductResource( + upc="harrie", + title="harrie", + slug="asdf-harrie", + structure=Product.STANDALONE, + product_class=product_class, + price=D("20"), + availability=2, + currency="EUR", + partner=partner, + images=[ + ImageResource( + caption="gekke caption", + display_order=0, + code="harrie", + original=File(self.image, name="harrie.jpg"), + ), + ], + ), + ProductResource( + upc="bat", + title="bat", + slug="asdf-bat", + structure=Product.STANDALONE, + product_class=product_class, + price=D("10"), + availability=2, + currency="EUR", + partner=partner, + images=[ + ImageResource( + caption="gekke caption", + display_order=0, + code="bat", + original=File(self.image, name="bat.jpg"), + ), + ], + ), + ProductResource( + upc="hat", + title="hat", + slug="asdf-hat", + structure=Product.STANDALONE, + product_class=product_class, + price=D("30"), + availability=1, + currency="EUR", + partner=partner, + images=[ + ImageResource( + caption="gekke caption", + display_order=0, + code="hat", + original=File(self.image, name="hat.jpg"), + ), + ], + ), + ] + _, errors = products_to_db(product_resources) + self.assertEqual(len(errors), 0) + self.assertEqual(Product.objects.count(), 3) + + product_resource = ProductResource( + upc="hat", + title="hat", + slug="asdf-hat", + structure=Product.STANDALONE, + product_class=product_class, + ) + _, errors = products_to_db(product_resource, delete_related=True) + self.assertEqual(len(errors), 0) + + self.assertEqual(Product.objects.count(), 3) + prd = Product.objects.get(upc="hat") + self.assertEqual(prd.stockrecords.count(), 0) + self.assertEqual(prd.images.count(), 0) + # Other products' related models of stay unaffected + self.assertTrue(Stockrecord.objects.count(), 2) + self.assertTrue(ProductImage.objects.count(), 2) diff --git a/tests/reverse/test_reallifecase.py b/tests/reverse/test_reallifecase.py index 0a9afc8..f3e48ea 100644 --- a/tests/reverse/test_reallifecase.py +++ b/tests/reverse/test_reallifecase.py @@ -209,7 +209,8 @@ def test_mapping(self): product_resources = CSVProductMapping.apply(products) # Map the product resources to products and save in DB - products_to_db(product_resources) + _, errors = products_to_db(product_resources) + self.assertEqual(len(errors), 0) self.assertEqual(Product.objects.all().count(), 59) self.assertEqual(ProductAttributeValue.objects.all().count(), 257) diff --git a/tests/test_settings.py b/tests/test_settings.py index 750e3ad..7547b6f 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -61,6 +61,7 @@ "haystack", "treebeard", "django_tables2", + "sorl.thumbnail", # Oscar apps "oscar.config.Shop", "oscar.apps.analytics.apps.AnalyticsConfig",