Refactor IP address search to make it available to other admin tools (#20141)

This commit is contained in:
Mathieu Pillard 2023-01-05 15:37:30 +01:00 коммит произвёл GitHub
Родитель 5e2514e581
Коммит de9bad03f0
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 366 добавлений и 289 удалений

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

@ -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;
}