Repository: django-oscar/django-oscar

Issue:
Load customer.history with get_class
### Issue Summary

All functions of customer.history are loaded by using direct import instead of `get_class`, making it very impractical to override.

### Steps to Reproduce

Use cases are for example if you want to change how the object are loaded when getting recently viewed products (I wanted to use search backend instead of DB), or if you want to change some properties of the oscar_history cookie (I wanted to set `samesite` to Lax). Not all use cases can be anticipated, hence this is a good candidate for a `get_class` encapsulation. I think, contrarily to its name, that `get_class` also works for module import, but history module could also be converted to a class with static methods, what would you prefer? I can do the pull request then.

### Technical details

* Python version: 3.9.1
* Django version: 3.1.6
* Oscar version: 3.0.0



Discussion:
Thanks @Chadys - this should definitely be loaded dynamically. As you note `get_class` does in fact work with modules, but I think moving these functions to a class with static methods would be helpful and more extensible in future. PR would be appreciated.

Resolving patch:
diff --git a/src/oscar/apps/customer/history.py b/src/oscar/apps/customer/history.py
--- a/src/oscar/apps/customer/history.py
+++ b/src/oscar/apps/customer/history.py
@@ -7,61 +7,67 @@
 Product = get_model('catalogue', 'Product')
 
 
-def get(request):
-    """
-    Return a list of recently viewed products
-    """
-    ids = extract(request)
-
-    # Reordering as the ID order gets messed up in the query
-    product_dict = Product.objects.browsable().in_bulk(ids)
-    ids.reverse()
-    return [product_dict[id] for id in ids if id in product_dict]
-
-
-def extract(request, response=None):
-    """
-    Extract the IDs of products in the history cookie
-    """
-    ids = []
+class CustomerHistoryManager:
     cookie_name = settings.OSCAR_RECENTLY_VIEWED_COOKIE_NAME
-    if cookie_name in request.COOKIES:
-        try:
-            ids = json.loads(request.COOKIES[cookie_name])
-        except ValueError:
-            # This can occur if something messes up the cookie
-            if response:
-                response.delete_cookie(cookie_name)
-        else:
-            # Badly written web crawlers send garbage in double quotes
-            if not isinstance(ids, list):
-                ids = []
-    return ids
+    cookie_kwargs = {
+        'max_age': settings.OSCAR_RECENTLY_VIEWED_COOKIE_LIFETIME,
+        'secure': settings.OSCAR_RECENTLY_VIEWED_COOKIE_SECURE,
+        'httponly': True,
+    }
+    max_products = settings.OSCAR_RECENTLY_VIEWED_PRODUCTS
 
+    @classmethod
+    def get(cls, request):
+        """
+        Return a list of recently viewed products
+        """
+        ids = cls.extract(request)
 
-def add(ids, new_id):
-    """
-    Add a new product ID to the list of product IDs
-    """
-    max_products = settings.OSCAR_RECENTLY_VIEWED_PRODUCTS
-    if new_id in ids:
-        ids.remove(new_id)
-    ids.append(new_id)
-    if (len(ids) > max_products):
-        ids = ids[len(ids) - max_products:]
-    return ids
+        # Reordering as the ID order gets messed up in the query
+        product_dict = Product.objects.browsable().in_bulk(ids)
+        ids.reverse()
+        return [product_dict[product_id] for product_id in ids if product_id in product_dict]
+
+    @classmethod
+    def extract(cls, request, response=None):
+        """
+        Extract the IDs of products in the history cookie
+        """
+        ids = []
+        if cls.cookie_name in request.COOKIES:
+            try:
+                ids = json.loads(request.COOKIES[cls.cookie_name])
+            except ValueError:
+                # This can occur if something messes up the cookie
+                if response:
+                    response.delete_cookie(cls.cookie_name)
+            else:
+                # Badly written web crawlers send garbage in double quotes
+                if not isinstance(ids, list):
+                    ids = []
+        return ids
 
+    @classmethod
+    def add(cls, ids, new_id):
+        """
+        Add a new product ID to the list of product IDs
+        """
+        if new_id in ids:
+            ids.remove(new_id)
+        ids.append(new_id)
+        if len(ids) > cls.max_products:
+            ids = ids[len(ids) - cls.max_products:]
+        return ids
 
-def update(product, request, response):
-    """
-    Updates the cookies that store the recently viewed products
-    removing possible duplicates.
-    """
-    ids = extract(request, response)
-    updated_ids = add(ids, product.id)
-    response.set_cookie(
-        settings.OSCAR_RECENTLY_VIEWED_COOKIE_NAME,
-        json.dumps(updated_ids),
-        max_age=settings.OSCAR_RECENTLY_VIEWED_COOKIE_LIFETIME,
-        secure=settings.OSCAR_RECENTLY_VIEWED_COOKIE_SECURE,
-        httponly=True)
+    @classmethod
+    def update(cls, product, request, response):
+        """
+        Updates the cookies that store the recently viewed products
+        removing possible duplicates.
+        """
+        ids = cls.extract(request, response)
+        updated_ids = cls.add(ids, product.id)
+        response.set_cookie(
+            cls.cookie_name,
+            json.dumps(updated_ids),
+            **cls.cookie_kwargs)
diff --git a/src/oscar/apps/customer/receivers.py b/src/oscar/apps/customer/receivers.py
--- a/src/oscar/apps/customer/receivers.py
+++ b/src/oscar/apps/customer/receivers.py
@@ -1,8 +1,9 @@
 from django.dispatch import receiver
 
 from oscar.apps.catalogue.signals import product_viewed
+from oscar.core.loading import get_class
 
-from . import history
+CustomerHistoryManager = get_class('customer.history', 'CustomerHistoryManager')
 
 
 @receiver(product_viewed)
@@ -12,4 +13,4 @@ def receive_product_view(sender, product, user, request, response, **kwargs):
 
     Requires the request and response objects due to dependence on cookies
     """
-    return history.update(product, request, response)
+    return CustomerHistoryManager.update(product, request, response)
diff --git a/src/oscar/templatetags/history_tags.py b/src/oscar/templatetags/history_tags.py
--- a/src/oscar/templatetags/history_tags.py
+++ b/src/oscar/templatetags/history_tags.py
@@ -4,10 +4,10 @@
 from django.urls import Resolver404, resolve
 from django.utils.translation import gettext_lazy as _
 
-from oscar.apps.customer import history
-from oscar.core.loading import get_model
+from oscar.core.loading import get_class, get_model
 
 Site = get_model('sites', 'Site')
+CustomerHistoryManager = get_class('customer.history', 'CustomerHistoryManager')
 
 register = template.Library()
 
@@ -19,7 +19,7 @@ def recently_viewed_products(context, current_product=None):
     Inclusion tag listing the most recently viewed products
     """
     request = context['request']
-    products = history.get(request)
+    products = CustomerHistoryManager.get(request)
     if current_product:
         products = [p for p in products if p != current_product]
     return {'products': products,


Test patch:
diff --git a/tests/functional/customer/test_history.py b/tests/functional/customer/test_history.py
--- a/tests/functional/customer/test_history.py
+++ b/tests/functional/customer/test_history.py
@@ -4,12 +4,13 @@
 from django.http import HttpRequest
 from django.urls import reverse
 
-from oscar.apps.customer import history
 from oscar.core.compat import get_user_model
+from oscar.core.loading import get_class
 from oscar.templatetags.history_tags import get_back_button
 from oscar.test.factories import create_product
 from oscar.test.testcases import WebTestCase
 
+CustomerHistoryManager = get_class('customer.history', 'CustomerHistoryManager')
 User = get_user_model()
 COOKIE_NAME = settings.OSCAR_RECENTLY_VIEWED_COOKIE_NAME
 
@@ -27,7 +28,7 @@ def test_id_gets_added_to_cookie(self):
         response = self.app.get(self.product.get_absolute_url())
         request = HttpRequest()
         request.COOKIES[COOKIE_NAME] = _unquote(response.test_app.cookies[COOKIE_NAME])
-        self.assertTrue(self.product.id in history.extract(request))
+        self.assertTrue(self.product.id in CustomerHistoryManager.extract(request))
 
     def test_get_back_button(self):
         request = HttpRequest()
diff --git a/tests/integration/customer/test_history.py b/tests/integration/customer/test_history.py
--- a/tests/integration/customer/test_history.py
+++ b/tests/integration/customer/test_history.py
@@ -1,7 +1,9 @@
 from django import http
 from django.test import TestCase
 
-from oscar.apps.customer import history
+from oscar.core.loading import get_class
+
+CustomerHistoryManager = get_class('customer.history', 'CustomerHistoryManager')
 
 
 class TestProductHistory(TestCase):
@@ -11,5 +13,5 @@ def setUp(self):
         self.response = http.HttpResponse()
 
     def test_starts_with_empty_list(self):
-        products = history.get(self.request)
+        products = CustomerHistoryManager.get(self.request)
         self.assertEqual([], products)