diff --git a/src/olympia/lib/settings_base.py b/src/olympia/lib/settings_base.py index b5dbfe2998c3..b04d760d9a74 100644 --- a/src/olympia/lib/settings_base.py +++ b/src/olympia/lib/settings_base.py @@ -769,6 +769,7 @@ def get_language_url_map(): 'olympia.activity.tasks.create_ratinglog': {'queue': 'adhoc'}, 'olympia.files.tasks.extract_host_permissions': {'queue': 'adhoc'}, 'olympia.lib.crypto.tasks.bump_and_resign_addons': {'queue': 'adhoc'}, + 'olympia.users.tasks.bulk_ban': {'queue': 'adhoc'}, 'olympia.users.tasks.restrict_banned_users': {'queue': 'adhoc'}, # Misc AMO tasks. 'olympia.blocklist.tasks.monitor_remote_settings': {'queue': 'amo'}, diff --git a/src/olympia/users/admin.py b/src/olympia/users/admin.py index be7b7f30e45b..915cd3efcf80 100644 --- a/src/olympia/users/admin.py +++ b/src/olympia/users/admin.py @@ -6,7 +6,7 @@ from django.db import models from django.db.models import Count, Q from django.db.utils import IntegrityError -from django.forms import TextInput +from django.forms import HiddenInput, TextInput from django.http import ( Http404, HttpResponseForbidden, @@ -25,7 +25,12 @@ from olympia.activity.models import ActivityLog, RequestFingerprintLog from olympia.addons.models import Addon, AddonUser from olympia.amo.admin import AMOModelAdmin -from olympia.amo.utils import backup_storage_enabled, create_signed_url_for_file_backup +from olympia.amo.templatetags.jinja_helpers import vite_asset +from olympia.amo.utils import ( + backup_storage_enabled, + chunked, + create_signed_url_for_file_backup, +) from olympia.api.models import APIKey, APIKeyConfirmation from olympia.bandwagon.models import Collection from olympia.constants.activity import LOG_STORE_IPS @@ -45,6 +50,7 @@ UserProfile, UserRestrictionHistory, ) +from .tasks import bulk_ban class GroupUserInline(admin.TabularInline): @@ -107,6 +113,9 @@ def picture_link(self, obj): @admin.register(UserProfile) class UserAdmin(AMOModelAdmin): + class Media: + css = {'all': (vite_asset('css/admin-user.less'),)} + list_filter = ('banned',) list_display = ( '__str__', @@ -242,6 +251,11 @@ def wrapper(*args, **kwargs): urlpatterns = super().get_urls() custom_urlpatterns = [ + re_path( + r'^bulk_ban/$', + wrap(self.bulk_ban_view), + name='users_userprofile_bulk_ban', + ), re_path( r'^(?P.+)/ban/$', wrap(self.ban_view), @@ -284,6 +298,19 @@ def get_actions(self, request): return actions + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context['has_users_edit_permission'] = acl.action_allowed_for( + request.user, amo.permissions.USERS_EDIT + ) + extra_context['has_users_ban_permission'] = acl.action_allowed_for( + request.user, amo.permissions.USERS_BAN + ) + return super().changelist_view( + request, + extra_context=extra_context, + ) + def change_view(self, request, object_id, form_url='', extra_context=None): extra_context = extra_context or {} extra_context['has_users_edit_permission'] = acl.action_allowed_for( @@ -425,10 +452,47 @@ def delete_picture_view(self, request, object_id, extra_context=None): reverse('admin:users_userprofile_change', args=(obj.pk,)) ) + def bulk_ban_view(self, request, extra_context=None): + if not acl.action_allowed_for(request.user, amo.permissions.USERS_BAN): + return HttpResponseForbidden() + + user_ids_to_ban = None + + if request.method == 'POST': + form = forms.BulkBanForm(request.POST) + if form.is_valid(): + user_ids_to_ban = form.cleaned_data['user_ids'] + form.fields['user_ids'].widget = HiddenInput() + if request.POST.get('post'): + self._trigger_bulk_ban_task(request, user_ids_to_ban) + return HttpResponseRedirect( + reverse('admin:users_userprofile_changelist') + ) + else: + form = forms.BulkBanForm() + context = { + 'title': 'Bulk ban users', + 'form': form, + 'user_ids_to_ban': user_ids_to_ban, + **self.admin_site.each_context(request), + 'opts': self.opts, + 'media': self.media + form.media, + **(extra_context or {}), + } + return TemplateResponse( + request, 'admin/users/userprofile/bulk_ban.html', context + ) + + def _trigger_bulk_ban_task(self, request, user_ids): + for chunk in chunked(user_ids, 50): + bulk_ban.delay(list(chunk)) + self.message_user( + request, + f'Bulk-ban for {len(user_ids)} user id(s) will be processed shortly.', + ) + def ban_action(self, request, qs): - qs.ban_and_disable_related_content(hard_block_addons=True) - kw = {'users': ', '.join(str(user) for user in qs)} - self.message_user(request, 'The users "%(users)s" have been banned.' % kw) + self._trigger_bulk_ban_task(request, qs.values_list('id', flat=True)) ban_action.short_description = 'Ban selected users' diff --git a/src/olympia/users/forms.py b/src/olympia/users/forms.py index 6637c5552421..e0def00fdb11 100644 --- a/src/olympia/users/forms.py +++ b/src/olympia/users/forms.py @@ -61,3 +61,16 @@ def clean(self): data['network'] = f'{ip_address}/32' return data + + +class BulkBanForm(forms.Form): + user_ids = forms.CharField( + label='', required=True, widget=forms.Textarea(attrs={'rows': 30, 'cols': 80}) + ) + + def clean_user_ids(self): + data = self.cleaned_data.get('user_ids', '') + user_ids = {id_ for id_ in data.splitlines() if id_.isdigit()} + if not user_ids: + raise forms.ValidationError('This field must contain a least one user id') + return user_ids diff --git a/src/olympia/users/models.py b/src/olympia/users/models.py index a405d64b3a79..ae6b1d53ae27 100644 --- a/src/olympia/users/models.py +++ b/src/olympia/users/models.py @@ -173,6 +173,7 @@ def ban_and_disable_related_content( RESTRICTION_TYPES.ADDON_SUBMISSION, RESTRICTION_TYPES.RATING, ] + if user.email ], ignore_conflicts=True, ) @@ -318,9 +319,10 @@ def unban_and_reenable_related_content(self, *, skip_activity_log=False): user.deleted = False user.banned = None user.save() - EmailUserRestriction.objects.filter( - email_pattern=EmailUserRestriction.normalize_email(user.email) - ).delete() + if user.email: + EmailUserRestriction.objects.filter( + email_pattern=EmailUserRestriction.normalize_email(user.email) + ).delete() if blocklist_submissions_pks: user_responsible = core.get_user() or get_task_user() revert_published_blocklist_submissions.delay( diff --git a/src/olympia/users/tasks.py b/src/olympia/users/tasks.py index 5c883d964220..5f587c0d7b55 100644 --- a/src/olympia/users/tasks.py +++ b/src/olympia/users/tasks.py @@ -278,3 +278,15 @@ def bulk_add_disposable_email_domains(entries: list[tuple[str, str]], batch_size f'Processed {len(processed_domains)} domains: ' f'{[obj.domain for obj in processed_domains]}' ) + + +@task +@use_primary_db +def bulk_ban(ids): + task_log.info( + '[1@None] Bulk-banning users %d-%d [%d].', + ids[0], + ids[-1], + len(ids), + ) + UserProfile.objects.filter(pk__in=ids).ban_and_disable_related_content() diff --git a/src/olympia/users/templates/admin/users/userprofile/bulk_ban.html b/src/olympia/users/templates/admin/users/userprofile/bulk_ban.html new file mode 100644 index 000000000000..f2eae1dff725 --- /dev/null +++ b/src/olympia/users/templates/admin/users/userprofile/bulk_ban.html @@ -0,0 +1,36 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + {{ media }} + +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} bulk-ban-confirmation{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {% if user_ids_to_ban %} +

You're about to bulk-ban {{ user_ids_to_ban|length }} user(s). Are you sure ?

+ {{ form.as_p }} + + + No, take me back + {% else %} +

Paste user ids to ban below, separated by newlines (only lines containing digits will be considered). A confirmation will be displayed on the next page.

+ {{ form.as_p }} + + {% endif %} +
+{% endblock content %} diff --git a/src/olympia/users/templates/admin/users/userprofile/change_list_object_tools.html b/src/olympia/users/templates/admin/users/userprofile/change_list_object_tools.html new file mode 100644 index 000000000000..4f2045942e5e --- /dev/null +++ b/src/olympia/users/templates/admin/users/userprofile/change_list_object_tools.html @@ -0,0 +1,12 @@ +{% extends "admin/change_list_object_tools.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} + {{ block.super }} + {% if has_users_ban_permission %} +
  • + {% url 'admin:users_userprofile_bulk_ban' as ban_url %} + Bulk ban +
  • + {% endif %} +{% endblock %} diff --git a/src/olympia/users/tests/test_models.py b/src/olympia/users/tests/test_models.py index c2061d8b757c..3b7f0c954ca9 100644 --- a/src/olympia/users/tests/test_models.py +++ b/src/olympia/users/tests/test_models.py @@ -240,6 +240,7 @@ def test_ban_and_disable_related_content_bulk( occupation='some job too', read_dev_agreement=datetime.now(), ) + user_no_email = user_factory(email=None, deleted=True) user_innocent = user_factory() addon_multi = addon_factory( users=UserProfile.objects.filter(id__in=[user_multi.id, user_innocent.id]) @@ -259,7 +260,7 @@ def test_ban_and_disable_related_content_bulk( # Now that everything is set up, disable/delete related content. UserProfile.objects.filter( - pk__in=(user_sole.pk, user_multi.pk) + pk__in=(user_sole.pk, user_multi.pk, user_no_email.pk) ).ban_and_disable_related_content(hard_block_addons=hard_block_addons) assert copy_file_to_backup_storage_mock.call_count == 2 diff --git a/static/css/admin-user.less b/static/css/admin-user.less new file mode 100644 index 000000000000..cffcf2f68692 --- /dev/null +++ b/static/css/admin-user.less @@ -0,0 +1,4 @@ +@import url('./admin/amoadmin.css'); +@import url('./admin/l10n.css'); +@import url('./admin/pagination.css'); +@import url('./admin/users.css'); diff --git a/static/css/admin/users.css b/static/css/admin/users.css new file mode 100644 index 000000000000..9afc758d2404 --- /dev/null +++ b/static/css/admin/users.css @@ -0,0 +1,11 @@ +.bulk-ban-confirmation .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +}