Skip to content

Commit

Permalink
Create the moderation decision model (#3989)
Browse files Browse the repository at this point in the history
Co-authored-by: sarayourfriend <24264157+sarayourfriend@users.noreply.github.com>
Co-authored-by: Madison Swain-Bowden <bowdenm@spu.edu>
  • Loading branch information
3 people authored Apr 3, 2024
1 parent 213c886 commit 2bd852c
Show file tree
Hide file tree
Showing 7 changed files with 221 additions and 70 deletions.
23 changes: 8 additions & 15 deletions api/api/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,16 @@ class AudioAdmin(admin.ModelAdmin):


class MediaReportAdmin(admin.ModelAdmin):
list_display = ("reason", "status", "description", "created_at")
media_specific_list_display = ()
list_filter = ("status", "reason")
list_display_links = ("status",)
list_display = ("id", "reason", "is_pending", "description", "created_at", "url")
list_filter = (
("decision", admin.EmptyFieldListFilter), # ~status, i.e. pending or moderated
"reason",
)
list_display_links = ("id",)
search_fields = ("description", "media_obj__identifier")
autocomplete_fields = ("media_obj",)
actions = None

def get_list_display(self, request):
return self.list_display + self.media_specific_list_display

def get_exclude(self, request, obj=None):
# ``identifier`` cannot be edited on an existing report.
if request.path.endswith("/change/"):
Expand All @@ -61,14 +60,8 @@ def get_readonly_fields(self, request, obj=None):
return readonly_fields


@admin.register(ImageReport)
class ImageReportAdmin(MediaReportAdmin):
media_specific_list_display = ("image_url",)


@admin.register(AudioReport)
class AudioReportAdmin(MediaReportAdmin):
media_specific_list_display = ("audio_url",)
admin.site.register(AudioReport, MediaReportAdmin)
admin.site.register(ImageReport, MediaReportAdmin)


class MediaSubreportAdmin(admin.ModelAdmin):
Expand Down
23 changes: 23 additions & 0 deletions api/api/constants/moderation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from django.db import models


class DecisionAction(models.TextChoices):
"""
This enumeration represents the actions that can be taken by a moderator as
a part of a moderation decision.
"""

MARKED_SENSITIVE = "marked_sensitive", "Marked sensitive"

DEINDEXED_COPYRIGHT = "deindexed_copyright", "Deindexed (copyright)"
DEINDEXED_SENSITIVE = "deindexed_sensitive", "Deindexed (sensitive)"

REJECTED_REPORTS = "rejected_reports", "Rejected"
DEDUPLICATED_REPORTS = "deduplicated_reports", "De-duplicated"

REVERSED_MARK_SENSITIVE = "reversed_mark_sensitive", "Reversed mark sensitive"
REVERSED_DEINDEX = "reversed_deindex", "Reversed deindex"

@property
def is_reversal(self):
return self in {self.REVERSED_DEINDEX, self.REVERSED_MARK_SENSITIVE}
56 changes: 56 additions & 0 deletions api/api/migrations/0058_moderation_decision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 4.2.7 on 2024-03-31 12:01

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('api', '0057_alter_sensitiveaudio_options'),
]

operations = [
migrations.CreateModel(
name='ImageDecision',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_on', models.DateTimeField(auto_now_add=True)),
('updated_on', models.DateTimeField(auto_now=True)),
('notes', models.TextField(blank=True, help_text="The moderator's explanation for the decision or additional notes.", max_length=500, null=True)),
('action', models.CharField(choices=[('marked_sensitive', 'Marked sensitive'), ('deindexed_copyright', 'Deindexed (copyright)'), ('deindexed_sensitive', 'Deindexed (sensitive)'), ('rejected_reports', 'Rejected'), ('deduplicated_reports', 'De-duplicated'), ('reversed_mark_sensitive', 'Reversed mark sensitive'), ('reversed_deindex', 'Reversed deindex')], help_text='Action taken by the moderator.', max_length=32)),
('media_objs', models.ManyToManyField(db_constraint=False, help_text='The image items being moderated.', to='api.image')),
('moderator', models.ForeignKey(help_text='The moderator who undertook this decision.', on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='AudioDecision',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_on', models.DateTimeField(auto_now_add=True)),
('updated_on', models.DateTimeField(auto_now=True)),
('notes', models.TextField(blank=True, help_text="The moderator's explanation for the decision or additional notes.", max_length=500, null=True)),
('action', models.CharField(choices=[('marked_sensitive', 'Marked sensitive'), ('deindexed_copyright', 'Deindexed (copyright)'), ('deindexed_sensitive', 'Deindexed (sensitive)'), ('rejected_reports', 'Rejected'), ('deduplicated_reports', 'De-duplicated'), ('reversed_mark_sensitive', 'Reversed mark sensitive'), ('reversed_deindex', 'Reversed deindex')], help_text='Action taken by the moderator.', max_length=32)),
('media_objs', models.ManyToManyField(db_constraint=False, help_text='The audio items being moderated.', to='api.audio')),
('moderator', models.ForeignKey(help_text='The moderator who undertook this decision.', on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='audioreport',
name='decision',
field=models.ForeignKey(blank=True, help_text='The moderation decision for this report.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.audiodecision'),
),
migrations.AddField(
model_name='imagereport',
name='decision',
field=models.ForeignKey(blank=True, help_text='The moderation decision for this report.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.imagedecision'),
),
]
24 changes: 19 additions & 5 deletions api/api/models/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
AbstractAltFile,
AbstractDeletedMedia,
AbstractMedia,
AbstractMediaDecision,
AbstractMediaList,
AbstractMediaReport,
AbstractSensitiveMedia,
Expand Down Expand Up @@ -289,8 +290,6 @@ class Meta:

class AudioReport(AbstractMediaReport):
media_class = Audio
sensitive_class = SensitiveAudio
deleted_class = DeletedAudio

media_obj = models.ForeignKey(
to="Audio",
Expand All @@ -301,13 +300,28 @@ class AudioReport(AbstractMediaReport):
related_name="audio_report",
help_text="The reference to the audio being reported.",
)
decision = models.ForeignKey(
to="AudioDecision",
on_delete=models.SET_NULL,
blank=True,
null=True,
help_text="The moderation decision for this report.",
)

class Meta:
db_table = "nsfw_reports_audio"

@property
def audio_url(self):
return super().url("audio")

class AudioDecision(AbstractMediaDecision):
"""Represents moderation decisions taken for audio tracks."""

media_class = Audio

media_objs = models.ManyToManyField(
to="Audio",
db_constraint=False,
help_text="The audio items being moderated.",
)


class AudioList(AbstractMediaList):
Expand Down
24 changes: 19 additions & 5 deletions api/api/models/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from api.models.media import (
AbstractDeletedMedia,
AbstractMedia,
AbstractMediaDecision,
AbstractMediaList,
AbstractMediaReport,
AbstractSensitiveMedia,
Expand Down Expand Up @@ -108,8 +109,6 @@ class Meta:

class ImageReport(AbstractMediaReport):
media_class = Image
sensitive_class = SensitiveImage
deleted_class = DeletedImage

media_obj = models.ForeignKey(
to="Image",
Expand All @@ -120,13 +119,28 @@ class ImageReport(AbstractMediaReport):
related_name="image_report",
help_text="The reference to the image being reported.",
)
decision = models.ForeignKey(
to="ImageDecision",
on_delete=models.SET_NULL,
blank=True,
null=True,
help_text="The moderation decision for this report.",
)

class Meta:
db_table = "nsfw_reports"

@property
def image_url(self):
return super().url("images")

class ImageDecision(AbstractMediaDecision):
"""Represents moderation decisions taken for images."""

media_class = Image

media_objs = models.ManyToManyField(
to="Image",
db_constraint=False,
help_text="The image items being moderated.",
)


class ImageList(AbstractMediaList):
Expand Down
128 changes: 88 additions & 40 deletions api/api/models/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.html import format_html

from elasticsearch import Elasticsearch, NotFoundError

from api.constants.moderation import DecisionAction
from api.models.base import OpenLedgerModel
from api.models.mixins import ForeignIdentifierMixin, IdentifierMixin, MediaMixin
from api.utils.attribution import get_attribution_text
Expand Down Expand Up @@ -134,16 +136,11 @@ class AbstractMediaReport(models.Model):
Generic model from which to inherit all reported media classes.
'Reported' here refers to content reports such as sensitive, copyright-violating or
deleted content. Subclasses must populate ``media_class``, ``sensitive_class`` and
``deleted_class`` fields.
deleted content. Subclasses must populate the field ``media_class``.
"""

media_class: type[models.Model] = None
"""the model class associated with this media type e.g. ``Image`` or ``Audio``"""
sensitive_class: type[models.Model] = None
"""the class storing sensitive media e.g. ``SensitiveImage`` or ``SensitiveAudio``"""
deleted_class: type[models.Model] = None
"""the class storing deleted media e.g. ``DeletedImage`` or ``DeletedAudio``"""

REPORT_CHOICES = [(MATURE, MATURE), (DMCA, DMCA), (OTHER, OTHER)]

Expand Down Expand Up @@ -183,60 +180,111 @@ class AbstractMediaReport(models.Model):
help_text="The explanation on why media is being reported.",
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=PENDING)
"""
All statuses except ``PENDING`` are deprecated. Instead refer to the
property ``is_pending``.
"""

decision = models.ForeignKey(
to="AbstractMediaDecision",
on_delete=models.SET_NULL,
blank=True,
null=True,
help_text="The moderation decision for this report.",
)

class Meta:
abstract = True

def clean(self):
"""Clean fields and raise errors that can be handled by Django Admin."""

if not self.media_class.objects.filter(
identifier=self.media_obj.identifier
).exists():
if not self.media_class.objects.filter(identifier=self.media_obj_id).exists():
raise ValidationError(
f"No '{self.media_class.__name__}' instance"
f"with identifier {self.media_obj.identifier}."
f"No '{self.media_class.__name__}' instance "
f"with identifier '{self.media_obj_id}'."
)

def url(self, media_type):
url = f"{settings.CANONICAL_ORIGIN}v1/{media_type}/{self.media_obj.identifier}"
return format_html(f"<a href={url}>{url}</a>")
def url(self, request=None) -> str:
"""
Build the URL of the media item. This uses ``reverse`` and
``request.build_absolute_uri`` to build the URL without having to worry
about canonical URL or trailing slashes.
def save(self, *args, **kwargs):
:param request: the current request object, to get absolute URLs
:return: the URL of the media item
"""
Save changes to the DB and sync them with Elasticsearch.

Extend the built-in ``save()`` functionality of Django with Elasticsearch
integration to update records and refresh indices.
url = reverse(
f"{self.media_class.__name__.lower()}-detail",
args=[self.media_obj_id],
)
if request is not None:
url = request.build_absolute_uri(url)
return format_html(f"<a href={url}>{url}</a>")

@property
def is_pending(self) -> bool:
"""
Determine if the report has not been moderated and does not have an
associated decision. Use the inverse of this function to determine
if a report has been reviewed and moderated.
Media marked as sensitive or deleted also leads to instantiation of their
corresponding sensitive or deleted classes.
:return: whether the report is in the "pending" state
"""

self.clean()
return self.decision_id is None

def save(self, *args, **kwargs):
"""Perform a clean, and then save changes to the DB."""

self.clean()
super().save(*args, **kwargs)

if self.status == MATURE_FILTERED:
# Create an instance of the sensitive class for this media. This will
# automatically set the ``mature`` field in the ES document.
self.sensitive_class.objects.create(media_obj=self.media_obj)
elif self.status == DEINDEXED:
# Create an instance of the deleted class for this media, so that we don't
# reindex it later. This will automatically delete the ES document and the
# DB instance.
self.deleted_class.objects.create(media_obj=self.media_obj)

same_reports = self.__class__.objects.filter(
media_obj=self.media_obj,
status=PENDING,
)
if self.status != DEINDEXED:
same_reports = same_reports.filter(reason=self.reason)

# Prevent redundant update statement when creating the report
if self.status != PENDING:
same_reports.update(status=self.status)
class AbstractMediaDecision(OpenLedgerModel):
"""Generic model from which to inherit all moderation decision classes."""

media_class: type[models.Model] = None
"""the model class associated with this media type e.g. ``Image`` or ``Audio``"""

moderator = models.ForeignKey(
to="auth.User",
on_delete=models.DO_NOTHING,
help_text="The moderator who undertook this decision.",
)
"""
The ``User`` referenced by this field must be a part of the moderators'
group.
"""

media_objs = models.ManyToManyField(
to="AbstractMedia",
db_constraint=False,
help_text="The media items being moderated.",
)
"""
This is a many-to-many relation, using a bridge table, to enable bulk
moderation which applies a single action to more than one media items.
"""

notes = models.TextField(
max_length=500,
blank=True,
null=True,
help_text="The moderator's explanation for the decision or additional notes.",
)

action = models.CharField(
max_length=32,
choices=DecisionAction.choices,
help_text="Action taken by the moderator.",
)

class Meta:
abstract = True

# TODO: Implement ``clean`` and ``save``, if needed.


class PerformIndexUpdateMixin:
Expand Down
Loading

0 comments on commit 2bd852c

Please sign in to comment.