diff --git a/api/api/admin/media_report.py b/api/api/admin/media_report.py
index 85fccca26f3..4bef2af20ee 100644
--- a/api/api/admin/media_report.py
+++ b/api/api/admin/media_report.py
@@ -5,10 +5,13 @@
from django import forms
from django.conf import settings
from django.contrib import admin, messages
+from django.contrib.admin import helpers
from django.contrib.admin.views.main import ChangeList
+from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, F, Min
from django.http import JsonResponse
from django.shortcuts import redirect
+from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
@@ -23,12 +26,16 @@
AudioDecision,
AudioDecisionThrough,
AudioReport,
+ DeletedAudio,
+ DeletedImage,
Image,
ImageDecision,
ImageDecisionThrough,
ImageReport,
+ SensitiveAudio,
+ SensitiveImage,
)
-from api.models.media import AbstractDeletedMedia, AbstractSensitiveMedia
+from api.utils.moderation import perform_moderation
from api.utils.moderation_lock import LockManager
@@ -42,11 +49,11 @@ def register(site):
site.register(AudioReport, AudioReportAdmin)
site.register(ImageReport, ImageReportAdmin)
- for klass in [
- *AbstractSensitiveMedia.__subclasses__(),
- *AbstractDeletedMedia.__subclasses__(),
- ]:
- site.register(klass, MediaSubreportAdmin)
+ site.register(SensitiveImage, SensitiveImageAdmin)
+ site.register(SensitiveAudio, SensitiveAudioAdmin)
+
+ site.register(DeletedImage, DeletedImageAdmin)
+ site.register(DeletedAudio, DeletedAudioAdmin)
site.register(ImageDecision, ImageDecisionAdmin)
site.register(AudioDecision, AudioDecisionAdmin)
@@ -181,12 +188,13 @@ def choices(self, changelist):
def lookups(self, request, model_admin):
return [
(None, "Moderation queue"),
+ ("prev", "Resolved"),
("all", "All"),
]
def queryset(self, request, qs):
value = self.value()
- if value is None:
+ if value is None or value == "prev":
# Filter down to only instances with reports
qs = qs.filter(**{f"{media_type}_report__isnull": False})
@@ -204,13 +212,108 @@ def queryset(self, request, qs):
qs = qs.order_by(
"-total_report_count", "-pending_report_count", "oldest_report_date"
)
+ if value is None:
+ qs = qs.filter(pending_report_count__gt=0)
+ if value == "prev":
+ qs = qs.filter(pending_report_count=0)
return qs
return PendingRecordCountFilter
-class MediaListAdmin(admin.ModelAdmin):
+class BulkModerationMixin:
+ def has_bulk_mod_permission(self, request):
+ return request.user.has_perm(f"api.add_{self.media_type}decision")
+
+ def _bulk_mod(self, request, queryset, action: DecisionAction):
+ """
+ Perform bulk moderation for the queryset as per the requested
+ action.
+
+ This follows the pattern of the ``delete_selected`` action
+ defined in ``django.contrib.admin.actions.py``.
+ """
+
+ opts = self.model._meta
+ verbose_name_plural = opts.verbose_name_plural
+
+ if action == DecisionAction.MARKED_SENSITIVE:
+ init_count = queryset.count()
+ queryset = queryset.filter(**{f"sensitive_{self.media_type}__isnull": True})
+
+ count = len(queryset)
+ prev_count = init_count - count
+ stats = {
+ f"selected {verbose_name_plural}": init_count,
+ f"{verbose_name_plural} already marked as sensitive": prev_count,
+ }
+ else:
+ # No filtering is needed for any other actions.
+ count = len(queryset)
+ stats = {}
+ stats[f"{verbose_name_plural} to be {action.verb}"] = count
+
+ if count == 0:
+ messages.info(
+ request,
+ f"All selected items are already {action.verb}.",
+ )
+ return None
+
+ if request.POST.get("post"):
+ # The user has already confirmed so we will perform the
+ # moderation and return ``None`` to display the change list
+ # view again.
+ decision = perform_moderation(request, self.media_type, queryset, action)
+ path = reverse(
+ f"admin:api_{self.media_type}decision_change", args=(decision.id,)
+ )
+ messages.success(
+ request,
+ format_html(
+ 'Successfully moderated {} items via decision {}.',
+ queryset.count(),
+ path,
+ decision.id,
+ ),
+ )
+ return None
+
+ objects_name = opts.verbose_name if count == 1 else opts.verbose_name_plural
+ moderatable_objects = [
+ format_html(
+ '{}',
+ reverse(
+ f"admin:api_{queryset.model._meta.model_name}_change",
+ args=(obj.pk,),
+ ),
+ obj,
+ )
+ for obj in queryset
+ ]
+
+ context = {
+ **self.admin_site.each_context(request),
+ "title": "Are you sure?",
+ "objects_name": str(objects_name),
+ "moderatable_objects": [moderatable_objects],
+ "stats": dict(stats).items(),
+ "queryset": queryset,
+ "opts": opts,
+ "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
+ "media": self.media,
+ "decision_action": action,
+ }
+
+ return TemplateResponse(
+ request,
+ "admin/api/bulk_moderation_confirmation.html",
+ context,
+ )
+
+
+class MediaListAdmin(BulkModerationMixin, admin.ModelAdmin):
media_type = None
def __init__(self, *args, **kwargs):
@@ -294,23 +397,30 @@ def has_sensitive_text(self, obj):
list_display_links = ("identifier",)
search_fields = _production_deferred("identifier")
sortable_by = () # Ordering is defined in ``get_queryset``.
+ actions = ["marked_sensitive", "deindexed_sensitive", "deindexed_copyright"]
def get_list_filter(self, request):
return (get_pending_record_filter(self.media_type),)
def get_list_display(self, request):
- if request.GET.get("pending_record_count") != "all":
+ if request.GET.get("pending_record_count") == "all":
+ return self.list_display + (
+ "source",
+ "provider",
+ )
+ elif request.GET.get("pending_record_count") == "prev":
return self.list_display + (
"total_report_count",
- "pending_report_count",
"oldest_report_date",
- "pending_reports_links",
"has_sensitive_text",
)
else:
return self.list_display + (
- "source",
- "provider",
+ "total_report_count",
+ "pending_report_count",
+ "oldest_report_date",
+ "pending_reports_links",
+ "has_sensitive_text",
)
def total_report_count(self, obj):
@@ -322,17 +432,18 @@ def pending_report_count(self, obj):
def oldest_report_date(self, obj):
return obj.oldest_report_date
+ @admin.display(description="Open reports")
def pending_reports_links(self, obj):
reports = getattr(obj, f"{self.media_type}_report")
pending_reports = reports.filter(decision__isnull=True)
data = []
for report in pending_reports.all():
- url = reverse(
+ path = reverse(
f"admin:api_{self.media_type}report_change", args=(report.id,)
)
- data.append(format_html('Report {}', url, report.id))
+ data.append(format_html('• Report {}', path, report.id))
- return mark_safe(", ".join(data))
+ return mark_safe("
".join(data))
def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
@@ -554,6 +665,31 @@ def report_create_view(self, request, object_id):
def get_changelist(self, request, **kwargs):
return PredeterminedOrderChangelist
+ ################
+ # Bulk actions #
+ ################
+
+ @admin.action(
+ permissions=["bulk_mod"],
+ description="Mark selected %(verbose_name_plural)s as sensitive",
+ )
+ def marked_sensitive(self, request, queryset):
+ return self._bulk_mod(request, queryset, DecisionAction.MARKED_SENSITIVE)
+
+ @admin.action(
+ permissions=["bulk_mod"],
+ description="Deindex selected %(verbose_name_plural)s (sensitive)",
+ )
+ def deindexed_sensitive(self, request, queryset):
+ return self._bulk_mod(request, queryset, DecisionAction.DEINDEXED_SENSITIVE)
+
+ @admin.action(
+ permissions=["bulk_mod"],
+ description="Deindex selected %(verbose_name_plural)s (copyright)",
+ )
+ def deindexed_copyright(self, request, queryset):
+ return self._bulk_mod(request, queryset, DecisionAction.DEINDEXED_COPYRIGHT)
+
class MediaReportAdmin(admin.ModelAdmin):
media_type = None
@@ -586,7 +722,6 @@ def is_pending(self, obj):
"reason",
("decision", admin.EmptyFieldListFilter), # ~is_pending
)
- list_select_related = ("media_obj",)
search_fields = ("description", *_production_deferred("media_obj__identifier"))
@admin.display(description="Media obj")
@@ -663,13 +798,18 @@ def has_add_permission(self, request) -> bool:
@admin.display(description="Media objs")
def media_ids(self, obj):
through_objs = getattr(obj, f"{self.media_type}decisionthrough_set").all()
- text = []
+ data = []
for obj in through_objs:
- path = reverse(
- f"admin:api_{self.media_type}_change", args=(obj.media_obj.id,)
- )
- text.append(f'• {obj.media_obj}')
- return format_html("
".join(text))
+ try:
+ path = reverse(
+ f"admin:api_{self.media_type}_change", args=(obj.media_obj.id,)
+ )
+ data.append(
+ format_html('• {}', path, obj.media_obj.identifier)
+ )
+ except ObjectDoesNotExist:
+ data.append(f"• {obj.media_obj_id}")
+ return mark_safe("
".join(data))
###############
# Change view #
@@ -706,8 +846,20 @@ class MediaDecisionThroughAdmin(admin.TabularInline):
autocomplete_fields = _production_deferred("media_obj")
raw_id_fields = _non_production_deferred("media_obj")
+ def get_readonly_fields(self, request, obj):
+ if is_mutable:
+ return super().get_readonly_fields(request, obj)
+ else:
+ return ("media_obj_id",)
+
+ def get_exclude(self, request, obj):
+ if is_mutable:
+ return super().get_exclude(request, obj)
+ else:
+ return ("media_obj",)
+
def has_add_permission(self, request, obj=None):
- return is_mutable and super().has_change_permission(request, obj)
+ return is_mutable and super().has_add_permission(request, obj)
def has_change_permission(self, request, obj=None):
return is_mutable and super().has_change_permission(request, obj)
@@ -722,6 +874,60 @@ def save_model(self, request, obj, form, change):
return super().save_model(request, obj, form, change)
+class MediaSubreportAdmin(BulkModerationMixin, admin.ModelAdmin):
+ media_type = None
+
+ exclude = ("media_obj",)
+ ordering = ("-created_on",)
+ search_fields = ("media_obj__identifier",)
+ readonly_fields = ("media_obj_id",)
+
+ def has_add_permission(self, *args, **kwargs):
+ # These objects are created through moderation and
+ # bulk-moderation operations.
+ return False
+
+
+class DeletedMediaAdmin(MediaSubreportAdmin):
+ actions = ["reversed_deindex"]
+ list_display = ("media_obj_id", "created_on")
+
+ ################
+ # Bulk actions #
+ ################
+
+ @admin.action(
+ permissions=["bulk_mod"],
+ description="Reindex selected %(verbose_name_plural)s",
+ )
+ def reversed_deindex(self, request, queryset):
+ return self._bulk_mod(request, queryset, DecisionAction.REVERSED_DEINDEX)
+
+
+class SensitiveMediaAdmin(MediaSubreportAdmin):
+ actions = ["reversed_mark_sensitive"]
+ list_display = ("media_obj_id", "created_on", "is_deindexed")
+
+ @admin.display(description="Deindexed?", boolean=True)
+ def is_deindexed(self, obj):
+ try:
+ getattr(obj, "media_obj")
+ return False
+ except ObjectDoesNotExist:
+ return True
+
+ ################
+ # Bulk actions #
+ ################
+
+ @admin.action(
+ permissions=["bulk_mod"],
+ description="Unmark selected %(verbose_name_plural)s as sensitive",
+ )
+ def reversed_mark_sensitive(self, request, queryset):
+ return self._bulk_mod(request, queryset, DecisionAction.REVERSED_MARK_SENSITIVE)
+
+
class ImageReportAdmin(MediaReportAdmin):
media_type = "image"
@@ -748,11 +954,17 @@ class AudioDecisionAdmin(MediaDecisionAdmin):
through_model = AudioDecisionThrough
-class MediaSubreportAdmin(admin.ModelAdmin):
- exclude = ("media_obj",)
- search_fields = ("media_obj__identifier",)
- readonly_fields = ("media_obj_id",)
+class SensitiveImageAdmin(SensitiveMediaAdmin):
+ media_type = "image"
- def has_add_permission(self, *args, **kwargs):
- """Create ``_Report`` instances instead."""
- return False
+
+class SensitiveAudioAdmin(SensitiveMediaAdmin):
+ media_type = "audio"
+
+
+class DeletedImageAdmin(DeletedMediaAdmin):
+ media_type = "image"
+
+
+class DeletedAudioAdmin(DeletedMediaAdmin):
+ media_type = "audio"
diff --git a/api/api/constants/moderation.py b/api/api/constants/moderation.py
index ba1232e1d18..416df5f6a42 100644
--- a/api/api/constants/moderation.py
+++ b/api/api/constants/moderation.py
@@ -19,5 +19,42 @@ class DecisionAction(models.TextChoices):
REVERSED_DEINDEX = "reversed_deindex", "Reversed deindex"
@property
- def is_reversal(self):
+ def is_forward(self):
+ return self in {
+ self.MARKED_SENSITIVE,
+ self.DEINDEXED_COPYRIGHT,
+ self.DEINDEXED_SENSITIVE,
+ }
+
+ @property
+ def is_reverse(self):
return self in {self.REVERSED_DEINDEX, self.REVERSED_MARK_SENSITIVE}
+
+ @property
+ def is_deindex(self):
+ return self in {self.DEINDEXED_COPYRIGHT, self.DEINDEXED_SENSITIVE}
+
+ @property
+ def verb(self) -> str:
+ """
+ Return the verb form of the action for use in sentences.
+
+ :param object: the object of the sentence
+ :return: the grammatically coherent verb phrase of the action
+ """
+
+ match self:
+ case self.MARKED_SENSITIVE:
+ return "marked as sensitive"
+ case self.DEINDEXED_COPYRIGHT:
+ return "deindexed (copyright)"
+ case self.DEINDEXED_SENSITIVE:
+ return "deindexed (sensitive)"
+ case self.REJECTED_REPORTS:
+ return "rejected"
+ case self.DEDUPLICATED_REPORTS:
+ return "de-duplicated"
+ case self.REVERSED_MARK_SENSITIVE:
+ return "unmarked as sensitive"
+ case self.REVERSED_DEINDEX:
+ return "reindexed"
diff --git a/api/api/models/media.py b/api/api/models/media.py
index ff16b9778a2..694ac3a05ca 100644
--- a/api/api/models/media.py
+++ b/api/api/models/media.py
@@ -8,7 +8,7 @@
from django.utils.html import format_html
import structlog
-from elasticsearch import Elasticsearch, NotFoundError
+from elasticsearch import Elasticsearch, NotFoundError, helpers
from openverse_attribution.license import License
from api.constants.moderation import DecisionAction
@@ -365,9 +365,9 @@ def save(self, *args, **kwargs):
class PerformIndexUpdateMixin:
- @property
- def indexes(self):
- return [self.es_index, f"{self.es_index}-filtered"]
+ @classmethod
+ def indexes(cls):
+ return [cls.es_index, f"{cls.es_index}-filtered"]
def _perform_index_update(self, method: str, raise_errors: bool, **es_method_args):
"""
@@ -387,7 +387,7 @@ def _perform_index_update(self, method: str, raise_errors: bool, **es_method_arg
f"with identifier {self.media_obj.identifier}."
)
- for index in self.indexes:
+ for index in self.indexes():
try:
getattr(es, method)(
index=index,
@@ -404,6 +404,42 @@ def _perform_index_update(self, method: str, raise_errors: bool, **es_method_arg
)
continue
+ @classmethod
+ def _bulk_perform_index_update(
+ cls,
+ method: str,
+ document_ids: list[str],
+ **es_method_args,
+ ):
+ """
+ Call ``method`` on the Elasticsearch client in a bulk operation.
+
+ Automatically handles 404 errors for documents, forces a refresh,
+ and calls the method for origin and filtered indexes.
+
+ Unlike the single-document behaviour, this function does not
+ provide validation to check if the media objects exist.
+ """
+
+ es: Elasticsearch = settings.ES
+
+ actions = [
+ {
+ "_op_type": method,
+ "_index": index,
+ "_id": document_id,
+ **es_method_args,
+ }
+ for index in cls.indexes()
+ for document_id in document_ids
+ ]
+
+ # Perform all actions in bulk, while allowing for missing
+ # documents, similar to the single-document behaviour. In all
+ # other cases, this raises ``BulkIndexError``.
+ helpers.bulk(es, actions, ignore_status=(404,))
+ es.indices.refresh(index=cls.indexes())
+
class AbstractDeletedMedia(PerformIndexUpdateMixin, OpenLedgerModel):
"""
@@ -432,22 +468,41 @@ class AbstractDeletedMedia(PerformIndexUpdateMixin, OpenLedgerModel):
"""
Sub-classes must override this field to point to a concrete sub-class of
``AbstractMedia``.
+
+ Note that unlike ``AbstractSensitiveMedia``, this does not provide
+ a ``delete()`` method to undo the effects of ``save()``. Deindexed
+ media can only be restored through a data refresh.
"""
class Meta:
abstract = True
- def _update_es(self, raise_errors: bool) -> models.Model:
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+ self.perform_action()
+
+ def _update_es(self, raise_errors: bool):
self._perform_index_update(
"delete",
raise_errors,
)
- def save(self, *args, **kwargs):
+ def perform_action(self):
self._update_es(True)
- super().save(*args, **kwargs)
self.media_obj.delete() # remove the actual model instance
+ @classmethod
+ def _bulk_update_es(cls, media_item_ids: list[str]):
+ cls._bulk_perform_index_update(
+ "delete",
+ media_item_ids,
+ )
+
+ @classmethod
+ def bulk_perform_action(cls, media_items: list[type[AbstractMedia]]):
+ cls._bulk_update_es(media_items.values_list("id", flat=True))
+ media_items.delete() # remove the actual model instances
+
class AbstractSensitiveMedia(PerformIndexUpdateMixin, models.Model):
"""
@@ -481,6 +536,14 @@ class AbstractSensitiveMedia(PerformIndexUpdateMixin, models.Model):
class Meta:
abstract = True
+ def save(self, *args, **kwargs):
+ self._update_es(True, True)
+ super().save(*args, **kwargs)
+
+ def delete(self, *args, **kwargs):
+ self._update_es(False, False)
+ super().delete(*args, **kwargs)
+
def _update_es(self, is_mature: bool, raise_errors: bool):
"""
Update the Elasticsearch document associated with the given model.
@@ -494,13 +557,21 @@ def _update_es(self, is_mature: bool, raise_errors: bool):
doc={"mature": is_mature},
)
- def save(self, *args, **kwargs):
- self._update_es(True, True)
- super().save(*args, **kwargs)
+ @classmethod
+ def _bulk_update_es(cls, is_mature: bool, media_item_ids: list[str]):
+ cls._bulk_perform_index_update(
+ "update",
+ media_item_ids,
+ doc={"mature": is_mature},
+ )
- def delete(self, *args, **kwargs):
- self._update_es(False, False)
- super().delete(*args, **kwargs)
+ @classmethod
+ def bulk_perform_action(
+ cls,
+ is_mature: bool,
+ media_items: list[type[AbstractMedia]],
+ ):
+ cls._bulk_update_es(is_mature, media_items.values_list("id", flat=True))
class AbstractMediaList(OpenLedgerModel):
diff --git a/api/api/templates/admin/api/bulk_moderation_confirmation.html b/api/api/templates/admin/api/bulk_moderation_confirmation.html
new file mode 100644
index 00000000000..16586b58df0
--- /dev/null
+++ b/api/api/templates/admin/api/bulk_moderation_confirmation.html
@@ -0,0 +1,68 @@
+{% extends "admin/base_site.html" %}
+{% load admin_urls static %}
+
+{% block extrahead %}
+ {{ block.super }}
+ {{ media }}
+
+{% endblock %}
+
+{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation delete-selected-confirmation{% endblock %}
+
+{% block breadcrumbs %}
+
Are you sure you want the selected {{ objects_name }} to be {{ decision_action.verb }}?
+ +{% if decision_action == "reversed_deindex" %} +