Repository: django-oscar/django-oscar

Issue:
Add SEO fields to product and category models and make editable in the dashboard
There are a few SEO components that we regularly find clients asking for the ability to control on product and category pages:

1. Meta title
2. Meta description
3. Slug

For products, slug, meta title and description are all auto-generated from the product title/description, with no option to override any of these. While this is fine a lot of the time, Oscar should provide the ability to override these.

For categories, meta title and description are also auto-generated from the product title/description, and there is no option to override.

I think we should have a "Search Engine Optimisation" tab on the dashboard edit views for both of these, which allow override of these parameters.


Discussion:
Hey @solarissmoke ,
Just wanted to confirm that we are talking about the `AbtractProduct` and `AbstractCategory` classes from `src/oscar/apps/catalogue/abstract_models.py` right?

Also, will this issue involve changes in the model and changes to the frontend (dashboard)?
@SameeranB yes, but note that there is [already a PR](https://github.com/django-oscar/django-oscar/pull/3522) for this feature under review.
Aah... that's great. No issues... I'll look for some other issue to resolve.

Fix patch:
diff --git a/src/oscar/apps/catalogue/abstract_models.py b/src/oscar/apps/catalogue/abstract_models.py
--- a/src/oscar/apps/catalogue/abstract_models.py
+++ b/src/oscar/apps/catalogue/abstract_models.py
@@ -14,6 +14,7 @@
 from django.db.models import Count, Exists, OuterRef, Sum
 from django.db.models.fields import Field
 from django.db.models.lookups import StartsWith
+from django.template.defaultfilters import striptags
 from django.urls import reverse
 from django.utils.functional import SimpleLazyObject, cached_property
 from django.utils.html import strip_tags
@@ -128,6 +129,8 @@ class AbstractCategory(MP_Node):
 
     name = models.CharField(_('Name'), max_length=255, db_index=True)
     description = models.TextField(_('Description'), blank=True)
+    meta_title = models.CharField(_('Meta title'), max_length=255, blank=True, null=True)
+    meta_description = models.TextField(_('Meta description'), blank=True, null=True)
     image = models.ImageField(_('Image'), upload_to='categories', blank=True,
                               null=True, max_length=255)
     slug = SlugField(_('Slug'), max_length=255, db_index=True)
@@ -235,6 +238,12 @@ def fix_tree(cls, destructive=False):
             else:
                 node.set_ancestors_are_public()
 
+    def get_meta_title(self):
+        return self.meta_title or self.name
+
+    def get_meta_description(self):
+        return self.meta_description or striptags(self.description)
+
     def get_ancestors_and_self(self):
         """
         Gets ancestors and includes itself. Use treebeard's get_ancestors
@@ -369,6 +378,8 @@ class AbstractProduct(models.Model):
                              max_length=255, blank=True)
     slug = models.SlugField(_('Slug'), max_length=255, unique=False)
     description = models.TextField(_('Description'), blank=True)
+    meta_title = models.CharField(_('Meta title'), max_length=255, blank=True, null=True)
+    meta_description = models.TextField(_('Meta description'), blank=True, null=True)
 
     #: "Kind" of product, e.g. T-Shirt, Book, etc.
     #: None for child products, they inherit their parent's product class
@@ -613,6 +624,20 @@ def get_title(self):
         return title
     get_title.short_description = pgettext_lazy("Product title", "Title")
 
+    def get_meta_title(self):
+        title = self.meta_title
+        if not title and self.is_child:
+            title = self.parent.meta_title
+        return title or self.get_title()
+    get_meta_title.short_description = pgettext_lazy("Product meta title", "Meta title")
+
+    def get_meta_description(self):
+        meta_description = self.meta_description
+        if not meta_description and self.is_child:
+            meta_description = self.parent.meta_description
+        return meta_description or striptags(self.description)
+    get_meta_description.short_description = pgettext_lazy("Product meta description", "Meta description")
+
     def get_product_class(self):
         """
         Return a product's item class. Child products inherit their parent's.
diff --git a/src/oscar/apps/catalogue/migrations/0021_auto_20201005_0844.py b/src/oscar/apps/catalogue/migrations/0021_auto_20201005_0844.py
new file mode 100644
--- /dev/null
+++ b/src/oscar/apps/catalogue/migrations/0021_auto_20201005_0844.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.0.10 on 2020-10-05 07:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0020_auto_20200801_0817'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='category',
+            name='meta_description',
+            field=models.TextField(blank=True, null=True, verbose_name='Meta description'),
+        ),
+        migrations.AddField(
+            model_name='category',
+            name='meta_title',
+            field=models.CharField(blank=True, null=True, max_length=255, verbose_name='Meta title'),
+        ),
+        migrations.AddField(
+            model_name='product',
+            name='meta_description',
+            field=models.TextField(blank=True, null=True, verbose_name='Meta description'),
+        ),
+        migrations.AddField(
+            model_name='product',
+            name='meta_title',
+            field=models.CharField(blank=True, null=True, max_length=255, verbose_name='Meta title'),
+        ),
+    ]
diff --git a/src/oscar/apps/dashboard/catalogue/forms.py b/src/oscar/apps/dashboard/catalogue/forms.py
--- a/src/oscar/apps/dashboard/catalogue/forms.py
+++ b/src/oscar/apps/dashboard/catalogue/forms.py
@@ -27,12 +27,25 @@
 
 BaseCategoryForm = movenodeform_factory(
     Category,
-    fields=['name', 'slug', 'description', 'image', 'is_public'],
-    exclude=['ancestors_are_public'])
+    fields=['name', 'slug', 'description', 'image', 'is_public', 'meta_title', 'meta_description'],
+    exclude=['ancestors_are_public'],
+    widgets={'meta_description': forms.Textarea(attrs={'class': 'no-widget-init'})})
 
 
-class CategoryForm(BaseCategoryForm):
+class SEOFormMixin:
+    seo_fields = ['meta_title', 'meta_description', 'slug']
 
+    def primary_form_fields(self):
+        return [field for field in self if not field.is_hidden and not self.is_seo_field(field)]
+
+    def seo_form_fields(self):
+        return [field for field in self if self.is_seo_field(field)]
+
+    def is_seo_field(self, field):
+        return field.name in self.seo_fields
+
+
+class CategoryForm(SEOFormMixin, BaseCategoryForm):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         if 'slug' in self.fields:
@@ -178,7 +191,7 @@ def _attr_image_field(attribute):
         label=attribute.name, required=attribute.required)
 
 
-class ProductForm(forms.ModelForm):
+class ProductForm(SEOFormMixin, forms.ModelForm):
     FIELD_FACTORIES = {
         "text": _attr_text_field,
         "richtext": _attr_textarea_field,
@@ -198,9 +211,11 @@ class ProductForm(forms.ModelForm):
     class Meta:
         model = Product
         fields = [
-            'title', 'upc', 'description', 'is_public', 'is_discountable', 'structure']
+            'title', 'upc', 'description', 'is_public', 'is_discountable', 'structure', 'slug', 'meta_title',
+            'meta_description']
         widgets = {
-            'structure': forms.HiddenInput()
+            'structure': forms.HiddenInput(),
+            'meta_description': forms.Textarea(attrs={'class': 'no-widget-init'})
         }
 
     def __init__(self, product_class, data=None, parent=None, *args, **kwargs):
@@ -220,6 +235,9 @@ def __init__(self, product_class, data=None, parent=None, *args, **kwargs):
             self.instance.product_class = product_class
         self.add_attribute_fields(product_class, self.instance.is_parent)
 
+        if 'slug' in self.fields:
+            self.fields['slug'].required = False
+            self.fields['slug'].help_text = _('Leave blank to generate from product title')
         if 'title' in self.fields:
             self.fields['title'].widget = forms.TextInput(
                 attrs={'autocomplete': 'off'})
diff --git a/src/oscar/apps/dashboard/catalogue/views.py b/src/oscar/apps/dashboard/catalogue/views.py
--- a/src/oscar/apps/dashboard/catalogue/views.py
+++ b/src/oscar/apps/dashboard/catalogue/views.py
@@ -628,6 +628,9 @@ def get_context_data(self, **kwargs):
 
     def get_success_url(self):
         messages.info(self.request, _("Category updated successfully"))
+        action = self.request.POST.get('action')
+        if action == 'continue':
+            return reverse('dashboard:catalogue-category-update', kwargs={"pk": self.object.id})
         return super().get_success_url()
 
 


Test patch:
diff --git a/tests/_site/apps/catalogue/migrations/0021_auto_20201005_0844.py b/tests/_site/apps/catalogue/migrations/0021_auto_20201005_0844.py
new file mode 100644
--- /dev/null
+++ b/tests/_site/apps/catalogue/migrations/0021_auto_20201005_0844.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.0.10 on 2020-10-05 07:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0020_auto_20200801_0817'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='category',
+            name='meta_description',
+            field=models.TextField(blank=True, null=True, verbose_name='Meta description'),
+        ),
+        migrations.AddField(
+            model_name='category',
+            name='meta_title',
+            field=models.CharField(blank=True, null=True, max_length=255, verbose_name='Meta title'),
+        ),
+        migrations.AddField(
+            model_name='product',
+            name='meta_description',
+            field=models.TextField(blank=True, null=True, verbose_name='Meta description'),
+        ),
+        migrations.AddField(
+            model_name='product',
+            name='meta_title',
+            field=models.CharField(blank=True, null=True, max_length=255, verbose_name='Meta title'),
+        ),
+    ]
diff --git a/tests/unit/catalogue/test_models.py b/tests/unit/catalogue/test_models.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/catalogue/test_models.py
@@ -0,0 +1,40 @@
+from django.test import TestCase
+
+from oscar.apps.catalogue.models import Product
+from oscar.test.factories.catalogue import ProductFactory
+
+
+class ProductTestCase(TestCase):
+
+    @staticmethod
+    def _get_saved(model_obj):
+        model_obj.save()
+        model_obj.refresh_from_db()
+        return model_obj
+
+    def test_get_meta_title(self):
+        parent_title, child_title = "P title", "C title"
+        parent_meta_title, child_meta_title = "P meta title", "C meta title"
+        parent_product = ProductFactory(structure=Product.PARENT, title=parent_title, meta_title=parent_meta_title)
+        child_product = ProductFactory(structure=Product.CHILD, title=child_title, meta_title=child_meta_title,
+                                       parent=parent_product)
+        self.assertEqual(child_product.get_meta_title(), child_meta_title)
+        child_product.meta_title = ""
+        self.assertEqual(self._get_saved(child_product).get_meta_title(), parent_meta_title)
+        parent_product.meta_title = ""
+        child_product.parent = self._get_saved(parent_product)
+        self.assertEqual(self._get_saved(child_product).get_meta_title(), child_title)
+
+    def test_get_meta_description(self):
+        parent_description, child_description = "P description", "C description"
+        parent_meta_description, child_meta_description = "P meta description", "C meta description"
+        parent_product = ProductFactory(structure=Product.PARENT, description=parent_description,
+                                        meta_description=parent_meta_description)
+        child_product = ProductFactory(structure=Product.CHILD, description=child_description,
+                                       meta_description=child_meta_description, parent=parent_product)
+        self.assertEqual(child_product.get_meta_description(), child_meta_description)
+        child_product.meta_description = ""
+        self.assertEqual(self._get_saved(child_product).get_meta_description(), parent_meta_description)
+        parent_product.meta_description = ""
+        child_product.parent = self._get_saved(parent_product)
+        self.assertEqual(self._get_saved(child_product).get_meta_description(), child_description)