Refactor IP address search to make it available to other admin tools (#20141)
This commit is contained in:
Родитель
5e2514e581
Коммит
de9bad03f0
|
@ -77,8 +77,13 @@ class MinimumReportsCountFilter(FakeChoicesMixin, admin.SimpleListFilter):
|
|||
|
||||
|
||||
class AbuseReportAdmin(AMOModelAdmin):
|
||||
class Media:
|
||||
css = {'all': ('css/admin/abuse_reports.css',)}
|
||||
class Media(AMOModelAdmin.Media):
|
||||
css = {
|
||||
'all': (
|
||||
'css/admin/amoadmin.css',
|
||||
'css/admin/abuse_reports.css',
|
||||
)
|
||||
}
|
||||
|
||||
actions = ('delete_selected', 'mark_as_valid', 'mark_as_suspicious')
|
||||
date_hierarchy = 'modified'
|
||||
|
|
|
@ -145,9 +145,10 @@ class FileInline(admin.TabularInline):
|
|||
|
||||
|
||||
class AddonAdmin(AMOModelAdmin):
|
||||
class Media:
|
||||
class Media(AMOModelAdmin.Media):
|
||||
css = {
|
||||
'all': (
|
||||
'css/admin/amoadmin.css',
|
||||
'css/admin/l10n.css',
|
||||
'css/admin/pagination.css',
|
||||
'css/admin/addons.css',
|
||||
|
|
|
@ -635,8 +635,9 @@ class TestReplacementAddonList(TestCase):
|
|||
|
||||
def test_fields(self):
|
||||
model_admin = ReplacementAddonAdmin(ReplacementAddon, admin.site)
|
||||
request = RequestFactory().get('/')
|
||||
self.assertEqual(
|
||||
list(model_admin.get_list_display(None)),
|
||||
list(model_admin.get_list_display(request)),
|
||||
['guid', 'path', 'guid_slug', '_url'],
|
||||
)
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import functools
|
||||
import ipaddress
|
||||
import operator
|
||||
from collections import OrderedDict
|
||||
|
||||
|
@ -6,12 +7,19 @@ from rangefilter.filter import DateRangeFilter as DateRangeFilterBase
|
|||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.views.main import ChangeList, ChangeListSearchForm, SEARCH_VAR
|
||||
from django.contrib.admin.views.main import (
|
||||
ChangeList,
|
||||
ChangeListSearchForm,
|
||||
SEARCH_VAR,
|
||||
)
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db import models
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from olympia.activity.models import IPLog
|
||||
from olympia.amo.models import GroupConcat, Inet6Ntoa
|
||||
from .models import FakeEmail
|
||||
|
||||
|
||||
|
@ -35,6 +43,23 @@ class AMOModelAdminChangeList(ChangeList):
|
|||
|
||||
|
||||
class AMOModelAdmin(admin.ModelAdmin):
|
||||
class Media:
|
||||
js = (
|
||||
'js/admin/ip_address_search.js',
|
||||
'js/exports.js',
|
||||
'js/node_lib/netmask.js',
|
||||
)
|
||||
css = {'all': ('css/admin/amoadmin.css',)}
|
||||
|
||||
# Classes that want to implement search by ip can override these if needed.
|
||||
search_by_ip_actions = () # Deactivated by default.
|
||||
search_by_ip_activity_accessor = 'activitylog'
|
||||
search_by_ip_activity_reverse_accessor = 'activity_log__user'
|
||||
# get_search_results() below searches using `IPLog`. It sets an annotation
|
||||
# that we can then use in the custom `known_ip_adresses` method referenced
|
||||
# in the line below, which is added to the` list_display` fields for IP
|
||||
# searches.
|
||||
extra_list_display_for_ip_searches = ('known_ip_adresses',)
|
||||
# We rarely care about showing this: it's the full count of the number of
|
||||
# objects for this model in the database, unfiltered. It does an extra
|
||||
# COUNT() query, so avoid it by default.
|
||||
|
@ -52,6 +77,24 @@ class AMOModelAdmin(admin.ModelAdmin):
|
|||
"""
|
||||
return 'pk'
|
||||
|
||||
def get_search_query(self, request):
|
||||
# We don't have access to the _search_form instance the ChangeList
|
||||
# creates, so make our own just for this method to grab the cleaned
|
||||
# search term.
|
||||
search_form = AMOModelAdminChangeListSearchForm(request.GET)
|
||||
return (
|
||||
search_form.cleaned_data.get(SEARCH_VAR) if search_form.is_valid() else None
|
||||
)
|
||||
|
||||
def get_list_display(self, request):
|
||||
"""Get fields to use for displaying changelist."""
|
||||
list_display = super().get_list_display(request)
|
||||
if (
|
||||
search_term := self.get_search_query(request)
|
||||
) and self.ip_addresses_and_networks_from_query(search_term):
|
||||
return (*list_display, *self.extra_list_display_for_ip_searches)
|
||||
return list_display
|
||||
|
||||
def lookup_spawns_duplicates(self, opts, lookup_path):
|
||||
"""
|
||||
Return True if 'distinct()' should be used to query the given lookup
|
||||
|
@ -76,6 +119,104 @@ class AMOModelAdmin(admin.ModelAdmin):
|
|||
rval = True
|
||||
return rval
|
||||
|
||||
def ip_addresses_and_networks_from_query(self, search_term):
|
||||
# Caller should already have cleaned up search_term at this point,
|
||||
# removing whitespace etc if there is a comma separating multiple
|
||||
# terms.
|
||||
search_terms = search_term.split(',')
|
||||
ips = []
|
||||
networks = []
|
||||
for term in search_terms:
|
||||
# If term is a number, skip trying to recognize an IP address
|
||||
# entirely, because ip_address() is able to understand IP addresses
|
||||
# as integers, and we don't want that, it's likely an user ID.
|
||||
if term.isdigit():
|
||||
return None
|
||||
# Is the search term an IP ?
|
||||
try:
|
||||
ips.append(ipaddress.ip_address(term))
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
# Is the search term a network ?
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(term))
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
# Is the search term an IP range ?
|
||||
if term.count('-') == 1:
|
||||
try:
|
||||
networks.extend(
|
||||
ipaddress.summarize_address_range(
|
||||
*(ipaddress.ip_address(i.strip()) for i in term.split('-'))
|
||||
)
|
||||
)
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# That search term doesn't look like an IP, network or range, so
|
||||
# we're not doing an IP search.
|
||||
return None
|
||||
return {'ips': ips, 'networks': networks}
|
||||
|
||||
def get_queryset_with_related_ips(self, request, queryset, ips_and_networks):
|
||||
condition = models.Q()
|
||||
if ips_and_networks is not None:
|
||||
if ips_and_networks['ips']:
|
||||
# IPs search can be implemented in a single __in=() query.
|
||||
arg = (
|
||||
f'{self.search_by_ip_activity_accessor}__'
|
||||
'iplog__ip_address_binary__in'
|
||||
)
|
||||
condition |= models.Q(**{arg: ips_and_networks['ips']})
|
||||
if ips_and_networks['networks']:
|
||||
# Networks search need one __range conditions for each network.
|
||||
arg = (
|
||||
f'{self.search_by_ip_activity_accessor}__'
|
||||
'iplog__ip_address_binary__range'
|
||||
)
|
||||
for network in ips_and_networks['networks']:
|
||||
condition |= models.Q(**{arg: (network[0], network[-1])})
|
||||
annotations = {
|
||||
'activity_ips': GroupConcat(
|
||||
Inet6Ntoa(
|
||||
f'{self.search_by_ip_activity_accessor}__iplog__ip_address_binary'
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
# Add an annotation for {search_by_ip_activity_accessor}__iplog__id
|
||||
# so that we can apply a filter on the specific JOIN that will be
|
||||
# used to grab the IPs through GroupConcat to help MySQL optimizer
|
||||
# remove non relevant activities from the DISTINCT bit.
|
||||
'activity_ips_ids': models.F(
|
||||
f'{self.search_by_ip_activity_accessor}__iplog__id'
|
||||
),
|
||||
}
|
||||
if condition:
|
||||
arg = f'{self.search_by_ip_activity_accessor}__action__in'
|
||||
condition &= models.Q(**{arg: self.search_by_ip_actions})
|
||||
# When searching, we want to duplicate the joins against
|
||||
# activitylog + iplog so that one is used for the group concat
|
||||
# showing all IPs for activities related to that object and another
|
||||
# for the search results. Django doesn't let us do that out of the
|
||||
# box, but through FilteredRelation we can force it...
|
||||
annotations['activitylog_filtered'] = models.FilteredRelation(
|
||||
f'{self.search_by_ip_activity_accessor}__iplog',
|
||||
condition=condition,
|
||||
)
|
||||
queryset = queryset.annotate(**annotations)
|
||||
if condition:
|
||||
queryset = queryset.filter(
|
||||
activity_ips_ids__isnull=False,
|
||||
activitylog_filtered__isnull=False,
|
||||
)
|
||||
# A GROUP_BY will already have been applied thanks to our annotations
|
||||
# so we can let django know there won't be any duplicates and avoid
|
||||
# doing a DISTINCT.
|
||||
may_have_duplicates = False
|
||||
return queryset, may_have_duplicates
|
||||
|
||||
def get_search_results(self, request, queryset, search_term):
|
||||
"""
|
||||
Return a tuple containing a queryset to implement the search,
|
||||
|
@ -90,6 +231,8 @@ class AMOModelAdmin(admin.ModelAdmin):
|
|||
- If the search terms are all numeric and there is more than one, then
|
||||
we also restrict the fields we search to the one returned by
|
||||
get_search_id_field(request) using a __in ORM lookup directly.
|
||||
- If the search terms are all IP addresses, a special search for
|
||||
objects matching those IPs is triggered
|
||||
|
||||
"""
|
||||
# Apply keyword searches.
|
||||
|
@ -122,7 +265,20 @@ class AMOModelAdmin(admin.ModelAdmin):
|
|||
# Otherwise, use the field with icontains.
|
||||
return '%s__icontains' % field_name
|
||||
|
||||
may_have_duplicates = False
|
||||
if self.search_by_ip_actions:
|
||||
ips_and_networks = self.ip_addresses_and_networks_from_query(search_term)
|
||||
# If self.search_by_ip_actions is truthy, then we can call
|
||||
# get_queryset_with_related_ips(), which will add IP
|
||||
# annotations regardless of whether or not we're actually
|
||||
# searching by IP...
|
||||
queryset, may_have_duplicates = self.get_queryset_with_related_ips(
|
||||
request, queryset, ips_and_networks
|
||||
)
|
||||
# ... We can return here early if we were indeed searching by IP.
|
||||
if ips_and_networks:
|
||||
return queryset, may_have_duplicates
|
||||
else:
|
||||
may_have_duplicates = False
|
||||
|
||||
search_fields = self.get_search_fields(request)
|
||||
filters = []
|
||||
|
@ -171,6 +327,34 @@ class AMOModelAdmin(admin.ModelAdmin):
|
|||
queryset = queryset.filter(functools.reduce(joining_operator, filters))
|
||||
return queryset, may_have_duplicates
|
||||
|
||||
def known_ip_adresses(self, obj):
|
||||
# activity_ips is an annotation added by get_search_results() above
|
||||
# thanks to a GROUP_CONCAT. If present, use that (avoiding making
|
||||
# extra queries for each row of results), otherwise, look where
|
||||
# appropriate.
|
||||
unset = object()
|
||||
activity_ips = getattr(obj, 'activity_ips', unset)
|
||||
if activity_ips is not unset:
|
||||
# The GroupConcat value is a comma seperated string of the ip
|
||||
# addresses (already converted to string thanks to INET6_NTOA,
|
||||
# except if there was nothing to find, then it would be None)
|
||||
ip_addresses = set((activity_ips or '').split(','))
|
||||
else:
|
||||
arg = self.search_by_ip_activity_reverse_accessor
|
||||
ip_addresses = set(
|
||||
IPLog.objects.filter(**{arg: obj})
|
||||
.values_list('ip_address_binary', flat=True)
|
||||
.order_by()
|
||||
.distinct()
|
||||
)
|
||||
|
||||
contents = format_html_join(
|
||||
'', '<li>{}</li>', ((ip,) for ip in sorted(ip_addresses))
|
||||
)
|
||||
return format_html('<ul>{}</ul>', contents)
|
||||
|
||||
known_ip_adresses.short_description = 'IP addresses'
|
||||
|
||||
# Triggering a search by id only isn't always what the admin wants for an
|
||||
# all numeric query, but on the other hand is a nice optimization.
|
||||
# The default is 2 so that if there is a field in search_fields for which
|
||||
|
|
|
@ -896,3 +896,6 @@ LOG_REVIEW_QUEUE_DEVELOPER = list(set(LOG_REVIEW_QUEUE) - set(LOG_HIDE_DEVELOPER
|
|||
LOG_SHOW_USER_TO_DEVELOPER = [
|
||||
log.id for log in LOGS if hasattr(log, 'show_user_to_developer')
|
||||
]
|
||||
|
||||
# Actions that store IP
|
||||
LOG_STORE_IPS = [log.id for log in LOGS if getattr(log, 'store_ip', False)]
|
||||
|
|
|
@ -323,8 +323,13 @@ class AbstractScannerResultAdminMixin:
|
|||
|
||||
ordering = ('-pk',)
|
||||
|
||||
class Media:
|
||||
css = {'all': ('css/admin/scannerresult.css',)}
|
||||
class Media(AMOModelAdmin.Media):
|
||||
css = {
|
||||
'all': (
|
||||
'css/admin/amoadmin.css',
|
||||
'css/admin/scannerresult.css',
|
||||
)
|
||||
}
|
||||
|
||||
def get_changelist(self, request, **kwargs):
|
||||
return ScannerResultChangeList
|
||||
|
|
|
@ -1,17 +1,9 @@
|
|||
import functools
|
||||
import ipaddress
|
||||
import itertools
|
||||
|
||||
from django import http
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin.utils import unquote
|
||||
from django.db.models import (
|
||||
Count,
|
||||
F,
|
||||
FilteredRelation,
|
||||
Q,
|
||||
)
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.db.utils import IntegrityError
|
||||
from django.http import (
|
||||
Http404,
|
||||
|
@ -22,23 +14,18 @@ from django.http import (
|
|||
from django.template.response import TemplateResponse
|
||||
from django.urls import re_path, reverse
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
|
||||
from olympia import amo
|
||||
from olympia.abuse.models import AbuseReport
|
||||
from olympia.access import acl
|
||||
from olympia.activity.models import ActivityLog, IPLog
|
||||
from olympia.activity.models import ActivityLog
|
||||
from olympia.addons.models import Addon, AddonUser
|
||||
from olympia.amo.admin import (
|
||||
AMOModelAdmin,
|
||||
AMOModelAdminChangeListSearchForm,
|
||||
SEARCH_VAR,
|
||||
)
|
||||
from olympia.amo.fields import IPAddressBinaryField
|
||||
from olympia.amo.models import GroupConcat, Inet6Ntoa
|
||||
from olympia.amo.admin import AMOModelAdmin
|
||||
from olympia.api.models import APIKey, APIKeyConfirmation
|
||||
from olympia.bandwagon.models import Collection
|
||||
from olympia.constants.activity import LOG_STORE_IPS
|
||||
from olympia.ratings.models import Rating
|
||||
from olympia.zadmin.admin import related_content_link, related_single_content_link
|
||||
|
||||
|
@ -64,21 +51,16 @@ class GroupUserInline(admin.TabularInline):
|
|||
class UserAdmin(AMOModelAdmin):
|
||||
list_display = ('__str__', 'email', 'last_login', 'is_public', 'deleted')
|
||||
# pk and IP address search are supported without needing to specify them in
|
||||
# search_fields (see `AMOModelAdminMixin.get_search_results()` and
|
||||
# `get_search_id_field()` as well as `get_search_results()` below)
|
||||
# search_fields (see `AMOModelAdmin`)
|
||||
search_fields = ('email__like',)
|
||||
# We can trigger search by ids with only one search term, if somehow an
|
||||
# admin wants to search for an email using a single query term that's all
|
||||
# numeric they can add a wildcard.
|
||||
minimum_search_terms_to_search_by_id = 1
|
||||
# get_search_results() below searches using `IPLog`. It sets an annotation
|
||||
# that we can then use in the custom `known_ip_adresses` method referenced
|
||||
# in the line below, which is added to the` list_display` fields for IP
|
||||
# searches.
|
||||
extra_list_display_for_ip_searches = ('known_ip_adresses',)
|
||||
# A custom field used in search json in zadmin, not django.admin.
|
||||
search_fields_response = 'email'
|
||||
inlines = (GroupUserInline,)
|
||||
search_by_ip_actions = LOG_STORE_IPS
|
||||
|
||||
readonly_fields = (
|
||||
'abuse_reports_by_this_user',
|
||||
|
@ -164,121 +146,6 @@ class UserAdmin(AMOModelAdmin):
|
|||
|
||||
actions = ['ban_action', 'reset_api_key_action', 'reset_session_action']
|
||||
|
||||
class Media:
|
||||
js = ('js/admin/userprofile.js', 'js/exports.js', 'js/node_lib/netmask.js')
|
||||
css = {'all': ('css/admin/userprofile.css',)}
|
||||
|
||||
def get_list_display(self, request):
|
||||
"""Get fields to use for displaying changelist."""
|
||||
# We don't have access to the _search_form instance the ChangeList
|
||||
# creates, so make our own just for this method to grab the cleaned
|
||||
# search term.
|
||||
search_form = AMOModelAdminChangeListSearchForm(request.GET)
|
||||
search_term = (
|
||||
search_form.cleaned_data.get(SEARCH_VAR) if search_form.is_valid() else None
|
||||
)
|
||||
if search_term and self.ip_addresses_and_networks_from_query(search_term):
|
||||
return (*self.list_display, *self.extra_list_display_for_ip_searches)
|
||||
return self.list_display
|
||||
|
||||
def ip_addresses_and_networks_from_query(self, search_term):
|
||||
# Caller should already have cleaned up search_term at this point,
|
||||
# removing whitespace etc if there is a comma separating multiple
|
||||
# terms.
|
||||
search_terms = search_term.split(',')
|
||||
ips = []
|
||||
networks = []
|
||||
for term in search_terms:
|
||||
# If term is a number, skip trying to recognize an IP address
|
||||
# entirely, because ip_address() is able to understand IP addresses
|
||||
# as integers, and we don't want that, it's likely an user ID.
|
||||
if term.isdigit():
|
||||
return None
|
||||
# Is the search term an IP ?
|
||||
try:
|
||||
ips.append(ipaddress.ip_address(term))
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
# Is the search term a network ?
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(term))
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
# Is the search term an IP range ?
|
||||
if term.count('-') == 1:
|
||||
try:
|
||||
networks.extend(
|
||||
ipaddress.summarize_address_range(
|
||||
*(ipaddress.ip_address(i.strip()) for i in term.split('-'))
|
||||
)
|
||||
)
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# That search term doesn't look like an IP, network or range, so
|
||||
# we're not doing an IP search.
|
||||
return None
|
||||
return {'ips': ips, 'networks': networks}
|
||||
|
||||
def get_search_results(self, request, queryset, search_term):
|
||||
ips_and_networks = self.ip_addresses_and_networks_from_query(search_term)
|
||||
if ips_and_networks:
|
||||
condition = Q()
|
||||
if ips_and_networks['ips']:
|
||||
# IPs search can be implemented in a single __in=() query.
|
||||
condition |= Q(
|
||||
activitylog__iplog__ip_address_binary__in=ips_and_networks['ips']
|
||||
)
|
||||
if ips_and_networks['networks']:
|
||||
# Networks search need one __range conditions for each network.
|
||||
for network in ips_and_networks['networks']:
|
||||
condition |= Q(
|
||||
activitylog__iplog__ip_address_binary__range=(
|
||||
network[0],
|
||||
network[-1],
|
||||
)
|
||||
)
|
||||
# We want to duplicate the joins against activitylog + iplog so
|
||||
# that one is used for the search, and the other for the group
|
||||
# concat showing all IPs for activities of that user. Django
|
||||
# doesn't let us do that out of the box, but through
|
||||
# FilteredRelation we can force it...
|
||||
annotations = {
|
||||
'activity_ips': GroupConcat(
|
||||
Inet6Ntoa('activitylog__iplog__ip_address_binary'),
|
||||
distinct=True,
|
||||
),
|
||||
'activitylog_filtered': FilteredRelation(
|
||||
'activitylog__iplog', condition=condition
|
||||
),
|
||||
# Add an annotation for activitylog__iplog__id so that we can
|
||||
# apply a filter on the specific JOIN that will be used to grab
|
||||
# the IPs through GroupConcat to help MySQL optimizer remove
|
||||
# non relevant activities from the DISTINCT bit.
|
||||
'activity_ips_ids': F('activitylog__iplog__id'),
|
||||
}
|
||||
# ...and then add the most simple filter to "activate" the join
|
||||
# which has our search condition.
|
||||
queryset = queryset.annotate(**annotations).filter(
|
||||
activitylog_filtered__isnull=False,
|
||||
activity_ips_ids__isnull=False,
|
||||
)
|
||||
# A GROUP_BY will already have been applied thanks to our
|
||||
# annotations so we can let django know there won't be any
|
||||
# duplicates and avoid doing a DISTINCT.
|
||||
may_have_duplicates = False
|
||||
else:
|
||||
# We support `*` as a wildcard character for `email__like` lookup.
|
||||
search_term = search_term.replace('*', '%')
|
||||
queryset, may_have_duplicates = super().get_search_results(
|
||||
request,
|
||||
queryset,
|
||||
search_term,
|
||||
)
|
||||
return queryset, may_have_duplicates
|
||||
|
||||
def get_urls(self):
|
||||
def wrap(view):
|
||||
def wrapper(*args, **kwargs):
|
||||
|
@ -480,50 +347,6 @@ class UserAdmin(AMOModelAdmin):
|
|||
|
||||
picture_img.short_description = _('Profile Photo')
|
||||
|
||||
def known_ip_adresses(self, obj):
|
||||
# activity_ips is an annotation added by get_search_results() above
|
||||
# thanks to a GROUP_CONCAT. If present, use that (avoiding making
|
||||
# extra queries for each row of results), otherwise, look everywhere
|
||||
# we can.
|
||||
activity_ips = getattr(obj, 'activity_ips', None)
|
||||
if activity_ips is not None:
|
||||
# The GroupConcat value is a comma seperated string of the ip
|
||||
# addresses (already converted to string thanks to INET6_NTOA).
|
||||
ip_addresses = set(activity_ips.split(','))
|
||||
else:
|
||||
to_ipaddress = IPAddressBinaryField().to_python
|
||||
ip_addresses = set(
|
||||
Rating.objects.filter(user=obj)
|
||||
.values_list('ip_address', flat=True)
|
||||
.order_by()
|
||||
.distinct()
|
||||
)
|
||||
ip_addresses.update(
|
||||
itertools.chain(
|
||||
*UserRestrictionHistory.objects.filter(user=obj)
|
||||
.values_list('last_login_ip', 'ip_address')
|
||||
.order_by()
|
||||
.distinct()
|
||||
)
|
||||
)
|
||||
ip_addresses.add(obj.last_login_ip)
|
||||
# In the glorious future all these ip addresses will be IPv[4|6]Address
|
||||
# objects but for now some of them are strings so we have to convert.
|
||||
ip_addresses = {to_ipaddress(ip) for ip in ip_addresses if ip}
|
||||
ip_addresses.update(
|
||||
IPLog.objects.filter(activity_log__user=obj)
|
||||
.values_list('ip_address_binary', flat=True)
|
||||
.order_by()
|
||||
.distinct()
|
||||
)
|
||||
|
||||
contents = format_html_join(
|
||||
'', '<li>{}</li>', ((ip,) for ip in sorted(ip_addresses))
|
||||
)
|
||||
return format_html('<ul>{}</ul>', contents)
|
||||
|
||||
known_ip_adresses.short_description = 'Known IP addresses'
|
||||
|
||||
def last_known_activity_time(self, obj):
|
||||
from django.contrib.admin.utils import display_for_value
|
||||
|
||||
|
|
|
@ -854,40 +854,41 @@ class TestUserAdmin(TestCase):
|
|||
)
|
||||
|
||||
def test_known_ip_adresses(self):
|
||||
self.user.update(last_login_ip='127.1.2.3')
|
||||
Rating.objects.create(
|
||||
addon=addon_factory(), user=self.user, ip_address='127.1.2.3'
|
||||
)
|
||||
dummy_addon = addon_factory()
|
||||
Rating.objects.create(
|
||||
addon=dummy_addon,
|
||||
version=dummy_addon.current_version,
|
||||
user=self.user,
|
||||
ip_address='128.1.2.3',
|
||||
)
|
||||
Rating.objects.create(
|
||||
addon=dummy_addon,
|
||||
version=version_factory(addon=dummy_addon),
|
||||
user=self.user,
|
||||
ip_address='129.1.2.4',
|
||||
)
|
||||
Rating.objects.create(
|
||||
addon=addon_factory(), user=self.user, ip_address='130.1.2.4'
|
||||
)
|
||||
Rating.objects.create(
|
||||
addon=addon_factory(), user=self.user, ip_address='130.1.2.4'
|
||||
)
|
||||
Rating.objects.create(
|
||||
addon=dummy_addon, user=user_factory(), ip_address='255.255.0.0'
|
||||
)
|
||||
another_user = user_factory()
|
||||
core.set_user(another_user)
|
||||
with core.override_remote_addr('255.255.0.0'):
|
||||
Rating.objects.create(addon=dummy_addon, user=another_user)
|
||||
core.set_user(self.user)
|
||||
with core.override_remote_addr('127.1.2.3'):
|
||||
ActivityLog.create(amo.LOG.LOG_IN, user=self.user)
|
||||
with core.override_remote_addr('129.1.2.4'):
|
||||
Rating.objects.create(addon=addon_factory(), user=self.user)
|
||||
with core.override_remote_addr('128.1.2.3'):
|
||||
Rating.objects.create(
|
||||
addon=dummy_addon,
|
||||
version=dummy_addon.current_version,
|
||||
user=self.user,
|
||||
)
|
||||
with core.override_remote_addr('129.1.2.4'):
|
||||
Rating.objects.create(
|
||||
addon=dummy_addon,
|
||||
version=version_factory(addon=dummy_addon),
|
||||
user=self.user,
|
||||
)
|
||||
with core.override_remote_addr('130.1.2.4'):
|
||||
Rating.objects.create(addon=addon_factory(), user=self.user)
|
||||
with core.override_remote_addr('130.1.2.4'):
|
||||
Rating.objects.create(addon=addon_factory(), user=self.user)
|
||||
with core.override_remote_addr('15.16.23.42'):
|
||||
ActivityLog.create(amo.LOG.ADD_VERSION, dummy_addon, user=self.user)
|
||||
UserRestrictionHistory.objects.create(user=self.user, last_login_ip='4.8.15.16')
|
||||
UserRestrictionHistory.objects.create(user=self.user, ip_address='172.0.0.2')
|
||||
with core.override_remote_addr('4.8.15.16'):
|
||||
ActivityLog.create(amo.LOG.RESTRICTED, user=self.user)
|
||||
with core.override_remote_addr('172.0.0.2'):
|
||||
ActivityLog.create(amo.LOG.RESTRICTED, user=self.user)
|
||||
model_admin = UserAdmin(UserProfile, admin.site)
|
||||
doc = pq(model_admin.known_ip_adresses(self.user))
|
||||
result = doc('ul li').text().split()
|
||||
assert len(result) == 7
|
||||
assert set(result) == {
|
||||
'130.1.2.4',
|
||||
'128.1.2.3',
|
||||
|
@ -897,23 +898,23 @@ class TestUserAdmin(TestCase):
|
|||
'172.0.0.2',
|
||||
'4.8.15.16',
|
||||
}
|
||||
assert len(result) == 7
|
||||
|
||||
# Duplicates are ignored
|
||||
Rating.objects.create(
|
||||
addon=dummy_addon,
|
||||
version=version_factory(addon=dummy_addon),
|
||||
user=self.user,
|
||||
ip_address='127.1.2.3',
|
||||
)
|
||||
with core.override_remote_addr('127.1.2.3'):
|
||||
Rating.objects.create(
|
||||
addon=dummy_addon,
|
||||
version=version_factory(addon=dummy_addon),
|
||||
user=self.user,
|
||||
)
|
||||
with core.override_remote_addr('172.0.0.2'):
|
||||
ActivityLog.create(amo.LOG.ADD_VERSION, dummy_addon, user=self.user)
|
||||
UserRestrictionHistory.objects.create(
|
||||
user=self.user, last_login_ip='15.16.23.42'
|
||||
)
|
||||
UserRestrictionHistory.objects.create(user=self.user, ip_address='4.8.15.16')
|
||||
with core.override_remote_addr('15.16.23.42'):
|
||||
ActivityLog.create(amo.LOG.RESTRICTED, user=self.user)
|
||||
with core.override_remote_addr('4.8.15.16'):
|
||||
ActivityLog.create(amo.LOG.RESTRICTED, user=self.user)
|
||||
doc = pq(model_admin.known_ip_adresses(self.user))
|
||||
result = doc('ul li').text().split()
|
||||
assert len(result) == 7
|
||||
assert set(result) == {
|
||||
'130.1.2.4',
|
||||
'128.1.2.3',
|
||||
|
@ -923,6 +924,7 @@ class TestUserAdmin(TestCase):
|
|||
'172.0.0.2',
|
||||
'4.8.15.16',
|
||||
}
|
||||
assert len(result) == 7
|
||||
|
||||
def test_last_known_activity_time(self):
|
||||
someone_else = user_factory(username='someone_else')
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
#searchbar-wrapper {
|
||||
padding-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#searchbar-wrapper #toolbar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#searchbar-explainer {
|
||||
display: none;
|
||||
margin-top: 5px;
|
||||
position: absolute;
|
||||
width: 60em;
|
||||
height: auto;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--message-warning-bg);
|
||||
color: var(--body-quiet-color);
|
||||
opacity: 0.95;
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
#searchbar-wrapper:hover #searchbar-explainer,
|
||||
#searchbar-explainer:hover {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#searchbar-explainer, #searchbar-explainer li {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#changelist #searchbar-explainer li {
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
|
||||
#changelist #searchbar-explainer li {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
#searchbar-explainer code {
|
||||
background: var(--selected-bg);
|
||||
color: var(--body-loud-color);
|
||||
outline: 1px dotted var(--accent);
|
||||
}
|
||||
|
||||
.hasaddremoveip .deletelink {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.hasaddremoveip .addlink, .hasaddremoveip .deletelink {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hasaddremoveip .addlink, .hasaddremoveip .deletelink {
|
||||
visibility: hidden
|
||||
}
|
||||
|
||||
.hasaddremoveip:hover .addlink, .hasaddremoveip:hover .deletelink {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.hasaddremoveip.notinsearch .deletelink, .hasaddremoveip:not(.notinsearch) .addlink {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hasaddremoveip .addlink:hover, .hasaddremoveip .deletelink:hover {
|
||||
outline: 1px dotted black;
|
||||
}
|
||||
|
||||
.notinsearch {
|
||||
color: deeppink;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#changelist-search > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#toolbar #searchbar {
|
||||
font-size: 16px;
|
||||
height: auto;
|
||||
width: 46em;
|
||||
}
|
||||
|
||||
#toolbar #searchbar.dirty {
|
||||
border-color: orange;
|
||||
}
|
||||
|
||||
.change-list .field-known_ip_adresses ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.change-list .field-known_ip_adresses li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
#changelist-filter form {
|
||||
margin: 5px 0;
|
||||
padding: 0 15px 15px;
|
||||
border-bottom: 1px solid var(--hairline-color);
|
||||
}
|
||||
|
||||
#changelist-filter select {
|
||||
width: 100%;
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
.hasaddremoveip .deletelink {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.hasaddremoveip .addlink, .hasaddremoveip .deletelink {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hasaddremoveip .addlink, .hasaddremoveip .deletelink {
|
||||
visibility: hidden
|
||||
}
|
||||
|
||||
.hasaddremoveip:hover .addlink, .hasaddremoveip:hover .deletelink {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.hasaddremoveip.notinsearch .deletelink, .hasaddremoveip:not(.notinsearch) .addlink {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hasaddremoveip .addlink:hover, .hasaddremoveip .deletelink:hover {
|
||||
outline: 1px dotted black;
|
||||
}
|
||||
|
||||
.notinsearch {
|
||||
color: deeppink;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#changelist-search > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#toolbar #searchbar {
|
||||
font-size: 18px;
|
||||
height: auto;
|
||||
width: 42em;
|
||||
}
|
||||
|
||||
#toolbar #searchbar.dirty {
|
||||
border-color: orange;
|
||||
}
|
||||
|
||||
.change-list .field-known_ip_adresses ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.change-list .field-known_ip_adresses li {
|
||||
list-style-type: none;
|
||||
}
|
|
@ -100,12 +100,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
}
|
||||
|
||||
const result_list = document.querySelector(
|
||||
'body.change-list.model-userprofile.change-list #result_list',
|
||||
);
|
||||
const result_list = document.querySelector('body.change-list #result_list');
|
||||
|
||||
if (!result_list) {
|
||||
// This is only for the userprofile result change list page.
|
||||
return;
|
||||
}
|
||||
|
Загрузка…
Ссылка в новой задаче