diff --git a/docs/topics/api/hero.rst b/docs/topics/api/hero.rst index 6f7c24d3a0..994b88a708 100644 --- a/docs/topics/api/hero.rst +++ b/docs/topics/api/hero.rst @@ -67,3 +67,9 @@ small number of shelves - and likely only one - this endpoint is not paginated. :>json object|null cta: The optional call to action link and text to be displayed with the item. :>json string cta.url: The url the call to action would link to. :>json string cta.text: The call to action text. + :>json array modules: The modules for this shelf. Should always be 3. + :>json string modules[].icon: The icon used to illustrate the item. + :>json string modules[].description: The description for this item. + :>json object|null modules[].cta: The optional call to action link and text to be displayed with the item. + :>json string modules[].cta.url: The url the call to action would link to. + :>json string modules[].cta.text: The call to action text. diff --git a/docs/topics/api/overview.rst b/docs/topics/api/overview.rst index d5d1fd8109..02267e5f0d 100644 --- a/docs/topics/api/overview.rst +++ b/docs/topics/api/overview.rst @@ -335,6 +335,7 @@ v4 API changelog * 2019-08-08: removed ``heading_text`` property from discovery api. https://github.com/mozilla/addons-server/issues/11817 * 2019-08-08: add secondary shelf to /hero/ endpoint. https://github.com/mozilla/addons-server/issues/11779 * 2019-08-15: dropped support for LWT specific statuses. +* 2019-08-15: added promo modules to secondary hero shelves. https://github.com/mozilla/addons-server/issues/11780 ---------------- v5 API changelog diff --git a/src/olympia/constants/permissions.py b/src/olympia/constants/permissions.py index 15797d706d..419516f24c 100644 --- a/src/olympia/constants/permissions.py +++ b/src/olympia/constants/permissions.py @@ -123,6 +123,9 @@ DJANGO_PERMISSIONS_MAPPING.update({ 'hero.add_primaryhero': DISCOVERY_EDIT, 'hero.change_primaryhero': DISCOVERY_EDIT, 'hero.delete_primaryhero': DISCOVERY_EDIT, + 'hero.add_secondaryheromodule': DISCOVERY_EDIT, + 'hero.change_secondaryheromodule': DISCOVERY_EDIT, + 'hero.delete_secondaryheromodule': DISCOVERY_EDIT, 'reviewers.delete_reviewerscore': ADMIN_ADVANCED, diff --git a/src/olympia/discovery/tests/test_admin.py b/src/olympia/discovery/tests/test_admin.py index 2f42dec84b..dfeafb3b00 100644 --- a/src/olympia/discovery/tests/test_admin.py +++ b/src/olympia/discovery/tests/test_admin.py @@ -2,7 +2,7 @@ from olympia.amo.tests import TestCase, addon_factory, user_factory from olympia.amo.urlresolvers import django_reverse, reverse from olympia.discovery.models import DiscoveryItem -from olympia.hero.models import PrimaryHero, SecondaryHero +from olympia.hero.models import PrimaryHero, SecondaryHero, SecondaryHeroModule class TestDiscoveryAdmin(TestCase): @@ -463,6 +463,29 @@ class TestSecondaryHeroShelfAdmin(TestCase): 'admin:discovery_secondaryheroshelf_changelist') self.detail_url_name = 'admin:discovery_secondaryheroshelf_change' + def _get_moduleform(self, item, module_data, initial=0): + count = str(len(module_data)) + out = { + "modules-TOTAL_FORMS": count, + "modules-INITIAL_FORMS": initial, + "modules-MIN_NUM_FORMS": count, + "modules-MAX_NUM_FORMS": count, + "modules-__prefix__-icon": "", + "modules-__prefix__-description": "", + "modules-__prefix__-id": "", + "modules-__prefix__-shelf": str(item), + } + for index in range(0, len(module_data)): + out.update(**{ + f"modules-{index}-icon": str(module_data[index]['icon']), + f"modules-{index}-description": str( + module_data[index]['description']), + f"modules-{index}-id": str(module_data[index].get('id', '')), + f"modules-{index}-shelf": str(item), + + }) + return out + def test_can_see_secondary_hero_module_in_admin_with_discovery_edit(self): user = user_factory() self.grant_permission(user, 'Admin:Tools') @@ -490,6 +513,11 @@ class TestSecondaryHeroShelfAdmin(TestCase): def test_can_edit_with_discovery_edit_permission(self): item = SecondaryHero.objects.create(headline='BarFöo') + modules = [ + SecondaryHeroModule.objects.create(shelf=item), + SecondaryHeroModule.objects.create(shelf=item), + SecondaryHeroModule.objects.create(shelf=item), + ] detail_url = reverse(self.detail_url_name, args=(item.pk,)) user = user_factory() self.grant_permission(user, 'Admin:Tools') @@ -500,16 +528,42 @@ class TestSecondaryHeroShelfAdmin(TestCase): content = response.content.decode('utf-8') assert 'BarFöo' in content + shelves = [ + { + 'id': modules[0].id, + 'description': 'foo', + 'icon': 'Audio.svg' + }, + { + 'id': modules[1].id, + 'description': 'baa', + 'icon': 'Developer.svg', + }, + { + 'id': modules[2].id, + 'description': 'ugh', + 'icon': 'Extensions.svg', + }, + ] response = self.client.post( - detail_url, { + detail_url, + dict(self._get_moduleform(item.id, shelves, initial=3), **{ 'headline': 'This headline is ... something.', 'description': 'This description is as well!', - }, follow=True) + }), follow=True) assert response.status_code == 200 + assert 'errors' not in response.context_data, ( + response.context_data['errors']) item.reload() assert SecondaryHero.objects.count() == 1 assert item.headline == 'This headline is ... something.' assert item.description == 'This description is as well!' + assert SecondaryHeroModule.objects.count() == 3 + (module.reload() for module in modules) + module_values = list( + SecondaryHeroModule.objects.all().values( + 'id', 'description', 'icon')) + assert module_values == shelves def test_can_delete_with_discovery_edit_permission(self): item = SecondaryHero.objects.create() @@ -528,10 +582,11 @@ class TestSecondaryHeroShelfAdmin(TestCase): # Can actually delete. response = self.client.post( delete_url, - {'post': 'yes'}, + dict(self._get_moduleform(item.pk, {}), post='yes'), follow=True) assert response.status_code == 200 assert not SecondaryHero.objects.filter(pk=item.pk).exists() + assert not SecondaryHeroModule.objects.filter(shelf=item.pk).exists() def test_can_add_with_discovery_edit_permission(self): add_url = reverse('admin:discovery_secondaryheroshelf_add') @@ -542,17 +597,38 @@ class TestSecondaryHeroShelfAdmin(TestCase): response = self.client.get(add_url, follow=True) assert response.status_code == 200 assert SecondaryHero.objects.count() == 0 + assert SecondaryHeroModule.objects.count() == 0 + shelves = [ + { + 'description': 'foo', + 'icon': 'Audio.svg' + }, + { + 'description': 'baa', + 'icon': 'Developer.svg', + }, + { + 'description': 'ugh', + 'icon': 'Extensions.svg', + }, + ] response = self.client.post( - add_url, { + add_url, + dict(self._get_moduleform('', shelves), **{ 'headline': 'This headline is ... something.', 'description': 'This description is as well!', - }, + }), follow=True) assert response.status_code == 200 + assert 'errors' not in response.context_data assert SecondaryHero.objects.count() == 1 item = SecondaryHero.objects.get() assert item.headline == 'This headline is ... something.' assert item.description == 'This description is as well!' + assert SecondaryHeroModule.objects.count() == 3 + module_values = list( + SecondaryHeroModule.objects.all().values('description', 'icon')) + assert module_values == shelves def test_can_not_add_without_discovery_edit_permission(self): add_url = reverse('admin:discovery_secondaryheroshelf_add') @@ -610,3 +686,24 @@ class TestSecondaryHeroShelfAdmin(TestCase): delete_url, data={'post': 'yes'}, follow=True) assert response.status_code == 403 assert SecondaryHero.objects.filter(pk=item.pk).exists() + + def test_need_3_modules(self): + add_url = reverse('admin:discovery_secondaryheroshelf_add') + user = user_factory() + self.grant_permission(user, 'Admin:Tools') + self.grant_permission(user, 'Discovery:Edit') + self.client.login(email=user.email) + response = self.client.get(add_url, follow=True) + assert response.status_code == 200 + assert SecondaryHero.objects.count() == 0 + response = self.client.post( + add_url, + dict(self._get_moduleform('', {}), **{ + 'headline': 'This headline is ... something.', + 'description': 'This description is as well!', + }), + follow=True) + assert response.status_code == 200 + assert 'There must be exactly 3 modules in this shelf.' in ( + response.context_data['errors']) + assert SecondaryHero.objects.count() == 0 diff --git a/src/olympia/hero/admin.py b/src/olympia/hero/admin.py index 7aa375be49..60ae5a1a41 100644 --- a/src/olympia/hero/admin.py +++ b/src/olympia/hero/admin.py @@ -1,13 +1,38 @@ from django.contrib import admin +from django.core.exceptions import ValidationError +from django.forms import BaseInlineFormSet -from .models import PrimaryHero +from .models import PrimaryHero, SecondaryHeroModule class PrimaryHeroInline(admin.StackedInline): model = PrimaryHero fields = ('image', 'gradient_color', 'is_external', 'enabled') + view_on_site = False + + +class HeroModuleInlineFormSet(BaseInlineFormSet): + def clean(self): + super().clean() + if len(self.forms) != 3: + raise ValidationError( + 'There must be exactly 3 modules in this shelf.') + + +class SecondaryHeroModuleInline(admin.StackedInline): + model = SecondaryHeroModule + view_on_site = False + max_num = 3 + min_num = 3 + can_delete = False + formset = HeroModuleInlineFormSet class SecondaryHeroAdmin(admin.ModelAdmin): - list_display = ('__str__', 'headline') + class Media: + css = { + 'all': ('css/admin/discovery.css',) + } + list_display = ('headline', 'description', 'enabled') + inlines = [SecondaryHeroModuleInline] view_on_site = False diff --git a/src/olympia/hero/models.py b/src/olympia/hero/models.py index 947d2d306d..d203e8ab96 100644 --- a/src/olympia/hero/models.py +++ b/src/olympia/hero/models.py @@ -17,6 +17,12 @@ GRADIENT_CHOICES = ( ('#712290', 'PURPLE70'), ('#582ACB', 'VIOLET70'), ) +FEATURED_IMAGE_PATH = os.path.join( + settings.ROOT, 'static', 'img', 'hero', 'featured') +MODULE_ICON_PATH = os.path.join( + settings.ROOT, 'static', 'img', 'hero', 'icons') +FEATURED_IMAGE_URL = f'{settings.STATIC_URL}img/hero/featured/' +MODULE_ICON_URL = f'{settings.STATIC_URL}img/hero/icons/' class GradientChoiceWidget(RadioSelect): @@ -35,19 +41,26 @@ class GradientChoiceWidget(RadioSelect): class ImageChoiceWidget(RadioSelect): option_template_name = 'hero/image_option.html' option_inherits_attrs = True + image_url_base = FEATURED_IMAGE_URL def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): - attrs['image_url'] = f'{settings.STATIC_URL}img/hero/featured/{value}' + attrs['image_url'] = f'{self.image_url_base}{value}' return super().create_option( name=name, value=value, label=label, selected=selected, index=index, subindex=subindex, attrs=attrs) -class FeaturedImageChoices: +class IconChoiceWidget(ImageChoiceWidget): + image_url_base = MODULE_ICON_URL + + +class DirImageChoices: + def __init__(self, path): + self.path = path + def __iter__(self): - path = os.path.join(settings.ROOT, 'static', 'img', 'hero', 'featured') - self.os_iter = os.scandir(path) + self.os_iter = os.scandir(self.path) return self def __next__(self): @@ -68,7 +81,7 @@ class WidgetCharField(models.CharField): class PrimaryHero(ModelBase): image = WidgetCharField( - choices=FeaturedImageChoices(), + choices=DirImageChoices(path=FEATURED_IMAGE_PATH), max_length=255, widget=ImageChoiceWidget) gradient_color = WidgetCharField( choices=GRADIENT_CHOICES, max_length=7, widget=GradientChoiceWidget) @@ -81,8 +94,8 @@ class PrimaryHero(ModelBase): return str(self.disco_addon) @property - def image_path(self): - return f'{settings.STATIC_URL}img/hero/featured/{self.image}' + def image_url(self): + return f'{FEATURED_IMAGE_URL}{self.image}' @property def gradient(self): @@ -103,7 +116,18 @@ class PrimaryHero(ModelBase): 'primary shelves.') -class SecondaryHero(ModelBase): +class CTACheckMixin(): + + def clean(self): + super().clean() + both_or_neither = not (bool(self.cta_text) ^ bool(self.cta_url)) + if getattr(self, 'enabled', True) and not both_or_neither: + raise ValidationError( + 'Both the call to action URL and text must be defined, or ' + 'neither, for enabled shelves.') + + +class SecondaryHero(CTACheckMixin, ModelBase): headline = models.CharField(max_length=50, blank=False) description = models.CharField(max_length=100, blank=False) cta_url = models.CharField(max_length=255, blank=True) @@ -113,9 +137,22 @@ class SecondaryHero(ModelBase): def __str__(self): return str(self.headline) - def clean(self): - both_or_neither = not (bool(self.cta_text) ^ bool(self.cta_url)) - if self.enabled and not both_or_neither: - raise ValidationError( - 'Both the call to action URL and text must be defined, or ' - 'neither, for enabled shelves.') + +class SecondaryHeroModule(CTACheckMixin, ModelBase): + icon = WidgetCharField( + choices=DirImageChoices(path=MODULE_ICON_PATH), + max_length=255, widget=IconChoiceWidget) + description = models.CharField(max_length=50, blank=False) + cta_url = models.CharField(max_length=255, blank=True) + cta_text = models.CharField(max_length=20, blank=True) + shelf = models.ForeignKey( + SecondaryHero, on_delete=models.CASCADE, + related_name='modules' + ) + + def __str__(self): + return str(self.description) + + @property + def icon_url(self): + return f'{MODULE_ICON_URL}{self.icon}' diff --git a/src/olympia/hero/serializers.py b/src/olympia/hero/serializers.py index d3822550d7..cdb0d24adb 100644 --- a/src/olympia/hero/serializers.py +++ b/src/olympia/hero/serializers.py @@ -5,7 +5,7 @@ from olympia.addons.serializers import AddonSerializer from olympia.amo.templatetags.jinja_helpers import absolutify from olympia.discovery.serializers import DiscoveryAddonSerializer -from .models import PrimaryHero, SecondaryHero +from .models import PrimaryHero, SecondaryHero, SecondaryHeroModule class ExternalAddonSerializer(AddonSerializer): @@ -16,7 +16,7 @@ class ExternalAddonSerializer(AddonSerializer): class PrimaryHeroShelfSerializer(serializers.ModelSerializer): description = serializers.CharField(source='disco_addon.description') - featured_image = serializers.SerializerMethodField() + featured_image = serializers.CharField(source='image_url') addon = DiscoveryAddonSerializer(source='disco_addon.addon') external = ExternalAddonSerializer(source='disco_addon.addon') @@ -30,16 +30,8 @@ class PrimaryHeroShelfSerializer(serializers.ModelSerializer): rep.pop('addon' if instance.is_external else 'external') return rep - def get_featured_image(self, obj): - return absolutify(obj.image_path) - -class SecondaryHeroShelfSerializer(serializers.ModelSerializer): - cta = serializers.SerializerMethodField() - - class Meta: - model = SecondaryHero - fields = ('headline', 'description', 'cta') +class CTAMixin(): def get_cta(self, obj): if obj.cta_url and obj.cta_text: @@ -49,3 +41,22 @@ class SecondaryHeroShelfSerializer(serializers.ModelSerializer): } else: return None + + +class SecondaryHeroShelfModuleSerializer(CTAMixin, + serializers.ModelSerializer): + cta = serializers.SerializerMethodField() + icon = serializers.CharField(source='icon_url') + + class Meta: + model = SecondaryHeroModule + fields = ('icon', 'description', 'cta') + + +class SecondaryHeroShelfSerializer(CTAMixin, serializers.ModelSerializer): + cta = serializers.SerializerMethodField() + modules = SecondaryHeroShelfModuleSerializer(many=True) + + class Meta: + model = SecondaryHero + fields = ('headline', 'description', 'cta', 'modules') diff --git a/src/olympia/hero/templates/hero/image_option.html b/src/olympia/hero/templates/hero/image_option.html index 09794f46ac..927360a8a9 100644 --- a/src/olympia/hero/templates/hero/image_option.html +++ b/src/olympia/hero/templates/hero/image_option.html @@ -1,2 +1,2 @@ -
+
{% include "django/forms/widgets/radio_option.html" %}
diff --git a/src/olympia/hero/tests/test_models.py b/src/olympia/hero/tests/test_models.py index 3108689c1b..99be76418d 100644 --- a/src/olympia/hero/tests/test_models.py +++ b/src/olympia/hero/tests/test_models.py @@ -1,16 +1,16 @@ from django.core.exceptions import ValidationError from olympia.amo.tests import addon_factory, TestCase -from olympia.hero.models import PrimaryHero, SecondaryHero +from olympia.hero.models import PrimaryHero, SecondaryHero, SecondaryHeroModule from olympia.discovery.models import DiscoveryItem class TestPrimaryHero(TestCase): - def test_image_path(self): + def test_image_url(self): ph = PrimaryHero.objects.create( disco_addon=DiscoveryItem.objects.create(addon=addon_factory()), image='foo.png') - assert ph.image_path == ( + assert ph.image_url == ( 'http://testserver/static/img/hero/featured/foo.png') def test_gradiant(self): @@ -51,6 +51,7 @@ class TestPrimaryHero(TestCase): class TestSecondaryHero(TestCase): + def test_str(self): sh = SecondaryHero.objects.create( headline='Its a héadline!', description='description') @@ -85,3 +86,42 @@ class TestSecondaryHero(TestCase): ph.enabled = True ph.cta_url = 'http://goo.gl' ph.clean() # it raises if there's an error + + +class TestSecondaryHeroModule(TestCase): + + def test_str(self): + shm = SecondaryHeroModule.objects.create( + description='descríption', + shelf=SecondaryHero.objects.create()) + assert str(shm) == 'descríption' + + def test_clean_cta(self): + ph = SecondaryHeroModule.objects.create( + shelf=SecondaryHero.objects.create()) + + # neither cta_url or cta_text are set, and that's okay. + ph.clean() # it raises if there's an error. + + # just set the url without the text is invalid when enabled though. + ph.cta_url = 'http://goo.gl/' + with self.assertRaises(ValidationError): + ph.clean() + ph.cta_url = None + ph.cta_text = 'click it!' + with self.assertRaises(ValidationError): + ph.clean() + ph.cta_url = '' + with self.assertRaises(ValidationError): + ph.clean() + + # And setting both is okay too. + ph.cta_url = 'http://goo.gl' + ph.clean() # it raises if there's an error + + def test_icon_url(self): + ph = SecondaryHeroModule.objects.create( + shelf=SecondaryHero.objects.create(), + icon='foo.svg') + assert ph.icon_url == ( + 'http://testserver/static/img/hero/icons/foo.svg') diff --git a/src/olympia/hero/tests/test_serializers.py b/src/olympia/hero/tests/test_serializers.py index e4478bcb00..a1e5a38927 100644 --- a/src/olympia/hero/tests/test_serializers.py +++ b/src/olympia/hero/tests/test_serializers.py @@ -3,7 +3,8 @@ from olympia.amo.tests import addon_factory, TestCase from olympia.discovery.models import DiscoveryItem from olympia.discovery.serializers import DiscoveryAddonSerializer -from ..models import GRADIENT_START_COLOR, PrimaryHero, SecondaryHero +from ..models import ( + GRADIENT_START_COLOR, PrimaryHero, SecondaryHero, SecondaryHeroModule) from ..serializers import ( ExternalAddonSerializer, PrimaryHeroShelfSerializer, SecondaryHeroShelfSerializer) @@ -19,7 +20,8 @@ class TestPrimaryHeroShelfSerializer(TestCase): gradient_color='#123456') data = PrimaryHeroShelfSerializer(instance=hero).data assert data == { - 'featured_image': hero.image_path, + 'featured_image': ( + 'http://testserver/static/img/hero/featured/foo.png'), 'description': '
Déscription
', 'gradient': { 'start': GRADIENT_START_COLOR, @@ -38,7 +40,8 @@ class TestPrimaryHeroShelfSerializer(TestCase): gradient_color='#123456', is_external=True) assert PrimaryHeroShelfSerializer(instance=hero).data == { - 'featured_image': hero.image_path, + 'featured_image': ( + 'http://testserver/static/img/hero/featured/foo.png'), 'description': '
Summary
', 'gradient': { 'start': GRADIENT_START_COLOR, @@ -64,6 +67,7 @@ class TestSecondaryHeroShelfSerializer(TestCase): 'headline': 'Its a héadline!', 'description': 'description', 'cta': None, + 'modules': [], } hero.update(cta_url='/extensions/', cta_text='Go here') data = SecondaryHeroShelfSerializer(instance=hero).data @@ -74,4 +78,43 @@ class TestSecondaryHeroShelfSerializer(TestCase): 'url': 'http://testserver/extensions/', 'text': 'Go here', }, + 'modules': [], + } + + def test_with_modules(self): + hero = SecondaryHero.objects.create() + promos = [ + SecondaryHeroModule.objects.create( + description='It does things!', shelf=hero, icon='a.svg'), + SecondaryHeroModule.objects.create( + shelf=hero, cta_url='/extensions/', cta_text='Go here', + icon='b.svg'), + SecondaryHeroModule.objects.create( + shelf=hero, icon='c.svg'), + ] + data = SecondaryHeroShelfSerializer(instance=hero).data + assert data == { + 'headline': '', + 'description': '', + 'cta': None, + 'modules': [ + { + 'description': promos[0].description, + 'icon': 'http://testserver/static/img/hero/icons/a.svg', + 'cta': None, + }, + { + 'description': '', + 'icon': 'http://testserver/static/img/hero/icons/b.svg', + 'cta': { + 'url': 'http://testserver/extensions/', + 'text': 'Go here', + }, + }, + { + 'description': '', + 'icon': 'http://testserver/static/img/hero/icons/c.svg', + 'cta': None, + }, + ], } diff --git a/src/olympia/hero/tests/test_views.py b/src/olympia/hero/tests/test_views.py index a8d40231ab..01ebda247f 100644 --- a/src/olympia/hero/tests/test_views.py +++ b/src/olympia/hero/tests/test_views.py @@ -2,7 +2,7 @@ from olympia.amo.templatetags.jinja_helpers import absolutify from olympia.amo.tests import addon_factory, TestCase, reverse_ns from olympia.discovery.models import DiscoveryItem -from ..models import PrimaryHero, SecondaryHero +from ..models import PrimaryHero, SecondaryHero, SecondaryHeroModule from ..serializers import ( PrimaryHeroShelfSerializer, SecondaryHeroShelfSerializer) @@ -96,15 +96,22 @@ class TestSecondaryHeroShelfViewSet(TestCase): SecondaryHero.objects.create( headline='dfdfd!', description='dfdfd') + SecondaryHeroModule.objects.create(shelf=hero_a) + SecondaryHeroModule.objects.create(shelf=hero_a) + SecondaryHeroModule.objects.create(shelf=hero_a) - # The shelf isn't enabled so still won't show up + # The shelves aren't enabled so won't show up. response = self.client.get(self.url) assert response.json() == {'results': []} hero_a.update(enabled=True) hero_b.update(enabled=True) # don't enable the 3rd PrimaryHero object - response = self.client.get(self.url) + with self.assertNumQueries(2): + # 2 queries: + # - 1 to fetch all SecondaryHero results + # - 1 to fetch all the SecondaryHeroModules + response = self.client.get(self.url) assert response.status_code == 200 assert response.json() == { 'results': [ @@ -123,11 +130,14 @@ class TestHeroShelvesView(TestCase): shero = SecondaryHero.objects.create( headline='headline', description='description', enabled=True) + SecondaryHeroModule.objects.create(shelf=shero) + SecondaryHeroModule.objects.create(shelf=shero) + SecondaryHeroModule.objects.create(shelf=shero) - with self.assertNumQueries(12): - # 12 queries: - # first 11 as TestPrimaryHeroShelfViewSet.test_basic above - # + 1 to fetch SecondaryHero result + with self.assertNumQueries(13): + # 13 queries: + # - 11 as TestPrimaryHeroShelfViewSet.test_basic above + # - 2 as TestSecondaryHeroShelfViewSet.test_basic above response = self.client.get(self.url, {'lang': 'en-US'}) assert response.status_code == 200 assert response.json() == { diff --git a/src/olympia/hero/views.py b/src/olympia/hero/views.py index 4c0476f31a..18c3ca9423 100644 --- a/src/olympia/hero/views.py +++ b/src/olympia/hero/views.py @@ -51,6 +51,11 @@ class SecondaryHeroShelfViewSet(ShelfViewSet): queryset = SecondaryHero.objects serializer_class = SecondaryHeroShelfSerializer + def get_queryset(self): + qs = super().get_queryset() + qs = qs.prefetch_related('modules') + return qs + class HeroShelvesView(APIView): def get(self, request, format=None): diff --git a/src/olympia/migrations/1115-add-secondary-hero-module.sql b/src/olympia/migrations/1115-add-secondary-hero-module.sql new file mode 100644 index 0000000000..9fab80d512 --- /dev/null +++ b/src/olympia/migrations/1115-add-secondary-hero-module.sql @@ -0,0 +1,13 @@ +CREATE TABLE `hero_secondaryheromodule` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `created` datetime(6) NOT NULL, + `modified` datetime(6) NOT NULL, + `icon` varchar(255) NOT NULL, + `description` varchar(50) NOT NULL, + `cta_url` varchar(255) NOT NULL, + `cta_text` varchar(20) NOT NULL, + `shelf_id` integer NOT NULL +); +ALTER TABLE `hero_secondaryheromodule` +ADD CONSTRAINT `hero_secondaryheromo_shelf_id_dabb040a_fk_hero_seco` +FOREIGN KEY (`shelf_id`) REFERENCES `hero_secondaryhero` (`id`); diff --git a/static/css/admin/discovery.css b/static/css/admin/discovery.css index e0bf8bb1c1..5e4f348274 100644 --- a/static/css/admin/discovery.css +++ b/static/css/admin/discovery.css @@ -57,10 +57,16 @@ } .dynamic-primaryhero .field-gradient_color li, -.dynamic-primaryhero .field-image li { +.dynamic-primaryhero .field-image li, +.dynamic-modules .field-icon li { display: inline-block; } +.dynamic-modules .field-icon li { + margin: 0.5em; + vertical-align: top; +} + .dynamic-primaryhero .field-gradient_color li div, .dynamic-primaryhero .field-image li div { height: 9em; @@ -68,3 +74,14 @@ background-size: contain; background-repeat: no-repeat; } + +.dynamic-modules .field-icon li div { + height: 3em; + width: 3em; + background-size: contain; + background-repeat: no-repeat; +} + +.dynamic-modules .field-icon li div label { + color: transparent; +} diff --git a/static/img/hero/icons/Accessibility.svg b/static/img/hero/icons/Accessibility.svg new file mode 100644 index 0000000000..884fef9f70 --- /dev/null +++ b/static/img/hero/icons/Accessibility.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Audio.svg b/static/img/hero/icons/Audio.svg new file mode 100644 index 0000000000..c0a30cf4d7 --- /dev/null +++ b/static/img/hero/icons/Audio.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Bookmark Outline.svg b/static/img/hero/icons/Bookmark Outline.svg new file mode 100644 index 0000000000..61befddda2 --- /dev/null +++ b/static/img/hero/icons/Bookmark Outline.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Customize.svg b/static/img/hero/icons/Customize.svg new file mode 100644 index 0000000000..a280290f08 --- /dev/null +++ b/static/img/hero/icons/Customize.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Developer.svg b/static/img/hero/icons/Developer.svg new file mode 100644 index 0000000000..cd1a7b53a5 --- /dev/null +++ b/static/img/hero/icons/Developer.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Device Mobile.svg b/static/img/hero/icons/Device Mobile.svg new file mode 100644 index 0000000000..c3798bbfb2 --- /dev/null +++ b/static/img/hero/icons/Device Mobile.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Dictionaries.svg b/static/img/hero/icons/Dictionaries.svg new file mode 100644 index 0000000000..d9ad49a8f5 --- /dev/null +++ b/static/img/hero/icons/Dictionaries.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Download.svg b/static/img/hero/icons/Download.svg new file mode 100644 index 0000000000..ac0539a3bb --- /dev/null +++ b/static/img/hero/icons/Download.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Extensions.svg b/static/img/hero/icons/Extensions.svg new file mode 100644 index 0000000000..f86792f4ad --- /dev/null +++ b/static/img/hero/icons/Extensions.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Folder.svg b/static/img/hero/icons/Folder.svg new file mode 100644 index 0000000000..94ad7ec0bc --- /dev/null +++ b/static/img/hero/icons/Folder.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Globe.svg b/static/img/hero/icons/Globe.svg new file mode 100644 index 0000000000..82d41832ae --- /dev/null +++ b/static/img/hero/icons/Globe.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Highlights.svg b/static/img/hero/icons/Highlights.svg new file mode 100644 index 0000000000..df194c9e76 --- /dev/null +++ b/static/img/hero/icons/Highlights.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/History.svg b/static/img/hero/icons/History.svg new file mode 100644 index 0000000000..a724513d64 --- /dev/null +++ b/static/img/hero/icons/History.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Login.svg b/static/img/hero/icons/Login.svg new file mode 100644 index 0000000000..a0c441c944 --- /dev/null +++ b/static/img/hero/icons/Login.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Notification.svg b/static/img/hero/icons/Notification.svg new file mode 100644 index 0000000000..922758a880 --- /dev/null +++ b/static/img/hero/icons/Notification.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Performance.svg b/static/img/hero/icons/Performance.svg new file mode 100644 index 0000000000..38e3d19333 --- /dev/null +++ b/static/img/hero/icons/Performance.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Pin.svg b/static/img/hero/icons/Pin.svg new file mode 100644 index 0000000000..02fffc70bc --- /dev/null +++ b/static/img/hero/icons/Pin.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Play.svg b/static/img/hero/icons/Play.svg new file mode 100644 index 0000000000..7888610c5b --- /dev/null +++ b/static/img/hero/icons/Play.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Popular.svg b/static/img/hero/icons/Popular.svg new file mode 100644 index 0000000000..5496a888f1 --- /dev/null +++ b/static/img/hero/icons/Popular.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Private browsing.svg b/static/img/hero/icons/Private browsing.svg new file mode 100644 index 0000000000..9780430783 --- /dev/null +++ b/static/img/hero/icons/Private browsing.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Reader Mode.svg b/static/img/hero/icons/Reader Mode.svg new file mode 100644 index 0000000000..462ef7dfbb --- /dev/null +++ b/static/img/hero/icons/Reader Mode.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Save.svg b/static/img/hero/icons/Save.svg new file mode 100644 index 0000000000..3c44424fb4 --- /dev/null +++ b/static/img/hero/icons/Save.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Search.svg b/static/img/hero/icons/Search.svg new file mode 100644 index 0000000000..3082f5d5fc --- /dev/null +++ b/static/img/hero/icons/Search.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Secure.svg b/static/img/hero/icons/Secure.svg new file mode 100644 index 0000000000..425474c060 --- /dev/null +++ b/static/img/hero/icons/Secure.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Subscribe.svg b/static/img/hero/icons/Subscribe.svg new file mode 100644 index 0000000000..6d75dab594 --- /dev/null +++ b/static/img/hero/icons/Subscribe.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Tab new.svg b/static/img/hero/icons/Tab new.svg new file mode 100644 index 0000000000..91d6d2090d --- /dev/null +++ b/static/img/hero/icons/Tab new.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Themes.svg b/static/img/hero/icons/Themes.svg new file mode 100644 index 0000000000..a280290f08 --- /dev/null +++ b/static/img/hero/icons/Themes.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Trackers Disabled.svg b/static/img/hero/icons/Trackers Disabled.svg new file mode 100644 index 0000000000..497531cdeb --- /dev/null +++ b/static/img/hero/icons/Trackers Disabled.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Tracking Protection.svg b/static/img/hero/icons/Tracking Protection.svg new file mode 100644 index 0000000000..36fe7bd430 --- /dev/null +++ b/static/img/hero/icons/Tracking Protection.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Video Recorder.svg b/static/img/hero/icons/Video Recorder.svg new file mode 100644 index 0000000000..8895314e66 --- /dev/null +++ b/static/img/hero/icons/Video Recorder.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/Window New.svg b/static/img/hero/icons/Window New.svg new file mode 100644 index 0000000000..a349f80106 --- /dev/null +++ b/static/img/hero/icons/Window New.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/static/img/hero/icons/artist.png b/static/img/hero/icons/artist.png deleted file mode 100644 index 553bba5fa6..0000000000 Binary files a/static/img/hero/icons/artist.png and /dev/null differ