-
Notifications
You must be signed in to change notification settings - Fork 442
Moderation proof of concept implementation #9461
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
Changes from all commits
3978d9e
a3e3c16
336d097
d4cca1e
8c2245d
0ce47b2
c52d4d4
660d458
9305a30
d157b9b
d348dc6
7a12fbb
75f4119
6a3eafe
882a229
f3c7e89
28e5f0b
8d76500
5100cca
fe9fbf2
09c6076
f19a24a
ee8ff56
fc1312f
f4fa8dd
9fc40d7
75a47a3
fc4f741
922fded
b4ba3b9
c106ca4
a6671c5
2eb14c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,12 @@ | ||
from typing import Literal | ||
|
||
AnnotationAction = Literal["create", "update", "delete"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't remember why I did this but I reckon I was looking for possible values |
||
|
||
|
||
class AnnotationEvent: | ||
"""An event representing an action on an annotation.""" | ||
|
||
def __init__(self, request, annotation_id, action): | ||
def __init__(self, request, annotation_id, action: AnnotationAction): | ||
self.request = request | ||
self.annotation_id = annotation_id | ||
self.action = action |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
"""Backfill DENIED based on moderation.""" | ||
|
||
import sqlalchemy as sa | ||
from alembic import op | ||
|
||
revision = "96cde96b2fd7" | ||
down_revision = "c8f748cbfb8f" | ||
|
||
|
||
def upgrade() -> None: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This migrates existing AnnotationModeration rows to the moderation_status column. |
||
conn = op.get_bind() | ||
|
||
result = conn.execute( | ||
sa.text( | ||
""" | ||
UPDATE annotation | ||
SET moderation_status = 'DENIED' | ||
FROM annotation_moderation | ||
WHERE annotation.id = annotation_moderation.annotation_id | ||
AND annotation.moderation_status is null | ||
""" | ||
) | ||
) | ||
print("\tUpdated annotation rows as DENIED:", result.rowcount) # noqa: T201 | ||
|
||
result = conn.execute( | ||
sa.text( | ||
""" | ||
UPDATE annotation_slim | ||
SET moderation_status = 'DENIED' | ||
FROM annotation_moderation | ||
WHERE annotation_slim.pubid = annotation_moderation.annotation_id | ||
AND annotation_slim.moderation_status is null | ||
""" | ||
) | ||
) | ||
print("\tUpdated annotations as DENIED:", result.rowcount) # noqa: T201 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is not going to be possible as we don't have the user who hide them. We could:
|
||
|
||
|
||
def downgrade() -> None: | ||
pass |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
"""Create moderation log table.""" | ||
|
||
import sqlalchemy as sa | ||
from alembic import op | ||
|
||
from h.db import types | ||
|
||
revision = "bd226cc1c359" | ||
down_revision = "96cde96b2fd7" | ||
|
||
|
||
def upgrade() -> None: | ||
op.create_table( | ||
"moderation_log", | ||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), | ||
sa.Column("user_id", sa.Integer(), nullable=False), | ||
sa.Column("annotation_id", types.URLSafeUUID(), nullable=False), | ||
sa.Column("old_moderation_status", sa.String(), nullable=False), | ||
sa.Column("new_moderation_status", sa.String(), nullable=False), | ||
sa.Column( | ||
"created", sa.DateTime(), server_default=sa.text("now()"), nullable=False | ||
), | ||
sa.ForeignKeyConstraint( | ||
["annotation_id"], | ||
["annotation.id"], | ||
name=op.f("fk__moderation_log__annotation_id__annotation"), | ||
ondelete="CASCADE", | ||
), | ||
sa.ForeignKeyConstraint( | ||
["user_id"], | ||
["user.id"], | ||
name=op.f("fk__moderation_log__user_id__user"), | ||
ondelete="CASCADE", | ||
), | ||
sa.PrimaryKeyConstraint("id", name=op.f("pk__moderation_log")), | ||
) | ||
op.create_index( | ||
op.f("ix__moderation_log_annotation_id"), | ||
"moderation_log", | ||
["annotation_id"], | ||
unique=False, | ||
) | ||
|
||
|
||
def downgrade() -> None: | ||
op.drop_table("moderation_log") | ||
# ### end Alembic commands ### |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
"""Create the moderation_status column.""" | ||
|
||
import sqlalchemy as sa | ||
from alembic import op | ||
from sqlalchemy.dialects import postgresql | ||
|
||
revision = "c8f748cbfb8f" | ||
down_revision = "cf4eedee60f7" | ||
|
||
|
||
def upgrade() -> None: | ||
moderation_status_type = postgresql.ENUM( | ||
"APPROVED", | ||
"DENIED", | ||
"SPAM", | ||
"PENDING", | ||
name="moderationstatus", | ||
) | ||
moderation_status_type.create(op.get_bind(), checkfirst=True) | ||
op.add_column( | ||
"annotation", | ||
sa.Column( | ||
"moderation_status", | ||
moderation_status_type, | ||
nullable=True, | ||
), | ||
) | ||
op.add_column( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
We should have a spike/discussion about AnnotationSlim. We did some work there but we haven't committed to move the codebase over that table. My current thinking is that we should:
I don't think we should block the pre-moderation work over this but it something we could work in parallel or right afterwards. |
||
"annotation_slim", | ||
sa.Column( | ||
"moderation_status", | ||
moderation_status_type, | ||
nullable=True, | ||
), | ||
) | ||
|
||
|
||
def downgrade() -> None: | ||
op.drop_column("annotation_slim", "moderation_status") | ||
op.drop_column("annotation", "moderation_status") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
"""Add Group.pre_moderated.""" | ||
|
||
import sqlalchemy as sa | ||
from alembic import op | ||
|
||
revision = "cf4eedee60f7" | ||
down_revision = "9d97a3e4921e" | ||
|
||
|
||
def upgrade() -> None: | ||
op.add_column("group", sa.Column("pre_moderated", sa.Boolean(), nullable=True)) | ||
|
||
|
||
def downgrade() -> None: | ||
op.drop_column("group", "pre_moderated") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,20 +1,32 @@ | ||
import datetime | ||
from enum import Enum | ||
from uuid import UUID | ||
|
||
import sqlalchemy as sa | ||
from sqlalchemy.dialects import postgresql as pg | ||
from sqlalchemy.ext.hybrid import hybrid_property | ||
from sqlalchemy.ext.mutable import MutableDict, MutableList | ||
from sqlalchemy.orm import Mapped, relationship | ||
|
||
from h.db import Base, types | ||
from h.models.group import Group | ||
from h.util import markdown_render, uri | ||
from h.util.user import split_user | ||
|
||
|
||
class ModerationStatus(Enum): | ||
APPROVED = "APPROVED" | ||
PENDING = "PENDING" | ||
DENIED = "DENIED" | ||
SPAM = "SPAM" | ||
|
||
|
||
class Annotation(Base): | ||
"""Model class representing a single annotation.""" | ||
|
||
# Expose the ModerationStatus directly here | ||
ModerationStatus = ModerationStatus | ||
|
||
__tablename__ = "annotation" | ||
__table_args__ = ( | ||
# Tags are stored in an array-type column, and indexed using a | ||
|
@@ -68,7 +80,7 @@ class Annotation(Base): | |
index=True, | ||
) | ||
|
||
group = sa.orm.relationship( | ||
group = relationship( | ||
Group, | ||
primaryjoin=(Group.pubid == groupid), | ||
foreign_keys=[groupid], | ||
|
@@ -138,11 +150,11 @@ class Annotation(Base): | |
uselist=True, | ||
) | ||
|
||
mentions = sa.orm.relationship("Mention", back_populates="annotation") | ||
mentions = relationship("Mention", back_populates="annotation") | ||
|
||
notifications = sa.orm.relationship( | ||
"Notification", back_populates="source_annotation" | ||
) | ||
notifications = relationship("Notification", back_populates="source_annotation") | ||
|
||
moderation_status: Mapped[ModerationStatus | None] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is an enum in postgres. I generally try to avoid postgres enum because alembic is not 100% aware of them. Here I reckon we should use them, they are very efficient storage-wise and the manual migrations we might have to write on them are actually trivial, is just that alembic doesn't pick them up:
Note that this a nullable field. This is necessary to create the new column but also the code assumes that null is either a private annotation or an approved one. |
||
|
||
@property | ||
def uuid(self): | ||
|
@@ -258,8 +270,16 @@ def authority(self): | |
@property | ||
def is_hidden(self): | ||
"""Check if this annotation id is hidden.""" | ||
|
||
# TODO, move to the new column after migration and backfill migration | ||
return self.moderation is not None | ||
|
||
@property | ||
def moderated(self): | ||
# This replaces is_hidden, adding a new property to give more visibility to the change in the PoC | ||
return bool( | ||
self.moderation_status | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. First place of two we need to account for moderation_status potentially being null meaning approved. Here is not a problem because both private and approved mean |
||
and self.moderation_status != ModerationStatus.APPROVED | ||
) | ||
|
||
def __repr__(self): | ||
return f"<Annotation {self.id}>" |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,7 +6,7 @@ | |
import slugify | ||
import sqlalchemy as sa | ||
from sqlalchemy.dialects.postgresql import JSONB | ||
from sqlalchemy.orm import relationship | ||
from sqlalchemy.orm import Mapped, mapped_column, relationship | ||
|
||
from h import pubid | ||
from h.db import Base, mixins | ||
|
@@ -157,6 +157,8 @@ class Group(Base, mixins.Timestamps): | |
sa.Enum(WriteableBy, name="group_writeable_by"), nullable=True | ||
) | ||
|
||
pre_moderated: Mapped[bool | None] = mapped_column() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is probably the simplest change of this stream of work. I haven't modified the API to take/expose this. |
||
|
||
@property | ||
def groupid(self): | ||
if self.authority_provided_id is None: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -47,7 +47,7 @@ def asdict(self): | |
def _add_hidden(self, result): | ||
# Mark an annotation as hidden if it and all of it's children have been | ||
# moderated and hidden. | ||
parents_and_replies = [self.annotation.id] + self.annotation.thread_ids # noqa: RUF005 | ||
parents_and_replies = [self.annotation.id, *self.annotation.thread_ids] | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TODO This method below is not reading from the new status column yet. This has the effect that for example single annotation threads are not hidden from the response (but their content is hidden). |
||
ann_mod_svc = self.request.find_service(name="annotation_moderation") | ||
is_hidden = len(ann_mod_svc.all_hidden(parents_and_replies)) == len( | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Splitting this in two, we not always need updated but if we have created we most likely want also updated.