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

Make media items the centre for all moderation activity #4386

Merged
merged 46 commits into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b63525c
Create template tag `get_attr` which will be useful later
dhruvkb May 25, 2024
ec9b28e
Define `get_absolute_url` for media models
dhruvkb May 25, 2024
12c4c91
Make media change view accessible
dhruvkb May 25, 2024
4e004ca
Create form to create decisions for media
dhruvkb May 25, 2024
ecb005e
Section parts of huge `ModelAdmin` class
dhruvkb May 25, 2024
1e7310b
Create utility for soft-lock management
dhruvkb May 25, 2024
691a93d
Create and save `LockManager` instance in `MediaListAdmin`
dhruvkb May 25, 2024
2fda662
Create lock endpoint
dhruvkb May 25, 2024
cb7f8fb
Override change view with extra context
dhruvkb May 25, 2024
9d9cd54
Add preview and tags to the media change view
dhruvkb May 25, 2024
10bfa62
Document list of decisions for a media item
dhruvkb May 25, 2024
58e581a
Define a form to handle creation of decisions
dhruvkb May 25, 2024
3f1b497
Create moderation endpoint
dhruvkb May 25, 2024
7437e8f
Highlight locked rows in media list
dhruvkb May 25, 2024
bbc2261
Give moderators access to view media models
dhruvkb May 26, 2024
9582fd9
Add message for conflicting moderators
dhruvkb May 26, 2024
f0509f7
Handle scenario when no tags
dhruvkb May 26, 2024
8a01fc7
Filter locks by media types
dhruvkb May 26, 2024
25fbafc
Take away option to sort table
dhruvkb May 26, 2024
2775411
Remove invalid comment
dhruvkb May 26, 2024
2bffcdd
Show whether a media item has sensitive text on the list and change p…
dhruvkb May 26, 2024
0af83c9
Add license URL as a read-only field
dhruvkb May 26, 2024
94a1faf
Introduce decision model-admins in production environment
dhruvkb May 27, 2024
7f43f39
Grand additional permissions to moderators
dhruvkb May 27, 2024
9a2563c
Create the opposite of `_production_deferred`
dhruvkb May 27, 2024
eb2d0bc
Match reports UI to the table shown inside media items
dhruvkb May 27, 2024
59b4ef2
Add UI for decisions, similar to the UI for reports
dhruvkb May 27, 2024
6c20fea
Record known bug in implementation of `get_queryset`
dhruvkb May 27, 2024
8e621fe
Do not highlight a moderator's own locks in the list view
dhruvkb May 28, 2024
fd6c7fd
Use `.get_username()` instead of `.username`
dhruvkb May 28, 2024
5f61e2c
Credit the source of the ranked-set idea
dhruvkb May 28, 2024
83df8a4
Avoid use of ambiguous term "shadow"
dhruvkb May 29, 2024
b120145
Fix imports missed during rebase
sarayourfriend May 29, 2024
2bb855f
Fix linting problem
dhruvkb May 29, 2024
4bf0117
Remove the decision creation form if there are no pending reports
dhruvkb May 29, 2024
7d29cf8
Merge branch 'main' of https://github.com/WordPress/openverse into me…
dhruvkb May 30, 2024
e789172
Move list of reports above list of decisions
dhruvkb May 30, 2024
3766e06
Replace hardcoded labels with `.label_tag` and add help text
dhruvkb May 30, 2024
dcec561
Add colons to label for consistency
dhruvkb May 30, 2024
8c69e14
Use model-form for code clarity
dhruvkb May 30, 2024
a2851cf
Change "complaining about' to "referencing"
dhruvkb May 30, 2024
d0d3b03
Use correct reverse URL for audio detail
dhruvkb May 30, 2024
9e5e3b2
Bulk update reports with new decision
dhruvkb May 30, 2024
7c222b9
Remove class causing errors in browser console
dhruvkb May 30, 2024
e799814
Explain the purpose of the script in a comment
dhruvkb May 30, 2024
90b9fbe
Explain slice assignment syntax and purpose
dhruvkb May 30, 2024
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
569 changes: 450 additions & 119 deletions api/api/admin/media_report.py

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion api/api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@
from api.models.audio import (
AltAudioFile,
Audio,
AudioDecision,
AudioDecisionThrough,
AudioList,
AudioReport,
AudioSet,
DeletedAudio,
SensitiveAudio,
)
from api.models.image import DeletedImage, Image, ImageList, ImageReport, SensitiveImage
from api.models.image import (
DeletedImage,
Image,
ImageDecision,
ImageDecisionThrough,
ImageList,
ImageReport,
SensitiveImage,
)
from api.models.media import (
DEINDEXED,
DMCA,
Expand Down
7 changes: 7 additions & 0 deletions api/api/models/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,13 @@ def get_or_create_waveform(self):
class Meta(AbstractMedia.Meta):
db_table = "audio"

def get_absolute_url(self):
"""Enable the "View on site" link in the Django Admin."""

from django.urls import reverse

return reverse("image-detail", args=[str(self.identifier)])
dhruvkb marked this conversation as resolved.
Show resolved Hide resolved


class DeletedAudio(AbstractDeletedMedia):
"""
Expand Down
7 changes: 7 additions & 0 deletions api/api/models/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ class Image(ImageFileMixin, AbstractMedia):
class Meta(AbstractMedia.Meta):
db_table = "image"

def get_absolute_url(self):
"""Enable the "View on site" link in the Django Admin."""

from django.urls import reverse

return reverse("image-detail", args=[str(self.identifier)])

@property
def sensitive(self) -> bool:
return hasattr(self, "sensitive_image")
Expand Down
18 changes: 17 additions & 1 deletion api/api/models/moderation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models import Q
from django.db.models.signals import post_save
from django.dispatch import receiver

Expand All @@ -9,7 +11,7 @@ class UserPreferences(models.Model):
preferences = models.JSONField(default=dict)

def __str__(self):
return f"{self.user.username}'s preferences"
return f"{self.user.get_username()}'s preferences"

@property
def moderator(self):
Expand All @@ -36,3 +38,17 @@ def create_or_update_user_profile(sender, instance, created, **kwargs):
if created:
UserPreferences.objects.create(user=instance)
instance.userpreferences.save()


def get_moderators() -> models.QuerySet:
"""
Get all users who either are members of the "Content Moderators"
group or have superuser privileges.

:return: a ``QuerySet`` of ``User``s who can perform moderation
"""

User = get_user_model()
return User.objects.filter(
Q(groups__name="Content Moderators") | Q(is_superuser=True)
)
69 changes: 69 additions & 0 deletions api/api/templates/admin/api/components/media_additional.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{% comment %}
Props:
- media_type
- media_obj
- tags
{% endcomment %}

<fieldset class="module aligned">
<h2>Additional information</h2>

<div class="form-row field-height">
<div>
<div class="flex-container">
<label>Preview</label>
{% if media_type == 'image' %}
<div class="overflow-hidden">
<img
src="{% url media_type|add:'-thumb' identifier=media_obj.identifier %}"
alt="Media Image"
class="transition-filter blur-60px"
height="300"
onclick="toggleBlur(this)"
onerror="this.onerror=null;this.src='{{ media_obj.url }}';">
</div>
<style>
.overflow-hidden { overflow: hidden; }
.transition-filter { transition: filter 0.3s; }
.blur-60px { filter: blur(60px); }
</style>
<script>
function toggleBlur(img) {
img.classList.toggle('blur-60px');
}
</script>

{% elif media_type == 'audio' %}
<audio controls>
<source src="{{ media_obj.url }}">
Your browser does not support the audio element.
</audio>
{% endif %}
</div>
<div class="help">
{% if media_type == 'image' %}
<div>Click to show/hide content.</div>
{% endif %}
</div>
</div>
</div>

<div class="form-row field-height">
<div>
<div class="flex-container">
<label>Tags</label>
<div>
<dl class="pl-0">
{% for provider, provider_tags in tags.items %}
<dt>{{ provider }}:</dt>
<dd>{{ provider_tags|join:', ' }}</dd>
{% endfor %}
</dl>
<style>
dl.pl-0 { padding-left: 0; }
</style>
</div>
</div>
</div>
</div>
</fieldset>
55 changes: 55 additions & 0 deletions api/api/templates/admin/api/components/media_decisions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{% comment %}
Props:
- media_type
- decision_throughs
{% endcomment %}

{% load get_attr %}

<fieldset class="module">
<h2>Decisions</h2>

<table class="w-full">
<thead>
<tr>
<th class="hidden"></th>
<th>ID</th>
<th>Date</th>
<th>Moderator</th>
<th>Action</th>
<th>Notes</th>
<th>Reports</th>
</tr>
</thead>
<tbody>
{% for decision_through in decision_throughs %}
{% with decision_through.decision as decision %}
<tr>
<td class="hidden"><!-- Hidden inputs etc. --></td>
<td>
<a href="{% url 'admin:api_'|add:media_type|add:'decision_change' decision.id %}">{{ decision.id }}</a>
</td>
<td>{{ decision.created_on }}</td>
<td>{{ decision.moderator }}</td>
<td>{{ decision.action }}</td>
<td>{{ decision.notes }}</td>
<td>
{% with media_type|add:'report_set' as attr_name %}
{% with decision|get_attr:attr_name as reports %}
{% for report in reports.all %}
<a href="{% url 'admin:api_'|add:media_type|add:'report_change' report.id %}">{{ report.id }}</a> ({{report.reason}})<br/>
{% endfor %}
{% endwith %}
{% endwith %}
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>

<style>
.hidden { display: none; }
.w-full { width: 100%; }
</style>
</fieldset>
106 changes: 106 additions & 0 deletions api/api/templates/admin/api/components/media_reports.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
{% comment %}
Props:
- media_type
- reports
- pending_report_count
- mod_form
{% endcomment %}

<fieldset class="module aligned">
<h2>Reports</h2>

{% if pending_report_count %}
<p>
You can take a decision for the pending reports by selecting one
or more of them and creating a decision.
</p>
{% endif %}
<table class="w-full">
<thead>
<tr>
<th class="hidden"></th>
{% if pending_report_count %}<th>Select</th>{% endif %}
<th>ID</th>
<th>Date</th>
<th>Reason</th>
<th>Description</th>
<th>Is pending?</th>
<th>Decision</th>
</tr>
</thead>
<tbody>
{% for report in reports %}
<tr>
<td class="hidden"><!-- Hidden inputs etc. --></td>
{% if pending_report_count %}
<td>
{% if report.is_pending %}
<input
form="decision-create"
type="checkbox"
name="report_id"
value="{{ report.id }}"
class="action-select">
{% endif %}
</td>
{% endif %}
<td>
<a href="{% url 'admin:api_'|add:media_type|add:'report_change' report.id %}">{{ report.id }}</a>
</td>
<td>{{ report.created_at }}</td>
<td>{{ report.reason }}</td>
<td>{{ report.description }}</td>
<td>
{% if report.is_pending %}
<img src="/static/admin/img/icon-yes.svg" alt="False">
{% else %}
<img src="/static/admin/img/icon-no.svg" alt="False">
{% endif %}
</td>
<td>
{% if report.decision %}
<a href="{% url 'admin:api_'|add:media_type|add:'decision_change' report.decision.id %}">{{ report.decision.id }}</a>
({{ report.decision.action }})
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

<style>
.hidden { display: none; }
.w-full { width: 100%; }
</style>
{% if pending_report_count %}
<div class="form-row field-height">
<div>
<div class="flex-container">
<label>Action</label>
<div>
{{ mod_form.action }}
</div>
</div>
</div>
</div>

<div class="form-row field-height">
<div>
<div class="flex-container">
<label>Notes</label>
<div>{{ mod_form.notes }}</div>
</div>
</div>
</div>

<div class="p-10px">
<input form="decision-create" type="submit" value="Create decision">
</div>
{% endif %}
</fieldset>

<style>
.p-10px { padding: 10px; }
</style>
60 changes: 60 additions & 0 deletions api/api/templates/admin/api/media/change_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{% extends "admin/change_form.html" %}

{% block extrahead %}{{ block.super }}
<!--
This script make links clickable. Since we've stored URLs in
`TextField`s, they are not rendered as links in the Django Admin UI.
This script identifies `<div>`s containing links and converts them to
`<a>` tags.
-->
<script>
document.addEventListener("DOMContentLoaded", function() {
document.querySelectorAll("div.readonly").forEach(div => {
if (div.textContent.match(/https?:\/\/\S+/)) {
div.innerHTML = div.textContent.replace(/(https?:\/\/\S+)/g, '<a href="$1">$1</a>');
}
});
});
</script>

<!--
This script polls the soft-lock endpoint at intervals shorter than the
lock TTL to keep the lock alive.
-->
<script>
function softLock() {
fetch('{% url "admin:api_"|add:media_type|add:"_lock" object_id=object_id %}', {
method: "POST",
keepalive: true, // This makes the request equivalent to a beacon.
headers: {
"Content-Type": "application/json",
"X-CSRFToken": document.querySelector('[name="csrfmiddlewaretoken"]').value
},
})
}

document.addEventListener("DOMContentLoaded", function() {
softLock()
setInterval(softLock, 5000)
})
</script>
{% endblock %}

{% block content %}{{ block.super }}
<!-- Fields for this form are supplied separately in `media_reports.html`. -->
{% if pending_report_count %}
<form id="decision-create" method="POST" action="{% url 'admin:api_'|add:media_type|add:'_moderate' object_id %}">
{% csrf_token %}
</form>
{% endif %}
{% endblock %}

{% block object-tools-items %}{{ block.super }}
<li><a href="https://openverse.org/{{media_type}}/{{media_obj.identifier}}" class="viewsitelink">View on openverse.org</a></li>
{% endblock %}

{% block after_field_sets %}
{% include 'admin/api/components/media_additional.html' with media_type=media_type media_obj=media_obj tags=tags only %}
{% include 'admin/api/components/media_decisions.html' with media_type=media_type decision_throughs=decision_throughs only %}
{% include 'admin/api/components/media_reports.html' with media_type=media_type reports=reports pending_report_count=pending_report_count mod_form=mod_form only %}
{% endblock %}
Loading