Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Django admin views for bulk moderation ergonomics #4673

Merged
merged 5 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions api/api/admin/media_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,110 @@ def queryset(self, request, qs):
return PendingRecordCountFilter


def get_media_decision_filter(media_type: str):
match media_type:
case "image":
MediaDecision = ImageDecision
MediaDecisionThrough = ImageDecisionThrough
case "audio":
MediaDecision = AudioDecision
MediaDecisionThrough = AudioDecisionThrough

class MediaDecisionFilter(admin.ListFilter):
title = f"{media_type} decision"
template = "admin/api/filters/media_decision.html"

parameter_name = "decision_id"

def __init__(self, request, params, *args, **kwargs):
super().__init__(request, params, *args, **kwargs)
if self.parameter_name in params:
value = params.pop(self.parameter_name)
self.used_parameters[self.parameter_name] = value

self.range_min = first.id if (first := MediaDecision.objects.first()) else 0
self.range_max = last.id if (last := MediaDecision.objects.last()) else 0

def has_output(self) -> bool:
"""
Determine if the filter should be displayed. The filter is only
displayed if there is at least one decision for the media type.
"""

return MediaDecision.objects.exists()

def choices(self, changelist):
"""
Return two URL query strings, one for filtering by an arbitrary
decision ID and for clearing the filter.
"""

return [
changelist.get_query_string({self.parameter_name: "VALUE"}),
changelist.get_query_string(remove=[self.parameter_name]),
]

def expected_parameters(self):
return [self.parameter_name]

def queryset(self, request, queryset):
"""
Filter the query set to only show UUIDs associated with the selected
media decision.
"""

if self.value() is not None:
uuids = MediaDecisionThrough.objects.filter(
decision_id=self.value()
).values_list("media_obj_id")
queryset = queryset.filter(media_obj_id__in=uuids)
return queryset

def value(self):
"""
Parse the value from the URL query string. Any non numerical value
will be treated as ``None``. Any value outside of the filter's range
will also be treated as ``None``.
"""

try:
value = int(self.used_parameters.get(self.parameter_name))
if not self.range_min <= value <= self.range_max:
return None
return value
except (TypeError, ValueError):
return None

return MediaDecisionFilter


def get_single_bulk_moderation_filter(media_type: str):
class SingleBulkModerationFilter(admin.SimpleListFilter):
title = "media count"
parameter_name = "media_count"

def lookups(self, request, model_admin):
return [
("single", "Single"),
("bulk", "Bulk"),
]

def queryset(self, request, queryset):
conditions = {
"single": {"media_count": 1},
"bulk": {"media_count__gt": 1},
}
if self.value() not in conditions:
return queryset

queryset = queryset.annotate(
media_count=Count(f"{media_type}decisionthrough")
)
return queryset.filter(**conditions[self.value()])

return SingleBulkModerationFilter


class BulkModerationMixin:
def has_bulk_mod_permission(self, request):
return request.user.has_perm(f"api.add_{self.media_type}decision")
Expand Down Expand Up @@ -837,6 +941,9 @@ def has_add_permission(self, request) -> bool:
list_prefetch_related = ("media_objs",)
search_fields = ("notes", *_production_deferred("media_objs__identifier"))

def get_list_filter(self, request):
return (get_single_bulk_moderation_filter(self.media_type),)

@admin.display(description="Media objs")
def media_ids(self, obj):
through_objs = getattr(obj, f"{self.media_type}decisionthrough_set").all()
Expand All @@ -857,6 +964,23 @@ def media_ids(self, obj):
# Change view #
###############

change_form_template = "admin/api/media_decision/change_form.html"

def change_view(self, request, object_id, form_url="", extra_context=None):
# Expand the context based on the template's needs.
dhruvkb marked this conversation as resolved.
Show resolved Hide resolved
extra_context = extra_context or {}

extra_context["media_type"] = self.media_type

decision_obj = self.get_object(request, object_id)
if decision_obj:
extra_context["decision_obj"] = decision_obj
else:
messages.warning(request, f"No media decision found with ID {object_id}.")
return redirect(f"admin:api_{self.media_type}decision_changelist")

return super().change_view(request, object_id, form_url, extra_context)

def get_readonly_fields(self, request, obj=None):
if obj is None:
return ()
Expand Down Expand Up @@ -924,6 +1048,12 @@ class MediaSubreportAdmin(BulkModerationMixin, admin.ModelAdmin):
search_fields = ("media_obj__identifier",)
readonly_fields = ("media_obj_id",)

def get_list_filter(self, request):
return (
"created_on",
get_media_decision_filter(self.media_type),
)

def has_add_permission(self, *args, **kwargs):
# These objects are created through moderation and
# bulk-moderation operations.
Expand Down
71 changes: 71 additions & 0 deletions api/api/templates/admin/api/filters/media_decision.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<details data-filter-title="{{ title }}" open>
<summary>By {{ title }}</summary>

<div id="decision-container">
<form id="decision-form">
<input
id="decision-input"
type="number"
placeholder="{{ title|capfirst }}"
value="{{ spec.value }}"
min="{{ spec.range_min }}"
max="{{ spec.range_max }}">
<button
type="submit"
id="decision-submit">
Filter
</button>
</form>

<script>
document.getElementById('decision-form').addEventListener('submit', function(event) {
event.preventDefault();

const value = document.getElementById('decision-input').value;
const search = "{{ choices.0 }}".replace("VALUE", value);
window.location.search = search;
});
</script>

<li><a href="{{ choices.1 }}">Clear decision ID</a></li>
</div>

<style>
#decision-container {
margin: 5px 0;
padding: 0 15px 15px;
}

#decision-form {
margin-bottom: 4px;
}

#decision-input {
height: 1.1875rem;
border: 1px solid var(--border-color);
padding: 2px 5px;
margin: 0;
vertical-align: top;
border-radius: 4px;
color: var(--body-fg);
width: 128px;
}

#decision-submit {
border: 1px solid var(--border-color);
font-size: 0.8125rem;
padding: 4px 8px;
margin: 0;
vertical-align: middle;
background: var(--body-bg);
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
color: var(--body-fg);
cursor: pointer;
border-radius: 4px;
}

#decision-submit:hover {
border-color: var(--body-quiet-color);
}
</style>
</details>
15 changes: 15 additions & 0 deletions api/api/templates/admin/api/media_decision/change_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "admin/change_form.html" %}

{% block object-tools-items %}{{ block.super }}
<li>
{% if decision_obj.action == "deindexed_copyright" or decision_obj.action == "deindexed_sensitive" %}
<a href="{% url 'admin:api_deleted'|add:media_type|add:'_changelist' %}?decision_id={{ object_id}}">
View deleted media
</a>
{% elif decision_obj.action == "marked_sensitive" %}
<a href="{% url 'admin:api_sensitive'|add:media_type|add:'_changelist' %}?decision_id={{ object_id}}">
View sensitive media
</a>
{% endif %}
</li>
{% endblock %}