Ocyan plugin Oscar Odin
=======================

The oscar odin plugin comes with some tools to create dynamic mappings.
A dynamic mapping can be edited from the wagtail admin, where rules
can be added or overriden.


Installation
-------------
Add `ocyan.plugin.oscar_odin` to your projects dependencies.

Describe how to install the plugin. 


About & Usage of Oscar Odin
---------------------------

How to make a dynamic mapping that can be edited in wagtail.
============================================================

Dynamic mappings can be used to change the behaviour of existing mapping
classes. This is most powerful if the mapping are not fully overwriting
the models that contains the saved data. For this reason we need to be
able to determine exactly which fields where impacted by the mapping.


Lets start with a basic mapping to the ProductResource.
We have to make sure to map any required field on the ProductModel

```
import odin
from oscar.core.loading import get_model, get_classes

Product = get_model("catalogue", "Product")
ProductClass = get_model("catalogue", "ProductClass")

ProductResource, ProductClassResource = get_classes(
    "oscar_odin.resources.catalogue", ["ProductResource", "ProductClassResource"]
)


class ExampleToProductResourceMapping(odin.Mapping):
    from_obj = ExampleProduct
    to_obj = ProductResource
    # make sure to have this set to False or this will be the only mapping you can have
    # for the ExampleProduct
    register_mapping = False
    mappings = (
        ("Code", None, "upc"),
        ("ID", None, "code"),
        ("StandardSalesPrice", None, "price"),
        ("Description", None, "title"),
    )

    @odin.map_field(from_field="Description", to_field="slug")
    def slug(self, value):
        return slugify(value)

    @odin.assign_field(to_field="partner")
    def partner(self):
        return self.context["partner"]

    @odin.assign_field(to_field="product_class")
    def product_class(self):
        return ProductClassResource(slug=self.context["product_class"].slug)

    @odin.assign_field(to_field="structure")
    def structure(self):
        return Product.STANDALONE
```

Now we can use this mapping in combination with ``MappingEditorViewSet``
to make it editable.

```
from wagtail.snippets.views.snippets import SnippetViewSetGroup
from wagtail.snippets.models import register_snippet
from oscar.core.loading import get_model
from ocyan.plugin.oscar_odin.wagtailadmin import MappingEditorViewSet

from .mappings import ExampleToProductResourceMapping

Product = get_model("catalogue", "Product")

@register_snippet
class ExampleGroup(SnippetViewSetGroup):
    menu_label = _("Example")
    menu_icon = "tigre"
    menu_order = 900
    items = (
        MappingEditorViewSet(
            mapping_class=ExampleToProductResourceMapping,
            # not really sure why I introduced the associated model ...
            associated_model=Product,
        ),
        # .. whatever other items you need
    )
```

Now you can add mappings from ExampleProduct to ProductResource with different kinds of
mappings from within wagtail.

To use the mapping with dynamic mapping rules in your code, it goes like this:

```
import requests
from django.contrib.contenttypes.models import ContentType
from oscar.core.loading import get_model, get_class
from ocyan.plugin.oscar_odin.models import MappingModel


Product = get_model("catalogue", "Product")

ct = ContentType.objects.get_for_model(Product)
mm, _ = MappingModel.objects.get_or_create(contenttype=ct)
mapper = mm.mapping_class(ExampleToProductResourceMapping)
```
Get the data you want mapped
```
data = requests.get("http://superapi.com/").json()
```
Convert the product data to a resource
```
from_resource = ExampleProduct.create_from_dict(data)
```
Map the resource to a ProductResource.
If your mapping needs some context, import it
```
from .context import get_product_context

context = get_product_context()
to_resource = mapper.apply(from_resource, context=context)
to_resource.full_clean()
```
Finally map to ``ProductResource``, which can easily be converted to ``Product``.

```
ProductResource = get_class("oscar_odin.resources.catalogue", "ProductResource")

to_resource = from_resource.convert_to(
    ProductResource, context=context
)
```
To save the ProductResource to the database, if we want partial update, only to update the mapped fields,
first we need to find out which fields where mapped by our dynamic mapper.
```
from oscar_odin.utils import get_mapped_fields

mapped_fields = get_mapped_fields(mapper)
```

Next we need to ask the mapper that maps to Product, what would be the ``fields_to_update``
for ``products_to_db`` so we can do the partial update
```
ProductToModel = get_class("oscar_odin.mappings.catalogue", "ProductToModel")

fields_to_update = ProductToModel.get_fields_impacted_by_mapping(
    *mapped_fields
)

result, errors = products_to_db(
    to_resource,
    fields_to_update=fields_to_update,
    clean_instances=False,
)
```

If you want you can have an alternative ``model_identifier_mapping``
```
from oscar_odin.mappings.constants import MODEL_IDENTIFIERS_MAPPING

EXACT_MODEL_IDENTIFIERS_MAPPING = {**MODEL_IDENTIFIERS_MAPPING, Product: ("code",)}

result, errors = products_to_db(
    to_resource,
    fields_to_update=fields_to_update,
    identifier_mapping=EXACT_MODEL_IDENTIFIERS_MAPPING,
    clean_instances=False,
)
```

So this is what you can do with any resource, just use resource_to_db the details will be alike.
But for ``Product`` there is one extra thing you can do in your mapping. If you want you
can have to user map field to product attributes. For this to work you have to use
a different baseclass for your mapping:
```
MappingModelProductAttributesMapping = get_class("oscar_odin.mappings.productattributes", "MappingModelProductAttributesMapping")

class ExampleToProductResourceMapping(MappingModelProductAttributesMapping):
    from_obj = ExampleProduct
    to_obj = ProductResource
    # make sure to have this set to False or this will be the only mapping you can have
    # for the ExampleProduct
    register_mapping = False
    ...
```
Next you must define the productclasses that you are using for your mapped models, so the
available attributes can be determined. You can just define it as a class variable:

```
class ExampleToProductResourceMapping(MappingModelProductAttributesMapping):
    product_classes = ProductClass.objects.filter(slug="WHATEVER_FILTER_I_NEED")
    ...
```

Register extra actions
======================

If you need more operations on you mapped fields, you can register them, and they will
appear in the action dropdown in wagtail. You map action by their module path.

```
from ocyan.plugin.oscar_odin import mapping_action_registry

mapping_action_registry.register(ExampleToProductResourceMapping, "the.module.path_to_my_action.name_of_action", "Really nice action")
```

Alternatively you can define actions on you mapping class as a class variable.

```
class ExampleToProductResourceMapping(MappingModelProductAttributesMapping):
  action_choices = [
    ("the.module.path_to_my_action.name_of_action", "Really nice action"),
    ("the.module.path_to_my_action.name_of_anotgher_action", "Bad bad action")
  ]
```

providing clean for the wagtail forms
=====================================

By defining a classmethod named ``clean_form`` on you mapping class you can provide a clean method to
you wagtail mapping forms

``MappingModelProductAttributesMapping`` allready has an implementation that can server as an example

```
class MappingModelProductAttributesMapping(MappingBase, metaclass=MappingMeta):
    "Baseclass for dynamic mappings that need to map to ProductResource.attributes"

    @classmethod
    def clean_form(cls, form):
        "Clean the FieldMappingModel form when aditing the mapping in wagtail"
        attributes = set(dict(cls.get_extra_to_obj_field_choices()))
        to_fields = form.cleaned_data["to_fields"]
        action = form.cleaned_data["action_importstring"]
        for to_obj in to_fields:
            if to_obj in attributes and action != cls.MAP_ATTRIBUTES_ACTION:
                raise ValidationError(
                    gettext(
                        "Action must be set to product attibute when selecting a product attribute"
                    )
                )
```