This commit is contained in:
William Durand 2020-12-09 19:24:56 +01:00 коммит произвёл GitHub
Родитель 8572b7906c
Коммит e61d8b0de7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
490 изменённых файлов: 33737 добавлений и 26230 удалений

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

@ -189,6 +189,7 @@ setup-codestyle:
.PHONY: lint
lint: ## lint the code
black --check src/ services/ tests/
flake8 src/ services/ tests/
$(shell npm $(NPM_ARGS) bin)/prettier --check '**'
curlylint src/
@ -255,6 +256,7 @@ watch_js_tests: ## Run+watch the JavaScript test suite (requires compiled/compre
.PHONY: format
format: ## Autoformat our codebase.
$(shell npm $(NPM_ARGS) bin)/prettier --write '**'
black src/ services/ tests/
.PHONY: help_submake
help_submake:

23
pyproject.toml Normal file
Просмотреть файл

@ -0,0 +1,23 @@
[tool.black]
line-length = 88
target-version = ['py38']
skip-string-normalization = true
exclude = '''
(
/(
docs
| node_modules
| build*.py
| media
| storage
| logs
| site-static
| static
| \.git
| \.npm
| \.tox
)/
| src/.*/migrations/.*\.py
| src/olympia/translations/tests/testapp/migrations/.*\.py
)
'''

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

@ -45,3 +45,85 @@ curlylint==0.12.0 \
toml==0.10.2 \
--hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \
--hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
black==20.8b1 \
--hash=sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea
typing-extensions==3.7.4.3 \
--hash=sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918 \
--hash=sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c \
--hash=sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f
regex==2020.11.13 \
--hash=sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538 \
--hash=sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4 \
--hash=sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc \
--hash=sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa \
--hash=sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444 \
--hash=sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1 \
--hash=sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af \
--hash=sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8 \
--hash=sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9 \
--hash=sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88 \
--hash=sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba \
--hash=sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364 \
--hash=sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e \
--hash=sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7 \
--hash=sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0 \
--hash=sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31 \
--hash=sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683 \
--hash=sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee \
--hash=sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b \
--hash=sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884 \
--hash=sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c \
--hash=sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e \
--hash=sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562 \
--hash=sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85 \
--hash=sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c \
--hash=sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6 \
--hash=sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d \
--hash=sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b \
--hash=sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70 \
--hash=sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b \
--hash=sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b \
--hash=sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f \
--hash=sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0 \
--hash=sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5 \
--hash=sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5 \
--hash=sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f \
--hash=sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e \
--hash=sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512 \
--hash=sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d \
--hash=sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917 \
--hash=sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f
mypy-extensions==0.4.3 \
--hash=sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d \
--hash=sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8
typed-ast==1.4.1 \
--hash=sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355 \
--hash=sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919 \
--hash=sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d \
--hash=sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa \
--hash=sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652 \
--hash=sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75 \
--hash=sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c \
--hash=sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01 \
--hash=sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d \
--hash=sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1 \
--hash=sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907 \
--hash=sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c \
--hash=sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3 \
--hash=sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d \
--hash=sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b \
--hash=sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614 \
--hash=sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c \
--hash=sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb \
--hash=sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395 \
--hash=sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b \
--hash=sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41 \
--hash=sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6 \
--hash=sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34 \
--hash=sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe \
--hash=sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072 \
--hash=sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298 \
--hash=sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91 \
--hash=sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4 \
--hash=sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f \
--hash=sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7

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

@ -90,3 +90,6 @@ WebOb==1.8.6 \
wcwidth==0.2.5 \
--hash=sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784 \
--hash=sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83
toml==0.10.2 \
--hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \
--hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f

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

@ -6,7 +6,12 @@ from urllib.parse import parse_qsl
from time import time
from services.utils import (
get_cdn_url, log_configure, mypool, settings, PLATFORM_NAMES_TO_CONSTANTS)
get_cdn_url,
log_configure,
mypool,
settings,
PLATFORM_NAMES_TO_CONSTANTS,
)
# This has to be imported after the settings so statsd knows where to log to.
from django_statsd.clients import statsd
@ -27,7 +32,6 @@ log = olympia.core.logger.getLogger('z.services')
class Update(object):
def __init__(self, data, compat_mode='strict'):
self.conn, self.cursor = None, None
self.data = data.copy()
@ -60,11 +64,14 @@ class Update(object):
inactive = 0 AND
status NOT IN (%(STATUS_DELETED)s, %(STATUS_DISABLED)s)
LIMIT 1;"""
self.cursor.execute(sql, {
'guid': self.data['id'],
'STATUS_DELETED': base.STATUS_DELETED,
'STATUS_DISABLED': base.STATUS_DISABLED,
})
self.cursor.execute(
sql,
{
'guid': self.data['id'],
'STATUS_DELETED': base.STATUS_DELETED,
'STATUS_DISABLED': base.STATUS_DISABLED,
},
)
result = self.cursor.fetchone()
if result is None:
return False
@ -88,7 +95,8 @@ class Update(object):
data['STATUS_APPROVED'] = base.STATUS_APPROVED
data['RELEASE_CHANNEL_LISTED'] = base.RELEASE_CHANNEL_LISTED
sql = ["""
sql = [
"""
SELECT
addons.guid as guid, addons.addontype_id as type,
addons.inactive as disabled_by_user, appmin.version as min,
@ -111,11 +119,13 @@ class Update(object):
AND appmax.application_id = %(app_id)s
INNER JOIN files
ON files.version_id = versions.id AND (files.platform_id = 1
"""]
"""
]
if data.get('appOS'):
sql.append(' OR files.platform_id = %(appOS)s')
sql.append("""
sql.append(
"""
)
-- Find a reference to the user's current version, if it exists.
-- These should never be inner joins. We need results even if we
@ -128,7 +138,8 @@ class Update(object):
versions.deleted = 0 AND
versions.channel = %(RELEASE_CHANNEL_LISTED)s AND
files.status = %(STATUS_APPROVED)s
""")
"""
)
sql.append('AND appmin.version_int <= %(version_int)s ')
@ -138,11 +149,13 @@ class Update(object):
elif self.compat_mode == 'normal':
# When file has strict_compatibility enabled, or file has binary
# components, default to compatible is disabled.
sql.append("""AND
sql.append(
"""AND
CASE WHEN files.strict_compatibility = 1 OR
files.binary_components = 1
THEN appmax.version_int >= %(version_int)s ELSE 1 END
""")
"""
)
# Filter out versions that don't have the minimum maxVersion
# requirement to qualify for default-to-compatible.
d2c_min = applications.D2C_MIN_VERSIONS.get(data['app_id'])
@ -158,12 +171,27 @@ class Update(object):
result = self.cursor.fetchone()
if result:
row = dict(zip([
'guid', 'type', 'disabled_by_user', 'min', 'max',
'file_id', 'file_status', 'hash', 'filename', 'version_id',
'datestatuschanged', 'strict_compat', 'releasenotes',
'version'],
list(result)))
row = dict(
zip(
[
'guid',
'type',
'disabled_by_user',
'min',
'max',
'file_id',
'file_status',
'hash',
'filename',
'version_id',
'datestatuschanged',
'strict_compat',
'releasenotes',
'version',
],
list(result),
)
)
row['type'] = base.ADDON_SLUGS_UPDATE[row['type']]
row['url'] = get_cdn_url(data['id'], row)
row['appguid'] = applications.APPS_ALL[data['app_id']].guid
@ -189,24 +217,14 @@ class Update(object):
return {}
def get_no_updates_output(self):
return {
'addons': {
self.data['guid']: {
'updates': []
}
}
}
return {'addons': {self.data['guid']: {'updates': []}}}
def get_success_output(self):
data = self.data['row']
update = {
'version': data['version'],
'update_link': data['url'],
'applications': {
'gecko': {
'strict_min_version': data['min']
}
}
'applications': {'gecko': {'strict_min_version': data['min']}},
}
if data['strict_compat']:
update['applications']['gecko']['strict_max_version'] = data['max']
@ -214,25 +232,24 @@ class Update(object):
update['update_hash'] = data['hash']
if data['releasenotes']:
update['update_info_url'] = '%s%s%s/%%APP_LOCALE%%/' % (
settings.SITE_URL, '/versions/updateInfo/', data['version_id'])
return {
'addons': {
self.data['guid']: {
'updates': [update]
}
}
}
settings.SITE_URL,
'/versions/updateInfo/',
data['version_id'],
)
return {'addons': {self.data['guid']: {'updates': [update]}}}
def format_date(self, secs):
return '%s GMT' % formatdate(time() + secs)[:25]
def get_headers(self, length):
content_type = 'application/json'
return [('Content-Type', content_type),
('Cache-Control', 'public, max-age=3600'),
('Last-Modified', self.format_date(0)),
('Expires', self.format_date(3600)),
('Content-Length', str(length))]
return [
('Content-Type', content_type),
('Cache-Control', 'public, max-age=3600'),
('Last-Modified', self.format_date(0)),
('Expires', self.format_date(3600)),
('Content-Length', str(length)),
]
def application(environ, start_response):

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

@ -55,7 +55,8 @@ PLATFORM_NAMES_TO_CONSTANTS = {
}
version_re = re.compile(r"""(?P<major>\d+) # major (x in x.y)
version_re = re.compile(
r"""(?P<major>\d+) # major (x in x.y)
\.(?P<minor1>\d+) # minor1 (y in x.y)
\.?(?P<minor2>\d+|\*)? # minor2 (z in x.y.z)
\.?(?P<minor3>\d+|\*)? # minor3 (w in x.y.z.w)
@ -63,7 +64,9 @@ version_re = re.compile(r"""(?P<major>\d+) # major (x in x.y)
(?P<alpha_ver>\d*) # alpha/beta version
(?P<pre>pre)? # pre release
(?P<pre_ver>\d)? # pre release version
""", re.VERBOSE)
""",
re.VERBOSE,
)
def get_cdn_url(id, row):
@ -75,9 +78,13 @@ def get_cdn_url(id, row):
def getconn():
db = settings.SERVICES_DATABASE
return mysql.connect(host=db['HOST'], user=db['USER'],
passwd=db['PASSWORD'], db=db['NAME'],
charset=db['OPTIONS']['charset'])
return mysql.connect(
host=db['HOST'],
user=db['USER'],
passwd=db['PASSWORD'],
db=db['NAME'],
charset=db['OPTIONS']['charset'],
)
mypool = pool.QueuePool(getconn, max_overflow=10, pool_size=5, recycle=300)
@ -92,13 +99,13 @@ def log_configure():
'mozlog': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'json'
'formatter': 'json',
},
},
'formatters': {
'json': {
'()': olympia.core.logger.JsonFormatter,
'logger_name': 'http_app_addons'
'logger_name': 'http_app_addons',
},
},
}

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

@ -4,8 +4,7 @@ import site
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings_local")
wsgidir = os.path.dirname(__file__)
for path in ['../', '../..',
'../../apps']:
for path in ['../', '../..', '../../apps']:
site.addsitedir(os.path.abspath(os.path.join(wsgidir, path)))
from ..pfs import application # noqa

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

@ -4,9 +4,7 @@ import site
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings_local")
wsgidir = os.path.dirname(__file__)
for path in ['../',
'../..',
'../../apps']:
for path in ['../', '../..', '../../apps']:
site.addsitedir(os.path.abspath(os.path.join(wsgidir, path)))
from ..theme_update import application # noqa

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

@ -4,10 +4,7 @@ import site
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings_local")
wsgidir = os.path.dirname(__file__)
for path in ['../',
'../..',
'../../..',
'../../apps']:
for path in ['../', '../..', '../../..', '../../apps']:
site.addsitedir(os.path.abspath(os.path.join(wsgidir, path)))
from update import application # noqa

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

@ -23,7 +23,10 @@ filterwarnings =
ignore::ResourceWarning
[flake8]
ignore = F999,F405,W504
# E203: https://github.com/psf/black/issues/315
# W503: https://www.flake8rules.com/rules/W503.html
ignore = F999,F405,W503,E203
max-line-length = 88
exclude =
src/olympia/wsgi.py,
docs,
@ -49,5 +52,5 @@ default_section = THIRDPARTY
include_trailing_comma = false
known_django = django
known_olympia = olympia
line_length = 79
line_length = 88
sections=FUTURE,STDLIB,DJANGO,THIRDPARTY,OLYMPIA,FIRSTPARTY,LOCALFOLDER

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

@ -51,8 +51,7 @@ class AbuseReportTypeFilter(admin.SimpleListFilter):
if self.value() == 'user':
return queryset.filter(user__isnull=False)
elif self.value() == 'addon':
return queryset.filter(Q(addon__isnull=False) |
Q(guid__isnull=False))
return queryset.filter(Q(addon__isnull=False) | Q(guid__isnull=False))
return queryset
@ -69,8 +68,10 @@ class FakeChoicesMixin(object):
"""
# Grab search query parts and filter query parts as tuples of tuples.
search_query_parts = (
((admin.views.main.SEARCH_VAR, changelist.query),)
) if changelist.query else ()
(((admin.views.main.SEARCH_VAR, changelist.query),))
if changelist.query
else ()
)
filters_query_parts = tuple(
(k, v)
for k, v in changelist.get_filters_params().items()
@ -95,6 +96,7 @@ class MinimumReportsCountFilter(FakeChoicesMixin, admin.SimpleListFilter):
Original idea:
https://hakibenita.com/how-to-add-a-text-filter-to-django-admin
"""
template = 'admin/abuse/abusereport/minimum_reports_count_filter.html'
title = ugettext('minimum reports count (grouped by guid)')
parameter_name = 'minimum_reports_count'
@ -122,24 +124,33 @@ class DateRangeFilter(FakeChoicesMixin, DateRangeFilterBase):
Needs FakeChoicesMixin for the fake choices the template will be using (the
upstream implementation depends on JavaScript for this).
"""
template = 'admin/abuse/abusereport/date_range_filter.html'
title = ugettext('creation date')
def _get_form_fields(self):
return OrderedDict((
(self.lookup_kwarg_gte, forms.DateField(
label='From',
widget=HTML5DateInput(),
localize=True,
required=False
)),
(self.lookup_kwarg_lte, forms.DateField(
label='To',
widget=HTML5DateInput(),
localize=True,
required=False
)),
))
return OrderedDict(
(
(
self.lookup_kwarg_gte,
forms.DateField(
label='From',
widget=HTML5DateInput(),
localize=True,
required=False,
),
),
(
self.lookup_kwarg_lte,
forms.DateField(
label='To',
widget=HTML5DateInput(),
localize=True,
required=False,
),
),
)
)
def choices(self, changelist):
# We want a fake 'All' choice as per FakeChoicesMixin, but as of 0.3.15
@ -152,14 +163,20 @@ class DateRangeFilter(FakeChoicesMixin, DateRangeFilterBase):
class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
class Media:
css = {
'all': ('css/admin/abuse_reports.css',)
}
css = {'all': ('css/admin/abuse_reports.css',)}
actions = ('delete_selected', 'mark_as_valid', 'mark_as_suspicious')
date_hierarchy = 'modified'
list_display = ('target_name', 'guid', 'type', 'state', 'distribution',
'reason', 'message_excerpt', 'created')
list_display = (
'target_name',
'guid',
'type',
'state',
'distribution',
'reason',
'message_excerpt',
'created',
)
list_filter = (
AbuseReportTypeFilter,
'state',
@ -204,25 +221,30 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
ADDON_METADATA_FIELDSET = 'Add-on metadata'
fieldsets = (
(None, {'fields': ('state', 'reason', 'message')}),
(None, {'fields': (
'created',
'modified',
'reporter',
'country_code',
'client_id',
'addon_signature',
'application',
'application_version',
'application_locale',
'operating_system',
'operating_system_version',
'install_date',
'addon_install_origin',
'addon_install_method',
'addon_install_source',
'addon_install_source_url',
'report_entry_point'
)})
(
None,
{
'fields': (
'created',
'modified',
'reporter',
'country_code',
'client_id',
'addon_signature',
'application',
'application_version',
'application_locale',
'operating_system',
'operating_system_version',
'install_date',
'addon_install_origin',
'addon_install_method',
'addon_install_source',
'addon_install_source_url',
'report_entry_point',
)
},
),
)
# The first fieldset is going to be dynamically added through
# get_fieldsets() depending on the target (add-on, user or unknown add-on),
@ -247,7 +269,10 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
extra_context = extra_context or {}
extra_context['show_save_and_continue'] = False # Don't need this.
return super().change_view(
request, object_id, form_url, extra_context=extra_context,
request,
object_id,
form_url,
extra_context=extra_context,
)
def delete_queryset(self, request, queryset):
@ -269,12 +294,19 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
type_ = request.GET.get('type')
if type_ == 'addon':
search_fields = (
'addon__name__localized_string', 'addon__slug', 'addon_name',
'=guid', 'message', '=addon__id',
'addon__name__localized_string',
'addon__slug',
'addon_name',
'=guid',
'message',
'=addon__id',
)
elif type_ == 'user':
search_fields = (
'message', '=user__id', '^user__username', '^user__email',
'message',
'=user__id',
'^user__username',
'^user__email',
)
else:
search_fields = ()
@ -304,11 +336,13 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
# filtering is actually done here, because it needs to happen after
# all other filters have been applied in order for the aggregate
# queryset to be correct.
guids = (qs.values_list('guid', flat=True)
.filter(guid__isnull=False)
.annotate(Count('guid'))
.filter(guid__count__gte=minimum_reports_count)
.order_by())
guids = (
qs.values_list('guid', flat=True)
.filter(guid__isnull=False)
.annotate(Count('guid'))
.filter(guid__count__gte=minimum_reports_count)
.order_by()
)
qs = qs.filter(guid__in=list(guids))
qs, use_distinct = super().get_search_results(request, qs, search_term)
return qs, use_distinct
@ -322,8 +356,7 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
# through prefetch_related() + only_translations() (we don't care about
# the other transforms).
return qs.prefetch_related(
Prefetch(
'addon', queryset=Addon.unfiltered.all().only_translations()),
Prefetch('addon', queryset=Addon.unfiltered.all().only_translations()),
)
def get_fieldsets(self, request, obj=None):
@ -333,14 +366,13 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
target = 'user'
else:
target = 'guid'
dynamic_fieldset = (
(None, {'fields': self.dynamic_fieldset_fields[target]}),
)
dynamic_fieldset = ((None, {'fields': self.dynamic_fieldset_fields[target]}),)
return dynamic_fieldset + self.fieldsets
def target_name(self, obj):
name = obj.target.name if obj.target else obj.addon_name
return '%s %s' % (name, obj.addon_version or '')
target_name.short_description = ugettext('User / Add-on')
def addon_card(self, obj):
@ -359,28 +391,40 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
'addon_name': addon.name,
'approvals_info': approvals_info,
'reports': Paginator(
(AbuseReport.objects
.filter(Q(addon=addon) | Q(user__in=developers))
.order_by('-created')), 5).page(1),
(
AbuseReport.objects.filter(
Q(addon=addon) | Q(user__in=developers)
).order_by('-created')
),
5,
).page(1),
'user_ratings': Paginator(
(Rating.without_replies
.filter(addon=addon, rating__lte=3, body__isnull=False)
.order_by('-created')), 5).page(1),
(
Rating.without_replies.filter(
addon=addon, rating__lte=3, body__isnull=False
).order_by('-created')
),
5,
).page(1),
'version': addon.current_version,
}
return template.render(context)
addon_card.short_description = ''
def distribution(self, obj):
return obj.get_addon_signature_display() if obj.addon_signature else ''
distribution.short_description = ugettext('Distribution')
def reporter_country(self, obj):
return obj.country_code
reporter_country.short_description = ugettext("Reporter's country")
def message_excerpt(self, obj):
return truncate_text(obj.message, 140)[0] if obj.message else ''
message_excerpt.short_description = ugettext('Message excerpt')
def mark_as_valid(self, request, qs):
@ -389,8 +433,10 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
self.message_user(
request,
ugettext(
'The %d selected reports have been marked as valid.' % (
qs.count())))
'The %d selected reports have been marked as valid.' % (qs.count())
),
)
mark_as_valid.short_description = 'Mark selected abuse reports as valid'
def mark_as_suspicious(self, request, qs):
@ -399,10 +445,13 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
self.message_user(
request,
ugettext(
'The %d selected reports have been marked as suspicious.' % (
qs.count())))
mark_as_suspicious.short_description = (
ugettext('Mark selected abuse reports as suspicious'))
'The %d selected reports have been marked as suspicious.' % (qs.count())
),
)
mark_as_suspicious.short_description = ugettext(
'Mark selected abuse reports as suspicious'
)
admin.site.register(AbuseReport, AbuseReportAdmin)

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

@ -50,8 +50,11 @@ class AbuseReport(ModelBase):
REASONS = APIChoicesWithNone(
('DAMAGE', 1, 'Damages computer and/or data'),
('SPAM', 2, 'Creates spam or advertising'),
('SETTINGS', 3, 'Changes search / homepage / new tab page '
'without informing user'),
(
'SETTINGS',
3,
'Changes search / homepage / new tab page ' 'without informing user',
),
# `4` was previously 'New tab takeover' but has been merged into the
# previous one. We avoid re-using the value.
('BROKEN', 5, "Doesnt work, breaks websites, or slows Firefox down"),
@ -128,73 +131,90 @@ class AbuseReport(ModelBase):
# NULL if the reporter is anonymous.
reporter = models.ForeignKey(
UserProfile, null=True, blank=True, related_name='abuse_reported',
on_delete=models.SET_NULL)
country_code = models.CharField(
max_length=2, default=None, null=True)
UserProfile,
null=True,
blank=True,
related_name='abuse_reported',
on_delete=models.SET_NULL,
)
country_code = models.CharField(max_length=2, default=None, null=True)
# An abuse report can be for an addon or a user.
# If user is non-null then both addon and guid should be null.
# If user is null then addon should be non-null if guid was in our DB,
# otherwise addon will be null also.
# If both addon and user is null guid should be set.
addon = models.ForeignKey(
Addon, null=True, related_name='abuse_reports',
on_delete=models.CASCADE)
Addon, null=True, related_name='abuse_reports', on_delete=models.CASCADE
)
guid = models.CharField(max_length=255, null=True)
user = models.ForeignKey(
UserProfile, null=True, related_name='abuse_reports',
on_delete=models.SET_NULL)
UserProfile, null=True, related_name='abuse_reports', on_delete=models.SET_NULL
)
message = models.TextField(blank=True)
state = models.PositiveSmallIntegerField(
default=STATES.UNTRIAGED, choices=STATES.choices)
default=STATES.UNTRIAGED, choices=STATES.choices
)
# Extra optional fields for more information, giving some context that is
# meant to be extracted automatically by the client (i.e. Firefox) and
# submitted via the API.
client_id = models.CharField(
default=None, max_length=64, blank=True, null=True)
addon_name = models.CharField(
default=None, max_length=255, blank=True, null=True)
client_id = models.CharField(default=None, max_length=64, blank=True, null=True)
addon_name = models.CharField(default=None, max_length=255, blank=True, null=True)
addon_summary = models.CharField(
default=None, max_length=255, blank=True, null=True)
default=None, max_length=255, blank=True, null=True
)
addon_version = models.CharField(
default=None, max_length=255, blank=True, null=True)
default=None, max_length=255, blank=True, null=True
)
addon_signature = models.PositiveSmallIntegerField(
default=None, choices=ADDON_SIGNATURES.choices, blank=True, null=True)
default=None, choices=ADDON_SIGNATURES.choices, blank=True, null=True
)
application = models.PositiveSmallIntegerField(
default=amo.FIREFOX.id, choices=amo.APPS_CHOICES, blank=True,
null=True)
default=amo.FIREFOX.id, choices=amo.APPS_CHOICES, blank=True, null=True
)
application_version = models.CharField(
default=None, max_length=255, blank=True, null=True)
default=None, max_length=255, blank=True, null=True
)
application_locale = models.CharField(
default=None, max_length=255, blank=True, null=True)
default=None, max_length=255, blank=True, null=True
)
operating_system = models.CharField(
default=None, max_length=255, blank=True, null=True)
default=None, max_length=255, blank=True, null=True
)
operating_system_version = models.CharField(
default=None, max_length=255, blank=True, null=True)
install_date = models.DateTimeField(
default=None, blank=True, null=True)
default=None, max_length=255, blank=True, null=True
)
install_date = models.DateTimeField(default=None, blank=True, null=True)
reason = models.PositiveSmallIntegerField(
default=None, choices=REASONS.choices, blank=True, null=True)
default=None, choices=REASONS.choices, blank=True, null=True
)
addon_install_origin = models.CharField(
# Supposed to be an URL, but the scheme could be moz-foo: or something
# like that, and it's potentially truncated, so use a CharField and not
# a URLField. We also don't want to automatically turn this into a
# clickable link in the admin in case it's dangerous.
default=None, max_length=255, blank=True, null=True)
default=None,
max_length=255,
blank=True,
null=True,
)
addon_install_method = models.PositiveSmallIntegerField(
default=None, choices=ADDON_INSTALL_METHODS.choices, blank=True,
null=True)
default=None, choices=ADDON_INSTALL_METHODS.choices, blank=True, null=True
)
addon_install_source = models.PositiveSmallIntegerField(
default=None, choices=ADDON_INSTALL_SOURCES.choices, blank=True,
null=True)
default=None, choices=ADDON_INSTALL_SOURCES.choices, blank=True, null=True
)
addon_install_source_url = models.CharField(
# See addon_install_origin above as for why it's not an URLField.
default=None, max_length=255, blank=True, null=True)
default=None,
max_length=255,
blank=True,
null=True,
)
report_entry_point = models.PositiveSmallIntegerField(
default=None, choices=REPORT_ENTRY_POINTS.choices, blank=True,
null=True)
default=None, choices=REPORT_ENTRY_POINTS.choices, blank=True, null=True
)
unfiltered = AbuseReportManager(include_deleted=True)
objects = AbuseReportManager()
@ -238,7 +258,7 @@ class AbuseReport(ModelBase):
def type(self):
with translation.override(settings.LANGUAGE_CODE):
if self.addon and self.addon.type in amo.ADDON_TYPE:
type_ = (translation.ugettext(amo.ADDON_TYPE[self.addon.type]))
type_ = translation.ugettext(amo.ADDON_TYPE[self.addon.type])
elif self.user:
type_ = 'User'
else:

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

@ -23,15 +23,14 @@ class BaseAbuseReportSerializer(serializers.ModelSerializer):
def validate_target(self, data, target_name):
if target_name not in data:
msg = serializers.Field.default_error_messages['required']
raise serializers.ValidationError({
target_name: [msg]
})
raise serializers.ValidationError({target_name: [msg]})
def to_internal_value(self, data):
output = super(BaseAbuseReportSerializer, self).to_internal_value(data)
request = self.context['request']
output['country_code'] = AbuseReport.lookup_country_code_from_ip(
request.META.get('REMOTE_ADDR'))
request.META.get('REMOTE_ADDR')
)
if request.user.is_authenticated:
output['reporter'] = request.user
return output
@ -40,40 +39,54 @@ class BaseAbuseReportSerializer(serializers.ModelSerializer):
class AddonAbuseReportSerializer(BaseAbuseReportSerializer):
error_messages = {
'max_length': _(
'Please ensure this field has no more than {max_length} '
'characters.'
'Please ensure this field has no more than {max_length} ' 'characters.'
)
}
addon = serializers.SerializerMethodField()
reason = ReverseChoiceField(
choices=list(AbuseReport.REASONS.api_choices), required=False,
allow_null=True)
choices=list(AbuseReport.REASONS.api_choices), required=False, allow_null=True
)
# 'message' has custom validation rules below depending on whether 'reason'
# was provided or not. We need to not set it as required and allow blank at
# the field level to make that work.
message = serializers.CharField(
required=False, allow_blank=True, max_length=10000,
error_messages=error_messages)
required=False,
allow_blank=True,
max_length=10000,
error_messages=error_messages,
)
app = ReverseChoiceField(
choices=list((v.id, k) for k, v in amo.APPS.items()), required=False,
source='application')
choices=list((v.id, k) for k, v in amo.APPS.items()),
required=False,
source='application',
)
appversion = serializers.CharField(
required=False, source='application_version', max_length=255)
required=False, source='application_version', max_length=255
)
lang = serializers.CharField(
required=False, source='application_locale', max_length=255)
required=False, source='application_locale', max_length=255
)
report_entry_point = ReverseChoiceField(
choices=list(AbuseReport.REPORT_ENTRY_POINTS.api_choices),
required=False, allow_null=True)
required=False,
allow_null=True,
)
addon_install_method = ReverseChoiceField(
choices=list(AbuseReport.ADDON_INSTALL_METHODS.api_choices),
required=False, allow_null=True)
required=False,
allow_null=True,
)
addon_install_source = ReverseChoiceField(
choices=list(AbuseReport.ADDON_INSTALL_SOURCES.api_choices),
required=False, allow_null=True)
required=False,
allow_null=True,
)
addon_signature = ReverseChoiceField(
choices=list(AbuseReport.ADDON_SIGNATURES.api_choices),
required=False, allow_null=True)
required=False,
allow_null=True,
)
class Meta:
model = AbuseReport
@ -95,7 +108,7 @@ class AddonAbuseReportSerializer(BaseAbuseReportSerializer):
'operating_system',
'operating_system_version',
'reason',
'report_entry_point'
'report_entry_point',
)
def validate(self, data):
@ -110,17 +123,18 @@ class AddonAbuseReportSerializer(BaseAbuseReportSerializer):
msg = serializers.Field.default_error_messages['null']
else:
msg = serializers.CharField.default_error_messages['blank']
raise serializers.ValidationError({
'message': [msg]
})
raise serializers.ValidationError({'message': [msg]})
return data
def handle_unknown_install_method_or_source(self, data, field_name):
reversed_choices = self.fields[field_name].reversed_choices
value = data[field_name]
if value not in reversed_choices:
log.warning('Unknown abuse report %s value submitted: %s',
field_name, str(data[field_name])[:255])
log.warning(
'Unknown abuse report %s value submitted: %s',
field_name,
str(data[field_name])[:255],
)
value = 'other'
return value
@ -130,22 +144,20 @@ class AddonAbuseReportSerializer(BaseAbuseReportSerializer):
# do it in a custom validation method because validation would be
# skipped entirely if the value is not a valid choice.
if 'addon_install_method' in data:
data['addon_install_method'] = (
self.handle_unknown_install_method_or_source(
data, 'addon_install_method'))
data['addon_install_method'] = self.handle_unknown_install_method_or_source(
data, 'addon_install_method'
)
if 'addon_install_source' in data:
data['addon_install_source'] = (
self.handle_unknown_install_method_or_source(
data, 'addon_install_source'))
data['addon_install_source'] = self.handle_unknown_install_method_or_source(
data, 'addon_install_source'
)
self.validate_target(data, 'addon')
view = self.context.get('view')
output = view.get_guid_and_addon()
# Pop 'addon' from data before passing that data to super(), we already
# have it in the output value.
data.pop('addon')
output.update(
super(AddonAbuseReportSerializer, self).to_internal_value(data)
)
output.update(super(AddonAbuseReportSerializer, self).to_internal_value(data))
return output
def get_addon(self, obj):
@ -163,25 +175,18 @@ class UserAbuseReportSerializer(BaseAbuseReportSerializer):
user = BaseUserSerializer(required=False) # We validate it ourselves.
# Unlike add-on reports, for user reports we don't have a 'reason' field so
# the message is always required and can't be blank.
message = serializers.CharField(
required=True, allow_blank=False, max_length=10000)
message = serializers.CharField(required=True, allow_blank=False, max_length=10000)
class Meta:
model = AbuseReport
fields = BaseAbuseReportSerializer.Meta.fields + (
'user',
)
fields = BaseAbuseReportSerializer.Meta.fields + ('user',)
def to_internal_value(self, data):
view = self.context.get('view')
self.validate_target(data, 'user')
output = {
'user': view.get_user_object()
}
output = {'user': view.get_user_object()}
# Pop 'user' before passing it to super(), we already have the
# output value and did the validation above.
data.pop('user')
output.update(
super(UserAbuseReportSerializer, self).to_internal_value(data)
)
output.update(super(UserAbuseReportSerializer, self).to_internal_value(data))
return output

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

@ -4,8 +4,7 @@ from urllib.parse import parse_qsl, urlparse
from django.conf import settings
from django.contrib import admin
from django.contrib.messages.storage import (
default_storage as default_messages_storage)
from django.contrib.messages.storage import default_storage as default_messages_storage
from django.test import RequestFactory
from django.urls import reverse
@ -16,7 +15,11 @@ from olympia.abuse.admin import AbuseReportAdmin
from olympia.abuse.models import AbuseReport
from olympia.addons.models import AddonApprovalsCounter
from olympia.amo.tests import (
addon_factory, days_ago, grant_permission, TestCase, user_factory
addon_factory,
days_ago,
grant_permission,
TestCase,
user_factory,
)
from olympia.ratings.models import Rating
from olympia.reviewers.models import AutoApprovalSummary
@ -24,37 +27,48 @@ from olympia.versions.models import VersionPreview
class TestAbuse(TestCase):
@classmethod
def setUpTestData(cls):
cls.addon1 = addon_factory(guid='@guid1', name='Neo')
cls.addon1.name.__class__.objects.create(
id=cls.addon1.name_id, locale='fr', localized_string='Elu')
id=cls.addon1.name_id, locale='fr', localized_string='Elu'
)
cls.addon2 = addon_factory(guid='@guid2', name='Two')
cls.addon3 = addon_factory(guid='@guid3', name='Three')
cls.user = user_factory(email='someone@mozilla.com')
grant_permission(cls.user, 'AbuseReports:Edit', 'Abuse Report Triage')
# Create a few abuse reports.
cls.report1 = AbuseReport.objects.create(
addon=cls.addon1, guid='@guid1', message='Foo',
addon=cls.addon1,
guid='@guid1',
message='Foo',
state=AbuseReport.STATES.VALID,
created=days_ago(98))
created=days_ago(98),
)
AbuseReport.objects.create(
addon=cls.addon2, guid='@guid2', message='Bar',
state=AbuseReport.STATES.VALID)
addon=cls.addon2,
guid='@guid2',
message='Bar',
state=AbuseReport.STATES.VALID,
)
AbuseReport.objects.create(
addon=cls.addon3, guid='@guid3', message='Soap',
addon=cls.addon3,
guid='@guid3',
message='Soap',
reason=AbuseReport.REASONS.OTHER,
created=days_ago(100))
created=days_ago(100),
)
AbuseReport.objects.create(
addon=cls.addon1, guid='@guid1', message='',
reporter=user_factory())
addon=cls.addon1, guid='@guid1', message='', reporter=user_factory()
)
# This is a report for an addon not in the database.
cls.report2 = AbuseReport.objects.create(
guid='@unknown_guid', addon_name='Mysterious Addon', message='Doo')
guid='@unknown_guid', addon_name='Mysterious Addon', message='Doo'
)
# This is one against a user.
cls.report3 = AbuseReport.objects.create(
user=user_factory(username='malicious_user'), message='Ehehehehe')
user=user_factory(username='malicious_user'), message='Ehehehehe'
)
def setUp(self):
self.client.login(email=self.user.email)
@ -83,15 +97,13 @@ class TestAbuse(TestCase):
assert doc('#result_list tbody tr').length == expected_length
def test_list_filter_by_type(self):
response = self.client.get(
self.list_url, {'type': 'addon'}, follow=True)
response = self.client.get(self.list_url, {'type': 'addon'}, follow=True)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#result_list tbody tr').length == 5
assert 'Ehehehehe' not in doc('#result_list').text()
response = self.client.get(
self.list_url, {'type': 'user'}, follow=True)
response = self.client.get(self.list_url, {'type': 'user'}, follow=True)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#result_list tbody tr').length == 1
@ -103,8 +115,7 @@ class TestAbuse(TestCase):
assert lis.text().split() == ['Users', 'All', 'All', 'All', 'All']
def test_search_deactivated_if_not_filtering_by_type(self):
response = self.client.get(
self.list_url, {'q': 'Mysterious'}, follow=True)
response = self.client.get(self.list_url, {'q': 'Mysterious'}, follow=True)
assert response.status_code == 200
doc = pq(response.content)
assert not doc('#changelist-search')
@ -112,7 +123,8 @@ class TestAbuse(TestCase):
def test_search_user(self):
response = self.client.get(
self.list_url, {'q': 'Ehe', 'type': 'user'}, follow=True)
self.list_url, {'q': 'Ehe', 'type': 'user'}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -121,8 +133,8 @@ class TestAbuse(TestCase):
user = AbuseReport.objects.get(message='Ehehehehe').user
response = self.client.get(
self.list_url, {'q': user.username[:4], 'type': 'user'},
follow=True)
self.list_url, {'q': user.username[:4], 'type': 'user'}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -131,8 +143,8 @@ class TestAbuse(TestCase):
user = AbuseReport.objects.get(message='Ehehehehe').user
response = self.client.get(
self.list_url, {'q': user.email[:3], 'type': 'user'},
follow=True)
self.list_url, {'q': user.email[:3], 'type': 'user'}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -140,8 +152,8 @@ class TestAbuse(TestCase):
assert 'Ehehehehe' in doc('#result_list').text()
response = self.client.get(
self.list_url, {'q': str(user.pk), 'type': 'user'},
follow=True)
self.list_url, {'q': str(user.pk), 'type': 'user'}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -149,8 +161,8 @@ class TestAbuse(TestCase):
assert 'Ehehehehe' in doc('#result_list').text()
response = self.client.get(
self.list_url, {'q': 'NotGoingToFindAnything', 'type': 'user'},
follow=True)
self.list_url, {'q': 'NotGoingToFindAnything', 'type': 'user'}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -159,7 +171,8 @@ class TestAbuse(TestCase):
def test_search_addon(self):
response = self.client.get(
self.list_url, {'q': 'sterious', 'type': 'addon'}, follow=True)
self.list_url, {'q': 'sterious', 'type': 'addon'}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -167,8 +180,8 @@ class TestAbuse(TestCase):
assert 'Mysterious' in doc('#result_list').text()
response = self.client.get(
self.list_url, {'q': str(self.addon1.pk), 'type': 'addon'},
follow=True)
self.list_url, {'q': str(self.addon1.pk), 'type': 'addon'}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -176,8 +189,8 @@ class TestAbuse(TestCase):
assert 'Neo' in doc('#result_list').text()
response = self.client.get(
self.list_url, {'q': 'NotGoingToFindAnything', 'type': 'addon'},
follow=True)
self.list_url, {'q': 'NotGoingToFindAnything', 'type': 'addon'}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -189,7 +202,8 @@ class TestAbuse(TestCase):
response = self.client.get(
self.list_url,
{'q': '%s,%s' % (self.addon1.pk, self.addon2.pk), 'type': 'addon'},
follow=True)
follow=True,
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -199,13 +213,13 @@ class TestAbuse(TestCase):
def test_search_multiple_users(self):
user1 = AbuseReport.objects.get(message='Ehehehehe').user
user2 = user_factory(username='second_user')
AbuseReport.objects.create(
user=user2, message='One more')
AbuseReport.objects.create(user=user2, message='One more')
response = self.client.get(
self.list_url,
{'q': '%s,%s' % (user1.pk, user2.pk), 'type': 'user'},
follow=True)
follow=True,
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#changelist-search')
@ -213,8 +227,8 @@ class TestAbuse(TestCase):
def test_filter_by_state(self):
response = self.client.get(
self.list_url, {'state__exact': AbuseReport.STATES.VALID},
follow=True)
self.list_url, {'state__exact': AbuseReport.STATES.VALID}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#result_list tbody tr').length == 2
@ -229,8 +243,8 @@ class TestAbuse(TestCase):
def test_filter_by_reason(self):
response = self.client.get(
self.list_url, {'reason__exact': AbuseReport.REASONS.OTHER},
follow=True)
self.list_url, {'reason__exact': AbuseReport.REASONS.OTHER}, follow=True
)
assert response.status_code == 200
doc = pq(response.content)
assert doc('#result_list tbody tr').length == 1
@ -263,9 +277,7 @@ class TestAbuse(TestCase):
# (because of the "All" default choice) but since 'created' is actually
# 2 fields, and we have submitted both, we now have 6 expected items.
assert len(lis) == 6
assert lis.text().split() == [
'All', 'All', 'All', 'From:', 'To:', 'All'
]
assert lis.text().split() == ['All', 'All', 'All', 'From:', 'To:', 'All']
elm = lis.eq(3).find('#id_created__range__gte')
assert elm
assert elm.attr('name') == 'created__range__gte'
@ -292,9 +304,7 @@ class TestAbuse(TestCase):
lis = doc('#changelist-filter li.selected')
# We've got 5 filters.
assert len(lis) == 5
assert lis.text().split() == [
'All', 'All', 'All', 'From:', 'All'
]
assert lis.text().split() == ['All', 'All', 'All', 'From:', 'All']
elm = lis.eq(3).find('#id_created__range__gte')
assert elm
assert elm.attr('name') == 'created__range__gte'
@ -317,18 +327,14 @@ class TestAbuse(TestCase):
lis = doc('#changelist-filter li.selected')
# We've got 5 filters.
assert len(lis) == 5
assert lis.text().split() == [
'All', 'All', 'All', 'To:', 'All'
]
assert lis.text().split() == ['All', 'All', 'All', 'To:', 'All']
elm = lis.eq(3).find('#id_created__range__lte')
assert elm
assert elm.attr('name') == 'created__range__lte'
assert elm.attr('value') == some_time_ago.isoformat()
def test_filter_by_minimum_reports_count_for_guid(self):
data = {
'minimum_reports_count': '2'
}
data = {'minimum_reports_count': '2'}
response = self.client.get(self.list_url, data, follow=True)
assert response.status_code == 200
doc = pq(response.content)
@ -344,7 +350,10 @@ class TestAbuse(TestCase):
# There is no label for minimum reports count, so despite having 5 lis
# we only have 4 things in .text().
assert lis.text().split() == [
'All', 'All', 'All', 'All',
'All',
'All',
'All',
'All',
]
# The 4th item should contain the input though.
elm = lis.eq(4).find('#id_minimum_reports_count')
@ -372,14 +381,20 @@ class TestAbuse(TestCase):
# Also, the forms we used for the 'created' filters should contain all
# active filters and search query so that we can combine them.
forms = doc('#changelist-filter form')
inputs = [(elm.name, elm.value) for elm in forms.find('input')
if elm.name and elm.value != '']
inputs = [
(elm.name, elm.value)
for elm in forms.find('input')
if elm.name and elm.value != ''
]
assert set(inputs) == set(data.items())
# Same for the 'search' form
form = doc('#changelist-filter form')
inputs = [(elm.name, elm.value) for elm in form.find('input')
if elm.name and elm.value != '']
inputs = [
(elm.name, elm.value)
for elm in form.find('input')
if elm.name and elm.value != ''
]
assert set(inputs) == set(data.items())
# Gather selected filters.
@ -389,9 +404,7 @@ class TestAbuse(TestCase):
# (because of the "All" default choice) but since 'created' is actually
# 2 fields, and we have submitted both, we now have 6 expected items.
assert len(lis) == 6
assert lis.text().split() == [
'Addons', 'All', 'Other', 'From:', 'To:', 'All'
]
assert lis.text().split() == ['Addons', 'All', 'Other', 'From:', 'To:', 'All']
assert lis.eq(3).find('#id_created__range__gte')
assert lis.eq(4).find('#id_created__range__lte')
@ -412,18 +425,19 @@ class TestAbuse(TestCase):
# self.user has AbuseReports:Edit
request.user = self.user
assert list(
abuse_report_admin.get_actions(request).keys()) == [
'mark_as_valid', 'mark_as_suspicious'
assert list(abuse_report_admin.get_actions(request).keys()) == [
'mark_as_valid',
'mark_as_suspicious',
]
# Advanced admins can also delete.
request.user = user_factory()
self.grant_permission(request.user, 'AbuseReports:Edit')
self.grant_permission(request.user, 'Admin:Advanced')
assert list(
abuse_report_admin.get_actions(request).keys()) == [
'delete_selected', 'mark_as_valid', 'mark_as_suspicious'
assert list(abuse_report_admin.get_actions(request).keys()) == [
'delete_selected',
'mark_as_valid',
'mark_as_suspicious',
]
def test_action_mark_multiple_as_valid(self):
@ -431,16 +445,14 @@ class TestAbuse(TestCase):
request = RequestFactory().post('/')
request.user = self.user
request._messages = default_messages_storage(request)
reports = AbuseReport.objects.filter(
guid__in=('@guid3', '@unknown_guid'))
reports = AbuseReport.objects.filter(guid__in=('@guid3', '@unknown_guid'))
assert reports.count() == 2
for report in reports.all():
assert report.state == AbuseReport.STATES.UNTRIAGED
other_report = AbuseReport.objects.get(guid='@guid1', message='')
assert other_report.state == AbuseReport.STATES.UNTRIAGED
action_callback = abuse_report_admin.get_actions(
request)['mark_as_valid'][0]
action_callback = abuse_report_admin.get_actions(request)['mark_as_valid'][0]
rval = action_callback(abuse_report_admin, request, reports)
assert rval is None # successful actions return None
for report in reports.all():
@ -454,16 +466,16 @@ class TestAbuse(TestCase):
request = RequestFactory().post('/')
request.user = self.user
request._messages = default_messages_storage(request)
reports = AbuseReport.objects.filter(
guid__in=('@guid3', '@unknown_guid'))
reports = AbuseReport.objects.filter(guid__in=('@guid3', '@unknown_guid'))
assert reports.count() == 2
for report in reports.all():
assert report.state == AbuseReport.STATES.UNTRIAGED
other_report = AbuseReport.objects.get(guid='@guid1', message='')
assert other_report.state == AbuseReport.STATES.UNTRIAGED
action_callback = abuse_report_admin.get_actions(
request)['mark_as_suspicious'][0]
action_callback = abuse_report_admin.get_actions(request)['mark_as_suspicious'][
0
]
rval = action_callback(abuse_report_admin, request, reports)
assert rval is None # successful actions return None
for report in reports.all():
@ -479,14 +491,12 @@ class TestAbuse(TestCase):
self.grant_permission(request.user, 'AbuseReports:Edit')
self.grant_permission(request.user, 'Admin:Advanced')
request._messages = default_messages_storage(request)
reports = AbuseReport.objects.filter(
guid__in=('@guid3', '@unknown_guid'))
reports = AbuseReport.objects.filter(guid__in=('@guid3', '@unknown_guid'))
assert reports.count() == 2
assert AbuseReport.objects.count() == 6
assert AbuseReport.unfiltered.count() == 6
action_callback = abuse_report_admin.get_actions(
request)['delete_selected'][0]
action_callback = abuse_report_admin.get_actions(request)['delete_selected'][0]
rval = action_callback(abuse_report_admin, request, reports)
assert rval is None # successful actions return None
assert reports.count() == 0 # All should have been soft-deleted.
@ -495,14 +505,17 @@ class TestAbuse(TestCase):
def test_detail_addon_report(self):
AddonApprovalsCounter.objects.create(
addon=self.addon1, last_human_review=datetime.now())
addon=self.addon1, last_human_review=datetime.now()
)
Rating.objects.create(
addon=self.addon1, rating=2.0, body='Badd-on', user=user_factory())
addon=self.addon1, rating=2.0, body='Badd-on', user=user_factory()
)
AutoApprovalSummary.objects.create(
version=self.addon1.current_version,
verdict=amo.AUTO_APPROVED)
version=self.addon1.current_version, verdict=amo.AUTO_APPROVED
)
self.detail_url = reverse(
'admin:abuse_abusereport_change', args=(self.report1.pk,))
'admin:abuse_abusereport_change', args=(self.report1.pk,)
)
response = self.client.get(self.detail_url, follow=True)
assert response.status_code == 200
doc = pq(response.content)
@ -511,13 +524,12 @@ class TestAbuse(TestCase):
assert 'Neo' in doc('.addon-info-and-previews h2').text()
assert doc('.addon-info-and-previews .meta-abuse td').text() == '2'
assert doc('.addon-info-and-previews .meta-rating td').text() == (
'Rated 2 out of 5 stars 1 review')
'Rated 2 out of 5 stars 1 review'
)
assert doc('.addon-info-and-previews .last-approval-date td').text()
assert doc('.reports-and-ratings')
assert doc('.reports-and-ratings h3').eq(0).text() == (
'Abuse Reports (2)')
assert doc('.reports-and-ratings h3').eq(1).text() == (
'Bad User Ratings (1)')
assert doc('.reports-and-ratings h3').eq(0).text() == ('Abuse Reports (2)')
assert doc('.reports-and-ratings h3').eq(1).text() == ('Bad User Ratings (1)')
# 'addon-info-and-previews' and 'reports-and-ratings' are coming from a
# reviewer tools template and shouldn't contain any admin-specific
# links. It also means that all links in it should be external, in
@ -539,7 +551,8 @@ class TestAbuse(TestCase):
def test_detail_guid_report(self):
self.detail_url = reverse(
'admin:abuse_abusereport_change', args=(self.report2.pk,))
'admin:abuse_abusereport_change', args=(self.report2.pk,)
)
response = self.client.get(self.detail_url, follow=True)
assert response.status_code == 200
doc = pq(response.content)
@ -549,7 +562,8 @@ class TestAbuse(TestCase):
def test_detail_user_report(self):
self.detail_url = reverse(
'admin:abuse_abusereport_change', args=(self.report3.pk,))
'admin:abuse_abusereport_change', args=(self.report3.pk,)
)
response = self.client.get(self.detail_url, follow=True)
assert response.status_code == 200
doc = pq(response.content)

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

@ -45,8 +45,7 @@ class TestAbuse(TestCase):
(None, 'None'),
(1, 'Damages computer and/or data'),
(2, 'Creates spam or advertising'),
(3, 'Changes search / homepage / new tab page without informing '
'user'),
(3, 'Changes search / homepage / new tab page without informing ' 'user'),
(5, "Doesnt work, breaks websites, or slows Firefox down"),
(6, 'Hateful, violent, or illegal content'),
(7, "Pretends to be something its not"),
@ -81,7 +80,7 @@ class TestAbuse(TestCase):
(12, 'Temporary Add-on'),
(13, 'Sync'),
(14, 'URL'),
(127, 'Other')
(127, 'Other'),
)
assert AbuseReport.ADDON_INSTALL_METHODS.api_choices == (
@ -100,7 +99,7 @@ class TestAbuse(TestCase):
(12, 'temporary_addon'),
(13, 'sync'),
(14, 'url'),
(127, 'other')
(127, 'other'),
)
assert AbuseReport.ADDON_INSTALL_SOURCES.choices == (
@ -123,7 +122,7 @@ class TestAbuse(TestCase):
(16, 'System Add-on'),
(17, 'Temporary Add-on'),
(18, 'Unknown'),
(127, 'Other')
(127, 'Other'),
)
assert AbuseReport.ADDON_INSTALL_SOURCES.api_choices == (
@ -146,7 +145,7 @@ class TestAbuse(TestCase):
(16, 'system_addon'),
(17, 'temporary_addon'),
(18, 'unknown'),
(127, 'other')
(127, 'other'),
)
assert AbuseReport.REPORT_ENTRY_POINTS.choices == (

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

@ -9,13 +9,14 @@ from django.test.client import RequestFactory
from olympia import amo
from olympia.abuse.models import AbuseReport
from olympia.abuse.serializers import (
AddonAbuseReportSerializer, UserAbuseReportSerializer)
AddonAbuseReportSerializer,
UserAbuseReportSerializer,
)
from olympia.accounts.serializers import BaseUserSerializer
from olympia.amo.tests import TestCase, addon_factory, user_factory
class TestAddonAbuseReportSerializer(TestCase):
def serialize(self, report, **extra_context):
return AddonAbuseReportSerializer(report, context=extra_context).data
@ -25,11 +26,7 @@ class TestAddonAbuseReportSerializer(TestCase):
serialized = self.serialize(report)
assert serialized == {
'reporter': None,
'addon': {
'guid': addon.guid,
'id': addon.id,
'slug': addon.slug
},
'addon': {'guid': addon.guid, 'id': addon.id, 'slug': addon.slug},
'message': 'bad stuff',
'addon_install_method': None,
'addon_install_origin': None,
@ -55,11 +52,7 @@ class TestAddonAbuseReportSerializer(TestCase):
serialized = self.serialize(report)
assert serialized == {
'reporter': None,
'addon': {
'guid': '@guid',
'id': None,
'slug': None
},
'addon': {'guid': '@guid', 'id': None, 'slug': None},
'message': 'bad stuff',
'addon_install_method': None,
'addon_install_origin': None,
@ -115,7 +108,8 @@ class TestAddonAbuseReportSerializer(TestCase):
'report_entry_point': 'uninstall',
}
result = AddonAbuseReportSerializer(
data, context=extra_context).to_internal_value(data)
data, context=extra_context
).to_internal_value(data)
expected = {
'addon': None,
'addon_install_method': AbuseReport.ADDON_INSTALL_METHODS.URL,
@ -143,7 +137,6 @@ class TestAddonAbuseReportSerializer(TestCase):
class TestUserAbuseReportSerializer(TestCase):
def serialize(self, report, **extra_context):
return UserAbuseReportSerializer(report, context=extra_context).data
@ -155,5 +148,5 @@ class TestUserAbuseReportSerializer(TestCase):
assert serialized == {
'reporter': None,
'user': serialized_user,
'message': 'bad stuff'
'message': 'bad stuff',
}

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

@ -7,7 +7,12 @@ from unittest import mock
from olympia import amo
from olympia.abuse.models import AbuseReport
from olympia.amo.tests import (
APITestClient, TestCase, addon_factory, reverse_ns, user_factory)
APITestClient,
TestCase,
addon_factory,
reverse_ns,
user_factory,
)
class AddonAbuseViewSetTestBase(object):
@ -33,14 +38,14 @@ class AddonAbuseViewSetTestBase(object):
response = self.client.post(
self.url,
data={'addon': str(addon.id), 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(addon_id=addon.id).exists()
report = AbuseReport.objects.get(addon_id=addon.id)
assert report.guid == addon.guid
self.check_report(report,
u'[Extension] Abuse Report for %s' % addon.name)
self.check_report(report, u'[Extension] Abuse Report for %s' % addon.name)
assert report.message == 'abuse!'
def test_report_addon_by_slug(self):
@ -48,28 +53,28 @@ class AddonAbuseViewSetTestBase(object):
response = self.client.post(
self.url,
data={'addon': addon.slug, 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(addon_id=addon.id).exists()
report = AbuseReport.objects.get(addon_id=addon.id)
assert report.guid == addon.guid
self.check_report(report,
u'[Extension] Abuse Report for %s' % addon.name)
self.check_report(report, u'[Extension] Abuse Report for %s' % addon.name)
def test_report_addon_by_guid(self):
addon = addon_factory(guid='@badman')
response = self.client.post(
self.url,
data={'addon': addon.guid, 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(addon_id=addon.id).exists()
report = AbuseReport.objects.get(addon_id=addon.id)
assert report.guid == addon.guid
self.check_report(report,
u'[Extension] Abuse Report for %s' % addon.name)
self.check_report(report, u'[Extension] Abuse Report for %s' % addon.name)
assert report.message == 'abuse!'
def test_report_addon_guid_not_on_amo(self):
@ -77,20 +82,20 @@ class AddonAbuseViewSetTestBase(object):
response = self.client.post(
self.url,
data={'addon': guid, 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(guid=guid).exists()
report = AbuseReport.objects.get(guid=guid)
assert not report.addon
self.check_report(report,
u'[Addon] Abuse Report for %s' % guid)
self.check_report(report, u'[Addon] Abuse Report for %s' % guid)
assert report.message == 'abuse!'
def test_report_addon_invalid_identifier(self):
response = self.client.post(
self.url,
data={'addon': 'randomnotguid', 'message': 'abuse!'})
self.url, data={'addon': 'randomnotguid', 'message': 'abuse!'}
)
assert response.status_code == 404
def test_addon_not_public(self):
@ -98,89 +103,78 @@ class AddonAbuseViewSetTestBase(object):
response = self.client.post(
self.url,
data={'addon': str(addon.id), 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(addon_id=addon.id).exists()
report = AbuseReport.objects.get(addon_id=addon.id)
self.check_report(report,
u'[Extension] Abuse Report for %s' % addon.name)
self.check_report(report, u'[Extension] Abuse Report for %s' % addon.name)
assert report.message == 'abuse!'
def test_no_addon_fails(self):
response = self.client.post(
self.url,
data={'message': 'abuse!'})
response = self.client.post(self.url, data={'message': 'abuse!'})
assert response.status_code == 400
assert json.loads(response.content) == {
'addon': ['This field is required.']}
assert json.loads(response.content) == {'addon': ['This field is required.']}
def test_message_required_empty(self):
addon = addon_factory()
response = self.client.post(
self.url,
data={'addon': str(addon.id),
'message': ''})
self.url, data={'addon': str(addon.id), 'message': ''}
)
assert response.status_code == 400
assert json.loads(response.content) == {
'message': ['This field may not be blank.']}
'message': ['This field may not be blank.']
}
def test_message_required_missing(self):
addon = addon_factory()
response = self.client.post(
self.url,
data={'addon': str(addon.id)})
response = self.client.post(self.url, data={'addon': str(addon.id)})
assert response.status_code == 400
assert json.loads(response.content) == {
'message': ['This field is required.']}
assert json.loads(response.content) == {'message': ['This field is required.']}
def test_message_not_required_if_reason_is_provided(self):
addon = addon_factory()
response = self.client.post(
self.url,
data={'addon': str(addon.id), 'reason': 'broken'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(addon_id=addon.id).exists()
report = AbuseReport.objects.get(addon_id=addon.id)
self.check_report(report,
u'[Extension] Abuse Report for %s' % addon.name)
self.check_report(report, u'[Extension] Abuse Report for %s' % addon.name)
assert report.message == ''
def test_message_can_be_blank_if_reason_is_provided(self):
addon = addon_factory()
response = self.client.post(
self.url,
data={'addon': str(addon.id), 'reason': 'broken',
'message': ''},
REMOTE_ADDR='123.45.67.89')
data={'addon': str(addon.id), 'reason': 'broken', 'message': ''},
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(addon_id=addon.id).exists()
report = AbuseReport.objects.get(addon_id=addon.id)
self.check_report(report,
u'[Extension] Abuse Report for %s' % addon.name)
self.check_report(report, u'[Extension] Abuse Report for %s' % addon.name)
assert report.message == ''
def test_message_length_limited(self):
addon = addon_factory()
response = self.client.post(
self.url,
data={'addon': str(addon.id),
'message': 'a' * 10000})
self.url, data={'addon': str(addon.id), 'message': 'a' * 10000}
)
assert response.status_code == 201
response = self.client.post(
self.url,
data={'addon': str(addon.id),
'message': 'a' * 10001})
self.url, data={'addon': str(addon.id), 'message': 'a' * 10001}
)
assert response.status_code == 400
assert json.loads(response.content) == {
'message': [
'Please ensure this field has no more than 10000 characters.'
]
'message': ['Please ensure this field has no more than 10000 characters.']
}
def test_throttle(self):
@ -189,13 +183,15 @@ class AddonAbuseViewSetTestBase(object):
response = self.client.post(
self.url,
data={'addon': str(addon.id), 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201, x
response = self.client.post(
self.url,
data={'addon': str(addon.id), 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 429
def test_optional_fields(self):
@ -217,21 +213,23 @@ class AddonAbuseViewSetTestBase(object):
'addon_install_method': 'url',
'report_entry_point': None,
}
response = self.client.post(
self.url,
data=data,
REMOTE_ADDR='123.45.67.89')
response = self.client.post(self.url, data=data, REMOTE_ADDR='123.45.67.89')
assert response.status_code == 201, response.content
assert AbuseReport.objects.filter(guid=data['addon']).exists()
report = AbuseReport.objects.get(guid=data['addon'])
self.check_report(
report, u'[Addon] Abuse Report for %s' % data['addon'])
self.check_report(report, u'[Addon] Abuse Report for %s' % data['addon'])
assert not report.addon # Not an add-on in database, that's ok.
# Straightforward comparisons:
for field in ('message', 'client_id', 'addon_name', 'addon_summary',
'addon_version', 'operating_system',
'addon_install_origin'):
for field in (
'message',
'client_id',
'addon_name',
'addon_summary',
'addon_version',
'operating_system',
'addon_install_origin',
):
assert getattr(report, field) == data[field], field
# More complex comparisons:
assert report.addon_signature is None
@ -240,8 +238,7 @@ class AddonAbuseViewSetTestBase(object):
assert report.application_locale == data['lang']
assert report.install_date == datetime(2004, 8, 15, 16, 23, 42)
assert report.reason == 2 # Spam / Advertising
assert report.addon_install_method == (
AbuseReport.ADDON_INSTALL_METHODS.URL)
assert report.addon_install_method == (AbuseReport.ADDON_INSTALL_METHODS.URL)
assert report.addon_install_source is None
assert report.addon_install_source_url is None
assert report.report_entry_point is None
@ -267,33 +264,32 @@ class AddonAbuseViewSetTestBase(object):
'addon_install_source_url': 'http://%s' % 'a' * 249,
'report_entry_point': 'Something not in entrypoint choices',
}
response = self.client.post(
self.url,
data=data,
REMOTE_ADDR='123.45.67.89')
response = self.client.post(self.url, data=data, REMOTE_ADDR='123.45.67.89')
assert response.status_code == 400
expected_max_length_message = (
'Ensure this field has no more than %d characters.')
'Ensure this field has no more than %d characters.'
)
expected_choices_message = '"%s" is not a valid choice.'
assert response.json() == {
'client_id': [expected_max_length_message % 64],
'addon_name': [expected_max_length_message % 255],
'addon_summary': [expected_max_length_message % 255],
'addon_version': [expected_max_length_message % 255],
'addon_signature': [
expected_choices_message % data['addon_signature']],
'addon_signature': [expected_choices_message % data['addon_signature']],
'app': [expected_choices_message % data['app']],
'appversion': [expected_max_length_message % 255],
'lang': [expected_max_length_message % 255],
'operating_system': [expected_max_length_message % 255],
'install_date': [
'Datetime has wrong format. Use one of these formats '
'instead: YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].'],
'instead: YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].'
],
'reason': [expected_choices_message % data['reason']],
'addon_install_origin': [expected_max_length_message % 255],
'addon_install_source_url': [expected_max_length_message % 255],
'report_entry_point': [
expected_choices_message % data['report_entry_point']],
expected_choices_message % data['report_entry_point']
],
}
# Note: addon_install_method and addon_install_source silently convert
# unknown values to "other", so the values submitted here, despite not
@ -312,13 +308,10 @@ class AddonAbuseViewSetTestBase(object):
assert AbuseReport.objects.filter(guid=data['addon']).exists()
report = AbuseReport.objects.get(guid=data['addon'])
self.check_report(
report, u'[Addon] Abuse Report for %s' % data['addon'])
self.check_report(report, u'[Addon] Abuse Report for %s' % data['addon'])
assert not report.addon # Not an add-on in database, that's ok.
assert report.addon_install_method == (
AbuseReport.ADDON_INSTALL_METHODS.OTHER)
assert report.addon_install_source == (
AbuseReport.ADDON_INSTALL_SOURCES.OTHER)
assert report.addon_install_method == (AbuseReport.ADDON_INSTALL_METHODS.OTHER)
assert report.addon_install_source == (AbuseReport.ADDON_INSTALL_SOURCES.OTHER)
def test_addon_unknown_install_source_and_method_not_string(self):
addon = addon_factory()
@ -333,13 +326,10 @@ class AddonAbuseViewSetTestBase(object):
assert AbuseReport.objects.filter(guid=addon.guid).exists()
report = AbuseReport.objects.get(addon=addon)
self.check_report(
report, u'[Extension] Abuse Report for %s' % addon.name)
self.check_report(report, u'[Extension] Abuse Report for %s' % addon.name)
assert report.addon == addon
assert report.addon_install_method == (
AbuseReport.ADDON_INSTALL_METHODS.OTHER)
assert report.addon_install_source == (
AbuseReport.ADDON_INSTALL_SOURCES.OTHER)
assert report.addon_install_method == (AbuseReport.ADDON_INSTALL_METHODS.OTHER)
assert report.addon_install_source == (AbuseReport.ADDON_INSTALL_SOURCES.OTHER)
class TestAddonAbuseViewSetLoggedOut(AddonAbuseViewSetTestBase, TestCase):
@ -380,67 +370,63 @@ class UserAbuseViewSetTestBase(object):
response = self.client.post(
self.url,
data={'user': str(user.id), 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(user_id=user.id).exists()
report = AbuseReport.objects.get(user_id=user.id)
self.check_report(report,
u'[User] Abuse Report for %s' % user.name)
self.check_report(report, u'[User] Abuse Report for %s' % user.name)
def test_report_user_username(self):
user = user_factory()
response = self.client.post(
self.url,
data={'user': str(user.username), 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201
assert AbuseReport.objects.filter(user_id=user.id).exists()
report = AbuseReport.objects.get(user_id=user.id)
self.check_report(report,
u'[User] Abuse Report for %s' % user.name)
self.check_report(report, u'[User] Abuse Report for %s' % user.name)
def test_no_user_fails(self):
response = self.client.post(
self.url,
data={'message': 'abuse!'})
response = self.client.post(self.url, data={'message': 'abuse!'})
assert response.status_code == 400
assert json.loads(response.content) == {
'user': ['This field is required.']}
assert json.loads(response.content) == {'user': ['This field is required.']}
def test_message_required_empty(self):
user = user_factory()
response = self.client.post(
self.url,
data={'user': str(user.username), 'message': ''})
self.url, data={'user': str(user.username), 'message': ''}
)
assert response.status_code == 400
assert json.loads(response.content) == {
'message': ['This field may not be blank.']}
'message': ['This field may not be blank.']
}
def test_message_required_missing(self):
user = user_factory()
response = self.client.post(
self.url,
data={'user': str(user.username)})
response = self.client.post(self.url, data={'user': str(user.username)})
assert response.status_code == 400
assert json.loads(response.content) == {
'message': ['This field is required.']}
assert json.loads(response.content) == {'message': ['This field is required.']}
def test_throttle(self):
user = user_factory()
for x in range(20):
response = self.client.post(
self.url,
data={'user': str(
user.username), 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
data={'user': str(user.username), 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 201, x
response = self.client.post(
self.url,
data={'user': str(user.username), 'message': 'abuse!'},
REMOTE_ADDR='123.45.67.89')
REMOTE_ADDR='123.45.67.89',
)
assert response.status_code == 429

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

@ -5,10 +5,8 @@ from .views import AddonAbuseViewSet, UserAbuseViewSet
reporting = SimpleRouter()
reporting.register(r'addon', AddonAbuseViewSet,
basename='abusereportaddon')
reporting.register(r'user', UserAbuseViewSet,
basename='abusereportuser')
reporting.register(r'addon', AddonAbuseViewSet, basename='abusereportaddon')
reporting.register(r'user', UserAbuseViewSet, basename='abusereportuser')
urlpatterns = [
re_path(r'report/', include(reporting.urls)),

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

@ -4,7 +4,9 @@ from rest_framework.mixins import CreateModelMixin
from rest_framework.viewsets import GenericViewSet
from olympia.abuse.serializers import (
AddonAbuseReportSerializer, UserAbuseReportSerializer)
AddonAbuseReportSerializer,
UserAbuseReportSerializer,
)
from olympia.accounts.views import AccountViewSet
from olympia.addons.views import AddonViewSet
from olympia.api.throttling import GranularUserRateThrottle
@ -25,13 +27,15 @@ class AddonAbuseViewSet(CreateModelMixin, GenericViewSet):
return self.addon_viewset
if 'addon_pk' not in self.kwargs:
self.kwargs['addon_pk'] = (
self.request.data.get('addon') or
self.request.GET.get('addon'))
self.kwargs['addon_pk'] = self.request.data.get(
'addon'
) or self.request.GET.get('addon')
self.addon_viewset = AddonViewSet(
request=self.request, permission_classes=[],
request=self.request,
permission_classes=[],
kwargs={'pk': self.kwargs['addon_pk']},
action='retrieve_from_related')
action='retrieve_from_related',
)
return self.addon_viewset
def get_addon_object(self):
@ -48,8 +52,7 @@ class AddonAbuseViewSet(CreateModelMixin, GenericViewSet):
}
# See if the addon input is guid-like first. It doesn't have to exist
# in our database.
if self.get_addon_viewset().get_lookup_field(
self.kwargs['addon_pk']) == 'guid':
if self.get_addon_viewset().get_lookup_field(self.kwargs['addon_pk']) == 'guid':
data['guid'] = self.kwargs['addon_pk']
try:
# But see if it's also in our database.
@ -79,10 +82,12 @@ class UserAbuseViewSet(CreateModelMixin, GenericViewSet):
return self.user_object
if 'user_pk' not in self.kwargs:
self.kwargs['user_pk'] = (
self.request.data.get('user') or
self.request.GET.get('user'))
self.kwargs['user_pk'] = self.request.data.get(
'user'
) or self.request.GET.get('user')
return AccountViewSet(
request=self.request, permission_classes=[],
kwargs={'pk': self.kwargs['user_pk']}).get_object()
request=self.request,
permission_classes=[],
kwargs={'pk': self.kwargs['user_pk']},
).get_object()

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

@ -42,7 +42,8 @@ def action_allowed_user(user, permission):
assert permission in amo.permissions.PERMISSIONS_LIST # constants only.
return any(
match_rules(group.rules, permission.app, permission.action)
for group in user.groups_list)
for group in user.groups_list
)
def experiments_submission_allowed(user, parsed_addon_data):
@ -51,9 +52,9 @@ def experiments_submission_allowed(user, parsed_addon_data):
See bug 1220097.
"""
return (
not parsed_addon_data.get('is_experiment', False) or
action_allowed_user(user, amo.permissions.EXPERIMENTS_SUBMIT))
return not parsed_addon_data.get('is_experiment', False) or action_allowed_user(
user, amo.permissions.EXPERIMENTS_SUBMIT
)
def langpack_submission_allowed(user, parsed_addon_data):
@ -63,9 +64,9 @@ def langpack_submission_allowed(user, parsed_addon_data):
See https://github.com/mozilla/addons-server/issues/11788 and
https://github.com/mozilla/addons-server/issues/11793
"""
return (
not parsed_addon_data.get('type') == amo.ADDON_LPAPP or
action_allowed_user(user, amo.permissions.LANGPACK_SUBMIT))
return not parsed_addon_data.get('type') == amo.ADDON_LPAPP or action_allowed_user(
user, amo.permissions.LANGPACK_SUBMIT
)
def system_addon_submission_allowed(user, parsed_addon_data):
@ -73,31 +74,40 @@ def system_addon_submission_allowed(user, parsed_addon_data):
by people with the right permission.
"""
guid = parsed_addon_data.get('guid') or ''
return (
not guid.lower().endswith(amo.SYSTEM_ADDON_GUIDS) or
action_allowed_user(user, amo.permissions.SYSTEM_ADDON_SUBMIT))
return not guid.lower().endswith(amo.SYSTEM_ADDON_GUIDS) or action_allowed_user(
user, amo.permissions.SYSTEM_ADDON_SUBMIT
)
def mozilla_signed_extension_submission_allowed(user, parsed_addon_data):
"""Add-ons already signed with mozilla internal certificate can only be
submitted by people with the right permission.
"""
return (
not parsed_addon_data.get('is_mozilla_signed_extension') or
action_allowed_user(user, amo.permissions.SYSTEM_ADDON_SUBMIT))
return not parsed_addon_data.get(
'is_mozilla_signed_extension'
) or action_allowed_user(user, amo.permissions.SYSTEM_ADDON_SUBMIT)
def check_ownership(request, obj, require_owner=False, require_author=False,
ignore_disabled=False, admin=True):
def check_ownership(
request,
obj,
require_owner=False,
require_author=False,
ignore_disabled=False,
admin=True,
):
"""
A convenience function. Check if request.user has permissions
for the object.
"""
if hasattr(obj, 'check_ownership'):
return obj.check_ownership(request, require_owner=require_owner,
require_author=require_author,
ignore_disabled=ignore_disabled,
admin=admin)
return obj.check_ownership(
request,
require_owner=require_owner,
require_author=require_author,
ignore_disabled=ignore_disabled,
admin=admin,
)
return False
@ -108,19 +118,21 @@ def check_collection_ownership(request, collection, require_owner=False):
if request.user.id == collection.author_id:
return True
elif collection.author_id == settings.TASK_USER_ID and action_allowed_user(
request.user, amo.permissions.ADMIN_CURATION):
request.user, amo.permissions.ADMIN_CURATION
):
return True
elif not require_owner:
return (
collection.pk == settings.COLLECTION_FEATURED_THEMES_ID and
action_allowed_user(
request.user, amo.permissions.COLLECTIONS_CONTRIBUTE))
collection.pk == settings.COLLECTION_FEATURED_THEMES_ID
and action_allowed_user(
request.user, amo.permissions.COLLECTIONS_CONTRIBUTE
)
)
else:
return False
def check_addon_ownership(request, addon, dev=False, admin=True,
ignore_disabled=False):
def check_addon_ownership(request, addon, dev=False, admin=True, ignore_disabled=False):
"""
Check request.user's permissions for the addon.
@ -145,15 +157,13 @@ def check_addon_ownership(request, addon, dev=False, admin=True,
if dev:
roles += (amo.AUTHOR_ROLE_DEV,)
return addon.addonuser_set.filter(
user=request.user, role__in=roles
).exists()
return addon.addonuser_set.filter(user=request.user, role__in=roles).exists()
def check_addons_reviewer(request, allow_content_reviewers=True):
permissions = [
amo.permissions.ADDONS_REVIEW,
amo.permissions.ADDONS_RECOMMENDED_REVIEW
amo.permissions.ADDONS_RECOMMENDED_REVIEW,
]
if allow_content_reviewers:
permissions.append(amo.permissions.ADDONS_CONTENT_REVIEW)
@ -180,7 +190,8 @@ def is_reviewer(request, addon, allow_content_reviewers=True):
if addon.type == amo.ADDON_STATICTHEME:
return check_static_theme_reviewer(request)
return check_addons_reviewer(
request, allow_content_reviewers=allow_content_reviewers)
request, allow_content_reviewers=allow_content_reviewers
)
def is_user_any_kind_of_reviewer(user, allow_viewers=False):

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

@ -14,9 +14,11 @@ class GroupUserInline(admin.TabularInline):
if obj.pk:
return format_html(
'<a href="{}">Admin User Profile</a>',
reverse('admin:users_userprofile_change', args=(obj.user.pk,)))
reverse('admin:users_userprofile_change', args=(obj.user.pk,)),
)
else:
return ''
user_profile_link.short_description = 'User Profile'

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

@ -15,7 +15,6 @@ log = olympia.core.logger.getLogger('z.access')
class UserAndAddrMiddleware(MiddlewareMixin):
def process_request(self, request):
"""Attach authentication/permission helpers to request, and persist
user and remote addr in current thread."""

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

@ -18,7 +18,8 @@ class Group(ModelBase):
name = models.CharField(max_length=255, default='')
rules = models.TextField()
users = models.ManyToManyField(
'users.UserProfile', through='GroupUser', related_name='groups')
'users.UserProfile', through='GroupUser', related_name='groups'
)
notes = models.TextField(blank=True)
class Meta:
@ -36,8 +37,7 @@ class GroupUser(models.Model):
class Meta:
db_table = 'groups_users'
constraints = [
models.UniqueConstraint(fields=('group', 'user'),
name='group_id'),
models.UniqueConstraint(fields=('group', 'user'), name='group_id'),
]
def invalidate_groups_list(self):
@ -52,25 +52,25 @@ class GroupUser(models.Model):
pass
@dispatch.receiver(signals.post_save, sender=GroupUser,
dispatch_uid='groupuser.post_save')
@dispatch.receiver(
signals.post_save, sender=GroupUser, dispatch_uid='groupuser.post_save'
)
def groupuser_post_save(sender, instance, **kw):
if kw.get('raw'):
return
activity.log_create(amo.LOG.GROUP_USER_ADDED, instance.group,
instance.user)
activity.log_create(amo.LOG.GROUP_USER_ADDED, instance.group, instance.user)
log.info('Added %s to %s' % (instance.user, instance.group))
instance.invalidate_groups_list()
@dispatch.receiver(signals.post_delete, sender=GroupUser,
dispatch_uid='groupuser.post_delete')
@dispatch.receiver(
signals.post_delete, sender=GroupUser, dispatch_uid='groupuser.post_delete'
)
def groupuser_post_delete(sender, instance, **kw):
if kw.get('raw'):
return
activity.log_create(amo.LOG.GROUP_USER_REMOVED, instance.group,
instance.user)
activity.log_create(amo.LOG.GROUP_USER_REMOVED, instance.group, instance.user)
log.info('Removed %s from %s' % (instance.user, instance.group))
instance.invalidate_groups_list()

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

@ -7,12 +7,16 @@ from .. import acl
@library.global_function
@jinja2.contextfunction
def check_ownership(context, obj, require_owner=False,
require_author=False, ignore_disabled=True):
return acl.check_ownership(context['request'], obj,
require_owner=require_owner,
require_author=require_author,
ignore_disabled=ignore_disabled)
def check_ownership(
context, obj, require_owner=False, require_author=False, ignore_disabled=True
):
return acl.check_ownership(
context['request'],
obj,
require_owner=require_owner,
require_author=require_author,
ignore_disabled=ignore_disabled,
)
@library.global_function

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

@ -6,16 +6,21 @@ from django.contrib.auth.models import AnonymousUser
from olympia import amo
from olympia.access.models import Group, GroupUser
from olympia.addons.models import Addon, AddonUser
from olympia.amo.tests import (
addon_factory, TestCase, req_factory_factory, user_factory)
from olympia.amo.tests import addon_factory, TestCase, req_factory_factory, user_factory
from olympia.users.models import UserProfile
from .acl import (
action_allowed, check_addon_ownership, check_addons_reviewer,
check_ownership, check_static_theme_reviewer,
action_allowed,
check_addon_ownership,
check_addons_reviewer,
check_ownership,
check_static_theme_reviewer,
check_unlisted_addons_reviewer,
is_reviewer, is_user_any_kind_of_reviewer, match_rules,
system_addon_submission_allowed)
is_reviewer,
is_user_any_kind_of_reviewer,
match_rules,
system_addon_submission_allowed,
)
pytestmark = pytest.mark.django_db
@ -58,8 +63,9 @@ def test_match_rules():
)
for rule in rules:
assert not match_rules(rule, 'Admin', '%'), \
assert not match_rules(rule, 'Admin', '%'), (
"%s == Admin:%% and shouldn't" % rule
)
def test_anonymous_user():
@ -69,13 +75,15 @@ def test_anonymous_user():
class ACLTestCase(TestCase):
"""Test some basic ACLs by going to various locked pages on AMO."""
fixtures = ['access/login.json']
def test_admin_login_anon(self):
# Login form for anonymous user on the admin page.
url = '/en-US/admin/models/'
self.assert3xx(self.client.get(url),
'/admin/models/login/?next=/en-US/admin/models/')
self.assert3xx(
self.client.get(url), '/admin/models/login/?next=/en-US/admin/models/'
)
class TestHasPerm(TestCase):
@ -117,8 +125,7 @@ class TestHasPerm(TestCase):
self.request = self.fake_request_with_user(self.login_admin())
assert check_ownership(self.request, self.addon, require_author=False)
assert not check_ownership(self.request, self.addon,
require_author=True)
assert not check_ownership(self.request, self.addon, require_author=True)
def test_disabled(self):
self.addon.update(status=amo.STATUS_DISABLED)
@ -133,8 +140,7 @@ class TestHasPerm(TestCase):
def test_ignore_disabled(self):
self.addon.update(status=amo.STATUS_DISABLED)
assert check_addon_ownership(self.request, self.addon,
ignore_disabled=True)
assert check_addon_ownership(self.request, self.addon, ignore_disabled=True)
def test_owner(self):
assert check_addon_ownership(self.request, self.addon)
@ -172,9 +178,7 @@ class TestHasPerm(TestCase):
# At this point, `user_dev` is an owner of `addon_for_user_dev`.
# Let's add `user_dev` as a developer of `self.addon`.
self.addon.addonuser_set.create(
user=user_dev, role=amo.AUTHOR_ROLE_DEV
)
self.addon.addonuser_set.create(user=user_dev, role=amo.AUTHOR_ROLE_DEV)
# Now, let's make sure `user_dev` is not an owner.
self.request = self.fake_request_with_user(user_dev)
@ -199,8 +203,7 @@ class TestCheckReviewer(TestCase):
assert not check_static_theme_reviewer(request)
assert not is_user_any_kind_of_reviewer(request.user)
assert not is_reviewer(request, self.addon)
assert not is_reviewer(
request, self.addon, allow_content_reviewers=False)
assert not is_reviewer(request, self.addon, allow_content_reviewers=False)
assert not is_reviewer(request, self.statictheme)
def test_perm_addons(self):
@ -240,11 +243,9 @@ class TestCheckReviewer(TestCase):
self.grant_permission(self.user, 'Addons:ThemeReview')
request = req_factory_factory('noop', user=self.user)
assert not is_reviewer(request, self.addon)
assert not is_reviewer(
request, self.addon, allow_content_reviewers=False)
assert not is_reviewer(request, self.addon, allow_content_reviewers=False)
assert is_reviewer(request, self.statictheme)
assert is_reviewer(
request, self.statictheme, allow_content_reviewers=False)
assert is_reviewer(request, self.statictheme, allow_content_reviewers=False)
assert check_static_theme_reviewer(request)
assert is_user_any_kind_of_reviewer(request.user)
@ -256,13 +257,11 @@ class TestCheckReviewer(TestCase):
assert not check_unlisted_addons_reviewer(request)
assert not check_static_theme_reviewer(request)
assert not is_reviewer(request, self.statictheme)
assert not is_reviewer(
request, self.statictheme, allow_content_reviewers=False)
assert not is_reviewer(request, self.statictheme, allow_content_reviewers=False)
assert check_addons_reviewer(request)
assert is_reviewer(request, self.addon)
assert not is_reviewer(
request, self.addon, allow_content_reviewers=False)
assert not is_reviewer(request, self.addon, allow_content_reviewers=False)
def test_perm_reviewertools_view(self):
self.grant_permission(self.user, 'ReviewerTools:View')
@ -274,17 +273,26 @@ class TestCheckReviewer(TestCase):
assert not is_reviewer(request, self.statictheme)
assert not check_addons_reviewer(request)
assert not is_reviewer(request, self.addon)
assert not is_reviewer(
request, self.addon, allow_content_reviewers=False)
assert not is_reviewer(request, self.addon, allow_content_reviewers=False)
system_guids = pytest.mark.parametrize('guid', [
'foø@mozilla.org', 'baa@shield.mozilla.org', 'moo@pioneer.mozilla.org',
'blâh@mozilla.com', 'foø@Mozilla.Org', 'addon@shield.moZilla.com',
'baa@ShielD.MozillA.OrG', 'moo@PIONEER.mozilla.org', 'blâh@MOZILLA.COM',
'flop@search.mozilla.org', 'user@mozillaonline.com',
'tester@MoZiLlAoNlInE.CoM'
])
system_guids = pytest.mark.parametrize(
'guid',
[
'foø@mozilla.org',
'baa@shield.mozilla.org',
'moo@pioneer.mozilla.org',
'blâh@mozilla.com',
'foø@Mozilla.Org',
'addon@shield.moZilla.com',
'baa@ShielD.MozillA.OrG',
'moo@PIONEER.mozilla.org',
'blâh@MOZILLA.COM',
'flop@search.mozilla.org',
'user@mozillaonline.com',
'tester@MoZiLlAoNlInE.CoM',
],
)
@system_guids

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

@ -11,6 +11,7 @@ class Command(BaseCommand):
processing each as it is found. It polls indefinitely and does not return;
to interrupt execution you'll need to e.g. SIGINT the process.
"""
help = 'Monitor the AWS SQS queue for FxA events.'
def add_arguments(self, parser):
@ -20,7 +21,8 @@ class Command(BaseCommand):
action='store',
dest='queue_url',
default=settings.FXA_SQS_AWS_QUEUE_URL,
help='Monitor specified SQS queue, rather than default.')
help='Monitor specified SQS queue, rather than default.',
)
def handle(self, *args, **options):
queue_url = options['queue_url']

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

@ -11,8 +11,13 @@ from olympia.access import acl
from olympia.access.models import Group
from olympia.amo.templatetags.jinja_helpers import absolutify
from olympia.amo.utils import (
clean_nl, has_links, ImageCheck,
subscribe_newsletter, unsubscribe_newsletter, urlparams)
clean_nl,
has_links,
ImageCheck,
subscribe_newsletter,
unsubscribe_newsletter,
urlparams,
)
from olympia.api.serializers import SiteStatusSerializer
from olympia.api.utils import is_gate_active
from olympia.api.validators import OneOrMorePrintableCharacterAPIValidator
@ -33,8 +38,7 @@ class BaseUserSerializer(serializers.ModelSerializer):
def get_url(self, obj):
def is_adminish(user):
return (user and
acl.action_allowed_user(user, amo.permissions.USERS_EDIT))
return user and acl.action_allowed_user(user, amo.permissions.USERS_EDIT)
request = self.context.get('request', None)
current_user = getattr(request, 'user', None) if request else None
@ -44,8 +48,7 @@ class BaseUserSerializer(serializers.ModelSerializer):
# Used in subclasses.
def get_permissions(self, obj):
out = {perm for group in obj.groups_list
for perm in group.rules.split(',')}
out = {perm for group in obj.groups_list for perm in group.rules.split(',')}
return sorted(out)
# Used in subclasses.
@ -61,10 +64,19 @@ class PublicUserProfileSerializer(BaseUserSerializer):
class Meta(BaseUserSerializer.Meta):
fields = BaseUserSerializer.Meta.fields + (
'average_addon_rating', 'created', 'biography',
'has_anonymous_display_name', 'has_anonymous_username', 'homepage',
'is_addon_developer', 'is_artist', 'location', 'occupation',
'num_addons_listed', 'picture_type', 'picture_url',
'average_addon_rating',
'created',
'biography',
'has_anonymous_display_name',
'has_anonymous_username',
'homepage',
'is_addon_developer',
'is_artist',
'location',
'occupation',
'num_addons_listed',
'picture_type',
'picture_url',
)
# This serializer should never be used for updates but just to be sure.
read_only_fields = fields
@ -72,90 +84,111 @@ class PublicUserProfileSerializer(BaseUserSerializer):
class UserProfileSerializer(PublicUserProfileSerializer):
display_name = serializers.CharField(
min_length=2, max_length=50,
validators=[OneOrMorePrintableCharacterAPIValidator()])
min_length=2,
max_length=50,
validators=[OneOrMorePrintableCharacterAPIValidator()],
)
picture_upload = serializers.ImageField(use_url=True, write_only=True)
permissions = serializers.SerializerMethodField()
fxa_edit_email_url = serializers.SerializerMethodField()
reviewer_name = serializers.CharField(
min_length=2, max_length=50, allow_blank=True,
validators=[OneOrMorePrintableCharacterAPIValidator()])
min_length=2,
max_length=50,
allow_blank=True,
validators=[OneOrMorePrintableCharacterAPIValidator()],
)
# Just Need to specify any field for the source - '*' is the entire obj.
site_status = SiteStatusSerializer(source='*')
class Meta(PublicUserProfileSerializer.Meta):
fields = PublicUserProfileSerializer.Meta.fields + (
'deleted', 'display_name', 'email', 'fxa_edit_email_url',
'last_login', 'last_login_ip', 'permissions', 'picture_upload',
'read_dev_agreement', 'reviewer_name', 'site_status', 'username'
'deleted',
'display_name',
'email',
'fxa_edit_email_url',
'last_login',
'last_login_ip',
'permissions',
'picture_upload',
'read_dev_agreement',
'reviewer_name',
'site_status',
'username',
)
writeable_fields = (
'biography', 'display_name', 'homepage', 'location', 'occupation',
'picture_upload', 'reviewer_name',
'biography',
'display_name',
'homepage',
'location',
'occupation',
'picture_upload',
'reviewer_name',
)
read_only_fields = tuple(set(fields) - set(writeable_fields))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if (not self.instance or
not acl.is_user_any_kind_of_reviewer(self.instance)):
if not self.instance or not acl.is_user_any_kind_of_reviewer(self.instance):
self.fields.pop('reviewer_name', None)
def get_fxa_edit_email_url(self, user):
base_url = '{}/settings'.format(
settings.FXA_CONTENT_HOST
base_url = '{}/settings'.format(settings.FXA_CONTENT_HOST)
return urlparams(
base_url, uid=user.fxa_id, email=user.email, entrypoint='addons'
)
return urlparams(base_url, uid=user.fxa_id, email=user.email,
entrypoint='addons')
def validate_biography(self, value):
if has_links(clean_nl(str(value))):
# There's some links, we don't want them.
raise serializers.ValidationError(
ugettext(u'No links are allowed.'))
raise serializers.ValidationError(ugettext(u'No links are allowed.'))
return value
def validate_display_name(self, value):
if DeniedName.blocked(value):
raise serializers.ValidationError(
ugettext(u'This display name cannot be used.'))
ugettext(u'This display name cannot be used.')
)
return value
def validate_reviewer_name(self, value):
if DeniedName.blocked(value):
raise serializers.ValidationError(
ugettext(u'This reviewer name cannot be used.'))
ugettext(u'This reviewer name cannot be used.')
)
return value
def validate_homepage(self, value):
if settings.DOMAIN.lower() in value.lower():
raise serializers.ValidationError(
ugettext(u'The homepage field can only be used to link to '
u'external websites.')
ugettext(
u'The homepage field can only be used to link to '
u'external websites.'
)
)
return value
def validate_picture_upload(self, value):
image_check = ImageCheck(value)
if (value.content_type not in amo.IMG_TYPES or
not image_check.is_image()):
if value.content_type not in amo.IMG_TYPES or not image_check.is_image():
raise serializers.ValidationError(
ugettext(u'Images must be either PNG or JPG.'))
ugettext(u'Images must be either PNG or JPG.')
)
if image_check.is_animated():
raise serializers.ValidationError(
ugettext(u'Images cannot be animated.'))
raise serializers.ValidationError(ugettext(u'Images cannot be animated.'))
if value.size > settings.MAX_PHOTO_UPLOAD_SIZE:
raise serializers.ValidationError(
ugettext(u'Please use images smaller than %dMB.' %
(settings.MAX_PHOTO_UPLOAD_SIZE / 1024 / 1024)))
ugettext(
u'Please use images smaller than %dMB.'
% (settings.MAX_PHOTO_UPLOAD_SIZE / 1024 / 1024)
)
)
return value
def update(self, instance, validated_data):
instance = super(UserProfileSerializer, self).update(
instance, validated_data)
instance = super(UserProfileSerializer, self).update(instance, validated_data)
photo = validated_data.get('picture_upload')
if photo:
@ -166,16 +199,17 @@ class UserProfileSerializer(PublicUserProfileSerializer):
temp_file.write(chunk)
instance.update(picture_type=photo.content_type)
resize_photo.delay(
tmp_destination, instance.picture_path,
set_modified_on=instance.serializable_reference())
tmp_destination,
instance.picture_path,
set_modified_on=instance.serializable_reference(),
)
return instance
def to_representation(self, obj):
data = super(UserProfileSerializer, self).to_representation(obj)
request = self.context.get('request', None)
if request and is_gate_active(request,
'del-accounts-fxa-edit-email-url'):
if request and is_gate_active(request, 'del-accounts-fxa-edit-email-url'):
data.pop('fxa_edit_email_url', None)
return data
@ -190,19 +224,20 @@ class AccountSuperCreateSerializer(serializers.Serializer):
username = serializers.CharField(required=False)
email = serializers.EmailField(required=False)
fxa_id = serializers.CharField(required=False)
group = serializers.ChoiceField(choices=list(group_rules.items()),
required=False)
group = serializers.ChoiceField(choices=list(group_rules.items()), required=False)
def validate_email(self, email):
if email and UserProfile.objects.filter(email=email).exists():
raise serializers.ValidationError(
'Someone with this email already exists in the system')
'Someone with this email already exists in the system'
)
return email
def validate_username(self, username):
if username and UserProfile.objects.filter(username=username).exists():
raise serializers.ValidationError(
'Someone with this username already exists in the system')
'Someone with this username already exists in the system'
)
return username
def validate_group(self, group):
@ -214,12 +249,13 @@ class AccountSuperCreateSerializer(serializers.Serializer):
qs = Group.objects.filter(rules=rule)
count = qs.count()
if count != 1:
log.info(u'Super creation: looking for group with '
u'permissions {} {} (count: {})'
.format(group, rule, count))
log.info(
u'Super creation: looking for group with '
u'permissions {} {} (count: {})'.format(group, rule, count)
)
raise serializers.ValidationError(
'Could not find a permissions group with the exact '
'rules needed.')
'Could not find a permissions group with the exact ' 'rules needed.'
)
group = qs.get()
return group
@ -233,26 +269,25 @@ class UserNotificationSerializer(serializers.Serializer):
if instance.notification.mandatory:
raise serializers.ValidationError(
'Attempting to set [%s] to %s. Mandatory notifications can\'t '
'be modified' %
(instance.notification.short, validated_data.get('enabled')))
'be modified'
% (instance.notification.short, validated_data.get('enabled'))
)
enabled = validated_data['enabled']
request = self.context['request']
current_user = request.user
remote_by_id = {
ntfn.id: ntfn for ntfn in notifications.REMOTE_NOTIFICATIONS}
remote_by_id = {ntfn.id: ntfn for ntfn in notifications.REMOTE_NOTIFICATIONS}
if instance.notification_id in remote_by_id:
notification = remote_by_id[instance.notification_id]
if not enabled:
unsubscribe_newsletter(
current_user, notification.basket_newsletter_id)
unsubscribe_newsletter(current_user, notification.basket_newsletter_id)
elif enabled:
subscribe_newsletter(
current_user, notification.basket_newsletter_id,
request=request)
current_user, notification.basket_newsletter_id, request=request
)
elif 'enabled' in validated_data:
# Only save if non-mandatory and 'enabled' is set.
# Ignore other fields.
@ -265,6 +300,13 @@ class UserNotificationSerializer(serializers.Serializer):
class UserProfileBasketSyncSerializer(UserProfileSerializer):
class Meta(UserProfileSerializer.Meta):
model = UserProfile
fields = ('id', 'deleted', 'display_name', 'homepage', 'fxa_id',
'last_login', 'location')
fields = (
'id',
'deleted',
'display_name',
'homepage',
'fxa_id',
'last_login',
'location',
)
read_only_fields = fields

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

@ -26,6 +26,7 @@ def user_profile_from_uid(f):
log.warning('Multiple profile matches for FxA id %s' % uid)
except UserProfile.DoesNotExist:
log.info('No profile match for FxA id %s' % uid)
return wrapper
@ -34,15 +35,17 @@ def user_profile_from_uid(f):
@user_profile_from_uid
def primary_email_change_event(profile, changed_date, email):
"""Process the primaryEmailChangedEvent."""
if (not profile.email_changed or
profile.email_changed < changed_date):
if not profile.email_changed or profile.email_changed < changed_date:
profile.update(email=email, email_changed=changed_date)
log.info(
'Account pk [%s] email [%s] changed from FxA on %s' % (
profile.id, email, changed_date))
'Account pk [%s] email [%s] changed from FxA on %s'
% (profile.id, email, changed_date)
)
else:
log.warning('Account pk [%s] email updated ignored, %s > %s' %
(profile.id, profile.email_changed, changed_date))
log.warning(
'Account pk [%s] email updated ignored, %s > %s'
% (profile.id, profile.email_changed, changed_date)
)
@task
@ -52,9 +55,9 @@ def delete_user_event(user, deleted_date):
"""Process the delete user event."""
if switch_is_active('fxa-account-delete'):
user.delete(addon_msg='Deleted via FxA account deletion')
log.info(
'Account pk [%s] deleted from FxA on %s' % (user.id, deleted_date))
log.info('Account pk [%s] deleted from FxA on %s' % (user.id, deleted_date))
else:
log.info(
f'Skipping deletion from FxA for account [{user.id}] because '
'waffle inactive')
'waffle inactive'
)

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

@ -7,18 +7,17 @@ from olympia.accounts.templatetags import jinja_helpers
@mock.patch(
'olympia.accounts.templatetags.jinja_helpers.utils.default_fxa_login_url',
lambda c: 'http://auth.ca')
lambda c: 'http://auth.ca',
)
def test_login_link():
request = RequestFactory().get('/en-US/firefox/addons')
assert jinja_helpers.login_link({'request': request}) == (
'http://auth.ca')
assert jinja_helpers.login_link({'request': request}) == ('http://auth.ca')
@mock.patch(
'olympia.accounts.templatetags.jinja_helpers.utils.'
'default_fxa_register_url',
lambda c: 'http://auth.ca')
'olympia.accounts.templatetags.jinja_helpers.utils.' 'default_fxa_register_url',
lambda c: 'http://auth.ca',
)
def test_register_link():
request = RequestFactory().get('/en-US/firefox/addons')
assert jinja_helpers.register_link({'request': request}) == (
'http://auth.ca')
assert jinja_helpers.register_link({'request': request}) == ('http://auth.ca')

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

@ -7,9 +7,12 @@ from rest_framework.test import APIRequestFactory
from olympia import amo
from olympia.access.models import Group, GroupUser
from olympia.accounts.serializers import (
BaseUserSerializer, PublicUserProfileSerializer,
UserNotificationSerializer, UserProfileBasketSyncSerializer,
UserProfileSerializer)
BaseUserSerializer,
PublicUserProfileSerializer,
UserNotificationSerializer,
UserProfileBasketSyncSerializer,
UserProfileSerializer,
)
from olympia.amo.templatetags.jinja_helpers import absolutify
from olympia.amo.tests import TestCase, addon_factory, days_ago, user_factory
from olympia.amo.utils import urlparams
@ -22,8 +25,7 @@ class BaseTestUserMixin(object):
def serialize(self):
# Manually reload the user first to clear any cached properties.
self.user = UserProfile.objects.get(pk=self.user.pk)
serializer = self.serializer_class(
self.user, context={'request': self.request})
serializer = self.serializer_class(self.user, context={'request': self.request})
return serializer.to_representation(self.user)
def test_basic(self):
@ -69,8 +71,10 @@ class TestPublicUserProfileSerializer(TestCase):
serializer = PublicUserProfileSerializer
user_kwargs = {
'username': 'amo',
'biography': 'stuff', 'homepage': 'http://mozilla.org/',
'location': 'everywhere', 'occupation': 'job',
'biography': 'stuff',
'homepage': 'http://mozilla.org/',
'location': 'everywhere',
'occupation': 'job',
}
user_private_kwargs = {
'reviewer_name': 'batman',
@ -78,12 +82,12 @@ class TestPublicUserProfileSerializer(TestCase):
def setUp(self):
self.request = APIRequestFactory().get('/')
self.user = user_factory(
**self.user_kwargs, **self.user_private_kwargs)
self.user = user_factory(**self.user_kwargs, **self.user_private_kwargs)
def serialize(self):
return (self.serializer(self.user, context={'request': self.request})
.to_representation(self.user))
return self.serializer(
self.user, context={'request': self.request}
).to_representation(self.user)
def test_picture(self):
serial = self.serialize()
@ -134,8 +138,7 @@ class TestPublicUserProfileSerializer(TestCase):
assert result['url'] == absolutify(self.user.get_url_path())
def test_anonymous_username_display_name(self):
self.user = user_factory(
username='anonymous-bb4f3cbd422e504080e32f2d9bbfcee0')
self.user = user_factory(username='anonymous-bb4f3cbd422e504080e32f2d9bbfcee0')
data = self.serialize()
assert self.user.has_anonymous_username is True
assert data['has_anonymous_username'] is True
@ -165,48 +168,54 @@ class PermissionsTestMixin(object):
# Single permission
group = Group.objects.create(name='a', rules='Addons:Review')
GroupUser.objects.create(group=group, user=self.user)
assert self.serializer(self.user).data['permissions'] == [
'Addons:Review']
assert self.serializer(self.user).data['permissions'] == ['Addons:Review']
# Multiple permissions
group.update(rules='Addons:Review,Addons:Edit')
del self.user.groups_list
assert self.serializer(self.user).data['permissions'] == [
'Addons:Edit', 'Addons:Review']
'Addons:Edit',
'Addons:Review',
]
# Change order to test sort
group.update(rules='Addons:Edit,Addons:Review')
del self.user.groups_list
assert self.serializer(self.user).data['permissions'] == [
'Addons:Edit', 'Addons:Review']
'Addons:Edit',
'Addons:Review',
]
# Add a second group membership to test duplicates
group2 = Group.objects.create(name='b', rules='Foo:Bar,Addons:Edit')
GroupUser.objects.create(group=group2, user=self.user)
assert self.serializer(self.user).data['permissions'] == [
'Addons:Edit', 'Addons:Review', 'Foo:Bar']
'Addons:Edit',
'Addons:Review',
'Foo:Bar',
]
class TestUserProfileSerializer(TestPublicUserProfileSerializer,
PermissionsTestMixin):
class TestUserProfileSerializer(TestPublicUserProfileSerializer, PermissionsTestMixin):
serializer = UserProfileSerializer
def setUp(self):
self.now = days_ago(0)
self.user_email = u'a@m.o'
self.user_kwargs.update({
'email': self.user_email,
'display_name': u'This is my náme',
'last_login_ip': '123.45.67.89',
})
self.user_kwargs.update(
{
'email': self.user_email,
'display_name': u'This is my náme',
'last_login_ip': '123.45.67.89',
}
)
super(TestUserProfileSerializer, self).setUp()
def test_basic(self):
# Have to update these separately as dates as tricky. As are bools.
self.user.update(last_login=self.now, read_dev_agreement=self.now)
data = super(TestUserProfileSerializer, self).test_basic()
assert data['last_login'] == (
self.now.replace(microsecond=0).isoformat() + 'Z')
assert data['last_login'] == (self.now.replace(microsecond=0).isoformat() + 'Z')
assert data['read_dev_agreement'] == data['last_login']
def test_is_reviewer(self):
@ -224,9 +233,12 @@ class TestUserProfileSerializer(TestPublicUserProfileSerializer,
self.user.update(fxa_id=user_fxa_id)
with override_settings(FXA_CONTENT_HOST=fxa_host):
expected_url = urlparams('{}/settings'.format(fxa_host),
uid=user_fxa_id, email=self.user_email,
entrypoint='addons')
expected_url = urlparams(
'{}/settings'.format(fxa_host),
uid=user_fxa_id,
email=self.user_email,
entrypoint='addons',
)
data = super(TestUserProfileSerializer, self).test_basic()
assert data['fxa_edit_email_url'] == expected_url
@ -281,8 +293,8 @@ class TestUserProfileSerializer(TestPublicUserProfileSerializer,
class TestUserProfileBasketSyncSerializer(TestCase):
def setUp(self):
self.user = user_factory(
display_name=None, last_login=self.days_ago(1),
fxa_id='qsdfghjklmù')
display_name=None, last_login=self.days_ago(1), fxa_id='qsdfghjklmù'
)
def test_basic(self):
serializer = UserProfileBasketSyncSerializer(self.user)
@ -292,9 +304,8 @@ class TestUserProfileBasketSyncSerializer(TestCase):
'fxa_id': self.user.fxa_id,
'homepage': '',
'id': self.user.pk,
'last_login': self.user.last_login.replace(
microsecond=0).isoformat() + 'Z',
'location': ''
'last_login': self.user.last_login.replace(microsecond=0).isoformat() + 'Z',
'location': '',
}
self.user.update(display_name='Dîsplay Mé!')
@ -310,21 +321,20 @@ class TestUserProfileBasketSyncSerializer(TestCase):
'fxa_id': self.user.fxa_id,
'homepage': '',
'id': self.user.pk,
'last_login': self.user.last_login.replace(
microsecond=0).isoformat() + 'Z',
'location': ''
'last_login': self.user.last_login.replace(microsecond=0).isoformat() + 'Z',
'location': '',
}
class TestUserNotificationSerializer(TestCase):
def setUp(self):
self.user = user_factory()
def test_basic(self):
notification = NOTIFICATIONS_BY_SHORT['upgrade_fail']
user_notification = UserNotification.objects.create(
user=self.user, notification_id=notification.id, enabled=True)
user=self.user, notification_id=notification.id, enabled=True
)
data = UserNotificationSerializer(user_notification).data
assert data['name'] == user_notification.notification.short
assert data['enabled'] == user_notification.enabled

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

@ -4,11 +4,9 @@ from unittest import mock
from waffle.testutils import override_switch
from olympia import amo
from olympia.accounts.tasks import (
delete_user_event, primary_email_change_event)
from olympia.accounts.tasks import delete_user_event, primary_email_change_event
from olympia.accounts.tests.test_utils import totimestamp
from olympia.amo.tests import (
addon_factory, collection_factory, TestCase, user_factory)
from olympia.amo.tests import addon_factory, collection_factory, TestCase, user_factory
from olympia.bandwagon.models import Collection
from olympia.ratings.models import Rating
@ -17,48 +15,39 @@ class TestPrimaryEmailChangeEvent(TestCase):
fxa_id = 'ABCDEF012345689'
def test_success(self):
user = user_factory(email='old-email@example.com',
fxa_id=self.fxa_id)
user = user_factory(email='old-email@example.com', fxa_id=self.fxa_id)
primary_email_change_event(
self.fxa_id,
totimestamp(datetime(2017, 10, 11)),
'new-email@example.com')
self.fxa_id, totimestamp(datetime(2017, 10, 11)), 'new-email@example.com'
)
user.reload()
assert user.email == 'new-email@example.com'
assert user.email_changed == datetime(2017, 10, 11, 0, 0)
def test_ignored_because_old_timestamp(self):
user = user_factory(email='old-email@example.com',
fxa_id=self.fxa_id)
user = user_factory(email='old-email@example.com', fxa_id=self.fxa_id)
yesterday = datetime(2017, 10, 1)
today = datetime(2017, 10, 2)
tomorrow = datetime(2017, 10, 3)
primary_email_change_event(
self.fxa_id,
totimestamp(today),
'today@example.com')
primary_email_change_event(self.fxa_id, totimestamp(today), 'today@example.com')
assert user.reload().email == 'today@example.com'
primary_email_change_event(
self.fxa_id,
totimestamp(tomorrow),
'tomorrow@example.com')
self.fxa_id, totimestamp(tomorrow), 'tomorrow@example.com'
)
assert user.reload().email == 'tomorrow@example.com'
primary_email_change_event(
self.fxa_id,
totimestamp(yesterday),
'yesterday@example.com')
self.fxa_id, totimestamp(yesterday), 'yesterday@example.com'
)
assert user.reload().email != 'yesterday@example.com'
assert user.reload().email == 'tomorrow@example.com'
def test_ignored_if_user_not_found(self):
"""Check that this doesn't throw"""
primary_email_change_event(
self.fxa_id,
totimestamp(datetime(2017, 10, 11)),
'email@example.com')
self.fxa_id, totimestamp(datetime(2017, 10, 11)), 'email@example.com'
)
class TestDeleteUserEvent(TestCase):
@ -68,9 +57,7 @@ class TestDeleteUserEvent(TestCase):
self.user = user_factory(fxa_id=self.fxa_id)
def _fire_event(self):
delete_user_event(
self.fxa_id,
totimestamp(datetime(2017, 10, 11)))
delete_user_event(self.fxa_id, totimestamp(datetime(2017, 10, 11)))
self.user.reload()
assert self.user.email is not None
assert self.user.deleted
@ -82,8 +69,9 @@ class TestDeleteUserEvent(TestCase):
collection = collection_factory(author=self.user)
another_addon = addon_factory()
Rating.objects.create(addon=another_addon, user=self.user, rating=5)
assert list(another_addon.ratings.all().values('rating', 'user')) == [{
'user': self.user.id, 'rating': 5}]
assert list(another_addon.ratings.all().values('rating', 'user')) == [
{'user': self.user.id, 'rating': 5}
]
self._fire_event()
assert not Collection.objects.filter(id=collection.id).exists()
assert not another_addon.ratings.all().exists()
@ -107,8 +95,6 @@ class TestDeleteUserEvent(TestCase):
@override_switch('fxa-account-delete', active=False)
def test_waffle_off(self):
delete_user_event(
self.fxa_id,
totimestamp(datetime(2017, 10, 11)))
delete_user_event(self.fxa_id, totimestamp(datetime(2017, 10, 11)))
self.user.reload()
assert not self.user.deleted

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

@ -69,7 +69,8 @@ def test_default_fxa_login_url_with_state():
raw_url = utils.default_fxa_login_url(request)
url = urlparse(raw_url)
base = '{scheme}://{netloc}{path}'.format(
scheme=url.scheme, netloc=url.netloc, path=url.path)
scheme=url.scheme, netloc=url.netloc, path=url.path
)
assert base == 'https://accounts.firefox.com/oauth/authorization'
query = parse_qs(url.query)
next_path = urlsafe_b64encode(force_bytes(path)).rstrip(b'=')
@ -77,8 +78,7 @@ def test_default_fxa_login_url_with_state():
'action': ['signin'],
'client_id': ['foo'],
'scope': ['profile openid'],
'state': ['myfxastate:{next_path}'.format(
next_path=force_text(next_path))],
'state': ['myfxastate:{next_path}'.format(next_path=force_text(next_path))],
}
@ -91,7 +91,8 @@ def test_default_fxa_register_url_with_state():
raw_url = utils.default_fxa_register_url(request)
url = urlparse(raw_url)
base = '{scheme}://{netloc}{path}'.format(
scheme=url.scheme, netloc=url.netloc, path=url.path)
scheme=url.scheme, netloc=url.netloc, path=url.path
)
assert base == 'https://accounts.firefox.com/oauth/authorization'
query = parse_qs(url.query)
next_path = urlsafe_b64encode(force_bytes(path)).rstrip(b'=')
@ -99,8 +100,7 @@ def test_default_fxa_register_url_with_state():
'action': ['signup'],
'client_id': ['foo'],
'scope': ['profile openid'],
'state': ['myfxastate:{next_path}'.format(
next_path=force_text(next_path))],
'state': ['myfxastate:{next_path}'.format(next_path=force_text(next_path))],
}
@ -113,12 +113,16 @@ def test_fxa_login_url_without_requiring_two_factor_auth():
raw_url = utils.fxa_login_url(
config=FXA_CONFIG['default'],
state=request.session['fxa_state'], next_path=path, action='signin',
force_two_factor=False)
state=request.session['fxa_state'],
next_path=path,
action='signin',
force_two_factor=False,
)
url = urlparse(raw_url)
base = '{scheme}://{netloc}{path}'.format(
scheme=url.scheme, netloc=url.netloc, path=url.path)
scheme=url.scheme, netloc=url.netloc, path=url.path
)
assert base == 'https://accounts.firefox.com/oauth/authorization'
query = parse_qs(url.query)
next_path = urlsafe_b64encode(path.encode('utf-8')).rstrip(b'=')
@ -126,8 +130,7 @@ def test_fxa_login_url_without_requiring_two_factor_auth():
'action': ['signin'],
'client_id': ['foo'],
'scope': ['profile openid'],
'state': ['myfxastate:{next_path}'.format(
next_path=force_text(next_path))],
'state': ['myfxastate:{next_path}'.format(next_path=force_text(next_path))],
}
@ -140,12 +143,16 @@ def test_fxa_login_url_requiring_two_factor_auth():
raw_url = utils.fxa_login_url(
config=FXA_CONFIG['default'],
state=request.session['fxa_state'], next_path=path, action='signin',
force_two_factor=True)
state=request.session['fxa_state'],
next_path=path,
action='signin',
force_two_factor=True,
)
url = urlparse(raw_url)
base = '{scheme}://{netloc}{path}'.format(
scheme=url.scheme, netloc=url.netloc, path=url.path)
scheme=url.scheme, netloc=url.netloc, path=url.path
)
assert base == 'https://accounts.firefox.com/oauth/authorization'
query = parse_qs(url.query)
next_path = urlsafe_b64encode(path.encode('utf-8')).rstrip(b'=')
@ -154,8 +161,7 @@ def test_fxa_login_url_requiring_two_factor_auth():
'action': ['signin'],
'client_id': ['foo'],
'scope': ['profile openid'],
'state': ['myfxastate:{next_path}'.format(
next_path=force_text(next_path))],
'state': ['myfxastate:{next_path}'.format(next_path=force_text(next_path))],
}
@ -168,12 +174,17 @@ def test_fxa_login_url_requiring_two_factor_auth_passing_token():
raw_url = utils.fxa_login_url(
config=FXA_CONFIG['default'],
state=request.session['fxa_state'], next_path=path, action='signin',
force_two_factor=True, id_token='YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=')
state=request.session['fxa_state'],
next_path=path,
action='signin',
force_two_factor=True,
id_token='YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=',
)
url = urlparse(raw_url)
base = '{scheme}://{netloc}{path}'.format(
scheme=url.scheme, netloc=url.netloc, path=url.path)
scheme=url.scheme, netloc=url.netloc, path=url.path
)
assert base == 'https://accounts.firefox.com/oauth/authorization'
query = parse_qs(url.query)
next_path = urlsafe_b64encode(path.encode('utf-8')).rstrip(b'=')
@ -184,8 +195,7 @@ def test_fxa_login_url_requiring_two_factor_auth_passing_token():
'id_token_hint': ['YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo='],
'prompt': ['none'],
'scope': ['profile openid'],
'state': ['myfxastate:{next_path}'.format(
next_path=force_text(next_path))],
'state': ['myfxastate:{next_path}'.format(next_path=force_text(next_path))],
}
@ -215,8 +225,11 @@ def test_fxa_login_url_when_faking_fxa_auth():
request = RequestFactory().get(path)
request.session = {'fxa_state': 'myfxastate'}
raw_url = utils.fxa_login_url(
config=FXA_CONFIG['default'], state=request.session['fxa_state'],
next_path=path, action='signin')
config=FXA_CONFIG['default'],
state=request.session['fxa_state'],
next_path=path,
action='signin',
)
url = urlparse(raw_url)
assert url.scheme == ''
assert url.netloc == ''
@ -227,46 +240,47 @@ def test_fxa_login_url_when_faking_fxa_auth():
'action': ['signin'],
'client_id': ['foo'],
'scope': ['profile openid'],
'state': ['myfxastate:{next_path}'.format(
next_path=force_text(next_path))],
'state': ['myfxastate:{next_path}'.format(next_path=force_text(next_path))],
}
class TestProcessSqsQueue(TestCase):
@mock.patch('boto3._get_default_session')
@mock.patch('olympia.accounts.utils.process_fxa_event')
@mock.patch('boto3.client')
def test_process_sqs_queue(self, client, process_fxa_event, get_session):
messages = [
{'Body': 'foo', 'ReceiptHandle': '$$$'}, {'Body': 'bar'}, None,
{'Body': 'thisonetoo'}]
{'Body': 'foo', 'ReceiptHandle': '$$$'},
{'Body': 'bar'},
None,
{'Body': 'thisonetoo'},
]
sqs = mock.MagicMock(
**{'receive_message.side_effect': [{'Messages': messages}]})
**{'receive_message.side_effect': [{'Messages': messages}]}
)
session_mock = mock.MagicMock(
**{'get_available_regions.side_effect': ['nowh-ere']})
**{'get_available_regions.side_effect': ['nowh-ere']}
)
get_session.return_value = session_mock
delete_mock = mock.MagicMock()
sqs.delete_message = delete_mock
client.return_value = sqs
with self.assertRaises(StopIteration):
utils.process_sqs_queue(
queue_url='https://sqs.nowh-ere.aws.com/123456789/')
utils.process_sqs_queue(queue_url='https://sqs.nowh-ere.aws.com/123456789/')
client.assert_called()
client.assert_called_with(
'sqs', region_name='nowh-ere'
)
client.assert_called_with('sqs', region_name='nowh-ere')
process_fxa_event.assert_called()
# The 'None' in messages would cause an exception, but it should be
# handled, and the remaining message(s) still processed.
process_fxa_event.assert_has_calls(
[mock.call('foo'), mock.call('bar'), mock.call('thisonetoo')])
[mock.call('foo'), mock.call('bar'), mock.call('thisonetoo')]
)
delete_mock.assert_called_once() # Receipt handle is present in foo.
delete_mock.assert_called_with(
QueueUrl='https://sqs.nowh-ere.aws.com/123456789/',
ReceiptHandle='$$$')
QueueUrl='https://sqs.nowh-ere.aws.com/123456789/', ReceiptHandle='$$$'
)
@mock.patch('olympia.accounts.utils.primary_email_change_event.delay')
@mock.patch('olympia.accounts.utils.delete_user_event.delay')
@ -275,15 +289,37 @@ class TestProcessSqsQueue(TestCase):
process_fxa_event(json.dumps({'Message': ''}))
process_fxa_event(json.dumps({'Message': 'ddfdfd'}))
# No timestamps
process_fxa_event(json.dumps({'Message': json.dumps(
{'email': 'foo@baa', 'event': 'primaryEmailChanged',
'uid': '999'})}))
process_fxa_event(json.dumps({'Message': json.dumps(
{'event': 'delete', 'uid': '999'})}))
process_fxa_event(
json.dumps(
{
'Message': json.dumps(
{
'email': 'foo@baa',
'event': 'primaryEmailChanged',
'uid': '999',
}
)
}
)
)
process_fxa_event(
json.dumps({'Message': json.dumps({'event': 'delete', 'uid': '999'})})
)
# Not a supported event type
process_fxa_event(json.dumps({'Message': json.dumps(
{'email': 'foo@baa', 'event': 'not-an-event', 'uid': '999',
'ts': totimestamp(datetime.now())})}))
process_fxa_event(
json.dumps(
{
'Message': json.dumps(
{
'email': 'foo@baa',
'event': 'not-an-event',
'uid': '999',
'ts': totimestamp(datetime.now()),
}
)
}
)
)
delete_mock.assert_not_called()
email_mock.assert_not_called()
@ -297,23 +333,32 @@ class TestProcessFxAEventEmail(TestCase):
def setUp(self):
self.email_changed_date = self.days_ago(42)
self.body = json.dumps({'Message': json.dumps(
{'email': 'new-email@example.com', 'event': 'primaryEmailChanged',
'uid': self.fxa_id,
'ts': totimestamp(self.email_changed_date)})})
self.body = json.dumps(
{
'Message': json.dumps(
{
'email': 'new-email@example.com',
'event': 'primaryEmailChanged',
'uid': self.fxa_id,
'ts': totimestamp(self.email_changed_date),
}
)
}
)
def test_success_integration(self):
user = user_factory(email='old-email@example.com',
fxa_id=self.fxa_id)
user = user_factory(email='old-email@example.com', fxa_id=self.fxa_id)
process_fxa_event(self.body)
user.reload()
assert user.email == 'new-email@example.com'
assert user.email_changed == self.email_changed_date
def test_success_integration_previously_changed_once(self):
user = user_factory(email='old-email@example.com',
fxa_id=self.fxa_id,
email_changed=datetime(2017, 10, 11))
user = user_factory(
email='old-email@example.com',
fxa_id=self.fxa_id,
email_changed=datetime(2017, 10, 11),
)
process_fxa_event(self.body)
user.reload()
assert user.email == 'new-email@example.com'
@ -324,9 +369,8 @@ class TestProcessFxAEventEmail(TestCase):
process_fxa_event(self.body)
primary_email_change_event.assert_called()
primary_email_change_event.assert_called_with(
self.fxa_id,
totimestamp(self.email_changed_date),
'new-email@example.com')
self.fxa_id, totimestamp(self.email_changed_date), 'new-email@example.com'
)
class TestProcessFxAEventDelete(TestCase):
@ -334,10 +378,17 @@ class TestProcessFxAEventDelete(TestCase):
def setUp(self):
self.email_changed_date = self.days_ago(42)
self.body = json.dumps({'Message': json.dumps(
{'event': 'delete',
'uid': self.fxa_id,
'ts': totimestamp(self.email_changed_date)})})
self.body = json.dumps(
{
'Message': json.dumps(
{
'event': 'delete',
'uid': self.fxa_id,
'ts': totimestamp(self.email_changed_date),
}
)
}
)
@override_switch('fxa-account-delete', active=True)
def test_success_integration(self):
@ -353,5 +404,5 @@ class TestProcessFxAEventDelete(TestCase):
process_fxa_event(self.body)
delete_user_event_mock.assert_called()
delete_user_event_mock.assert_called_with(
self.fxa_id,
totimestamp(self.email_changed_date))
self.fxa_id, totimestamp(self.email_changed_date)
)

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

@ -8,7 +8,6 @@ from olympia.accounts import verify
class TestProfile(TestCase):
def setUp(self):
patcher = mock.patch('olympia.accounts.verify.requests.get')
self.get = patcher.start()
@ -21,9 +20,12 @@ class TestProfile(TestCase):
self.get.return_value.json.return_value = profile_data
profile = verify.get_fxa_profile('profile-plz', {})
assert profile == profile_data
self.get.assert_called_with('https://app.fxa/v1/profile', headers={
'Authorization': 'Bearer profile-plz',
})
self.get.assert_called_with(
'https://app.fxa/v1/profile',
headers={
'Authorization': 'Bearer profile-plz',
},
)
@override_settings(FXA_PROFILE_HOST='https://app.fxa/v1')
def test_success_no_email(self):
@ -32,9 +34,12 @@ class TestProfile(TestCase):
self.get.return_value.json.return_value = profile_data
with pytest.raises(verify.IdentificationError):
verify.get_fxa_profile('profile-plz', {})
self.get.assert_called_with('https://app.fxa/v1/profile', headers={
'Authorization': 'Bearer profile-plz',
})
self.get.assert_called_with(
'https://app.fxa/v1/profile',
headers={
'Authorization': 'Bearer profile-plz',
},
)
@override_settings(FXA_PROFILE_HOST='https://app.fxa/v1')
def test_failure(self):
@ -43,13 +48,15 @@ class TestProfile(TestCase):
self.get.json.return_value = profile_data
with pytest.raises(verify.IdentificationError):
verify.get_fxa_profile('profile-plz', {})
self.get.assert_called_with('https://app.fxa/v1/profile', headers={
'Authorization': 'Bearer profile-plz',
})
self.get.assert_called_with(
'https://app.fxa/v1/profile',
headers={
'Authorization': 'Bearer profile-plz',
},
)
class TestToken(TestCase):
def setUp(self):
patcher = mock.patch('olympia.accounts.verify.requests.post')
self.post = patcher.start()
@ -60,16 +67,22 @@ class TestToken(TestCase):
token_data = {'access_token': 'c0de'}
self.post.return_value.status_code = 200
self.post.return_value.json.return_value = token_data
token = verify.get_fxa_token('token-plz', {
'client_id': 'test-client-id',
'client_secret': "don't look",
})
token = verify.get_fxa_token(
'token-plz',
{
'client_id': 'test-client-id',
'client_secret': "don't look",
},
)
assert token == token_data
self.post.assert_called_with('https://app.fxa/oauth/v1/token', data={
'code': 'token-plz',
'client_id': 'test-client-id',
'client_secret': "don't look",
})
self.post.assert_called_with(
'https://app.fxa/oauth/v1/token',
data={
'code': 'token-plz',
'client_id': 'test-client-id',
'client_secret': "don't look",
},
)
@override_settings(FXA_OAUTH_HOST='https://app.fxa/oauth/v1')
def test_no_token(self):
@ -77,15 +90,21 @@ class TestToken(TestCase):
self.post.return_value.status_code = 200
self.post.return_value.json.return_value = token_data
with pytest.raises(verify.IdentificationError):
verify.get_fxa_token('token-plz', {
verify.get_fxa_token(
'token-plz',
{
'client_id': 'test-client-id',
'client_secret': "don't look",
},
)
self.post.assert_called_with(
'https://app.fxa/oauth/v1/token',
data={
'code': 'token-plz',
'client_id': 'test-client-id',
'client_secret': "don't look",
})
self.post.assert_called_with('https://app.fxa/oauth/v1/token', data={
'code': 'token-plz',
'client_id': 'test-client-id',
'client_secret': "don't look",
})
},
)
@override_settings(FXA_OAUTH_HOST='https://app.fxa/oauth/v1')
def test_failure(self):
@ -93,15 +112,21 @@ class TestToken(TestCase):
self.post.return_value.status_code = 400
self.post.json.return_value = token_data
with pytest.raises(verify.IdentificationError):
verify.get_fxa_token('token-plz', {
verify.get_fxa_token(
'token-plz',
{
'client_id': 'test-client-id',
'client_secret': "don't look",
},
)
self.post.assert_called_with(
'https://app.fxa/oauth/v1/token',
data={
'code': 'token-plz',
'client_id': 'test-client-id',
'client_secret': "don't look",
})
self.post.assert_called_with('https://app.fxa/oauth/v1/token', data={
'code': 'token-plz',
'client_id': 'test-client-id',
'client_secret': "don't look",
})
},
)
class TestIdentify(TestCase):
@ -142,7 +167,7 @@ class TestIdentify(TestCase):
def test_with_id_token(self):
self.get_token.return_value = {
'access_token': 'cafe',
'id_token': 'openidisawesome'
'id_token': 'openidisawesome',
}
self.get_profile.return_value = {'email': 'me@em.hi'}
identity = verify.fxa_identify('heya', self.CONFIG)

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -12,44 +12,49 @@ accounts = SimpleRouter()
accounts.register(r'account', views.AccountViewSet, basename='account')
collections = NestedSimpleRouter(accounts, r'account', lookup='user')
collections.register(r'collections', CollectionViewSet,
basename='collection')
sub_collections = NestedSimpleRouter(collections, r'collections',
lookup='collection')
sub_collections.register('addons', CollectionAddonViewSet,
basename='collection-addon')
collections.register(r'collections', CollectionViewSet, basename='collection')
sub_collections = NestedSimpleRouter(collections, r'collections', lookup='collection')
sub_collections.register('addons', CollectionAddonViewSet, basename='collection-addon')
notifications = NestedSimpleRouter(accounts, r'account', lookup='user')
notifications.register(r'notifications', views.AccountNotificationViewSet,
basename='notification')
notifications.register(
r'notifications', views.AccountNotificationViewSet, basename='notification'
)
accounts_v4 = [
re_path(r'^login/start/$',
views.LoginStartView.as_view(),
name='accounts.login_start'),
re_path(r'^session/$', views.SessionView.as_view(),
name='accounts.session'),
re_path(
r'^login/start/$', views.LoginStartView.as_view(), name='accounts.login_start'
),
re_path(r'^session/$', views.SessionView.as_view(), name='accounts.session'),
re_path(r'', include(accounts.urls)),
re_path(r'^profile/$', views.ProfileView.as_view(),
name='account-profile'),
re_path(r'^super-create/$', views.AccountSuperCreate.as_view(),
name='accounts.super-create'),
re_path(r'^unsubscribe/$',
views.AccountNotificationUnsubscribeView.as_view(),
name='account-unsubscribe'),
re_path(r'^profile/$', views.ProfileView.as_view(), name='account-profile'),
re_path(
r'^super-create/$',
views.AccountSuperCreate.as_view(),
name='accounts.super-create',
),
re_path(
r'^unsubscribe/$',
views.AccountNotificationUnsubscribeView.as_view(),
name='account-unsubscribe',
),
re_path(r'', include(collections.urls)),
re_path(r'', include(sub_collections.urls)),
re_path(r'', include(notifications.urls)),
]
accounts_v3 = accounts_v4 + [
re_path(r'^authenticate/$', views.AuthenticateView.as_view(),
name='accounts.authenticate'),
re_path(
r'^authenticate/$',
views.AuthenticateView.as_view(),
name='accounts.authenticate',
),
]
auth_callback_patterns = [
re_path(r'^authenticate-callback/$', views.AuthenticateView.as_view(),
name='accounts.authenticate'),
re_path(
r'^authenticate-callback/$',
views.AuthenticateView.as_view(),
name='accounts.authenticate',
),
]

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

@ -12,8 +12,7 @@ from django.utils.http import is_safe_url
import boto3
from olympia.accounts.tasks import (
delete_user_event, primary_email_change_event)
from olympia.accounts.tasks import delete_user_event, primary_email_change_event
from olympia.amo.urlresolvers import reverse
from olympia.amo.utils import use_fake_fxa
from olympia.core.logger import getLogger
@ -27,33 +26,44 @@ def _is_safe_url(url, request):
urlparse(settings.CODE_MANAGER_URL).netloc,
)
require_https = request.is_secure() if request else False
return is_safe_url(url, allowed_hosts=allowed_hosts,
require_https=require_https)
return is_safe_url(url, allowed_hosts=allowed_hosts, require_https=require_https)
def fxa_config(request):
config = {camel_case(key): value
for key, value in settings.FXA_CONFIG['default'].items()
if key != 'client_secret'}
config = {
camel_case(key): value
for key, value in settings.FXA_CONFIG['default'].items()
if key != 'client_secret'
}
request.session.setdefault('fxa_state', generate_fxa_state())
config.update(**{
'contentHost': settings.FXA_CONTENT_HOST,
'oauthHost': settings.FXA_OAUTH_HOST,
'profileHost': settings.FXA_PROFILE_HOST,
'scope': 'profile openid',
'state': request.session['fxa_state'],
})
config.update(
**{
'contentHost': settings.FXA_CONTENT_HOST,
'oauthHost': settings.FXA_OAUTH_HOST,
'profileHost': settings.FXA_PROFILE_HOST,
'scope': 'profile openid',
'state': request.session['fxa_state'],
}
)
if request.user.is_authenticated:
config['email'] = request.user.email
return config
def fxa_login_url(config, state, next_path=None, action=None,
force_two_factor=False, request=None, id_token=None):
def fxa_login_url(
config,
state,
next_path=None,
action=None,
force_two_factor=False,
request=None,
id_token=None,
):
if next_path and _is_safe_url(next_path, request):
state += ':' + force_text(
urlsafe_b64encode(next_path.encode('utf-8'))).rstrip('=')
state += ':' + force_text(urlsafe_b64encode(next_path.encode('utf-8'))).rstrip(
'='
)
query = {
'client_id': config['client_id'],
'scope': 'profile openid',
@ -77,8 +87,7 @@ def fxa_login_url(config, state, next_path=None, action=None,
base_url = reverse('fake-fxa-authorization')
else:
base_url = '{host}/authorization'.format(host=settings.FXA_OAUTH_HOST)
return '{base_url}?{query}'.format(
base_url=base_url, query=urlencode(query))
return '{base_url}?{query}'.format(base_url=base_url, query=urlencode(query))
def default_fxa_register_url(request):
@ -87,7 +96,8 @@ def default_fxa_register_url(request):
config=settings.FXA_CONFIG['default'],
state=request.session['fxa_state'],
next_path=path_with_query(request),
action='signup')
action='signup',
)
def default_fxa_login_url(request):
@ -96,7 +106,8 @@ def default_fxa_login_url(request):
config=settings.FXA_CONFIG['default'],
state=request.session['fxa_state'],
next_path=path_with_query(request),
action='signin')
action='signin',
)
def generate_fxa_state():
@ -133,16 +144,16 @@ def process_fxa_event(raw_body):
uid = event.get('uid')
timestamp = event.get('ts', 0)
if not (event_type and uid and timestamp):
raise ValueError(
'Properties event, uuid, and ts must all be non-empty')
raise ValueError('Properties event, uuid, and ts must all be non-empty')
except (ValueError, KeyError, TypeError) as e:
log.exception('Invalid account message: %s' % e)
else:
if event_type == 'primaryEmailChanged':
email = event.get('email')
if not email:
log.error('Email property must be non-empty for "%s" event' %
event_type)
log.error(
'Email property must be non-empty for "%s" event' % event_type
)
else:
primary_email_change_event.delay(uid, timestamp, email)
elif event_type == 'delete':
@ -156,11 +167,12 @@ def process_sqs_queue(queue_url):
log.info('Processing account events from %s', queue_url)
try:
region = queue_url.split('.')[1]
available_regions = (boto3._get_default_session()
.get_available_regions('sqs'))
available_regions = boto3._get_default_session().get_available_regions('sqs')
if region not in available_regions:
log.error('SQS misconfigured, expected region, got %s from %s' % (
region, queue_url))
log.error(
'SQS misconfigured, expected region, got %s from %s'
% (region, queue_url)
)
# Connect to the SQS queue.
# Credentials are specified in EC2 as an IAM role on prod/stage/dev.
# If you're testing locally see boto3 docs for how to specify:
@ -171,7 +183,8 @@ def process_sqs_queue(queue_url):
response = sqs.receive_message(
QueueUrl=queue_url,
WaitTimeSeconds=settings.FXA_SQS_AWS_WAIT_TIME,
MaxNumberOfMessages=10)
MaxNumberOfMessages=10,
)
msgs = response.get('Messages', []) if response else []
for message in msgs:
try:
@ -180,8 +193,8 @@ def process_sqs_queue(queue_url):
# unrecognized type. Not point leaving a backlog.
if 'ReceiptHandle' in message:
sqs.delete_message(
QueueUrl=queue_url,
ReceiptHandle=message['ReceiptHandle'])
QueueUrl=queue_url, ReceiptHandle=message['ReceiptHandle']
)
except Exception as exc:
log.exception('Error while processing message: %s' % exc)
except Exception as exc:

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

@ -38,11 +38,14 @@ def get_fxa_token(code, config):
"""
log.info('Getting token [{code}]'.format(code=code))
with statsd.timer('accounts.fxa.identify.token'):
response = requests.post(settings.FXA_OAUTH_HOST + '/token', data={
'code': code,
'client_id': config['client_id'],
'client_secret': config['client_secret'],
})
response = requests.post(
settings.FXA_OAUTH_HOST + '/token',
data={
'code': code,
'client_id': config['client_id'],
'client_secret': config['client_secret'],
},
)
if response.status_code == 200:
data = response.json()
if data.get('access_token'):
@ -51,13 +54,17 @@ def get_fxa_token(code, config):
else:
log.info('No token returned [{code}]'.format(code=code))
raise IdentificationError(
'No access token returned for {code}'.format(code=code))
'No access token returned for {code}'.format(code=code)
)
else:
log.info(
'Token returned non-200 status {status} {body} [{code}]'.format(
code=code, status=response.status_code, body=response.content))
code=code, status=response.status_code, body=response.content
)
)
raise IdentificationError(
'Could not get access token for {code}'.format(code=code))
'Could not get access token for {code}'.format(code=code)
)
def get_fxa_profile(token, config):
@ -65,9 +72,10 @@ def get_fxa_profile(token, config):
corresponding user."""
with statsd.timer('accounts.fxa.identify.profile'):
response = requests.get(
settings.FXA_PROFILE_HOST + '/profile', headers={
settings.FXA_PROFILE_HOST + '/profile',
headers={
'Authorization': 'Bearer {token}'.format(token=token),
}
},
)
if response.status_code == 200:
profile = response.json()
@ -75,10 +83,15 @@ def get_fxa_profile(token, config):
return profile
else:
log.info('Incomplete profile {profile}'.format(profile=profile))
raise IdentificationError('Profile incomplete for {token}'.format(
token=token))
raise IdentificationError(
'Profile incomplete for {token}'.format(token=token)
)
else:
log.info('Profile returned non-200 status {status} {body}'.format(
status=response.status_code, body=response.content))
raise IdentificationError('Could not find profile for {token}'.format(
token=token))
log.info(
'Profile returned non-200 status {status} {body}'.format(
status=response.status_code, body=response.content
)
)
raise IdentificationError(
'Could not find profile for {token}'.format(token=token)
)

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

@ -20,9 +20,12 @@ import waffle
from corsheaders.conf import conf as corsheaders_conf
from corsheaders.middleware import (
ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_ALLOW_CREDENTIALS,
ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_MAX_AGE)
ACCESS_CONTROL_ALLOW_ORIGIN,
ACCESS_CONTROL_ALLOW_CREDENTIALS,
ACCESS_CONTROL_ALLOW_HEADERS,
ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_MAX_AGE,
)
from django_statsd.clients import statsd
from rest_framework import serializers
from rest_framework.authentication import SessionAuthentication
@ -30,9 +33,12 @@ from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import GenericAPIView
from rest_framework.mixins import (
DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin)
from rest_framework.permissions import (
AllowAny, BasePermission, IsAuthenticated)
DestroyModelMixin,
ListModelMixin,
RetrieveModelMixin,
UpdateModelMixin,
)
from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated
from rest_framework.response import Response
from rest_framework.status import HTTP_204_NO_CONTENT
from rest_framework.views import APIView
@ -48,17 +54,24 @@ from olympia.amo import messages
from olympia.amo.decorators import use_primary_db
from olympia.amo.utils import fetch_subscribed_newsletters, use_fake_fxa
from olympia.api.authentication import (
JWTKeyAuthentication, UnsubscribeTokenAuthentication,
WebTokenAuthentication)
JWTKeyAuthentication,
UnsubscribeTokenAuthentication,
WebTokenAuthentication,
)
from olympia.api.permissions import AnyOf, ByHttpMethod, GroupPermission
from olympia.users.models import UserNotification, UserProfile
from olympia.users.notifications import (
NOTIFICATIONS_COMBINED, REMOTE_NOTIFICATIONS_BY_BASKET_ID)
NOTIFICATIONS_COMBINED,
REMOTE_NOTIFICATIONS_BY_BASKET_ID,
)
from . import verify
from .serializers import (
AccountSuperCreateSerializer, PublicUserProfileSerializer,
UserNotificationSerializer, UserProfileSerializer)
AccountSuperCreateSerializer,
PublicUserProfileSerializer,
UserNotificationSerializer,
UserProfileSerializer,
)
from .utils import _is_safe_url, fxa_login_url, generate_fxa_state
@ -77,10 +90,8 @@ ERROR_STATUSES = {
}
LOGIN_ERROR_MESSAGES = {
ERROR_AUTHENTICATED: _(u'You are already logged in.'),
ERROR_NO_CODE:
_(u'Your login attempt could not be parsed. Please try again.'),
ERROR_NO_PROFILE:
_(u'Your Firefox Account could not be found. Please try again.'),
ERROR_NO_CODE: _(u'Your login attempt could not be parsed. Please try again.'),
ERROR_NO_PROFILE: _(u'Your Firefox Account could not be found. Please try again.'),
ERROR_STATE_MISMATCH: _(u'You could not be logged in. Please try again.'),
}
@ -113,7 +124,8 @@ def find_user(identity):
"""
try:
user = UserProfile.objects.get(
Q(fxa_id=identity['uid']) | Q(email=identity['email']))
Q(fxa_id=identity['uid']) | Q(email=identity['email'])
)
is_task_user = user.id == settings.TASK_USER_ID
if user.banned or is_task_user:
# If the user was banned raise a 403, it's not the prettiest
@ -128,14 +140,15 @@ def find_user(identity):
except UserProfile.MultipleObjectsReturned:
# This shouldn't happen, so let it raise.
log.error(
'Found multiple users for %s and %s',
identity['email'], identity['uid'])
'Found multiple users for %s and %s', identity['email'], identity['uid']
)
raise
def register_user(identity):
user = UserProfile.objects.create_user(
email=identity['email'], fxa_id=identity['uid'])
email=identity['email'], fxa_id=identity['uid']
)
log.info('Created user {} from FxA'.format(user))
statsd.incr('accounts.account_created_from_fxa')
return user
@ -150,13 +163,17 @@ def reregister_user(user):
def update_user(user, identity):
"""Update a user's info from FxA if needed, as well as generating the id
that is used as part of the session/api token generation."""
if (user.fxa_id != identity['uid'] or
user.email != identity['email']):
if user.fxa_id != identity['uid'] or user.email != identity['email']:
log.info(
'Updating user info from FxA for {pk}. Old {old_email} {old_uid} '
'New {new_email} {new_uid}'.format(
pk=user.pk, old_email=user.email, old_uid=user.fxa_id,
new_email=identity['email'], new_uid=identity['uid']))
pk=user.pk,
old_email=user.email,
old_uid=user.fxa_id,
new_email=identity['email'],
new_uid=identity['uid'],
)
)
user.update(fxa_id=identity['uid'], email=identity['email'])
if user.auth_id is None:
# If the user didn't have an auth id (old user account created before
@ -174,12 +191,13 @@ def login_user(sender, request, user, identity):
def fxa_error_message(message, login_help_url):
return format_html(
u'{error} <a href="{url}">{help_text}</a>',
url=login_help_url, help_text=_(u'Need help?'),
error=message)
url=login_help_url,
help_text=_(u'Need help?'),
error=message,
)
LOGIN_HELP_URL = (
'https://support.mozilla.org/kb/access-your-add-ons-firefox-accounts')
LOGIN_HELP_URL = 'https://support.mozilla.org/kb/access-your-add-ons-firefox-accounts'
def render_error(request, error, next_path=None, format=None):
@ -192,7 +210,8 @@ def render_error(request, error, next_path=None, format=None):
messages.error(
request,
fxa_error_message(LOGIN_ERROR_MESSAGES[error], LOGIN_HELP_URL),
extra_tags='fxa')
extra_tags='fxa',
)
if next_path is None:
response = HttpResponseRedirect('/')
else:
@ -207,11 +226,11 @@ def parse_next_path(state_parts, request=None):
# but it only cares if there are too few so add 4 of them.
encoded_path = state_parts[1] + '===='
try:
next_path = base64.urlsafe_b64decode(
force_bytes(encoded_path)).decode('utf-8')
next_path = base64.urlsafe_b64decode(force_bytes(encoded_path)).decode(
'utf-8'
)
except (TypeError, ValueError):
log.info('Error decoding next_path {}'.format(
encoded_path))
log.info('Error decoding next_path {}'.format(encoded_path))
pass
if not _is_safe_url(next_path, request):
next_path = None
@ -219,7 +238,6 @@ def parse_next_path(state_parts, request=None):
def with_user(format):
def outer(fn):
@functools.wraps(fn)
@use_primary_db
@ -236,29 +254,34 @@ def with_user(format):
if not data.get('code'):
log.info('No code provided.')
return render_error(
request, ERROR_NO_CODE, next_path=next_path, format=format)
elif (not request.session.get('fxa_state') or
request.session['fxa_state'] != state):
request, ERROR_NO_CODE, next_path=next_path, format=format
)
elif (
not request.session.get('fxa_state')
or request.session['fxa_state'] != state
):
log.info(
'State mismatch. URL: {url} Session: {session}'.format(
url=data.get('state'),
session=request.session.get('fxa_state'),
))
)
)
return render_error(
request, ERROR_STATE_MISMATCH, next_path=next_path,
format=format)
request, ERROR_STATE_MISMATCH, next_path=next_path, format=format
)
elif request.user.is_authenticated:
response = render_error(
request, ERROR_AUTHENTICATED, next_path=next_path,
format=format)
request, ERROR_AUTHENTICATED, next_path=next_path, format=format
)
# If the api token cookie is missing but we're still
# authenticated using the session, add it back.
if API_TOKEN_COOKIE not in request.COOKIES:
log.info('User %s was already authenticated but did not '
'have an API token cookie, adding one.',
request.user.pk)
response = add_api_token_to_response(
response, request.user)
log.info(
'User %s was already authenticated but did not '
'have an API token cookie, adding one.',
request.user.pk,
)
response = add_api_token_to_response(response, request.user)
return response
try:
if use_fake_fxa() and 'fake_fxa_email' in data:
@ -266,33 +289,36 @@ def with_user(format):
# and generate a random fxa id.
identity = {
'email': data['fake_fxa_email'],
'uid': 'fake_fxa_id-%s' % force_text(
binascii.b2a_hex(os.urandom(16))
)
'uid': 'fake_fxa_id-%s'
% force_text(binascii.b2a_hex(os.urandom(16))),
}
id_token = identity['email']
else:
identity, id_token = verify.fxa_identify(
data['code'], config=fxa_config)
data['code'], config=fxa_config
)
except verify.IdentificationError:
log.info('Profile not found. Code: {}'.format(data['code']))
return render_error(
request, ERROR_NO_PROFILE, next_path=next_path,
format=format)
request, ERROR_NO_PROFILE, next_path=next_path, format=format
)
else:
user = find_user(identity)
# We can't use waffle.flag_is_active() wrapper, because
# request.user isn't populated at this point (and we don't want
# it to be).
flag = waffle.get_waffle_flag_model().get(
'2fa-enforcement-for-developers-and-special-users')
enforce_2fa_for_developers_and_special_users = (
flag.is_active(request) or
(flag.pk and flag.is_active_for_user(user)))
if (user and
not identity.get('twoFactorAuthentication') and
enforce_2fa_for_developers_and_special_users and
(user.is_addon_developer or user.groups_list)):
'2fa-enforcement-for-developers-and-special-users'
)
enforce_2fa_for_developers_and_special_users = flag.is_active(
request
) or (flag.pk and flag.is_active_for_user(user))
if (
user
and not identity.get('twoFactorAuthentication')
and enforce_2fa_for_developers_and_special_users
and (user.is_addon_developer or user.groups_list)
):
# https://github.com/mozilla/addons/issues/732
# The user is an add-on developer (with other types of
# add-ons than just themes) or part of any group (so they
@ -316,9 +342,11 @@ def with_user(format):
)
)
return fn(
self, request, user=user, identity=identity,
next_path=next_path)
self, request, user=user, identity=identity, next_path=next_path
)
return inner
return outer
@ -347,7 +375,8 @@ def add_api_token_to_response(response, user):
max_age=settings.SESSION_COOKIE_AGE,
secure=settings.SESSION_COOKIE_SECURE,
httponly=settings.SESSION_COOKIE_HTTPONLY,
samesite=settings.SESSION_COOKIE_SAMESITE)
samesite=settings.SESSION_COOKIE_SAMESITE,
)
return response
@ -359,8 +388,7 @@ class FxAConfigMixin(object):
def get_config_name(self, request):
config_name = request.GET.get('config', self.DEFAULT_FXA_CONFIG_NAME)
if config_name not in self.ALLOWED_FXA_CONFIGS:
log.info('Using default FxA config instead of {}'.format(
config_name))
log.info('Using default FxA config instead of {}'.format(config_name))
config_name = self.DEFAULT_FXA_CONFIG_NAME
return config_name
@ -369,7 +397,6 @@ class FxAConfigMixin(object):
class LoginStartView(FxAConfigMixin, APIView):
def get(self, request):
request.session.setdefault('fxa_state', generate_fxa_state())
return HttpResponseRedirect(
@ -417,17 +444,20 @@ def logout_user(request, response):
response.delete_cookie(
API_TOKEN_COOKIE,
domain=settings.SESSION_COOKIE_DOMAIN,
samesite=settings.SESSION_COOKIE_SAMESITE)
samesite=settings.SESSION_COOKIE_SAMESITE,
)
# This view is not covered by the CORS middleware, see:
# https://github.com/mozilla/addons-server/issues/11100
class SessionView(APIView):
permission_classes = [
ByHttpMethod({
'options': AllowAny, # Needed for CORS.
'delete': IsAuthenticated,
}),
ByHttpMethod(
{
'options': AllowAny, # Needed for CORS.
'delete': IsAuthenticated,
}
),
]
def options(self, request, *args, **kwargs):
@ -446,8 +476,7 @@ class SessionView(APIView):
corsheaders_conf.CORS_ALLOW_METHODS
)
if corsheaders_conf.CORS_PREFLIGHT_MAX_AGE:
response[ACCESS_CONTROL_MAX_AGE] = (
corsheaders_conf.CORS_PREFLIGHT_MAX_AGE)
response[ACCESS_CONTROL_MAX_AGE] = corsheaders_conf.CORS_PREFLIGHT_MAX_AGE
return response
def delete(self, request, *args, **kwargs):
@ -469,19 +498,20 @@ class AllowSelf(BasePermission):
return request.user.is_authenticated and obj == request.user
class AccountViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin,
GenericViewSet):
class AccountViewSet(
RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet
):
permission_classes = [
ByHttpMethod({
'get': AllowAny,
'head': AllowAny,
'options': AllowAny, # Needed for CORS.
# To edit a profile it has to yours, or be an admin.
'patch': AnyOf(AllowSelf, GroupPermission(
amo.permissions.USERS_EDIT)),
'delete': AnyOf(AllowSelf, GroupPermission(
amo.permissions.USERS_EDIT)),
}),
ByHttpMethod(
{
'get': AllowAny,
'head': AllowAny,
'options': AllowAny, # Needed for CORS.
# To edit a profile it has to yours, or be an admin.
'patch': AnyOf(AllowSelf, GroupPermission(amo.permissions.USERS_EDIT)),
'delete': AnyOf(AllowSelf, GroupPermission(amo.permissions.USERS_EDIT)),
}
),
]
# Periods are not allowed in username, but we still have some in the
# database so relax the lookup regexp to allow them to load their profile.
@ -499,10 +529,11 @@ class AccountViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin,
self.instance = super(AccountViewSet, self).get_object()
# action won't exist for other classes that are using this ViewSet.
can_view_instance = (
not getattr(self, 'action', None) or
self.self_view or
self.admin_viewing or
self.instance.is_public)
not getattr(self, 'action', None)
or self.self_view
or self.admin_viewing
or self.instance.is_public
)
if can_view_instance:
return self.instance
else:
@ -519,13 +550,13 @@ class AccountViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin,
@property
def self_view(self):
return (
self.request.user.is_authenticated and
self.get_object() == self.request.user)
self.request.user.is_authenticated
and self.get_object() == self.request.user
)
@property
def admin_viewing(self):
return acl.action_allowed_user(
self.request.user, amo.permissions.USERS_EDIT)
return acl.action_allowed_user(self.request.user, amo.permissions.USERS_EDIT)
def get_serializer_class(self):
if self.self_view or self.admin_viewing:
@ -543,8 +574,11 @@ class AccountViewSet(RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin,
@action(
detail=True,
methods=['delete'], permission_classes=[
AnyOf(AllowSelf, GroupPermission(amo.permissions.USERS_EDIT))])
methods=['delete'],
permission_classes=[
AnyOf(AllowSelf, GroupPermission(amo.permissions.USERS_EDIT))
],
)
def picture(self, request, pk=None):
user = self.get_object()
user.delete_picture()
@ -560,7 +594,8 @@ class ProfileView(APIView):
account_viewset = AccountViewSet(
request=request,
permission_classes=self.permission_classes,
kwargs={'pk': str(self.request.user.pk)})
kwargs={'pk': str(self.request.user.pk)},
)
account_viewset.format_kwarg = self.format_kwarg
return account_viewset.retrieve(request)
@ -569,14 +604,14 @@ class AccountSuperCreate(APIView):
authentication_classes = [JWTKeyAuthentication]
permission_classes = [
IsAuthenticated,
GroupPermission(amo.permissions.ACCOUNTS_SUPER_CREATE)]
GroupPermission(amo.permissions.ACCOUNTS_SUPER_CREATE),
]
@waffle_switch('super-create-accounts')
def post(self, request):
serializer = AccountSuperCreateSerializer(data=request.data)
if not serializer.is_valid():
return Response({'errors': serializer.errors},
status=422)
return Response({'errors': serializer.errors}, status=422)
data = serializer.data
@ -591,7 +626,8 @@ class AccountSuperCreate(APIView):
email=email,
fxa_id=fxa_id,
display_name='Super Created {}'.format(user_token),
notes='auto-generated from API')
notes='auto-generated from API',
)
user.save()
if group:
@ -601,11 +637,12 @@ class AccountSuperCreate(APIView):
login_user(self.__class__, request, user, identity)
request.session.save()
log.info(u'API user {api_user} created and logged in a user from '
u'the super-create API: user_id: {user.pk}; '
u'user_name: {user.username}; fxa_id: {user.fxa_id}; '
u'group: {group}'
.format(user=user, api_user=request.user, group=group))
log.info(
u'API user {api_user} created and logged in a user from '
u'the super-create API: user_id: {user.pk}; '
u'user_name: {user.username}; fxa_id: {user.fxa_id}; '
u'group: {group}'.format(user=user, api_user=request.user, group=group)
)
cookie = {
'name': settings.SESSION_COOKIE_NAME,
@ -613,19 +650,21 @@ class AccountSuperCreate(APIView):
}
cookie['encoded'] = '{name}={value}'.format(**cookie)
return Response({
'user_id': user.pk,
'username': user.username,
'email': user.email,
'display_name': user.display_name,
'groups': list((g.pk, g.name, g.rules) for g in user.groups.all()),
'fxa_id': user.fxa_id,
'session_cookie': cookie,
}, status=201)
return Response(
{
'user_id': user.pk,
'username': user.username,
'email': user.email,
'display_name': user.display_name,
'groups': list((g.pk, g.name, g.rules) for g in user.groups.all()),
'fxa_id': user.fxa_id,
'session_cookie': cookie,
},
status=201,
)
class AccountNotificationMixin(object):
def get_user(self):
raise NotImplementedError
@ -633,7 +672,8 @@ class AccountNotificationMixin(object):
return UserNotification(
user=self.get_user(),
notification_id=notification.id,
enabled=notification.default_checked)
enabled=notification.default_checked,
)
def get_queryset(self, dev=False):
user = self.get_user()
@ -644,8 +684,10 @@ class AccountNotificationMixin(object):
# Put it into a dict so we can easily check for existence.
set_notifications = {
user_nfn.notification.short: user_nfn for user_nfn in queryset
if user_nfn.notification}
user_nfn.notification.short: user_nfn
for user_nfn in queryset
if user_nfn.notification
}
out = []
newsletters = None # Lazy - fetch the first time needed.
@ -665,22 +707,28 @@ class AccountNotificationMixin(object):
if notification.group == 'dev' and not include_dev:
# We only return dev notifications for developers.
continue
out.append(set_notifications.get(
notification.short, # It's been set by the user.
self._get_default_object(notification))) # Or, default.
out.append(
set_notifications.get(
notification.short, # It's been set by the user.
self._get_default_object(notification),
)
) # Or, default.
return out
class AccountNotificationViewSet(AccountNotificationMixin, ListModelMixin,
GenericViewSet):
class AccountNotificationViewSet(
AccountNotificationMixin, ListModelMixin, GenericViewSet
):
"""Returns account notifications.
If not already set by the user, defaults will be returned.
"""
permission_classes = [IsAuthenticated]
# We're pushing the primary permission checking to AccountViewSet for ease.
account_permission_classes = [
AnyOf(AllowSelf, GroupPermission(amo.permissions.USERS_EDIT))]
AnyOf(AllowSelf, GroupPermission(amo.permissions.USERS_EDIT))
]
serializer_class = UserNotificationSerializer
paginator = None
@ -692,7 +740,8 @@ class AccountNotificationViewSet(AccountNotificationMixin, ListModelMixin,
self.account_viewset = AccountViewSet(
request=self.request,
permission_classes=self.account_permission_classes,
kwargs={'pk': self.kwargs['user_pk']})
kwargs={'pk': self.kwargs['user_pk']},
)
return self.account_viewset
def create(self, request, *args, **kwargs):
@ -703,14 +752,14 @@ class AccountNotificationViewSet(AccountNotificationMixin, ListModelMixin,
enabled = request.data.get(notification.notification.short)
if enabled is not None:
serializer = self.get_serializer(
notification, partial=True, data={'enabled': enabled})
notification, partial=True, data={'enabled': enabled}
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(self.get_serializer(queryset, many=True).data)
class AccountNotificationUnsubscribeView(AccountNotificationMixin,
GenericAPIView):
class AccountNotificationUnsubscribeView(AccountNotificationMixin, GenericAPIView):
authentication_classes = (UnsubscribeTokenAuthentication,)
permission_classes = ()
serializer_class = UserNotificationSerializer
@ -724,11 +773,13 @@ class AccountNotificationUnsubscribeView(AccountNotificationMixin,
for notification in self.get_queryset(dev=True):
if notification_name == notification.notification.short:
serializer = self.get_serializer(
notification, partial=True, data={'enabled': False})
notification, partial=True, data={'enabled': False}
)
serializer.is_valid(raise_exception=True)
serializer.save()
if not serializer:
raise serializers.ValidationError(
_('Notification [%s] does not exist') % notification_name)
_('Notification [%s] does not exist') % notification_name
)
return Response(serializer.data)

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

@ -1,4 +1,5 @@
def log_create(action, *args, **kw):
"""Use this if importing ActivityLog causes a circular import."""
from olympia.activity.models import ActivityLog
return ActivityLog.create(action, *args, **kw)

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

@ -4,11 +4,23 @@ from .models import ActivityLog
class ActivityLogAdmin(admin.ModelAdmin):
list_display = ('created', 'user', '__str__',)
list_display = (
'created',
'user',
'__str__',
)
raw_id_fields = ('user',)
readonly_fields = ('created', 'user', '__str__',)
readonly_fields = (
'created',
'user',
'__str__',
)
date_hierarchy = 'created'
fields = ('user', 'created', '__str__',)
fields = (
'user',
'created',
'__str__',
)
raw_id_fields = ('user',)
def has_add_permission(self, request):

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

@ -15,25 +15,31 @@ class Command(BaseCommand):
"""Handle command arguments."""
parser.add_argument('token_uuid', nargs='*')
parser.add_argument(
'--version_id', action='store', type=int,
'--version_id',
action='store',
type=int,
dest='version_id',
help='Expire all tokens on this version.')
help='Expire all tokens on this version.',
)
def handle(self, *args, **options):
version_pk = options.get('version_id')
token_uuids = options.get('token_uuid')
if token_uuids:
done = [t.expire() for t in ActivityLogToken.objects.filter(
uuid__in=token_uuids)]
log.info(
u'%s tokens (%s) expired' % (len(done), ','.join(token_uuids)))
done = [
t.expire()
for t in ActivityLogToken.objects.filter(uuid__in=token_uuids)
]
log.info(u'%s tokens (%s) expired' % (len(done), ','.join(token_uuids)))
if version_pk:
print('Warning: --version_id ignored as tokens provided too')
elif version_pk:
done = [t.expire() for t in ActivityLogToken.objects.filter(
version__pk=version_pk)]
log.info(
u'%s tokens for version %s expired' % (len(done), version_pk))
done = [
t.expire()
for t in ActivityLogToken.objects.filter(version__pk=version_pk)
]
log.info(u'%s tokens for version %s expired' % (len(done), version_pk))
else:
raise CommandError(
u'Please provide either at least one token, or a version id.')
u'Please provide either at least one token, or a version id.'
)

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

@ -41,43 +41,49 @@ MAX_TOKEN_USE_COUNT = 100
class ActivityLogToken(ModelBase):
id = PositiveAutoField(primary_key=True)
version = models.ForeignKey(
Version, related_name='token', on_delete=models.CASCADE)
version = models.ForeignKey(Version, related_name='token', on_delete=models.CASCADE)
user = models.ForeignKey(
'users.UserProfile', related_name='activity_log_tokens',
on_delete=models.CASCADE)
'users.UserProfile',
related_name='activity_log_tokens',
on_delete=models.CASCADE,
)
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
use_count = models.IntegerField(
default=0,
help_text='Stores the number of times the token has been used')
default=0, help_text='Stores the number of times the token has been used'
)
class Meta:
db_table = 'log_activity_tokens'
constraints = [
models.UniqueConstraint(fields=('version', 'user'),
name='version_id'),
models.UniqueConstraint(fields=('version', 'user'), name='version_id'),
]
def is_expired(self):
return self.use_count >= MAX_TOKEN_USE_COUNT
def is_valid(self):
return (not self.is_expired() and
self.version == self.version.addon.find_latest_version(
channel=self.version.channel, exclude=()))
return (
not self.is_expired()
and self.version
== self.version.addon.find_latest_version(
channel=self.version.channel, exclude=()
)
)
def expire(self):
self.update(use_count=MAX_TOKEN_USE_COUNT)
def increment_use(self):
self.__class__.objects.filter(pk=self.pk).update(
use_count=models.expressions.F('use_count') + 1)
use_count=models.expressions.F('use_count') + 1
)
self.use_count = self.use_count + 1
class ActivityLogEmails(ModelBase):
"""A log of message ids of incoming emails so we don't duplicate process
them."""
messageid = models.CharField(max_length=255, unique=True)
class Meta:
@ -88,6 +94,7 @@ class AddonLog(ModelBase):
"""
This table is for indexing the activity log by addon.
"""
addon = models.ForeignKey(Addon, on_delete=models.CASCADE)
activity_log = models.ForeignKey('ActivityLog', on_delete=models.CASCADE)
@ -101,8 +108,9 @@ class AddonLog(ModelBase):
# ``arguments = [{'addons.addon':12}, {'addons.addon':1}, ... ]``
arguments = json.loads(self.activity_log._arguments)
except Exception:
log.info('unserializing data from addon_log failed: %s' %
self.activity_log.id)
log.info(
'unserializing data from addon_log failed: %s' % self.activity_log.id
)
return None
new_arguments = []
@ -120,6 +128,7 @@ class CommentLog(ModelBase):
"""
This table is for indexing the activity log by comment.
"""
activity_log = models.ForeignKey('ActivityLog', on_delete=models.CASCADE)
comments = models.TextField()
@ -132,6 +141,7 @@ class VersionLog(ModelBase):
"""
This table is for indexing the activity log by version.
"""
activity_log = models.ForeignKey('ActivityLog', on_delete=models.CASCADE)
version = models.ForeignKey(Version, on_delete=models.CASCADE)
@ -145,6 +155,7 @@ class UserLog(ModelBase):
This table is for indexing the activity log by user.
Note: This includes activity performed unto the user.
"""
activity_log = models.ForeignKey('ActivityLog', on_delete=models.CASCADE)
user = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
@ -157,6 +168,7 @@ class GroupLog(ModelBase):
"""
This table is for indexing the activity log by access group.
"""
id = PositiveAutoField(primary_key=True)
activity_log = models.ForeignKey('ActivityLog', on_delete=models.CASCADE)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
@ -170,6 +182,7 @@ class BlockLog(ModelBase):
"""
This table is for indexing the activity log by Blocklist Block.
"""
id = PositiveAutoField(primary_key=True)
activity_log = models.ForeignKey('ActivityLog', on_delete=models.CASCADE)
block = models.ForeignKey(Block, on_delete=models.SET_NULL, null=True)
@ -186,14 +199,15 @@ class DraftComment(ModelBase):
This is being used by the commenting API by the code-manager.
"""
id = PositiveAutoField(primary_key=True)
version = models.ForeignKey(Version, on_delete=models.CASCADE)
user = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
filename = models.CharField(max_length=255, null=True, blank=True)
lineno = models.PositiveIntegerField(null=True)
canned_response = models.ForeignKey(
CannedResponse, null=True, default=None,
on_delete=models.SET_DEFAULT)
CannedResponse, null=True, default=None, on_delete=models.SET_DEFAULT
)
comment = models.TextField(blank=True)
class Meta:
@ -241,8 +255,10 @@ class ActivityLogManager(ManagerBase):
return self.filter(blocklog__guid=guid)
def for_developer(self):
return self.exclude(action__in=constants.activity.LOG_ADMINS +
constants.activity.LOG_HIDE_DEVELOPER)
return self.exclude(
action__in=constants.activity.LOG_ADMINS
+ constants.activity.LOG_HIDE_DEVELOPER
)
def admin_events(self):
return self.filter(action__in=constants.activity.LOG_ADMINS)
@ -252,45 +268,55 @@ class ActivityLogManager(ManagerBase):
def review_queue(self):
qs = self._by_type()
return (qs.filter(action__in=constants.activity.LOG_REVIEW_QUEUE)
.exclude(user__id=settings.TASK_USER_ID))
return qs.filter(action__in=constants.activity.LOG_REVIEW_QUEUE).exclude(
user__id=settings.TASK_USER_ID
)
def review_log(self):
qs = self._by_type()
return (
qs.filter(action__in=constants.activity.LOG_REVIEWER_REVIEW_ACTION)
.exclude(user__id=settings.TASK_USER_ID))
return qs.filter(
action__in=constants.activity.LOG_REVIEWER_REVIEW_ACTION
).exclude(user__id=settings.TASK_USER_ID)
def total_ratings(self, theme=False):
"""Return the top users, and their # of reviews."""
qs = self._by_type()
action_ids = ([amo.LOG.THEME_REVIEW.id] if theme
else constants.activity.LOG_REVIEWER_REVIEW_ACTION)
return (qs.values('user', 'user__display_name', 'user__username')
.filter(action__in=action_ids)
.exclude(user__id=settings.TASK_USER_ID)
.annotate(approval_count=models.Count('id'))
.order_by('-approval_count'))
action_ids = (
[amo.LOG.THEME_REVIEW.id]
if theme
else constants.activity.LOG_REVIEWER_REVIEW_ACTION
)
return (
qs.values('user', 'user__display_name', 'user__username')
.filter(action__in=action_ids)
.exclude(user__id=settings.TASK_USER_ID)
.annotate(approval_count=models.Count('id'))
.order_by('-approval_count')
)
def monthly_reviews(self, theme=False):
"""Return the top users for the month, and their # of reviews."""
qs = self._by_type()
now = datetime.now()
created_date = datetime(now.year, now.month, 1)
actions = ([constants.activity.LOG.THEME_REVIEW.id] if theme
else constants.activity.LOG_REVIEWER_REVIEW_ACTION)
return (qs.values('user', 'user__display_name', 'user__username')
.filter(created__gte=created_date,
action__in=actions)
.exclude(user__id=settings.TASK_USER_ID)
.annotate(approval_count=models.Count('id'))
.order_by('-approval_count'))
actions = (
[constants.activity.LOG.THEME_REVIEW.id]
if theme
else constants.activity.LOG_REVIEWER_REVIEW_ACTION
)
return (
qs.values('user', 'user__display_name', 'user__username')
.filter(created__gte=created_date, action__in=actions)
.exclude(user__id=settings.TASK_USER_ID)
.annotate(approval_count=models.Count('id'))
.order_by('-approval_count')
)
def user_approve_reviews(self, user):
qs = self._by_type()
return qs.filter(
action__in=constants.activity.LOG_REVIEWER_REVIEW_ACTION,
user__id=user.id)
action__in=constants.activity.LOG_REVIEWER_REVIEW_ACTION, user__id=user.id
)
def current_month_user_approve_reviews(self, user):
now = datetime.now()
@ -299,8 +325,14 @@ class ActivityLogManager(ManagerBase):
def user_position(self, values_qs, user):
try:
return next(i for (i, d) in enumerate(list(values_qs))
if d.get('user') == user.id) + 1
return (
next(
i
for (i, d) in enumerate(list(values_qs))
if d.get('user') == user.id
)
+ 1
)
except StopIteration:
return None
@ -314,9 +346,8 @@ class ActivityLogManager(ManagerBase):
qs = self.get_queryset()
table = 'log_activity_addon'
return qs.extra(
tables=[table],
where=['%s.activity_log_id=%s.id'
% (table, 'log_activity')])
tables=[table], where=['%s.activity_log_id=%s.id' % (table, 'log_activity')]
)
class SafeFormatter(string.Formatter):
@ -330,10 +361,9 @@ class SafeFormatter(string.Formatter):
class ActivityLog(ModelBase):
TYPES = sorted(
[(value.id, key)
for key, value in constants.activity.LOG_BY_ID.items()])
user = models.ForeignKey(
'users.UserProfile', null=True, on_delete=models.SET_NULL)
[(value.id, key) for key, value in constants.activity.LOG_BY_ID.items()]
)
user = models.ForeignKey('users.UserProfile', null=True, on_delete=models.SET_NULL)
action = models.SmallIntegerField(choices=TYPES)
_arguments = models.TextField(blank=True, db_column='arguments')
_details = models.TextField(blank=True, db_column='details')
@ -375,9 +405,7 @@ class ActivityLog(ModelBase):
# `[{'addons.addon':12}, {'addons.addon':1}, ... ]`
activity.arguments_data = json.loads(activity._arguments)
except Exception as e:
log.info(
'unserializing data from activity_log failed: %s',
activity.id)
log.info('unserializing data from activity_log failed: %s', activity.id)
log.info(e)
activity.arguments_data = []
@ -499,31 +527,36 @@ class ActivityLog(ModelBase):
for arg in self.arguments:
if isinstance(arg, Addon) and not addon:
if arg.has_listed_versions():
addon = self.f(u'<a href="{0}">{1}</a>',
arg.get_url_path(), arg.name)
addon = self.f(
u'<a href="{0}">{1}</a>', arg.get_url_path(), arg.name
)
else:
addon = self.f(u'{0}', arg.name)
arguments.remove(arg)
if isinstance(arg, Rating) and not rating:
rating = self.f(u'<a href="{0}">{1}</a>',
arg.get_url_path(), ugettext('Review'))
rating = self.f(
u'<a href="{0}">{1}</a>', arg.get_url_path(), ugettext('Review')
)
arguments.remove(arg)
if isinstance(arg, Version) and not version:
text = ugettext('Version {0}')
if arg.channel == amo.RELEASE_CHANNEL_LISTED:
version = self.f(u'<a href="{1}">%s</a>' % text,
arg.version, arg.get_url_path())
version = self.f(
u'<a href="{1}">%s</a>' % text, arg.version, arg.get_url_path()
)
else:
version = self.f(text, arg.version)
arguments.remove(arg)
if isinstance(arg, Collection) and not collection:
collection = self.f(u'<a href="{0}">{1}</a>',
arg.get_url_path(), arg.name)
collection = self.f(
u'<a href="{0}">{1}</a>', arg.get_url_path(), arg.name
)
arguments.remove(arg)
if isinstance(arg, Tag) and not tag:
if arg.can_reverse():
tag = self.f(u'<a href="{0}">{1}</a>',
arg.get_url_path(), arg.tag_text)
tag = self.f(
u'<a href="{0}">{1}</a>', arg.get_url_path(), arg.tag_text
)
else:
tag = self.f('{0}', arg.tag_text)
if isinstance(arg, Group) and not group:
@ -532,17 +565,19 @@ class ActivityLog(ModelBase):
if isinstance(arg, File) and not file_:
validation = 'passed'
if self.action in (
amo.LOG.UNLISTED_SIGNED.id,
amo.LOG.UNLISTED_SIGNED_VALIDATION_FAILED.id):
amo.LOG.UNLISTED_SIGNED.id,
amo.LOG.UNLISTED_SIGNED_VALIDATION_FAILED.id,
):
validation = 'ignored'
file_ = self.f(u'<a href="{0}">{1}</a> (validation {2})',
arg.get_url_path(),
arg.filename,
validation)
file_ = self.f(
u'<a href="{0}">{1}</a> (validation {2})',
arg.get_url_path(),
arg.filename,
validation,
)
arguments.remove(arg)
if (self.action == amo.LOG.CHANGE_STATUS.id and
not isinstance(arg, Addon)):
if self.action == amo.LOG.CHANGE_STATUS.id and not isinstance(arg, Addon):
# Unfortunately, this action has been abused in the past and
# the non-addon argument could be a string or an int. If it's
# an int, we want to retrieve the string and translate it.
@ -609,8 +644,8 @@ class ActivityLog(ModelBase):
# creating a new one, especially useful for log entries created
# in a loop.
al = ActivityLog(
user=user, action=action.id,
created=kw.get('created', timezone.now()))
user=user, action=action.id, created=kw.get('created', timezone.now())
)
al.set_arguments(args)
if 'details' in kw:
al.details = kw['details']
@ -618,8 +653,10 @@ class ActivityLog(ModelBase):
if 'details' in kw and 'comments' in al.details:
CommentLog.objects.create(
comments=al.details['comments'], activity_log=al,
created=kw.get('created', timezone.now()))
comments=al.details['comments'],
activity_log=al,
created=kw.get('created', timezone.now()),
)
for arg in args:
if isinstance(arg, tuple):
@ -631,27 +668,38 @@ class ActivityLog(ModelBase):
if class_ == Addon:
AddonLog.objects.create(
addon_id=id_, activity_log=al,
created=kw.get('created', timezone.now()))
addon_id=id_,
activity_log=al,
created=kw.get('created', timezone.now()),
)
elif class_ == Version:
VersionLog.objects.create(
version_id=id_, activity_log=al,
created=kw.get('created', timezone.now()))
version_id=id_,
activity_log=al,
created=kw.get('created', timezone.now()),
)
elif class_ == UserProfile:
UserLog.objects.create(
user_id=id_, activity_log=al,
created=kw.get('created', timezone.now()))
user_id=id_,
activity_log=al,
created=kw.get('created', timezone.now()),
)
elif class_ == Group:
GroupLog.objects.create(
group_id=id_, activity_log=al,
created=kw.get('created', timezone.now()))
group_id=id_,
activity_log=al,
created=kw.get('created', timezone.now()),
)
elif class_ == Block:
BlockLog.objects.create(
block_id=id_, activity_log=al, guid=arg.guid,
created=kw.get('created', timezone.now()))
block_id=id_,
activity_log=al,
guid=arg.guid,
created=kw.get('created', timezone.now()),
)
# Index by every user
UserLog.objects.create(
activity_log=al, user=user,
created=kw.get('created', timezone.now()))
activity_log=al, user=user, created=kw.get('created', timezone.now())
)
return al

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

@ -16,8 +16,15 @@ class ActivityLogSerializer(serializers.ModelSerializer):
class Meta:
model = ActivityLog
fields = ('id', 'action', 'action_label', 'comments', 'user', 'date',
'highlight')
fields = (
'id',
'action',
'action_label',
'comments',
'user',
'date',
'highlight',
)
def __init__(self, *args, **kwargs):
super(ActivityLogSerializer, self).__init__(*args, **kwargs)
@ -50,9 +57,5 @@ class ActivityLogSerializer(serializers.ModelSerializer):
}
request = self.context.get('request')
if request and is_gate_active(request, 'activity-user-shim'):
data.update({
'id': None,
'username': None,
'url': None
})
data.update({'id': None, 'username': None, 'url': None})
return data

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

@ -21,19 +21,19 @@ def process_email(message, spam_rating, **kwargs):
if header.get('Name', '').lower() == 'message-id':
msg_id = header.get('Value')
if not msg_id:
log.error('No MessageId in message, aborting.', extra={
'message_obj': message
})
log.error('No MessageId in message, aborting.', extra={'message_obj': message})
return
_, created = ActivityLogEmails.objects.get_or_create(messageid=msg_id)
if not created:
log.warning('Already processed email [%s], skipping', msg_id, extra={
'message_obj': message
})
log.warning(
'Already processed email [%s], skipping',
msg_id,
extra={'message_obj': message},
)
return
res = add_email_to_activity_log_wrapper(message, spam_rating)
if not res:
log.error('Failed to process email [%s].', msg_id, extra={
'message_obj': message
})
log.error(
'Failed to process email [%s].', msg_id, extra={'message_obj': message}
)

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

@ -11,28 +11,36 @@ from olympia.amo.tests import TestCase, addon_factory, user_factory
class TestRepudiateActivityLogToken(TestCase):
def setUp(self):
addon = addon_factory()
self.version = addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
self.version = addon.find_latest_version(channel=amo.RELEASE_CHANNEL_LISTED)
self.token1 = ActivityLogToken.objects.create(
uuid='5a0b8a83d501412589cc5d562334b46b',
version=self.version, user=user_factory())
version=self.version,
user=user_factory(),
)
self.token2 = ActivityLogToken.objects.create(
uuid='8a0b8a834e71412589cc5d562334b46b',
version=self.version, user=user_factory())
version=self.version,
user=user_factory(),
)
self.token3 = ActivityLogToken.objects.create(
uuid='336ae924bc23804cef345d562334b46b',
version=self.version, user=user_factory())
version=self.version,
user=user_factory(),
)
addon2 = addon_factory()
addon2_version = addon2.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
addon2_version = addon2.find_latest_version(channel=amo.RELEASE_CHANNEL_LISTED)
self.token_diff_version = ActivityLogToken.objects.create(
uuid='470023efdac5730773340eaf3080b589',
version=addon2_version, user=user_factory())
version=addon2_version,
user=user_factory(),
)
def test_with_tokens(self):
call_command('repudiate_token',
'5a0b8a83d501412589cc5d562334b46b',
'8a0b8a834e71412589cc5d562334b46b')
call_command(
'repudiate_token',
'5a0b8a83d501412589cc5d562334b46b',
'8a0b8a834e71412589cc5d562334b46b',
)
assert self.token1.reload().is_expired()
assert self.token2.reload().is_expired()
assert not self.token3.reload().is_expired()
@ -46,9 +54,11 @@ class TestRepudiateActivityLogToken(TestCase):
assert not self.token_diff_version.reload().is_expired()
def test_with_token_and_version_ignores_version(self):
call_command('repudiate_token',
'5a0b8a83d501412589cc5d562334b46b',
version_id=self.version.id)
call_command(
'repudiate_token',
'5a0b8a83d501412589cc5d562334b46b',
version_id=self.version.id,
)
assert self.token1.reload().is_expired() # token supplied is expired.
assert not self.token2.reload().is_expired() # version supplied isn't.
assert not self.token3.reload().is_expired() # check the others too.

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

@ -6,11 +6,14 @@ from pyquery import PyQuery as pq
from olympia import amo, core
from olympia.activity.models import (
MAX_TOKEN_USE_COUNT, ActivityLog, ActivityLogToken, AddonLog,
DraftComment)
MAX_TOKEN_USE_COUNT,
ActivityLog,
ActivityLogToken,
AddonLog,
DraftComment,
)
from olympia.addons.models import Addon, AddonUser
from olympia.amo.tests import (
TestCase, addon_factory, user_factory, version_factory)
from olympia.amo.tests import TestCase, addon_factory, user_factory, version_factory
from olympia.bandwagon.models import Collection
from olympia.ratings.models import Rating
from olympia.reviewers.models import CannedResponse
@ -24,11 +27,13 @@ class TestActivityLogToken(TestCase):
super(TestActivityLogToken, self).setUp()
self.addon = addon_factory()
self.version = self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
channel=amo.RELEASE_CHANNEL_LISTED
)
self.version.update(created=self.days_ago(1))
self.user = user_factory()
self.token = ActivityLogToken.objects.create(
version=self.version, user=self.user)
version=self.version, user=self.user
)
def test_uuid_is_automatically_created(self):
assert self.token.uuid
@ -45,7 +50,8 @@ class TestActivityLogToken(TestCase):
assert self.token.is_expired()
# But the version is still the latest version.
assert self.version == self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
channel=amo.RELEASE_CHANNEL_LISTED
)
assert not self.token.is_valid()
def test_increment_use(self):
@ -53,7 +59,8 @@ class TestActivityLogToken(TestCase):
self.token.increment_use()
assert self.token.use_count == 1
token_from_db = ActivityLogToken.objects.get(
version=self.version, user=self.user)
version=self.version, user=self.user
)
assert token_from_db.use_count == 1
def test_validity_version_out_of_date(self):
@ -66,7 +73,8 @@ class TestActivityLogToken(TestCase):
def test_validity_still_valid_if_new_version_in_different_channel(self):
version_factory(addon=self.addon, channel=amo.RELEASE_CHANNEL_UNLISTED)
assert self.version == self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
channel=amo.RELEASE_CHANNEL_LISTED
)
# The token isn't expired.
assert not self.token.is_expired()
@ -86,8 +94,7 @@ class TestActivityLog(TestCase):
def setUp(self):
super(TestActivityLog, self).setUp()
self.user = UserProfile.objects.create(
username='yolo', display_name='Yolo')
self.user = UserProfile.objects.create(username='yolo', display_name='Yolo')
self.request = Mock()
self.request.user = self.user
core.set_user(self.user)
@ -176,13 +183,15 @@ class TestActivityLog(TestCase):
def test_arguments_old_reviews_app(self):
addon = Addon.objects.get()
rating = Rating.objects.create(
addon=addon, user=self.user, user_responsible=self.user, rating=5)
addon=addon, user=self.user, user_responsible=self.user, rating=5
)
activity = ActivityLog.objects.latest('pk')
# Override _arguments to use reviews.review instead of ratings.rating,
# as old data already present in the db would use.
activity._arguments = (
u'[{"addons.addon": %d}, {"reviews.review": %d}]' % (
addon.pk, rating.pk))
activity._arguments = u'[{"addons.addon": %d}, {"reviews.review": %d}]' % (
addon.pk,
rating.pk,
)
assert activity.arguments == [addon, rating]
def test_no_arguments(self):
@ -208,8 +217,9 @@ class TestActivityLog(TestCase):
"""
user = UserProfile(username='Marlboro Manatee')
user.save()
ActivityLog.create(amo.LOG.ADD_USER_WITH_ROLE,
user, 'developer', Addon.objects.get())
ActivityLog.create(
amo.LOG.ADD_USER_WITH_ROLE, user, 'developer', Addon.objects.get()
)
entries = ActivityLog.objects.for_user(self.request.user)
assert len(entries) == 1
entries = ActivityLog.objects.for_user(user)
@ -217,8 +227,9 @@ class TestActivityLog(TestCase):
def test_version_log(self):
version = Version.objects.all()[0]
ActivityLog.create(amo.LOG.REJECT_VERSION, version.addon, version,
user=self.request.user)
ActivityLog.create(
amo.LOG.REJECT_VERSION, version.addon, version, user=self.request.user
)
entries = ActivityLog.objects.for_versions(version)
assert len(entries) == 1
assert version.get_url_path() in str(entries[0])
@ -229,8 +240,8 @@ class TestActivityLog(TestCase):
addon_factory() # To create an extra unrelated version
for version in Version.objects.all():
ActivityLog.create(
amo.LOG.REJECT_VERSION, version.addon, version,
user=self.request.user)
amo.LOG.REJECT_VERSION, version.addon, version, user=self.request.user
)
entries = ActivityLog.objects.for_versions(addon.versions.all())
assert len(entries) == 2
@ -239,8 +250,9 @@ class TestActivityLog(TestCase):
# Get the url before the addon is changed to unlisted.
url_path = version.get_url_path()
self.make_addon_unlisted(version.addon)
ActivityLog.create(amo.LOG.REJECT_VERSION, version.addon, version,
user=self.request.user)
ActivityLog.create(
amo.LOG.REJECT_VERSION, version.addon, version, user=self.request.user
)
entries = ActivityLog.objects.for_versions(version)
assert len(entries) == 1
assert url_path not in str(entries[0])
@ -248,18 +260,22 @@ class TestActivityLog(TestCase):
def test_version_log_transformer(self):
addon = Addon.objects.get()
version = addon.current_version
ActivityLog.create(amo.LOG.REJECT_VERSION, addon, version,
user=self.request.user)
ActivityLog.create(
amo.LOG.REJECT_VERSION, addon, version, user=self.request.user
)
version_two = Version(addon=addon, license=version.license,
version='1.2.3')
version_two = Version(addon=addon, license=version.license, version='1.2.3')
version_two.save()
ActivityLog.create(amo.LOG.REJECT_VERSION, addon, version_two,
user=self.request.user)
ActivityLog.create(
amo.LOG.REJECT_VERSION, addon, version_two, user=self.request.user
)
versions = (Version.objects.filter(addon=addon).order_by('-created')
.transform(Version.transformer_activity))
versions = (
Version.objects.filter(addon=addon)
.order_by('-created')
.transform(Version.transformer_activity)
)
assert len(versions[0].all_activity) == 1
assert len(versions[1].all_activity) == 1
@ -271,17 +287,18 @@ class TestActivityLog(TestCase):
addon = addon.reload()
au = AddonUser(addon=addon, user=self.user)
ActivityLog.create(
amo.LOG.CHANGE_USER_WITH_ROLE, au.user,
str(au.get_role_display()), addon)
amo.LOG.CHANGE_USER_WITH_ROLE, au.user, str(au.get_role_display()), addon
)
log = ActivityLog.objects.get()
log_expected = ('Yolo role changed to Owner for <a href="/en-US/'
'firefox/addon/a3615/">Delicious &lt;script src='
'&#34;x.js&#34;&gt;Bookmarks</a>.')
log_expected = (
'Yolo role changed to Owner for <a href="/en-US/'
'firefox/addon/a3615/">Delicious &lt;script src='
'&#34;x.js&#34;&gt;Bookmarks</a>.'
)
assert log.to_string() == log_expected
rendered = amo.utils.from_string('<p>{{ log }}</p>').render(
{'log': log})
rendered = amo.utils.from_string('<p>{{ log }}</p>').render({'log': log})
assert rendered == '<p>%s</p>' % log_expected
def test_tag_no_match(self):
@ -295,42 +312,53 @@ class TestActivityLog(TestCase):
def test_change_status(self):
addon = Addon.objects.get()
log = ActivityLog.create(
amo.LOG.CHANGE_STATUS, addon, amo.STATUS_APPROVED)
expected = ('<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to Approved.')
log = ActivityLog.create(amo.LOG.CHANGE_STATUS, addon, amo.STATUS_APPROVED)
expected = (
'<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to Approved.'
)
assert str(log) == expected
log.arguments = [amo.STATUS_DISABLED, addon]
expected = ('<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to '
'Disabled by Mozilla.')
expected = (
'<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to '
'Disabled by Mozilla.'
)
assert str(log) == expected
log.arguments = [addon, amo.STATUS_NULL]
expected = ('<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to Incomplete.')
expected = (
'<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to Incomplete.'
)
assert str(log) == expected
log.arguments = [addon, 666]
expected = ('<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to 666.')
expected = (
'<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to 666.'
)
assert str(log) == expected
log.arguments = [addon, 'Some String']
expected = ('<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to Some String.')
expected = (
'<a href="/en-US/firefox/addon/a3615/">'
'Delicious Bookmarks</a> status changed to Some String.'
)
assert str(log) == expected
def test_str_activity_file(self):
addon = Addon.objects.get()
log = ActivityLog.create(
amo.LOG.UNLISTED_SIGNED, addon.current_version.current_file)
amo.LOG.UNLISTED_SIGNED, addon.current_version.current_file
)
assert str(log) == (
'<a href="/firefox/downloads/file/67442/'
'delicious_bookmarks-2.1.072-fx.xpi">'
'delicious_bookmarks-2.1.072-fx.xpi</a>'
' (validation ignored) was signed.')
' (validation ignored) was signed.'
)
class TestActivityLogCount(TestCase):
@ -366,8 +394,7 @@ class TestActivityLogCount(TestCase):
assert result[0]['approval_count'] == 5
def test_review_last_month(self):
log = ActivityLog.create(amo.LOG.APPROVE_VERSION,
Addon.objects.get())
log = ActivityLog.create(amo.LOG.APPROVE_VERSION, Addon.objects.get())
log.update(created=self.lm)
assert len(ActivityLog.objects.monthly_reviews()) == 0
@ -382,8 +409,7 @@ class TestActivityLogCount(TestCase):
assert result[0]['approval_count'] == 5
def test_total_last_month(self):
log = ActivityLog.create(amo.LOG.APPROVE_VERSION,
Addon.objects.get())
log = ActivityLog.create(amo.LOG.APPROVE_VERSION, Addon.objects.get())
log.update(created=self.lm)
result = ActivityLog.objects.total_ratings()
assert len(result) == 1
@ -415,8 +441,7 @@ class TestActivityLogCount(TestCase):
assert result == 3
result = ActivityLog.objects.user_approve_reviews(other).count()
assert result == 2
another = UserProfile.objects.create(
email="no@mtrala.la", username="a")
another = UserProfile.objects.create(email="no@mtrala.la", username="a")
result = ActivityLog.objects.user_approve_reviews(another).count()
assert result == 0
@ -425,7 +450,8 @@ class TestActivityLogCount(TestCase):
ActivityLog.objects.update(created=self.days_ago(40))
self.add_approve_logs(2)
result = ActivityLog.objects.current_month_user_approve_reviews(
self.user).count()
self.user
).count()
assert result == 2
def test_log_admin(self):
@ -440,14 +466,12 @@ class TestActivityLogCount(TestCase):
class TestDraftComment(TestCase):
def test_default_requirements(self):
addon = addon_factory()
user = user_factory()
# user and version are the absolute minimum required to
# create a DraftComment
comment = DraftComment.objects.create(
user=user, version=addon.current_version)
comment = DraftComment.objects.create(user=user, version=addon.current_version)
assert comment.user == user
assert comment.version == addon.current_version
@ -464,11 +488,12 @@ class TestDraftComment(TestCase):
name=u'Terms of services',
response=u'test',
category=amo.CANNED_RESPONSE_CATEGORY_OTHER,
type=amo.CANNED_RESPONSE_TYPE_ADDON)
type=amo.CANNED_RESPONSE_TYPE_ADDON,
)
DraftComment.objects.create(
user=user, version=addon.current_version,
canned_response=canned_response)
user=user, version=addon.current_version, canned_response=canned_response
)
canned_response.delete()

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

@ -9,10 +9,8 @@ from olympia.amo.tests import TestCase, addon_factory, user_factory
class LogMixin(object):
def log(self, comments, action, created=None):
version = self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
details = {'comments': comments,
'version': version.version}
version = self.addon.find_latest_version(channel=amo.RELEASE_CHANNEL_LISTED)
details = {'comments': comments, 'version': version.version}
kwargs = {'user': self.user, 'details': details}
al = ActivityLog.create(action, self.addon, version, **kwargs)
if created:
@ -96,19 +94,21 @@ class TestReviewNotesSerializerOutput(TestCase, LogMixin):
self.entry = self.log(u'ßäď ŞŤųƒƒ', amo.LOG.REQUEST_ADMIN_REVIEW_CODE)
result = self.serialize()
assert result['action_label'] == (
amo.LOG.REQUEST_ADMIN_REVIEW_CODE.short)
assert result['action_label'] == (amo.LOG.REQUEST_ADMIN_REVIEW_CODE.short)
# Comments should be the santized text rather than the actual content.
assert result['comments'] == amo.LOG.REQUEST_ADMIN_REVIEW_CODE.sanitize
assert result['comments'].startswith(
'The addon has been flagged for Admin Review.')
'The addon has been flagged for Admin Review.'
)
def test_log_entry_without_details(self):
# Create a log but without a details property.
self.entry = ActivityLog.create(
amo.LOG.APPROVAL_NOTES_CHANGED, self.addon,
amo.LOG.APPROVAL_NOTES_CHANGED,
self.addon,
self.addon.find_latest_version(channel=amo.RELEASE_CHANNEL_LISTED),
user=self.user)
user=self.user,
)
result = self.serialize()
# Should output an empty string.
assert result['comments'] == ''

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

@ -15,8 +15,7 @@ def test_process_email(_mock):
message = sample_message_content.get('Message')
process_email(message, 0)
assert _mock.call_count == 1
assert ActivityLogEmails.objects.filter(
messageid='This is a MessageID').exists()
assert ActivityLogEmails.objects.filter(messageid='This is a MessageID').exists()
# don't try to process the same message twice
process_email(message, 0)
assert _mock.call_count == 1
@ -27,11 +26,9 @@ def test_process_email(_mock):
@mock.patch('olympia.activity.tasks.add_email_to_activity_log_wrapper')
def test_process_email_different_messageid(_mock):
# Test 'Message-ID' works too.
message = {'CustomHeaders': [
{'Name': 'Message-ID', 'Value': '<gmail_tastic>'}]}
message = {'CustomHeaders': [{'Name': 'Message-ID', 'Value': '<gmail_tastic>'}]}
process_email(message, 0)
assert ActivityLogEmails.objects.filter(
messageid='<gmail_tastic>').exists()
assert ActivityLogEmails.objects.filter(messageid='<gmail_tastic>').exists()
assert _mock.call_count == 1
# don't try to process the same message twice
process_email(message, 0)
@ -43,11 +40,9 @@ def test_process_email_different_messageid(_mock):
@mock.patch('olympia.activity.tasks.add_email_to_activity_log_wrapper')
def test_process_email_different_messageid_case(_mock):
# Test 'Message-Id' (different case)
message = {'CustomHeaders': [
{'Name': 'Message-Id', 'Value': '<its_ios>'}]}
message = {'CustomHeaders': [{'Name': 'Message-Id', 'Value': '<its_ios>'}]}
process_email(message, 0)
assert ActivityLogEmails.objects.filter(
messageid='<its_ios>').exists()
assert ActivityLogEmails.objects.filter(messageid='<its_ios>').exists()
assert _mock.call_count == 1
# don't try to process the same message twice
process_email(message, 0)

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

@ -16,14 +16,21 @@ from waffle.testutils import override_switch
from olympia import amo
from olympia.access.models import Group, GroupUser
from olympia.activity.models import (
MAX_TOKEN_USE_COUNT, ActivityLog, ActivityLogToken)
from olympia.activity.models import MAX_TOKEN_USE_COUNT, ActivityLog, ActivityLogToken
from olympia.activity.utils import (
ACTIVITY_MAIL_GROUP, ActivityEmailEncodingError, ActivityEmailError,
ActivityEmailParser, ActivityEmailTokenError, ActivityEmailUUIDError,
ACTIVITY_MAIL_GROUP,
ActivityEmailEncodingError,
ActivityEmailError,
ActivityEmailParser,
ActivityEmailTokenError,
ActivityEmailUUIDError,
add_email_to_activity_log,
add_email_to_activity_log_wrapper, log_and_notify,
notify_about_activity_log, NOTIFICATIONS_FROM_EMAIL, send_activity_mail)
add_email_to_activity_log_wrapper,
log_and_notify,
notify_about_activity_log,
NOTIFICATIONS_FROM_EMAIL,
send_activity_mail,
)
from olympia.amo.templatetags.jinja_helpers import absolutify
from olympia.amo.tests import TestCase, addon_factory, user_factory
from olympia.amo.urlresolvers import reverse
@ -39,8 +46,7 @@ class TestEmailParser(TestCase):
def test_basic_email(self):
parser = ActivityEmailParser(sample_message_content['Message'])
assert parser.get_uuid() == '5a0b8a83d501412589cc5d562334b46b'
assert parser.reply == (
'This is a developer reply to an AMO. It\'s nice.')
assert parser.reply == ('This is a developer reply to an AMO. It\'s nice.')
def test_with_invalid_msg(self):
with self.assertRaises(ActivityEmailEncodingError):
@ -79,11 +85,15 @@ class TestEmailBouncing(TestCase):
BOUNCE_REPLY = (
'Hello,\n\nAn email was received, apparently from you. Unfortunately '
'we couldn\'t process it because of:\n%s\n\nPlease visit %s to leave '
'a reply instead.\n--\nMozilla Add-ons\n%s\n')
'a reply instead.\n--\nMozilla Add-ons\n%s\n'
)
def setUp(self):
self.bounce_reply = (
self.BOUNCE_REPLY % ('%s', settings.SITE_URL, settings.SITE_URL))
self.bounce_reply = self.BOUNCE_REPLY % (
'%s',
settings.SITE_URL,
settings.SITE_URL,
)
self.email_text = sample_message_content['Message']
@mock.patch('olympia.activity.utils.ActivityLog.create')
@ -94,8 +104,8 @@ class TestEmailBouncing(TestCase):
user = user_factory()
self.grant_permission(user, '*:*')
ActivityLogToken.objects.create(
user=user, version=version,
uuid='5a0b8a83d501412589cc5d562334b46b')
user=user, version=version, uuid='5a0b8a83d501412589cc5d562334b46b'
)
# Make log_mock return false for some reason.
log_mock.return_value = False
@ -103,8 +113,7 @@ class TestEmailBouncing(TestCase):
assert not add_email_to_activity_log_wrapper(self.email_text, 0)
assert len(mail.outbox) == 1
out = mail.outbox[0]
assert out.body == (
self.bounce_reply % 'Undefined Error.')
assert out.body == (self.bounce_reply % 'Undefined Error.')
assert out.subject == 'Re: This is the subject of a test message.'
assert out.to == ['sender@example.com']
@ -114,25 +123,29 @@ class TestEmailBouncing(TestCase):
assert len(mail.outbox) == 1
out = mail.outbox[0]
assert out.body == (
self.bounce_reply %
'UUID found in email address TO: header but is not a valid token '
'(5a0b8a83d501412589cc5d562334b46b).')
self.bounce_reply
% 'UUID found in email address TO: header but is not a valid token '
'(5a0b8a83d501412589cc5d562334b46b).'
)
assert out.subject == 'Re: This is the subject of a test message.'
assert out.to == ['sender@example.com']
def test_exception_because_invalid_email(self):
# Fails because the token doesn't exist in ActivityToken.objects
email_text = copy.deepcopy(self.email_text)
email_text['To'] = [{
'EmailAddress': 'foobar@addons.mozilla.org',
'FriendlyName': 'not a valid activity mail reply'}]
email_text['To'] = [
{
'EmailAddress': 'foobar@addons.mozilla.org',
'FriendlyName': 'not a valid activity mail reply',
}
]
assert not add_email_to_activity_log_wrapper(email_text, 0)
assert len(mail.outbox) == 1
out = mail.outbox[0]
assert out.body == (
self.bounce_reply %
'TO: address does not contain activity email uuid ('
'foobar@addons.mozilla.org).')
self.bounce_reply % 'TO: address does not contain activity email uuid ('
'foobar@addons.mozilla.org).'
)
assert out.subject == 'Re: This is the subject of a test message.'
assert out.to == ['sender@example.com']
@ -145,31 +158,38 @@ class TestEmailBouncing(TestCase):
assert not add_email_to_activity_log_wrapper(message, 0)
assert len(mail.outbox) == 1
assert mail.outbox[0].body == (
self.bounce_reply % 'Invalid or malformed json message object.')
self.bounce_reply % 'Invalid or malformed json message object.'
)
assert mail.outbox[0].subject == 'Re: your email to us'
assert mail.outbox[0].to == ['bob@dole.org']
def test_exception_in_parser_but_from_defined(self):
"""Unlikely scenario of an email missing a body but having a From."""
self._test_exception_in_parser_but_can_send_email(
{'From': {'EmailAddress': 'bob@dole.org'}})
{'From': {'EmailAddress': 'bob@dole.org'}}
)
def test_exception_in_parser_but_reply_to_defined(self):
"""Even more unlikely scenario of an email missing a body but having a
ReplyTo."""
self._test_exception_in_parser_but_can_send_email(
{'ReplyTo': {'EmailAddress': 'bob@dole.org'}})
{'ReplyTo': {'EmailAddress': 'bob@dole.org'}}
)
def test_exception_to_notifications_alias(self):
email_text = copy.deepcopy(self.email_text)
email_text['To'] = [{
'EmailAddress': 'notifications@%s' % settings.INBOUND_EMAIL_DOMAIN,
'FriendlyName': 'not a valid activity mail reply'}]
email_text['To'] = [
{
'EmailAddress': 'notifications@%s' % settings.INBOUND_EMAIL_DOMAIN,
'FriendlyName': 'not a valid activity mail reply',
}
]
assert not add_email_to_activity_log_wrapper(email_text, 0)
assert len(mail.outbox) == 1
out = mail.outbox[0]
assert ('This email address is not meant to receive emails '
'directly.') in out.body
assert (
'This email address is not meant to receive emails ' 'directly.'
) in out.body
assert out.subject == 'Re: This is the subject of a test message.'
assert out.to == ['sender@example.com']
@ -195,11 +215,9 @@ class TestEmailBouncing(TestCase):
class TestAddEmailToActivityLog(TestCase):
def setUp(self):
self.addon = addon_factory(name='Badger', status=amo.STATUS_NOMINATED)
version = self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
version = self.addon.find_latest_version(channel=amo.RELEASE_CHANNEL_LISTED)
self.profile = user_factory()
self.token = ActivityLogToken.objects.create(
version=version, user=self.profile)
self.token = ActivityLogToken.objects.create(version=version, user=self.profile)
self.token.update(uuid='5a0b8a83d501412589cc5d562334b46b')
self.parser = ActivityEmailParser(sample_message_content['Message'])
@ -238,8 +256,7 @@ class TestAddEmailToActivityLog(TestCase):
assert not add_email_to_activity_log(self.parser)
def test_broken_token(self):
parser = ActivityEmailParser(
copy.deepcopy(sample_message_content['Message']))
parser = ActivityEmailParser(copy.deepcopy(sample_message_content['Message']))
parser.email['To'][0]['EmailAddress'] = 'reviewreply+1234@foo.bar'
with self.assertRaises(ActivityEmailUUIDError):
assert not add_email_to_activity_log(parser)
@ -252,17 +269,16 @@ class TestAddEmailToActivityLog(TestCase):
class TestLogAndNotify(TestCase):
def setUp(self):
self.developer = user_factory()
self.developer2 = user_factory()
self.reviewer = user_factory(reviewer_name='Revîewer')
self.grant_permission(self.reviewer, 'Addons:Review',
'Addon Reviewers')
self.grant_permission(self.reviewer, 'Addons:Review', 'Addon Reviewers')
self.addon = addon_factory()
self.version = self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
channel=amo.RELEASE_CHANNEL_LISTED
)
self.addon.addonuser_set.create(user=self.developer)
self.addon.addonuser_set.create(user=self.developer2)
self.task_user = user_factory(id=settings.TASK_USER_ID)
@ -271,9 +287,11 @@ class TestLogAndNotify(TestCase):
author = author or self.reviewer
details = {
'comments': u'I spy, with my líttle €ye...',
'version': self.version.version}
'version': self.version.version,
}
activity = ActivityLog.create(
action, self.addon, self.version, user=author, details=details)
action, self.addon, self.version, user=author, details=details
)
activity.update(created=self.days_ago(1))
return activity
@ -290,7 +308,9 @@ class TestLogAndNotify(TestCase):
subject = call[0][0]
body = call[0][1]
assert subject == u'Mozilla Add-ons: %s %s' % (
self.addon.name, self.version.version)
self.addon.name,
self.version.version,
)
assert ('visit %s' % url) in body
assert ('receiving this email because %s' % reason_text) in body
assert 'If we do not hear from you within' not in body
@ -311,8 +331,7 @@ class TestLogAndNotify(TestCase):
assert logs[0].details['comments'] == u'Thïs is á reply'
assert send_mail_mock.call_count == 2 # One author, one reviewer.
sender = formataddr(
(self.developer.name, NOTIFICATIONS_FROM_EMAIL))
sender = formataddr((self.developer.name, NOTIFICATIONS_FROM_EMAIL))
assert sender == send_mail_mock.call_args_list[0][1]['from_email']
recipients = self._recipients(send_mail_mock)
assert len(recipients) == 2
@ -324,15 +343,18 @@ class TestLogAndNotify(TestCase):
self._check_email(
send_mail_mock.call_args_list[0],
absolutify(self.addon.get_dev_url('versions')),
'you are listed as an author of this add-on.')
'you are listed as an author of this add-on.',
)
review_url = absolutify(
reverse('reviewers.review',
kwargs={'addon_id': self.version.addon.pk,
'channel': 'listed'},
add_prefix=False))
reverse(
'reviewers.review',
kwargs={'addon_id': self.version.addon.pk, 'channel': 'listed'},
add_prefix=False,
)
)
self._check_email(
send_mail_mock.call_args_list[1],
review_url, 'you reviewed this add-on.')
send_mail_mock.call_args_list[1], review_url, 'you reviewed this add-on.'
)
@mock.patch('olympia.activity.utils.send_mail')
def test_reviewer_reply(self, send_mail_mock):
@ -349,8 +371,7 @@ class TestLogAndNotify(TestCase):
assert logs[0].details['comments'] == u'Thîs ïs a revïewer replyîng'
assert send_mail_mock.call_count == 2 # Both authors.
sender = formataddr(
(self.reviewer.reviewer_name, NOTIFICATIONS_FROM_EMAIL))
sender = formataddr((self.reviewer.reviewer_name, NOTIFICATIONS_FROM_EMAIL))
assert sender == send_mail_mock.call_args_list[0][1]['from_email']
recipients = self._recipients(send_mail_mock)
assert len(recipients) == 2
@ -362,11 +383,13 @@ class TestLogAndNotify(TestCase):
self._check_email(
send_mail_mock.call_args_list[0],
absolutify(self.addon.get_dev_url('versions')),
'you are listed as an author of this add-on.')
'you are listed as an author of this add-on.',
)
self._check_email(
send_mail_mock.call_args_list[1],
absolutify(self.addon.get_dev_url('versions')),
'you are listed as an author of this add-on.')
'you are listed as an author of this add-on.',
)
@mock.patch('olympia.activity.utils.send_mail')
def test_log_with_no_comment(self, send_mail_mock):
@ -374,31 +397,30 @@ class TestLogAndNotify(TestCase):
self._create(amo.LOG.REJECT_VERSION, self.reviewer)
action = amo.LOG.APPROVAL_NOTES_CHANGED
log_and_notify(
action=action, comments=None, note_creator=self.developer,
version=self.version)
action=action,
comments=None,
note_creator=self.developer,
version=self.version,
)
logs = ActivityLog.objects.filter(action=action.id)
assert len(logs) == 1
assert not logs[0].details # No details json because no comment.
assert send_mail_mock.call_count == 2 # One author, one reviewer.
sender = formataddr(
(self.developer.name, NOTIFICATIONS_FROM_EMAIL))
sender = formataddr((self.developer.name, NOTIFICATIONS_FROM_EMAIL))
assert sender == send_mail_mock.call_args_list[0][1]['from_email']
recipients = self._recipients(send_mail_mock)
assert len(recipients) == 2
assert self.reviewer.email in recipients
assert self.developer2.email in recipients
assert u'Approval notes changed' in (
send_mail_mock.call_args_list[0][0][1])
assert u'Approval notes changed' in (
send_mail_mock.call_args_list[1][0][1])
assert u'Approval notes changed' in (send_mail_mock.call_args_list[0][0][1])
assert u'Approval notes changed' in (send_mail_mock.call_args_list[1][0][1])
def test_staff_cc_group_is_empty_no_failure(self):
Group.objects.create(name=ACTIVITY_MAIL_GROUP, rules='None:None')
log_and_notify(amo.LOG.REJECT_VERSION, u'á', self.reviewer,
self.version)
log_and_notify(amo.LOG.REJECT_VERSION, u'á', self.reviewer, self.version)
@mock.patch('olympia.activity.utils.send_mail')
def test_staff_cc_group_get_mail(self, send_mail_mock):
@ -411,21 +433,24 @@ class TestLogAndNotify(TestCase):
assert len(logs) == 1
recipients = self._recipients(send_mail_mock)
sender = formataddr(
(self.developer.name, NOTIFICATIONS_FROM_EMAIL))
sender = formataddr((self.developer.name, NOTIFICATIONS_FROM_EMAIL))
assert sender == send_mail_mock.call_args_list[0][1]['from_email']
assert len(recipients) == 2
# self.reviewers wasn't on the thread, but gets an email anyway.
assert self.reviewer.email in recipients
assert self.developer2.email in recipients
review_url = absolutify(
reverse('reviewers.review',
kwargs={'addon_id': self.version.addon.pk,
'channel': 'listed'},
add_prefix=False))
self._check_email(send_mail_mock.call_args_list[1],
review_url,
'you are member of the activity email cc group.')
reverse(
'reviewers.review',
kwargs={'addon_id': self.version.addon.pk, 'channel': 'listed'},
add_prefix=False,
)
)
self._check_email(
send_mail_mock.call_args_list[1],
review_url,
'you are member of the activity email cc group.',
)
@mock.patch('olympia.activity.utils.send_mail')
def test_task_user_doesnt_get_mail(self, send_mail_mock):
@ -450,8 +475,9 @@ class TestLogAndNotify(TestCase):
"""If a reviewer has now left the team don't email them."""
self._create(amo.LOG.REJECT_VERSION, self.reviewer)
# Take his joob!
GroupUser.objects.get(group=Group.objects.get(name='Addon Reviewers'),
user=self.reviewer).delete()
GroupUser.objects.get(
group=Group.objects.get(name='Addon Reviewers'), user=self.reviewer
).delete()
action = amo.LOG.DEVELOPER_REPLY_VERSION
comments = u'Thïs is á reply'
@ -487,20 +513,26 @@ class TestLogAndNotify(TestCase):
# The developer who sent it doesn't get their email back.
assert self.developer.email not in recipients
self._check_email(send_mail_mock.call_args_list[0],
absolutify(self.addon.get_dev_url('versions')),
'you are listed as an author of this add-on.')
self._check_email(
send_mail_mock.call_args_list[0],
absolutify(self.addon.get_dev_url('versions')),
'you are listed as an author of this add-on.',
)
review_url = absolutify(
reverse('reviewers.review', add_prefix=False,
kwargs={'channel': 'listed', 'addon_id': self.addon.pk}))
self._check_email(send_mail_mock.call_args_list[1],
review_url, 'you reviewed this add-on.')
reverse(
'reviewers.review',
add_prefix=False,
kwargs={'channel': 'listed', 'addon_id': self.addon.pk},
)
)
self._check_email(
send_mail_mock.call_args_list[1], review_url, 'you reviewed this add-on.'
)
@mock.patch('olympia.activity.utils.send_mail')
def test_review_url_unlisted(self, send_mail_mock):
self.version.update(channel=amo.RELEASE_CHANNEL_UNLISTED)
self.grant_permission(self.reviewer, 'Addons:ReviewUnlisted',
'Addon Reviewers')
self.grant_permission(self.reviewer, 'Addons:ReviewUnlisted', 'Addon Reviewers')
# One from the reviewer.
self._create(amo.LOG.COMMENT_VERSION, self.reviewer)
@ -523,14 +555,21 @@ class TestLogAndNotify(TestCase):
# The developer who sent it doesn't get their email back.
assert self.developer.email not in recipients
self._check_email(send_mail_mock.call_args_list[0],
absolutify(self.addon.get_dev_url('versions')),
'you are listed as an author of this add-on.')
self._check_email(
send_mail_mock.call_args_list[0],
absolutify(self.addon.get_dev_url('versions')),
'you are listed as an author of this add-on.',
)
review_url = absolutify(
reverse('reviewers.review', add_prefix=False,
kwargs={'channel': 'unlisted', 'addon_id': self.addon.pk}))
self._check_email(send_mail_mock.call_args_list[1],
review_url, 'you reviewed this add-on.')
reverse(
'reviewers.review',
add_prefix=False,
kwargs={'channel': 'unlisted', 'addon_id': self.addon.pk},
)
)
self._check_email(
send_mail_mock.call_args_list[1], review_url, 'you reviewed this add-on.'
)
@mock.patch('olympia.activity.utils.send_mail')
def test_from_name_escape(self, send_mail_mock):
@ -543,7 +582,8 @@ class TestLogAndNotify(TestCase):
log_and_notify(action, comments, self.reviewer, self.version)
sender = r'"mr \"quote\" escape" <notifications@%s>' % (
settings.INBOUND_EMAIL_DOMAIN)
settings.INBOUND_EMAIL_DOMAIN
)
assert sender == send_mail_mock.call_args_list[0][1]['from_email']
@mock.patch('olympia.activity.utils.send_mail')
@ -566,8 +606,7 @@ class TestLogAndNotify(TestCase):
assert ActivityLog.objects.count() == 1 # No new activity created.
assert send_mail_mock.call_count == 2 # Both authors.
sender = formataddr((
self.reviewer.reviewer_name, NOTIFICATIONS_FROM_EMAIL))
sender = formataddr((self.reviewer.reviewer_name, NOTIFICATIONS_FROM_EMAIL))
assert sender == send_mail_mock.call_args_list[0][1]['from_email']
recipients = self._recipients(send_mail_mock)
assert len(recipients) == 2
@ -579,11 +618,13 @@ class TestLogAndNotify(TestCase):
self._check_email(
send_mail_mock.call_args_list[0],
absolutify(self.addon.get_dev_url('versions')),
'you are listed as an author of this add-on.')
'you are listed as an author of this add-on.',
)
self._check_email(
send_mail_mock.call_args_list[1],
absolutify(self.addon.get_dev_url('versions')),
'you are listed as an author of this add-on.')
'you are listed as an author of this add-on.',
)
@pytest.mark.django_db
@ -591,25 +632,32 @@ def test_send_activity_mail():
subject = u'This ïs ã subject'
message = u'And... this ïs a messãge!'
addon = addon_factory()
latest_version = addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
latest_version = addon.find_latest_version(channel=amo.RELEASE_CHANNEL_LISTED)
user = user_factory()
recipients = [user, ]
recipients = [
user,
]
from_email = 'bob@bob.bob'
action = ActivityLog.create(amo.LOG.DEVELOPER_REPLY_VERSION, user=user)
send_activity_mail(
subject, message, latest_version, recipients, from_email, action.id)
subject, message, latest_version, recipients, from_email, action.id
)
assert len(mail.outbox) == 1
assert mail.outbox[0].body == message
assert mail.outbox[0].subject == subject
uuid = latest_version.token.get(user=user).uuid.hex
reference_header = '<{addon}/{version}@{site}>'.format(
addon=latest_version.addon.id, version=latest_version.id,
site=settings.INBOUND_EMAIL_DOMAIN)
addon=latest_version.addon.id,
version=latest_version.id,
site=settings.INBOUND_EMAIL_DOMAIN,
)
message_id = '<{addon}/{version}/{action}@{site}>'.format(
addon=latest_version.addon.id, version=latest_version.id,
action=action.id, site=settings.INBOUND_EMAIL_DOMAIN)
addon=latest_version.addon.id,
version=latest_version.id,
action=action.id,
site=settings.INBOUND_EMAIL_DOMAIN,
)
assert mail.outbox[0].extra_headers['In-Reply-To'] == reference_header
assert mail.outbox[0].extra_headers['References'] == reference_header

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

@ -13,14 +13,21 @@ from olympia.activity.views import EmailCreationPermission, inbound_email
from olympia.addons.models import AddonUser, AddonRegionalRestrictions
from olympia.addons.utils import generate_addon_guid
from olympia.amo.tests import (
APITestClient, TestCase, addon_factory, req_factory_factory, reverse_ns,
user_factory, version_factory)
APITestClient,
TestCase,
addon_factory,
req_factory_factory,
reverse_ns,
user_factory,
version_factory,
)
from olympia.users.models import UserProfile
class ReviewNotesViewSetDetailMixin(LogMixin):
"""Tests that play with addon state and permissions. Shared between review
note viewset detail tests since both need to react the same way."""
def _test_url(self):
raise NotImplementedError
@ -139,25 +146,29 @@ class ReviewNotesViewSetDetailMixin(LogMixin):
def test_developer_geo_restricted(self):
AddonRegionalRestrictions.objects.create(
addon=self.addon, excluded_regions=['AB', 'CD'])
addon=self.addon, excluded_regions=['AB', 'CD']
)
self._login_developer()
response = self.client.get(self.url, HTTP_X_COUNTRY_CODE='fr')
assert response.status_code == 200
AddonRegionalRestrictions.objects.filter(
addon=self.addon).update(excluded_regions=['AB', 'CD', 'FR'])
AddonRegionalRestrictions.objects.filter(addon=self.addon).update(
excluded_regions=['AB', 'CD', 'FR']
)
response = self.client.get(self.url, HTTP_X_COUNTRY_CODE='fr')
assert response.status_code == 200
def test_reviewer_geo_restricted(self):
AddonRegionalRestrictions.objects.create(
addon=self.addon, excluded_regions=['AB', 'CD'])
addon=self.addon, excluded_regions=['AB', 'CD']
)
self._login_reviewer()
response = self.client.get(self.url, HTTP_X_COUNTRY_CODE='fr')
assert response.status_code == 200
AddonRegionalRestrictions.objects.filter(
addon=self.addon).update(excluded_regions=['AB', 'CD', 'FR'])
AddonRegionalRestrictions.objects.filter(addon=self.addon).update(
excluded_regions=['AB', 'CD', 'FR']
)
response = self.client.get(self.url, HTTP_X_COUNTRY_CODE='fr')
assert response.status_code == 200
@ -168,12 +179,13 @@ class TestReviewNotesViewSetDetail(ReviewNotesViewSetDetailMixin, TestCase):
def setUp(self):
super(TestReviewNotesViewSetDetail, self).setUp()
self.addon = addon_factory(
guid=generate_addon_guid(), name=u'My Addôn', slug='my-addon')
guid=generate_addon_guid(), name=u'My Addôn', slug='my-addon'
)
self.user = user_factory()
self.version = self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
self.note = self.log(u'noôo!', amo.LOG.REVIEWER_REPLY_VERSION,
self.days_ago(0))
channel=amo.RELEASE_CHANNEL_LISTED
)
self.note = self.log(u'noôo!', amo.LOG.REVIEWER_REPLY_VERSION, self.days_ago(0))
self._set_tested_url()
def _test_url(self):
@ -186,10 +198,14 @@ class TestReviewNotesViewSetDetail(ReviewNotesViewSetDetailMixin, TestCase):
assert result['highlight'] # Its the first reply so highlight
def _set_tested_url(self, pk=None, version_pk=None, addon_pk=None):
self.url = reverse_ns('version-reviewnotes-detail', kwargs={
'addon_pk': addon_pk or self.addon.pk,
'version_pk': version_pk or self.version.pk,
'pk': pk or self.note.pk})
self.url = reverse_ns(
'version-reviewnotes-detail',
kwargs={
'addon_pk': addon_pk or self.addon.pk,
'version_pk': version_pk or self.version.pk,
'pk': pk or self.note.pk,
},
)
def test_get_note_not_found(self):
self._login_reviewer(permission='*:*')
@ -204,22 +220,26 @@ class TestReviewNotesViewSetList(ReviewNotesViewSetDetailMixin, TestCase):
def setUp(self):
super(TestReviewNotesViewSetList, self).setUp()
self.addon = addon_factory(
guid=generate_addon_guid(), name=u'My Addôn', slug='my-addon')
guid=generate_addon_guid(), name=u'My Addôn', slug='my-addon'
)
self.user = user_factory()
self.note = self.log(u'noôo!', amo.LOG.APPROVE_VERSION,
self.days_ago(3))
self.note2 = self.log(u'réply!', amo.LOG.DEVELOPER_REPLY_VERSION,
self.days_ago(2))
self.note3 = self.log(u'yéss!', amo.LOG.REVIEWER_REPLY_VERSION,
self.days_ago(1))
self.note = self.log(u'noôo!', amo.LOG.APPROVE_VERSION, self.days_ago(3))
self.note2 = self.log(
u'réply!', amo.LOG.DEVELOPER_REPLY_VERSION, self.days_ago(2)
)
self.note3 = self.log(
u'yéss!', amo.LOG.REVIEWER_REPLY_VERSION, self.days_ago(1)
)
self.version = self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
channel=amo.RELEASE_CHANNEL_LISTED
)
self._set_tested_url()
def test_queries(self):
self.note4 = self.log(u'fiiiine', amo.LOG.REVIEWER_REPLY_VERSION,
self.days_ago(0))
self.note4 = self.log(
u'fiiiine', amo.LOG.REVIEWER_REPLY_VERSION, self.days_ago(0)
)
self._login_developer()
with self.assertNumQueries(17):
# - 2 savepoints because of tests
@ -259,9 +279,13 @@ class TestReviewNotesViewSetList(ReviewNotesViewSetDetailMixin, TestCase):
assert not result_version['highlight'] # The dev replied so read it.
def _set_tested_url(self, pk=None, version_pk=None, addon_pk=None):
self.url = reverse_ns('version-reviewnotes-list', kwargs={
'addon_pk': addon_pk or self.addon.pk,
'version_pk': version_pk or self.version.pk})
self.url = reverse_ns(
'version-reviewnotes-list',
kwargs={
'addon_pk': addon_pk or self.addon.pk,
'version_pk': version_pk or self.version.pk,
},
)
def test_admin_activity_hidden_from_developer(self):
# Add an extra activity note but a type we don't show the developer.
@ -277,19 +301,21 @@ class TestReviewNotesViewSetCreate(TestCase):
def setUp(self):
super(TestReviewNotesViewSetCreate, self).setUp()
self.addon = addon_factory(
guid=generate_addon_guid(), name=u'My Addôn', slug='my-addon')
guid=generate_addon_guid(), name=u'My Addôn', slug='my-addon'
)
self.version = self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
self.url = reverse_ns('version-reviewnotes-list', kwargs={
'addon_pk': self.addon.pk,
'version_pk': self.version.pk})
channel=amo.RELEASE_CHANNEL_LISTED
)
self.url = reverse_ns(
'version-reviewnotes-list',
kwargs={'addon_pk': self.addon.pk, 'version_pk': self.version.pk},
)
def _post_reply(self):
return self.client.post(self.url, {'comments': u'comménty McCómm€nt'})
def get_review_activity_queryset(self):
return ActivityLog.objects.filter(
action__in=amo.LOG_REVIEW_QUEUE_DEVELOPER)
return ActivityLog.objects.filter(action__in=amo.LOG_REVIEW_QUEUE_DEVELOPER)
def test_anonymous_is_401(self):
assert self._post_reply().status_code == 401
@ -317,8 +343,8 @@ class TestReviewNotesViewSetCreate(TestCase):
rdata = response.data
assert reply.pk == rdata['id']
assert (
str(reply.details['comments']) == rdata['comments'] ==
u'comménty McCómm€nt')
str(reply.details['comments']) == rdata['comments'] == u'comménty McCómm€nt'
)
assert reply.user == self.user
assert reply.user.name == rdata['user']['name'] == self.user.name
assert reply.action == amo.LOG.DEVELOPER_REPLY_VERSION.id
@ -343,8 +369,8 @@ class TestReviewNotesViewSetCreate(TestCase):
rdata = response.data
assert reply.pk == rdata['id']
assert (
str(reply.details['comments']) == rdata['comments'] ==
u'comménty McCómm€nt')
str(reply.details['comments']) == rdata['comments'] == u'comménty McCómm€nt'
)
assert reply.user == self.user
assert reply.user.name == rdata['user']['name'] == self.user.name
assert reply.action == amo.LOG.REVIEWER_REPLY_VERSION.id
@ -367,14 +393,14 @@ class TestReviewNotesViewSetCreate(TestCase):
assert not self.get_review_activity_queryset().exists()
def test_reply_to_deleted_version_is_400(self):
old_version = self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
old_version = self.addon.find_latest_version(channel=amo.RELEASE_CHANNEL_LISTED)
new_version = version_factory(addon=self.addon)
old_version.delete()
# Just in case, make sure the add-on is still public.
self.addon.reload()
assert new_version == self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
channel=amo.RELEASE_CHANNEL_LISTED
)
assert self.addon.status
self.user = user_factory()
@ -385,22 +411,22 @@ class TestReviewNotesViewSetCreate(TestCase):
assert not self.get_review_activity_queryset().exists()
def test_cant_reply_to_old_version(self):
old_version = self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
old_version = self.addon.find_latest_version(channel=amo.RELEASE_CHANNEL_LISTED)
old_version.update(created=self.days_ago(1))
new_version = version_factory(addon=self.addon)
assert new_version == self.addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
channel=amo.RELEASE_CHANNEL_LISTED
)
self.user = user_factory()
self.grant_permission(self.user, 'Addons:Review')
self.client.login_api(self.user)
# First check we can reply to new version
new_url = reverse_ns('version-reviewnotes-list', kwargs={
'addon_pk': self.addon.pk,
'version_pk': new_version.pk})
response = self.client.post(
new_url, {'comments': u'comménty McCómm€nt'})
new_url = reverse_ns(
'version-reviewnotes-list',
kwargs={'addon_pk': self.addon.pk, 'version_pk': new_version.pk},
)
response = self.client.post(new_url, {'comments': u'comménty McCómm€nt'})
assert response.status_code == 201
assert self.get_review_activity_queryset().count() == 1
@ -427,7 +453,6 @@ class TestReviewNotesViewSetCreate(TestCase):
@override_settings(INBOUND_EMAIL_SECRET_KEY='SOME SECRET KEY')
@override_settings(INBOUND_EMAIL_VALIDATION_KEY='validation key')
class TestEmailApi(TestCase):
def get_request(self, data):
# Request body should be a bytes string, so it needs to be encoded
# after having built the json representation of it, then fed into
@ -442,7 +467,8 @@ class TestEmailApi(TestCase):
def get_validation_request(self, data):
req = req_factory_factory(
url=reverse_ns('inbound-email-api'), post=True, data=data)
url=reverse_ns('inbound-email-api'), post=True, data=data
)
req.META['REMOTE_ADDR'] = '10.10.10.10'
return req
@ -450,13 +476,12 @@ class TestEmailApi(TestCase):
user = user_factory()
self.grant_permission(user, '*:*')
addon = addon_factory()
version = addon.find_latest_version(
channel=amo.RELEASE_CHANNEL_LISTED)
version = addon.find_latest_version(channel=amo.RELEASE_CHANNEL_LISTED)
req = self.get_request(sample_message_content)
ActivityLogToken.objects.create(
user=user, version=version,
uuid='5a0b8a83d501412589cc5d562334b46b')
user=user, version=version, uuid='5a0b8a83d501412589cc5d562334b46b'
)
res = inbound_email(req)
assert res.status_code == 201
@ -468,7 +493,8 @@ class TestEmailApi(TestCase):
def test_allowed(self):
assert EmailCreationPermission().has_permission(
self.get_request({'SecretKey': 'SOME SECRET KEY'}), None)
self.get_request({'SecretKey': 'SOME SECRET KEY'}), None
)
def test_ip_denied(self):
req = self.get_request({'SecretKey': 'SOME SECRET KEY'})
@ -486,8 +512,8 @@ class TestEmailApi(TestCase):
@mock.patch('olympia.activity.tasks.process_email.apply_async')
def test_successful(self, _mock):
req = self.get_request(
{'SecretKey': 'SOME SECRET KEY', 'Message': 'something',
'SpamScore': 4.56})
{'SecretKey': 'SOME SECRET KEY', 'Message': 'something', 'SpamScore': 4.56}
)
res = inbound_email(req)
_mock.assert_called_with(('something', 4.56))
assert res.status_code == 201
@ -502,7 +528,8 @@ class TestEmailApi(TestCase):
@mock.patch('olympia.activity.tasks.process_email.apply_async')
def test_validation_response(self, _mock):
req = self.get_validation_request(
{'SecretKey': 'SOME SECRET KEY', 'Type': 'Validation'})
{'SecretKey': 'SOME SECRET KEY', 'Type': 'Validation'}
)
res = inbound_email(req)
assert not _mock.called
assert res.status_code == 200
@ -512,7 +539,8 @@ class TestEmailApi(TestCase):
@mock.patch('olympia.activity.tasks.process_email.apply_async')
def test_validation_response_wrong_secret(self, _mock):
req = self.get_validation_request(
{'SecretKey': 'WRONG SECRET', 'Type': 'Validation'})
{'SecretKey': 'WRONG SECRET', 'Type': 'Validation'}
)
res = inbound_email(req)
assert not _mock.called
assert res.status_code == 403

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

@ -57,17 +57,19 @@ class ActivityEmailToNotificationsError(ActivityEmailError):
class ActivityEmailParser(object):
"""Utility to parse email replies."""
address_prefix = REPLY_TO_PREFIX
def __init__(self, message):
invalid_email = (
not isinstance(message, dict) or
not message.get('TextBody', None))
invalid_email = not isinstance(message, dict) or not message.get(
'TextBody', None
)
if invalid_email:
log.exception('ActivityEmailParser didn\'t get a valid message.')
raise ActivityEmailEncodingError(
'Invalid or malformed json message object.')
'Invalid or malformed json message object.'
)
self.email = message
reply = self._extra_email_reply_parse(self.email['TextBody'])
@ -95,25 +97,27 @@ class ActivityEmailParser(object):
for address in addresses:
if address.startswith(self.address_prefix):
# Strip everything between "reviewreply+" and the "@" sign.
return address[len(self.address_prefix):].split('@')[0]
return address[len(self.address_prefix) :].split('@')[0]
elif address == NOTIFICATIONS_FROM_EMAIL:
# Someone sent an email to notifications@
to_notifications_alias = True
if to_notifications_alias:
log.exception('TO: notifications email used (%s)'
% ', '.join(addresses))
log.exception('TO: notifications email used (%s)' % ', '.join(addresses))
raise ActivityEmailToNotificationsError(
'This email address is not meant to receive emails directly. '
'If you want to get in contact with add-on reviewers, please '
'reply to the original email or join us in Matrix on '
'https://chat.mozilla.org/#/room/#addon-reviewers:mozilla.org '
'. Thank you.')
'. Thank you.'
)
log.debug(
'TO: address missing or not related to activity emails. (%s)',
', '.join(addresses))
', '.join(addresses),
)
raise ActivityEmailUUIDError(
'TO: address does not contain activity email uuid (%s).'
% ', '.join(addresses))
% ', '.join(addresses)
)
def add_email_to_activity_log_wrapper(message, spam_rating):
@ -137,8 +141,7 @@ def add_email_to_activity_log_wrapper(message, spam_rating):
except Exception:
log.exception('Bouncing invalid email failed.')
else:
log.info(
f'Skipping email bounce because probable spam ({spam_rating})')
log.info(f'Skipping email bounce because probable spam ({spam_rating})')
return note
@ -151,7 +154,8 @@ def add_email_to_activity_log(parser):
log.error('An email was skipped with non-existing uuid %s.' % uuid)
raise ActivityEmailUUIDError(
'UUID found in email address TO: header but is not a valid token '
'(%s).' % uuid)
'(%s).' % uuid
)
version = token.version
user = token.user
@ -161,31 +165,43 @@ def add_email_to_activity_log(parser):
if log_type:
note = log_and_notify(log_type, parser.reply, user, version)
log.info('A new note has been created (from %s using '
'tokenid %s).' % (user.id, uuid))
log.info(
'A new note has been created (from %s using '
'tokenid %s).' % (user.id, uuid)
)
token.increment_use()
return note
else:
log.error('%s did not have perms to reply to email thread %s.'
% (user.email, version.id))
log.error(
'%s did not have perms to reply to email thread %s.'
% (user.email, version.id)
)
raise ActivityEmailTokenError(
'You don\'t have permission to reply to this add-on. You '
'have to be a listed developer currently, or an AMO '
'reviewer.')
'reviewer.'
)
else:
log.warning('%s tried to use an invalid activity email token for '
'version %s.', user.email, version.id)
reason = ('it\'s for an old version of the addon'
if not token.is_expired() else
'there have been too many replies')
log.warning(
'%s tried to use an invalid activity email token for ' 'version %s.',
user.email,
version.id,
)
reason = (
'it\'s for an old version of the addon'
if not token.is_expired()
else 'there have been too many replies'
)
raise ActivityEmailTokenError(
'You can\'t reply to this email as the reply token is no '
'longer valid because %s.' % reason)
'longer valid because %s.' % reason
)
else:
log.info('Ignored email reply from banned user %s for version %s.'
% (user.id, version.id))
raise ActivityEmailError('Your account is not allowed to send '
'replies.')
log.info(
'Ignored email reply from banned user %s for version %s.'
% (user.id, version.id)
)
raise ActivityEmailError('Your account is not allowed to send ' 'replies.')
def action_from_user(user, version):
@ -197,16 +213,17 @@ def action_from_user(user, version):
def template_from_user(user, version):
template = 'activity/emails/developer.txt'
if (not version.addon.authors.filter(pk=user.pk).exists() and
acl.is_user_any_kind_of_reviewer(user)):
if not version.addon.authors.filter(
pk=user.pk
).exists() and acl.is_user_any_kind_of_reviewer(user):
template = 'activity/emails/from_reviewer.txt'
return loader.get_template(template)
def log_and_notify(action, comments, note_creator, version, perm_setting=None,
detail_kwargs=None):
"""Record an action through ActivityLog and notify relevant users about it.
"""
def log_and_notify(
action, comments, note_creator, version, perm_setting=None, detail_kwargs=None
):
"""Record an action through ActivityLog and notify relevant users about it."""
log_kwargs = {
'user': note_creator,
'created': datetime.now(),
@ -223,13 +240,13 @@ def log_and_notify(action, comments, note_creator, version, perm_setting=None,
if not note:
return
notify_about_activity_log(
version.addon, version, note, perm_setting=perm_setting)
notify_about_activity_log(version.addon, version, note, perm_setting=perm_setting)
return note
def notify_about_activity_log(addon, version, note, perm_setting=None,
send_to_reviewers=True, send_to_staff=True):
def notify_about_activity_log(
addon, version, note, perm_setting=None, send_to_reviewers=True, send_to_staff=True
):
"""Notify relevant users about an ActivityLog note."""
comments = (note.details or {}).get('comments')
if not comments:
@ -258,13 +275,21 @@ def notify_about_activity_log(addon, version, note, perm_setting=None,
# Not being localised because we don't know the recipients locale.
with translation.override('en-US'):
subject = reviewer_subject = u'Mozilla Add-ons: %s %s' % (
addon.name, version.version)
addon.name,
version.version,
)
# Build and send the mail for authors.
template = template_from_user(note.user, version)
from_email = formataddr((note.author_name, NOTIFICATIONS_FROM_EMAIL))
send_activity_mail(
subject, template.render(author_context_dict),
version, addon_authors, from_email, note.id, perm_setting)
subject,
template.render(author_context_dict),
version,
addon_authors,
from_email,
note.id,
perm_setting,
)
if send_to_reviewers or send_to_staff:
# If task_user doesn't exist that's no big issue (i.e. in tests)
@ -278,45 +303,63 @@ def notify_about_activity_log(addon, version, note, perm_setting=None,
# for automated messages), build the context for them and send them
# their copy.
log_users = {
alog.user for alog in ActivityLog.objects.for_versions(version) if
acl.is_user_any_kind_of_reviewer(alog.user)}
alog.user
for alog in ActivityLog.objects.for_versions(version)
if acl.is_user_any_kind_of_reviewer(alog.user)
}
reviewers = log_users - addon_authors - task_user - {note.user}
reviewer_context_dict = author_context_dict.copy()
reviewer_context_dict['url'] = absolutify(
reverse('reviewers.review',
kwargs={
'addon_id': version.addon.pk,
'channel': amo.CHANNEL_CHOICES_API[version.channel]
}, add_prefix=False))
reverse(
'reviewers.review',
kwargs={
'addon_id': version.addon.pk,
'channel': amo.CHANNEL_CHOICES_API[version.channel],
},
add_prefix=False,
)
)
reviewer_context_dict['email_reason'] = 'you reviewed this add-on'
send_activity_mail(
reviewer_subject, template.render(reviewer_context_dict),
version, reviewers, from_email, note.id, perm_setting)
reviewer_subject,
template.render(reviewer_context_dict),
version,
reviewers,
from_email,
note.id,
perm_setting,
)
if send_to_staff:
# Collect staff that want a copy of the email, build the context for
# them and send them their copy.
staff = set(
UserProfile.objects.filter(groups__name=ACTIVITY_MAIL_GROUP))
staff_cc = (
staff - reviewers - addon_authors - task_user - {note.user})
staff = set(UserProfile.objects.filter(groups__name=ACTIVITY_MAIL_GROUP))
staff_cc = staff - reviewers - addon_authors - task_user - {note.user}
staff_cc_context_dict = reviewer_context_dict.copy()
staff_cc_context_dict['email_reason'] = (
'you are member of the activity email cc group')
staff_cc_context_dict[
'email_reason'
] = 'you are member of the activity email cc group'
send_activity_mail(
reviewer_subject, template.render(staff_cc_context_dict),
version, staff_cc, from_email, note.id, perm_setting)
reviewer_subject,
template.render(staff_cc_context_dict),
version,
staff_cc,
from_email,
note.id,
perm_setting,
)
def send_activity_mail(subject, message, version, recipients, from_email,
unique_id, perm_setting=None):
thread_id = '{addon}/{version}'.format(
addon=version.addon.id, version=version.id)
def send_activity_mail(
subject, message, version, recipients, from_email, unique_id, perm_setting=None
):
thread_id = '{addon}/{version}'.format(addon=version.addon.id, version=version.id)
reference_header = '<{thread}@{site}>'.format(
thread=thread_id, site=settings.INBOUND_EMAIL_DOMAIN)
thread=thread_id, site=settings.INBOUND_EMAIL_DOMAIN
)
message_id = '<{thread}/{message}@{site}>'.format(
thread=thread_id, message=unique_id,
site=settings.INBOUND_EMAIL_DOMAIN)
thread=thread_id, message=unique_id, site=settings.INBOUND_EMAIL_DOMAIN
)
headers = {
'In-Reply-To': reference_header,
'References': reference_header,
@ -325,20 +368,33 @@ def send_activity_mail(subject, message, version, recipients, from_email,
for recipient in recipients:
token, created = ActivityLogToken.objects.get_or_create(
version=version, user=recipient)
version=version, user=recipient
)
if not created:
token.update(use_count=0)
else:
log.info('Created token with UUID %s for user: %s.' % (
token.uuid, recipient.id))
log.info(
'Created token with UUID %s for user: %s.' % (token.uuid, recipient.id)
)
reply_to = "%s%s@%s" % (
REPLY_TO_PREFIX, token.uuid.hex, settings.INBOUND_EMAIL_DOMAIN)
log.info('Sending activity email to %s for %s version %s' % (
recipient, version.addon.pk, version.pk))
REPLY_TO_PREFIX,
token.uuid.hex,
settings.INBOUND_EMAIL_DOMAIN,
)
log.info(
'Sending activity email to %s for %s version %s'
% (recipient, version.addon.pk, version.pk)
)
send_mail(
subject, message, recipient_list=[recipient.email],
from_email=from_email, use_deny_list=False, headers=headers,
perm_setting=perm_setting, reply_to=[reply_to])
subject,
message,
recipient_list=[recipient.email],
from_email=from_email,
use_deny_list=False,
headers=headers,
perm_setting=perm_setting,
reply_to=[reply_to],
)
NOT_PENDING_IDS = (
@ -353,26 +409,36 @@ NOT_PENDING_IDS = (
def filter_queryset_to_pending_replies(queryset, log_type_ids=NOT_PENDING_IDS):
latest_reply_date = queryset.filter(
action__in=log_type_ids).values_list('created', flat=True).first()
latest_reply_date = (
queryset.filter(action__in=log_type_ids)
.values_list('created', flat=True)
.first()
)
if not latest_reply_date:
return queryset
return queryset.filter(created__gt=latest_reply_date)
def bounce_mail(message, reason):
recipient = (None if not isinstance(message, dict)
else message.get('From', message.get('ReplyTo')))
recipient = (
None
if not isinstance(message, dict)
else message.get('From', message.get('ReplyTo'))
)
if not recipient:
log.error('Tried to bounce incoming activity mail but no From or '
'ReplyTo header present.')
log.error(
'Tried to bounce incoming activity mail but no From or '
'ReplyTo header present.'
)
return
body = (loader.get_template('activity/emails/bounce.txt').
render({'reason': reason, 'SITE_URL': settings.SITE_URL}))
body = loader.get_template('activity/emails/bounce.txt').render(
{'reason': reason, 'SITE_URL': settings.SITE_URL}
)
send_mail(
'Re: %s' % message.get('Subject', 'your email to us'),
body,
recipient_list=[recipient['EmailAddress']],
from_email=settings.ADDONS_EMAIL,
use_deny_list=False)
use_deny_list=False,
)

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

@ -7,7 +7,10 @@ from django.utils.translation import ugettext
from rest_framework import status
from rest_framework.decorators import (
api_view, authentication_classes, permission_classes)
api_view,
authentication_classes,
permission_classes,
)
from rest_framework.exceptions import ParseError
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.response import Response
@ -20,14 +23,22 @@ from olympia.activity.models import ActivityLog
from olympia.activity.serializers import ActivityLogSerializer
from olympia.activity.tasks import process_email
from olympia.activity.utils import (
action_from_user, filter_queryset_to_pending_replies, log_and_notify)
action_from_user,
filter_queryset_to_pending_replies,
log_and_notify,
)
from olympia.addons.views import AddonChildMixin
from olympia.api.permissions import (
AllowAddonAuthor, AllowReviewer, AllowReviewerUnlisted, AnyOf)
AllowAddonAuthor,
AllowReviewer,
AllowReviewerUnlisted,
AnyOf,
)
class VersionReviewNotesViewSet(AddonChildMixin, ListModelMixin,
RetrieveModelMixin, GenericViewSet):
class VersionReviewNotesViewSet(
AddonChildMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet
):
permission_classes = [
AnyOf(AllowAddonAuthor, AllowReviewer, AllowReviewerUnlisted),
]
@ -39,8 +50,8 @@ class VersionReviewNotesViewSet(AddonChildMixin, ListModelMixin,
def get_addon_object(self):
return super(VersionReviewNotesViewSet, self).get_addon_object(
permission_classes=self.permission_classes,
georestriction_classes=[])
permission_classes=self.permission_classes, georestriction_classes=[]
)
def get_version_object(self):
if not hasattr(self, 'version_object'):
@ -48,9 +59,11 @@ class VersionReviewNotesViewSet(AddonChildMixin, ListModelMixin,
self.version_object = get_object_or_404(
# Fetch the version without transforms, using the addon related
# manager to avoid reloading it from the database.
addon.versions(
manager='unfiltered_for_relations').all().no_transforms(),
pk=self.kwargs['version_pk'])
addon.versions(manager='unfiltered_for_relations')
.all()
.no_transforms(),
pk=self.kwargs['version_pk'],
)
return self.version_object
def check_object_permissions(self, request, obj):
@ -61,20 +74,28 @@ class VersionReviewNotesViewSet(AddonChildMixin, ListModelMixin,
def get_serializer_context(self):
ctx = super(VersionReviewNotesViewSet, self).get_serializer_context()
ctx['to_highlight'] = list(filter_queryset_to_pending_replies(
self.get_queryset()).values_list('pk', flat=True))
ctx['to_highlight'] = list(
filter_queryset_to_pending_replies(self.get_queryset()).values_list(
'pk', flat=True
)
)
return ctx
def create(self, request, *args, **kwargs):
version = self.get_version_object()
latest_version = version.addon.find_latest_version(
channel=version.channel, exclude=())
channel=version.channel, exclude=()
)
if version != latest_version:
raise ParseError(ugettext(
'Only latest versions of addons can have notes added.'))
raise ParseError(
ugettext('Only latest versions of addons can have notes added.')
)
activity_object = log_and_notify(
action_from_user(request.user, version), request.data['comments'],
request.user, version)
action_from_user(request.user, version),
request.data['comments'],
request.user,
version,
)
serializer = self.get_serializer(activity_object)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -95,8 +116,7 @@ class EmailCreationPermission(object):
secret_key = data.get('SecretKey', '')
if not secret_key == settings.INBOUND_EMAIL_SECRET_KEY:
log.info('Invalid secret key [%s] provided; data [%s]' % (
secret_key, data))
log.info('Invalid secret key [%s] provided; data [%s]' % (secret_key, data))
return False
remote_ip = request.META.get('REMOTE_ADDR', '')
@ -115,15 +135,12 @@ def inbound_email(request):
validation_response = settings.INBOUND_EMAIL_VALIDATION_KEY
if request.data.get('Type', '') == 'Validation':
# Its just a verification check that the end-point is working.
return Response(data=validation_response,
status=status.HTTP_200_OK)
return Response(data=validation_response, status=status.HTTP_200_OK)
message = request.data.get('Message', None)
if not message:
raise ParseError(
detail='Message not present in the POST data.')
raise ParseError(detail='Message not present in the POST data.')
spam_rating = request.data.get('SpamScore', 0.0)
process_email.apply_async((message, spam_rating))
return Response(data=validation_response,
status=status.HTTP_201_CREATED)
return Response(data=validation_response, status=status.HTTP_201_CREATED)

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

@ -7,9 +7,11 @@ from django.conf.urls import url
from django.contrib import admin
from django.core import validators
from django.forms.models import modelformset_factory
from django.http.response import (HttpResponseForbidden,
HttpResponseNotAllowed,
HttpResponseRedirect)
from django.http.response import (
HttpResponseForbidden,
HttpResponseNotAllowed,
HttpResponseRedirect,
)
from django.shortcuts import get_object_or_404
from django.urls import resolve
from django.utils.encoding import force_text
@ -47,9 +49,11 @@ class AddonUserInline(admin.TabularInline):
return format_html(
'<a href="{}">Admin User Profile</a> ({})',
reverse('admin:users_userprofile_change', args=(obj.user.pk,)),
obj.user.email)
obj.user.email,
)
else:
return ''
user_profile_link.short_description = 'User Profile'
@ -66,8 +70,14 @@ class FileInline(admin.TabularInline):
extra = 0
max_num = 0
fields = (
'created', 'version__version', 'version__channel', 'platform',
'status', 'version__is_blocked', 'hash_link')
'created',
'version__version',
'version__channel',
'platform',
'status',
'version__is_blocked',
'hash_link',
)
editable_fields = ('status',)
readonly_fields = tuple(set(fields) - set(editable_fields))
can_delete = False
@ -76,12 +86,13 @@ class FileInline(admin.TabularInline):
checks_class = FileInlineChecks
def version__version(self, obj):
return obj.version.version + (
' - Deleted' if obj.version.deleted else '')
return obj.version.version + (' - Deleted' if obj.version.deleted else '')
version__version.short_description = 'Version'
def version__channel(self, obj):
return obj.version.get_channel_display()
version__channel.short_description = 'Channel'
def version__is_blocked(self, obj):
@ -91,12 +102,14 @@ class FileInline(admin.TabularInline):
url = block.get_admin_url_path()
template = '<a href="{}">Blocked ({} - {})</a>'
return format_html(template, url, block.min_version, block.max_version)
version__is_blocked.short_description = 'Block status'
def hash_link(self, obj):
url = reverse('zadmin.recalc_hash', args=(obj.id,))
template = '<a href="{}" class="recalc" title="{}">Recalc Hash</a>'
return format_html(template, url, obj.hash)
hash_link.short_description = 'Hash'
def get_formset(self, request, obj=None, **kwargs):
@ -117,64 +130,117 @@ class FileInline(admin.TabularInline):
def get_queryset(self, request):
self.pager = amo.utils.paginate(
request,
Version.unfiltered.filter(addon=self.instance).values_list(
'pk', flat=True),
30)
Version.unfiltered.filter(addon=self.instance).values_list('pk', flat=True),
30,
)
# A list coercion so this doesn't result in a subquery with a LIMIT
# which MySQL doesn't support (at this time).
versions = list(self.pager.object_list)
qs = super().get_queryset(request).filter(
version__in=versions).order_by('-version__id')
qs = (
super()
.get_queryset(request)
.filter(version__in=versions)
.order_by('-version__id')
)
return qs.select_related('version')
class AddonAdmin(admin.ModelAdmin):
class Media:
css = {
'all': ('css/admin/l10n.css', 'css/admin/pagination.css')
}
js = (
'admin/js/jquery.init.js', 'js/admin/l10n.js',
'js/admin/recalc_hash.js'
)
css = {'all': ('css/admin/l10n.css', 'css/admin/pagination.css')}
js = ('admin/js/jquery.init.js', 'js/admin/l10n.js', 'js/admin/recalc_hash.js')
exclude = ('authors',)
list_display = ('__str__', 'type', 'guid', 'status', 'average_rating',
'reviewer_links')
list_display = (
'__str__',
'type',
'guid',
'status',
'average_rating',
'reviewer_links',
)
list_filter = ('type', 'status')
search_fields = ('id', '^guid', '^slug')
inlines = (AddonUserInline, FileInline)
readonly_fields = ('id', 'created',
'average_rating', 'bayesian_rating', 'guid',
'total_ratings_link', 'text_ratings_count',
'weekly_downloads', 'average_daily_users')
readonly_fields = (
'id',
'created',
'average_rating',
'bayesian_rating',
'guid',
'total_ratings_link',
'text_ratings_count',
'weekly_downloads',
'average_daily_users',
)
fieldsets = (
(None, {
'fields': ('id', 'created', 'name', 'slug', 'guid',
'default_locale', 'type',
'status'),
}),
('Details', {
'fields': ('summary', 'description', 'homepage', 'eula',
'privacy_policy', 'developer_comments', 'icon_type',
),
}),
('Support', {
'fields': ('support_url', 'support_email'),
}),
('Stats', {
'fields': ('total_ratings_link', 'average_rating',
'bayesian_rating', 'text_ratings_count',
'weekly_downloads', 'average_daily_users'),
}),
('Flags', {
'fields': ('disabled_by_user', 'requires_payment',
'is_experimental', 'reputation'),
}),
('Dictionaries and Language Packs', {
'fields': ('target_locale',),
}))
(
None,
{
'fields': (
'id',
'created',
'name',
'slug',
'guid',
'default_locale',
'type',
'status',
),
},
),
(
'Details',
{
'fields': (
'summary',
'description',
'homepage',
'eula',
'privacy_policy',
'developer_comments',
'icon_type',
),
},
),
(
'Support',
{
'fields': ('support_url', 'support_email'),
},
),
(
'Stats',
{
'fields': (
'total_ratings_link',
'average_rating',
'bayesian_rating',
'text_ratings_count',
'weekly_downloads',
'average_daily_users',
),
},
),
(
'Flags',
{
'fields': (
'disabled_by_user',
'requires_payment',
'is_experimental',
'reputation',
),
},
),
(
'Dictionaries and Language Packs',
{
'fields': ('target_locale',),
},
),
)
actions = ['git_extract_action']
def queryset(self, request):
@ -184,20 +250,28 @@ class AddonAdmin(admin.ModelAdmin):
def wrap(view):
def wrapper(*args, **kwargs):
return self.admin_site.admin_view(view)(*args, **kwargs)
return functools.update_wrapper(wrapper, view)
urlpatterns = super(AddonAdmin, self).get_urls()
custom_urlpatterns = [
url(r'^(?P<object_id>.+)/git_extract/$',
url(
r'^(?P<object_id>.+)/git_extract/$',
wrap(self.git_extract_view),
name='addons_git_extract'),
name='addons_git_extract',
),
]
return custom_urlpatterns + urlpatterns
def total_ratings_link(self, obj):
return related_content_link(
obj, Rating, 'addon', related_manager='without_replies',
count=obj.total_ratings)
obj,
Rating,
'addon',
related_manager='without_replies',
count=obj.total_ratings,
)
total_ratings_link.short_description = _(u'Ratings')
def reviewer_links(self, obj):
@ -244,34 +318,42 @@ class AddonAdmin(admin.ModelAdmin):
url += '?' + request.GET.urlencode()
return http.HttpResponsePermanentRedirect(url)
return super().change_view(request, object_id, form_url,
extra_context=extra_context)
return super().change_view(
request, object_id, form_url, extra_context=extra_context
)
def render_change_form(self, request, context, add=False, change=False,
form_url='', obj=None):
def render_change_form(
self, request, context, add=False, change=False, form_url='', obj=None
):
context.update(
{
'external_site_url': settings.EXTERNAL_SITE_URL,
'has_listed_versions': obj.has_listed_versions(
include_deleted=True
) if obj else False,
'has_unlisted_versions': obj.has_unlisted_versions(
include_deleted=True
) if obj else False
'has_listed_versions': obj.has_listed_versions(include_deleted=True)
if obj
else False,
'has_unlisted_versions': obj.has_unlisted_versions(include_deleted=True)
if obj
else False,
}
)
return super().render_change_form(request=request, context=context,
add=add, change=change,
form_url=form_url, obj=obj)
return super().render_change_form(
request=request,
context=context,
add=add,
change=change,
form_url=form_url,
obj=obj,
)
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
if 'status' in form.changed_data:
ActivityLog.create(
amo.LOG.CHANGE_STATUS, obj, form.cleaned_data['status'])
log.info('Addon "%s" status changed to: %s' % (
obj.slug, form.cleaned_data['status']))
ActivityLog.create(amo.LOG.CHANGE_STATUS, obj, form.cleaned_data['status'])
log.info(
'Addon "%s" status changed to: %s'
% (obj.slug, form.cleaned_data['status'])
)
def git_extract_action(self, request, qs):
addon_ids = []
@ -280,8 +362,9 @@ class AddonAdmin(admin.ModelAdmin):
addon_ids.append(force_text(addon))
kw = {'addons': ', '.join(addon_ids)}
self.message_user(
request, ugettext('Git extraction triggered for '
'"%(addons)s".' % kw))
request, ugettext('Git extraction triggered for ' '"%(addons)s".' % kw)
)
git_extract_action.short_description = "Git-Extract"
def git_extract_view(self, request, object_id, extra_context=None):
@ -296,7 +379,8 @@ class AddonAdmin(admin.ModelAdmin):
self.git_extract_action(request, (obj,))
return HttpResponseRedirect(
reverse('admin:addons_addon_change', args=(obj.pk, )))
reverse('admin:addons_addon_change', args=(obj.pk,))
)
class FrozenAddonAdmin(admin.ModelAdmin):
@ -313,7 +397,8 @@ class ReplacementAddonForm(forms.ModelForm):
if path.startswith(site):
raise forms.ValidationError(
'Paths for [%s] should be relative, not full URLs '
'including the domain name' % site)
'including the domain name' % site
)
validators.URLValidator()(path)
else:
path = ('/' if not path.startswith('/') else '') + path
@ -334,7 +419,8 @@ class ReplacementAddonAdmin(admin.ModelAdmin):
guid_param = urlencode({'guid': obj.guid})
return format_html(
'<a href="{}">Test</a>',
reverse('addons.find_replacement') + '?%s' % guid_param)
reverse('addons.find_replacement') + '?%s' % guid_param,
)
def guid_slug(self, obj):
try:
@ -354,12 +440,12 @@ class ReplacementAddonAdmin(admin.ModelAdmin):
# won't be able to make any changes but they can see the list.
if obj is not None:
return super(ReplacementAddonAdmin, self).has_change_permission(
request, obj=obj)
request, obj=obj
)
else:
return (
acl.action_allowed(request, amo.permissions.ADDONS_EDIT) or
super(ReplacementAddonAdmin, self).has_change_permission(
request, obj=obj))
return acl.action_allowed(request, amo.permissions.ADDONS_EDIT) or super(
ReplacementAddonAdmin, self
).has_change_permission(request, obj=obj)
@admin.register(models.AddonRegionalRestrictions)
@ -374,16 +460,19 @@ class AddonRegionalRestrictionsAdmin(admin.ModelAdmin):
def addon__name(self, obj):
return str(obj.addon)
addon__name.short_description = 'Addon'
def _send_mail(self, obj, action):
message = (
f'Regional restriction for addon "{obj.addon.name}" '
f'[{obj.addon.id}] {action}: {obj.excluded_regions}')
f'[{obj.addon.id}] {action}: {obj.excluded_regions}'
)
send_mail(
f'Regional Restriction {action} for Add-on',
message,
recipient_list=('amo-admins@mozilla.com',))
recipient_list=('amo-admins@mozilla.com',),
)
def delete_model(self, request, obj):
self._send_mail(obj, 'deleted')

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

@ -6,9 +6,17 @@ from rest_framework_nested.routers import NestedSimpleRouter
from olympia.activity.views import VersionReviewNotesViewSet
from .views import (
AddonAutoCompleteSearchView, AddonFeaturedView, AddonRecommendationView,
AddonSearchView, AddonVersionViewSet, AddonViewSet, CompatOverrideView,
LanguageToolsView, ReplacementAddonView, StaticCategoryView)
AddonAutoCompleteSearchView,
AddonFeaturedView,
AddonRecommendationView,
AddonSearchView,
AddonVersionViewSet,
AddonViewSet,
CompatOverrideView,
LanguageToolsView,
ReplacementAddonView,
StaticCategoryView,
)
addons = SimpleRouter()
@ -18,32 +26,43 @@ addons.register(r'addon', AddonViewSet, basename='addon')
sub_addons = NestedSimpleRouter(addons, r'addon', lookup='addon')
sub_addons.register('versions', AddonVersionViewSet, basename='addon-version')
sub_versions = NestedSimpleRouter(sub_addons, r'versions', lookup='version')
sub_versions.register(r'reviewnotes', VersionReviewNotesViewSet,
basename='version-reviewnotes')
sub_versions.register(
r'reviewnotes', VersionReviewNotesViewSet, basename='version-reviewnotes'
)
urls = [
re_path(r'', include(addons.urls)),
re_path(r'', include(sub_addons.urls)),
re_path(r'', include(sub_versions.urls)),
re_path(r'^autocomplete/$', AddonAutoCompleteSearchView.as_view(),
name='addon-autocomplete'),
re_path(
r'^autocomplete/$',
AddonAutoCompleteSearchView.as_view(),
name='addon-autocomplete',
),
re_path(r'^search/$', AddonSearchView.as_view(), name='addon-search'),
re_path(r'^categories/$', StaticCategoryView.as_view(),
name='category-list'),
re_path(r'^language-tools/$', LanguageToolsView.as_view(),
name='addon-language-tools'),
re_path(r'^replacement-addon/$', ReplacementAddonView.as_view(),
name='addon-replacement-addon'),
re_path(r'^recommendations/$', AddonRecommendationView.as_view(),
name='addon-recommendations'),
re_path(r'^categories/$', StaticCategoryView.as_view(), name='category-list'),
re_path(
r'^language-tools/$', LanguageToolsView.as_view(), name='addon-language-tools'
),
re_path(
r'^replacement-addon/$',
ReplacementAddonView.as_view(),
name='addon-replacement-addon',
),
re_path(
r'^recommendations/$',
AddonRecommendationView.as_view(),
name='addon-recommendations',
),
]
addons_v3 = urls + [
re_path(r'^compat-override/$', CompatOverrideView.as_view(),
name='addon-compat-override'),
re_path(r'^featured/$', AddonFeaturedView.as_view(),
name='addon-featured'),
re_path(
r'^compat-override/$',
CompatOverrideView.as_view(),
name='addon-compat-override',
),
re_path(r'^featured/$', AddonFeaturedView.as_view(), name='addon-featured'),
]
addons_v4 = urls

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

@ -11,7 +11,8 @@ from olympia.addons.tasks import (
update_addon_average_daily_users as _update_addon_average_daily_users,
update_addon_hotness as _update_addon_hotness,
update_addon_weekly_downloads as _update_addon_weekly_downloads,
update_appsupport)
update_appsupport,
)
from olympia.amo.celery import create_chunked_tasks_signatures
from olympia.amo.decorators import use_primary_db
from olympia.amo.utils import chunked
@ -19,7 +20,8 @@ from olympia.files.models import File
from olympia.stats.utils import (
get_addons_and_average_daily_users_from_bigquery,
get_addons_and_weekly_downloads_from_bigquery,
get_averages_by_addon_from_bigquery)
get_averages_by_addon_from_bigquery,
)
log = olympia.core.logger.getLogger('z.cron')
@ -32,8 +34,7 @@ def update_addon_average_daily_users(chunk_size=250):
# In order to reset the `average_daily_users` values of add-ons that
# don't exist in BigQuery, we prepare a set of `(guid, 0)` for most
# add-ons.
Addon.unfiltered
.filter(type__in=amo.ADDON_TYPES_WITH_STATS)
Addon.unfiltered.filter(type__in=amo.ADDON_TYPES_WITH_STATS)
.exclude(guid__isnull=True)
.exclude(guid__exact='')
.exclude(average_daily_users=0)
@ -47,8 +48,7 @@ def update_addon_average_daily_users(chunk_size=250):
counts.update(get_addons_and_average_daily_users_from_bigquery())
counts = list(counts.items())
log.info('Preparing update of `average_daily_users` for %s add-ons.',
len(counts))
log.info('Preparing update of `average_daily_users` for %s add-ons.', len(counts))
create_chunked_tasks_signatures(
_update_addon_average_daily_users, counts, chunk_size
@ -88,24 +88,28 @@ def addon_last_updated():
_change_last_updated(next)
# Get anything that didn't match above.
other = (Addon.objects.filter(last_updated__isnull=True)
.values_list('id', 'created'))
other = Addon.objects.filter(last_updated__isnull=True).values_list('id', 'created')
_change_last_updated(dict(other))
def update_addon_appsupport():
# Find all the add-ons that need their app support details updated.
newish = (Q(last_updated__gte=F('appsupport__created')) |
Q(appsupport__created__isnull=True))
newish = Q(last_updated__gte=F('appsupport__created')) | Q(
appsupport__created__isnull=True
)
has_app_and_file = Q(
versions__apps__isnull=False,
versions__files__status__in=amo.VALID_FILE_STATUSES)
ids = (Addon.objects.valid().distinct()
.filter(newish, has_app_and_file).values_list('id', flat=True))
versions__files__status__in=amo.VALID_FILE_STATUSES,
)
ids = (
Addon.objects.valid()
.distinct()
.filter(newish, has_app_and_file)
.values_list('id', flat=True)
)
task_log.info('Updating appsupport for %d new-ish addons.' % len(ids))
ts = [update_appsupport.subtask(args=[chunk])
for chunk in chunked(ids, 20)]
ts = [update_appsupport.subtask(args=[chunk]) for chunk in chunked(ids, 20)]
group(ts).apply_async()
@ -117,10 +121,11 @@ def hide_disabled_files():
See also unhide_disabled_files().
"""
ids = (File.objects.filter(
Q(version__addon__status=amo.STATUS_DISABLED) |
Q(version__addon__disabled_by_user=True) |
Q(status=amo.STATUS_DISABLED)).values_list('id', flat=True))
ids = File.objects.filter(
Q(version__addon__status=amo.STATUS_DISABLED)
| Q(version__addon__disabled_by_user=True)
| Q(status=amo.STATUS_DISABLED)
).values_list('id', flat=True)
for chunk in chunked(ids, 300):
qs = File.objects.select_related('version').filter(id__in=chunk)
for file_ in qs:
@ -137,10 +142,11 @@ def unhide_disabled_files():
See also hide_disabled_files().
"""
ids = (File.objects.exclude(
Q(version__addon__status=amo.STATUS_DISABLED) |
Q(version__addon__disabled_by_user=True) |
Q(status=amo.STATUS_DISABLED)).values_list('id', flat=True))
ids = File.objects.exclude(
Q(version__addon__status=amo.STATUS_DISABLED)
| Q(version__addon__disabled_by_user=True)
| Q(status=amo.STATUS_DISABLED)
).values_list('id', flat=True)
for chunk in chunked(ids, 300):
qs = File.objects.select_related('version').filter(id__in=chunk)
for file_ in qs:
@ -172,17 +178,14 @@ def update_addon_hotness(chunk_size=300):
.values_list('guid', flat=True)
)
averages = {
guid: {'avg_this_week': 1, 'avg_three_weeks_before': 1}
for guid in amo_guids
guid: {'avg_this_week': 1, 'avg_three_weeks_before': 1} for guid in amo_guids
}
log.info('Found %s add-on GUIDs in AMO DB.', len(averages))
bq_averages = get_averages_by_addon_from_bigquery(
today=date.today(), exclude=frozen_guids
)
log.info(
'Found %s add-on GUIDs with averages in BigQuery.', len(bq_averages)
)
log.info('Found %s add-on GUIDs with averages in BigQuery.', len(bq_averages))
averages.update(bq_averages)
log.info('Preparing update of `hotness` for %s add-ons.', len(averages))
@ -200,8 +203,7 @@ def update_addon_weekly_downloads(chunk_size=250):
# In order to reset the `weekly_downloads` values of add-ons that
# don't exist in BigQuery, we prepare a set of `(hashed_guid, 0)`
# for most add-ons.
Addon.objects
.filter(type__in=amo.ADDON_TYPES_WITH_STATS)
Addon.objects.filter(type__in=amo.ADDON_TYPES_WITH_STATS)
.exclude(guid__isnull=True)
.exclude(guid__exact='')
.exclude(weekly_downloads=0)
@ -212,8 +214,7 @@ def update_addon_weekly_downloads(chunk_size=250):
counts.update(get_addons_and_weekly_downloads_from_bigquery())
counts = list(counts.items())
log.info('Preparing update of `weekly_downloads` for %s add-ons.',
len(counts))
log.info('Preparing update of `weekly_downloads` for %s add-ons.', len(counts))
create_chunked_tasks_signatures(
_update_addon_weekly_downloads, counts, chunk_size

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

@ -8,15 +8,16 @@ from olympia.addons.models import Addon
def owner_or_unlisted_reviewer(request, addon):
return (acl.check_unlisted_addons_reviewer(request) or
# We don't want "admins" here, because it includes anyone with the
# "Addons:Edit" perm, we only want those with
# "Addons:ReviewUnlisted" perm (which is checked above).
acl.check_addon_ownership(request, addon, admin=False, dev=True))
return (
acl.check_unlisted_addons_reviewer(request)
# We don't want "admins" here, because it includes anyone with the
# "Addons:Edit" perm, we only want those with
# "Addons:ReviewUnlisted" perm (which is checked above).
or acl.check_addon_ownership(request, addon, admin=False, dev=True)
)
def addon_view(
f, qs=Addon.objects.all, include_deleted_when_checking_versions=False):
def addon_view(f, qs=Addon.objects.all, include_deleted_when_checking_versions=False):
@functools.wraps(f)
def wrapper(request, addon_id=None, *args, **kw):
"""Provides an addon instance to the view given addon_id, which can be
@ -44,11 +45,12 @@ def addon_view(
# If the addon has no listed versions it needs either an author
# (owner/viewer/dev/support) or an unlisted addon reviewer.
has_listed_versions = addon.has_listed_versions(
include_deleted=include_deleted_when_checking_versions)
if not (has_listed_versions or
owner_or_unlisted_reviewer(request, addon)):
include_deleted=include_deleted_when_checking_versions
)
if not (has_listed_versions or owner_or_unlisted_reviewer(request, addon)):
raise http.Http404
return f(request, addon, *args, **kw)
return wrapper

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

@ -40,9 +40,13 @@ class AdminBaseFileFormSet(BaseModelFormSet):
for form in self.initial_forms:
if 'status' in form.changed_data:
admin_log.info(
'Addon "%s" file (ID:%d) status changed to: %s' % (
self.instance.slug, form.instance.id,
form.cleaned_data['status']))
'Addon "%s" file (ID:%d) status changed to: %s'
% (
self.instance.slug,
form.instance.id,
form.cleaned_data['status'],
)
)
return objs
def save_new_objects(self, commit=True):

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

@ -18,6 +18,7 @@ log = olympia.core.logger.getLogger('z.es')
class AddonIndexer(BaseSearchIndexer):
"""Fields we don't need to expose in the results, only used for filtering
or sorting."""
hidden_fields = (
'*.raw',
'boost',
@ -52,16 +53,20 @@ class AddonIndexer(BaseSearchIndexer):
# for instance.
'tokenizer': 'standard',
'filter': [
'standard', 'custom_word_delimiter', 'lowercase',
'stop', 'custom_dictionary_decompounder', 'unique',
]
'standard',
'custom_word_delimiter',
'lowercase',
'stop',
'custom_dictionary_decompounder',
'unique',
],
},
'trigram': {
# Analyzer that splits the text into trigrams.
'tokenizer': 'ngram_tokenizer',
'filter': [
'lowercase',
]
],
},
},
'tokenizer': {
@ -69,7 +74,7 @@ class AddonIndexer(BaseSearchIndexer):
'type': 'ngram',
'min_gram': 3,
'max_gram': 3,
'token_chars': ['letter', 'digit']
'token_chars': ['letter', 'digit'],
}
},
'normalizer': {
@ -89,7 +94,7 @@ class AddonIndexer(BaseSearchIndexer):
# and Foo Bar. (preserve_original: True makes us index both
# the original and the split version.)
'type': 'word_delimiter',
'preserve_original': True
'preserve_original': True,
},
'custom_dictionary_decompounder': {
# This filter is also useful for add-on names that have
@ -100,32 +105,110 @@ class AddonIndexer(BaseSearchIndexer):
# helping users looking for 'tab password' find that addon.
'type': 'dictionary_decompounder',
'word_list': [
'all', 'auto', 'ball', 'bar', 'block', 'blog',
'bookmark', 'browser', 'bug', 'button', 'cat', 'chat',
'click', 'clip', 'close', 'color', 'context', 'cookie',
'cool', 'css', 'delete', 'dictionary', 'down',
'download', 'easy', 'edit', 'fill', 'fire', 'firefox',
'fix', 'flag', 'flash', 'fly', 'forecast', 'fox',
'foxy', 'google', 'grab', 'grease', 'html', 'http',
'image', 'input', 'inspect', 'inspector', 'iris', 'js',
'key', 'keys', 'lang', 'link', 'mail', 'manager',
'map', 'mega', 'menu', 'menus', 'monkey', 'name',
'net', 'new', 'open', 'password', 'persona', 'privacy',
'query', 'screen', 'scroll', 'search', 'secure',
'select', 'smart', 'spring', 'status', 'style',
'super', 'sync', 'tab', 'text', 'think', 'this',
'time', 'title', 'translate', 'tree', 'undo', 'upload',
'url', 'user', 'video', 'window', 'with', 'word',
'all',
'auto',
'ball',
'bar',
'block',
'blog',
'bookmark',
'browser',
'bug',
'button',
'cat',
'chat',
'click',
'clip',
'close',
'color',
'context',
'cookie',
'cool',
'css',
'delete',
'dictionary',
'down',
'download',
'easy',
'edit',
'fill',
'fire',
'firefox',
'fix',
'flag',
'flash',
'fly',
'forecast',
'fox',
'foxy',
'google',
'grab',
'grease',
'html',
'http',
'image',
'input',
'inspect',
'inspector',
'iris',
'js',
'key',
'keys',
'lang',
'link',
'mail',
'manager',
'map',
'mega',
'menu',
'menus',
'monkey',
'name',
'net',
'new',
'open',
'password',
'persona',
'privacy',
'query',
'screen',
'scroll',
'search',
'secure',
'select',
'smart',
'spring',
'status',
'style',
'super',
'sync',
'tab',
'text',
'think',
'this',
'time',
'title',
'translate',
'tree',
'undo',
'upload',
'url',
'user',
'video',
'window',
'with',
'word',
'zilla',
]
],
},
}
},
}
}
@classmethod
def get_model(cls):
from olympia.addons.models import Addon
return Addon
@classmethod
@ -147,8 +230,9 @@ class AddonIndexer(BaseSearchIndexer):
version_mapping = {
'type': 'object',
'properties': {
'compatible_apps': {'properties': {app.id: appver_mapping
for app in amo.APP_USAGE}},
'compatible_apps': {
'properties': {app.id: appver_mapping for app in amo.APP_USAGE}
},
# Keep '<version>.id' indexed to be able to run exists queries
# on it.
'id': {'type': 'long'},
@ -159,23 +243,17 @@ class AddonIndexer(BaseSearchIndexer):
'id': {'type': 'long', 'index': False},
'created': {'type': 'date', 'index': False},
'hash': {'type': 'keyword', 'index': False},
'filename': {
'type': 'keyword', 'index': False},
'filename': {'type': 'keyword', 'index': False},
'is_webextension': {'type': 'boolean'},
'is_mozilla_signed_extension': {'type': 'boolean'},
'is_restart_required': {
'type': 'boolean', 'index': False},
'platform': {
'type': 'byte', 'index': False},
'is_restart_required': {'type': 'boolean', 'index': False},
'platform': {'type': 'byte', 'index': False},
'size': {'type': 'long', 'index': False},
'strict_compatibility': {
'type': 'boolean', 'index': False},
'strict_compatibility': {'type': 'boolean', 'index': False},
'status': {'type': 'byte'},
'permissions': {
'type': 'keyword', 'index': False},
'optional_permissions': {
'type': 'keyword', 'index': False},
}
'permissions': {'type': 'keyword', 'index': False},
'optional_permissions': {'type': 'keyword', 'index': False},
},
},
'license': {
'type': 'object',
@ -183,19 +261,17 @@ class AddonIndexer(BaseSearchIndexer):
'id': {'type': 'long', 'index': False},
'builtin': {'type': 'boolean', 'index': False},
'name_translations': cls.get_translations_definition(),
'url': {'type': 'text', 'index': False}
'url': {'type': 'text', 'index': False},
},
},
'release_notes_translations':
cls.get_translations_definition(),
'release_notes_translations': cls.get_translations_definition(),
'version': {'type': 'keyword', 'index': False},
}
},
}
mapping = {
doc_name: {
'properties': {
'id': {'type': 'long'},
'app': {'type': 'byte'},
'average_daily_users': {'type': 'long'},
'bayesian_rating': {'type': 'double'},
@ -248,22 +324,20 @@ class AddonIndexer(BaseSearchIndexer):
'trigrams': {
'type': 'text',
'analyzer': 'trigram',
}
}
},
},
},
'platforms': {'type': 'byte'},
'previews': {
'type': 'object',
'properties': {
'id': {'type': 'long', 'index': False},
'caption_translations':
cls.get_translations_definition(),
'caption_translations': cls.get_translations_definition(),
'modified': {'type': 'date', 'index': False},
'sizes': {
'type': 'object',
'properties': {
'thumbnail': {'type': 'short',
'index': False},
'thumbnail': {'type': 'short', 'index': False},
'image': {'type': 'short', 'index': False},
},
},
@ -274,14 +348,14 @@ class AddonIndexer(BaseSearchIndexer):
'properties': {
'group_id': {'type': 'byte'},
'approved_for_apps': {'type': 'byte'},
}
},
},
'ratings': {
'type': 'object',
'properties': {
'count': {'type': 'short', 'index': False},
'average': {'type': 'float', 'index': False}
}
'average': {'type': 'float', 'index': False},
},
},
'slug': {'type': 'keyword'},
'requires_payment': {'type': 'boolean', 'index': False},
@ -297,16 +371,23 @@ class AddonIndexer(BaseSearchIndexer):
# Add fields that we expect to return all translations without being
# analyzed/indexed.
cls.attach_translation_mappings(
mapping, ('description', 'developer_comments', 'homepage', 'name',
'summary', 'support_email', 'support_url'))
mapping,
(
'description',
'developer_comments',
'homepage',
'name',
'summary',
'support_email',
'support_url',
),
)
# Add language-specific analyzers for localized fields that are
# analyzed/indexed.
cls.attach_language_specific_analyzers(
mapping, ('description', 'summary'))
cls.attach_language_specific_analyzers(mapping, ('description', 'summary'))
cls.attach_language_specific_analyzers_with_raw_variant(
mapping, ('name',))
cls.attach_language_specific_analyzers_with_raw_variant(mapping, ('name',))
return mapping
@ -314,33 +395,43 @@ class AddonIndexer(BaseSearchIndexer):
def extract_version(cls, obj, version_obj):
from olympia.versions.models import License, Version
data = {
'id': version_obj.pk,
'compatible_apps': cls.extract_compatibility_info(
obj, version_obj),
'files': [{
'id': file_.id,
'created': file_.created,
'filename': file_.filename,
'hash': file_.hash,
'is_webextension': file_.is_webextension,
'is_mozilla_signed_extension': (
file_.is_mozilla_signed_extension),
'is_restart_required': file_.is_restart_required,
'platform': file_.platform,
'size': file_.size,
'status': file_.status,
'strict_compatibility': file_.strict_compatibility,
'permissions': file_.permissions,
'optional_permissions': file_.optional_permissions,
} for file_ in version_obj.all_files],
'reviewed': version_obj.reviewed,
'version': version_obj.version,
} if version_obj else None
data = (
{
'id': version_obj.pk,
'compatible_apps': cls.extract_compatibility_info(obj, version_obj),
'files': [
{
'id': file_.id,
'created': file_.created,
'filename': file_.filename,
'hash': file_.hash,
'is_webextension': file_.is_webextension,
'is_mozilla_signed_extension': (
file_.is_mozilla_signed_extension
),
'is_restart_required': file_.is_restart_required,
'platform': file_.platform,
'size': file_.size,
'status': file_.status,
'strict_compatibility': file_.strict_compatibility,
'permissions': file_.permissions,
'optional_permissions': file_.optional_permissions,
}
for file_ in version_obj.all_files
],
'reviewed': version_obj.reviewed,
'version': version_obj.version,
}
if version_obj
else None
)
if data and version_obj:
attach_trans_dict(Version, [version_obj])
data.update(cls.extract_field_api_translations(
version_obj, 'release_notes', db_field='release_notes_id'))
data.update(
cls.extract_field_api_translations(
version_obj, 'release_notes', db_field='release_notes_id'
)
)
if version_obj.license:
data['license'] = {
'id': version_obj.license.id,
@ -348,8 +439,9 @@ class AddonIndexer(BaseSearchIndexer):
'url': version_obj.license.url,
}
attach_trans_dict(License, [version_obj.license])
data['license'].update(cls.extract_field_api_translations(
version_obj.license, 'name'))
data['license'].update(
cls.extract_field_api_translations(version_obj.license, 'name')
)
return data
@classmethod
@ -361,8 +453,7 @@ class AddonIndexer(BaseSearchIndexer):
if appver:
min_, max_ = appver.min.version_int, appver.max.version_int
min_human, max_human = appver.min.version, appver.max.version
if not version_obj.files.filter(
strict_compatibility=True).exists():
if not version_obj.files.filter(strict_compatibility=True).exists():
# The files attached to this version are not using strict
# compatibility, so the max version essentially needs to be
# ignored - let's fake a super high one. We leave max_human
@ -373,12 +464,16 @@ class AddonIndexer(BaseSearchIndexer):
# want to reindex every time a new version of the app is
# released, so we directly index a super high version as the
# max.
min_human, max_human = amo.D2C_MIN_VERSIONS.get(
app.id, '1.0'), amo.FAKE_MAX_VERSION,
min_human, max_human = (
amo.D2C_MIN_VERSIONS.get(app.id, '1.0'),
amo.FAKE_MAX_VERSION,
)
min_, max_ = version_int(min_human), version_int(max_human)
compatible_apps[app.id] = {
'min': min_, 'min_human': min_human,
'max': max_, 'max_human': max_human,
'min': min_,
'min_human': min_human,
'max': max_,
'max_human': max_human,
}
return compatible_apps
@ -387,19 +482,32 @@ class AddonIndexer(BaseSearchIndexer):
"""Extract indexable attributes from an add-on."""
from olympia.addons.models import Preview
attrs = ('id', 'average_daily_users', 'bayesian_rating',
'contributions', 'created',
'default_locale', 'guid', 'hotness', 'icon_hash', 'icon_type',
'is_disabled', 'is_experimental',
'last_updated',
'modified', 'requires_payment', 'slug',
'status', 'type', 'weekly_downloads')
attrs = (
'id',
'average_daily_users',
'bayesian_rating',
'contributions',
'created',
'default_locale',
'guid',
'hotness',
'icon_hash',
'icon_type',
'is_disabled',
'is_experimental',
'last_updated',
'modified',
'requires_payment',
'slug',
'status',
'type',
'weekly_downloads',
)
data = {attr: getattr(obj, attr) for attr in attrs}
data['colors'] = None
if obj.current_version:
data['platforms'] = [p.id for p in
obj.current_version.supported_platforms]
data['platforms'] = [p.id for p in obj.current_version.supported_platforms]
# Extract dominant colors from static themes.
if obj.type == amo.ADDON_STATICTHEME:
@ -409,19 +517,25 @@ class AddonIndexer(BaseSearchIndexer):
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)
data['boost'] = float(data['average_daily_users'] ** 0.2)
# Quadruple the boost if the add-on is public.
if (obj.status == amo.STATUS_APPROVED and not obj.is_experimental and
'boost' in data):
if (
obj.status == amo.STATUS_APPROVED
and not obj.is_experimental
and 'boost' in data
):
data['boost'] = float(max(data['boost'], 1) * 4)
# We can use all_categories because the indexing code goes through the
# transformer that sets it.
data['category'] = [cat.id for cat in obj.all_categories]
data['current_version'] = cls.extract_version(
obj, obj.current_version)
data['current_version'] = cls.extract_version(obj, obj.current_version)
data['listed_authors'] = [
{'name': a.name, 'id': a.id, 'username': a.username,
'is_public': a.is_public}
{
'name': a.name,
'id': a.id,
'username': a.username,
'is_public': a.is_public,
}
for a in obj.listed_authors
]
@ -429,18 +543,25 @@ class AddonIndexer(BaseSearchIndexer):
data['has_privacy_policy'] = bool(obj.privacy_policy)
data['is_recommended'] = bool(
obj.promoted and obj.promoted.group == RECOMMENDED)
obj.promoted and obj.promoted.group == RECOMMENDED
)
data['previews'] = [{'id': preview.id, 'modified': preview.modified,
'sizes': preview.sizes}
for preview in obj.current_previews]
data['previews'] = [
{'id': preview.id, 'modified': preview.modified, 'sizes': preview.sizes}
for preview in obj.current_previews
]
data['promoted'] = {
'group_id': obj.promoted.group_id,
# store the app approvals because .approved_applications needs it.
'approved_for_apps': [
app.id for app in obj.promoted.approved_applications],
} if obj.promoted else None
data['promoted'] = (
{
'group_id': obj.promoted.group_id,
# store the app approvals because .approved_applications needs it.
'approved_for_apps': [
app.id for app in obj.promoted.approved_applications
],
}
if obj.promoted
else None
)
data['ratings'] = {
'average': obj.average_rating,
@ -455,14 +576,14 @@ class AddonIndexer(BaseSearchIndexer):
# First, deal with the 3 fields that need everything:
for field in ('description', 'name', 'summary'):
data.update(cls.extract_field_api_translations(obj, field))
data.update(cls.extract_field_search_translation(
obj, field, obj.default_locale))
data.update(
cls.extract_field_search_translation(obj, field, obj.default_locale)
)
data.update(cls.extract_field_analyzed_translations(obj, field))
# Then add fields that only need to be returned to the API without
# contributing to search relevancy.
for field in ('developer_comments', 'homepage', 'support_email',
'support_url'):
for field in ('developer_comments', 'homepage', 'support_email', 'support_url'):
data.update(cls.extract_field_api_translations(obj, field))
if obj.type != amo.ADDON_STATICTHEME:
# Also do that for preview captions, which are set on each preview
@ -470,7 +591,8 @@ class AddonIndexer(BaseSearchIndexer):
attach_trans_dict(Preview, obj.current_previews)
for i, preview in enumerate(obj.current_previews):
data['previews'][i].update(
cls.extract_field_api_translations(preview, 'caption'))
cls.extract_field_api_translations(preview, 'caption')
)
return data
@ -505,8 +627,6 @@ class AddonIndexer(BaseSearchIndexer):
"""
from olympia.addons.tasks import index_addons
ids = cls.get_model().unfiltered.values_list(
'id', flat=True).order_by('id')
ids = cls.get_model().unfiltered.values_list('id', flat=True).order_by('id')
chunk_size = 150
return create_chunked_tasks_signatures(
index_addons, list(ids), chunk_size)
return create_chunked_tasks_signatures(index_addons, list(ids), chunk_size)

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

@ -13,7 +13,8 @@ class Command(BaseCommand):
def find_affected_langpacks(self):
qs = Version.unfiltered.filter(
addon__type=amo.ADDON_LPAPP, apps__max__version='*').distinct()
addon__type=amo.ADDON_LPAPP, apps__max__version='*'
).distinct()
return qs
def fix_max_appversion_for_version(self, version):
@ -22,32 +23,42 @@ class Command(BaseCommand):
log.info(
'Version %s for addon %s min version is not compatible '
'with %s, skipping this version for that app.',
version, version.addon, app.pretty)
version,
version.addon,
app.pretty,
)
continue
if version.compatible_apps[app].max.version != '*':
log.info(
'Version %s for addon %s max version is not "*" for %s '
'app, skipping this version for that app.',
version, version.addon, app.pretty)
version,
version.addon,
app.pretty,
)
continue
min_appversion_str = version.compatible_apps[app].min.version
max_appversion_str = '%d.*' % version_dict(
min_appversion_str)['major']
max_appversion_str = '%d.*' % version_dict(min_appversion_str)['major']
log.warning(
'Version %s for addon %s min version is %s for %s app, '
'max will be changed to %s instead of *',
version, version.addon, min_appversion_str, app.pretty,
max_appversion_str)
version,
version.addon,
min_appversion_str,
app.pretty,
max_appversion_str,
)
max_appversion = AppVersion.objects.get(
application=app.id, version=max_appversion_str)
application=app.id, version=max_appversion_str
)
version.compatible_apps[app].max = max_appversion
version.compatible_apps[app].save()
def handle(self, *args, **options):
versions = self.find_affected_langpacks()
log.info(
'Found %d langpack versions with an incorrect max version',
versions.count())
'Found %d langpack versions with an incorrect max version', versions.count()
)
for version in versions:
log.info('Fixing version %s for addon %s', version, version.addon)
self.fix_max_appversion_for_version(version)

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

@ -31,96 +31,108 @@ def get_recalc_needed_filters():
summary_modified = F('_current_version__autoapprovalsummary__modified')
# We don't take deleted reports into account
valid_abuse_report_states = (
AbuseReport.STATES.UNTRIAGED, AbuseReport.STATES.VALID,
AbuseReport.STATES.SUSPICIOUS)
AbuseReport.STATES.UNTRIAGED,
AbuseReport.STATES.VALID,
AbuseReport.STATES.SUSPICIOUS,
)
return [
# Only recalculate add-ons that received recent abuse reports
# possibly through their authors.
Q(
abuse_reports__state__in=valid_abuse_report_states,
abuse_reports__created__gte=summary_modified
) |
Q(
abuse_reports__created__gte=summary_modified,
)
| Q(
authors__abuse_reports__state__in=valid_abuse_report_states,
authors__abuse_reports__created__gte=summary_modified
) |
authors__abuse_reports__created__gte=summary_modified,
)
# And check ratings that have a rating of 3 or less
Q(
| Q(
_current_version__ratings__deleted=False,
_current_version__ratings__created__gte=summary_modified,
_current_version__ratings__rating__lte=3)
_current_version__ratings__rating__lte=3,
)
]
tasks = {
'find_inconsistencies_between_es_and_db': {
'method': find_inconsistencies_between_es_and_db, 'qs': []},
'method': find_inconsistencies_between_es_and_db,
'qs': [],
},
'get_preview_sizes': {'method': get_preview_sizes, 'qs': []},
'recalculate_post_review_weight': {
'method': recalculate_post_review_weight,
'qs': [
Q(**{current_autoapprovalsummary + 'verdict': amo.AUTO_APPROVED}) &
~Q(**{current_autoapprovalsummary + 'confirmed': True})
]},
Q(**{current_autoapprovalsummary + 'verdict': amo.AUTO_APPROVED})
& ~Q(**{current_autoapprovalsummary + 'confirmed': True})
],
},
'constantly_recalculate_post_review_weight': {
# This re-calculates the whole post-review weight which can be costly
# so take that into account. We may want to optimize that later
# in case we notice things are slower than needed - cgrebs 20190730
'method': recalculate_post_review_weight,
'kwargs': {'only_current_version': True},
'qs': get_recalc_needed_filters()},
'qs': get_recalc_needed_filters(),
},
'resign_addons_for_cose': {
'method': sign_addons,
'qs': [
# Only resign public add-ons where the latest version has been
# created before the 5th of April
Q(status=amo.STATUS_APPROVED,
_current_version__created__lt=datetime(2019, 4, 5))
]
Q(
status=amo.STATUS_APPROVED,
_current_version__created__lt=datetime(2019, 4, 5),
)
],
},
'recreate_previews': {
'method': recreate_previews,
'qs': [
~Q(type=amo.ADDON_STATICTHEME)
]
'qs': [~Q(type=amo.ADDON_STATICTHEME)],
},
'recreate_theme_previews': {
'method': recreate_theme_previews,
'qs': [
Q(type=amo.ADDON_STATICTHEME, status__in=[
amo.STATUS_APPROVED, amo.STATUS_AWAITING_REVIEW])
Q(
type=amo.ADDON_STATICTHEME,
status__in=[amo.STATUS_APPROVED, amo.STATUS_AWAITING_REVIEW],
)
],
'kwargs': {'only_missing': False},
},
'create_missing_theme_previews': {
'method': recreate_theme_previews,
'qs': [
Q(type=amo.ADDON_STATICTHEME, status__in=[
amo.STATUS_APPROVED, amo.STATUS_AWAITING_REVIEW])
Q(
type=amo.ADDON_STATICTHEME,
status__in=[amo.STATUS_APPROVED, amo.STATUS_AWAITING_REVIEW],
)
],
'kwargs': {'only_missing': True},
},
'add_dynamic_theme_tag_for_theme_api': {
'method': add_dynamic_theme_tag,
'qs': [
Q(status=amo.STATUS_APPROVED,
_current_version__files__is_webextension=True)
]
Q(status=amo.STATUS_APPROVED, _current_version__files__is_webextension=True)
],
},
'extract_colors_from_static_themes': {
'method': extract_colors_from_static_themes,
'qs': [Q(type=amo.ADDON_STATICTHEME)]
'qs': [Q(type=amo.ADDON_STATICTHEME)],
},
'delete_obsolete_addons': {
'method': delete_addons,
'qs': [
Q(type__in=(_ADDON_THEME,
amo.ADDON_LPADDON,
amo.ADDON_PLUGIN,
_ADDON_PERSONA,
_ADDON_WEBAPP,
))
Q(
type__in=(
_ADDON_THEME,
amo.ADDON_LPADDON,
amo.ADDON_PLUGIN,
_ADDON_PERSONA,
_ADDON_WEBAPP,
)
)
],
'allowed_kwargs': ('with_deleted',),
},
@ -140,6 +152,7 @@ class Command(BaseCommand):
allowed_kwargs: any extra boolean kwargs that can be applied via
additional arguments. Make sure to add it to `add_arguments` too.
"""
def add_arguments(self, parser):
"""Handle command arguments."""
parser.add_argument(
@ -147,27 +160,31 @@ class Command(BaseCommand):
action='store',
dest='task',
type=str,
help='Run task on the addons.')
help='Run task on the addons.',
)
parser.add_argument(
'--with-deleted',
action='store_true',
dest='with_deleted',
help='Include deleted add-ons when determining which '
'add-ons to process.')
'add-ons to process.',
)
parser.add_argument(
'--ids',
action='store',
dest='ids',
help='Only apply task to specific addon ids (comma-separated).')
help='Only apply task to specific addon ids (comma-separated).',
)
parser.add_argument(
'--limit',
action='store',
dest='limit',
type=int,
help='Only apply task to the first X addon ids.')
help='Only apply task to the first X addon ids.',
)
parser.add_argument(
'--batch-size',
@ -175,7 +192,8 @@ class Command(BaseCommand):
dest='batch_size',
type=int,
default=100,
help='Split the add-ons into X size chunks. Default 100.')
help='Split the add-ons into X size chunks. Default 100.',
)
parser.add_argument(
'--channel',
@ -185,12 +203,12 @@ class Command(BaseCommand):
choices=('listed', 'unlisted'),
help=(
'Only select add-ons who have either listed or unlisted '
'versions. Add-ons that have both will be returned too.'))
'versions. Add-ons that have both will be returned too.'
),
)
def get_pks(self, manager, q_objects, distinct=False):
pks = (manager.filter(q_objects)
.values_list('pk', flat=True)
.order_by('id'))
pks = manager.filter(q_objects).values_list('pk', flat=True).order_by('id')
if distinct:
pks = pks.distinct()
return pks
@ -198,8 +216,9 @@ class Command(BaseCommand):
def handle(self, *args, **options):
task = tasks.get(options.get('task'))
if not task:
raise CommandError('Unknown task provided. Options are: %s'
% ', '.join(tasks.keys()))
raise CommandError(
'Unknown task provided. Options are: %s' % ', '.join(tasks.keys())
)
if options.get('with_deleted'):
addon_manager = Addon.unfiltered
else:
@ -211,30 +230,27 @@ class Command(BaseCommand):
ids_list = options.get('ids').split(',')
addon_manager = addon_manager.filter(id__in=ids_list)
pks = self.get_pks(
addon_manager, *task['qs'], distinct=task.get('distinct'))
pks = self.get_pks(addon_manager, *task['qs'], distinct=task.get('distinct'))
if options.get('limit'):
pks = pks[:options.get('limit')]
pks = pks[: options.get('limit')]
if 'pre' in task:
# This is run in process to ensure its run before the tasks.
pks = task['pre'](pks)
if pks:
kwargs = task.get('kwargs', {})
if task.get('allowed_kwargs'):
kwargs.update({
arg: options.get(arg, None)
for arg in task['allowed_kwargs']})
kwargs.update(
{arg: options.get(arg, None) for arg in task['allowed_kwargs']}
)
# All the remaining tasks go in one group.
grouping = []
for chunk in chunked(pks, options.get('batch_size')):
grouping.append(
task['method'].subtask(args=[chunk], kwargs=kwargs))
grouping.append(task['method'].subtask(args=[chunk], kwargs=kwargs))
# Add the post task on to the end.
post = None
if 'post' in task:
post = task['post'].subtask(
args=[], kwargs=kwargs, immutable=True)
post = task['post'].subtask(args=[], kwargs=kwargs, immutable=True)
ts = chord(grouping, post)
else:
ts = group(grouping)

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -8,12 +8,16 @@ from rest_framework import exceptions, serializers
from olympia import amo
from olympia.accounts.serializers import (
BaseUserSerializer, UserProfileBasketSyncSerializer)
BaseUserSerializer,
UserProfileBasketSyncSerializer,
)
from olympia.amo.templatetags.jinja_helpers import absolutify
from olympia.amo.urlresolvers import get_outgoing_url, reverse
from olympia.api.fields import (
ESTranslationSerializerField, ReverseChoiceField,
TranslationSerializerField)
ESTranslationSerializerField,
ReverseChoiceField,
TranslationSerializerField,
)
from olympia.api.serializers import BaseESSerializer
from olympia.api.utils import is_gate_active
from olympia.applications.models import AppVersion
@ -27,28 +31,39 @@ from olympia.promoted.models import PromotedAddon
from olympia.search.filters import AddonAppVersionQueryParam
from olympia.users.models import UserProfile
from olympia.versions.models import (
ApplicationsVersions, License, Version, VersionPreview)
ApplicationsVersions,
License,
Version,
VersionPreview,
)
from .models import Addon, Preview, ReplacementAddon, attach_tags
class FileSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField()
platform = ReverseChoiceField(
choices=list(amo.PLATFORM_CHOICES_API.items()))
platform = ReverseChoiceField(choices=list(amo.PLATFORM_CHOICES_API.items()))
status = ReverseChoiceField(choices=list(amo.STATUS_CHOICES_API.items()))
permissions = serializers.ListField(
child=serializers.CharField())
optional_permissions = serializers.ListField(
child=serializers.CharField())
permissions = serializers.ListField(child=serializers.CharField())
optional_permissions = serializers.ListField(child=serializers.CharField())
is_restart_required = serializers.BooleanField()
class Meta:
model = File
fields = ('id', 'created', 'hash', 'is_restart_required',
'is_webextension', 'is_mozilla_signed_extension',
'platform', 'size', 'status', 'url', 'permissions',
'optional_permissions')
fields = (
'id',
'created',
'hash',
'is_restart_required',
'is_webextension',
'is_mozilla_signed_extension',
'platform',
'size',
'status',
'url',
'permissions',
'optional_permissions',
)
def get_url(self, obj):
return obj.get_absolute_url()
@ -62,8 +77,14 @@ class PreviewSerializer(serializers.ModelSerializer):
class Meta:
# Note: this serializer can also be used for VersionPreview.
model = Preview
fields = ('id', 'caption', 'image_size', 'image_url', 'thumbnail_size',
'thumbnail_url')
fields = (
'id',
'caption',
'image_size',
'image_url',
'thumbnail_size',
'thumbnail_url',
)
def get_image_url(self, obj):
return absolutify(obj.image_url)
@ -138,8 +159,7 @@ class LicenseSerializer(serializers.ModelSerializer):
def to_representation(self, instance):
data = super(LicenseSerializer, self).to_representation(instance)
request = self.context.get('request', None)
if request and is_gate_active(
request, 'del-version-license-is-custom'):
if request and is_gate_active(request, 'del-version-license-is-custom'):
data.pop('is_custom', None)
return data
@ -167,9 +187,17 @@ class SimpleVersionSerializer(MinimalVersionSerializer):
class Meta:
model = Version
fields = ('id', 'compatibility', 'edit_url', 'files',
'is_strict_compatibility_enabled', 'license',
'release_notes', 'reviewed', 'version')
fields = (
'id',
'compatibility',
'edit_url',
'files',
'is_strict_compatibility_enabled',
'license',
'release_notes',
'reviewed',
'version',
)
def to_representation(self, instance):
# Help the LicenseSerializer find the version we're currently
@ -181,30 +209,41 @@ class SimpleVersionSerializer(MinimalVersionSerializer):
def get_compatibility(self, obj):
return {
app.short: {
'min': compat.min.version if compat else (
amo.D2C_MIN_VERSIONS.get(app.id, '1.0')),
'max': compat.max.version if compat else amo.FAKE_MAX_VERSION
} for app, compat in obj.compatible_apps.items()
'min': compat.min.version
if compat
else (amo.D2C_MIN_VERSIONS.get(app.id, '1.0')),
'max': compat.max.version if compat else amo.FAKE_MAX_VERSION,
}
for app, compat in obj.compatible_apps.items()
}
def get_edit_url(self, obj):
return absolutify(obj.addon.get_dev_url(
'versions.edit', args=[obj.pk], prefix_only=True))
return absolutify(
obj.addon.get_dev_url('versions.edit', args=[obj.pk], prefix_only=True)
)
def get_is_strict_compatibility_enabled(self, obj):
return any(file_.strict_compatibility for file_ in obj.all_files)
class VersionSerializer(SimpleVersionSerializer):
channel = ReverseChoiceField(
choices=list(amo.CHANNEL_CHOICES_API.items()))
channel = ReverseChoiceField(choices=list(amo.CHANNEL_CHOICES_API.items()))
license = LicenseSerializer()
class Meta:
model = Version
fields = ('id', 'channel', 'compatibility', 'edit_url', 'files',
'is_strict_compatibility_enabled', 'license',
'release_notes', 'reviewed', 'version')
fields = (
'id',
'channel',
'compatibility',
'edit_url',
'files',
'is_strict_compatibility_enabled',
'license',
'release_notes',
'reviewed',
'version',
)
class CurrentVersionSerializer(SimpleVersionSerializer):
@ -216,9 +255,12 @@ class CurrentVersionSerializer(SimpleVersionSerializer):
request = self.context.get('request')
view = self.context.get('view')
addon = obj.addon
if (request and request.GET.get('appversion') and
getattr(view, 'action', None) == 'retrieve' and
addon.type == amo.ADDON_LPAPP):
if (
request
and request.GET.get('appversion')
and getattr(view, 'action', None) == 'retrieve'
and addon.type == amo.ADDON_LPAPP
):
obj = self.get_current_compatible_version(addon)
return super(CurrentVersionSerializer, self).to_representation(obj)
@ -241,12 +283,13 @@ class CurrentVersionSerializer(SimpleVersionSerializer):
raise exceptions.ParseError(str(exc))
version_qs = Version.objects.latest_public_compatible_with(
application, appversions).filter(addon=addon)
application, appversions
).filter(addon=addon)
return version_qs.first() or addon.current_version
class ESCompactLicenseSerializer(BaseESSerializer, CompactLicenseSerializer):
translated_fields = ('name', )
translated_fields = ('name',)
def __init__(self, *args, **kwargs):
super(ESCompactLicenseSerializer, self).__init__(*args, **kwargs)
@ -287,16 +330,14 @@ class AddonDeveloperSerializer(BaseUserSerializer):
picture_url = serializers.SerializerMethodField()
class Meta(BaseUserSerializer.Meta):
fields = BaseUserSerializer.Meta.fields + (
'picture_url',)
fields = BaseUserSerializer.Meta.fields + ('picture_url',)
read_only_fields = fields
class PromotedAddonSerializer(serializers.ModelSerializer):
GROUP_CHOICES = [(group.id, group.api_name) for group in PROMOTED_GROUPS]
apps = serializers.SerializerMethodField()
category = ReverseChoiceField(
choices=GROUP_CHOICES, source='group_id')
category = ReverseChoiceField(choices=GROUP_CHOICES, source='group_id')
class Meta:
model = PromotedAddon
@ -378,15 +419,17 @@ class AddonSerializer(serializers.ModelSerializer):
'tags',
'type',
'url',
'weekly_downloads'
'weekly_downloads',
)
def to_representation(self, obj):
data = super(AddonSerializer, self).to_representation(obj)
request = self.context.get('request', None)
if ('request' in self.context and
'wrap_outgoing_links' in self.context['request'].GET):
if (
'request' in self.context
and 'wrap_outgoing_links' in self.context['request'].GET
):
for key in ('homepage', 'support_url', 'contributions_url'):
if key in data:
data[key] = self.outgoingify(data[key])
@ -403,8 +446,10 @@ class AddonSerializer(serializers.ModelSerializer):
if isinstance(data, str):
return get_outgoing_url(data)
elif isinstance(data, dict):
return {key: get_outgoing_url(value) if value else None
for key, value in data.items()}
return {
key: get_outgoing_url(value) if value else None
for key, value in data.items()
}
# None or empty string... don't bother.
return data
@ -446,8 +491,8 @@ class AddonSerializer(serializers.ModelSerializer):
query = QueryDict(parts.query, mutable=True)
query.update(amo.CONTRIBUTE_UTM_PARAMS)
return urlunsplit(
(parts.scheme, parts.netloc, parts.path, query.urlencode(),
parts.fragment))
(parts.scheme, parts.netloc, parts.path, query.urlencode(), parts.fragment)
)
def get_edit_url(self, obj):
return absolutify(obj.get_dev_url())
@ -464,8 +509,7 @@ class AddonSerializer(serializers.ModelSerializer):
def get_icons(self, obj):
get_icon = obj.get_icon_url
return {str(size): absolutify(get_icon(size))
for size in amo.ADDON_ICON_SIZES}
return {str(size): absolutify(get_icon(size)) for size in amo.ADDON_ICON_SIZES}
def get_ratings(self, obj):
return {
@ -503,12 +547,19 @@ class ESAddonSerializer(BaseESSerializer, AddonSerializer):
_score = serializers.SerializerMethodField()
datetime_fields = ('created', 'last_updated', 'modified')
translated_fields = ('name', 'description', 'developer_comments',
'homepage', 'summary', 'support_email', 'support_url')
translated_fields = (
'name',
'description',
'developer_comments',
'homepage',
'summary',
'support_email',
'support_url',
)
class Meta:
model = Addon
fields = AddonSerializer.Meta.fields + ('_score', )
fields = AddonSerializer.Meta.fields + ('_score',)
def fake_preview_object(self, obj, data, model_class=Preview):
# This is what ESPreviewSerializer.fake_object() would do, but we do
@ -524,33 +575,40 @@ class ESAddonSerializer(BaseESSerializer, AddonSerializer):
preview_serializer._attach_fields(preview, data, ('modified',))
# Attach translations.
preview_serializer._attach_translations(
preview, data, preview_serializer.translated_fields)
preview, data, preview_serializer.translated_fields
)
return preview
def fake_file_object(self, obj, data):
file_ = File(
id=data['id'], created=self.handle_date(data['created']),
hash=data['hash'], filename=data['filename'],
id=data['id'],
created=self.handle_date(data['created']),
hash=data['hash'],
filename=data['filename'],
is_webextension=data.get('is_webextension'),
is_mozilla_signed_extension=data.get(
'is_mozilla_signed_extension'),
is_mozilla_signed_extension=data.get('is_mozilla_signed_extension'),
is_restart_required=data.get('is_restart_required', False),
platform=data['platform'], size=data['size'],
platform=data['platform'],
size=data['size'],
status=data['status'],
strict_compatibility=data.get('strict_compatibility', False),
version=obj)
version=obj,
)
file_.permissions = data.get(
'permissions', data.get('webext_permissions_list', []))
file_.optional_permissions = data.get(
'optional_permissions', [])
'permissions', data.get('webext_permissions_list', [])
)
file_.optional_permissions = data.get('optional_permissions', [])
return file_
def fake_version_object(self, obj, data, channel):
if data:
version = Version(
addon=obj, id=data['id'],
addon=obj,
id=data['id'],
reviewed=self.handle_date(data['reviewed']),
version=data['version'], channel=channel)
version=data['version'],
channel=channel,
)
version.all_files = [
self.fake_file_object(version, file_data)
for file_data in data.get('files', [])
@ -563,22 +621,26 @@ class ESAddonSerializer(BaseESSerializer, AddonSerializer):
app_name = APPS_ALL[int(app_id)]
compatible_apps[app_name] = ApplicationsVersions(
min=AppVersion(version=compat_dict.get('min_human', '')),
max=AppVersion(version=compat_dict.get('max_human', '')))
max=AppVersion(version=compat_dict.get('max_human', '')),
)
version._compatible_apps = compatible_apps
version_serializer = self.fields.get('current_version') or None
if version_serializer:
version_serializer._attach_translations(
version, data, version_serializer.translated_fields)
version, data, version_serializer.translated_fields
)
if 'license' in data and version_serializer:
license_serializer = version_serializer.fields['license']
version.license = License(id=data['license']['id'])
license_serializer._attach_fields(
version.license, data['license'], ('builtin', 'url'))
version.license, data['license'], ('builtin', 'url')
)
# Can't use license_serializer._attach_translations() directly
# because 'name' is a SerializerMethodField, not an
# ESTranslatedField.
license_serializer.db_name.attach_translations(
version.license, data['license'], 'name')
version.license, data['license'], 'name'
)
else:
version.license = None
else:
@ -592,7 +654,9 @@ class ESAddonSerializer(BaseESSerializer, AddonSerializer):
# Attach base attributes that have the same name/format in ES and in
# the model.
self._attach_fields(
obj, data, (
obj,
data,
(
'average_daily_users',
'bayesian_rating',
'contributions',
@ -611,14 +675,15 @@ class ESAddonSerializer(BaseESSerializer, AddonSerializer):
'slug',
'status',
'type',
'weekly_downloads'
)
'weekly_downloads',
),
)
# Attach attributes that do not have the same name/format in ES.
obj.tag_list = data.get('tags', [])
obj.all_categories = [
CATEGORIES_BY_ID[cat_id] for cat_id in data.get('category', [])]
CATEGORIES_BY_ID[cat_id] for cat_id in data.get('category', [])
]
# Not entirely accurate, but enough in the context of the search API.
obj.disabled_by_user = data.get('is_disabled', False)
@ -631,23 +696,25 @@ class ESAddonSerializer(BaseESSerializer, AddonSerializer):
# begins with an underscore.
data_version = data.get('current_version') or {}
obj._current_version = self.fake_version_object(
obj, data_version, amo.RELEASE_CHANNEL_LISTED)
obj, data_version, amo.RELEASE_CHANNEL_LISTED
)
obj._current_version_id = data_version.get('id')
data_authors = data.get('listed_authors', [])
obj.listed_authors = [
UserProfile(
id=data_author['id'], display_name=data_author['name'],
id=data_author['id'],
display_name=data_author['name'],
username=data_author['username'],
is_public=data_author.get('is_public', False))
is_public=data_author.get('is_public', False),
)
for data_author in data_authors
]
is_static_theme = data.get('type') == amo.ADDON_STATICTHEME
preview_model_class = VersionPreview if is_static_theme else Preview
obj.current_previews = [
self.fake_preview_object(
obj, preview_data, model_class=preview_model_class)
self.fake_preview_object(obj, preview_data, model_class=preview_model_class)
for preview_data in data.get('previews', [])
]
@ -657,13 +724,16 @@ class ESAddonSerializer(BaseESSerializer, AddonSerializer):
# .approved_applications.
approved_for_apps = promoted.get('approved_for_apps')
obj.promoted = PromotedAddon(
addon=obj, approved_application_ids=approved_for_apps,
group_id=promoted['group_id'])
addon=obj,
approved_application_ids=approved_for_apps,
group_id=promoted['group_id'],
)
# we can safely regenerate these tuples because
# .appproved_applications only cares about the current group
obj._current_version.approved_for_groups = (
(obj.promoted.group, APP_IDS.get(app_id))
for app_id in approved_for_apps)
for app_id in approved_for_apps
)
else:
obj.promoted = None
@ -682,8 +752,11 @@ class ESAddonSerializer(BaseESSerializer, AddonSerializer):
def to_representation(self, obj):
data = super(ESAddonSerializer, self).to_representation(obj)
request = self.context.get('request')
if request and '_score' in data and not is_gate_active(
request, 'addons-search-_score-field'):
if (
request
and '_score' in data
and not is_gate_active(request, 'addons-search-_score-field')
):
data.pop('_score')
return data
@ -704,6 +777,7 @@ class ESAddonAutoCompleteSerializer(ESAddonSerializer):
class StaticCategorySerializer(serializers.Serializer):
"""Serializes a `StaticCategory` as found in constants.categories"""
id = serializers.IntegerField()
name = serializers.CharField()
slug = serializers.CharField()
@ -726,14 +800,22 @@ class LanguageToolsSerializer(AddonSerializer):
class Meta:
model = Addon
fields = ('id', 'current_compatible_version', 'default_locale', 'guid',
'name', 'slug', 'target_locale', 'type', 'url', )
fields = (
'id',
'current_compatible_version',
'default_locale',
'guid',
'name',
'slug',
'target_locale',
'type',
'url',
)
def get_current_compatible_version(self, obj):
compatible_versions = getattr(obj, 'compatible_versions', None)
if compatible_versions is not None:
data = MinimalVersionSerializer(
compatible_versions, many=True).data
data = MinimalVersionSerializer(compatible_versions, many=True).data
try:
# 99% of the cases there will only be one result, since most
# language packs are automatically uploaded for a given app
@ -750,11 +832,12 @@ class LanguageToolsSerializer(AddonSerializer):
def to_representation(self, obj):
data = super(LanguageToolsSerializer, self).to_representation(obj)
request = self.context['request']
if (AddonAppVersionQueryParam.query_param not in request.GET and
'current_compatible_version' in data):
if (
AddonAppVersionQueryParam.query_param not in request.GET
and 'current_compatible_version' in data
):
data.pop('current_compatible_version')
if request and is_gate_active(
request, 'addons-locale_disambiguation-shim'):
if request and is_gate_active(request, 'addons-locale_disambiguation-shim'):
data['locale_disambiguation'] = None
return data
@ -762,8 +845,7 @@ class LanguageToolsSerializer(AddonSerializer):
class VersionBasketSerializer(SimpleVersionSerializer):
class Meta:
model = Version
fields = ('id', 'compatibility', 'is_strict_compatibility_enabled',
'version')
fields = ('id', 'compatibility', 'is_strict_compatibility_enabled', 'version')
class AddonBasketSyncSerializer(AddonSerializerWithUnlistedData):
@ -777,11 +859,24 @@ class AddonBasketSyncSerializer(AddonSerializerWithUnlistedData):
class Meta:
model = Addon
fields = ('authors', 'average_daily_users', 'categories',
'current_version', 'default_locale', 'guid', 'id',
'is_disabled', 'is_recommended', 'last_updated',
'latest_unlisted_version', 'name', 'ratings', 'slug',
'status', 'type')
fields = (
'authors',
'average_daily_users',
'categories',
'current_version',
'default_locale',
'guid',
'id',
'is_disabled',
'is_recommended',
'last_updated',
'latest_unlisted_version',
'name',
'ratings',
'slug',
'status',
'type',
)
read_only_fields = fields
def get_name(self, obj):
@ -798,7 +893,8 @@ class ReplacementAddonSerializer(serializers.ModelSerializer):
replacement = serializers.SerializerMethodField()
ADDON_PATH_REGEX = r"""/addon/(?P<addon_id>[^/<>"']+)/$"""
COLLECTION_PATH_REGEX = (
r"""/collections/(?P<user_id>[^/<>"']+)/(?P<coll_slug>[^/]+)/$""")
r"""/collections/(?P<user_id>[^/<>"']+)/(?P<coll_slug>[^/]+)/$"""
)
class Meta:
model = ReplacementAddon
@ -822,8 +918,7 @@ class ReplacementAddonSerializer(serializers.ModelSerializer):
except Collection.DoesNotExist:
return []
valid_q = Addon.objects.get_queryset().valid_q([amo.STATUS_APPROVED])
return list(
collection.addons.filter(valid_q).values_list('guid', flat=True))
return list(collection.addons.filter(valid_q).values_list('guid', flat=True))
def get_replacement(self, obj):
if obj.has_external_url():
@ -836,5 +931,6 @@ class ReplacementAddonSerializer(serializers.ModelSerializer):
coll_match = re.search(self.COLLECTION_PATH_REGEX, obj.path)
if coll_match:
return self._get_collection_guids(
coll_match.group('user_id'), coll_match.group('coll_slug'))
coll_match.group('user_id'), coll_match.group('coll_slug')
)
return []

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

@ -9,16 +9,20 @@ import olympia.core
from olympia import amo
from olympia.addons.indexers import AddonIndexer
from olympia.addons.models import (
Addon, AppSupport, DeniedGuid, Preview, attach_tags,
attach_translations_dict)
Addon,
AppSupport,
DeniedGuid,
Preview,
attach_tags,
attach_translations_dict,
)
from olympia.amo.celery import task
from olympia.amo.decorators import use_primary_db
from olympia.amo.utils import LocalFileStorage, extract_colors_from_image
from olympia.files.utils import get_filepath, parse_addon
from olympia.lib.es.utils import index_objects
from olympia.tags.models import Tag
from olympia.versions.models import (
generate_static_theme_preview, VersionPreview)
from olympia.versions.models import generate_static_theme_preview, VersionPreview
log = olympia.core.logger.getLogger('z.task')
@ -36,8 +40,9 @@ def update_last_updated(addon_id):
try:
addon = Addon.objects.get(pk=addon_id)
except Addon.DoesNotExist:
log.info('[1@None] Updating last updated for %s failed, no addon found'
% addon_id)
log.info(
'[1@None] Updating last updated for %s failed, no addon found' % addon_id
)
return
log.info('[1@None] Updating last updated for %s.' % addon_id)
@ -68,8 +73,7 @@ def update_appsupport(ids, **kw):
else:
min_, max_ = appver.min.version_int, appver.max.version_int
support.append(AppSupport(addon=addon, app=app.id,
min=min_, max=max_))
support.append(AppSupport(addon=addon, app=app.id, min=min_, max=max_))
if not support:
return
@ -106,8 +110,14 @@ def delete_preview_files(id, **kw):
def index_addons(ids, **kw):
log.info('Indexing addons %s-%s. [%s]' % (ids[0], ids[-1], len(ids)))
transforms = (attach_tags, attach_translations_dict)
index_objects(ids, Addon, AddonIndexer.extract_document,
kw.pop('index', None), transforms, Addon.unfiltered)
index_objects(
ids,
Addon,
AddonIndexer.extract_document,
kw.pop('index', None),
transforms,
Addon.unfiltered,
)
@task
@ -130,32 +140,46 @@ def find_inconsistencies_between_es_and_db(ids, **kw):
length = len(ids)
log.info(
'Searching for inconsistencies between db and es %d-%d [%d].',
ids[0], ids[-1], length)
ids[0],
ids[-1],
length,
)
db_addons = Addon.unfiltered.in_bulk(ids)
es_addons = Search(
doc_type=AddonIndexer.get_doctype_name(),
index=AddonIndexer.get_index_alias(),
using=amo.search.get_es()).filter('ids', values=ids)[:length].execute()
es_addons = (
Search(
doc_type=AddonIndexer.get_doctype_name(),
index=AddonIndexer.get_index_alias(),
using=amo.search.get_es(),
)
.filter('ids', values=ids)[:length]
.execute()
)
es_addons = es_addons
db_len = len(db_addons)
es_len = len(es_addons)
if db_len != es_len:
log.info('Inconsistency found: %d in db vs %d in es.',
db_len, es_len)
log.info('Inconsistency found: %d in db vs %d in es.', db_len, es_len)
for result in es_addons.hits.hits:
pk = result['_source']['id']
db_modified = db_addons[pk].modified.isoformat()
es_modified = result['_source']['modified']
if db_modified != es_modified:
log.info('Inconsistency found for addon %d: '
'modified is %s in db vs %s in es.',
pk, db_modified, es_modified)
log.info(
'Inconsistency found for addon %d: '
'modified is %s in db vs %s in es.',
pk,
db_modified,
es_modified,
)
db_status = db_addons[pk].status
es_status = result['_source']['status']
if db_status != es_status:
log.info('Inconsistency found for addon %d: '
'status is %s in db vs %s in es.',
pk, db_status, es_status)
log.info(
'Inconsistency found for addon %d: ' 'status is %s in db vs %s in es.',
pk,
db_status,
es_status,
)
@task
@ -163,8 +187,8 @@ def find_inconsistencies_between_es_and_db(ids, **kw):
def add_dynamic_theme_tag(ids, **kw):
"""Add dynamic theme tag to addons with the specified ids."""
log.info(
'Adding dynamic theme tag to addons %d-%d [%d].',
ids[0], ids[-1], len(ids))
'Adding dynamic theme tag to addons %d-%d [%d].', ids[0], ids[-1], len(ids)
)
addons = Addon.objects.filter(id__in=ids)
for addon in addons:
@ -178,8 +202,7 @@ def add_dynamic_theme_tag(ids, **kw):
@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))
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:
@ -195,9 +218,10 @@ def extract_colors_from_static_themes(ids, **kw):
@task
@use_primary_db
def recreate_theme_previews(addon_ids, **kw):
log.info('[%s@%s] Recreating previews for themes starting at id: %s...'
% (len(addon_ids), recreate_theme_previews.rate_limit,
addon_ids[0]))
log.info(
'[%s@%s] Recreating previews for themes starting at id: %s...'
% (len(addon_ids), recreate_theme_previews.rate_limit, addon_ids[0])
)
addons = Addon.objects.filter(pk__in=addon_ids).no_transforms()
only_missing = kw.get('only_missing', False)
@ -207,8 +231,11 @@ def recreate_theme_previews(addon_ids, **kw):
continue
try:
if only_missing:
with_size = (VersionPreview.objects.filter(version=version)
.exclude(sizes={}).count())
with_size = (
VersionPreview.objects.filter(version=version)
.exclude(sizes={})
.count()
)
if with_size == len(amo.THEME_PREVIEW_SIZES):
continue
log.info('Recreating previews for theme: %s' % addon.id)
@ -235,19 +262,24 @@ def delete_addons(addon_ids, with_deleted=False, **kw):
STATUS_DELETED. *Addon.delete() only does a hard-delete where the Addon
has no versions or files - and has never had any versions or files.
"""
log.info('[%s@%s] %sDeleting addons starting at id: %s...'
% ('Hard ' if with_deleted else '', len(addon_ids),
delete_addons.rate_limit, addon_ids[0]))
log.info(
'[%s@%s] %sDeleting addons starting at id: %s...'
% (
'Hard ' if with_deleted else '',
len(addon_ids),
delete_addons.rate_limit,
addon_ids[0],
)
)
addons = Addon.unfiltered.filter(pk__in=addon_ids).no_transforms()
if with_deleted:
with transaction.atomic():
# Stop any of these guids from being reused
addon_guids = list(
addons.exclude(guid=None).values_list('guid', flat=True))
addon_guids = list(addons.exclude(guid=None).values_list('guid', flat=True))
denied = [
DeniedGuid(
guid=guid, comments='Hard deleted with delete_addons task')
for guid in addon_guids]
DeniedGuid(guid=guid, comments='Hard deleted with delete_addons task')
for guid in addon_guids
]
DeniedGuid.objects.bulk_create(denied, ignore_conflicts=True)
# Call QuerySet.delete rather than Addon.delete.
addons.delete()
@ -273,8 +305,11 @@ def update_addon_hotness(averages):
# See: https://github.com/mozilla/addons-server/issues/15525
if not average:
log.error('Averages not found for addon with id=%s and GUID=%s.',
addon.id, addon.guid)
log.error(
'Averages not found for addon with id=%s and GUID=%s.',
addon.id,
addon.guid,
)
continue
this = average['avg_this_week']
@ -302,8 +337,12 @@ def update_addon_weekly_downloads(data):
except Addon.DoesNotExist:
# The processing input comes from metrics which might be out of
# date in regards to currently existing add-ons.
log.info('Got a weekly_downloads update (%s) but the add-on '
'doesn\'t exist (hashed_guid=%s).', count, hashed_guid)
log.info(
'Got a weekly_downloads update (%s) but the add-on '
'doesn\'t exist (hashed_guid=%s).',
count,
hashed_guid,
)
continue
addon.update(weekly_downloads=int(float(count)))

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

@ -6,9 +6,14 @@ from django_jinja import library
@library.global_function
@library.render_with('addons/impala/listing/sorter.html')
@jinja2.contextfunction
def impala_addon_listing_header(context, url_base, sort_opts=None,
selected=None, extra_sort_opts=None,
search_filter=None):
def impala_addon_listing_header(
context,
url_base,
sort_opts=None,
selected=None,
extra_sort_opts=None,
search_filter=None,
):
if sort_opts is None:
sort_opts = {}
if extra_sort_opts is None:

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

@ -4,17 +4,20 @@ from pyquery import PyQuery as pq
from django.conf import settings
from django.contrib import admin
from django.contrib.messages.storage import (
default_storage as default_messages_storage)
from django.contrib.messages.storage import default_storage as default_messages_storage
from django.core import mail
from olympia import amo, core
from olympia.activity.models import ActivityLog
from olympia.addons.admin import AddonAdmin, ReplacementAddonAdmin
from olympia.addons.models import (
Addon, AddonRegionalRestrictions, ReplacementAddon)
from olympia.addons.models import Addon, AddonRegionalRestrictions, ReplacementAddon
from olympia.amo.tests import (
TestCase, addon_factory, collection_factory, user_factory, version_factory)
TestCase,
addon_factory,
collection_factory,
user_factory,
version_factory,
)
from olympia.amo.urlresolvers import django_reverse, reverse
from olympia.blocklist.models import Block
@ -22,59 +25,69 @@ from olympia.blocklist.models import Block
class TestReplacementAddonForm(TestCase):
def test_valid_addon(self):
addon_factory(slug='bar')
form = ReplacementAddonAdmin(
ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': '/addon/bar/'})
form = ReplacementAddonAdmin(ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': '/addon/bar/'}
)
assert form.is_valid(), form.errors
assert form.cleaned_data['path'] == '/addon/bar/'
def test_invalid(self):
form = ReplacementAddonAdmin(
ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': '/invalid_url/'})
form = ReplacementAddonAdmin(ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': '/invalid_url/'}
)
assert not form.is_valid()
def test_valid_collection(self):
bagpuss = user_factory(username='bagpuss')
collection_factory(slug='stuff', author=bagpuss)
form = ReplacementAddonAdmin(
ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': '/collections/bagpuss/stuff/'})
form = ReplacementAddonAdmin(ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': '/collections/bagpuss/stuff/'}
)
assert form.is_valid(), form.errors
assert form.cleaned_data['path'] == '/collections/bagpuss/stuff/'
def test_url(self):
form = ReplacementAddonAdmin(
ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': 'https://google.com/'})
form = ReplacementAddonAdmin(ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': 'https://google.com/'}
)
assert form.is_valid()
assert form.cleaned_data['path'] == 'https://google.com/'
def test_invalid_urls(self):
assert not ReplacementAddonAdmin(
ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': 'ftp://google.com/'}).is_valid()
assert not ReplacementAddonAdmin(
ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': 'https://88999@~'}).is_valid()
assert not ReplacementAddonAdmin(
ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': 'https://www. rutrt/'}).is_valid()
assert (
not ReplacementAddonAdmin(ReplacementAddon, admin.site)
.get_form(None)({'guid': 'foo', 'path': 'ftp://google.com/'})
.is_valid()
)
assert (
not ReplacementAddonAdmin(ReplacementAddon, admin.site)
.get_form(None)({'guid': 'foo', 'path': 'https://88999@~'})
.is_valid()
)
assert (
not ReplacementAddonAdmin(ReplacementAddon, admin.site)
.get_form(None)({'guid': 'foo', 'path': 'https://www. rutrt/'})
.is_valid()
)
path = '/addon/bar/'
site = settings.SITE_URL
full_url = site + path
# path is okay
assert ReplacementAddonAdmin(
ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': path}).is_valid()
assert (
ReplacementAddonAdmin(ReplacementAddon, admin.site)
.get_form(None)({'guid': 'foo', 'path': path})
.is_valid()
)
# but we don't allow full urls for AMO paths
form = ReplacementAddonAdmin(
ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': full_url})
form = ReplacementAddonAdmin(ReplacementAddon, admin.site).get_form(None)(
{'guid': 'foo', 'path': full_url}
)
assert not form.is_valid()
assert ('Paths for [%s] should be relative, not full URLs including '
'the domain name' % site in form.errors['path'])
assert (
'Paths for [%s] should be relative, not full URLs including '
'the domain name' % site in form.errors['path']
)
class TestAddonAdmin(TestCase):
@ -143,9 +156,7 @@ class TestAddonAdmin(TestCase):
def test_can_edit_with_addons_edit_permission(self):
addon = addon_factory(guid='@foo')
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.client.login(email=user.email)
@ -206,13 +217,19 @@ class TestAddonAdmin(TestCase):
response = self.client.get(detail_url, follow=True)
content = response.content.decode('utf-8')
assert 'Reviewer Tools (listed)' in content
assert ('http://testserver{}'.format(
reverse('reviewers.review', args=('listed', addon.pk))
) in content)
assert (
'http://testserver{}'.format(
reverse('reviewers.review', args=('listed', addon.pk))
)
in content
)
assert 'Reviewer Tools (unlisted)' in content
assert ('http://testserver{}'.format(
reverse('reviewers.review', args=('unlisted', addon.pk))
) in content)
assert (
'http://testserver{}'.format(
reverse('reviewers.review', args=('unlisted', addon.pk))
)
in content
)
def test_can_not_list_without_addons_edit_permission(self):
addon = addon_factory()
@ -224,9 +241,7 @@ class TestAddonAdmin(TestCase):
def test_can_not_edit_without_addons_edit_permission(self):
addon = addon_factory(guid='@foo')
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.client.login(email=user.email)
response = self.client.get(self.detail_url, follow=True)
@ -254,12 +269,8 @@ class TestAddonAdmin(TestCase):
def test_access_using_slug(self):
addon = addon_factory(guid='@foo')
detail_url_by_slug = reverse(
'admin:addons_addon_change', args=(addon.slug,)
)
detail_url_final = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
detail_url_by_slug = reverse('admin:addons_addon_change', args=(addon.slug,))
detail_url_final = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.client.login(email=user.email)
@ -268,12 +279,8 @@ class TestAddonAdmin(TestCase):
def test_access_using_guid(self):
addon = addon_factory(guid='@foo')
detail_url_by_guid = reverse(
'admin:addons_addon_change', args=(addon.guid,)
)
detail_url_final = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
detail_url_by_guid = reverse('admin:addons_addon_change', args=(addon.guid,))
detail_url_final = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.client.login(email=user.email)
@ -283,9 +290,7 @@ class TestAddonAdmin(TestCase):
def test_can_edit_deleted_addon(self):
addon = addon_factory(guid='@foo')
addon.delete()
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.client.login(email=user.email)
@ -318,7 +323,6 @@ class TestAddonAdmin(TestCase):
'addonuser_set-0-role': amo.AUTHOR_ROLE_OWNER,
'addonuser_set-0-listed': 'on',
'addonuser_set-0-position': 0,
'files-TOTAL_FORMS': 1,
'files-INITIAL_FORMS': 1,
'files-MIN_NUM_FORMS': 0,
@ -331,9 +335,7 @@ class TestAddonAdmin(TestCase):
addon = addon_factory(guid='@foo', users=[user_factory()])
file = addon.current_version.all_files[0]
addonuser = addon.addonuser_set.get()
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.grant_permission(user, 'Admin:Advanced')
@ -342,11 +344,13 @@ class TestAddonAdmin(TestCase):
assert response.status_code == 200
assert addon.guid in response.content.decode('utf-8')
post_data = self._get_full_post_data(addon, addonuser)
post_data.update(**{
'type': amo.ADDON_STATICTHEME, # update it.
'addonuser_set-0-user': user.pk, # Different user than initial.
'files-0-status': amo.STATUS_AWAITING_REVIEW, # Different status.
})
post_data.update(
**{
'type': amo.ADDON_STATICTHEME, # update it.
'addonuser_set-0-user': user.pk, # Different user than initial.
'files-0-status': amo.STATUS_AWAITING_REVIEW, # Different status.
}
)
response = self.client.post(self.detail_url, post_data, follow=True)
assert response.status_code == 200
addon.reload()
@ -360,9 +364,7 @@ class TestAddonAdmin(TestCase):
addon = addon_factory(guid='@foo', users=[user_factory()])
file = addon.current_version.all_files[0]
addonuser = addon.addonuser_set.get()
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.client.login(email=user.email)
@ -371,11 +373,13 @@ class TestAddonAdmin(TestCase):
assert addon.guid in response.content.decode('utf-8')
post_data = self._get_full_post_data(addon, addonuser)
post_data.update(**{
'type': amo.ADDON_STATICTHEME, # update it.
'addonuser_set-0-user': user.pk, # Different user than initial.
'files-0-status': amo.STATUS_AWAITING_REVIEW, # Different status.
})
post_data.update(
**{
'type': amo.ADDON_STATICTHEME, # update it.
'addonuser_set-0-user': user.pk, # Different user than initial.
'files-0-status': amo.STATUS_AWAITING_REVIEW, # Different status.
}
)
response = self.client.post(self.detail_url, post_data, follow=True)
assert response.status_code == 200
addon.reload()
@ -388,12 +392,11 @@ class TestAddonAdmin(TestCase):
def test_can_manage_unlisted_versions_and_change_addon_status(self):
addon = addon_factory(guid='@foo', users=[user_factory()])
unlisted_version = version_factory(
addon=addon, channel=amo.RELEASE_CHANNEL_UNLISTED)
addon=addon, channel=amo.RELEASE_CHANNEL_UNLISTED
)
listed_version = addon.current_version
addonuser = addon.addonuser_set.get()
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.grant_permission(user, 'Admin:Advanced')
@ -403,24 +406,28 @@ class TestAddonAdmin(TestCase):
assert addon.guid in response.content.decode('utf-8')
doc = pq(response.content)
assert doc('#id_files-0-id').attr('value') == str(
unlisted_version.all_files[0].id)
unlisted_version.all_files[0].id
)
assert doc('#id_files-1-id').attr('value') == str(
addon.current_version.all_files[0].id)
addon.current_version.all_files[0].id
)
# pagination links aren't shown for less than page size (30) files.
next_url = self.detail_url + '?page=2'
assert next_url not in response.content.decode('utf-8')
post_data = self._get_full_post_data(addon, addonuser)
post_data.update(**{
'status': amo.STATUS_DISABLED,
'files-TOTAL_FORMS': 2,
'files-INITIAL_FORMS': 2,
'files-0-id': unlisted_version.all_files[0].pk,
'files-0-status': amo.STATUS_DISABLED,
'files-1-id': listed_version.all_files[0].pk,
'files-1-status': amo.STATUS_AWAITING_REVIEW, # Different status.
})
post_data.update(
**{
'status': amo.STATUS_DISABLED,
'files-TOTAL_FORMS': 2,
'files-INITIAL_FORMS': 2,
'files-0-id': unlisted_version.all_files[0].pk,
'files-0-status': amo.STATUS_DISABLED,
'files-1-id': listed_version.all_files[0].pk,
'files-1-status': amo.STATUS_AWAITING_REVIEW, # Different status.
}
)
# Confirm the original statuses so we know they're actually changing.
assert addon.status != amo.STATUS_DISABLED
assert listed_version.all_files[0].status != amo.STATUS_AWAITING_REVIEW
@ -430,8 +437,7 @@ class TestAddonAdmin(TestCase):
assert response.status_code == 200
addon.reload()
assert addon.status == amo.STATUS_DISABLED
assert ActivityLog.objects.filter(
action=amo.LOG.CHANGE_STATUS.id).exists()
assert ActivityLog.objects.filter(action=amo.LOG.CHANGE_STATUS.id).exists()
listed_version = addon.versions.get(id=listed_version.id)
assert listed_version.all_files[0].status == amo.STATUS_AWAITING_REVIEW
unlisted_version = addon.versions.get(id=unlisted_version.id)
@ -440,9 +446,7 @@ class TestAddonAdmin(TestCase):
def test_status_cannot_change_for_deleted_version(self):
addon = addon_factory(guid='@foo', users=[user_factory()])
file = addon.current_version.all_files[0]
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.grant_permission(user, 'Admin:Advanced')
@ -453,11 +457,12 @@ class TestAddonAdmin(TestCase):
response = self.client.get(self.detail_url, follow=True)
assert response.status_code == 200
assert f'{file.version} - Deleted' in response.content.decode('utf-8')
assert 'disabled' in (
pq(response.content)('#id_files-0-status')[0].attrib)
post_data.update(**{
'files-0-status': amo.STATUS_AWAITING_REVIEW, # Different status.
})
assert 'disabled' in (pq(response.content)('#id_files-0-status')[0].attrib)
post_data.update(
**{
'files-0-status': amo.STATUS_AWAITING_REVIEW, # Different status.
}
)
response = self.client.post(self.detail_url, post_data, follow=True)
assert response.status_code == 200
file.reload()
@ -465,9 +470,7 @@ class TestAddonAdmin(TestCase):
def test_block_status(self):
addon = addon_factory(guid='@foo', users=[user_factory()])
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,)
)
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.grant_permission(user, 'Admin:Advanced')
@ -478,21 +481,20 @@ class TestAddonAdmin(TestCase):
assert 'Blocked' not in response.content.decode('utf-8')
block = Block.objects.create(
addon=addon,
min_version=addon.current_version.version,
updated_by=user)
addon=addon, min_version=addon.current_version.version, updated_by=user
)
response = self.client.get(self.detail_url, follow=True)
assert response.status_code == 200
assert f'Blocked ({addon.current_version.version} - *)' in (
response.content.decode('utf-8'))
response.content.decode('utf-8')
)
link = pq(response.content)('.field-version__is_blocked a')[0]
assert link.attrib['href'] == block.get_admin_url_path()
def test_query_count(self):
addon = addon_factory(guid='@foo', users=[user_factory()])
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,))
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.grant_permission(user, 'Admin:Advanced')
@ -515,8 +517,7 @@ class TestAddonAdmin(TestCase):
addon = addon_factory(users=[user_factory()])
first_file = addon.current_version.all_files[0]
[version_factory(addon=addon) for i in range(0, 30)]
self.detail_url = reverse(
'admin:addons_addon_change', args=(addon.pk,))
self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.grant_permission(user, 'Admin:Advanced')
@ -532,14 +533,14 @@ class TestAddonAdmin(TestCase):
assert addon.guid in response.content.decode('utf-8')
assert len(pq(response.content)('.field-version__version')) == 1
assert pq(response.content)('#id_files-0-id')[0].attrib['value'] == (
str(first_file.id))
str(first_file.id)
)
def test_git_extract_action(self):
addon1 = addon_factory()
addon2 = addon_factory()
addons = Addon.objects.filter(
pk__in=(addon1.pk, addon2.pk))
addons = Addon.objects.filter(pk__in=(addon1.pk, addon2.pk))
addon_admin = AddonAdmin(Addon, admin.site)
request = RequestFactory().get('/')
request.user = user_factory()
@ -554,8 +555,7 @@ class TestAddonAdmin(TestCase):
def test_git_extract_button_in_change_view(self):
addon = addon_factory()
git_extract_url = reverse(
'admin:addons_git_extract', args=(addon.pk, ))
git_extract_url = reverse('admin:addons_git_extract', args=(addon.pk,))
detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
@ -567,10 +567,10 @@ class TestAddonAdmin(TestCase):
def test_git_extract(self):
addon = addon_factory()
git_extract_url = reverse(
'admin:addons_git_extract', args=(addon.pk, ))
git_extract_url = reverse('admin:addons_git_extract', args=(addon.pk,))
wrong_git_extract_url = reverse(
'admin:addons_git_extract', args=(addon.pk + 9, ))
'admin:addons_git_extract', args=(addon.pk + 9,)
)
detail_url = reverse('admin:addons_addon_change', args=(addon.pk,))
user = user_factory(email='someone@mozilla.com')
self.client.login(email=user.email)
@ -600,7 +600,8 @@ class TestReplacementAddonList(TestCase):
model_admin = ReplacementAddonAdmin(ReplacementAddon, admin.site)
self.assertEqual(
list(model_admin.get_list_display(None)),
['guid', 'path', 'guid_slug', '_url'])
['guid', 'path', 'guid_slug', '_url'],
)
def test_can_see_replacementaddon_module_in_admin_with_addons_edit(self):
user = user_factory(email='someone@mozilla.com')
@ -612,8 +613,7 @@ class TestReplacementAddonList(TestCase):
# Use django's reverse, since that's what the admin will use. Using our
# own would fail the assertion because of the locale that gets added.
self.list_url = django_reverse(
'admin:addons_replacementaddon_changelist')
self.list_url = django_reverse('admin:addons_replacementaddon_changelist')
assert self.list_url in response.content.decode('utf-8')
def test_can_see_replacementaddon_module_in_admin_with_admin_curate(self):
@ -626,13 +626,11 @@ class TestReplacementAddonList(TestCase):
# Use django's reverse, since that's what the admin will use. Using our
# own would fail the assertion because of the locale that gets added.
self.list_url = django_reverse(
'admin:addons_replacementaddon_changelist')
self.list_url = django_reverse('admin:addons_replacementaddon_changelist')
assert self.list_url in response.content.decode('utf-8')
def test_can_list_with_addons_edit_permission(self):
ReplacementAddon.objects.create(
guid='@bar', path='/addon/bar-replacement/')
ReplacementAddon.objects.create(guid='@bar', path='/addon/bar-replacement/')
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, 'Addons:Edit')
self.client.login(email=user.email)
@ -642,7 +640,8 @@ class TestReplacementAddonList(TestCase):
def test_can_not_edit_with_addons_edit_permission(self):
replacement = ReplacementAddon.objects.create(
guid='@bar', path='/addon/bar-replacement/')
guid='@bar', path='/addon/bar-replacement/'
)
self.detail_url = reverse(
'admin:addons_replacementaddon_change', args=(replacement.pk,)
)
@ -652,13 +651,14 @@ class TestReplacementAddonList(TestCase):
response = self.client.get(self.detail_url, follow=True)
assert response.status_code == 403
response = self.client.post(
self.detail_url, {'guid': '@bar', 'path': replacement.path},
follow=True)
self.detail_url, {'guid': '@bar', 'path': replacement.path}, follow=True
)
assert response.status_code == 403
def test_can_not_delete_with_addons_edit_permission(self):
replacement = ReplacementAddon.objects.create(
guid='@foo', path='/addon/foo-replacement/')
guid='@foo', path='/addon/foo-replacement/'
)
self.delete_url = reverse(
'admin:addons_replacementaddon_delete', args=(replacement.pk,)
)
@ -667,14 +667,14 @@ class TestReplacementAddonList(TestCase):
self.client.login(email=user.email)
response = self.client.get(self.delete_url, follow=True)
assert response.status_code == 403
response = self.client.post(
self.delete_url, data={'post': 'yes'}, follow=True)
response = self.client.post(self.delete_url, data={'post': 'yes'}, follow=True)
assert response.status_code == 403
assert ReplacementAddon.objects.filter(pk=replacement.pk).exists()
def test_can_edit_with_admin_curation_permission(self):
replacement = ReplacementAddon.objects.create(
guid='@foo', path='/addon/foo-replacement/')
guid='@foo', path='/addon/foo-replacement/'
)
self.detail_url = reverse(
'admin:addons_replacementaddon_change', args=(replacement.pk,)
)
@ -686,15 +686,16 @@ class TestReplacementAddonList(TestCase):
assert '/addon/foo-replacement/' in response.content.decode('utf-8')
response = self.client.post(
self.detail_url, {'guid': '@bar', 'path': replacement.path},
follow=True)
self.detail_url, {'guid': '@bar', 'path': replacement.path}, follow=True
)
assert response.status_code == 200
replacement.reload()
assert replacement.guid == '@bar'
def test_can_delete_with_admin_curation_permission(self):
replacement = ReplacementAddon.objects.create(
guid='@foo', path='/addon/foo-replacement/')
guid='@foo', path='/addon/foo-replacement/'
)
self.delete_url = reverse(
'admin:addons_replacementaddon_delete', args=(replacement.pk,)
)
@ -703,8 +704,7 @@ class TestReplacementAddonList(TestCase):
self.client.login(email=user.email)
response = self.client.get(self.delete_url, follow=True)
assert response.status_code == 200
response = self.client.post(
self.delete_url, data={'post': 'yes'}, follow=True)
response = self.client.post(self.delete_url, data={'post': 'yes'}, follow=True)
assert response.status_code == 200
assert not ReplacementAddon.objects.filter(pk=replacement.pk).exists()
@ -718,8 +718,10 @@ class TestReplacementAddonList(TestCase):
assert response.status_code == 200
assert '@foofoo&amp;foo' in response.content.decode('utf-8')
assert '/addon/bar/' in response.content.decode('utf-8')
test_url = str('<a href="%s">Test</a>' % (
reverse('addons.find_replacement') + '?guid=%40foofoo%26foo'))
test_url = str(
'<a href="%s">Test</a>'
% (reverse('addons.find_replacement') + '?guid=%40foofoo%26foo')
)
assert test_url in response.content.decode('utf-8')
# guid is not on AMO so no slug to show
assert '- Add-on not on AMO -' in response.content.decode('utf-8')
@ -732,8 +734,7 @@ class TestReplacementAddonList(TestCase):
class TestAddonRegionalRestrictionsAdmin(TestCase):
def setUp(self):
self.list_url = reverse(
'admin:addons_addonregionalrestrictions_changelist')
self.list_url = reverse('admin:addons_addonregionalrestrictions_changelist')
user = user_factory(email='someone@mozilla.com')
self.grant_permission(user, '*:*')
self.client.login(email=user.email)
@ -746,12 +747,14 @@ class TestAddonRegionalRestrictionsAdmin(TestCase):
# Use django's reverse, since that's what the admin will use. Using our
# own would fail the assertion because of the locale that gets added.
self.list_url = django_reverse(
'admin:addons_addonregionalrestrictions_changelist')
'admin:addons_addonregionalrestrictions_changelist'
)
assert self.list_url in response.content.decode('utf-8')
def test_can_list(self):
AddonRegionalRestrictions.objects.create(
addon=addon_factory(name='éléphant'), excluded_regions=['fr-FR'])
addon=addon_factory(name='éléphant'), excluded_regions=['fr-FR']
)
response = self.client.get(self.list_url, follow=True)
assert response.status_code == 200
assert b'fr-FR' in response.content
@ -766,28 +769,31 @@ class TestAddonRegionalRestrictionsAdmin(TestCase):
assert pq(response.content)('#id_addon') # addon input is editable
response = self.client.post(
self.add_url, {
self.add_url,
{
'excluded_regions': '["DE", "br"]', # should get uppercased
'addon': addon.id},
follow=True)
'addon': addon.id,
},
follow=True,
)
assert response.status_code == 200
restriction = AddonRegionalRestrictions.objects.get(addon=addon)
assert restriction.excluded_regions == ["DE", "BR"]
assert len(mail.outbox) == 1
assert mail.outbox[0].subject == (
'Regional Restriction added for Add-on')
assert mail.outbox[0].subject == ('Regional Restriction added for Add-on')
assert mail.outbox[0].body == (
f'Regional restriction for addon "Thíng" '
f"[{restriction.addon.id}] added: ['DE', 'BR']")
f"[{restriction.addon.id}] added: ['DE', 'BR']"
)
assert mail.outbox[0].to == ['amo-admins@mozilla.com']
def test_can_edit(self):
addon = addon_factory(name='Thíng')
restriction = AddonRegionalRestrictions.objects.create(
addon=addon, excluded_regions=['FR'])
addon=addon, excluded_regions=['FR']
)
self.detail_url = reverse(
'admin:addons_addonregionalrestrictions_change',
args=(restriction.pk,)
'admin:addons_addonregionalrestrictions_change', args=(restriction.pk,)
)
response = self.client.get(self.detail_url, follow=True)
assert response.status_code == 200
@ -795,40 +801,42 @@ class TestAddonRegionalRestrictionsAdmin(TestCase):
assert not pq(response.content)('#id_addon') # addon is readonly
response = self.client.post(
self.detail_url, {
'excluded_regions': '["de", "BR"]', # should get uppercased
self.detail_url,
{
'excluded_regions': '["de", "BR"]', # should get uppercased
# try to change the addon too
'addon': addon_factory().id},
follow=True)
'addon': addon_factory().id,
},
follow=True,
)
assert response.status_code == 200
restriction.reload()
assert restriction.excluded_regions == ["DE", "BR"]
assert restriction.addon == addon # didn't change
assert restriction.addon == addon # didn't change
assert len(mail.outbox) == 1
assert mail.outbox[0].subject == (
'Regional Restriction changed for Add-on')
assert mail.outbox[0].subject == ('Regional Restriction changed for Add-on')
assert mail.outbox[0].body == (
f'Regional restriction for addon "Thíng" '
f"[{restriction.addon.id}] changed: ['DE', 'BR']")
f"[{restriction.addon.id}] changed: ['DE', 'BR']"
)
assert mail.outbox[0].to == ['amo-admins@mozilla.com']
def test_can_delete(self):
restriction = AddonRegionalRestrictions.objects.create(
addon=addon_factory(name='Thíng'), excluded_regions=['FR'])
addon=addon_factory(name='Thíng'), excluded_regions=['FR']
)
self.delete_url = reverse(
'admin:addons_addonregionalrestrictions_delete',
args=(restriction.pk,)
'admin:addons_addonregionalrestrictions_delete', args=(restriction.pk,)
)
response = self.client.get(self.delete_url, follow=True)
assert response.status_code == 200
response = self.client.post(
self.delete_url, data={'post': 'yes'}, follow=True)
response = self.client.post(self.delete_url, data={'post': 'yes'}, follow=True)
assert response.status_code == 200
assert not AddonRegionalRestrictions.objects.exists()
assert len(mail.outbox) == 1
assert mail.outbox[0].subject == (
'Regional Restriction deleted for Add-on')
assert mail.outbox[0].subject == ('Regional Restriction deleted for Add-on')
assert mail.outbox[0].body == (
f'Regional restriction for addon "Thíng" '
f"[{restriction.addon.id}] deleted: ['FR']")
f"[{restriction.addon.id}] deleted: ['FR']"
)
assert mail.outbox[0].to == ['amo-admins@mozilla.com']

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

@ -12,17 +12,17 @@ import pytest
from olympia import amo
from olympia.addons.management.commands import (
fix_langpacks_with_max_version_star, process_addons)
fix_langpacks_with_max_version_star,
process_addons,
)
from olympia.addons.models import Addon, DeniedGuid
from olympia.abuse.models import AbuseReport
from olympia.amo.tests import (
TestCase, addon_factory, user_factory, version_factory)
from olympia.amo.tests import TestCase, addon_factory, user_factory, version_factory
from olympia.applications.models import AppVersion
from olympia.files.models import FileValidation, WebextPermission
from olympia.ratings.models import Rating
from olympia.reviewers.models import AutoApprovalSummary
from olympia.versions.models import (
ApplicationsVersions, Version, VersionPreview)
from olympia.versions.models import ApplicationsVersions, Version, VersionPreview
def id_function(fixture_value):
@ -38,16 +38,21 @@ def id_function(fixture_value):
unreviewed.
"""
addon_status, file_status, review_type = fixture_value
return '{0}-{1}-{2}'.format(amo.STATUS_CHOICES_API[addon_status],
amo.STATUS_CHOICES_API[file_status],
review_type)
return '{0}-{1}-{2}'.format(
amo.STATUS_CHOICES_API[addon_status],
amo.STATUS_CHOICES_API[file_status],
review_type,
)
@pytest.fixture(
params=[(amo.STATUS_NOMINATED, amo.STATUS_AWAITING_REVIEW, 'full'),
(amo.STATUS_APPROVED, amo.STATUS_AWAITING_REVIEW, 'full')],
params=[
(amo.STATUS_NOMINATED, amo.STATUS_AWAITING_REVIEW, 'full'),
(amo.STATUS_APPROVED, amo.STATUS_AWAITING_REVIEW, 'full'),
],
# ids are used to build better names for the tests using this fixture.
ids=id_function)
ids=id_function,
)
def use_case(request, db):
"""This fixture will return quadruples for different use cases.
@ -108,9 +113,7 @@ def count_subtask_calls(original_function):
@pytest.mark.django_db
def test_process_addons_limit_addons():
user_factory(id=settings.TASK_USER_ID)
addon_ids = [
addon_factory(status=amo.STATUS_APPROVED).id for _ in range(5)
]
addon_ids = [addon_factory(status=amo.STATUS_APPROVED).id for _ in range(5)]
assert Addon.objects.count() == 5
with count_subtask_calls(process_addons.sign_addons) as calls:
@ -127,9 +130,7 @@ def test_process_addons_limit_addons():
@pytest.mark.django_db
@mock.patch.object(process_addons.Command, 'get_pks')
def test_process_addons_batch_size(mock_get_pks):
addon_ids = [
random.randrange(1000) for _ in range(101)
]
addon_ids = [random.randrange(1000) for _ in range(101)]
mock_get_pks.return_value = addon_ids
with count_subtask_calls(process_addons.recreate_previews) as calls:
@ -139,9 +140,7 @@ def test_process_addons_batch_size(mock_get_pks):
assert calls[1]['kwargs']['args'] == [addon_ids[100:]]
with count_subtask_calls(process_addons.recreate_previews) as calls:
call_command(
'process_addons', task='recreate_previews',
**{'batch_size': 50})
call_command('process_addons', task='recreate_previews', **{'batch_size': 50})
assert len(calls) == 3
assert calls[0]['kwargs']['args'] == [addon_ids[:50]]
assert calls[1]['kwargs']['args'] == [addon_ids[50:100]]
@ -151,40 +150,36 @@ def test_process_addons_batch_size(mock_get_pks):
class TestAddDynamicThemeTagForThemeApiCommand(TestCase):
def test_affects_only_public_webextensions(self):
addon_factory()
addon_factory(file_kw={'is_webextension': True,
'status': amo.STATUS_AWAITING_REVIEW},
status=amo.STATUS_NOMINATED)
addon_factory(
file_kw={'is_webextension': True, 'status': amo.STATUS_AWAITING_REVIEW},
status=amo.STATUS_NOMINATED,
)
public_webextension = addon_factory(file_kw={'is_webextension': True})
with count_subtask_calls(
process_addons.add_dynamic_theme_tag) as calls:
call_command(
'process_addons', task='add_dynamic_theme_tag_for_theme_api')
with count_subtask_calls(process_addons.add_dynamic_theme_tag) as calls:
call_command('process_addons', task='add_dynamic_theme_tag_for_theme_api')
assert len(calls) == 1
assert calls[0]['kwargs']['args'] == [
[public_webextension.pk]
]
assert calls[0]['kwargs']['args'] == [[public_webextension.pk]]
def test_tag_added_for_is_dynamic_theme(self):
addon = addon_factory(file_kw={'is_webextension': True})
WebextPermission.objects.create(
file=addon.current_version.all_files[0],
permissions=['theme'])
file=addon.current_version.all_files[0], permissions=['theme']
)
assert addon.tags.all().count() == 0
# Add some more that shouldn't be tagged
no_perms = addon_factory(file_kw={'is_webextension': True})
not_a_theme = addon_factory(file_kw={'is_webextension': True})
WebextPermission.objects.create(
file=not_a_theme.current_version.all_files[0],
permissions=['downloads'])
file=not_a_theme.current_version.all_files[0], permissions=['downloads']
)
call_command(
'process_addons', task='add_dynamic_theme_tag_for_theme_api')
call_command('process_addons', task='add_dynamic_theme_tag_for_theme_api')
assert (
list(addon.tags.all().values_list('tag_text', flat=True)) ==
[u'dynamic theme'])
assert list(addon.tags.all().values_list('tag_text', flat=True)) == [
u'dynamic theme'
]
assert not no_perms.tags.all().exists()
assert not not_a_theme.tags.all().exists()
@ -198,14 +193,15 @@ class RecalculateWeightTestCase(TestCase):
# Non auto-approved add-on that has an AutoApprovalSummary entry,
# should not be considered.
AutoApprovalSummary.objects.create(
version=addon_factory().current_version,
verdict=amo.NOT_AUTO_APPROVED)
version=addon_factory().current_version, verdict=amo.NOT_AUTO_APPROVED
)
# Add-on with the current version not auto-approved, should not be
# considered.
extra_addon = addon_factory()
AutoApprovalSummary.objects.create(
version=extra_addon.current_version, verdict=amo.AUTO_APPROVED)
version=extra_addon.current_version, verdict=amo.AUTO_APPROVED
)
extra_addon.current_version.update(created=self.days_ago(1))
version_factory(addon=extra_addon)
@ -214,25 +210,26 @@ class RecalculateWeightTestCase(TestCase):
already_confirmed_addon = addon_factory()
AutoApprovalSummary.objects.create(
version=already_confirmed_addon.current_version,
verdict=amo.AUTO_APPROVED, confirmed=True)
verdict=amo.AUTO_APPROVED,
confirmed=True,
)
# Add-on that should be considered because it's current version is
# auto-approved.
auto_approved_addon = addon_factory()
AutoApprovalSummary.objects.create(
version=auto_approved_addon.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon.current_version, verdict=amo.AUTO_APPROVED
)
# Add some extra versions that should not have an impact.
version_factory(
addon=auto_approved_addon,
file_kw={'status': amo.STATUS_AWAITING_REVIEW})
version_factory(
addon=auto_approved_addon, channel=amo.RELEASE_CHANNEL_UNLISTED)
addon=auto_approved_addon, file_kw={'status': amo.STATUS_AWAITING_REVIEW}
)
version_factory(addon=auto_approved_addon, channel=amo.RELEASE_CHANNEL_UNLISTED)
with count_subtask_calls(
process_addons.recalculate_post_review_weight) as calls:
call_command(
'process_addons', task='recalculate_post_review_weight')
process_addons.recalculate_post_review_weight
) as calls:
call_command('process_addons', task='recalculate_post_review_weight')
assert len(calls) == 1
assert calls[0]['kwargs']['args'] == [[auto_approved_addon.pk]]
@ -240,14 +237,15 @@ class RecalculateWeightTestCase(TestCase):
def test_task_works_correctly(self):
addon = addon_factory(average_daily_users=100000)
FileValidation.objects.create(
file=addon.current_version.all_files[0], validation=u'{}')
file=addon.current_version.all_files[0], validation=u'{}'
)
addon = Addon.objects.get(pk=addon.pk)
summary = AutoApprovalSummary.objects.create(
version=addon.current_version, verdict=amo.AUTO_APPROVED)
version=addon.current_version, verdict=amo.AUTO_APPROVED
)
assert summary.weight == 0
call_command(
'process_addons', task='recalculate_post_review_weight')
call_command('process_addons', task='recalculate_post_review_weight')
summary.reload()
# Weight should be 10 because of average_daily_users / 10000.
@ -262,13 +260,14 @@ class ConstantlyRecalculateWeightTestCase(TestCase):
# *not considered* - Non auto-approved add-on that has an
# AutoApprovalSummary entry
AutoApprovalSummary.objects.create(
version=addon_factory().current_version,
verdict=amo.NOT_AUTO_APPROVED)
version=addon_factory().current_version, verdict=amo.NOT_AUTO_APPROVED
)
# *not considered* -Add-on with the current version not auto-approved
extra_addon = addon_factory()
AutoApprovalSummary.objects.create(
version=extra_addon.current_version, verdict=amo.AUTO_APPROVED)
version=extra_addon.current_version, verdict=amo.AUTO_APPROVED
)
extra_addon.current_version.update(created=self.days_ago(1))
version_factory(addon=extra_addon)
@ -276,87 +275,99 @@ class ConstantlyRecalculateWeightTestCase(TestCase):
# have recent abuse reports or low ratings
auto_approved_addon = addon_factory()
AutoApprovalSummary.objects.create(
version=auto_approved_addon.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon.current_version, verdict=amo.AUTO_APPROVED
)
# *considered* - current version is auto-approved and
# has a recent rating with rating <= 3
auto_approved_addon1 = addon_factory()
summary = AutoApprovalSummary.objects.create(
version=auto_approved_addon1.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon1.current_version, verdict=amo.AUTO_APPROVED
)
Rating.objects.create(
created=summary.modified + timedelta(days=3),
addon=auto_approved_addon1,
version=auto_approved_addon1.current_version,
rating=2, body='Apocalypse', user=user_factory()),
rating=2,
body='Apocalypse',
user=user_factory(),
),
# *not considered* - current version is auto-approved but
# has a recent rating with rating > 3
auto_approved_addon2 = addon_factory()
summary = AutoApprovalSummary.objects.create(
version=auto_approved_addon2.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon2.current_version, verdict=amo.AUTO_APPROVED
)
Rating.objects.create(
created=summary.modified + timedelta(days=3),
addon=auto_approved_addon2,
version=auto_approved_addon2.current_version,
rating=4, body='Apocalypse', user=user_factory()),
rating=4,
body='Apocalypse',
user=user_factory(),
),
# *not considered* - current version is auto-approved but
# has a recent rating with rating > 3
auto_approved_addon3 = addon_factory()
summary = AutoApprovalSummary.objects.create(
version=auto_approved_addon3.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon3.current_version, verdict=amo.AUTO_APPROVED
)
Rating.objects.create(
created=summary.modified + timedelta(days=3),
addon=auto_approved_addon3,
version=auto_approved_addon3.current_version,
rating=4, body='Apocalypse', user=user_factory()),
rating=4,
body='Apocalypse',
user=user_factory(),
),
# *not considered* - current version is auto-approved but
# has a low rating that isn't recent enough
auto_approved_addon4 = addon_factory()
summary = AutoApprovalSummary.objects.create(
version=auto_approved_addon4.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon4.current_version, verdict=amo.AUTO_APPROVED
)
Rating.objects.create(
created=summary.modified - timedelta(days=3),
addon=auto_approved_addon4,
version=auto_approved_addon4.current_version,
rating=1, body='Apocalypse', user=user_factory()),
rating=1,
body='Apocalypse',
user=user_factory(),
),
# *considered* - current version is auto-approved and
# has a recent abuse report
auto_approved_addon5 = addon_factory()
summary = AutoApprovalSummary.objects.create(
version=auto_approved_addon5.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon5.current_version, verdict=amo.AUTO_APPROVED
)
AbuseReport.objects.create(
addon=auto_approved_addon5,
created=summary.modified + timedelta(days=3))
addon=auto_approved_addon5, created=summary.modified + timedelta(days=3)
)
# *not considered* - current version is auto-approved but
# has an abuse report that isn't recent enough
auto_approved_addon6 = addon_factory()
summary = AutoApprovalSummary.objects.create(
version=auto_approved_addon6.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon6.current_version, verdict=amo.AUTO_APPROVED
)
AbuseReport.objects.create(
addon=auto_approved_addon6,
created=summary.modified - timedelta(days=3))
addon=auto_approved_addon6, created=summary.modified - timedelta(days=3)
)
# *considered* - current version is auto-approved and
# has an abuse report through it's author that is recent enough
author = user_factory()
auto_approved_addon7 = addon_factory(users=[author])
summary = AutoApprovalSummary.objects.create(
version=auto_approved_addon7.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon7.current_version, verdict=amo.AUTO_APPROVED
)
AbuseReport.objects.create(
user=author,
created=summary.modified + timedelta(days=3))
user=author, created=summary.modified + timedelta(days=3)
)
# *not considered* - current version is auto-approved and
# has an abuse report through it's author that is recent enough
@ -364,26 +375,30 @@ class ConstantlyRecalculateWeightTestCase(TestCase):
author = user_factory()
auto_approved_addon8 = addon_factory(users=[author])
summary = AutoApprovalSummary.objects.create(
version=auto_approved_addon8.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon8.current_version, verdict=amo.AUTO_APPROVED
)
AbuseReport.objects.create(
user=author,
state=AbuseReport.STATES.DELETED,
created=summary.modified + timedelta(days=3))
created=summary.modified + timedelta(days=3),
)
# *not considered* - current version is auto-approved and
# has a recent rating with rating <= 3
# but the rating is deleted.
auto_approved_addon9 = addon_factory()
summary = AutoApprovalSummary.objects.create(
version=auto_approved_addon9.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon9.current_version, verdict=amo.AUTO_APPROVED
)
Rating.objects.create(
created=summary.modified + timedelta(days=3),
addon=auto_approved_addon9,
version=auto_approved_addon9.current_version,
deleted=True,
rating=2, body='Apocalypse', user=user_factory()),
rating=2,
body='Apocalypse',
user=user_factory(),
),
# *considered* - current version is auto-approved and
# has an abuse report through it's author that is recent enough
@ -391,39 +406,42 @@ class ConstantlyRecalculateWeightTestCase(TestCase):
# the most recent version
author = user_factory()
auto_approved_addon8 = addon_factory(
users=[author], version_kw={'version': '0.1'})
users=[author], version_kw={'version': '0.1'}
)
AutoApprovalSummary.objects.create(
version=auto_approved_addon8.current_version,
verdict=amo.AUTO_APPROVED)
version=auto_approved_addon8.current_version, verdict=amo.AUTO_APPROVED
)
# Let's create a new `current_version` and summary
current_version = version_factory(
addon=auto_approved_addon8, version='0.2')
current_version = version_factory(addon=auto_approved_addon8, version='0.2')
summary = AutoApprovalSummary.objects.create(
version=current_version,
verdict=amo.AUTO_APPROVED)
version=current_version, verdict=amo.AUTO_APPROVED
)
AbuseReport.objects.create(
user=author,
created=summary.modified + timedelta(days=3))
user=author, created=summary.modified + timedelta(days=3)
)
mod = 'olympia.reviewers.tasks.AutoApprovalSummary.calculate_weight'
with mock.patch(mod) as calc_weight_mock:
with count_subtask_calls(
process_addons.recalculate_post_review_weight) as calls:
process_addons.recalculate_post_review_weight
) as calls:
call_command(
'process_addons',
task='constantly_recalculate_post_review_weight')
'process_addons', task='constantly_recalculate_post_review_weight'
)
assert len(calls) == 1
assert calls[0]['kwargs']['args'] == [[
auto_approved_addon1.pk,
auto_approved_addon5.pk,
auto_approved_addon7.pk,
auto_approved_addon8.pk,
]]
assert calls[0]['kwargs']['args'] == [
[
auto_approved_addon1.pk,
auto_approved_addon5.pk,
auto_approved_addon7.pk,
auto_approved_addon8.pk,
]
]
# Only 4 calls for each add-on, doesn't consider the extra version
# that got created for addon 8
@ -436,14 +454,11 @@ class TestExtractColorsFromStaticThemes(TestCase):
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}
{'h': 4, 's': 8, 'l': 15, 'ratio': 0.16}
]
call_command(
'process_addons', task='extract_colors_from_static_themes')
call_command('process_addons', task='extract_colors_from_static_themes')
preview.reload()
assert preview.colors == [
{'h': 4, 's': 8, 'l': 15, 'ratio': .16}
]
assert preview.colors == [{'h': 4, 's': 8, 'l': 15, 'ratio': 0.16}]
class TestResignAddonsForCose(TestCase):
@ -504,8 +519,7 @@ class TestDeleteObsoleteAddons(TestCase):
DeniedGuid.objects.create(guid=self.xul_theme.guid)
DeniedGuid.objects.all().count() == 1
call_command(
'process_addons', task='delete_obsolete_addons', with_deleted=True)
call_command('process_addons', task='delete_obsolete_addons', with_deleted=True)
assert Addon.unfiltered.count() == 3
assert Addon.unfiltered.get(id=self.extension.id)
@ -525,8 +539,7 @@ class TestDeleteObsoleteAddons(TestCase):
self.test_hard()
def test_normal(self):
call_command(
'process_addons', task='delete_obsolete_addons')
call_command('process_addons', task='delete_obsolete_addons')
assert Addon.unfiltered.count() == 8
assert Addon.objects.count() == 3
@ -538,37 +551,49 @@ class TestDeleteObsoleteAddons(TestCase):
class TestFixLangpacksWithMaxVersionStar(TestCase):
def setUp(self):
addon = addon_factory( # Should autocreate the AppVersions for Firefox
type=amo.ADDON_LPAPP, version_kw={
type=amo.ADDON_LPAPP,
version_kw={
'min_app_version': '77.0',
'max_app_version': '*',
}
},
)
# Add the missing AppVersions for Android, and assign them to the addon
min_android = AppVersion.objects.get_or_create(
application=amo.ANDROID.id, version='77.0')[0]
application=amo.ANDROID.id, version='77.0'
)[0]
max_android_star = AppVersion.objects.get_or_create(
application=amo.ANDROID.id, version='*')[0]
application=amo.ANDROID.id, version='*'
)[0]
ApplicationsVersions.objects.create(
application=amo.ANDROID.id, version=addon.current_version,
min=min_android, max=max_android_star)
application=amo.ANDROID.id,
version=addon.current_version,
min=min_android,
max=max_android_star,
)
addon_factory( # Same kind of langpack, but without android compat.
type=amo.ADDON_LPAPP, version_kw={
type=amo.ADDON_LPAPP,
version_kw={
'min_app_version': '77.0',
'max_app_version': '*',
}
},
)
addon = addon_factory( # Shouldn't be touched, its max is not '*'.
type=amo.ADDON_LPAPP, version_kw={
type=amo.ADDON_LPAPP,
version_kw={
'min_app_version': '77.0',
'max_app_version': '77.*',
}
},
)
max_android = AppVersion.objects.get_or_create(
application=amo.ANDROID.id, version='77.*')[0]
application=amo.ANDROID.id, version='77.*'
)[0]
ApplicationsVersions.objects.create(
application=amo.ANDROID.id, version=addon.current_version,
min=min_android, max=max_android)
application=amo.ANDROID.id,
version=addon.current_version,
min=min_android,
max=max_android,
)
def test_find_affected_langpacks(self):
command = fix_langpacks_with_max_version_star.Command()

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

@ -26,13 +26,13 @@ class TestLastUpdated(TestCase):
"""Make sure the catch-all last_updated is stable and accurate."""
# Nullify all datestatuschanged so the public add-ons hit the
# catch-all.
(File.objects.filter(status=amo.STATUS_APPROVED)
.update(datestatuschanged=None))
(File.objects.filter(status=amo.STATUS_APPROVED).update(datestatuschanged=None))
Addon.objects.update(last_updated=None)
cron.addon_last_updated()
for addon in Addon.objects.filter(status=amo.STATUS_APPROVED,
type=amo.ADDON_EXTENSION):
for addon in Addon.objects.filter(
status=amo.STATUS_APPROVED, type=amo.ADDON_EXTENSION
):
assert addon.last_updated == addon.created
# Make sure it's stable.
@ -53,8 +53,7 @@ class TestLastUpdated(TestCase):
AppSupport.objects.all().delete()
assert AppSupport.objects.filter(addon=3723).count() == 0
cron.update_addon_appsupport()
assert AppSupport.objects.filter(
addon=3723, app=amo.FIREFOX.id).count() == 0
assert AppSupport.objects.filter(addon=3723, app=amo.FIREFOX.id).count() == 0
class TestHideDisabledFiles(TestCase):
@ -64,16 +63,20 @@ class TestHideDisabledFiles(TestCase):
super(TestHideDisabledFiles, self).setUp()
self.addon = Addon.objects.create(type=amo.ADDON_EXTENSION)
self.version = Version.objects.create(addon=self.addon)
self.f1 = File.objects.create(version=self.version, filename='f1',
platform=amo.PLATFORM_ALL.id)
self.f2 = File.objects.create(version=self.version, filename='f2',
platform=amo.PLATFORM_ALL.id)
self.f1 = File.objects.create(
version=self.version, filename='f1', platform=amo.PLATFORM_ALL.id
)
self.f2 = File.objects.create(
version=self.version, filename='f2', platform=amo.PLATFORM_ALL.id
)
@mock.patch('olympia.files.models.os')
def test_leave_nondisabled_files(self, os_mock):
# All these addon/file status pairs should stay.
stati = ((amo.STATUS_APPROVED, amo.STATUS_APPROVED),
(amo.STATUS_APPROVED, amo.STATUS_AWAITING_REVIEW))
stati = (
(amo.STATUS_APPROVED, amo.STATUS_APPROVED),
(amo.STATUS_APPROVED, amo.STATUS_AWAITING_REVIEW),
)
for addon_status, file_status in stati:
self.addon.update(status=addon_status)
File.objects.update(status=file_status)
@ -86,52 +89,44 @@ class TestHideDisabledFiles(TestCase):
def test_move_user_disabled_addon(self, mv_mock):
# Use Addon.objects.update so the signal handler isn't called.
Addon.objects.filter(id=self.addon.id).update(
status=amo.STATUS_APPROVED, disabled_by_user=True)
status=amo.STATUS_APPROVED, disabled_by_user=True
)
File.objects.update(status=amo.STATUS_APPROVED)
cron.hide_disabled_files()
# Check that f2 was moved.
f2 = self.f2
mv_mock.assert_called_with(f2.file_path, f2.guarded_file_path,
self.msg)
mv_mock.assert_called_with(f2.file_path, f2.guarded_file_path, self.msg)
# Check that f1 was moved as well.
f1 = self.f1
mv_mock.call_args = mv_mock.call_args_list[0]
mv_mock.assert_called_with(f1.file_path, f1.guarded_file_path,
self.msg)
mv_mock.assert_called_with(f1.file_path, f1.guarded_file_path, self.msg)
# There's only 2 files, both should have been moved.
assert mv_mock.call_count == 2
@mock.patch('olympia.files.models.File.move_file')
def test_move_admin_disabled_addon(self, mv_mock):
Addon.objects.filter(id=self.addon.id).update(
status=amo.STATUS_DISABLED)
Addon.objects.filter(id=self.addon.id).update(status=amo.STATUS_DISABLED)
File.objects.update(status=amo.STATUS_APPROVED)
cron.hide_disabled_files()
# Check that f2 was moved.
f2 = self.f2
mv_mock.assert_called_with(f2.file_path, f2.guarded_file_path,
self.msg)
mv_mock.assert_called_with(f2.file_path, f2.guarded_file_path, self.msg)
# Check that f1 was moved as well.
f1 = self.f1
mv_mock.call_args = mv_mock.call_args_list[0]
mv_mock.assert_called_with(f1.file_path, f1.guarded_file_path,
self.msg)
mv_mock.assert_called_with(f1.file_path, f1.guarded_file_path, self.msg)
# There's only 2 files, both should have been moved.
assert mv_mock.call_count == 2
@mock.patch('olympia.files.models.File.move_file')
def test_move_disabled_file(self, mv_mock):
Addon.objects.filter(id=self.addon.id).update(
status=amo.STATUS_APPROVED)
File.objects.filter(id=self.f1.id).update(
status=amo.STATUS_DISABLED)
File.objects.filter(id=self.f2.id).update(
status=amo.STATUS_AWAITING_REVIEW)
Addon.objects.filter(id=self.addon.id).update(status=amo.STATUS_APPROVED)
File.objects.filter(id=self.f1.id).update(status=amo.STATUS_DISABLED)
File.objects.filter(id=self.f2.id).update(status=amo.STATUS_AWAITING_REVIEW)
cron.hide_disabled_files()
# Only f1 should have been moved.
f1 = self.f1
mv_mock.assert_called_with(f1.file_path, f1.guarded_file_path,
self.msg)
mv_mock.assert_called_with(f1.file_path, f1.guarded_file_path, self.msg)
assert mv_mock.call_count == 1
@mock.patch('olympia.files.models.storage.exists')
@ -144,7 +139,8 @@ class TestHideDisabledFiles(TestCase):
# Use Addon.objects.update so the signal handler isn't called.
Addon.objects.filter(id=self.addon.id).update(
status=amo.STATUS_APPROVED, disabled_by_user=True)
status=amo.STATUS_APPROVED, disabled_by_user=True
)
File.objects.update(status=amo.STATUS_APPROVED)
cron.hide_disabled_files()
@ -171,7 +167,8 @@ class TestUnhideDisabledFiles(TestCase):
self.addon = Addon.objects.create(type=amo.ADDON_EXTENSION)
self.version = Version.objects.create(addon=self.addon)
self.file_ = File.objects.create(
version=self.version, platform=amo.PLATFORM_ALL.id, filename=u'')
version=self.version, platform=amo.PLATFORM_ALL.id, filename=u''
)
@mock.patch('olympia.files.models.os')
def test_leave_disabled_files(self, os_mock):
@ -201,7 +198,8 @@ class TestUnhideDisabledFiles(TestCase):
self.file_.update(status=amo.STATUS_APPROVED)
cron.unhide_disabled_files()
mv_mock.assert_called_with(
self.file_.guarded_file_path, self.file_.file_path, self.msg)
self.file_.guarded_file_path, self.file_.file_path, self.msg
)
assert mv_mock.call_count == 1
def test_cleans_up_empty_directories_after_moving(self):
@ -217,14 +215,14 @@ class TestUnhideDisabledFiles(TestCase):
assert storage.exists(self.file_.file_path)
assert not storage.exists(self.file_.guarded_file_path)
# Empty dir also removed:
assert not storage.exists(
os.path.dirname(self.file_.guarded_file_path))
assert not storage.exists(os.path.dirname(self.file_.guarded_file_path))
def test_doesnt_remove_non_empty_directories(self):
# Add an extra disabled file. The approved one should move, but not the
# other, so the directory should be left intact.
self.disabled_file = file_factory(
version=self.version, status=amo.STATUS_DISABLED)
version=self.version, status=amo.STATUS_DISABLED
)
self.addon.update(status=amo.STATUS_APPROVED)
self.file_.update(status=amo.STATUS_APPROVED)
with storage.open(self.file_.guarded_file_path, 'wb') as fp:
@ -267,9 +265,7 @@ class TestAvgDailyUserCountTestCase(TestCase):
def setUp(self):
super().setUp()
@mock.patch(
'olympia.addons.cron.get_addons_and_average_daily_users_from_bigquery'
)
@mock.patch('olympia.addons.cron.get_addons_and_average_daily_users_from_bigquery')
def test_update_addon_average_daily_users_with_bigquery(self, get_mock):
addon = Addon.objects.get(pk=3615)
addon.update(average_daily_users=0)
@ -278,8 +274,7 @@ class TestAvgDailyUserCountTestCase(TestCase):
langpack_count = 12345
dictionary = addon_factory(type=amo.ADDON_DICT, average_daily_users=0)
dictionary_count = 5567
addon_without_count = addon_factory(type=amo.ADDON_DICT,
average_daily_users=2)
addon_without_count = addon_factory(type=amo.ADDON_DICT, average_daily_users=2)
deleted_addon = addon_factory(average_daily_users=0)
deleted_addon_count = 23456
deleted_addon.delete()
@ -312,9 +307,7 @@ class TestAvgDailyUserCountTestCase(TestCase):
assert addon_without_count.average_daily_users == 0
@mock.patch('olympia.addons.cron.create_chunked_tasks_signatures')
@mock.patch(
'olympia.addons.cron.get_addons_and_average_daily_users_from_bigquery'
)
@mock.patch('olympia.addons.cron.get_addons_and_average_daily_users_from_bigquery')
def test_update_addon_average_daily_users_values_with_bigquery(
self, get_mock, create_chunked_mock
):
@ -326,8 +319,7 @@ class TestAvgDailyUserCountTestCase(TestCase):
langpack_count = 12345
dictionary = addon_factory(type=amo.ADDON_DICT, average_daily_users=0)
dictionary_count = 6789
addon_without_count = addon_factory(type=amo.ADDON_DICT,
average_daily_users=2)
addon_without_count = addon_factory(type=amo.ADDON_DICT, average_daily_users=2)
# This one should be ignored.
addon_factory(guid=None, type=amo.ADDON_LPAPP)
# This one should be ignored as well.
@ -356,7 +348,7 @@ class TestAvgDailyUserCountTestCase(TestCase):
(dictionary.guid, dictionary_count),
(deleted_addon.guid, deleted_addon_count),
],
chunk_size
chunk_size,
)
@ -445,12 +437,8 @@ class TestUpdateAddonHotness(TestCase):
class TestUpdateAddonWeeklyDownloads(TestCase):
@mock.patch('olympia.addons.cron.create_chunked_tasks_signatures')
@mock.patch(
'olympia.addons.cron.get_addons_and_weekly_downloads_from_bigquery'
)
def test_calls_create_chunked_tasks_signatures(
self, get_mock, create_chunked_mock
):
@mock.patch('olympia.addons.cron.get_addons_and_weekly_downloads_from_bigquery')
def test_calls_create_chunked_tasks_signatures(self, get_mock, create_chunked_mock):
create_chunked_mock.return_value = group([])
addon = addon_factory(weekly_downloads=0)
count = 56789
@ -458,8 +446,7 @@ class TestUpdateAddonWeeklyDownloads(TestCase):
langpack_count = 12345
dictionary = addon_factory(type=amo.ADDON_DICT, weekly_downloads=0)
dictionary_count = 6789
addon_without_count = addon_factory(type=amo.ADDON_DICT,
weekly_downloads=2)
addon_without_count = addon_factory(type=amo.ADDON_DICT, weekly_downloads=2)
# This one should be ignored.
addon_factory(guid=None, type=amo.ADDON_LPAPP)
# This one should be ignored as well.
@ -481,12 +468,10 @@ class TestUpdateAddonWeeklyDownloads(TestCase):
(langpack.addonguid.hashed_guid, langpack_count),
(dictionary.addonguid.hashed_guid, dictionary_count),
],
chunk_size
chunk_size,
)
@mock.patch(
'olympia.addons.cron.get_addons_and_weekly_downloads_from_bigquery'
)
@mock.patch('olympia.addons.cron.get_addons_and_weekly_downloads_from_bigquery')
def test_update_weekly_downloads(self, get_mock):
addon = addon_factory(weekly_downloads=0)
count = 56789

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

@ -9,7 +9,6 @@ from olympia.amo.tests import TestCase, addon_factory
class TestAddonView(TestCase):
def setUp(self):
super(TestAddonView, self).setUp()
self.addon = addon_factory()
@ -18,11 +17,12 @@ class TestAddonView(TestCase):
self.func.__name__ = 'mock_function'
self.view = dec.addon_view(self.func)
self.request = mock.Mock()
self.slug_path = (
'http://testserver/addon/%s/reviews' %
quote(self.addon.slug.encode('utf-8')))
self.slug_path = 'http://testserver/addon/%s/reviews' % quote(
self.addon.slug.encode('utf-8')
)
self.request.path = self.id_path = (
u'http://testserver/addon/%s/reviews' % self.addon.id)
u'http://testserver/addon/%s/reviews' % self.addon.id
)
self.request.GET = {}
def test_301_by_id(self):
@ -30,8 +30,7 @@ class TestAddonView(TestCase):
self.assert3xx(res, self.slug_path, 301)
def test_301_by_guid(self):
self.request.path = (
u'http://testserver/addon/%s/reviews' % self.addon.guid)
self.request.path = u'http://testserver/addon/%s/reviews' % self.addon.guid
res = self.view(self.request, str(self.addon.guid))
self.assert3xx(res, self.slug_path, 301)
@ -40,10 +39,9 @@ class TestAddonView(TestCase):
self.request.path = path.format(id=self.addon.id)
res = self.view(self.request, str(self.addon.id))
redirection = (
u'http://testserver/addon/{slug}/reviews/{id}345/path'.format(
id=self.addon.id,
slug=quote(self.addon.slug.encode('utf8'))))
redirection = u'http://testserver/addon/{slug}/reviews/{id}345/path'.format(
id=self.addon.id, slug=quote(self.addon.slug.encode('utf8'))
)
self.assert3xx(res, redirection, 301)
def test_301_with_querystring(self):
@ -109,20 +107,20 @@ class TestAddonView(TestCase):
request, addon_ = self.func.call_args[0]
assert addon_ == addon
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer',
lambda r: False)
@mock.patch('olympia.access.acl.check_addon_ownership',
lambda *args, **kwargs: False)
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer', lambda r: False)
@mock.patch(
'olympia.access.acl.check_addon_ownership', lambda *args, **kwargs: False
)
def test_no_versions_404(self):
self.addon.current_version.delete()
view = dec.addon_view_factory(qs=Addon.objects.all)(self.func)
with self.assertRaises(http.Http404):
view(self.request, self.addon.slug)
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer',
lambda r: False)
@mock.patch('olympia.access.acl.check_addon_ownership',
lambda *args, **kwargs: True)
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer', lambda r: False)
@mock.patch(
'olympia.access.acl.check_addon_ownership', lambda *args, **kwargs: True
)
def test_no_versions_developer(self):
self.addon.current_version.delete()
res = self.view(self.request, self.addon.slug)
@ -131,34 +129,31 @@ class TestAddonView(TestCase):
def test_no_versions_include_deleted_when_checking(self):
self.addon.current_version.delete()
view = dec.addon_view( # Not available on the factory
self.func,
qs=Addon.objects.all,
include_deleted_when_checking_versions=True)
self.func, qs=Addon.objects.all, include_deleted_when_checking_versions=True
)
res = view(self.request, self.addon.slug)
assert res == mock.sentinel.OK
class TestAddonViewWithUnlisted(TestAddonView):
def setUp(self):
super(TestAddonViewWithUnlisted, self).setUp()
self.view = dec.addon_view_factory(
qs=Addon.objects.all)(self.func)
self.view = dec.addon_view_factory(qs=Addon.objects.all)(self.func)
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer',
lambda r: False)
@mock.patch('olympia.access.acl.check_addon_ownership',
lambda *args, **kwargs: False)
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer', lambda r: False)
@mock.patch(
'olympia.access.acl.check_addon_ownership', lambda *args, **kwargs: False
)
def test_unlisted_addon(self):
"""Return a 404 for non authorized access."""
self.make_addon_unlisted(self.addon)
with self.assertRaises(http.Http404):
self.view(self.request, self.addon.slug)
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer',
lambda r: False)
@mock.patch('olympia.access.acl.check_addon_ownership',
lambda *args, **kwargs: True)
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer', lambda r: False)
@mock.patch(
'olympia.access.acl.check_addon_ownership', lambda *args, **kwargs: True
)
def test_unlisted_addon_owner(self):
"""Addon owners have access."""
self.make_addon_unlisted(self.addon)
@ -166,10 +161,10 @@ class TestAddonViewWithUnlisted(TestAddonView):
request, addon = self.func.call_args[0]
assert addon == self.addon
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer',
lambda r: True)
@mock.patch('olympia.access.acl.check_addon_ownership',
lambda *args, **kwargs: False)
@mock.patch('olympia.access.acl.check_unlisted_addons_reviewer', lambda r: True)
@mock.patch(
'olympia.access.acl.check_addon_ownership', lambda *args, **kwargs: False
)
def test_unlisted_addon_unlisted_admin(self):
"""Unlisted addon reviewers have access."""
self.make_addon_unlisted(self.addon)

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

@ -6,8 +6,7 @@ from django.conf import settings
from olympia import amo
from olympia.addons.indexers import AddonIndexer
from olympia.addons.models import (
Addon, Preview, attach_tags, attach_translations_dict)
from olympia.addons.models import Addon, Preview, attach_tags, attach_translations_dict
from olympia.amo.models import SearchMixin
from olympia.amo.tests import addon_factory, ESTestCase, TestCase, file_factory
from olympia.bandwagon.models import Collection
@ -27,10 +26,24 @@ class TestAddonIndexer(TestCase):
# This only contains the fields for which we use the value directly,
# see expected_fields() for the rest.
simple_fields = [
'average_daily_users', 'bayesian_rating', 'contributions', 'created',
'default_locale', 'guid', 'hotness', 'icon_hash', 'icon_type', 'id',
'is_disabled', 'is_experimental', 'last_updated',
'modified', 'requires_payment', 'slug', 'status', 'type',
'average_daily_users',
'bayesian_rating',
'contributions',
'created',
'default_locale',
'guid',
'hotness',
'icon_hash',
'icon_type',
'id',
'is_disabled',
'is_experimental',
'last_updated',
'modified',
'requires_payment',
'slug',
'status',
'type',
'weekly_downloads',
]
@ -53,10 +66,22 @@ 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', 'colors', 'current_version',
'description', 'has_eula', 'has_privacy_policy', 'is_recommended',
'app',
'boost',
'category',
'colors',
'current_version',
'description',
'has_eula',
'has_privacy_policy',
'is_recommended',
'listed_authors',
'name', 'platforms', 'previews', 'promoted', 'ratings', 'summary',
'name',
'platforms',
'previews',
'promoted',
'ratings',
'summary',
'tags',
]
@ -67,26 +92,43 @@ class TestAddonIndexer(TestCase):
# For each translated field that needs to be indexed, we store one
# version for each language we have an analyzer for.
_indexed_translated_fields = ('name', 'description', 'summary')
analyzer_fields = list(chain.from_iterable(
[['%s_l10n_%s' % (field, lang) for lang, analyzer
in SEARCH_LANGUAGE_TO_ANALYZER.items()]
for field in _indexed_translated_fields]))
analyzer_fields = list(
chain.from_iterable(
[
[
'%s_l10n_%s' % (field, lang)
for lang, analyzer in SEARCH_LANGUAGE_TO_ANALYZER.items()
]
for field in _indexed_translated_fields
]
)
)
# It'd be annoying to hardcode `analyzer_fields`, so we generate it,
# but to make sure the test is correct we still do a simple check of
# the length to make sure we properly flattened the list.
assert len(analyzer_fields) == (len(SEARCH_LANGUAGE_TO_ANALYZER) *
len(_indexed_translated_fields))
assert len(analyzer_fields) == (
len(SEARCH_LANGUAGE_TO_ANALYZER) * len(_indexed_translated_fields)
)
# Each translated field that we want to return to the API.
raw_translated_fields = [
'%s_translations' % field for field in
['name', 'description', 'developer_comments', 'homepage',
'summary', 'support_email', 'support_url']]
'%s_translations' % field
for field in [
'name',
'description',
'developer_comments',
'homepage',
'summary',
'support_email',
'support_url',
]
]
# Return a list with the base fields and the dynamic ones added.
fields = (cls.simple_fields + complex_fields + analyzer_fields +
raw_translated_fields)
fields = (
cls.simple_fields + complex_fields + analyzer_fields + raw_translated_fields
)
if include_nullable:
fields += nullable_fields
return fields
@ -111,17 +153,33 @@ class TestAddonIndexer(TestCase):
assert mapping_properties['current_version']['properties']
version_mapping = mapping_properties['current_version']['properties']
expected_version_keys = (
'id', 'compatible_apps', 'files', 'license',
'release_notes_translations', 'reviewed', 'version')
'id',
'compatible_apps',
'files',
'license',
'release_notes_translations',
'reviewed',
'version',
)
assert set(version_mapping.keys()) == set(expected_version_keys)
# Make sure files mapping is set inside current_version.
files_mapping = version_mapping['files']['properties']
expected_file_keys = (
'id', 'created', 'filename', 'hash', 'is_webextension',
'is_restart_required', 'is_mozilla_signed_extension', 'platform',
'size', 'status', 'strict_compatibility',
'permissions', 'optional_permissions')
'id',
'created',
'filename',
'hash',
'is_webextension',
'is_restart_required',
'is_mozilla_signed_extension',
'platform',
'size',
'status',
'strict_compatibility',
'permissions',
'optional_permissions',
)
assert set(files_mapping.keys()) == set(expected_file_keys)
def test_index_setting_boolean(self):
@ -138,20 +196,24 @@ class TestAddonIndexer(TestCase):
assert all(
isinstance(prop['index'], bool)
for prop in mapping_properties.values()
if 'index' in prop)
if 'index' in prop
)
# Make sure our version_mapping is setup correctly too.
props = mapping_properties['current_version']['properties']
assert all(
isinstance(prop['index'], bool)
for prop in props.values() if 'index' in prop)
for prop in props.values()
if 'index' in prop
)
# As well as for current_version.files
assert all(
isinstance(prop['index'], bool)
for prop in props['files']['properties'].values()
if 'index' in prop)
if 'index' in prop
)
def _extract(self):
qs = Addon.unfiltered.filter(id__in=[self.addon.pk])
@ -167,19 +229,25 @@ class TestAddonIndexer(TestCase):
# Make sure the method does not return fields we did not expect to be
# present, or omitted fields we want.
assert set(extracted.keys()) == set(
self.expected_fields(include_nullable=False))
self.expected_fields(include_nullable=False)
)
# Check base fields values. Other tests below check the dynamic ones.
for field_name in self.simple_fields:
assert extracted[field_name] == getattr(self.addon, field_name)
assert extracted['app'] == [FIREFOX.id]
assert extracted['boost'] == self.addon.average_daily_users ** .2 * 4
assert extracted['boost'] == self.addon.average_daily_users ** 0.2 * 4
assert extracted['category'] == [1, 22, 71] # From fixture.
assert extracted['current_version']
assert extracted['listed_authors'] == [
{'name': u'55021 التطب', 'id': 55021, 'username': '55021',
'is_public': True}]
{
'name': u'55021 التطب',
'id': 55021,
'username': '55021',
'is_public': True,
}
]
assert extracted['platforms'] == [PLATFORM_ALL.id]
assert extracted['ratings'] == {
'average': self.addon.average_rating,
@ -215,17 +283,19 @@ class TestAddonIndexer(TestCase):
# Make the version a webextension and add a bunch of things to it to
# test different scenarios.
version.all_files[0].update(is_webextension=True)
file_factory(
version=version, platform=PLATFORM_MAC.id, is_webextension=True)
file_factory(version=version, platform=PLATFORM_MAC.id, is_webextension=True)
del version.all_files
version.license = License.objects.create(
name=u'My licensé',
url='http://example.com/',
builtin=0)
[WebextPermission.objects.create(
file=file_, permissions=permissions,
optional_permissions=optional_permissions
) for file_ in version.all_files]
name=u'My licensé', url='http://example.com/', builtin=0
)
[
WebextPermission.objects.create(
file=file_,
permissions=permissions,
optional_permissions=optional_permissions,
)
for file_ in version.all_files
]
version.save()
# Now we can run the extraction and start testing.
@ -247,12 +317,15 @@ class TestAddonIndexer(TestCase):
'builtin': 0,
'id': version.license.pk,
'name_translations': [{'lang': u'en-US', 'string': u'My licensé'}],
'url': u'http://example.com/'
'url': u'http://example.com/',
}
assert extracted['current_version']['release_notes_translations'] == [
{'lang': 'en-US', 'string': u'Fix for an important bug'},
{'lang': 'fr', 'string': u"Quelque chose en fran\xe7ais."
u"\n\nQuelque chose d'autre."},
{
'lang': 'fr',
'string': u"Quelque chose en fran\xe7ais."
u"\n\nQuelque chose d'autre.",
},
]
assert extracted['current_version']['reviewed'] == version.reviewed
assert extracted['current_version']['version'] == version.version
@ -263,28 +336,23 @@ class TestAddonIndexer(TestCase):
assert extracted_file['filename'] == file_.filename
assert extracted_file['hash'] == file_.hash
assert extracted_file['is_webextension'] == file_.is_webextension
assert extracted_file['is_restart_required'] == (
file_.is_restart_required)
assert extracted_file['is_restart_required'] == (file_.is_restart_required)
assert extracted_file['is_mozilla_signed_extension'] == (
file_.is_mozilla_signed_extension)
file_.is_mozilla_signed_extension
)
assert extracted_file['platform'] == file_.platform
assert extracted_file['size'] == file_.size
assert extracted_file['status'] == file_.status
assert (
extracted_file['permissions'] ==
permissions)
assert (
extracted_file['optional_permissions'] ==
optional_permissions)
assert extracted_file['permissions'] == permissions
assert extracted_file['optional_permissions'] == optional_permissions
assert set(extracted['platforms']) == set([PLATFORM_MAC.id,
PLATFORM_ALL.id])
assert set(extracted['platforms']) == set([PLATFORM_MAC.id, PLATFORM_ALL.id])
def test_version_compatibility_with_strict_compatibility_enabled(self):
version = self.addon.current_version
file_factory(
version=version, platform=PLATFORM_MAC.id,
strict_compatibility=True)
version=version, platform=PLATFORM_MAC.id, strict_compatibility=True
)
extracted = self._extract()
assert extracted['current_version']['compatible_apps'] == {
@ -320,24 +388,17 @@ class TestAddonIndexer(TestCase):
assert extracted['description_translations'] == [
{'lang': 'en-US', 'string': translations_description['en-US']},
{'lang': 'es', 'string': translations_description['es']},
{'lang': 'it', 'string': '&lt;script&gt;alert(42)&lt;/script&gt;'}
{'lang': 'it', 'string': '&lt;script&gt;alert(42)&lt;/script&gt;'},
]
assert extracted['name_l10n_en-us'] == translations_name['en-US']
assert extracted['name_l10n_en-gb'] == ''
assert extracted['name_l10n_es'] == translations_name['es']
assert extracted['name_l10n_it'] == ''
assert (
extracted['description_l10n_en-us'] ==
translations_description['en-US']
)
assert (
extracted['description_l10n_es'] ==
translations_description['es']
)
assert extracted['description_l10n_en-us'] == translations_description['en-US']
assert extracted['description_l10n_es'] == translations_description['es']
assert extracted['description_l10n_fr'] == ''
assert (
extracted['description_l10n_it'] ==
'&lt;script&gt;alert(42)&lt;/script&gt;'
extracted['description_l10n_it'] == '&lt;script&gt;alert(42)&lt;/script&gt;'
)
assert extracted['summary_l10n_en-us'] == ''
# The non-l10n fields are fallbacks in the addon's default locale, they
@ -359,8 +420,7 @@ class TestAddonIndexer(TestCase):
self.addon = Addon.objects.create(**kwargs)
self.addon.name = {'es': 'Banana Bonkers espanole'}
self.addon.description = {
'es': 'Deje que su navegador coma sus plátanos'}
self.addon.description = {'es': 'Deje que su navegador coma sus plátanos'}
self.addon.summary = {'es': 'resumen banana'}
self.addon.save()
@ -372,28 +432,26 @@ class TestAddonIndexer(TestCase):
]
assert extracted['description_translations'] == [
{'lang': 'en-GB', 'string': 'Let your browser eat your bananas'},
{
'lang': 'es',
'string': 'Deje que su navegador coma sus plátanos'
},
{'lang': 'es', 'string': 'Deje que su navegador coma sus plátanos'},
]
assert extracted['name_l10n_en-gb'] == 'Banana Bonkers'
assert extracted['name_l10n_en-us'] == ''
assert extracted['name_l10n_es'] == 'Banana Bonkers espanole'
assert (
extracted['description_l10n_en-gb'] ==
'Let your browser eat your bananas'
extracted['description_l10n_en-gb'] == 'Let your browser eat your bananas'
)
assert (
extracted['description_l10n_es'] ==
'Deje que su navegador coma sus plátanos'
extracted['description_l10n_es']
== 'Deje que su navegador coma sus plátanos'
)
def test_extract_previews(self):
second_preview = Preview.objects.create(
addon=self.addon, position=2,
addon=self.addon,
position=2,
caption={'en-US': u'My câption', 'fr': u'Mön tîtré'},
sizes={'thumbnail': [199, 99], 'image': [567, 780]})
sizes={'thumbnail': [199, 99], 'image': [567, 780]},
)
first_preview = Preview.objects.create(addon=self.addon, position=1)
first_preview.reload()
second_preview.reload()
@ -408,9 +466,13 @@ class TestAddonIndexer(TestCase):
assert extracted['previews'][1]['modified'] == second_preview.modified
assert extracted['previews'][1]['caption_translations'] == [
{'lang': 'en-US', 'string': u'My câption'},
{'lang': 'fr', 'string': u'Mön tîtré'}]
assert extracted['previews'][1]['sizes'] == second_preview.sizes == {
'thumbnail': [199, 99], 'image': [567, 780]}
{'lang': 'fr', 'string': u'Mön tîtré'},
]
assert (
extracted['previews'][1]['sizes']
== second_preview.sizes
== {'thumbnail': [199, 99], 'image': [567, 780]}
)
# Only raw translations dict should exist, since we don't need the
# to search against preview captions.
@ -422,23 +484,33 @@ class TestAddonIndexer(TestCase):
current_preview = VersionPreview.objects.create(
version=self.addon.current_version,
colors=[{'h': 1, 's': 2, 'l': 3, 'ratio': 0.9}],
sizes={'thumbnail': [56, 78], 'image': [91, 234]}, position=1)
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)
sizes={'thumbnail': [12, 34], 'image': [56, 78]},
position=2,
)
extracted = self._extract()
assert extracted['previews']
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 (
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]}
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}]
@ -464,26 +536,30 @@ class TestAddonIndexer(TestCase):
assert extracted['promoted']
assert extracted['promoted']['group_id'] == RECOMMENDED.id
assert extracted['promoted']['approved_for_apps'] == [
amo.FIREFOX.id, amo.ANDROID.id]
amo.FIREFOX.id,
amo.ANDROID.id,
]
assert extracted['is_recommended'] is True
# Specific application.
self.addon.promotedaddon.update(application_id=amo.FIREFOX.id)
extracted = self._extract()
assert extracted['promoted']['approved_for_apps'] == [
amo.FIREFOX.id]
assert extracted['promoted']['approved_for_apps'] == [amo.FIREFOX.id]
assert extracted['is_recommended'] is True
# Promoted theme.
self.addon = addon_factory(type=amo.ADDON_STATICTHEME)
featured_collection, _ = Collection.objects.get_or_create(
id=settings.COLLECTION_FEATURED_THEMES_ID)
id=settings.COLLECTION_FEATURED_THEMES_ID
)
featured_collection.add_addon(self.addon)
extracted = self._extract()
assert extracted['promoted']
assert extracted['promoted']['group_id'] == RECOMMENDED.id
assert extracted['promoted']['approved_for_apps'] == [
amo.FIREFOX.id, amo.ANDROID.id]
amo.FIREFOX.id,
amo.ANDROID.id,
]
assert extracted['is_recommended'] is True
@mock.patch('olympia.addons.indexers.create_chunked_tasks_signatures')
@ -495,12 +571,15 @@ class TestAddonIndexer(TestCase):
addon_factory(status=amo.STATUS_DELETED).pk,
addon_factory(
status=amo.STATUS_NULL,
version_kw={'channel': amo.RELEASE_CHANNEL_UNLISTED}).pk,
version_kw={'channel': amo.RELEASE_CHANNEL_UNLISTED},
).pk,
]
rval = AddonIndexer.reindex_tasks_group('addons')
assert create_chunked_tasks_signatures_mock.call_count == 1
assert create_chunked_tasks_signatures_mock.call_args[0] == (
index_addons, expected_ids, 150
index_addons,
expected_ids,
150,
)
assert rval == create_chunked_tasks_signatures_mock.return_value
@ -519,8 +598,9 @@ class TestAddonIndexerWithES(ESTestCase):
indexer = AddonIndexer()
doc_name = indexer.get_doctype_name()
real_index_name = self.get_index_name(SearchMixin.ES_ALIAS_KEY)
mappings = self.es.indices.get_mapping(
indexer.get_index_alias())[real_index_name]['mappings']
mappings = self.es.indices.get_mapping(indexer.get_index_alias())[
real_index_name
]['mappings']
actual_properties = mappings[doc_name]['properties']
indexer_properties = indexer.get_mapping()[doc_name]['properties']

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -7,10 +7,12 @@ from django.conf import settings
from waffle.testutils import override_switch
from olympia import amo
from olympia.addons.tasks import (recreate_theme_previews,
update_addon_average_daily_users,
update_addon_hotness,
update_addon_weekly_downloads)
from olympia.addons.tasks import (
recreate_theme_previews,
update_addon_average_daily_users,
update_addon_hotness,
update_addon_weekly_downloads,
)
from olympia.amo.storage_utils import copy_stored_file
from olympia.amo.tests import addon_factory
from olympia.versions.models import VersionPreview
@ -19,36 +21,42 @@ from olympia.versions.models import VersionPreview
@pytest.mark.django_db
def test_recreate_theme_previews():
xpi_path = os.path.join(
settings.ROOT,
'src/olympia/devhub/tests/addons/mozilla_static_theme.zip')
settings.ROOT, 'src/olympia/devhub/tests/addons/mozilla_static_theme.zip'
)
addon_without_previews = addon_factory(type=amo.ADDON_STATICTHEME)
copy_stored_file(
xpi_path,
addon_without_previews.current_version.all_files[0].file_path)
xpi_path, addon_without_previews.current_version.all_files[0].file_path
)
addon_with_previews = addon_factory(type=amo.ADDON_STATICTHEME)
copy_stored_file(
xpi_path,
addon_with_previews.current_version.all_files[0].file_path)
xpi_path, addon_with_previews.current_version.all_files[0].file_path
)
VersionPreview.objects.create(
version=addon_with_previews.current_version,
sizes={'image': [123, 456], 'thumbnail': [34, 45]})
sizes={'image': [123, 456], 'thumbnail': [34, 45]},
)
assert addon_without_previews.current_previews.count() == 0
assert addon_with_previews.current_previews.count() == 1
recreate_theme_previews(
[addon_without_previews.id, addon_with_previews.id])
recreate_theme_previews([addon_without_previews.id, addon_with_previews.id])
assert addon_without_previews.reload().current_previews.count() == 3
assert addon_with_previews.reload().current_previews.count() == 3
sizes = addon_without_previews.current_previews.values_list(
'sizes', flat=True)
sizes = addon_without_previews.current_previews.values_list('sizes', flat=True)
assert list(sizes) == [
{'image': list(amo.THEME_PREVIEW_SIZES['header']['full']),
'thumbnail': list(amo.THEME_PREVIEW_SIZES['header']['thumbnail'])},
{'image': list(amo.THEME_PREVIEW_SIZES['list']['full']),
'thumbnail': list(amo.THEME_PREVIEW_SIZES['list']['thumbnail'])},
{'image': list(amo.THEME_PREVIEW_SIZES['single']['full']),
'thumbnail': list(amo.THEME_PREVIEW_SIZES['single']['thumbnail'])}]
{
'image': list(amo.THEME_PREVIEW_SIZES['header']['full']),
'thumbnail': list(amo.THEME_PREVIEW_SIZES['header']['thumbnail']),
},
{
'image': list(amo.THEME_PREVIEW_SIZES['list']['full']),
'thumbnail': list(amo.THEME_PREVIEW_SIZES['list']['thumbnail']),
},
{
'image': list(amo.THEME_PREVIEW_SIZES['single']['full']),
'thumbnail': list(amo.THEME_PREVIEW_SIZES['single']['thumbnail']),
},
]
@pytest.mark.django_db
@ -58,13 +66,16 @@ def test_create_missing_theme_previews(parse_addon_mock):
theme = addon_factory(type=amo.ADDON_STATICTHEME)
preview = VersionPreview.objects.create(
version=theme.current_version,
sizes={'image': [123, 456], 'thumbnail': [34, 45]})
sizes={'image': [123, 456], 'thumbnail': [34, 45]},
)
VersionPreview.objects.create(
version=theme.current_version,
sizes={'image': [123, 456], 'thumbnail': [34, 45]})
sizes={'image': [123, 456], 'thumbnail': [34, 45]},
)
VersionPreview.objects.create(
version=theme.current_version,
sizes={'image': [123, 456], 'thumbnail': [34, 45]})
sizes={'image': [123, 456], 'thumbnail': [34, 45]},
)
# addon has 3 complete previews already so skip when only_missing=True
with mock.patch('olympia.addons.tasks.generate_static_theme_preview') as p:
@ -117,23 +128,15 @@ def test_update_deleted_addon_average_daily_users():
@pytest.mark.django_db
def test_update_addon_hotness():
addon1 = addon_factory(hotness=0, status=amo.STATUS_APPROVED)
addon2 = addon_factory(hotness=123,
status=amo.STATUS_APPROVED)
addon3 = addon_factory(hotness=123,
status=amo.STATUS_AWAITING_REVIEW)
addon2 = addon_factory(hotness=123, status=amo.STATUS_APPROVED)
addon3 = addon_factory(hotness=123, status=amo.STATUS_AWAITING_REVIEW)
averages = {
addon1.guid: {
'avg_this_week': 213467,
'avg_three_weeks_before': 123467
},
addon1.guid: {'avg_this_week': 213467, 'avg_three_weeks_before': 123467},
addon2.guid: {
'avg_this_week': 1,
'avg_three_weeks_before': 1,
},
addon3.guid: {
'avg_this_week': 213467,
'avg_three_weeks_before': 123467
},
addon3.guid: {'avg_this_week': 213467, 'avg_three_weeks_before': 123467},
}
update_addon_hotness(averages=averages.items())

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

@ -20,7 +20,6 @@ from olympia.versions.models import ApplicationsVersions, Version
class VersionCheckMixin(object):
def get_update_instance(self, data):
instance = update.Update(data)
instance.cursor = connection.cursor()
@ -142,8 +141,10 @@ class TestLookup(VersionCheckMixin, TestCase):
assert instance.is_valid()
instance.data['version_int'] = args[1]
instance.get_update()
return (instance.data['row'].get('version_id'),
instance.data['row'].get('file_id'))
return (
instance.data['row'].get('version_id'),
instance.data['row'].get('file_id'),
)
def change_status(self, version, status):
version = Version.objects.get(pk=version)
@ -161,7 +162,8 @@ class TestLookup(VersionCheckMixin, TestCase):
add-on is returned.
"""
version, file = self.get_update_instance(
'', '3000000001100', self.app, self.platform)
'', '3000000001100', self.app, self.platform
)
assert version == self.version_1_0_2
def test_new_client(self):
@ -170,7 +172,8 @@ class TestLookup(VersionCheckMixin, TestCase):
add-on is returned.
"""
version, file = self.get_update_instance(
'', self.version_int, self.app, self.platform)
'', self.version_int, self.app, self.platform
)
assert version == self.version_1_2_2
def test_min_client(self):
@ -185,7 +188,8 @@ class TestLookup(VersionCheckMixin, TestCase):
appversion.save()
version, file = self.get_update_instance(
'', '3070000005000', self.app, self.platform) # 3.7a5pre
'', '3070000005000', self.app, self.platform
) # 3.7a5pre
assert version == self.version_1_1_3
def test_new_client_ordering(self):
@ -206,7 +210,8 @@ class TestLookup(VersionCheckMixin, TestCase):
application_version.save()
version, file = self.get_update_instance(
'', self.version_int, self.app, self.platform)
'', self.version_int, self.app, self.platform
)
assert version == self.version_1_2_2
def test_public(self):
@ -217,7 +222,8 @@ class TestLookup(VersionCheckMixin, TestCase):
self.addon.reload()
assert self.addon.status == amo.STATUS_APPROVED
version, file = self.get_update_instance(
'1.2', self.version_int, self.app, self.platform)
'1.2', self.version_int, self.app, self.platform
)
assert version == self.version_1_2_1
def test_no_unlisted(self):
@ -225,11 +231,13 @@ class TestLookup(VersionCheckMixin, TestCase):
Unlisted versions are always ignored, never served as updates.
"""
Version.objects.get(pk=self.version_1_2_2).update(
channel=amo.RELEASE_CHANNEL_UNLISTED)
channel=amo.RELEASE_CHANNEL_UNLISTED
)
self.addon.reload()
assert self.addon.status == amo.STATUS_APPROVED
version, file = self.get_update_instance(
'1.2', self.version_int, self.app, self.platform)
'1.2', self.version_int, self.app, self.platform
)
assert version == self.version_1_2_1
def test_can_downgrade(self):
@ -241,7 +249,8 @@ class TestLookup(VersionCheckMixin, TestCase):
for v in Version.objects.filter(pk__gte=self.version_1_2_1):
v.delete()
version, file = self.get_update_instance(
'1.2', self.version_int, self.app, self.platform)
'1.2', self.version_int, self.app, self.platform
)
assert version == self.version_1_1_3
@ -257,7 +266,8 @@ class TestLookup(VersionCheckMixin, TestCase):
self.change_version(self.version_1_2_0, '1.2beta')
version, file = self.get_update_instance(
'1.2', self.version_int, self.app, self.platform)
'1.2', self.version_int, self.app, self.platform
)
assert version == self.version_1_2_1
@ -272,7 +282,8 @@ class TestLookup(VersionCheckMixin, TestCase):
Version.objects.get(pk=self.version_1_2_0).files.all().delete()
version, file = self.get_update_instance(
'1.2beta', self.version_int, self.app, self.platform)
'1.2beta', self.version_int, self.app, self.platform
)
dest = Version.objects.get(pk=self.version_1_2_2)
assert dest.addon.status == amo.STATUS_APPROVED
assert dest.files.all()[0].status == amo.STATUS_APPROVED
@ -286,7 +297,8 @@ class TestLookup(VersionCheckMixin, TestCase):
self.change_status(self.version_1_2_2, amo.STATUS_NULL)
self.addon.update(status=amo.STATUS_NULL)
version, file = self.get_update_instance(
'1.2.1', self.version_int, self.app, self.platform)
'1.2.1', self.version_int, self.app, self.platform
)
assert version == self.version_1_2_1
def test_platform_does_not_exist(self):
@ -297,7 +309,8 @@ class TestLookup(VersionCheckMixin, TestCase):
file.save()
version, file = self.get_update_instance(
'1.2', self.version_int, self.app, self.platform)
'1.2', self.version_int, self.app, self.platform
)
assert version == self.version_1_2_1
def test_platform_exists(self):
@ -308,7 +321,8 @@ class TestLookup(VersionCheckMixin, TestCase):
file.save()
version, file = self.get_update_instance(
'1.2', self.version_int, self.app, amo.PLATFORM_LINUX)
'1.2', self.version_int, self.app, amo.PLATFORM_LINUX
)
assert version == self.version_1_2_2
def test_file_for_platform(self):
@ -318,17 +332,23 @@ class TestLookup(VersionCheckMixin, TestCase):
file_one.platform = amo.PLATFORM_LINUX.id
file_one.save()
file_two = File(version=version, filename='foo', hash='bar',
platform=amo.PLATFORM_WIN.id,
status=amo.STATUS_APPROVED)
file_two = File(
version=version,
filename='foo',
hash='bar',
platform=amo.PLATFORM_WIN.id,
status=amo.STATUS_APPROVED,
)
file_two.save()
version, file = self.get_update_instance(
'1.2', self.version_int, self.app, amo.PLATFORM_LINUX)
'1.2', self.version_int, self.app, amo.PLATFORM_LINUX
)
assert version == self.version_1_2_2
assert file == file_one.pk
version, file = self.get_update_instance(
'1.2', self.version_int, self.app, amo.PLATFORM_WIN)
'1.2', self.version_int, self.app, amo.PLATFORM_WIN
)
assert version == self.version_1_2_2
assert file == file_two.pk
@ -337,6 +357,7 @@ class TestDefaultToCompat(VersionCheckMixin, TestCase):
"""
Test default to compatible with all the various combinations of input.
"""
fixtures = ['addons/default-to-compat']
def setUp(self):
@ -356,7 +377,9 @@ class TestDefaultToCompat(VersionCheckMixin, TestCase):
self.ver_1_3 = 1268884
self.expected = {
'3.0-strict': None, '3.0-normal': None, '3.0-ignore': None,
'3.0-strict': None,
'3.0-normal': None,
'3.0-ignore': None,
'4.0-strict': self.ver_1_0,
'4.0-normal': self.ver_1_0,
'4.0-ignore': self.ver_1_0,
@ -380,13 +403,15 @@ class TestDefaultToCompat(VersionCheckMixin, TestCase):
file.update(**kw)
def get_update_instance(self, **kw):
instance = super(TestDefaultToCompat, self).get_update_instance({
'reqVersion': 1,
'id': self.addon.guid,
'version': kw.get('item_version', '1.0'),
'appID': self.app.guid,
'appVersion': kw.get('app_version', '3.0'),
})
instance = super(TestDefaultToCompat, self).get_update_instance(
{
'reqVersion': 1,
'id': self.addon.guid,
'version': kw.get('item_version', '1.0'),
'appID': self.app.guid,
'appVersion': kw.get('app_version', '3.0'),
}
)
assert instance.is_valid()
instance.compat_mode = kw.get('compat_mode', 'strict')
instance.get_update()
@ -403,9 +428,8 @@ class TestDefaultToCompat(VersionCheckMixin, TestCase):
for version in versions:
for mode in modes:
assert (
self.get_update_instance(
app_version=version, compat_mode=mode) ==
expected['-'.join([version, mode])]
self.get_update_instance(app_version=version, compat_mode=mode)
== expected['-'.join([version, mode])]
)
def test_baseline(self):
@ -415,17 +439,21 @@ class TestDefaultToCompat(VersionCheckMixin, TestCase):
def test_binary_components(self):
# Tests add-on with binary_components flag.
self.update_files(binary_components=True)
self.expected.update({
'8.0-normal': None,
})
self.expected.update(
{
'8.0-normal': None,
}
)
self.check(self.expected)
def test_strict_opt_in(self):
# Tests add-on with opt-in strict compatibility
self.update_files(strict_compatibility=True)
self.expected.update({
'8.0-normal': None,
})
self.expected.update(
{
'8.0-normal': None,
}
)
self.check(self.expected)
def test_min_max_version(self):
@ -434,17 +462,19 @@ class TestDefaultToCompat(VersionCheckMixin, TestCase):
av.min_id = 233 # Firefox 3.0.
av.max_id = 268 # Firefox 3.5.
av.save()
self.expected.update({
'3.0-strict': self.ver_1_3,
'3.0-ignore': self.ver_1_3,
'4.0-ignore': self.ver_1_3,
'5.0-ignore': self.ver_1_3,
'6.0-strict': self.ver_1_2,
'6.0-normal': self.ver_1_2,
'7.0-strict': self.ver_1_2,
'7.0-normal': self.ver_1_2,
'8.0-normal': self.ver_1_2,
})
self.expected.update(
{
'3.0-strict': self.ver_1_3,
'3.0-ignore': self.ver_1_3,
'4.0-ignore': self.ver_1_3,
'5.0-ignore': self.ver_1_3,
'6.0-strict': self.ver_1_2,
'6.0-normal': self.ver_1_2,
'7.0-strict': self.ver_1_2,
'7.0-normal': self.ver_1_2,
'8.0-normal': self.ver_1_2,
}
)
self.check(self.expected)
@ -483,9 +513,7 @@ class TestResponse(VersionCheckMixin, TestCase):
data['appOS'] = self.mac.api_name
instance = self.get_update_instance(data)
assert (
json.loads(instance.get_output()) ==
instance.get_no_updates_output())
assert json.loads(instance.get_output()) == instance.get_no_updates_output()
def test_different_platform(self):
file = File.objects.get(pk=67442)
@ -554,8 +582,7 @@ class TestResponse(VersionCheckMixin, TestCase):
# way lies pain with broken tests later.
instance = self.get_update_instance(self.data)
headers = dict(instance.get_headers(1))
last_modified = datetime(
*utils.parsedate_tz(headers['Last-Modified'])[:7])
last_modified = datetime(*utils.parsedate_tz(headers['Last-Modified'])[:7])
expires = datetime(*utils.parsedate_tz(headers['Expires'])[:7])
assert (expires - last_modified).seconds == 3600
@ -565,7 +592,8 @@ class TestResponse(VersionCheckMixin, TestCase):
'http://testserver/user-media/addons/3615/'
'delicious_bookmarks-2.1.072-fx.xpi?'
'filehash=sha256%3A3808b13ef8341378b9c8305ca648200954ee7dcd8dc'
'e09fef55f2673458bc31f')
'e09fef55f2673458bc31f'
)
def test_url(self):
instance = self.get_update_instance(self.data)
@ -611,17 +639,13 @@ class TestResponse(VersionCheckMixin, TestCase):
def test_no_updates_at_all(self):
self.addon_one.versions.all().delete()
instance = self.get_update_instance(self.data)
assert (
json.loads(instance.get_output()) ==
instance.get_no_updates_output())
assert json.loads(instance.get_output()) == instance.get_no_updates_output()
def test_no_updates_my_fx(self):
data = self.data.copy()
data['appVersion'] = '5.0.1'
instance = self.get_update_instance(data)
assert (
json.loads(instance.get_output()) ==
instance.get_no_updates_output())
assert json.loads(instance.get_output()) == instance.get_no_updates_output()
def test_application(self):
# Basic test making sure application() is returning the output of
@ -630,14 +654,10 @@ class TestResponse(VersionCheckMixin, TestCase):
# settings_test.py, we wouldn't see results because the data wouldn't
# exist with the cursor the update service is using, which is different
# from the one used by django tests.
environ = {
'QUERY_STRING': ''
}
environ = {'QUERY_STRING': ''}
self.start_response_call_count = 0
expected_headers = [
('FakeHeader', 'FakeHeaderValue')
]
expected_headers = [('FakeHeader', 'FakeHeaderValue')]
expected_output = b'{"fake": "output"}'
@ -660,6 +680,7 @@ class TestResponse(VersionCheckMixin, TestCase):
@mock.patch('services.update.Update')
def test_exception_handling(self, UpdateMock, log_mock):
"""Test ensuring exceptions are raised and logged properly."""
class CustomException(Exception):
pass
@ -676,8 +697,7 @@ class TestResponse(VersionCheckMixin, TestCase):
# The log should be present.
assert log_mock.exception.call_count == 1
log_mock.exception.assert_called_with(
update_instance.get_output.side_effect)
log_mock.exception.assert_called_with(update_instance.get_output.side_effect)
# This test needs to be a TransactionTestCase because we want to test the

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

@ -6,38 +6,46 @@ from django.conf import settings
from django.forms import ValidationError
from olympia.addons.utils import (
get_addon_recommendations, get_addon_recommendations_invalid,
get_addon_recommendations,
get_addon_recommendations_invalid,
is_outcome_recommended,
TAAR_LITE_FALLBACK_REASON_EMPTY, TAAR_LITE_FALLBACK_REASON_TIMEOUT,
TAAR_LITE_FALLBACKS, TAAR_LITE_OUTCOME_CURATED,
TAAR_LITE_OUTCOME_REAL_FAIL, TAAR_LITE_OUTCOME_REAL_SUCCESS,
TAAR_LITE_FALLBACK_REASON_EMPTY,
TAAR_LITE_FALLBACK_REASON_TIMEOUT,
TAAR_LITE_FALLBACKS,
TAAR_LITE_OUTCOME_CURATED,
TAAR_LITE_OUTCOME_REAL_FAIL,
TAAR_LITE_OUTCOME_REAL_SUCCESS,
TAAR_LITE_FALLBACK_REASON_INVALID,
verify_mozilla_trademark)
verify_mozilla_trademark,
)
from olympia.amo.tests import TestCase, addon_factory, user_factory
from olympia.users.models import Group, GroupUser
@pytest.mark.django_db
@pytest.mark.parametrize('name, allowed, give_permission', (
('Fancy new Add-on', True, False),
# We allow the 'for ...' postfix to be used
('Fancy new Add-on for Firefox', True, False),
('Fancy new Add-on for Mozilla', True, False),
# But only the postfix
('Fancy new Add-on for Firefox Browser', False, False),
('For Firefox fancy new add-on', False, False),
# But users with the TRADEMARK_BYPASS permission are allowed
('Firefox makes everything better', False, False),
('Firefox makes everything better', True, True),
('Mozilla makes everything better', True, True),
# A few more test-cases...
('Firefox add-on for Firefox', False, False),
('Firefox add-on for Firefox', True, True),
('Foobarfor Firefox', False, False),
('Better Privacy for Firefox!', True, False),
('Firefox awesome for Mozilla', False, False),
('Firefox awesome for Mozilla', True, True),
))
@pytest.mark.parametrize(
'name, allowed, give_permission',
(
('Fancy new Add-on', True, False),
# We allow the 'for ...' postfix to be used
('Fancy new Add-on for Firefox', True, False),
('Fancy new Add-on for Mozilla', True, False),
# But only the postfix
('Fancy new Add-on for Firefox Browser', False, False),
('For Firefox fancy new add-on', False, False),
# But users with the TRADEMARK_BYPASS permission are allowed
('Firefox makes everything better', False, False),
('Firefox makes everything better', True, True),
('Mozilla makes everything better', True, True),
# A few more test-cases...
('Firefox add-on for Firefox', False, False),
('Firefox add-on for Firefox', True, True),
('Foobarfor Firefox', False, False),
('Better Privacy for Firefox!', True, False),
('Firefox awesome for Mozilla', False, False),
('Firefox awesome for Mozilla', True, True),
),
)
def test_verify_mozilla_trademark(name, allowed, give_permission):
user = user_factory()
if give_permission:
@ -48,8 +56,7 @@ def test_verify_mozilla_trademark(name, allowed, give_permission):
with pytest.raises(ValidationError) as exc:
verify_mozilla_trademark(name, user)
assert exc.value.message == (
'Add-on names cannot contain the Mozilla or Firefox '
'trademarks.'
'Add-on names cannot contain the Mozilla or Firefox ' 'trademarks.'
)
else:
verify_mozilla_trademark(name, user)
@ -57,8 +64,7 @@ def test_verify_mozilla_trademark(name, allowed, give_permission):
class TestGetAddonRecommendations(TestCase):
def setUp(self):
patcher = mock.patch(
'olympia.addons.utils.call_recommendation_server')
patcher = mock.patch('olympia.addons.utils.call_recommendation_server')
self.recommendation_server_mock = patcher.start()
self.addCleanup(patcher.stop)
self.a101 = addon_factory(id=101, guid='101@mozilla')
@ -67,43 +73,44 @@ class TestGetAddonRecommendations(TestCase):
addon_factory(id=104, guid='104@mozilla')
self.recommendation_guids = [
'101@mozilla', '102@mozilla', '103@mozilla', '104@mozilla'
'101@mozilla',
'102@mozilla',
'103@mozilla',
'104@mozilla',
]
self.recommendation_server_mock.return_value = (
self.recommendation_guids)
self.recommendation_server_mock.return_value = self.recommendation_guids
def test_recommended(self):
recommendations, outcome, reason = get_addon_recommendations(
'a@b', True)
recommendations, outcome, reason = get_addon_recommendations('a@b', True)
assert recommendations == self.recommendation_guids
assert outcome == TAAR_LITE_OUTCOME_REAL_SUCCESS
assert reason is None
self.recommendation_server_mock.assert_called_with(
settings.TAAR_LITE_RECOMMENDATION_ENGINE_URL, 'a@b', {})
settings.TAAR_LITE_RECOMMENDATION_ENGINE_URL, 'a@b', {}
)
def test_recommended_no_results(self):
self.recommendation_server_mock.return_value = []
recommendations, outcome, reason = get_addon_recommendations(
'a@b', True)
recommendations, outcome, reason = get_addon_recommendations('a@b', True)
assert recommendations == TAAR_LITE_FALLBACKS
assert outcome == TAAR_LITE_OUTCOME_REAL_FAIL
assert reason is TAAR_LITE_FALLBACK_REASON_EMPTY
self.recommendation_server_mock.assert_called_with(
settings.TAAR_LITE_RECOMMENDATION_ENGINE_URL, 'a@b', {})
settings.TAAR_LITE_RECOMMENDATION_ENGINE_URL, 'a@b', {}
)
def test_recommended_timeout(self):
self.recommendation_server_mock.return_value = None
recommendations, outcome, reason = get_addon_recommendations(
'a@b', True)
recommendations, outcome, reason = get_addon_recommendations('a@b', True)
assert recommendations == TAAR_LITE_FALLBACKS
assert outcome == TAAR_LITE_OUTCOME_REAL_FAIL
assert reason is TAAR_LITE_FALLBACK_REASON_TIMEOUT
self.recommendation_server_mock.assert_called_with(
settings.TAAR_LITE_RECOMMENDATION_ENGINE_URL, 'a@b', {})
settings.TAAR_LITE_RECOMMENDATION_ENGINE_URL, 'a@b', {}
)
def test_not_recommended(self):
recommendations, outcome, reason = get_addon_recommendations(
'a@b', False)
recommendations, outcome, reason = get_addon_recommendations('a@b', False)
assert not self.recommendation_server_mock.called
assert recommendations == TAAR_LITE_FALLBACKS
assert outcome == TAAR_LITE_OUTCOME_CURATED

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -11,9 +11,7 @@ ADDON_ID = r"""(?P<addon_id>[^/<>"']+)"""
# These will all start with /addon/<addon_id>/
detail_patterns = [
re_path(r'^$', frontend_view, name='addons.detail'),
re_path(r'^license/(?P<version>[^/]+)?', frontend_view,
name='addons.license'),
re_path(r'^license/(?P<version>[^/]+)?', frontend_view, name='addons.license'),
re_path(r'^reviews/', include('olympia.ratings.urls')),
re_path(r'^statistics/', include(stats_patterns)),
re_path(r'^versions/', include('olympia.versions.urls')),
@ -22,11 +20,11 @@ detail_patterns = [
urlpatterns = [
# URLs for a single add-on.
re_path(r'^addon/%s/' % ADDON_ID, include(detail_patterns)),
re_path(r'^find-replacement/$', views.find_replacement_addon,
name='addons.find_replacement'),
re_path(
r'^find-replacement/$',
views.find_replacement_addon,
name='addons.find_replacement',
),
# frontend block view
re_path(r'^blocked-addon/%s/' % ADDON_ID, frontend_view,
name='blocklist.block'),
re_path(r'^blocked-addon/%s/' % ADDON_ID, frontend_view, name='blocklist.block'),
]

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

@ -17,22 +17,26 @@ def generate_addon_guid():
def verify_mozilla_trademark(name, user, form=None):
skip_trademark_check = (
user and user.is_authenticated and action_allowed_user(
user, amo.permissions.TRADEMARK_BYPASS))
user
and user.is_authenticated
and action_allowed_user(user, amo.permissions.TRADEMARK_BYPASS)
)
def _check(name):
name = normalize_string(name, strip_punctuation=True).lower()
for symbol in amo.MOZILLA_TRADEMARK_SYMBOLS:
violates_trademark = (
name.count(symbol) > 1 or (
name.count(symbol) >= 1 and not
name.endswith(' for {}'.format(symbol))))
violates_trademark = name.count(symbol) > 1 or (
name.count(symbol) >= 1 and not name.endswith(' for {}'.format(symbol))
)
if violates_trademark:
raise forms.ValidationError(ugettext(
u'Add-on names cannot contain the Mozilla or '
u'Firefox trademarks.'))
raise forms.ValidationError(
ugettext(
u'Add-on names cannot contain the Mozilla or '
u'Firefox trademarks.'
)
)
if not skip_trademark_check:
if not isinstance(name, dict):
@ -45,7 +49,8 @@ def verify_mozilla_trademark(name, user, form=None):
if form is not None:
for message in exc.messages:
error_message = LocaleErrorMessage(
message=message, locale=locale)
message=message, locale=locale
)
form.add_error('name', error_message)
else:
raise
@ -54,9 +59,10 @@ def verify_mozilla_trademark(name, user, form=None):
TAAR_LITE_FALLBACKS = [
'enhancerforyoutube@maximerf.addons.mozilla.org', # /enhancer-for-youtube/
'{2e5ff8c8-32fe-46d0-9fc8-6b8986621f3c}', # /search_by_image/
'uBlock0@raymondhill.net', # /ublock-origin/
'newtaboverride@agenedia.com'] # /new-tab-override/
'{2e5ff8c8-32fe-46d0-9fc8-6b8986621f3c}', # /search_by_image/
'uBlock0@raymondhill.net', # /ublock-origin/
'newtaboverride@agenedia.com',
] # /new-tab-override/
TAAR_LITE_OUTCOME_REAL_SUCCESS = 'recommended'
TAAR_LITE_OUTCOME_REAL_FAIL = 'recommended_fallback'
@ -71,12 +77,17 @@ def get_addon_recommendations(guid_param, taar_enable):
fail_reason = None
if taar_enable:
guids = call_recommendation_server(
settings.TAAR_LITE_RECOMMENDATION_ENGINE_URL, guid_param, {})
outcome = (TAAR_LITE_OUTCOME_REAL_SUCCESS if guids
else TAAR_LITE_OUTCOME_REAL_FAIL)
settings.TAAR_LITE_RECOMMENDATION_ENGINE_URL, guid_param, {}
)
outcome = (
TAAR_LITE_OUTCOME_REAL_SUCCESS if guids else TAAR_LITE_OUTCOME_REAL_FAIL
)
if not guids:
fail_reason = (TAAR_LITE_FALLBACK_REASON_EMPTY if guids == []
else TAAR_LITE_FALLBACK_REASON_TIMEOUT)
fail_reason = (
TAAR_LITE_FALLBACK_REASON_EMPTY
if guids == []
else TAAR_LITE_FALLBACK_REASON_TIMEOUT
)
else:
outcome = TAAR_LITE_OUTCOME_CURATED
if not guids:
@ -90,5 +101,7 @@ def is_outcome_recommended(outcome):
def get_addon_recommendations_invalid():
return (
TAAR_LITE_FALLBACKS, TAAR_LITE_OUTCOME_REAL_FAIL,
TAAR_LITE_FALLBACK_REASON_INVALID)
TAAR_LITE_FALLBACKS,
TAAR_LITE_OUTCOME_REAL_FAIL,
TAAR_LITE_FALLBACK_REASON_INVALID,
)

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

@ -26,15 +26,27 @@ from olympia.amo.urlresolvers import get_outgoing_url
from olympia.api.exceptions import UnavailableForLegalReasons
from olympia.api.pagination import ESPageNumberPagination
from olympia.api.permissions import (
AllowAddonAuthor, AllowReadOnlyIfPublic, AllowRelatedObjectPermissions,
AllowReviewer, AllowReviewerUnlisted, AnyOf, GroupPermission,
RegionalRestriction)
AllowAddonAuthor,
AllowReadOnlyIfPublic,
AllowRelatedObjectPermissions,
AllowReviewer,
AllowReviewerUnlisted,
AnyOf,
GroupPermission,
RegionalRestriction,
)
from olympia.constants.categories import CATEGORIES_BY_ID
from olympia.search.filters import (
AddonAppQueryParam, AddonAppVersionQueryParam, AddonAuthorQueryParam,
AddonTypeQueryParam, AutoCompleteSortFilter,
ReviewedContentFilter, SearchParameterFilter, SearchQueryFilter,
SortingFilter)
AddonAppQueryParam,
AddonAppVersionQueryParam,
AddonAuthorQueryParam,
AddonTypeQueryParam,
AutoCompleteSortFilter,
ReviewedContentFilter,
SearchParameterFilter,
SearchQueryFilter,
SortingFilter,
)
from olympia.translations.query import order_by_translation
from olympia.versions.models import Version
@ -43,12 +55,20 @@ from .indexers import AddonIndexer
from .models import Addon, ReplacementAddon
from .serializers import (
AddonEulaPolicySerializer,
AddonSerializer, AddonSerializerWithUnlistedData,
ESAddonAutoCompleteSerializer, ESAddonSerializer, LanguageToolsSerializer,
ReplacementAddonSerializer, StaticCategorySerializer, VersionSerializer)
AddonSerializer,
AddonSerializerWithUnlistedData,
ESAddonAutoCompleteSerializer,
ESAddonSerializer,
LanguageToolsSerializer,
ReplacementAddonSerializer,
StaticCategorySerializer,
VersionSerializer,
)
from .utils import (
get_addon_recommendations, get_addon_recommendations_invalid,
is_outcome_recommended)
get_addon_recommendations,
get_addon_recommendations_invalid,
is_outcome_recommended,
)
log = olympia.core.logger.getLogger('z.addons')
@ -80,8 +100,9 @@ class BaseFilter(object):
def options(self, request, key, default):
"""Get the (option, title) pair we want according to the request."""
if key in request.GET and (request.GET[key] in self.opts_dict or
request.GET[key] in self.extras_dict):
if key in request.GET and (
request.GET[key] in self.opts_dict or request.GET[key] in self.extras_dict
):
opt = request.GET[key]
else:
opt = default
@ -149,12 +170,16 @@ def find_replacement_addon(request):
class AddonViewSet(RetrieveModelMixin, GenericViewSet):
permission_classes = [
AnyOf(AllowReadOnlyIfPublic, AllowAddonAuthor,
AllowReviewer, AllowReviewerUnlisted),
AnyOf(
AllowReadOnlyIfPublic,
AllowAddonAuthor,
AllowReviewer,
AllowReviewerUnlisted,
),
]
georestriction_classes = [
RegionalRestriction |
GroupPermission(amo.permissions.ADDONS_EDIT)]
RegionalRestriction | GroupPermission(amo.permissions.ADDONS_EDIT)
]
serializer_class = AddonSerializer
serializer_class_with_unlisted_data = AddonSerializerWithUnlistedData
lookup_value_regex = '[^/]+' # Allow '.' for email-like guids.
@ -164,9 +189,9 @@ class AddonViewSet(RetrieveModelMixin, GenericViewSet):
# Special case: admins - and only admins - can see deleted add-ons.
# This is handled outside a permission class because that condition
# would pollute all other classes otherwise.
if (self.request.user.is_authenticated and
acl.action_allowed(self.request,
amo.permissions.ADDONS_VIEW_DELETED)):
if self.request.user.is_authenticated and acl.action_allowed(
self.request, amo.permissions.ADDONS_VIEW_DELETED
):
qs = Addon.unfiltered.all()
else:
# Permission classes disallow access to non-public/unlisted add-ons
@ -187,9 +212,11 @@ class AddonViewSet(RetrieveModelMixin, GenericViewSet):
# we are allowed to access unlisted data.
obj = getattr(self, 'instance')
request = self.request
if (acl.check_unlisted_addons_reviewer(request) or
(obj and request.user.is_authenticated and
obj.authors.filter(pk=request.user.pk).exists())):
if acl.check_unlisted_addons_reviewer(request) or (
obj
and request.user.is_authenticated
and obj.authors.filter(pk=request.user.pk).exists()
):
return self.serializer_class_with_unlisted_data
return self.serializer_class
@ -248,15 +275,17 @@ class AddonViewSet(RetrieveModelMixin, GenericViewSet):
def eula_policy(self, request, pk=None):
obj = self.get_object()
serializer = AddonEulaPolicySerializer(
obj, context=self.get_serializer_context())
obj, context=self.get_serializer_context()
)
return Response(serializer.data)
class AddonChildMixin(object):
"""Mixin containing method to retrieve the parent add-on object."""
def get_addon_object(self, permission_classes=None,
georestriction_classes=None, lookup='addon_pk'):
def get_addon_object(
self, permission_classes=None, georestriction_classes=None, lookup='addon_pk'
):
"""Return the parent Addon object using the URL parameter passed
to the view.
@ -281,12 +310,14 @@ class AddonChildMixin(object):
permission_classes=permission_classes,
georestriction_classes=georestriction_classes,
kwargs={'pk': self.kwargs[lookup]},
action='retrieve_from_related').get_object()
action='retrieve_from_related',
).get_object()
return self.addon_object
class AddonVersionViewSet(AddonChildMixin, RetrieveModelMixin,
ListModelMixin, GenericViewSet):
class AddonVersionViewSet(
AddonChildMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet
):
# Permissions are always checked against the parent add-on in
# get_addon_object() using AddonViewSet.permission_classes so we don't need
# to set any here. Some extra permission classes are added dynamically
@ -301,33 +332,37 @@ class AddonVersionViewSet(AddonChildMixin, RetrieveModelMixin,
if requested == 'all_with_deleted':
# To see deleted versions, you need Addons:ViewDeleted.
self.permission_classes = [
GroupPermission(amo.permissions.ADDONS_VIEW_DELETED)]
GroupPermission(amo.permissions.ADDONS_VIEW_DELETED)
]
elif requested == 'all_with_unlisted':
# To see unlisted versions, you need to be add-on author or
# unlisted reviewer.
self.permission_classes = [AnyOf(
AllowReviewerUnlisted, AllowAddonAuthor)]
self.permission_classes = [
AnyOf(AllowReviewerUnlisted, AllowAddonAuthor)
]
elif requested == 'all_without_unlisted':
# To see all listed versions (not just public ones) you need to
# be add-on author or reviewer.
self.permission_classes = [AnyOf(
AllowReviewer, AllowReviewerUnlisted, AllowAddonAuthor)]
self.permission_classes = [
AnyOf(AllowReviewer, AllowReviewerUnlisted, AllowAddonAuthor)
]
# When listing, we can't use AllowRelatedObjectPermissions() with
# check_permissions(), because AllowAddonAuthor needs an author to
# do the actual permission check. To work around that, we call
# super + check_object_permission() ourselves, passing down the
# addon object directly.
return super(AddonVersionViewSet, self).check_object_permissions(
request, self.get_addon_object())
request, self.get_addon_object()
)
super(AddonVersionViewSet, self).check_permissions(request)
def check_object_permissions(self, request, obj):
# If the instance is marked as deleted and the client is not allowed to
# see deleted instances, we want to return a 404, behaving as if it
# does not exist.
if (obj.deleted and
not GroupPermission(amo.permissions.ADDONS_VIEW_DELETED).
has_object_permission(request, self, obj)):
if obj.deleted and not GroupPermission(
amo.permissions.ADDONS_VIEW_DELETED
).has_object_permission(request, self, obj):
raise http.Http404
if obj.channel == amo.RELEASE_CHANNEL_UNLISTED:
@ -335,13 +370,15 @@ class AddonVersionViewSet(AddonChildMixin, RetrieveModelMixin,
# authors..
self.permission_classes = [
AllowRelatedObjectPermissions(
'addon', [AnyOf(AllowReviewerUnlisted, AllowAddonAuthor)])
'addon', [AnyOf(AllowReviewerUnlisted, AllowAddonAuthor)]
)
]
elif not obj.is_public():
# If the instance is disabled, only allow reviewers and authors.
self.permission_classes = [
AllowRelatedObjectPermissions(
'addon', [AnyOf(AllowReviewer, AllowAddonAuthor)])
'addon', [AnyOf(AllowReviewer, AllowAddonAuthor)]
)
]
super(AddonVersionViewSet, self).check_object_permissions(request, obj)
@ -356,10 +393,12 @@ class AddonVersionViewSet(AddonChildMixin, RetrieveModelMixin,
if requested is not None:
if self.action != 'list':
raise serializers.ValidationError(
'The "filter" parameter is not valid in this context.')
'The "filter" parameter is not valid in this context.'
)
elif requested not in valid_filters:
raise serializers.ValidationError(
'Invalid "filter" parameter specified.')
'Invalid "filter" parameter specified.'
)
# When listing, by default we only want to return listed, approved
# versions, matching frontend needs - this can be overridden by the
# filter in use. When fetching a single instance however, we use the
@ -375,12 +414,11 @@ class AddonVersionViewSet(AddonChildMixin, RetrieveModelMixin,
elif requested == 'all_with_unlisted':
queryset = addon.versions.all()
elif requested == 'all_without_unlisted':
queryset = addon.versions.filter(
channel=amo.RELEASE_CHANNEL_LISTED)
queryset = addon.versions.filter(channel=amo.RELEASE_CHANNEL_LISTED)
else:
queryset = addon.versions.filter(
files__status=amo.STATUS_APPROVED,
channel=amo.RELEASE_CHANNEL_LISTED).distinct()
files__status=amo.STATUS_APPROVED, channel=amo.RELEASE_CHANNEL_LISTED
).distinct()
return queryset
@ -388,7 +426,9 @@ class AddonVersionViewSet(AddonChildMixin, RetrieveModelMixin,
class AddonSearchView(ListAPIView):
authentication_classes = []
filter_backends = [
ReviewedContentFilter, SearchQueryFilter, SearchParameterFilter,
ReviewedContentFilter,
SearchQueryFilter,
SearchParameterFilter,
SortingFilter,
]
pagination_class = ESPageNumberPagination
@ -396,12 +436,15 @@ class AddonSearchView(ListAPIView):
serializer_class = ESAddonSerializer
def get_queryset(self):
qset = Search(
using=amo.search.get_es(),
index=AddonIndexer.get_index_alias(),
doc_type=AddonIndexer.get_doctype_name()).extra(
_source={'excludes': AddonIndexer.hidden_fields}).params(
search_type='dfs_query_then_fetch')
qset = (
Search(
using=amo.search.get_es(),
index=AddonIndexer.get_index_alias(),
doc_type=AddonIndexer.get_doctype_name(),
)
.extra(_source={'excludes': AddonIndexer.hidden_fields})
.params(search_type='dfs_query_then_fetch')
)
return qset
@ -422,7 +465,9 @@ class AddonAutoCompleteSearchView(AddonSearchView):
pagination_class = None
serializer_class = ESAddonAutoCompleteSerializer
filter_backends = [
ReviewedContentFilter, SearchQueryFilter, SearchParameterFilter,
ReviewedContentFilter,
SearchQueryFilter,
SearchParameterFilter,
AutoCompleteSortFilter,
]
@ -443,12 +488,11 @@ class AddonAutoCompleteSearchView(AddonSearchView):
'type', # Needed to attach the Persona for icon_url (sadly).
)
qset = (
Search(
using=amo.search.get_es(),
index=AddonIndexer.get_index_alias(),
doc_type=AddonIndexer.get_doctype_name())
.extra(_source={'includes': included_fields}))
qset = Search(
using=amo.search.get_es(),
index=AddonIndexer.get_index_alias(),
doc_type=AddonIndexer.get_doctype_name(),
).extra(_source={'includes': included_fields})
return qset
@ -463,18 +507,19 @@ class AddonAutoCompleteSearchView(AddonSearchView):
class AddonFeaturedView(AddonSearchView):
"""Featuring is gone, so this view is a hollowed out shim that returns
recommended addons for api/v3."""
# We accept the 'page_size' parameter but we do not allow pagination for
# this endpoint since the order is random.
pagination_class = None
filter_backends = [
ReviewedContentFilter, SearchParameterFilter,
ReviewedContentFilter,
SearchParameterFilter,
]
def get(self, request, *args, **kwargs):
try:
page_size = int(
self.request.GET.get('page_size', api_settings.PAGE_SIZE))
page_size = int(self.request.GET.get('page_size', api_settings.PAGE_SIZE))
except ValueError:
raise exceptions.ParseError('Invalid page_size parameter')
@ -486,9 +531,8 @@ class AddonFeaturedView(AddonSearchView):
def filter_queryset(self, qs):
qs = super().filter_queryset(qs)
qs = qs.query(query.Bool(filter=[Q('term', is_recommended=True)]))
return (
qs.query('function_score', functions=[query.SF('random_score')])
.sort('_score')
return qs.query('function_score', functions=[query.SF('random_score')]).sort(
'_score'
)
@ -508,7 +552,8 @@ class StaticCategoryView(ListAPIView):
def finalize_response(self, request, response, *args, **kwargs):
response = super(StaticCategoryView, self).finalize_response(
request, response, *args, **kwargs)
request, response, *args, **kwargs
)
patch_cache_control(response, max_age=60 * 60 * 6)
return response
@ -522,8 +567,7 @@ class LanguageToolsView(ListAPIView):
@classmethod
def as_view(cls, **initkwargs):
"""The API is read-only so we can turn off atomic requests."""
return non_atomic_requests(
super(LanguageToolsView, cls).as_view(**initkwargs))
return non_atomic_requests(super(LanguageToolsView, cls).as_view(**initkwargs))
def get_query_params(self):
"""
@ -548,12 +592,8 @@ class LanguageToolsView(ListAPIView):
# appversion parameter is optional.
if AddonAppVersionQueryParam.query_param in self.request.GET:
try:
value = AddonAppVersionQueryParam(
self.request.GET).get_values()
appversions = {
'min': value[1],
'max': value[2]
}
value = AddonAppVersionQueryParam(self.request.GET).get_values()
appversions = {'min': value[1], 'max': value[2]}
except ValueError:
raise exceptions.ParseError('Invalid appversion parameter.')
else:
@ -565,12 +605,12 @@ class LanguageToolsView(ListAPIView):
# to filter by type if they want appversion filtering.
if AddonTypeQueryParam.query_param in self.request.GET or appversions:
try:
addon_types = tuple(
AddonTypeQueryParam(self.request.GET).get_values())
addon_types = tuple(AddonTypeQueryParam(self.request.GET).get_values())
except ValueError:
raise exceptions.ParseError(
'Invalid or missing type parameter while appversion '
'parameter is set.')
'parameter is set.'
)
else:
addon_types = (amo.ADDON_LPAPP, amo.ADDON_DICT)
@ -596,7 +636,8 @@ class LanguageToolsView(ListAPIView):
params = self.get_query_params()
if params['types'] == (amo.ADDON_LPAPP,) and params['appversions']:
qs = self.get_language_packs_queryset_with_appversions(
params['application'], params['appversions'])
params['application'], params['appversions']
)
else:
# appversions filtering only makes sense for language packs only,
# so it's ignored here.
@ -604,8 +645,8 @@ class LanguageToolsView(ListAPIView):
if params['authors']:
qs = qs.filter(
addonuser__user__username__in=params['authors'],
addonuser__listed=True).distinct()
addonuser__user__username__in=params['authors'], addonuser__listed=True
).distinct()
return qs
def get_queryset_base(self, application, addon_types):
@ -615,23 +656,25 @@ class LanguageToolsView(ListAPIView):
"""
return (
Addon.objects.public()
.filter(appsupport__app=application, type__in=addon_types,
target_locale__isnull=False)
.exclude(target_locale='')
.filter(
appsupport__app=application,
type__in=addon_types,
target_locale__isnull=False,
)
.exclude(target_locale='')
# Deactivate default transforms which fetch a ton of stuff we
# don't need here like authors, previews or current version.
# It would be nice to avoid translations entirely, because the
# translations transformer is going to fetch a lot of translations
# we don't need, but some language packs or dictionaries have
# custom names, so we can't use a generic one for them...
.only_translations()
.only_translations()
# Since we're fetching everything with no pagination, might as well
# not order it.
.order_by()
.order_by()
)
def get_language_packs_queryset_with_appversions(
self, application, appversions):
def get_language_packs_queryset_with_appversions(self, application, appversions):
"""
Return queryset to use specifically when requesting language packs
compatible with a given app + versions.
@ -647,18 +690,23 @@ class LanguageToolsView(ListAPIView):
# re-applying the default one that takes care of the files and compat
# info.
versions_qs = (
Version.objects
.latest_public_compatible_with(application, appversions)
.no_transforms().transform(Version.transformer))
Version.objects.latest_public_compatible_with(application, appversions)
.no_transforms()
.transform(Version.transformer)
)
return (
qs.prefetch_related(Prefetch('versions',
to_attr='compatible_versions',
queryset=versions_qs))
.filter(versions__apps__application=application,
versions__apps__min__version_int__lte=appversions['min'],
versions__apps__max__version_int__gte=appversions['max'],
versions__channel=amo.RELEASE_CHANNEL_LISTED,
versions__files__status=amo.STATUS_APPROVED)
qs.prefetch_related(
Prefetch(
'versions', to_attr='compatible_versions', queryset=versions_qs
)
)
.filter(
versions__apps__application=application,
versions__apps__min__version_int__lte=appversions['min'],
versions__apps__max__version_int__gte=appversions['max'],
versions__channel=amo.RELEASE_CHANNEL_LISTED,
versions__files__status=amo.STATUS_APPROVED,
)
.distinct()
)
@ -702,29 +750,37 @@ class AddonRecommendationView(AddonSearchView):
def get_paginated_response(self, data):
data = data[:4] # taar is only supposed to return 4 anyway.
return Response(OrderedDict([
('outcome', self.ab_outcome),
('fallback_reason', self.fallback_reason),
('page_size', 1),
('page_count', 1),
('count', len(data)),
('next', None),
('previous', None),
('results', data),
]))
return Response(
OrderedDict(
[
('outcome', self.ab_outcome),
('fallback_reason', self.fallback_reason),
('page_size', 1),
('page_count', 1),
('count', len(data)),
('next', None),
('previous', None),
('results', data),
]
)
)
def filter_queryset(self, qs):
qs = super(AddonRecommendationView, self).filter_queryset(qs)
guid_param = self.request.GET.get('guid')
taar_enable = self.request.GET.get('recommended', '').lower() == 'true'
guids, self.ab_outcome, self.fallback_reason = (
get_addon_recommendations(guid_param, taar_enable))
guids, self.ab_outcome, self.fallback_reason = get_addon_recommendations(
guid_param, taar_enable
)
results_qs = qs.query(query.Bool(must=[Q('terms', guid=guids)]))
results_qs.execute() # To cache the results.
if results_qs.count() != 4 and is_outcome_recommended(self.ab_outcome):
guids, self.ab_outcome, self.fallback_reason = (
get_addon_recommendations_invalid())
(
guids,
self.ab_outcome,
self.fallback_reason,
) = get_addon_recommendations_invalid()
return qs.query(query.Bool(must=[Q('terms', guid=guids)]))
return results_qs

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

@ -4,9 +4,17 @@ Miscellaneous helpers that make Django compatible with AMO.
from olympia.constants import permissions # noqa
from olympia.constants import promoted # noqa
from olympia.constants.activity import ( # noqa
LOG, LOG_BY_ID, LOG_ADMINS, LOG_REVIEWER_REVIEW_ACTION,
LOG_RATING_MODERATION, LOG_HIDE_DEVELOPER, LOG_KEEP, LOG_REVIEW_QUEUE,
LOG_REVIEW_QUEUE_DEVELOPER, LOG_REVIEW_EMAIL_USER)
LOG,
LOG_BY_ID,
LOG_ADMINS,
LOG_REVIEWER_REVIEW_ACTION,
LOG_RATING_MODERATION,
LOG_HIDE_DEVELOPER,
LOG_KEEP,
LOG_REVIEW_QUEUE,
LOG_REVIEW_QUEUE_DEVELOPER,
LOG_REVIEW_EMAIL_USER,
)
from olympia.constants.applications import * # noqa
from olympia.constants.base import * # noqa
from olympia.constants.licenses import * # noqa

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

@ -31,8 +31,10 @@ class CommaSearchInAdminMixin:
# Not pretty but looking up the actual field would require truly
# resolving the field name, walking to any relations we find up until
# the last one, that would be a lot of work for a simple edge case.
if any(field_name in lookup_fields for field_name in
('localized_string', 'localized_string_clean')):
if any(
field_name in lookup_fields
for field_name in ('localized_string', 'localized_string_clean')
):
rval = True
return rval
@ -103,24 +105,24 @@ class CommaSearchInAdminMixin:
queryset = queryset.filter(**{orm_lookup: search_terms})
else:
orm_lookups = [
construct_search(str(search_field))
for search_field in search_fields]
construct_search(str(search_field)) for search_field in search_fields
]
for bit in search_terms:
or_queries = [models.Q(**{orm_lookup: bit})
for orm_lookup in orm_lookups]
or_queries = [
models.Q(**{orm_lookup: bit}) for orm_lookup in orm_lookups
]
q_for_this_term = models.Q(
functools.reduce(operator.or_, or_queries))
q_for_this_term = models.Q(functools.reduce(operator.or_, or_queries))
filters.append(q_for_this_term)
use_distinct |= any(
# Use our own lookup_needs_distinct(), not django's.
self.lookup_needs_distinct(self.opts, search_spec)
for search_spec in orm_lookups)
for search_spec in orm_lookups
)
if filters:
queryset = queryset.filter(
functools.reduce(joining_operator, filters))
queryset = queryset.filter(functools.reduce(joining_operator, filters))
return queryset, use_distinct

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

@ -17,7 +17,10 @@ from celery.signals import task_failure, task_postrun, task_prerun
from django_statsd.clients import statsd
from kombu import serialization
from post_request_task.task import (
PostRequestTask, _start_queuing_tasks, _send_tasks_and_stop_queuing)
PostRequestTask,
_start_queuing_tasks,
_send_tasks_and_stop_queuing,
)
import olympia.core.logger
@ -38,35 +41,33 @@ class AMOTask(PostRequestTask):
that would cause them to try to serialize data that has already been
serialized...
"""
abstract = True
def _serialize_args_and_kwargs_for_eager_mode(
self, args=None, kwargs=None, **options):
self, args=None, kwargs=None, **options
):
producer = options.get('producer')
with app.producer_or_acquire(producer) as eager_producer:
serializer = options.get(
'serializer', eager_producer.serializer
)
serializer = options.get('serializer', eager_producer.serializer)
body = args, kwargs
content_type, content_encoding, data = serialization.dumps(
body, serializer
)
args, kwargs = serialization.loads(
data, content_type, content_encoding
)
content_type, content_encoding, data = serialization.dumps(body, serializer)
args, kwargs = serialization.loads(data, content_type, content_encoding)
return args, kwargs
def apply_async(self, args=None, kwargs=None, **options):
if app.conf.task_always_eager:
args, kwargs = self._serialize_args_and_kwargs_for_eager_mode(
args=args, kwargs=kwargs, **options)
args=args, kwargs=kwargs, **options
)
return super().apply_async(args=args, kwargs=kwargs, **options)
def apply(self, args=None, kwargs=None, **options):
if app.conf.task_always_eager:
args, kwargs = self._serialize_args_and_kwargs_for_eager_mode(
args=args, kwargs=kwargs, **options)
args=args, kwargs=kwargs, **options
)
return super().apply(args=args, kwargs=kwargs, **options)
@ -79,8 +80,9 @@ app.autodiscover_tasks()
@task_failure.connect
def process_failure_signal(exception, traceback, sender, task_id,
signal, args, kwargs, einfo, **kw):
def process_failure_signal(
exception, traceback, sender, task_id, signal, args, kwargs, einfo, **kw
):
"""Catch any task failure signals from within our worker processes and log
them as exceptions, so they appear in Sentry and ordinary logging
output."""
@ -94,18 +96,21 @@ def process_failure_signal(exception, traceback, sender, task_id,
'task_id': task_id,
'sender': sender,
'args': args,
'kwargs': kwargs
'kwargs': kwargs,
}
})
},
)
@task_prerun.connect
def start_task_timer(task_id, task, **kw):
timer = TaskTimer()
log.info('starting task timer; id={id}; name={name}; '
'current_dt={current_dt}'
.format(id=task_id, name=task.name,
current_dt=timer.current_datetime))
log.info(
'starting task timer; id={id}; name={name}; '
'current_dt={current_dt}'.format(
id=task_id, name=task.name, current_dt=timer.current_datetime
)
)
# Cache start time for one hour. This will allow us to catch crazy long
# tasks. Currently, stats indexing tasks run around 20-30 min.
@ -119,35 +124,41 @@ def track_task_run_time(task_id, task, **kw):
timer = TaskTimer()
start_time = cache.get(timer.cache_key(task_id))
if start_time is None:
log.info('could not track task run time; id={id}; name={name}; '
'current_dt={current_dt}'
.format(id=task_id, name=task.name,
current_dt=timer.current_datetime))
log.info(
'could not track task run time; id={id}; name={name}; '
'current_dt={current_dt}'.format(
id=task_id, name=task.name, current_dt=timer.current_datetime
)
)
else:
run_time = timer.current_epoch_ms - start_time
log.info('tracking task run time; id={id}; name={name}; '
'run_time={run_time}; current_dt={current_dt}'
.format(id=task_id, name=task.name,
current_dt=timer.current_datetime,
run_time=run_time))
log.info(
'tracking task run time; id={id}; name={name}; '
'run_time={run_time}; current_dt={current_dt}'.format(
id=task_id,
name=task.name,
current_dt=timer.current_datetime,
run_time=run_time,
)
)
statsd.timing('tasks.{}'.format(task.name), run_time)
cache.delete(timer.cache_key(task_id))
class TaskTimer(object):
def __init__(self):
from olympia.amo.utils import utc_millesecs_from_epoch
self.current_datetime = datetime.datetime.now()
self.current_epoch_ms = utc_millesecs_from_epoch(
self.current_datetime)
self.current_epoch_ms = utc_millesecs_from_epoch(self.current_datetime)
def cache_key(self, task_id):
return 'task_start_time.{}'.format(task_id)
def create_chunked_tasks_signatures(
task, items, chunk_size, task_args=None, task_kwargs=None):
task, items, chunk_size, task_args=None, task_kwargs=None
):
"""
Splits a task depending on a list of items into a bunch of tasks of the
specified chunk_size, passing a chunked queryset and optional additional
@ -155,6 +166,7 @@ def create_chunked_tasks_signatures(
Return the group of task signatures without executing it."""
from olympia.amo.utils import chunked
if task_args is None:
task_args = ()
if task_kwargs is None:
@ -164,11 +176,7 @@ def create_chunked_tasks_signatures(
task.si(chunk, *task_args, **task_kwargs)
for chunk in chunked(items, chunk_size)
]
log.info(
'Created a group of %s tasks for task "%s".',
len(tasks),
str(task.name)
)
log.info('Created a group of %s tasks for task "%s".', len(tasks), str(task.name))
return group(tasks)

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

@ -9,15 +9,16 @@ from olympia.amo.urlresolvers import reverse
def static_url(request):
return {'CDN_HOST': settings.CDN_HOST,
'STATIC_URL': settings.STATIC_URL}
return {'CDN_HOST': settings.CDN_HOST, 'STATIC_URL': settings.STATIC_URL}
def i18n(request):
lang = get_language()
return {'LANGUAGES': settings.LANGUAGES,
'LANG': settings.LANGUAGE_URL_MAP.get(lang) or lang,
'DIR': 'rtl' if get_language_bidi() else 'ltr'}
return {
'LANGUAGES': settings.LANGUAGES,
'LANG': settings.LANGUAGE_URL_MAP.get(lang) or lang,
'DIR': 'rtl' if get_language_bidi() else 'ltr',
}
def global_settings(request):
@ -37,58 +38,84 @@ def global_settings(request):
if getattr(request, 'user', AnonymousUser()).is_authenticated:
is_reviewer = acl.is_user_any_kind_of_reviewer(request.user)
account_links.append({'text': ugettext('My Profile'),
'href': request.user.get_url_path()})
account_links.append(
{'text': ugettext('My Profile'), 'href': request.user.get_url_path()}
)
account_links.append({'text': ugettext('Account Settings'),
'href': reverse('users.edit')})
account_links.append({
'text': ugettext('My Collections'),
'href': reverse('collections.list')})
account_links.append(
{'text': ugettext('Account Settings'), 'href': reverse('users.edit')}
)
account_links.append(
{'text': ugettext('My Collections'), 'href': reverse('collections.list')}
)
if request.user.favorite_addons:
account_links.append(
{'text': ugettext('My Favorites'),
'href': reverse('collections.detail',
args=[request.user.id, 'favorites'])})
{
'text': ugettext('My Favorites'),
'href': reverse(
'collections.detail', args=[request.user.id, 'favorites']
),
}
)
account_links.append({
'text': ugettext('Log out'),
'href': reverse('devhub.logout') + '?to=' + urlquote(request.path),
})
account_links.append(
{
'text': ugettext('Log out'),
'href': reverse('devhub.logout') + '?to=' + urlquote(request.path),
}
)
if request.user.is_developer:
tools_links.append({'text': ugettext('Manage My Submissions'),
'href': reverse('devhub.addons')})
tools_links.append(
{
'text': ugettext('Manage My Submissions'),
'href': reverse('devhub.addons'),
}
)
tools_links.append(
{'text': ugettext('Submit a New Add-on'),
'href': reverse('devhub.submit.agreement')})
{
'text': ugettext('Submit a New Add-on'),
'href': reverse('devhub.submit.agreement'),
}
)
tools_links.append(
{'text': ugettext('Submit a New Theme'),
'href': reverse('devhub.submit.agreement')})
{
'text': ugettext('Submit a New Theme'),
'href': reverse('devhub.submit.agreement'),
}
)
tools_links.append(
{'text': ugettext('Developer Hub'),
'href': reverse('devhub.index')})
{'text': ugettext('Developer Hub'), 'href': reverse('devhub.index')}
)
tools_links.append(
{'text': ugettext('Manage API Keys'),
'href': reverse('devhub.api_key')}
{'text': ugettext('Manage API Keys'), 'href': reverse('devhub.api_key')}
)
if is_reviewer:
tools_links.append({'text': ugettext('Reviewer Tools'),
'href': reverse('reviewers.dashboard')})
tools_links.append(
{
'text': ugettext('Reviewer Tools'),
'href': reverse('reviewers.dashboard'),
}
)
if acl.action_allowed(request, amo.permissions.ANY_ADMIN):
tools_links.append({'text': ugettext('Admin Tools'),
'href': reverse('admin:index')})
tools_links.append(
{'text': ugettext('Admin Tools'), 'href': reverse('admin:index')}
)
context['user'] = request.user
else:
context['user'] = AnonymousUser()
context.update({'account_links': account_links,
'settings': settings,
'amo': amo,
'tools_links': tools_links,
'tools_title': tools_title,
'is_reviewer': is_reviewer})
context.update(
{
'account_links': account_links,
'settings': settings,
'amo': amo,
'tools_links': tools_links,
'tools_title': tools_title,
'is_reviewer': is_reviewer,
}
)
return context

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

@ -22,18 +22,21 @@ log = olympia.core.logger.getLogger('z.cron')
def gc(test_result=True):
"""Site-wide garbage collections."""
def days_ago(days):
return datetime.today() - timedelta(days=days)
log.info('Collecting data to delete')
logs = (ActivityLog.objects.filter(created__lt=days_ago(90))
.exclude(action__in=amo.LOG_KEEP).values_list('id', flat=True))
logs = (
ActivityLog.objects.filter(created__lt=days_ago(90))
.exclude(action__in=amo.LOG_KEEP)
.values_list('id', flat=True)
)
collections_to_delete = (
Collection.objects.filter(created__lt=days_ago(2),
type=amo.COLLECTION_ANONYMOUS)
.values_list('id', flat=True))
collections_to_delete = Collection.objects.filter(
created__lt=days_ago(2), type=amo.COLLECTION_ANONYMOUS
).values_list('id', flat=True)
for chunk in chunked(logs, 100):
tasks.delete_logs.delay(chunk)
@ -44,19 +47,20 @@ def gc(test_result=True):
# Delete stale add-ons with no versions. Should soft-delete add-ons that
# are somehow not in incomplete status, hard-delete the rest. No email
# should be sent in either case.
versionless_addons = (
Addon.objects.filter(versions__pk=None, created__lte=a_week_ago)
.values_list('pk', flat=True)
)
versionless_addons = Addon.objects.filter(
versions__pk=None, created__lte=a_week_ago
).values_list('pk', flat=True)
for chunk in chunked(versionless_addons, 100):
delete_addons.delay(chunk)
# Delete stale FileUploads.
stale_uploads = FileUpload.objects.filter(
created__lte=a_week_ago).order_by('id')
stale_uploads = FileUpload.objects.filter(created__lte=a_week_ago).order_by('id')
for file_upload in stale_uploads:
log.info(u'[FileUpload:{uuid}] Removing file: {path}'
.format(uuid=file_upload.uuid, path=file_upload.path))
log.info(
u'[FileUpload:{uuid}] Removing file: {path}'.format(
uuid=file_upload.uuid, path=file_upload.path
)
)
if file_upload.path:
try:
storage.delete(file_upload.path)
@ -77,7 +81,8 @@ def category_totals():
file_statuses = ",".join(['%s'] * len(VALID_FILE_STATUSES))
with connection.cursor() as cursor:
cursor.execute("""
cursor.execute(
"""
UPDATE categories AS t INNER JOIN (
SELECT at.category_id, COUNT(DISTINCT Addon.id) AS ct
FROM addons AS Addon
@ -93,5 +98,7 @@ def category_totals():
GROUP BY at.category_id)
AS j ON (t.id = j.category_id)
SET t.count = j.ct
""" % (file_statuses, addon_statuses),
VALID_FILE_STATUSES + VALID_ADDON_STATUSES)
"""
% (file_statuses, addon_statuses),
VALID_FILE_STATUSES + VALID_ADDON_STATUSES,
)

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

@ -21,11 +21,13 @@ def login_required(f=None, redirect=True):
If redirect=False then we return 401 instead of redirecting to the
login page. That's nice for ajax views.
"""
def decorator(func):
@functools.wraps(func)
def wrapper(request, *args, **kw):
# Prevent circular ref in accounts.utils
from olympia.accounts.utils import redirect_for_login
if request.user.is_authenticated:
return func(request, *args, **kw)
else:
@ -33,7 +35,9 @@ def login_required(f=None, redirect=True):
return redirect_for_login(request)
else:
return http.HttpResponse(status=401)
return wrapper
if f:
return decorator(f)
else:
@ -47,6 +51,7 @@ def post_required(f):
return http.HttpResponseNotAllowed(['POST'])
else:
return f(request, *args, **kw)
return wrapper
@ -56,11 +61,14 @@ def permission_required(permission):
@login_required
def wrapper(request, *args, **kw):
from olympia.access import acl
if acl.action_allowed(request, permission):
return f(request, *args, **kw)
else:
raise PermissionDenied
return wrapper
return decorator
@ -71,13 +79,14 @@ def json_response(response, has_trans=False, status_code=200):
"""
# to avoid circular imports with users.models
from .utils import AMOJSONEncoder
if has_trans:
response = json.dumps(response, cls=AMOJSONEncoder)
else:
response = json.dumps(response)
return http.HttpResponse(response,
content_type='application/json',
status=status_code)
return http.HttpResponse(
response, content_type='application/json', status=status_code
)
def json_view(f=None, has_trans=False, status_code=200):
@ -88,9 +97,12 @@ def json_view(f=None, has_trans=False, status_code=200):
if isinstance(response, http.HttpResponse):
return response
else:
return json_response(response, has_trans=has_trans,
status_code=status_code)
return json_response(
response, has_trans=has_trans, status_code=status_code
)
return wrapper
if f:
return decorator(f)
else:
@ -98,7 +110,8 @@ def json_view(f=None, has_trans=False, status_code=200):
json_view.error = lambda s: http.HttpResponseBadRequest(
json.dumps(s), content_type='application/json')
json.dumps(s), content_type='application/json'
)
def use_primary_db(f):
@ -106,6 +119,7 @@ def use_primary_db(f):
def wrapper(*args, **kw):
with context.use_primary_db():
return f(*args, **kw)
return wrapper
@ -130,8 +144,10 @@ def set_modified_on(f):
# kwargs to the set_modified_on_object task. Useful to set
# things like icon hashes.
kwargs_from_result = result if isinstance(result, dict) else {}
task_log.info('Delaying setting modified on object: %s, %s' %
(obj_info[0], obj_info[1]))
task_log.info(
'Delaying setting modified on object: %s, %s'
% (obj_info[0], obj_info[1])
)
# Execute set_modified_on_object in NFS_LAG_DELAY seconds. This
# allows us to make sure any changes have been written to disk
# before changing modification date and/or image hashes stored
@ -140,15 +156,20 @@ def set_modified_on(f):
set_modified_on_object.apply_async(
args=obj_info,
kwargs=kwargs_from_result,
eta=(datetime.datetime.now() +
datetime.timedelta(seconds=settings.NFS_LAG_DELAY)))
eta=(
datetime.datetime.now()
+ datetime.timedelta(seconds=settings.NFS_LAG_DELAY)
),
)
return result
return wrapper
def allow_cross_site_request(f):
"""Allow other sites to access this resource, see
https://developer.mozilla.org/en/HTTP_access_control."""
@functools.wraps(f)
def wrapper(request, *args, **kw):
response = f(request, *args, **kw)
@ -158,6 +179,7 @@ def allow_cross_site_request(f):
response['Access-Control-Allow-Origin'] = '*'
response['Access-Control-Allow-Methods'] = 'GET'
return response
return wrapper
@ -170,9 +192,11 @@ def allow_mine(f):
"""
# Prevent circular ref in accounts.utils
from olympia.accounts.utils import redirect_for_login
if user_id == 'mine':
if not request.user.is_authenticated:
return redirect_for_login(request)
user_id = request.user.id
return f(request, user_id, *args, **kw)
return wrapper

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

@ -10,6 +10,7 @@ class BaseFeed(Feed):
A base feed class that does not use transactions and tries to avoid raising
exceptions on unserializable content.
"""
# Regexp controlling which characters to strip from the items because they
# would raise UnserializableContentError. Pretty much all control chars
# except things like line feed, carriage return etc which are fine.
@ -26,8 +27,7 @@ class BaseFeed(Feed):
# we're returning, so we can use it to strip XML control chars before they
# are being used. This avoid raising UnserializableContentError later.
def _get_dynamic_attr(self, attname, obj, default=None):
data = super(BaseFeed, self)._get_dynamic_attr(
attname, obj, default=default)
data = super(BaseFeed, self)._get_dynamic_attr(attname, obj, default=default)
# Limite the search to the item types we know can potentially contain
# some weird characters.
@ -38,7 +38,7 @@ class BaseFeed(Feed):
'item_author_name',
'item_description',
'item_title',
'title'
'title',
)
if data and attname in problematic_keys:
data = re.sub(self.CONTROL_CHARS_REGEXP, '', str(data))

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

@ -19,6 +19,7 @@ class PositiveAutoField(models.AutoField):
Because AutoFields are special we need a custom database backend to support
using them. See olympia.core.db.mysql.base for that."""
description = _("Positive integer")
def get_internal_type(self):
@ -29,7 +30,6 @@ class PositiveAutoField(models.AutoField):
class HttpHttpsOnlyURLField(fields.URLField):
def __init__(self, *args, **kwargs):
super(HttpHttpsOnlyURLField, self).__init__(*args, **kwargs)
@ -42,10 +42,11 @@ class HttpHttpsOnlyURLField(fields.URLField):
message=_(
'This field can only be used to link to external websites.'
' URLs on %(domain)s are not allowed.',
) % {'domain': settings.DOMAIN},
)
% {'domain': settings.DOMAIN},
code='no_amo_url',
inverse_match=True
)
inverse_match=True,
),
]
@ -62,15 +63,14 @@ def validate_cidr(value):
ipaddress.ip_network(value)
except ValueError:
raise exceptions.ValidationError(
_('Enter a valid IP4 or IP6 network.'), code='invalid')
_('Enter a valid IP4 or IP6 network.'), code='invalid'
)
class CIDRField(models.Field):
empty_strings_allowed = False
description = _('CIDR')
default_error_messages = {
'invalid': _('Enter a valid IP4 or IP6 network.')
}
default_error_messages = {'invalid': _('Enter a valid IP4 or IP6 network.')}
def __init__(self, verbose_name=None, name=None, *args, **kwargs):
self.validators = [validate_cidr]
@ -112,9 +112,6 @@ class CIDRField(models.Field):
return str(value)
def formfield(self, **kwargs):
defaults = {
'form_class': fields.CharField,
'validators': self.validators
}
defaults = {'form_class': fields.CharField, 'validators': self.validators}
defaults.update(kwargs)
return super(CIDRField, self).formfield(**defaults)

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

@ -5,7 +5,6 @@ from django.utils.functional import cached_property
class AMOModelForm(forms.ModelForm):
@cached_property
def changed_data(self):
"""
@ -19,9 +18,9 @@ class AMOModelForm(forms.ModelForm):
changed_data = forms.ModelForm.changed_data.__get__(self)[:]
changed_translation_fields = [
field.name for field in Model._meta.get_fields()
if isinstance(field, TranslatedField) and
field.name in changed_data
field.name
for field in Model._meta.get_fields()
if isinstance(field, TranslatedField) and field.name in changed_data
]
# If there are translated fields, pull the model from the database

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

@ -25,6 +25,7 @@ class BaseSearchIndexer(object):
- create_new_index(cls, index_name)
- reindex_tasks_group(cls, index_name)
"""
@classmethod
def get_index_alias(cls):
return settings.ES_INDEXES.get(cls.get_model().ES_ALIAS_KEY)
@ -51,8 +52,9 @@ class BaseSearchIndexer(object):
for field_name in field_names:
# _translations is the suffix in TranslationSerializer.
mapping[doc_name]['properties']['%s_translations' % field_name] = (
cls.get_translations_definition())
mapping[doc_name]['properties'][
'%s_translations' % field_name
] = cls.get_translations_definition()
@classmethod
def get_translations_definition(cls):
@ -65,8 +67,8 @@ class BaseSearchIndexer(object):
'type': 'object',
'properties': {
'lang': {'type': 'text', 'index': False},
'string': {'type': 'text', 'index': False}
}
'string': {'type': 'text', 'index': False},
},
}
@classmethod
@ -108,8 +110,7 @@ class BaseSearchIndexer(object):
}
@classmethod
def attach_language_specific_analyzers_with_raw_variant(
cls, mapping, field_names):
def attach_language_specific_analyzers_with_raw_variant(cls, mapping, field_names):
"""
Like attach_language_specific_analyzers() but with an extra field to
storethe "raw" variant of the value, for exact matches.
@ -124,7 +125,7 @@ class BaseSearchIndexer(object):
'analyzer': analyzer,
'fields': {
'raw': cls.get_raw_field_definition(),
}
},
}
@classmethod
@ -137,7 +138,8 @@ class BaseSearchIndexer(object):
db_field = '%s_id' % field
extend_with_me = {
'%s_translations' % field: [
'%s_translations'
% field: [
{'lang': to_language(lang), 'string': str(string)}
for lang, string in obj.translations[getattr(obj, db_field)]
if string
@ -174,9 +176,7 @@ class BaseSearchIndexer(object):
if db_field is None:
db_field = '%s_id' % field
translations = dict(
obj.translations[getattr(obj, db_field)]
)
translations = dict(obj.translations[getattr(obj, db_field)])
return {
'%s_l10n_%s' % (field, lang): translations.get(lang) or ''

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

@ -8,8 +8,10 @@ from olympia.applications.models import AppVersion
class Command(BaseCommand):
help = ('Add a new version of an application. Syntax: \n'
' ./manage.py addnewversion <application_name> <version>')
help = (
'Add a new version of an application. Syntax: \n'
' ./manage.py addnewversion <application_name> <version>'
)
log = olympia.core.logger.getLogger('z.appversions')
def add_arguments(self, parser):
@ -23,7 +25,9 @@ class Command(BaseCommand):
raise CommandError(self.help)
msg = 'Adding version %r to application %r\n' % (
options['version'], options['application_name'])
options['version'],
options['application_name'],
)
self.log.info(msg)
self.stdout.write(msg)
@ -32,7 +36,6 @@ def do_addnewversion(application, version):
if application not in amo.APPS:
raise CommandError('Application %r does not exist.' % application)
try:
AppVersion.objects.create(application=amo.APPS[application].id,
version=version)
AppVersion.objects.create(application=amo.APPS[application].id, version=version)
except IntegrityError as e:
raise CommandError('Version %r already exists: %r' % (version, e))

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

@ -16,7 +16,8 @@ from olympia.lib.jingo_minify_helpers import ensure_path_exists
def run_command(command):
"""Run a command and correctly poll the output and write that to stdout"""
process = subprocess.Popen(
command, stdout=subprocess.PIPE, shell=True, universal_newlines=True)
command, stdout=subprocess.PIPE, shell=True, universal_newlines=True
)
while True:
output = process.stdout.readline()
if output == '' and process.poll() is not None:
@ -27,7 +28,7 @@ def run_command(command):
class Command(BaseCommand):
help = ('Compresses css and js assets defined in settings.MINIFY_BUNDLES')
help = 'Compresses css and js assets defined in settings.MINIFY_BUNDLES'
# This command must not do any system checks because Django runs db-field
# related checks since 1.10 which require a working MySQL connection.
@ -44,19 +45,19 @@ class Command(BaseCommand):
def add_arguments(self, parser):
"""Handle command arguments."""
parser.add_argument(
'--force', action='store_true',
help='Ignores modified/created dates and forces compression.')
'--force',
action='store_true',
help='Ignores modified/created dates and forces compression.',
)
def generate_build_id(self):
return uuid.uuid4().hex[:8]
def update_hashes(self):
# Adds a time based hash on to the build id.
self.build_id = '%s-%s' % (
self.generate_build_id(), hex(int(time.time()))[2:])
self.build_id = '%s-%s' % (self.generate_build_id(), hex(int(time.time()))[2:])
build_id_file = os.path.realpath(
os.path.join(settings.ROOT, 'build.py'))
build_id_file = os.path.realpath(os.path.join(settings.ROOT, 'build.py'))
with open(build_id_file, 'w') as f:
f.write('BUILD_ID_CSS = "%s"\n' % self.build_id)
@ -75,11 +76,25 @@ class Command(BaseCommand):
for name, files in bundle.items():
# Set the paths to the files.
concatted_file = os.path.join(
settings.ROOT, 'static',
ftype, '%s-all.%s' % (name, ftype,))
settings.ROOT,
'static',
ftype,
'%s-all.%s'
% (
name,
ftype,
),
)
compressed_file = os.path.join(
settings.ROOT, 'static',
ftype, '%s-min.%s' % (name, ftype,))
settings.ROOT,
'static',
ftype,
'%s-min.%s'
% (
name,
ftype,
),
)
ensure_path_exists(concatted_file)
ensure_path_exists(compressed_file)
@ -96,13 +111,13 @@ class Command(BaseCommand):
if len(files_all) == 0:
raise CommandError(
'No input files specified in '
'MINIFY_BUNDLES["%s"]["%s"] in settings.py!' %
(ftype, name)
'MINIFY_BUNDLES["%s"]["%s"] in settings.py!' % (ftype, name)
)
run_command('cat {files} > {tmp}'.format(
files=' '.join(files_all),
tmp=tmp_concatted
))
run_command(
'cat {files} > {tmp}'.format(
files=' '.join(files_all), tmp=tmp_concatted
)
)
# Cache bust individual images in the CSS.
if ftype == 'css':
@ -116,8 +131,8 @@ class Command(BaseCommand):
self._minify(ftype, concatted_file, compressed_file)
else:
print(
'File unchanged, skipping minification of %s' % (
concatted_file))
'File unchanged, skipping minification of %s' % (concatted_file)
)
self.minify_skipped += 1
# Write out the hashes
@ -125,8 +140,8 @@ class Command(BaseCommand):
if self.minify_skipped:
print(
'Unchanged files skipped for minification: %s' % (
self.minify_skipped))
'Unchanged files skipped for minification: %s' % (self.minify_skipped)
)
def _preprocess_file(self, filename):
"""Preprocess files and return new filenames."""
@ -135,10 +150,11 @@ class Command(BaseCommand):
target = source
if css_bin:
target = '%s.css' % source
run_command('{lessc} {source} {target}'.format(
lessc=css_bin,
source=str(source),
target=str(target)))
run_command(
'{lessc} {source} {target}'.format(
lessc=css_bin, source=str(source), target=str(target)
)
)
return target
def _is_changed(self, concatted_file):
@ -147,9 +163,9 @@ class Command(BaseCommand):
return True
tmp_concatted = '%s.tmp' % concatted_file
file_exists = (
os.path.exists(concatted_file) and
os.path.getsize(concatted_file) == os.path.getsize(tmp_concatted))
file_exists = os.path.exists(concatted_file) and os.path.getsize(
concatted_file
) == os.path.getsize(tmp_concatted)
if file_exists:
orig_hash = self._file_hash(concatted_file)
temp_hash = self._file_hash(tmp_concatted)
@ -166,7 +182,8 @@ class Command(BaseCommand):
def _cachebust(self, css_file, bundle_name):
"""Cache bust images. Return a new bundle hash."""
self.stdout.write(
'Cache busting images in %s\n' % re.sub('.tmp$', '', css_file))
'Cache busting images in %s\n' % re.sub('.tmp$', '', css_file)
)
if not os.path.exists(css_file):
return
@ -188,8 +205,7 @@ class Command(BaseCommand):
self.checked_hash[css_file] = file_hash
if self.missing_files:
self.stdout.write(
' - Error finding %s images\n' % (self.missing_files,))
self.stdout.write(' - Error finding %s images\n' % (self.missing_files,))
self.missing_files = 0
return file_hash
@ -200,20 +216,18 @@ class Command(BaseCommand):
opts = {'method': 'terser', 'bin': settings.JS_MINIFIER_BIN}
run_command(
'{bin} --compress --mangle -o {target} {source} -m'.format(
bin=opts['bin'],
target=file_out,
source=file_in
bin=opts['bin'], target=file_out, source=file_in
)
)
elif ftype == 'css' and hasattr(settings, 'CLEANCSS_BIN'):
opts = {'method': 'clean-css', 'bin': settings.CLEANCSS_BIN}
run_command('{cleancss} -o {target} {source}'.format(
cleancss=opts['bin'],
target=file_out,
source=file_in))
run_command(
'{cleancss} -o {target} {source}'.format(
cleancss=opts['bin'], target=file_out, source=file_in
)
)
self.stdout.write(
'Minifying %s (using %s)\n' % (file_in, opts['method']))
self.stdout.write('Minifying %s (using %s)\n' % (file_in, opts['method']))
def _file_hash(self, url):
"""Open the file and get a hash of it."""
@ -238,7 +252,6 @@ class Command(BaseCommand):
return 'url(%s)' % url
url = url.split('?')[0]
full_url = os.path.join(
settings.ROOT, os.path.dirname(parent), url)
full_url = os.path.join(settings.ROOT, os.path.dirname(parent), url)
return 'url(%s?%s)' % (url, self._file_hash(full_url))

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

@ -11,13 +11,12 @@ class Command(BaseCommand):
"""Based on django_extension's reset_db command but simplifed and with
support for all character sets defined in settings."""
help = ('Creates the database for this project.')
help = 'Creates the database for this project.'
def add_arguments(self, parser):
super(Command, self).add_arguments(parser)
parser.add_argument(
'--force', action='store_true',
help='Drops any existing database first.'
'--force', action='store_true', help='Drops any existing database first.'
)
def handle(self, *args, **options):
@ -47,7 +46,9 @@ class Command(BaseCommand):
character_set = db_info.get('OPTIONS').get('charset', 'utf8mb4')
create_query = 'CREATE DATABASE `%s` CHARACTER SET %s' % (
database_name, character_set)
database_name,
character_set,
)
if drop_query:
logging.info('Executing... "' + drop_query + '"')
connection.query(drop_query)

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

@ -24,20 +24,26 @@ class Command(BaseCommand):
def handle(self, *args, **options):
if not options['name']:
log.error("Cron called without args")
raise CommandError('These jobs are available:\n%s' % '\n'.join(
sorted(settings.CRON_JOBS.keys())))
raise CommandError(
'These jobs are available:\n%s'
% '\n'.join(sorted(settings.CRON_JOBS.keys()))
)
name, args_and_kwargs = options['name'], options['cron_args']
args = [arg for arg in args_and_kwargs if '=' not in arg]
kwargs = dict(
(kwarg.split('=', maxsplit=1) for kwarg in args_and_kwargs
if kwarg not in args))
(
kwarg.split('=', maxsplit=1)
for kwarg in args_and_kwargs
if kwarg not in args
)
)
path = settings.CRON_JOBS.get(name)
if not path:
log.error(
'Cron called with an unknown cron job: '
f'{name} {args} {kwargs}')
'Cron called with an unknown cron job: ' f'{name} {args} {kwargs}'
)
raise CommandError(f'Unrecognized job name: {name}')
module = import_module(path)
@ -46,8 +52,10 @@ class Command(BaseCommand):
log.info(
f'Beginning job: {name} {args} {kwargs} '
f'(start timestamp: {current_millis})')
f'(start timestamp: {current_millis})'
)
getattr(module, name)(*args, **kwargs)
log.info(
f'Ending job: {name} {args} {kwargs} '
f'(start timestamp: {current_millis})')
f'(start timestamp: {current_millis})'
)

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

@ -18,7 +18,8 @@ class Command(BaseCommand):
fake_request.method = 'GET'
for lang in settings.AMO_LANGUAGES:
filename = os.path.join(
settings.STATICFILES_DIRS[0], 'js', 'i18n', '%s.js' % lang)
settings.STATICFILES_DIRS[0], 'js', 'i18n', '%s.js' % lang
)
with translation.override(lang):
response = JavaScriptCatalog.as_view()(fake_request)
with open(filename, 'w') as f:

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

@ -33,11 +33,13 @@ class DoubleSafe(safestring.SafeText, jinja2.Markup):
"""
def _make_message(title=None, message=None, title_safe=False,
message_safe=False):
def _make_message(title=None, message=None, title_safe=False, message_safe=False):
context = {
'title': title, 'message': message,
'title_safe': title_safe, 'message_safe': message_safe}
'title': title,
'message': message,
'title_safe': title_safe,
'message_safe': message_safe,
}
tpl = loader.get_template('message_content.html').render(context)
return DoubleSafe(tpl)
@ -67,8 +69,16 @@ def _is_dupe(msg, request):
return is_dupe
def _file_message(type_, request, title, message=None, extra_tags='',
fail_silently=False, title_safe=False, message_safe=False):
def _file_message(
type_,
request,
title,
message=None,
extra_tags='',
fail_silently=False,
title_safe=False,
message_safe=False,
):
msg = _make_message(title, message, title_safe, message_safe)
# Don't save duplicates.
if _is_dupe(msg, request):

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

@ -12,8 +12,10 @@ from django.contrib.sessions.middleware import SessionMiddleware
from django.db import transaction
from django.urls import is_valid_path
from django.http import (
HttpResponsePermanentRedirect, HttpResponseRedirect,
JsonResponse)
HttpResponsePermanentRedirect,
HttpResponseRedirect,
JsonResponse,
)
from django.middleware import common
from django.utils.cache import patch_cache_control, patch_vary_headers
from django.utils.deprecation import MiddlewareMixin
@ -53,13 +55,14 @@ class LocaleAndAppURLMiddleware(MiddlewareMixin):
urlresolvers.set_url_prefix(prefixer)
full_path = prefixer.fix(prefixer.shortened_path)
if (prefixer.app == amo.MOBILE.short and
request.path.rstrip('/').endswith('/' + amo.MOBILE.short)):
if prefixer.app == amo.MOBILE.short and request.path.rstrip('/').endswith(
'/' + amo.MOBILE.short
):
return redirect_type(request.path.replace('/mobile', '/android'))
if ('lang' in request.GET and not re.match(
settings.SUPPORTED_NONAPPS_NONLOCALES_REGEX,
prefixer.shortened_path)):
if 'lang' in request.GET and not re.match(
settings.SUPPORTED_NONAPPS_NONLOCALES_REGEX, prefixer.shortened_path
):
# Blank out the locale so that we can set a new one. Remove lang
# from query params so we don't have an infinite loop.
prefixer.locale = ''
@ -103,13 +106,14 @@ class AuthenticationMiddlewareWithoutAPI(AuthenticationMiddleware):
Like AuthenticationMiddleware, but disabled for the API, which uses its
own authentication mechanism.
"""
def process_request(self, request):
if request.is_api and not auth_path.match(request.path):
request.user = AnonymousUser()
else:
return super(
AuthenticationMiddlewareWithoutAPI,
self).process_request(request)
return super(AuthenticationMiddlewareWithoutAPI, self).process_request(
request
)
class NoVarySessionMiddleware(SessionMiddleware):
@ -122,6 +126,7 @@ class NoVarySessionMiddleware(SessionMiddleware):
We skip the cache in Zeus if someone has an AMOv3+ cookie, so varying on
Cookie at this level only hurts us.
"""
def process_response(self, request, response):
if settings.READ_ONLY:
return response
@ -132,9 +137,9 @@ class NoVarySessionMiddleware(SessionMiddleware):
if hasattr(response, 'get'):
vary = response.get('Vary', None)
new_response = (
super(NoVarySessionMiddleware, self)
.process_response(request, response))
new_response = super(NoVarySessionMiddleware, self).process_response(
request, response
)
if vary:
new_response['Vary'] = vary
@ -152,10 +157,12 @@ class RemoveSlashMiddleware(MiddlewareMixin):
"""
def process_response(self, request, response):
if (response.status_code == 404 and
request.path_info.endswith('/') and
not is_valid_path(request.path_info) and
is_valid_path(request.path_info[:-1])):
if (
response.status_code == 404
and request.path_info.endswith('/')
and not is_valid_path(request.path_info)
and is_valid_path(request.path_info[:-1])
):
# Use request.path because we munged app/locale in path_info.
newurl = request.path[:-1]
if request.GET:
@ -183,7 +190,6 @@ def safe_query_string(request):
class CommonMiddleware(common.CommonMiddleware):
def process_request(self, request):
with safe_query_string(request):
return super(CommonMiddleware, self).process_request(request)
@ -194,6 +200,7 @@ class NonAtomicRequestsForSafeHttpMethodsMiddleware(MiddlewareMixin):
Middleware to make the view non-atomic if the HTTP method used is safe,
in order to avoid opening and closing a useless transaction.
"""
def process_view(self, request, view_func, view_args, view_kwargs):
# This uses undocumented django APIS:
# - transaction.get_connection() followed by in_atomic_block property,
@ -217,10 +224,12 @@ class ReadOnlyMiddleware(MiddlewareMixin):
Supports issuing `Retry-After` header.
"""
ERROR_MSG = _(
u'Some features are temporarily disabled while we '
u'perform website maintenance. We\'ll be back to '
u'full capacity shortly.')
u'full capacity shortly.'
)
def process_request(self, request):
if not settings.READ_ONLY:
@ -250,6 +259,7 @@ class SetRemoteAddrFromForwardedFor(MiddlewareMixin):
Our application servers should always be behind a load balancer that sets
this header correctly.
"""
def is_valid_ip(self, ip):
for af in (socket.AF_INET, socket.AF_INET6):
try:
@ -263,8 +273,7 @@ class SetRemoteAddrFromForwardedFor(MiddlewareMixin):
ips = []
if 'HTTP_X_FORWARDED_FOR' in request.META:
xff = [i.strip() for i in
request.META['HTTP_X_FORWARDED_FOR'].split(',')]
xff = [i.strip() for i in request.META['HTTP_X_FORWARDED_FOR'].split(',')]
ips = [ip for ip in xff if self.is_valid_ip(ip)]
else:
return

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

@ -39,7 +39,6 @@ def use_primary_db():
class BaseQuerySet(models.QuerySet):
def __init__(self, *args, **kwargs):
super(BaseQuerySet, self).__init__(*args, **kwargs)
self._transform_fns = []
@ -76,6 +75,7 @@ class BaseQuerySet(models.QuerySet):
def only_translations(self):
"""Remove all transforms except translations."""
from olympia.translations import transformer
# Add an extra select so these are cached separately.
qs = self.no_transforms()
if hasattr(self.model._meta, 'translated_fields'):
@ -108,6 +108,7 @@ class ManagerBase(models.Manager):
If a model has translated fields, they'll be attached through a transform
function.
"""
_queryset_class = BaseQuerySet
def get_queryset(self):
@ -116,6 +117,7 @@ class ManagerBase(models.Manager):
def _with_translations(self, qs):
from olympia.translations import transformer
# Since we're attaching translations to the object, we need to stick
# the locale in the query so objects aren't shared across locales.
if hasattr(self.model._meta, 'translated_fields'):
@ -130,8 +132,9 @@ class ManagerBase(models.Manager):
return self.all().transform(fn)
def raw(self, raw_query, params=None, *args, **kwargs):
return RawQuerySet(raw_query, self.model, params=params,
using=self._db, *args, **kwargs)
return RawQuerySet(
raw_query, self.model, params=params, using=self._db, *args, **kwargs
)
class _NoChangeInstance(object):
@ -228,8 +231,12 @@ class OnChangeMixin(object):
new_attr = old_attr.copy()
new_attr.update(new_attr_kw)
for cb in _on_change_callbacks[self.__class__]:
cb(old_attr=old_attr, new_attr=new_attr,
instance=_NoChangeInstance(self), sender=self.__class__)
cb(
old_attr=old_attr,
new_attr=new_attr,
instance=_NoChangeInstance(self),
sender=self.__class__,
)
def save(self, *args, **kw):
"""
@ -270,8 +277,12 @@ class SearchMixin(object):
def index(cls, document, id=None, refresh=False, index=None):
"""Wrapper around Elasticsearch.index."""
search.get_es().index(
body=document, index=index or cls._get_index(),
doc_type=cls.get_mapping_type(), id=id, refresh=refresh)
body=document,
index=index or cls._get_index(),
doc_type=cls.get_mapping_type(),
id=id,
refresh=refresh,
)
@classmethod
def unindex(cls, id, index=None):
@ -335,8 +346,7 @@ class SaveUpdateMixin(object):
objects = cls.get_unfiltered_manager()
objects.filter(pk=self.pk).update(**kw)
if signal:
models.signals.post_save.send(sender=cls, instance=self,
created=False)
models.signals.post_save.send(sender=cls, instance=self, created=False)
def save(self, **kwargs):
# Unfortunately we have to save our translations before we call `save`
@ -356,8 +366,7 @@ class ModelBase(SearchMixin, SaveUpdateMixin, models.Model):
* Fetches all translations in one subsequent query during initialization.
"""
created = models.DateTimeField(
default=timezone.now, editable=False, blank=True)
created = models.DateTimeField(default=timezone.now, editable=False, blank=True)
modified = models.DateTimeField(auto_now=True)
objects = ManagerBase()
@ -390,8 +399,7 @@ class ModelBase(SearchMixin, SaveUpdateMixin, models.Model):
"""
Return the relative URL pointing to the instance admin change page.
"""
urlname = 'admin:%s_%s_change' % (
self._meta.app_label, self._meta.model_name)
urlname = 'admin:%s_%s_change' % (self._meta.app_label, self._meta.model_name)
return reverse(urlname, args=(self.pk,))
def get_admin_absolute_url(self):
@ -415,9 +423,9 @@ def manual_order(qs, pks, pk_name='id'):
if not pks:
return qs.none()
return qs.filter(id__in=pks).extra(
select={'_manual': 'FIELD(%s, %s)' % (pk_name,
','.join(map(str, pks)))},
order_by=['_manual'])
select={'_manual': 'FIELD(%s, %s)' % (pk_name, ','.join(map(str, pks)))},
order_by=['_manual'],
)
class SlugField(models.SlugField):
@ -425,6 +433,7 @@ class SlugField(models.SlugField):
Django 1.6's SlugField rejects non-ASCII slugs. This field just
keeps the old behaviour of not checking contents.
"""
default_validators = []
@ -445,6 +454,7 @@ class BasePreview(object):
def _image_url(self, url_template):
from olympia.amo.templatetags.jinja_helpers import user_media_url
if self.modified is not None:
modified = int(time.mktime(self.modified.timetuple()))
else:
@ -454,6 +464,7 @@ class BasePreview(object):
def _image_path(self, url_template):
from olympia.amo.templatetags.jinja_helpers import user_media_path
args = [user_media_path(self.media_folder), self.id // 1000, self.id]
return url_template % tuple(args)
@ -488,28 +499,30 @@ class BasePreview(object):
@classmethod
def delete_preview_files(cls, sender, instance, **kw):
"""On delete of the Preview object from the database, unlink the image
and thumb on the file system """
and thumb on the file system"""
image_paths = [
instance.image_path, instance.thumbnail_path,
instance.original_path]
instance.image_path,
instance.thumbnail_path,
instance.original_path,
]
for filename in image_paths:
try:
log.info('Removing filename: %s for preview: %s'
% (filename, instance.pk))
log.info(
'Removing filename: %s for preview: %s' % (filename, instance.pk)
)
storage.delete(filename)
except Exception as e:
log.error(
'Error deleting preview file (%s): %s' % (filename, e))
log.error('Error deleting preview file (%s): %s' % (filename, e))
class LongNameIndex(models.Index):
"""Django's Index, but with a longer allowed name since we don't care about
compatibility with Oracle."""
max_name_length = 64 # Django default is 30, but MySQL can go up to 64.
class FilterableManyToManyDescriptor(ManyToManyDescriptor):
def __init__(self, *args, **kwargs):
self.q_filter = kwargs.pop('q_filter', None)
super().__init__(*args, **kwargs)
@ -518,13 +531,14 @@ class FilterableManyToManyDescriptor(ManyToManyDescriptor):
def _get_manager_with_default_filtering(cls, manager, q_filter):
"""This is wrapping the manager class so we can add an extra
filter to the queryset returned via get_queryset."""
class ManagerWithFiltering(manager):
def get_queryset(self):
# Check the queryset caching django uses during these lookups -
# we only want to add the q_filter the first time.
from_cache = (
self.prefetch_cache_name in
getattr(self.instance, '_prefetched_objects_cache', {}))
from_cache = self.prefetch_cache_name in getattr(
self.instance, '_prefetched_objects_cache', {}
)
qs = super().get_queryset()
if not from_cache and q_filter:
# Here is where we add the filter.
@ -570,9 +584,12 @@ class FilterableManyToManyField(models.fields.related.ManyToManyField):
super().contribute_to_class(cls, name, **kwargs)
# Add the descriptor for the m2m relation.
setattr(
cls, self.name,
cls,
self.name,
FilterableManyToManyDescriptor(
self.remote_field, reverse=False, q_filter=self.q_filter))
self.remote_field, reverse=False, q_filter=self.q_filter
),
)
def contribute_to_related_class(self, cls, related):
"""All we're doing here is overriding the `setattr` so it creates an
@ -580,10 +597,13 @@ class FilterableManyToManyField(models.fields.related.ManyToManyField):
ManyToManyDescriptor, and pass down the q_filter property."""
super().contribute_to_related_class(cls, related)
if (
not self.remote_field.is_hidden() and
not related.related_model._meta.swapped
not self.remote_field.is_hidden()
and not related.related_model._meta.swapped
):
setattr(
cls, related.get_accessor_name(),
cls,
related.get_accessor_name(),
FilterableManyToManyDescriptor(
self.remote_field, reverse=True, q_filter=self.q_filter))
self.remote_field, reverse=True, q_filter=self.q_filter
),
)

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

@ -48,8 +48,9 @@ def memcache():
memcache_results.append((ip, port, result))
if not using_twemproxy and len(memcache_results) < 2:
status = ('2+ memcache servers are required.'
'%s available') % len(memcache_results)
status = ('2+ memcache servers are required.' '%s available') % len(
memcache_results
)
monitor_log.warning(status)
if not memcache_results:
@ -70,8 +71,7 @@ def libraries():
msg = "Failed to create a jpeg image: %s" % e
libraries_results.append(('PIL+JPEG', False, msg))
missing_libs = [
lib for lib, success, _ in libraries_results if not success]
missing_libs = [lib for lib, success, _ in libraries_results if not success]
if missing_libs:
status = 'missing libs: %s' % ",".join(missing_libs)
return status, libraries_results
@ -101,10 +101,12 @@ def path():
user_media_path('guarded_addons'),
user_media_path('addon_icons'),
user_media_path('previews'),
user_media_path('userpics'),)
user_media_path('userpics'),
)
read_only = [os.path.join(settings.ROOT, 'locale')]
filepaths = [(path, os.R_OK | os.W_OK, 'We want read + write')
for path in read_and_write]
filepaths = [
(path, os.R_OK | os.W_OK, 'We want read + write') for path in read_and_write
]
filepaths += [(path, os.R_OK, 'We want read') for path in read_only]
filepath_results = []
filepath_status = True
@ -154,11 +156,13 @@ def signer():
try:
response = requests.get(
'{host}/__heartbeat__'.format(host=autograph_url),
timeout=settings.SIGNING_SERVER_MONITORING_TIMEOUT)
timeout=settings.SIGNING_SERVER_MONITORING_TIMEOUT,
)
if response.status_code != 200:
status = (
'Failed to chat with signing service. '
'Invalid HTTP response code.')
'Invalid HTTP response code.'
)
monitor_log.critical(status)
signer_results = False
else:

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

@ -2,7 +2,12 @@ from math import ceil
from django.conf import settings
from django.core.paginator import (
EmptyPage, InvalidPage, Page, PageNotAnInteger, Paginator)
EmptyPage,
InvalidPage,
Page,
PageNotAnInteger,
Paginator,
)
from django.utils.functional import cached_property
@ -38,9 +43,7 @@ class ESPaginator(Paginator):
return 0
# Make sure we never return a page beyond max_result_window
hits = min(
self.max_result_window,
max(1, self.count - self.orphans))
hits = min(self.max_result_window, max(1, self.count - self.orphans))
return int(ceil(hits / float(self.per_page)))
def validate_number(self, number):
@ -67,8 +70,7 @@ class ESPaginator(Paginator):
top = bottom + self.per_page
if top > self.max_result_window:
raise InvalidPage(
'That page number is too high for the current page size')
raise InvalidPage('That page number is too high for the current page size')
# Force the search to evaluate and then attach the count. We want to
# avoid an extra useless query even if there are no results, so we

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

@ -20,8 +20,11 @@ def get_es(hosts=None, timeout=None, **settings):
"""Create an ES object and return it."""
# Cheap way of de-None-ifying things
hosts = hosts or getattr(dj_settings, 'ES_HOSTS', DEFAULT_HOSTS)
timeout = (timeout if timeout is not None else
getattr(dj_settings, 'ES_TIMEOUT', DEFAULT_TIMEOUT))
timeout = (
timeout
if timeout is not None
else getattr(dj_settings, 'ES_TIMEOUT', DEFAULT_TIMEOUT)
)
if os.environ.get('RUNNING_IN_CI'):
settings['http_auth'] = ('elastic', 'changeme')
@ -30,7 +33,6 @@ def get_es(hosts=None, timeout=None, **settings):
class ES(object):
def __init__(self, type_, index):
self.type = type_
self.index = index
@ -151,8 +153,7 @@ class ES(object):
search = Search().query(query)
if query_string:
search = SearchQueryFilter().apply_search_query(
query_string, search)
search = SearchQueryFilter().apply_search_query(query_string, search)
if sort:
search = search.sort(*sort)
@ -191,7 +192,8 @@ class ES(object):
elif field_action == 'exists':
if val is not True:
raise NotImplementedError(
'<field>__exists only works with a "True" value.')
'<field>__exists only works with a "True" value.'
)
filters.append(Q('exists', **{'field': key}))
elif field_action == 'in':
filters.append(Q('terms', **{key: val}))
@ -269,7 +271,6 @@ class ES(object):
class SearchResults(object):
def __init__(self, type, results, source):
self.type = type
self.took = results['took']
@ -289,7 +290,6 @@ class SearchResults(object):
class DictSearchResults(SearchResults):
def set_objects(self, hits):
self.objects = [r['_source'] for r in hits]
@ -297,7 +297,6 @@ class DictSearchResults(SearchResults):
class ListSearchResults(SearchResults):
def set_objects(self, hits):
# When fields are specified in `values(...)` we return the fields.
objs = []
@ -308,7 +307,6 @@ class ListSearchResults(SearchResults):
class ObjectSearchResults(SearchResults):
def set_objects(self, hits):
self.ids = [int(r['_id']) for r in hits]
self.objects = self.type.objects.filter(id__in=self.ids)

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

@ -13,8 +13,9 @@ from django.utils.encoding import force_text
DEFAULT_CHUNK_SIZE = 64 * 2 ** 10 # 64kB
def walk_storage(path, topdown=True, onerror=None, followlinks=False,
storage=default_storage):
def walk_storage(
path, topdown=True, onerror=None, followlinks=False, storage=default_storage
):
"""
Generate the file names in a stored directory tree by walking the tree
top-down.
@ -45,8 +46,9 @@ def walk_storage(path, topdown=True, onerror=None, followlinks=False,
roots[:] = new_roots
def copy_stored_file(src_path, dest_path, storage=default_storage,
chunk_size=DEFAULT_CHUNK_SIZE):
def copy_stored_file(
src_path, dest_path, storage=default_storage, chunk_size=DEFAULT_CHUNK_SIZE
):
"""
Copy one storage path to another storage path.
@ -64,8 +66,9 @@ def copy_stored_file(src_path, dest_path, storage=default_storage,
break
def move_stored_file(src_path, dest_path, storage=default_storage,
chunk_size=DEFAULT_CHUNK_SIZE):
def move_stored_file(
src_path, dest_path, storage=default_storage, chunk_size=DEFAULT_CHUNK_SIZE
):
"""
Move a storage path to another storage path.
@ -73,8 +76,7 @@ def move_stored_file(src_path, dest_path, storage=default_storage,
This attempts to be compatible with a wide range of storage backends
rather than attempt to be optimized for each individual one.
"""
copy_stored_file(src_path, dest_path, storage=storage,
chunk_size=chunk_size)
copy_stored_file(src_path, dest_path, storage=storage, chunk_size=chunk_size)
storage.delete(src_path)

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше