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

feat(moderation-log) #326

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Added moderation model

Revision ID: 3038cd0ae3b0
Revises: 4c13fdf8868d
Create Date: 2024-07-07 00:06:45.747702

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = '3038cd0ae3b0'
down_revision = '4c13fdf8868d'
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('service_moderation',
sa.Column('target_type', sa.String(length=64), nullable=False),
sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('log_id', sa.Uuid(), nullable=True),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['service_users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_service_moderation_target_type'), 'service_moderation', ['target_type'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_service_moderation_target_type'), table_name='service_moderation')
op.drop_table('service_moderation')
# ### end Alembic commands ###
3 changes: 3 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ async def lifespan(app: FastAPI):
{"name": "Read"},
{"name": "Related"},
{"name": "Edit"},
{"name": "Moderation"},
{"name": "Settings"},
{"name": "Schedule"},
{"name": "Upload"},
Expand Down Expand Up @@ -84,6 +85,7 @@ async def lifespan(app: FastAPI):
from .integrations import router as integrations_router
from .collections import router as collections_router
from .characters import router as characters_router
from .moderation import router as moderation_router
from .companies import router as companies_router
from .favourite import router as favourite_router
from .settings import router as settings_router
Expand Down Expand Up @@ -112,6 +114,7 @@ async def lifespan(app: FastAPI):
app.include_router(integrations_router)
app.include_router(collections_router)
app.include_router(characters_router)
app.include_router(moderation_router)
app.include_router(companies_router)
app.include_router(favourite_router)
app.include_router(settings_router)
Expand Down
8 changes: 8 additions & 0 deletions app/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,11 @@
COLLECTION_PUBLIC = "public"
COLLECTION_UNLISTED = "unlisted"
COLLECTION_PRIVATE = "private"

# Moderation types
MODERATION_EDIT_ACCEPTED = "edit_accepted"
MODERATION_EDIT_DENIED = "edit_denied"
MODERATION_EDIT_UPDATED = "edit_updated"
MODERATION_COMMENT_HIDDEN = "comment_hidden"
MODERATION_COLLECTION_DELETED = "collection_deleted"
MODERATION_COLLECTION_UPDATED = "collection_updated"
3 changes: 3 additions & 0 deletions app/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ class ErrorResponse(CustomModel):
"system": {
"bad-backup-token": ["Bad backup token", 401],
},
"moderation-log": {
"no-access": ["You do not have permission to access", 400],
},
"client": {
"already-verified": ["Client is already verified", 400],
"not-owner": ["User not owner of the client", 400],
Expand Down
41 changes: 21 additions & 20 deletions app/manga/schemas.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from pydantic import Field
from app.schemas import datetime_pd

from app.schemas import (
Expand All @@ -23,28 +24,28 @@ class MangaInfoResponse(CustomModel, DataTypeMixin):
authors: list[ContentAuthorResponse]
magazines: list[MagazineResponse]
external: list[ExternalResponse]
start_date: datetime_pd | None
end_date: datetime_pd | None
start_date: datetime_pd | None = Field(examples=[786585600])
end_date: datetime_pd | None = Field(examples=[1008806400])
genres: list[GenreResponse]
title_original: str | None
title_original: str | None = Field(examples=["Monster"])
stats: ReadStatsResponse
synopsis_en: str | None
synopsis_ua: str | None
media_type: str | None
chapters: int | None
title_en: str | None
title_ua: str | None
synopsis_en: str | None = Field(examples=["..."])
synopsis_ua: str | None = Field(examples=["..."])
media_type: str | None = Field(examples=["manga"])
chapters: int | None = Field(examples=[162])
title_en: str | None = Field(examples=["Monster"])
title_ua: str | None = Field(examples=["Монстр"])
updated: datetime_pd
synonyms: list[str]
comments_count: int
has_franchise: bool
translated_ua: bool
volumes: int | None
status: str | None
image: str | None
year: int | None
scored_by: int
score: float
mal_id: int
nsfw: bool
slug: str
has_franchise: bool = Field(examples=[True])
translated_ua: bool = Field(examples=[True])
volumes: int | None = Field(examples=[18])
status: str | None = Field(examples=["finished"])
image: str | None = Field(examples=["https://cdn.hikka.io/hikka.jpg"])
year: int | None = Field(examples=[1994])
scored_by: int = Field(examples=[99368])
score: float = Field(examples=[9.16])
mal_id: int = Field(examples=[1])
nsfw: bool = Field(examples=[False])
slug: str = Field(examples=["monster-54bb37"])
3 changes: 3 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from .edit.edit import NovelEdit
from .edit.edit import Edit

from .moderation.moderation import Moderation

from .comments.comment import CollectionComment
from .comments.vote import CommentVoteLegacy
from .comments.comment import AnimeComment
Expand Down Expand Up @@ -113,6 +115,7 @@
"ReadImportHistory",
"WatchHistory",
"History",
"Moderation",
"UserOAuth",
"Follow",
"User",
Expand Down
20 changes: 20 additions & 0 deletions app/models/moderation/moderation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from sqlalchemy.dialects.postgresql import JSONB
from ..mixins import CreatedMixin
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Mapped
from sqlalchemy import ForeignKey
from sqlalchemy import String
from ..base import Base
from uuid import UUID


class Moderation(Base, CreatedMixin):
__tablename__ = "service_moderation"

target_type: Mapped[str] = mapped_column(String(64), index=True)
data: Mapped[dict] = mapped_column(JSONB, default={})
log_id: Mapped[UUID] = mapped_column(nullable=True)

user_id = mapped_column(ForeignKey("service_users.id"))
user: Mapped["User"] = relationship(foreign_keys=[user_id])
3 changes: 3 additions & 0 deletions app/moderation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .router import router

__all__ = ["router"]
33 changes: 33 additions & 0 deletions app/moderation/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from app.dependencies import auth_required
from app.errors import Abort
from fastapi import Depends

from app.models.user.user import User

from .schemas import ModerationSearchArgs

from app.service import (
get_user_by_username,
)


async def validate_moderation_search_args(
args: ModerationSearchArgs,
session: AsyncSession = Depends(get_session),
):
if args.author:
if not await get_user_by_username(session, args.author):
raise Abort("edit", "author-not-found")

return args


async def validate_moderation_role(
author: User = Depends(auth_required(optional=False)),
):
if author.role not in ["admin", "moderator"]:
raise Abort("moderation-log", "no-access")

return author
54 changes: 54 additions & 0 deletions app/moderation/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends
from app import constants
from app.database import get_session
from app.models import User
from . import service


from .schemas import (
ModerationPaginationResponse,
ModerationSearchArgs,
)

from .dependencies import (
validate_moderation_search_args,
validate_moderation_role,
)

from app.utils import (
pagination_dict,
pagination,
)

from app.dependencies import (
auth_required,
get_page,
get_size,
get_user,
)


router = APIRouter(prefix="/moderation", tags=["Moderation"])


@router.post(
"/log",
response_model=ModerationPaginationResponse,
summary="Moderation log",
)
async def moderation_log(
args: ModerationSearchArgs = Depends(validate_moderation_search_args),
session: AsyncSession = Depends(get_session),
user: User = Depends(validate_moderation_role),
page: int = Depends(get_page),
size: int = Depends(get_size),
):
limit, offset = pagination(page, size)
total = await service.get_moderation_count(session, args)
moderation = await service.get_moderation(session, args, limit, offset)

return {
"pagination": pagination_dict(total, page, limit),
"list": moderation.all(),
}
54 changes: 54 additions & 0 deletions app/moderation/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from enum import Enum
from pydantic import field_validator
from app import constants
from app.schemas import PaginationResponse, CustomModel
from app.schemas import datetime_pd


# Responses
class ModerationResponse(CustomModel):
target_type: str
created: datetime_pd
reference: str
data: dict


class ModerationPaginationResponse(CustomModel):
pagination: PaginationResponse
list: list[ModerationResponse]


# Enums
class ModerationTypeEnum(str, Enum):
edit_accepted = constants.MODERATION_EDIT_ACCEPTED
edit_denied = constants.MODERATION_EDIT_DENIED
edit_updated = constants.MODERATION_EDIT_UPDATED
comment_hidden = constants.MODERATION_COMMENT_HIDDEN
collection_deleted = constants.MODERATION_COLLECTION_DELETED
collection_updated = constants.MODERATION_COLLECTION_UPDATED


# Args
class ModerationSearchArgs(CustomModel):
sort: str = "created:desc"
target_type: ModerationTypeEnum | None = None
author: str | None = None

@field_validator("sort")
def validate_sort(cls, sort):
valid_orders = ["asc", "desc"]
valid_fields = [
"created",
]

parts = sort.split(":")

if len(parts) != 2:
raise ValueError(f"Invalid sort format: {sort}")

field, order = parts

if field not in valid_fields or order not in valid_orders:
raise ValueError(f"Invalid sort value: {sort}")

return sort
61 changes: 61 additions & 0 deletions app/moderation/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.selectable import Select
from sqlalchemy import asc, select, desc, func

from app.models import Moderation
from app.service import get_user_by_username
from .schemas import ModerationSearchArgs


async def moderation_search_filter(
session: AsyncSession,
args: ModerationSearchArgs,
query: Select,
):
if args.author:
author = await get_user_by_username(session, args.author)
query = query.filter(Moderation.user == author)

if args.target_type:
query = query.filter(Moderation.target_type == args.target_type)

return query


def build_moderation_order_by(sort: str):
order_mapping = {
"created": Moderation.created,
}

field, order = sort.split(":")

order_by = (
desc(order_mapping[field])
if order == "desc"
else asc(order_mapping[field])
)

return order_by


async def get_moderation_count(
session: AsyncSession, args: ModerationSearchArgs
) -> int:
query = await moderation_search_filter(
session, args, select(func.count(Moderation.id))
)

return await session.scalar(query)


async def get_moderation(
session: AsyncSession, args: ModerationSearchArgs, limit: int, offset: int
) -> list[Moderation]:
"""Get moderation log"""

query = await moderation_search_filter(session, args, select(Moderation))

query = query.order_by(build_moderation_order_by(args.sort))
query = query.limit(limit).offset(offset)

return await session.scalars(query)
Loading