Add search by color in the search API (for static themes only)

This commit is contained in:
Mathieu Pillard 2018-12-14 14:53:57 +01:00
Родитель c85c2172bb
Коммит c7186d0d53
17 изменённых файлов: 256 добавлений и 19 удалений

Просмотреть файл

@ -45,6 +45,7 @@ This endpoint allows you to search through public add-ons.
:query string appversion: Filter by application version compatibility. Pass the full version as a string, e.g. ``46.0``. Only valid when the ``app`` parameter is also present.
:query string author: Filter by exact (listed) author username or user id. Multiple author usernames or ids can be specified, separated by comma(s), in which case add-ons with at least one matching author are returned.
:query string category: Filter by :ref:`category slug <category-list>`. ``app`` and ``type`` parameters need to be set, otherwise this parameter is ignored.
:query string color: (Experimental) Filter by color in RGB hex format, trying to find themes that approximately match the specified color. Only works for static themes.
:query string exclude_addons: Exclude add-ons by ``slug`` or ``id``. Multiple add-ons can be specified, separated by comma(s).
:query boolean featured: Filter to only featured add-ons. Only ``featured=true`` is supported.
If ``app`` is provided as a parameter then only featured collections targeted to that application are taken into account.

Просмотреть файл

@ -147,6 +147,8 @@ chardet==3.0.4 \
click==7.0 \
--hash=sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13 \
--hash=sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7
colorgram.py==1.1.0 \
--hash=sha256:35f1692a2f070f65eae53645f7c3eebc36488cafeb7434838beb26def2910868
# contextlib2 is required by raven
contextlib2==0.5.5 \
--hash=sha256:f5260a6e679d2ff42ec91ec5252f4eeffdcf21053db9113bd0a8e4d953769c00 \

Просмотреть файл

@ -22,6 +22,7 @@ class AddonIndexer(BaseSearchIndexer):
hidden_fields = (
'*.raw',
'boost',
'colors',
'hotness',
# Translated content that is used for filtering purposes is stored
# under 3 different fields:
@ -99,6 +100,15 @@ class AddonIndexer(BaseSearchIndexer):
'bayesian_rating': {'type': 'double'},
'boost': {'type': 'float', 'null_value': 1.0},
'category': {'type': 'integer'},
'colors': {
'type': 'nested',
'properties': {
'h': {'type': 'integer'},
's': {'type': 'integer'},
'l': {'type': 'integer'},
'ratio': {'type': 'double'},
},
},
'contributions': {'type': 'text'},
'created': {'type': 'date'},
'current_version': version_mapping,
@ -284,6 +294,7 @@ class AddonIndexer(BaseSearchIndexer):
'status', 'type', 'view_source', 'weekly_downloads')
data = {attr: getattr(obj, attr) for attr in attrs}
data['colors'] = None
if obj.type == amo.ADDON_PERSONA:
# Personas are compatible with all platforms. They don't have files
# so we have to fake the info to be consistent with the rest of the
@ -320,6 +331,12 @@ class AddonIndexer(BaseSearchIndexer):
obj.current_version.supported_platforms]
data['has_theme_rereview'] = None
# Extract dominant colors from static themes.
if obj.type == amo.ADDON_STATICTHEME:
first_preview = obj.current_previews.first()
if first_preview:
data['colors'] = first_preview.colors
data['app'] = [app.id for app in obj.compatible_apps.keys()]
# Boost by the number of users on a logarithmic scale.
data['boost'] = float(data['average_daily_users'] ** .2)

Просмотреть файл

@ -9,6 +9,7 @@ from olympia.addons.models import Addon
from olympia.addons.tasks import (
add_dynamic_theme_tag, add_firefox57_tag, bump_appver_for_legacy_addons,
disable_legacy_files,
extract_colors_from_static_themes,
find_inconsistencies_between_es_and_db,
migrate_legacy_dictionaries_to_webextension,
migrate_lwts_to_static_themes,
@ -115,6 +116,10 @@ tasks = {
],
'pre': lambda values_qs: values_qs.distinct(),
},
'extract_colors_from_static_themes': {
'method': extract_colors_from_static_themes,
'qs': [Q(type=amo.ADDON_STATICTHEME)]
}
}

Просмотреть файл

@ -1350,7 +1350,7 @@ class Addon(OnChangeMixin, ModelBase):
if self.has_per_version_previews:
if self.current_version:
return self.current_version.previews.all()
return []
return VersionPreview.objects.none()
else:
return self._all_previews

Просмотреть файл

@ -28,7 +28,8 @@ from olympia.amo.decorators import set_modified_on, use_primary_db
from olympia.amo.storage_utils import rm_stored_dir
from olympia.amo.templatetags.jinja_helpers import user_media_path
from olympia.amo.utils import (
ImageCheck, LocalFileStorage, cache_ns_key, pngcrush_image, StopWatch)
ImageCheck, LocalFileStorage, cache_ns_key, extract_colors_from_image,
pngcrush_image, StopWatch)
from olympia.applications.models import AppVersion
from olympia.constants.categories import CATEGORIES
from olympia.constants.licenses import (
@ -903,3 +904,21 @@ def remove_amo_links_in_url_fields(ids, **kw):
).update(localized_string=u'', localized_string_clean=u'')
if settings.DOMAIN.lower() in addon.contributions.lower():
addon.update(contributions=u'')
@task
@use_primary_db
def extract_colors_from_static_themes(ids, **kw):
"""Extract and store colors from existing static themes."""
log.info('Extracting static themes colors %d-%d [%d].', ids[0], ids[-1],
len(ids))
addons = Addon.objects.filter(id__in=ids)
extracted = []
for addon in addons:
first_preview = addon.current_previews.first()
if first_preview and not first_preview.colors:
colors = extract_colors_from_image(first_preview.thumbnail_path)
addon.current_previews.update(colors=colors)
extracted.append(addon.pk)
if extracted:
index_addons.delay(extracted)

Просмотреть файл

@ -19,7 +19,7 @@ from olympia.amo.tests import (
from olympia.applications.models import AppVersion
from olympia.files.models import FileValidation, WebextPermission
from olympia.reviewers.models import AutoApprovalSummary, ReviewerScore
from olympia.versions.models import ApplicationsVersions
from olympia.versions.models import ApplicationsVersions, VersionPreview
# Where to monkeypatch "lib.crypto.tasks.sign_addons" so it's correctly mocked.
@ -751,3 +751,19 @@ class TestRemoveAMOLinksInURLFields(TestCase):
translation.activate('fr')
addon.reload()
assert addon.support_url == u''
class TestExtractColorsFromStaticThemes(TestCase):
@mock.patch('olympia.addons.tasks.extract_colors_from_image')
def test_basic(self, extract_colors_from_image_mock):
addon = addon_factory(type=amo.ADDON_STATICTHEME)
preview = VersionPreview.objects.create(version=addon.current_version)
extract_colors_from_image_mock.return_value = [
{'h': 4, 's': 8, 'l': 15, 'ratio': .16}
]
call_command(
'process_addons', task='extract_colors_from_static_themes')
preview.reload()
assert preview.colors == [
{'h': 4, 's': 8, 'l': 15, 'ratio': .16}
]

Просмотреть файл

@ -50,8 +50,8 @@ class TestAddonIndexer(TestCase):
# exist on the model, or it has a different name, or the value we need
# to store in ES differs from the one in the db.
complex_fields = [
'app', 'boost', 'category', 'current_version', 'description',
'featured_for', 'has_eula', 'has_privacy_policy',
'app', 'boost', 'category', 'colors', 'current_version',
'description', 'featured_for', 'has_eula', 'has_privacy_policy',
'has_theme_rereview', 'is_featured', 'listed_authors', 'name',
'platforms', 'previews', 'public_stats', 'ratings', 'summary',
'tags',
@ -191,6 +191,7 @@ class TestAddonIndexer(TestCase):
assert extracted['has_eula'] is True
assert extracted['has_privacy_policy'] is True
assert extracted['is_featured'] is False
assert extracted['colors'] is None
def test_extract_is_featured(self):
collection = collection_factory()
@ -482,15 +483,36 @@ class TestAddonIndexer(TestCase):
self.addon.update(type=amo.ADDON_STATICTHEME)
current_preview = VersionPreview.objects.create(
version=self.addon.current_version,
sizes={'thumbnail': [56, 78], 'image': [91, 234]})
colors=[{'h': 1, 's': 2, 'l': 3, 'ratio': 0.9}],
sizes={'thumbnail': [56, 78], 'image': [91, 234]}, position=1)
second_preview = VersionPreview.objects.create(
version=self.addon.current_version,
sizes={'thumbnail': [12, 34], 'image': [56, 78]}, position=2)
extracted = self._extract()
assert extracted['previews']
assert len(extracted['previews']) == 1
assert len(extracted['previews']) == 2
assert 'caption_translations' not in extracted['previews'][0]
assert extracted['previews'][0]['id'] == current_preview.pk
assert extracted['previews'][0]['modified'] == current_preview.modified
assert extracted['previews'][0]['sizes'] == current_preview.sizes == {
'thumbnail': [56, 78], 'image': [91, 234]}
assert 'caption_translations' not in extracted['previews'][1]
assert extracted['previews'][1]['id'] == second_preview.pk
assert extracted['previews'][1]['modified'] == second_preview.modified
assert extracted['previews'][1]['sizes'] == second_preview.sizes == {
'thumbnail': [12, 34], 'image': [56, 78]}
# Make sure we extract colors from the first preview.
assert extracted['colors'] == [{'h': 1, 's': 2, 'l': 3, 'ratio': 0.9}]
def test_extract_staticthemes_somehow_no_previews(self):
# Extracting a static theme with no previews should not fail.
self.addon.update(type=amo.ADDON_STATICTHEME)
extracted = self._extract()
assert extracted['id'] == self.addon.pk
assert extracted['previews'] == []
assert extracted['colors'] is None
class TestAddonIndexerWithES(ESTestCase):

Просмотреть файл

@ -2215,7 +2215,7 @@ class TestAddonSearchView(ESTestCase):
qset = view.get_queryset()
assert set(qset.to_dict()['_source']['excludes']) == set(
('*.raw', 'boost', 'hotness', 'name', 'description',
('*.raw', 'boost', 'colors', 'hotness', 'name', 'description',
'name_l10n_*', 'description_l10n_*', 'summary', 'summary_l10n_*')
)

Просмотреть файл

@ -1,5 +1,6 @@
import collections
import datetime
import os.path
import tempfile
from django.conf import settings
@ -15,8 +16,9 @@ from olympia import amo
from olympia.addons.models import Addon
from olympia.amo.tests import TestCase, addon_factory
from olympia.amo.utils import (
attach_trans_dict, get_locale_from_lang, pngcrush_image,
translations_for_field, utc_millesecs_from_epoch, walkfiles)
attach_trans_dict, extract_colors_from_image, get_locale_from_lang,
pngcrush_image, translations_for_field, utc_millesecs_from_epoch,
walkfiles)
from olympia.versions.models import Version
@ -241,3 +243,18 @@ def test_utc_millesecs_from_epoch():
new_timestamp = utc_millesecs_from_epoch(
future_now + datetime.timedelta(milliseconds=42))
assert new_timestamp == timestamp + 42
def test_extract_colors_from_image():
path = os.path.join(
settings.ROOT,
'src/olympia/versions/tests/static_themes/weta.png')
expected = [
{'h': 45, 'l': 158, 'ratio': 0.40547158773994313, 's': 34},
{'h': 44, 'l': 94, 'ratio': 0.2812929380875291, 's': 28},
{'h': 68, 'l': 99, 'ratio': 0.13200103391513734, 's': 19},
{'h': 43, 'l': 177, 'ratio': 0.06251105336906689, 's': 93},
{'h': 47, 'l': 115, 'ratio': 0.05938209966397758, 's': 60},
{'h': 40, 'l': 201, 'ratio': 0.05934128722434598, 's': 83}
]
assert extract_colors_from_image(path) == expected

Просмотреть файл

@ -35,6 +35,7 @@ from django.utils.encoding import force_bytes, force_text
from django.utils.http import _urlparse as django_urlparse, quote_etag
import bleach
import colorgram
import html5lib
import jinja2
import pytz
@ -1002,6 +1003,20 @@ def utc_millesecs_from_epoch(for_datetime=None):
return int(seconds * 1000)
def extract_colors_from_image(path):
try:
image_colors = colorgram.extract(path, 6)
colors = [{
'h': color.hsl.h,
's': color.hsl.s,
'l': color.hsl.l,
'ratio': color.proportion
} for color in image_colors]
except IOError:
colors = None
return colors
class AMOJSONEncoder(JSONEncoder):
def default(self, obj):
if isinstance(obj, Translation):

Просмотреть файл

@ -0,0 +1 @@
ALTER TABLE `version_previews` ADD COLUMN `colors` longtext NULL;

Просмотреть файл

@ -1,6 +1,7 @@
from django.utils import translation
from django.utils.translation import ugettext
import colorgram
from elasticsearch_dsl import Q, query
from rest_framework import serializers
from rest_framework.filters import BaseFilterBackend
@ -303,6 +304,58 @@ class AddonFeaturedQueryParam(AddonQueryParam):
filter=clauses))]
class AddonColorQueryParam(AddonQueryParam):
query_param = 'color'
def convert_to_hsl(self, hexvalue):
# The API is receiving color as a hex string. We store colors in HSL
# as colorgram generates it (which is on a 0 to 255 scale for each
# component), so some conversion is necessary.
if len(hexvalue) == 3:
hexvalue = ''.join(2 * c for c in hexvalue)
try:
rgb = tuple(bytearray.fromhex(hexvalue))
except ValueError:
rgb = (0, 0, 0)
return colorgram.colorgram.hsl(*rgb)
def get_value(self):
color = self.request.GET.get(self.query_param, '')
return self.convert_to_hsl(color.upper().lstrip('#'))
def get_es_query(self):
hsl = self.get_value()
# If we're given a color with a very low saturation, the user is
# searching for a black/white/grey and we need to take saturation and
# lightness into consideration, but ignore hue.
if hsl[1] <= 5: # 2.5% saturation or lower.
clauses = [
Q('range', **{'colors.s': {
'lte': 5,
}}),
Q('range', **{'colors.l': {
'gte': max(min(hsl[2] - 64, 255), 0),
'lte': max(min(hsl[2] + 64, 255), 0),
}})
]
else:
# Otherwise, we want to do the opposite and just try to match the
# hue. The idea is to keep the UI simple, presenting the user with
# a limited set of colors that still allows them to find all
# themes.
clauses = [
Q('range', **{'colors.h': {
'gte': (hsl[0] - 26) % 255, # 10% less hue
'lte': (hsl[0] + 26) % 255, # 10% more hue
}}),
]
# In any case, the color we're looking for needs to be present in at
# least 20% of the image.
clauses.append(Q('range', **{'colors.ratio': {'gte': 0.20}}))
return [Q('nested', path='colors', query=query.Bool(filter=clauses))]
class SearchQueryFilter(BaseFilterBackend):
"""
A django-rest-framework filter backend that performs an ES query according
@ -632,6 +685,7 @@ class SearchParameterFilter(BaseFilterBackend):
AddonPlatformQueryParam,
AddonTagQueryParam,
AddonTypeQueryParam,
AddonColorQueryParam,
]
def get_applicable_clauses(self, request):
@ -649,7 +703,8 @@ class SearchParameterFilter(BaseFilterBackend):
def filter_queryset(self, request, qs, view):
filters = self.get_applicable_clauses(request)
return qs.query(query.Bool(filter=filters)) if filters else qs
qs = qs.query(query.Bool(filter=filters)) if filters else qs
return qs
class ReviewedContentFilter(BaseFilterBackend):

Просмотреть файл

@ -784,6 +784,49 @@ class TestSearchParameterFilter(FilterTestsBase):
assert len(inner) == 1
assert {'terms': {'featured_for.locales': ['fr', 'ALL']}} in inner
def test_search_by_color(self):
qs = self._filter(data={'color': 'ff0000'})
filter_ = qs['query']['bool']['filter']
assert len(filter_) == 1
inner = filter_[0]['nested']['query']['bool']['filter']
assert len(inner) == 2
assert inner == [
{'range': {'colors.h': {'gte': 229, 'lte': 26}}},
{'range': {'colors.ratio': {'gte': 0.2}}},
]
qs = self._filter(data={'color': '#00ffff'})
filter_ = qs['query']['bool']['filter']
assert len(filter_) == 1
inner = filter_[0]['nested']['query']['bool']['filter']
assert len(inner) == 2
assert inner == [
{'range': {'colors.h': {'gte': 101, 'lte': 153}}},
{'range': {'colors.ratio': {'gte': 0.2}}},
]
qs = self._filter(data={'color': '#f6f6f6'})
filter_ = qs['query']['bool']['filter']
assert len(filter_) == 1
inner = filter_[0]['nested']['query']['bool']['filter']
assert len(inner) == 3
assert inner == [
{'range': {'colors.s': {'lte': 5}}},
{'range': {'colors.l': {'gte': 182, 'lte': 255}}},
{'range': {'colors.ratio': {'gte': 0.2}}},
]
qs = self._filter(data={'color': '333'})
filter_ = qs['query']['bool']['filter']
assert len(filter_) == 1
inner = filter_[0]['nested']['query']['bool']['filter']
assert len(inner) == 3
assert inner == [
{'range': {'colors.s': {'lte': 5}}},
{'range': {'colors.l': {'gte': 0, 'lte': 115}}},
{'range': {'colors.ratio': {'gte': 0.2}}},
]
class TestCombinedFilter(FilterTestsBase):
"""

Просмотреть файл

@ -643,6 +643,7 @@ class VersionPreview(BasePreview, ModelBase):
version = models.ForeignKey(Version, related_name='previews')
position = models.IntegerField(default=0)
sizes = JSONField(default={})
colors = JSONField(default=None, null=True)
media_folder = 'version-previews'
class Meta:

Просмотреть файл

@ -10,7 +10,7 @@ from olympia import amo
from olympia.addons.tasks import index_addons
from olympia.amo.celery import task
from olympia.amo.decorators import use_primary_db
from olympia.amo.utils import pngcrush_image
from olympia.amo.utils import extract_colors_from_image, pngcrush_image
from olympia.devhub.tasks import resize_image
from olympia.files.models import File
from olympia.files.utils import get_background_images
@ -70,6 +70,7 @@ def generate_static_theme_preview(theme_manifest, version_pk):
sizes = sorted(
amo.THEME_PREVIEW_SIZES.values(),
lambda x, y: x['position'] - y['position'])
colors = None
for size in sizes:
# Create a Preview for this size.
preview = VersionPreview.objects.create(
@ -81,10 +82,19 @@ def generate_static_theme_preview(theme_manifest, version_pk):
resize_image(
preview.image_path, preview.thumbnail_path, size['thumbnail'])
pngcrush_image(preview.image_path)
preview_sizes = {}
preview_sizes['image'] = size['full']
preview_sizes['thumbnail'] = size['thumbnail']
preview.update(sizes=preview_sizes)
# Extract colors once and store it for all previews.
# Use the thumbnail for extra speed, we don't need to be super
# accurate.
if colors is None:
colors = extract_colors_from_image(preview.thumbnail_path)
data = {
'sizes': {
'image': size['full'],
'thumbnail': size['thumbnail'],
},
'colors': colors,
}
preview.update(**data)
addon_id = Version.objects.values_list(
'addon_id', flat=True).get(id=version_pk)
index_addons.delay([addon_id])

Просмотреть файл

@ -58,10 +58,12 @@ def check_preview(preview_instance, theme_size_constant, write_svg_mock_args,
assert thumb_path == preview_instance.thumbnail_path
assert thumb_size == theme_size_constant['thumbnail']
assert png_crush_mock_args[0] == preview_instance.image_path
assert preview_instance.colors
@pytest.mark.django_db
@mock.patch('olympia.versions.tasks.index_addons.delay')
@mock.patch('olympia.versions.tasks.extract_colors_from_image')
@mock.patch('olympia.versions.tasks.pngcrush_image')
@mock.patch('olympia.versions.tasks.resize_image')
@mock.patch('olympia.versions.tasks.write_svg_to_png')
@ -81,9 +83,12 @@ def check_preview(preview_instance, theme_size_constant, write_svg_mock_args,
)
def test_generate_static_theme_preview(
write_svg_to_png_mock, resize_image_mock, pngcrush_image_mock,
index_addons_mock,
extract_colors_from_image_mock, index_addons_mock,
header_url, header_height, preserve_aspect_ratio, mimetype, valid_img):
write_svg_to_png_mock.return_value = True
extract_colors_from_image_mock.return_value = [
{'h': 9, 's': 8, 'l': 7, 'ratio': 0.6}
]
theme_manifest = {
"images": {
},
@ -164,13 +169,17 @@ def test_generate_static_theme_preview(
@pytest.mark.django_db
@mock.patch('olympia.versions.tasks.index_addons.delay')
@mock.patch('olympia.versions.tasks.extract_colors_from_image')
@mock.patch('olympia.versions.tasks.pngcrush_image')
@mock.patch('olympia.versions.tasks.resize_image')
@mock.patch('olympia.versions.tasks.write_svg_to_png')
def test_generate_static_theme_preview_with_chrome_properties(
write_svg_to_png_mock, resize_image_mock, pngcrush_image_mock,
index_addons_mock):
extract_colors_from_image_mock, index_addons_mock):
write_svg_to_png_mock.return_value = True
extract_colors_from_image_mock.return_value = [
{'h': 9, 's': 8, 'l': 7, 'ratio': 0.6}
]
theme_manifest = {
"images": {
"theme_frame": "transparent.gif"
@ -274,13 +283,17 @@ def check_render_additional(svg_content, inner_svg_width, colors):
@pytest.mark.django_db
@mock.patch('olympia.versions.tasks.index_addons.delay')
@mock.patch('olympia.versions.tasks.extract_colors_from_image')
@mock.patch('olympia.versions.tasks.pngcrush_image')
@mock.patch('olympia.versions.tasks.resize_image')
@mock.patch('olympia.versions.tasks.write_svg_to_png')
def test_generate_preview_with_additional_backgrounds(
write_svg_to_png_mock, resize_image_mock, pngcrush_image_mock,
index_addons_mock):
extract_colors_from_image_mock, index_addons_mock):
write_svg_to_png_mock.return_value = True
extract_colors_from_image_mock.return_value = [
{'h': 9, 's': 8, 'l': 7, 'ratio': 0.6}
]
theme_manifest = {
"images": {