3

I have a project based on django-oscar (and django-cms), which runs on multiple domains with different SITE_IDs using the django.contrib.sites module. The project is already productive and i can't change the category slugs anymore - Also switching the entire code to Nested Set Trees or Adjacent Trees is something i wish to avoid - for your better understanding: The initial requirements did not want a different category sorting for each domain, so i just used Oscars default category implementation.

But since Oscars category model/manager is based on django-treebeards materialized path tree implementation, i have to consider a few differences to the usual Django-way of changing the default ordering.

Given these two extra fields in the model (which inherits from django-oscars AbstractCategory)

class Category(AbstractCategory):
    # ...
    order_site_1 = models.IntegerField(
        null=False,
        default=0
    )
    order_site_2 = models.IntegerField(
        null=False,
        default=0
    )

I can't simply add the ordering to the meta class like:

class Category(AbstractCategory):
    class Meta:
        ordering = ['depth', '-order_site_{}'.format(Site.objects.get_current().id), 'name']

First, it will ignore this directive since treebeards MP_NodeManager.get_queryset() does not respect custom sorting - for the MP_Tree logic it has to rely on a sorting generated at insertion time (which is stored in path), so this approach would defy the purpose of MP_Tree itself.

I also had a look at node_order_by - but as stated in the docs:

Incorrect ordering of nodes when node_order_by is enabled. Ordering is enforced on node insertion, so if an attribute in node_order_by is modified after the node is inserted, the tree ordering will be inconsistent.

But again on multiple domains with different category ordering this is of no use. The only way left which i can think of is to override the MP_NodeManager class:

class CustomOrderQuerySet(MP_NodeManager):
    def get_queryset(self):
        sort_key = '-order_site_{}'.format(Site.objects.get_current().id)
        return MP_NodeQuerySet(self.model).order_by('depth', sort_key, 'name')

But this approach is clearly wrong, because MP_Tree relies on path sorting to generate a proper Tree - what i thought about is iterating over all entries, compare the path, ignore the last part and sort according to order_site_{id} and re-convert it to a QuerySet.

I am not even sure if that is possible, and i want to avoid to go there, so my question is: Is there a way to reflect this logic within a chain of ORM statements, so that i can continue using a native QuerySet?

wiesion
  • 2,349
  • 12
  • 21
  • 1
    I don't really have a solution for you, but just want to say that the whole point of using a materialised path is to make querying efficient. If you end up overriding the queryset/ordering, you will lose that efficiency and it will quickly become very expensive to do basic queries (e.g., fetch a category and its ancestors). This is already somewhat inefficient in Oscar, and would be made a whole lot worse. – solarissmoke Jun 26 '18 at 04:11
  • I absolutely agree on what you wrote, but as i said, changing the category system now would imply a lot of work, especially creating redirects for each of the MP ones to the new NS/AS ones is something that i wish to avoid, so i was wondering... Other than that, do you have a suggestion on how to replace the category system? – wiesion Jun 26 '18 at 08:08
  • How many sites/domains are you running on the same database? – solarissmoke Jun 26 '18 at 09:07
  • Currently only 2 and there are no plans for a third or fourth one, so currently there is no need to be fully dynamic with `SITE_ID` – wiesion Jun 26 '18 at 09:49
  • 1
    You might consider maintaining two separate category trees then, one for each site. Then you would need to override some of the view logic to determine which tree to use for each site, but you don't have to mess around with paths for individual categories. – solarissmoke Jun 27 '18 at 03:19
  • That seems reasonable, so basically i need to add a `site_id` to the category model, on import i do a double insertion (for each loop i use the respective `order_site_id`, add a custom getter on the views accessing categories? If you care to write an answer elaborating on these steps, i will accept it - otherwise i'm starting now and would add that answer later on my own. – wiesion Jun 27 '18 at 09:24
  • I'd suggest you add an answer once you've worked through it - the reason is that there are probably a few more hoops that you will need to jump to get it all working, and your answer will be more complete if you describe those! – solarissmoke Jun 27 '18 at 12:33
  • @solarissmoke - thanks for the help, i think the way i solved it now is somewhat of a trade-off, but all in all it is still better to rewrite all of MP_Tree to behave completely different thatn its core principle. – wiesion Jun 29 '18 at 17:46

1 Answers1

2

So this issue got me quite some headache initially - i was trying different approaches and not a single one was satisfactory - trying to make a dynamically sorted MP_Tree is something i would discourage heavily at this point, it is too much of a mess. Better to stick with ordering created at insertion time and work with all the variables during that stage. Also my thanks go to @solarissmoke for the provided help in the comments.

 Models

class Product(AbstractProduct):
    # ...
    @property
    def site_categories(self):
        return self.categories.filter(django_site_id=settings.SITE_ID)

    @property
    def first_category(self):
        if not self.site_categories:
            return None
        return self.site_categories[0]

class Category(AbstractCategory):
    # ...
    django_site = models.ForeignKey(Site, null=False)
    site_rank = models.IntegerField(_('Site Rank'), null=False, default=0)
    node_order_by = ['django_site_id', 'site_rank', 'name']

Templatetags

Copy templatetags/category_tags#get_annotated_list to project and modify it slightly:

# ...
start_depth, prev_depth = (None, None)
if parent:
    categories = parent.get_descendants()
    if max_depth is not None:
        max_depth += parent.get_depth()
else:
    categories = Category.get_tree()

# Just add this line
categories = categories.filter(django_site_id=settings.SITE_ID)
# ...

Templates

For templates/catalogue/detail.html: Replace {% with category=product.categories.all.0 %} with {% with category=product.first_category %}

Admin

You will need to make changes on the views, forms and formsets within apps.dashboard to take care of the newly added django_site and site_rank - i left this out because in my case all categories are defined through a regularly imported CSV. This should be not a big deal to get done - maybe later i will do this and would update this answer.

Creating MP Nodes

Whenever you are adding root, child, or sibling nodes, you now need to pass in the django_site_id parameter (along with all other required values contained in node_order_by), for instance:

parent_category.add_child(
    django_site_id=settings.SITE_ID, site_rank=10, name='Child 1')
Category.add_root(
    django_site_id=settings.SITE_ID, site_rank=1, name='Root 1')
Community
  • 1
  • 1
wiesion
  • 2,349
  • 12
  • 21