diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
index 865bc02f..7ae4e446 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -495,7 +495,7 @@ class GroupForm(CustomForm):
class ReportForm(CustomForm):
class Meta:
model = models.Report
- fields = ["user", "reporter", "statuses", "links", "note"]
+ fields = ["user", "reporter", "status", "links", "note"]
class EmailBlocklistForm(CustomForm):
@@ -550,3 +550,9 @@ class ReadThroughForm(CustomForm):
class Meta:
model = models.ReadThrough
fields = ["user", "book", "start_date", "finish_date"]
+
+
+class AutoModRuleForm(CustomForm):
+ class Meta:
+ model = models.AutoMod
+ fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
diff --git a/bookwyrm/migrations/0138_automod.py b/bookwyrm/migrations/0138_automod.py
new file mode 100644
index 00000000..691ea94b
--- /dev/null
+++ b/bookwyrm/migrations/0138_automod.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.12 on 2022-02-24 18:59
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0137_alter_sitesettings_allow_registration"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AutoMod",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("string_match", models.CharField(max_length=200, unique=True)),
+ ("flag_users", models.BooleanField(default=True)),
+ ("flag_statuses", models.BooleanField(default=True)),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/bookwyrm/migrations/0139_report_status.py b/bookwyrm/migrations/0139_report_status.py
new file mode 100644
index 00000000..c85a43b8
--- /dev/null
+++ b/bookwyrm/migrations/0139_report_status.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.2.12 on 2022-02-24 20:41
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def set_report_statuses(apps, schema_editor):
+ """copy over status fields"""
+ db_alias = schema_editor.connection.alias
+ report_model = apps.get_model("bookwyrm", "Report")
+ reports = report_model.objects.using(db_alias).filter(statuses__isnull=False)
+ for report in reports:
+ report.status = report.statuses.first()
+ report.save()
+
+
+def set_reverse(apps, schema_editor):
+ """copy over status fields"""
+ db_alias = schema_editor.connection.alias
+ report_model = apps.get_model("bookwyrm", "Report")
+ reports = report_model.objects.using(db_alias).filter(status__isnull=False)
+ for report in reports:
+ report.statuses.set(report.status)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0138_automod"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="report",
+ name="status",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="reports",
+ to="bookwyrm.status",
+ ),
+ ),
+ migrations.RunPython(set_report_statuses, reverse_code=set_reverse),
+ ]
diff --git a/bookwyrm/migrations/0140_remove_report_statuses.py b/bookwyrm/migrations/0140_remove_report_statuses.py
new file mode 100644
index 00000000..57651c3b
--- /dev/null
+++ b/bookwyrm/migrations/0140_remove_report_statuses.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.12 on 2022-02-24 20:43
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0139_report_status"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="report",
+ name="statuses",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0141_alter_report_status.py b/bookwyrm/migrations/0141_alter_report_status.py
new file mode 100644
index 00000000..58102dd5
--- /dev/null
+++ b/bookwyrm/migrations/0141_alter_report_status.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.12 on 2022-02-24 20:50
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0140_remove_report_statuses"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="report",
+ name="status",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.status",
+ ),
+ ),
+ ]
diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py
index 4c6305f9..440d18d9 100644
--- a/bookwyrm/models/__init__.py
+++ b/bookwyrm/models/__init__.py
@@ -29,7 +29,7 @@ from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite
from .site import PasswordReset, InviteRequest
from .announcement import Announcement
-from .antispam import EmailBlocklist, IPBlocklist
+from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
from .notification import Notification
diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py
index 7a85bbcf..f506b6f1 100644
--- a/bookwyrm/models/antispam.py
+++ b/bookwyrm/models/antispam.py
@@ -1,6 +1,13 @@
""" Lets try NOT to sell viagra """
-from django.db import models
+from functools import reduce
+import operator
+from django.apps import apps
+from django.db import models
+from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
+
+from bookwyrm.tasks import app
from .user import User
@@ -33,3 +40,107 @@ class IPBlocklist(models.Model):
"""default sorting"""
ordering = ("-created_date",)
+
+
+class AutoMod(models.Model):
+ """rules to automatically flag suspicious activity"""
+
+ string_match = models.CharField(max_length=200, unique=True)
+ flag_users = models.BooleanField(default=True)
+ flag_statuses = models.BooleanField(default=True)
+ created_by = models.ForeignKey("User", on_delete=models.PROTECT)
+
+
+@app.task(queue="low_priority")
+def automod_task():
+ """Create reports"""
+ if not AutoMod.objects.exists():
+ return
+ reporter = AutoMod.objects.first().created_by
+ reports = automod_users(reporter) + automod_statuses(reporter)
+ if reports:
+ admins = User.objects.filter(
+ models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
+ | models.Q(is_superuser=True)
+ ).all()
+ notification_model = apps.get_model(
+ "bookwyrm", "Notification", require_ready=True
+ )
+ for admin in admins:
+ notification_model.objects.bulk_create(
+ [
+ notification_model(
+ user=admin,
+ related_report=r,
+ notification_type="REPORT",
+ )
+ for r in reports
+ ]
+ )
+
+
+def automod_users(reporter):
+ """check users for moderation flags"""
+ user_rules = AutoMod.objects.filter(flag_users=True).values_list(
+ "string_match", flat=True
+ )
+ if not user_rules:
+ return []
+
+ filters = []
+ for field in ["username", "summary", "name"]:
+ filters += [{f"{field}__icontains": r} for r in user_rules]
+ users = User.objects.filter(
+ reduce(operator.or_, (Q(**f) for f in filters)),
+ is_active=True,
+ local=True,
+ report__isnull=True, # don't flag users that already have reports
+ ).distinct()
+
+ report_model = apps.get_model("bookwyrm", "Report", require_ready=True)
+
+ return report_model.objects.bulk_create(
+ [
+ report_model(
+ reporter=reporter,
+ note=_("Automatically generated report"),
+ user=u,
+ )
+ for u in users
+ ]
+ )
+
+
+def automod_statuses(reporter):
+ """check statues for moderation flags"""
+ status_rules = AutoMod.objects.filter(flag_statuses=True).values_list(
+ "string_match", flat=True
+ )
+
+ if not status_rules:
+ return []
+
+ filters = []
+ for field in ["content", "content_warning", "quotation__quote", "review__name"]:
+ filters += [{f"{field}__icontains": r} for r in status_rules]
+
+ status_model = apps.get_model("bookwyrm", "Status", require_ready=True)
+ statuses = status_model.objects.filter(
+ reduce(operator.or_, (Q(**f) for f in filters)),
+ deleted=False,
+ local=True,
+ report__isnull=True, # don't flag statuses that already have reports
+ ).distinct()
+
+ report_model = apps.get_model("bookwyrm", "Report", require_ready=True)
+ return report_model.objects.bulk_create(
+ [
+ report_model(
+ reporter=reporter,
+ note=_("Automatically generated report"),
+ user=s.user,
+ status=s,
+ )
+ for s in statuses
+ ]
+ )
diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py
index bd2a1ef0..bf3184f5 100644
--- a/bookwyrm/models/report.py
+++ b/bookwyrm/models/report.py
@@ -12,7 +12,12 @@ class Report(BookWyrmModel):
)
note = models.TextField(null=True, blank=True)
user = models.ForeignKey("User", on_delete=models.PROTECT)
- statuses = models.ManyToManyField("Status", blank=True)
+ status = models.ForeignKey(
+ "Status",
+ null=True,
+ blank=True,
+ on_delete=models.PROTECT,
+ )
links = models.ManyToManyField("Link", blank=True)
resolved = models.BooleanField(default=False)
diff --git a/bookwyrm/templates/settings/automod/rules.html b/bookwyrm/templates/settings/automod/rules.html
new file mode 100644
index 00000000..8205b3d7
--- /dev/null
+++ b/bookwyrm/templates/settings/automod/rules.html
@@ -0,0 +1,130 @@
+{% extends 'settings/layout.html' %}
+{% load i18n %}
+{% load utilities %}
+
+{% block title %}
+{% trans "Auto-moderation rules" %}
+{% endblock %}
+
+{% block header %}
+{% trans "Auto-moderation rules" %}
+{% endblock %}
+
+{% block panel %}
+
+
+
+ {% trans "Auto-moderation rules will create reports for any local user or status with fields matching the provided string." %}
+ {% trans "Users or statuses that have already been reported (regardless of whether the report was resolved) will not be flagged." %}
+ {% trans "At this time, reports are not being generated automatically, and you must manually trigger a scan." %}
+
+
+
+
+{% if success %}
+
+
+
+ {% trans "Successfully added rule" %}
+
+
+{% endif %}
+
+
+
{% trans "Add Rule" %}
+
+
+
+
+
{% trans "Current Rules" %}
+
+
+
+ {% trans "Show rules" %} ({{ rules.count }})
+
+
+
+
+
+
+
+
+ {% trans "String match" %}
+
+
+ {% trans "Flag users" %}
+
+
+ {% trans "Flag statuses" %}
+
+
+
+
+ {% for rule in rules %}
+
+
+ {{ rule.string_match }}
+
+
+ {{ rule.flag_users|yesno }}
+
+
+ {{ rule.flag_statuses|yesno }}
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
+
diff --git a/bookwyrm/templates/settings/layout.html b/bookwyrm/templates/settings/layout.html
index a74e111c..c5a3c5af 100644
--- a/bookwyrm/templates/settings/layout.html
+++ b/bookwyrm/templates/settings/layout.html
@@ -56,6 +56,10 @@
{% url 'settings-reports' as url %}
{% trans "Reports" %}
+
+ {% url 'settings-automod' as url %}
+ {% trans "Auto-moderation rules" %}
+
{% url 'settings-email-blocks' as url %}
{% trans "Email Blocklist" %}
diff --git a/bookwyrm/templates/settings/reports/report.html b/bookwyrm/templates/settings/reports/report.html
index 3f83f016..20fdeca3 100644
--- a/bookwyrm/templates/settings/reports/report.html
+++ b/bookwyrm/templates/settings/reports/report.html
@@ -1,6 +1,7 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}
+{% load feed_page_tags %}
{% block title %}
{% include "settings/reports/report_header.html" with report=report %}
@@ -30,20 +31,14 @@
-{% if report.statuses.exists %}
+{% if report.status %}
-
{% trans "Reported statuses" %}
-
- {% for status in report.statuses.select_subclasses.all %}
-
- {% if status.deleted %}
- {% trans "Status has been deleted" %}
- {% else %}
- {% include 'snippets/status/status.html' with status=status moderation_mode=True %}
- {% endif %}
-
- {% endfor %}
-
+
{% trans "Reported status" %}
+ {% if report.status.deleted %}
+
{% trans "Status has been deleted" %}
+ {% else %}
+ {% include 'snippets/status/status.html' with status=report.status|load_subclass moderation_mode=True %}
+ {% endif %}
{% endif %}
diff --git a/bookwyrm/templates/settings/reports/report_header.html b/bookwyrm/templates/settings/reports/report_header.html
index d76db104..878a825d 100644
--- a/bookwyrm/templates/settings/reports/report_header.html
+++ b/bookwyrm/templates/settings/reports/report_header.html
@@ -1,7 +1,7 @@
{% load i18n %}
{% load utilities %}
-{% if report.statuses.exists %}
+{% if report.status %}
{% blocktrans trimmed with report_id=report.id username=report.user|username %}
Report #{{ report_id }}: Status posted by @{{ username }}
diff --git a/bookwyrm/templates/snippets/report_button.html b/bookwyrm/templates/snippets/report_button.html
index 9d94d2af..60b542f4 100644
--- a/bookwyrm/templates/snippets/report_button.html
+++ b/bookwyrm/templates/snippets/report_button.html
@@ -12,6 +12,6 @@
>
{% trans "Report" %}
-{% include 'snippets/report_modal.html' with user=user id=modal_id status=status.id %}
+{% include 'snippets/report_modal.html' with user=user id=modal_id status_id=status.id %}
{% endwith %}
diff --git a/bookwyrm/templates/snippets/report_modal.html b/bookwyrm/templates/snippets/report_modal.html
index f65cab59..64e0c298 100644
--- a/bookwyrm/templates/snippets/report_modal.html
+++ b/bookwyrm/templates/snippets/report_modal.html
@@ -23,7 +23,7 @@
{% if status_id %}
-
+
{% endif %}
{% if link %}
diff --git a/bookwyrm/tests/models/test_automod.py b/bookwyrm/tests/models/test_automod.py
new file mode 100644
index 00000000..abb9aa55
--- /dev/null
+++ b/bookwyrm/tests/models/test_automod.py
@@ -0,0 +1,75 @@
+""" test for app action functionality """
+from unittest.mock import patch
+
+from django.test import TestCase
+from django.test.client import RequestFactory
+
+from bookwyrm import models
+from bookwyrm.models.antispam import automod_task
+
+
+@patch("bookwyrm.models.Status.broadcast")
+@patch("bookwyrm.activitystreams.add_status_task.delay")
+@patch("bookwyrm.activitystreams.remove_status_task.delay")
+class AutomodModel(TestCase):
+ """every response to a get request, html or json"""
+
+ def setUp(self):
+ """we need basic test data and mocks"""
+ self.factory = RequestFactory()
+ with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
+ "bookwyrm.activitystreams.populate_stream_task.delay"
+ ), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
+ self.local_user = models.User.objects.create_user(
+ "mouse@local.com",
+ "mouse@mouse.mouse",
+ "password",
+ local=True,
+ localname="mouse",
+ )
+
+ def test_automod_task_no_rules(self, *_):
+ """nothing to see here"""
+ self.assertFalse(models.Report.objects.exists())
+ automod_task()
+ self.assertFalse(models.Report.objects.exists())
+
+ def test_automod_task_user(self, *_):
+ """scan activity"""
+ self.assertFalse(models.Report.objects.exists())
+ models.AutoMod.objects.create(
+ string_match="hi",
+ flag_users=True,
+ flag_statuses=True,
+ created_by=self.local_user,
+ )
+
+ self.local_user.name = "okay hi"
+ self.local_user.save(broadcast=False, update_fields=["name"])
+
+ automod_task()
+
+ reports = models.Report.objects.all()
+ self.assertEqual(reports.count(), 1)
+ self.assertEqual(reports.first().user, self.local_user)
+
+ def test_automod_status(self, *_):
+ """scan activity"""
+ self.assertFalse(models.Report.objects.exists())
+ models.AutoMod.objects.create(
+ string_match="hi",
+ flag_users=True,
+ flag_statuses=True,
+ created_by=self.local_user,
+ )
+
+ status = models.Status.objects.create(
+ user=self.local_user, content="hello", content_warning="hi"
+ )
+
+ automod_task()
+
+ reports = models.Report.objects.all()
+ self.assertEqual(reports.count(), 1)
+ self.assertEqual(reports.first().status, status)
+ self.assertEqual(reports.first().user, self.local_user)
diff --git a/bookwyrm/tests/views/admin/test_automod.py b/bookwyrm/tests/views/admin/test_automod.py
new file mode 100644
index 00000000..1ed36caf
--- /dev/null
+++ b/bookwyrm/tests/views/admin/test_automod.py
@@ -0,0 +1,58 @@
+""" test for app action functionality """
+from unittest.mock import patch
+
+from django.template.response import TemplateResponse
+from django.test import TestCase
+from django.test.client import RequestFactory
+
+from bookwyrm import forms, models, views
+from bookwyrm.tests.validate_html import validate_html
+
+
+class AutomodViews(TestCase):
+ """every response to a get request, html or json"""
+
+ def setUp(self):
+ """we need basic test data and mocks"""
+ self.factory = RequestFactory()
+ with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
+ "bookwyrm.activitystreams.populate_stream_task.delay"
+ ), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
+ self.local_user = models.User.objects.create_user(
+ "mouse@local.com",
+ "mouse@mouse.mouse",
+ "password",
+ local=True,
+ localname="mouse",
+ )
+ models.SiteSettings.objects.create()
+
+ def test_automod_rules_get(self):
+ """there are so many views, this just makes sure it LOADS"""
+ view = views.AutoMod.as_view()
+ request = self.factory.get("")
+ request.user = self.local_user
+ request.user.is_superuser = True
+
+ result = view(request)
+ self.assertIsInstance(result, TemplateResponse)
+ validate_html(result.render())
+ self.assertEqual(result.status_code, 200)
+
+ def test_automod_rules_post(self):
+ """there are so many views, this just makes sure it LOADS"""
+ form = forms.AutoModRuleForm()
+ form.data["string_match"] = "hello"
+ form.data["flag_users"] = True
+ form.data["flag_statuses"] = False
+ form.data["created_by"] = self.local_user
+
+ view = views.AutoMod.as_view()
+ request = self.factory.post("", form.data)
+ request.user = self.local_user
+ request.user.is_superuser = True
+
+ result = view(request)
+ self.assertIsInstance(result, TemplateResponse)
+ validate_html(result.render())
+ self.assertEqual(result.status_code, 200)
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py
index 7572fe63..d2caa76e 100644
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -214,6 +214,14 @@ urlpatterns = [
views.IPBlocklist.as_view(),
name="settings-ip-blocks-delete",
),
+ # auto-moderation rules
+ re_path(r"^settings/automod/?$", views.AutoMod.as_view(), name="settings-automod"),
+ re_path(
+ r"^settings/automod/(?P\d+)/delete?$",
+ views.automod_delete,
+ name="settings-automod-delete",
+ ),
+ re_path(r"^settings/automod/run?$", views.run_automod, name="settings-automod-run"),
# moderation
re_path(
r"^settings/reports/?$", views.ReportsAdmin.as_view(), name="settings-reports"
diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py
index 7474421b..76e9ff02 100644
--- a/bookwyrm/views/__init__.py
+++ b/bookwyrm/views/__init__.py
@@ -2,6 +2,7 @@
# site admin
from .admin.announcements import Announcements, Announcement
from .admin.announcements import EditAnnouncement, delete_announcement
+from .admin.automod import AutoMod, automod_delete, run_automod
from .admin.dashboard import Dashboard
from .admin.federation import Federation, FederatedServer
from .admin.federation import AddFederatedServer, ImportServerBlocklist
diff --git a/bookwyrm/views/admin/automod.py b/bookwyrm/views/admin/automod.py
new file mode 100644
index 00000000..d9901d01
--- /dev/null
+++ b/bookwyrm/views/admin/automod.py
@@ -0,0 +1,64 @@
+""" moderation via flagged posts and users """
+from django.contrib.auth.decorators import login_required, permission_required
+from django.shortcuts import get_object_or_404, redirect
+from django.template.response import TemplateResponse
+from django.utils.decorators import method_decorator
+from django.views import View
+from django.views.decorators.http import require_POST
+
+from bookwyrm import forms, models
+
+
+@method_decorator(login_required, name="dispatch")
+@method_decorator(
+ permission_required("bookwyrm.moderate_user", raise_exception=True),
+ name="dispatch",
+)
+@method_decorator(
+ permission_required("bookwyrm.moderate_post", raise_exception=True),
+ name="dispatch",
+)
+# pylint: disable=no-self-use
+class AutoMod(View):
+ """Manage automated flagging"""
+
+ def get(self, request):
+ """view rules"""
+ data = {"rules": models.AutoMod.objects.all(), "form": forms.AutoModRuleForm()}
+ return TemplateResponse(request, "settings/automod/rules.html", data)
+
+ def post(self, request):
+ """add rule"""
+ form = forms.AutoModRuleForm(request.POST)
+ success = form.is_valid()
+ if success:
+ form.save()
+ form = forms.AutoModRuleForm()
+
+ data = {
+ "rules": models.AutoMod.objects.all(),
+ "form": form,
+ "success": success,
+ }
+ return TemplateResponse(request, "settings/automod/rules.html", data)
+
+
+@require_POST
+@permission_required("bookwyrm.moderate_user", raise_exception=True)
+@permission_required("bookwyrm.moderate_post", raise_exception=True)
+# pylint: disable=unused-argument
+def automod_delete(request, rule_id):
+ """Remove a rule"""
+ rule = get_object_or_404(models.AutoMod, id=rule_id)
+ rule.delete()
+ return redirect("settings-automod")
+
+
+@require_POST
+@permission_required("bookwyrm.moderate_user", raise_exception=True)
+@permission_required("bookwyrm.moderate_post", raise_exception=True)
+# pylint: disable=unused-argument
+def run_automod(request):
+ """run scan"""
+ models.automod_task.delay()
+ return redirect("settings-automod")