Coverage for api/admin/media_report.py: 30%
493 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 06:14 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 06:14 +0000
1import json
2from collections.abc import Sequence
3from functools import update_wrapper
5from django import forms
6from django.conf import settings
7from django.contrib import admin, messages
8from django.contrib.admin import helpers
9from django.contrib.admin.views.main import ChangeList
10from django.core.exceptions import ObjectDoesNotExist
11from django.db.models import Count, F, Min
12from django.http import JsonResponse
13from django.shortcuts import redirect
14from django.template.response import TemplateResponse
15from django.urls import reverse
16from django.utils.html import format_html
17from django.utils.safestring import mark_safe
19import structlog
20from elasticsearch import NotFoundError
21from elasticsearch_dsl import Search
23from api.constants.moderation import DecisionAction
24from api.models import (
25 Audio,
26 AudioDecision,
27 AudioDecisionThrough,
28 AudioReport,
29 ContentSource,
30 DeletedAudio,
31 DeletedImage,
32 Image,
33 ImageDecision,
34 ImageDecisionThrough,
35 ImageReport,
36 SensitiveAudio,
37 SensitiveImage,
38)
39from api.utils.moderation import perform_moderation
40from api.utils.moderation_lock import LockManager
43logger = structlog.get_logger(__name__)
46def register(site):
47 site.register(Image, ImageListViewAdmin)
48 site.register(Audio, AudioListViewAdmin)
50 site.register(AudioReport, AudioReportAdmin)
51 site.register(ImageReport, ImageReportAdmin)
53 site.register(SensitiveImage, SensitiveImageAdmin)
54 site.register(SensitiveAudio, SensitiveAudioAdmin)
56 site.register(DeletedImage, DeletedImageAdmin)
57 site.register(DeletedAudio, DeletedAudioAdmin)
59 site.register(ImageDecision, ImageDecisionAdmin)
60 site.register(AudioDecision, AudioDecisionAdmin)
63def get_report_form(media_type: str):
64 report_class = {
65 "image": ImageReport,
66 "audio": AudioReport,
67 }[media_type]
69 class MediaReportForm(forms.ModelForm):
70 class Meta:
71 fields = ["media_obj", "reason", "description"]
72 model = report_class
74 def __init__(self, *args, **kwargs):
75 super().__init__(*args, **kwargs)
76 for field in self.fields.values():
77 field.widget.attrs.update({"form": "report-create"})
79 return MediaReportForm
82class MultipleValueField(forms.MultipleChoiceField):
83 """
84 This is a variant of ``MultipleChoiceField`` that does not validate
85 the individual values.
86 """
88 def valid_value(self, value):
89 return True
92def get_decision_form(media_type: str):
93 decision_class, report_class = {
94 "image": (ImageDecision, ImageReport),
95 "audio": (AudioDecision, AudioReport),
96 }[media_type]
98 class MediaDecisionForm(forms.ModelForm):
99 report_id = MultipleValueField() # not rendered using its widget
101 class Meta:
102 model = decision_class
103 fields = ["action", "notes"]
105 def __init__(self, *args, **kwargs):
106 super().__init__(*args, **kwargs)
107 for field in self.fields.values():
108 field.widget.attrs.update({"form": "decision-create"})
110 def clean_report_id(self):
111 report_ids = set(self.cleaned_data["report_id"])
112 report_qs = report_class.objects.filter(
113 decision=None,
114 id__in=report_ids,
115 )
116 retrieved_report_ids = set(
117 str(val) for val in report_qs.values_list("id", flat=True)
118 )
119 if diff := (report_ids - retrieved_report_ids):
120 raise forms.ValidationError(
121 "No pending reports found for IDs %(value)s.",
122 params={"value": ", ".join(diff)},
123 )
124 self.cleaned_data["reports"] = report_qs
125 return report_ids
127 return MediaDecisionForm
130def _production_deferred(*values: str) -> Sequence[str]:
131 """
132 Define a sequence in all environment except production.
134 The autocomplete/search queries in Django are woefully unoptimized for massive
135 tables, and so enabling certain utility features in production will often incur
136 a significant performance hit or outage. This will return the input values except
137 when the environment is production, in which case it will return an empty sequence.
138 """
139 if settings.ENVIRONMENT == "production": 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 return ()
141 return values
144def _non_production_deferred(*values: str) -> Sequence[str]:
145 """
146 Define a sequence in only the production environment.
148 The raw ID field is perfectly suited for massive tables, and so enabling
149 that in production will often prevent performance hits or outages. This will
150 return the input values only the environment is production, and in all other
151 cases it will return an empty sequence.
152 """
153 if settings.ENVIRONMENT != "production": 153 ↛ 155line 153 didn't jump to line 155 because the condition on line 153 was always true
154 return ()
155 return values
158class PredeterminedOrderChangelist(ChangeList):
159 """
160 ChangeList class which does not apply any default ordering to the items.
162 This is necessary for lists where the ordering is done on an annotated field, since
163 the changelist attempts to apply the ordering to a QuerySet which is not aware that
164 it has the annotated field available (and thus raises a FieldError).
166 The caveat to this is that the ordering *must* be applied in
167 ModelAdmin::get_queryset
168 """
170 def _get_default_ordering(self):
171 return []
174def get_source_filter(media_type: str):
175 class SourceFilter(admin.SimpleListFilter):
176 """
177 Custom filter for source field in admin list view to prevent Django
178 from performing expensive `DISTINCT` query on media tables.
179 """
181 title = "source"
182 parameter_name = "source"
184 def lookups(self, request, model_admin):
185 return ContentSource.objects.filter(media_type=media_type).values_list(
186 "source_identifier", "source_name"
187 )
189 def queryset(self, request, queryset):
190 value = self.value()
191 if value is not None:
192 queryset = queryset.filter(source=value)
193 return queryset
195 return SourceFilter
198def get_pending_record_filter(media_type: str):
199 class PendingRecordCountFilter(admin.SimpleListFilter):
200 title = "pending record count"
201 parameter_name = "pending_record_count"
203 def choices(self, changelist):
204 for lookup, title in self.lookup_choices:
205 yield {
206 "selected": self.value() == lookup,
207 "query_string": changelist.get_query_string(
208 {self.parameter_name: lookup}, []
209 ),
210 "display": title,
211 }
213 def lookups(self, request, model_admin):
214 return [
215 (None, "Moderation queue"),
216 ("prev", "Resolved"),
217 ("all", "All"),
218 ]
220 def queryset(self, request, qs):
221 value = self.value()
222 if value is None or value == "prev":
223 # Filter down to only instances with reports
224 qs = qs.filter(**{f"{media_type}_report__isnull": False})
226 # Annotate and order by report count
227 qs = qs.annotate(total_report_count=Count(f"{media_type}_report"))
228 # Show total pending reports by subtracting the number of reports
229 # from the number of reports that have decisions
230 qs = qs.annotate(
231 pending_report_count=F("total_report_count")
232 - Count(f"{media_type}_report__decision__pk")
233 )
234 qs = qs.annotate(
235 oldest_report_date=Min(f"{media_type}_report__created_at")
236 )
237 qs = qs.order_by(
238 "-total_report_count", "-pending_report_count", "oldest_report_date"
239 )
240 if value is None:
241 qs = qs.filter(pending_report_count__gt=0)
242 if value == "prev":
243 qs = qs.filter(pending_report_count=0)
245 return qs
247 return PendingRecordCountFilter
250def get_media_decision_filter(media_type: str):
251 match media_type:
252 case "image":
253 MediaDecision = ImageDecision
254 MediaDecisionThrough = ImageDecisionThrough
255 case "audio":
256 MediaDecision = AudioDecision
257 MediaDecisionThrough = AudioDecisionThrough
259 class MediaDecisionFilter(admin.ListFilter):
260 title = f"{media_type} decision"
261 template = "admin/api/filters/media_decision.html"
263 parameter_name = "decision_id"
265 def __init__(self, request, params, *args, **kwargs):
266 super().__init__(request, params, *args, **kwargs)
267 if self.parameter_name in params:
268 value = params.pop(self.parameter_name)
269 self.used_parameters[self.parameter_name] = value
271 self.range_min = first.id if (first := MediaDecision.objects.first()) else 0
272 self.range_max = last.id if (last := MediaDecision.objects.last()) else 0
274 def has_output(self) -> bool:
275 """
276 Determine if the filter should be displayed. The filter is only
277 displayed if there is at least one decision for the media type.
278 """
280 return MediaDecision.objects.exists()
282 def choices(self, changelist):
283 """
284 Return two URL query strings, one for filtering by an arbitrary
285 decision ID and for clearing the filter.
286 """
288 return [
289 changelist.get_query_string({self.parameter_name: "VALUE"}),
290 changelist.get_query_string(remove=[self.parameter_name]),
291 ]
293 def expected_parameters(self):
294 return [self.parameter_name]
296 def queryset(self, request, queryset):
297 """
298 Filter the query set to only show UUIDs associated with the selected
299 media decision.
300 """
302 if self.value() is not None:
303 uuids = MediaDecisionThrough.objects.filter(
304 decision_id=self.value()
305 ).values_list("media_obj_id")
306 queryset = queryset.filter(media_obj_id__in=uuids)
307 return queryset
309 def value(self):
310 """
311 Parse the value from the URL query string. Any non numerical value
312 will be treated as ``None``. Any value outside of the filter's range
313 will also be treated as ``None``.
314 """
316 try:
317 value = int(self.used_parameters.get(self.parameter_name))
318 if not self.range_min <= value <= self.range_max:
319 return None
320 return value
321 except (TypeError, ValueError):
322 return None
324 return MediaDecisionFilter
327def get_single_bulk_moderation_filter(media_type: str):
328 class SingleBulkModerationFilter(admin.SimpleListFilter):
329 title = "media count"
330 parameter_name = "media_count"
332 def lookups(self, request, model_admin):
333 return [
334 ("single", "Single"),
335 ("bulk", "Bulk"),
336 ]
338 def queryset(self, request, queryset):
339 conditions = {
340 "single": {"media_count": 1},
341 "bulk": {"media_count__gt": 1},
342 }
343 if self.value() not in conditions:
344 return queryset
346 queryset = queryset.annotate(
347 media_count=Count(f"{media_type}decisionthrough")
348 )
349 return queryset.filter(**conditions[self.value()])
351 return SingleBulkModerationFilter
354class BulkModerationMixin:
355 def has_bulk_mod_permission(self, request):
356 return request.user.has_perm(f"api.add_{self.media_type}decision")
358 def _bulk_mod(self, request, queryset, action: DecisionAction):
359 """
360 Perform bulk moderation for the queryset as per the requested
361 action.
363 This follows the pattern of the ``delete_selected`` action
364 defined in ``django.contrib.admin.actions.py``.
365 """
367 opts = self.model._meta
368 verbose_name_plural = opts.verbose_name_plural
370 if action == DecisionAction.MARKED_SENSITIVE:
371 init_count = queryset.count()
372 queryset = queryset.filter(**{f"sensitive_{self.media_type}__isnull": True})
374 count = len(queryset)
375 prev_count = init_count - count
376 stats = {
377 f"selected {verbose_name_plural}": init_count,
378 f"{verbose_name_plural} already marked as sensitive": prev_count,
379 }
380 else:
381 # No filtering is needed for any other actions.
382 count = len(queryset)
383 stats = {}
384 stats[f"{verbose_name_plural} to be {action.verb}"] = count
386 if count == 0:
387 messages.info(
388 request,
389 f"All selected items are already {action.verb}.",
390 )
391 return None
393 if request.POST.get("post"):
394 # The user has already confirmed so we will perform the
395 # moderation and return ``None`` to display the change list
396 # view again.
397 decision = perform_moderation(request, self.media_type, queryset, action)
398 path = reverse(
399 f"admin:api_{self.media_type}decision_change", args=(decision.id,)
400 )
401 messages.success(
402 request,
403 format_html(
404 'Successfully moderated {} items via <a href="{}">decision {}</a>.',
405 queryset.count(),
406 path,
407 decision.id,
408 ),
409 )
410 return None
412 objects_name = opts.verbose_name if count == 1 else opts.verbose_name_plural
413 moderatable_objects = [
414 format_html(
415 '<a href="{}">{}</a>',
416 reverse(
417 f"admin:api_{queryset.model._meta.model_name}_change",
418 args=(obj.pk,),
419 ),
420 obj,
421 )
422 for obj in queryset
423 ]
425 context = {
426 **self.admin_site.each_context(request),
427 "title": "Are you sure?",
428 "objects_name": str(objects_name),
429 "moderatable_objects": [moderatable_objects],
430 "stats": dict(stats).items(),
431 "queryset": queryset,
432 "opts": opts,
433 "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
434 "media": self.media,
435 "decision_action": action,
436 }
438 return TemplateResponse(
439 request,
440 "admin/api/bulk_moderation_confirmation.html",
441 context,
442 )
445class MediaListAdmin(BulkModerationMixin, admin.ModelAdmin):
446 media_type = None
448 def __init__(self, *args, **kwargs):
449 self.lock_manager = LockManager(self.media_type)
451 super().__init__(*args, **kwargs)
453 def get_urls(self):
454 # Start of block lifted from Django source.
455 from django.urls import path
457 def wrap(view):
458 def wrapper(*args, **kwargs):
459 return self.admin_site.admin_view(view)(*args, **kwargs)
461 wrapper.model_admin = self
462 return update_wrapper(wrapper, view)
464 app, model = self.opts.app_label, self.opts.model_name
465 # End of block lifted from Django source.
467 urls = super().get_urls()
469 # Using slice assignment (docs:
470 # https://docs.python.org/3/tutorial/introduction.html#lists),
471 # insert custom URLs at the penultimate position so that they
472 # appear just before the catch-all view.
473 urls[-1:-1] = [
474 path(
475 "<path:object_id>/report_create/",
476 wrap(self.report_create_view),
477 name=f"{app}_{model}_report_create",
478 ),
479 path(
480 "<path:object_id>/decision_create/",
481 wrap(self.decision_create_view),
482 name=f"{app}_{model}_decision_create",
483 ),
484 path(
485 "<path:object_id>/lock/",
486 wrap(self.lock_view),
487 name=f"{app}_{model}_lock",
488 ),
489 ]
490 return urls
492 @admin.display(description="Has sensitive text?", boolean=True)
493 def has_sensitive_text(self, obj):
494 """
495 Determine if the item has sensitive text.
497 If the item cannot be found in the filtered index, that means it
498 was filtered out due to text sensitivity.
500 This is displayed both as a column in the list page as well as a
501 read-only field in the change page.
503 :param obj: the item to check for presence of sensitive text
504 :return: whether the item has sensitive text
505 """
507 filtered_index = f"{settings.MEDIA_INDEX_MAPPING[self.media_type]}-filtered"
508 try:
509 search = (
510 Search(index=filtered_index)
511 .query("term", identifier=obj.identifier)
512 .execute()
513 )
514 if search.hits:
515 return False
516 except NotFoundError:
517 logger.error("Could not resolve index.", name=filtered_index, exc_info=True)
518 return True
520 #############
521 # List view #
522 #############
524 change_list_template = "admin/api/media/change_list.html"
525 list_display = ("identifier",)
526 list_display_links = ("identifier",)
527 list_per_page = 15
528 show_full_result_count = False
529 search_fields = (None,) # Search functionality is overridden below.
530 search_help_text = format_html(
531 """
532 <p>
533 You can use <a href="{}">query string syntax</a> for advanced search.
534 You can use <code>"</code> to wrap phrases and <code>?</code>, <code>*</code> as wildcards.
535 </p>
536 <details>
537 <summary>Examples</summary>
538 <ul>
539 <li>Search all indexed fields in the model: <code>Animal</code></li>
540 <li>Search for an identifier: <code>3b858852-67df-44e1-8d57-683991d3ec67</code></li>
541 <li>
542 Search for a creator: <code>creator:Al</code><br>
543 When searching by creator, make sure to select a single provider using the filters in the sidebar.
544 </li>
545 <li>Search for a tag name: <code>tags.name:cat</code></li>
546 </ul>
547 </details>
548 """,
549 "https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax",
550 )
551 sortable_by = () # Ordering is defined in ``get_queryset``.
552 actions = ["marked_sensitive", "deindexed_sensitive", "deindexed_copyright"]
554 def get_list_filter(self, request):
555 return (
556 get_source_filter(self.media_type),
557 get_pending_record_filter(self.media_type),
558 )
560 def get_list_display(self, request):
561 if request.GET.get("pending_record_count") == "all":
562 return self.list_display + (
563 "source",
564 "provider",
565 )
566 elif request.GET.get("pending_record_count") == "prev":
567 return self.list_display + (
568 "total_report_count",
569 "oldest_report_date",
570 "has_sensitive_text",
571 )
572 else:
573 return self.list_display + (
574 "total_report_count",
575 "pending_report_count",
576 "oldest_report_date",
577 "pending_reports_links",
578 "has_sensitive_text",
579 )
581 def get_search_results(self, request, queryset, search_term):
582 """
583 Override admin search to use Elasticsearch.
585 This enables the admin to make complex search queries using
586 query string syntax, that would not normally be possible with
587 Django's native search.
588 """
590 if not search_term:
591 return queryset, False
593 search = Search(index=settings.MEDIA_INDEX_MAPPING[self.media_type])
594 search = search.query("query_string", query=search_term)
595 identifiers = (hit.identifier for hit in search.scan())
596 return queryset.filter(identifier__in=identifiers), False
598 def total_report_count(self, obj):
599 return obj.total_report_count
601 def pending_report_count(self, obj):
602 return obj.pending_report_count
604 def oldest_report_date(self, obj):
605 return obj.oldest_report_date
607 @admin.display(description="Open reports")
608 def pending_reports_links(self, obj):
609 reports = getattr(obj, f"{self.media_type}_report")
610 pending_reports = reports.filter(decision__isnull=True)
611 data = []
612 for report in pending_reports.all():
613 path = reverse(
614 f"admin:api_{self.media_type}report_change", args=(report.id,)
615 )
616 data.append(format_html('• <a href="{}">Report {}</a>', path, report.id))
618 return mark_safe("<br>".join(data))
620 def changelist_view(self, request, extra_context=None):
621 extra_context = extra_context or {}
623 extra_context["media_type"] = self.media_type
625 valid_locks = self.lock_manager.prune()
626 locked_media = list(
627 int(item.replace(f"{self.media_type}:", ""))
628 for moderator, lock_set in valid_locks.items()
629 for item in lock_set
630 if self.media_type in item and moderator != request.user.get_username()
631 )
632 extra_context["locked_media"] = locked_media
634 return super().changelist_view(request, extra_context)
636 ###############
637 # Change view #
638 ###############
640 change_form_template = "admin/api/media/change_form.html"
641 readonly_fields = (
642 "attribution",
643 "license_url",
644 "has_sensitive_text",
645 )
647 def change_view(self, request, object_id, form_url="", extra_context=None):
648 # Populate a warning message for locked items.
649 mods = self.lock_manager.moderator_set(object_id)
650 mods -= {request.user.get_username()}
651 if len(mods):
652 messages.warning(
653 request,
654 f"This {self.media_type} is also being viewed by {', '.join(mods)}.",
655 )
657 # Expand the context based on the template's needs.
658 extra_context = extra_context or {}
660 extra_context["media_type"] = self.media_type
662 media_obj = self.get_object(request, object_id)
663 if media_obj:
664 extra_context["media_obj"] = media_obj
665 else:
666 messages.warning(request, f"No media object found with ID {object_id}.")
667 return redirect(f"admin:api_{self.media_type}_changelist")
669 tags_by_provider = {}
670 if tags := media_obj.tags:
671 for tag in tags:
672 text = tag["name"]
673 if acc := tag.get("accuracy"):
674 text = f"{text} ({acc})"
675 tags_by_provider.setdefault(
676 tag.get("provider", "No provider"), []
677 ).append(text)
678 extra_context["tags"] = tags_by_provider
680 manager = getattr(media_obj, f"{self.media_type}decisionthrough_set")
681 decision_throughs = manager.order_by("decision__created_on")
682 extra_context["decision_throughs"] = decision_throughs
684 manager = getattr(media_obj, f"{self.media_type}_report")
685 reports = manager.order_by("-created_at")
686 extra_context["reports"] = reports
688 pending_report_count = reports.filter(decision_id=None).count()
689 extra_context["pending_report_count"] = pending_report_count
691 extra_context["mod_form"] = get_decision_form(self.media_type)()
693 extra_context["report_form"] = get_report_form(self.media_type)()
695 return super().change_view(request, object_id, form_url, extra_context)
697 #############
698 # Lock view #
699 #############
701 def lock_view(self, request, object_id):
702 """
703 Softly lock the media object with the current user to notify
704 other moderators about a potential conflict.
705 """
707 if request.method == "POST":
708 expiration = self.lock_manager.add_locks(
709 request.user.get_username(), object_id
710 )
711 return JsonResponse(
712 data={"expiration": expiration},
713 status=503 if expiration == 0 else 200,
714 )
716 return redirect(f"admin:api_{self.media_type}_change", object_id)
718 ########################
719 # Decision create view #
720 ########################
722 def decision_create_view(self, request, object_id):
723 """
724 Create a decision for the media object and associate selected
725 reports referencing the media with this decision.
726 """
728 redir = redirect(f"admin:api_{self.media_type}_change", object_id)
730 if request.method != "POST":
731 return redir
733 if not request.user.has_perm(f"api.add_{self.media_type}decision"):
734 messages.error(
735 request,
736 "You do not have permission to create decisions.",
737 )
738 return redir
740 media_obj = self.get_object(request, object_id)
742 through_model = {
743 "image": ImageDecisionThrough,
744 "audio": AudioDecisionThrough,
745 }[self.media_type]
746 form = get_decision_form(self.media_type)(request.POST)
747 if form.is_valid():
748 decision = form.save(commit=False)
749 decision.moderator = request.user
750 decision.save()
752 logger.debug(
753 "Decision created",
754 decision=decision.id,
755 action=decision.action,
756 notes=decision.notes,
757 moderator=request.user.get_username(),
758 )
760 through = through_model.objects.create(
761 decision=decision,
762 media_obj=media_obj,
763 )
764 logger.debug(
765 "Through model created",
766 through=through.id,
767 decision=decision.id,
768 media_obj=media_obj.id,
769 )
771 reports = form.cleaned_data["reports"]
772 count = reports.update(decision=decision)
773 logger.debug(
774 "Decision recorded in reports",
775 report_count=count,
776 decision=decision.id,
777 )
779 if decision.action in {
780 DecisionAction.DEINDEXED_COPYRIGHT,
781 DecisionAction.DEINDEXED_SENSITIVE,
782 }:
783 messages.debug(
784 request,
785 "The media object has been deindexed from ES and deleted from DB.",
786 )
787 return redirect(f"admin:api_{self.media_type}_changelist")
789 if decision.action == DecisionAction.MARKED_SENSITIVE:
790 messages.debug(
791 request,
792 "The media object has been marked as sensitive.",
793 )
794 else:
795 messages.warning(
796 request,
797 f"Form is invalid: {json.dumps(form.errors)}",
798 )
800 return redir
802 ######################
803 # Report create view #
804 ######################
806 def report_create_view(self, request, object_id):
807 """Create a report for the media object."""
809 if request.method == "POST":
810 media_obj = self.get_object(request, object_id)
812 form = get_report_form(self.media_type)(request.POST)
813 if form.is_valid():
814 report = form.save(commit=False)
815 report.media_obj = media_obj
816 report.save()
818 logger.debug(
819 "Report created",
820 report=report.id,
821 reason=report.reason,
822 description=report.description,
823 media_obj=media_obj.id,
824 )
825 else:
826 messages.warning(
827 request,
828 f"Form is invalid: {json.dumps(form.errors)}",
829 )
831 return redirect(f"admin:api_{self.media_type}_change", object_id)
833 #############
834 # Overrides #
835 #############
837 def get_changelist(self, request, **kwargs):
838 return PredeterminedOrderChangelist
840 ################
841 # Bulk actions #
842 ################
844 @admin.action(
845 permissions=["bulk_mod"],
846 description="Mark selected %(verbose_name_plural)s as sensitive",
847 )
848 def marked_sensitive(self, request, queryset):
849 return self._bulk_mod(request, queryset, DecisionAction.MARKED_SENSITIVE)
851 @admin.action(
852 permissions=["bulk_mod"],
853 description="Deindex selected %(verbose_name_plural)s (sensitive)",
854 )
855 def deindexed_sensitive(self, request, queryset):
856 return self._bulk_mod(request, queryset, DecisionAction.DEINDEXED_SENSITIVE)
858 @admin.action(
859 permissions=["bulk_mod"],
860 description="Deindex selected %(verbose_name_plural)s (copyright)",
861 )
862 def deindexed_copyright(self, request, queryset):
863 return self._bulk_mod(request, queryset, DecisionAction.DEINDEXED_COPYRIGHT)
866class MediaReportAdmin(admin.ModelAdmin):
867 media_type = None
869 @admin.display(description="Is pending?", boolean=True)
870 def is_pending(self, obj):
871 """
872 Set an explicit display type for the ``is_pending`` property.
874 This is required so that the property, which otherwise renders
875 "True" or "False" strings, now renders as check/cross icons in
876 Django Admin.
877 """
879 return obj.is_pending
881 #############
882 # List view #
883 #############
885 list_display = (
886 "id",
887 "created_at",
888 "reason",
889 "description",
890 "is_pending",
891 "media_id", # used because ``media_obj`` does not render a link
892 )
893 search_fields = ("description", *_production_deferred("media_obj__identifier"))
895 @admin.display(description="Media obj")
896 def media_id(self, obj):
897 path = reverse(f"admin:api_{self.media_type}_change", args=(obj.media_obj.id,))
898 return format_html(f'<a href="{path}">{obj.media_obj}</a>')
900 ###############
901 # Change view #
902 ###############
904 autocomplete_fields = ("decision", *_production_deferred("media_obj"))
905 raw_id_fields = _non_production_deferred("media_obj")
906 actions = None
908 def get_readonly_fields(self, request, obj=None):
909 if obj is None: # Create form
910 return ()
911 # These fields only make sense after a report has been created.
912 # Hence they are only shown in the change form.
913 return (
914 "created_at",
915 "is_pending",
916 "media_obj",
917 )
919 def get_exclude(self, request, obj=None):
920 if obj is None: # Create form
921 # The decision will be linked to the report after it has
922 # been created, not during.
923 return ("decision",)
924 else: # Change form
925 # In the change form, we do not want to allow the media
926 # object to be changed.
927 return ("media_obj",)
930class MediaDecisionAdmin(admin.ModelAdmin):
931 media_type = None
932 through_model = None
934 ###############
935 # Permissions #
936 ###############
938 def has_add_permission(self, request) -> bool:
939 """
940 Disables the "Add" form for media decisions from the admin site.
942 This hides the form even for users who are authorised to create
943 decisions (have the permissions "api.add_imagedecision" or
944 "api.add_audiodecision"), because decisions are supposed to be
945 created from the media item view.
946 """
948 return False
950 #############
951 # List view #
952 #############
954 list_display = (
955 "id",
956 "created_on",
957 "moderator",
958 "action",
959 "notes",
960 "media_ids",
961 )
962 list_filter = ("moderator", "action")
963 list_prefetch_related = ("media_objs",)
964 search_fields = ("notes", *_production_deferred("media_objs__identifier"))
966 def get_list_filter(self, request):
967 return (get_single_bulk_moderation_filter(self.media_type),)
969 @admin.display(description="Media objs")
970 def media_ids(self, obj):
971 through_objs = getattr(obj, f"{self.media_type}decisionthrough_set").all()
972 data = []
973 for obj in through_objs:
974 try:
975 path = reverse(
976 f"admin:api_{self.media_type}_change", args=(obj.media_obj.id,)
977 )
978 data.append(
979 format_html('• <a href="{}">{}</a>', path, obj.media_obj.identifier)
980 )
981 except ObjectDoesNotExist:
982 data.append(f"• {obj.media_obj_id}")
983 return mark_safe("<br>".join(data))
985 ###############
986 # Change view #
987 ###############
989 change_form_template = "admin/api/media_decision/change_form.html"
991 def change_view(self, request, object_id, form_url="", extra_context=None):
992 # Expand the context based on the template's needs.
993 extra_context = extra_context or {}
995 extra_context["media_type"] = self.media_type
997 decision_obj = self.get_object(request, object_id)
998 if decision_obj:
999 extra_context["decision_obj"] = decision_obj
1000 else:
1001 messages.warning(request, f"No media decision found with ID {object_id}.")
1002 return redirect(f"admin:api_{self.media_type}decision_changelist")
1004 return super().change_view(request, object_id, form_url, extra_context)
1006 def get_readonly_fields(self, request, obj=None):
1007 if obj is None:
1008 return ()
1009 # These fields only make sense after a decision has been created.
1010 # Moderator is set automatically and cannot be changed.
1011 return (
1012 "created_on",
1013 "moderator",
1014 )
1016 def get_exclude(self, request, obj=None):
1017 if obj is None: # Create form
1018 # Moderator is set automatically and cannot be changed.
1019 return ("moderator",)
1020 return ()
1022 def get_inlines(self, request, obj=None):
1023 if obj is None:
1024 # New decision, can make changes to the media objects.
1025 is_mutable = True
1026 else:
1027 # Once created, media objects associated with decisions are
1028 # immutable.
1029 is_mutable = False
1031 class MediaDecisionThroughAdmin(admin.TabularInline):
1032 model = self.through_model
1033 extra = 1
1034 autocomplete_fields = _production_deferred("media_obj")
1035 raw_id_fields = _non_production_deferred("media_obj")
1037 def get_readonly_fields(self, request, obj):
1038 if is_mutable:
1039 return super().get_readonly_fields(request, obj)
1040 else:
1041 return ("media_obj_id",)
1043 def get_exclude(self, request, obj):
1044 if is_mutable:
1045 return super().get_exclude(request, obj)
1046 else:
1047 return ("media_obj",)
1049 def has_add_permission(self, request, obj=None):
1050 return is_mutable and super().has_add_permission(request, obj)
1052 def has_change_permission(self, request, obj=None):
1053 return is_mutable and super().has_change_permission(request, obj)
1055 def has_delete_permission(self, request, obj=None):
1056 return is_mutable and super().has_delete_permission(request, obj)
1058 return (MediaDecisionThroughAdmin,)
1060 def save_model(self, request, obj, form, change):
1061 obj.moderator = request.user
1062 return super().save_model(request, obj, form, change)
1065class MediaSubreportAdmin(BulkModerationMixin, admin.ModelAdmin):
1066 media_type = None
1068 exclude = ("media_obj",)
1069 ordering = ("-created_on",)
1070 search_fields = ("media_obj__identifier",)
1071 readonly_fields = ("media_obj_id",)
1073 def get_list_filter(self, request):
1074 return (
1075 "created_on",
1076 get_media_decision_filter(self.media_type),
1077 )
1079 def has_add_permission(self, *args, **kwargs):
1080 # These objects are created through moderation and
1081 # bulk-moderation operations.
1082 return False
1085class DeletedMediaAdmin(MediaSubreportAdmin):
1086 actions = ["reversed_deindex"]
1087 list_display = ("media_obj_id", "created_on")
1089 ################
1090 # Bulk actions #
1091 ################
1093 @admin.action(
1094 permissions=["bulk_mod"],
1095 description="Reindex selected %(verbose_name_plural)s",
1096 )
1097 def reversed_deindex(self, request, queryset):
1098 return self._bulk_mod(request, queryset, DecisionAction.REVERSED_DEINDEX)
1101class SensitiveMediaAdmin(MediaSubreportAdmin):
1102 actions = ["reversed_mark_sensitive"]
1103 list_display = ("media_obj_id", "created_on", "is_deindexed")
1105 @admin.display(description="Deindexed?", boolean=True)
1106 def is_deindexed(self, obj):
1107 try:
1108 getattr(obj, "media_obj")
1109 return False
1110 except ObjectDoesNotExist:
1111 return True
1113 ################
1114 # Bulk actions #
1115 ################
1117 @admin.action(
1118 permissions=["bulk_mod"],
1119 description="Unmark selected %(verbose_name_plural)s as sensitive",
1120 )
1121 def reversed_mark_sensitive(self, request, queryset):
1122 return self._bulk_mod(request, queryset, DecisionAction.REVERSED_MARK_SENSITIVE)
1125class ImageReportAdmin(MediaReportAdmin):
1126 media_type = "image"
1129class AudioReportAdmin(MediaReportAdmin):
1130 media_type = "audio"
1133class ImageListViewAdmin(MediaListAdmin):
1134 media_type = "image"
1137class AudioListViewAdmin(MediaListAdmin):
1138 media_type = "audio"
1141class ImageDecisionAdmin(MediaDecisionAdmin):
1142 media_type = "image"
1143 through_model = ImageDecisionThrough
1146class AudioDecisionAdmin(MediaDecisionAdmin):
1147 media_type = "audio"
1148 through_model = AudioDecisionThrough
1151class SensitiveImageAdmin(SensitiveMediaAdmin):
1152 media_type = "image"
1155class SensitiveAudioAdmin(SensitiveMediaAdmin):
1156 media_type = "audio"
1159class DeletedImageAdmin(DeletedMediaAdmin):
1160 media_type = "image"
1163class DeletedAudioAdmin(DeletedMediaAdmin):
1164 media_type = "audio"