From c8b4d5ecf122ca1f44996e5db311cdc2e873738d Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 17 Feb 2022 21:01:34 -0800 Subject: [PATCH 01/12] Adds model for creating automated moderation flags --- bookwyrm/migrations/0138_autoflag.py | 26 ++++++++++++++++++++++++++ bookwyrm/models/antispam.py | 8 ++++++++ 2 files changed, 34 insertions(+) create mode 100644 bookwyrm/migrations/0138_autoflag.py diff --git a/bookwyrm/migrations/0138_autoflag.py b/bookwyrm/migrations/0138_autoflag.py new file mode 100644 index 00000000..b1e803d4 --- /dev/null +++ b/bookwyrm/migrations/0138_autoflag.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.12 on 2022-02-18 04:53 + +from django.conf import settings +import django.contrib.postgres.fields +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='AutoFlag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('flag_users', models.BooleanField(default=False)), + ('flag_statuses', models.BooleanField(default=False)), + ('string_match', models.CharField(max_length=200)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index 7a85bbcf..98587a47 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -33,3 +33,11 @@ class IPBlocklist(models.Model): """default sorting""" ordering = ("-created_date",) + + +class AutoFlag(models.Model): + """rules to automatically flag suspicious activity""" + created_by = models.ForeignKey("User", on_delete=models.PROTECT) + flag_users = models.BooleanField(default=False) + flag_statuses = models.BooleanField(default=False) + string_match = models.CharField(max_length=200) From 12f67dc0cee9bb2685a5a34ff5f83674f3db457d Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 11:18:43 -0800 Subject: [PATCH 02/12] Adds automod view --- bookwyrm/forms.py | 6 ++ .../{0138_autoflag.py => 0138_automod.py} | 11 +-- bookwyrm/models/__init__.py | 2 +- bookwyrm/models/antispam.py | 8 +- .../templates/settings/automod/rules.html | 91 +++++++++++++++++++ bookwyrm/templates/settings/layout.html | 4 + bookwyrm/urls.py | 3 + bookwyrm/views/__init__.py | 1 + bookwyrm/views/admin/automod.py | 53 +++++++++++ 9 files changed, 168 insertions(+), 11 deletions(-) rename bookwyrm/migrations/{0138_autoflag.py => 0138_automod.py} (71%) create mode 100644 bookwyrm/templates/settings/automod/rules.html create mode 100644 bookwyrm/views/admin/automod.py diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index 865bc02f..f2e3f9bb 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -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_autoflag.py b/bookwyrm/migrations/0138_automod.py similarity index 71% rename from bookwyrm/migrations/0138_autoflag.py rename to bookwyrm/migrations/0138_automod.py index b1e803d4..8b0369ef 100644 --- a/bookwyrm/migrations/0138_autoflag.py +++ b/bookwyrm/migrations/0138_automod.py @@ -1,7 +1,6 @@ -# Generated by Django 3.2.12 on 2022-02-18 04:53 +# Generated by Django 3.2.12 on 2022-02-24 18:59 from django.conf import settings -import django.contrib.postgres.fields from django.db import migrations, models import django.db.models.deletion @@ -14,12 +13,12 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='AutoFlag', + name='AutoMod', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('flag_users', models.BooleanField(default=False)), - ('flag_statuses', models.BooleanField(default=False)), - ('string_match', models.CharField(max_length=200)), + ('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/models/__init__.py b/bookwyrm/models/__init__.py index 4c6305f9..1dc6825e 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 from .notification import Notification diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index 98587a47..be1050b3 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -35,9 +35,9 @@ class IPBlocklist(models.Model): ordering = ("-created_date",) -class AutoFlag(models.Model): +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) - flag_users = models.BooleanField(default=False) - flag_statuses = models.BooleanField(default=False) - string_match = models.CharField(max_length=200) diff --git a/bookwyrm/templates/settings/automod/rules.html b/bookwyrm/templates/settings/automod/rules.html new file mode 100644 index 00000000..7555df2d --- /dev/null +++ b/bookwyrm/templates/settings/automod/rules.html @@ -0,0 +1,91 @@ +{% 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 user or status with fields matching the provided string." %} + {% trans "At this time, reports are not being generated automatically, and you must manually trigger a scan." %} +

+ +
+ +{% if success %} +
+ + + {% trans "Successfully added rule" %} + +
+{% endif %} + +
+ + + {% csrf_token %} + + + + + + + + + + + + + + + {% for rule in rules %} + + + + + + + {% endfor %} +
+ + + + + + +
+ {{ form.string_match }} + {% include 'snippets/form_errors.html' with errors_list=form.string_match.errors id="desc_string_match" %} + + {{ form.flag_users }} + + {{ form.flag_statuses }} + + +
+ {{ rule.string_match }} + + {{ rule.flag_users|yesno }} + + {{ rule.flag_statuses|yesno }} + +
+ {% csrf_token %} + +
+
+
+{% 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/urls.py b/bookwyrm/urls.py index 7572fe63..3c37eef5 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -214,6 +214,9 @@ 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"), # 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..f80b2450 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 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..a62ed0ca --- /dev/null +++ b/bookwyrm/views/admin/automod.py @@ -0,0 +1,53 @@ +""" 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") From e837da37db79a3f5957e9a29d39784bfd37ba6d4 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 12:15:08 -0800 Subject: [PATCH 03/12] Adds task --- bookwyrm/models/__init__.py | 2 +- bookwyrm/models/antispam.py | 69 ++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 1dc6825e..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, AutoMod +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 be1050b3..43b524f6 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 @@ -37,7 +44,67 @@ class IPBlocklist(models.Model): 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 + automod_users(reporter) + automod_statuses(reporter) + + +def automod_users(reporter): + """check users for moderation flags""" + user_rules = AutoMod.objects.filter(flag_users=True).values_list( + "string_match", flat=True + ) + + filters = [{"username__icontains": r} for r in user_rules] + users = User.objects.filter( + reduce(operator.or_, (Q(**f) for f in filters)), + is_active=True, + report__isnull=True, # don't flag users that already have reports + ).values_list("id", flat=True) + + report_model = apps.get_model("bookwyrm", "Report", require_ready=True) + + 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 + ) + + filters = [{"content__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, + report__isnull=True, # don't flag statuses that already have reports + ) + + report_model = apps.get_model("bookwyrm", "Report", require_ready=True) + report_model.objects.bulk_create([ + report_model( + reporter=reporter, + note=_("Automatically generated report"), + user=s.user, + statuses=[s.id], + ) for s in statuses + ]) From 3ce8b3390eef579ed00c5540b6bf4a371efab929 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 12:15:31 -0800 Subject: [PATCH 04/12] Adds task --- bookwyrm/migrations/0138_automod.py | 28 +++++++++---- bookwyrm/models/antispam.py | 42 +++++++++++-------- .../templates/settings/automod/rules.html | 5 ++- bookwyrm/urls.py | 7 +++- bookwyrm/views/__init__.py | 2 +- bookwyrm/views/admin/automod.py | 13 +++++- 6 files changed, 68 insertions(+), 29 deletions(-) diff --git a/bookwyrm/migrations/0138_automod.py b/bookwyrm/migrations/0138_automod.py index 8b0369ef..691ea94b 100644 --- a/bookwyrm/migrations/0138_automod.py +++ b/bookwyrm/migrations/0138_automod.py @@ -8,18 +8,32 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0137_alter_sitesettings_allow_registration'), + ("bookwyrm", "0137_alter_sitesettings_allow_registration"), ] operations = [ migrations.CreateModel( - name='AutoMod', + 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)), + ( + "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/models/antispam.py b/bookwyrm/models/antispam.py index 43b524f6..e0d1aa4e 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -71,22 +71,25 @@ def automod_users(reporter): users = User.objects.filter( reduce(operator.or_, (Q(**f) for f in filters)), is_active=True, - report__isnull=True, # don't flag users that already have reports + report__isnull=True, # don't flag users that already have reports ).values_list("id", flat=True) report_model = apps.get_model("bookwyrm", "Report", require_ready=True) - report_model.objects.bulk_create([ - report_model( - reporter=reporter, - note=_("Automatically generated report"), - user=u, - ) for u in users - ]) + 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 """ + """check statues for moderation flags""" status_rules = AutoMod.objects.filter(flag_statuses=True).values_list( "string_match", flat=True ) @@ -96,15 +99,18 @@ def automod_statuses(reporter): statuses = status_model.objects.filter( reduce(operator.or_, (Q(**f) for f in filters)), deleted=False, - report__isnull=True, # don't flag statuses that already have reports + report__isnull=True, # don't flag statuses that already have reports ) report_model = apps.get_model("bookwyrm", "Report", require_ready=True) - report_model.objects.bulk_create([ - report_model( - reporter=reporter, - note=_("Automatically generated report"), - user=s.user, - statuses=[s.id], - ) for s in statuses - ]) + report_model.objects.bulk_create( + [ + report_model( + reporter=reporter, + note=_("Automatically generated report"), + user=s.user, + statuses=[s.id], + ) + for s in statuses + ] + ) diff --git a/bookwyrm/templates/settings/automod/rules.html b/bookwyrm/templates/settings/automod/rules.html index 7555df2d..d913d596 100644 --- a/bookwyrm/templates/settings/automod/rules.html +++ b/bookwyrm/templates/settings/automod/rules.html @@ -17,7 +17,10 @@ {% trans "Auto-moderation rules will create reports for any user or status with fields matching the provided string." %} {% trans "At this time, reports are not being generated automatically, and you must manually trigger a scan." %}

    - +
    + {% csrf_token %} + +
    {% if success %} diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 3c37eef5..d2caa76e 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -216,7 +216,12 @@ urlpatterns = [ ), # 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/(?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 f80b2450..76e9ff02 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -2,7 +2,7 @@ # site admin from .admin.announcements import Announcements, Announcement from .admin.announcements import EditAnnouncement, delete_announcement -from .admin.automod import AutoMod, automod_delete +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 index a62ed0ca..d9901d01 100644 --- a/bookwyrm/views/admin/automod.py +++ b/bookwyrm/views/admin/automod.py @@ -42,12 +42,23 @@ class AutoMod(View): } 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 """ + """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") From ad41f19dc53bde368b497d2613fc3b2d9c00261a Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 12:48:52 -0800 Subject: [PATCH 05/12] Updates report model --- bookwyrm/forms.py | 2 +- bookwyrm/migrations/0139_report_status.py | 37 +++++++++++++++++++ .../migrations/0140_remove_report_statuses.py | 17 +++++++++ .../migrations/0141_alter_report_status.py | 19 ++++++++++ bookwyrm/models/antispam.py | 2 +- bookwyrm/models/report.py | 7 +++- .../templates/settings/automod/rules.html | 1 + .../templates/settings/reports/report.html | 21 ++++------- .../settings/reports/report_header.html | 2 +- .../templates/snippets/report_button.html | 2 +- bookwyrm/templates/snippets/report_modal.html | 2 +- 11 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 bookwyrm/migrations/0139_report_status.py create mode 100644 bookwyrm/migrations/0140_remove_report_statuses.py create mode 100644 bookwyrm/migrations/0141_alter_report_status.py diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index f2e3f9bb..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): diff --git a/bookwyrm/migrations/0139_report_status.py b/bookwyrm/migrations/0139_report_status.py new file mode 100644 index 00000000..fc938633 --- /dev/null +++ b/bookwyrm/migrations/0139_report_status.py @@ -0,0 +1,37 @@ +# 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..94303ce9 --- /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..e0a7e58e --- /dev/null +++ b/bookwyrm/migrations/0141_alter_report_status.py @@ -0,0 +1,19 @@ +# 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/antispam.py b/bookwyrm/models/antispam.py index e0d1aa4e..d948d419 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -109,7 +109,7 @@ def automod_statuses(reporter): reporter=reporter, note=_("Automatically generated report"), user=s.user, - statuses=[s.id], + 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 index d913d596..5b4b0c76 100644 --- a/bookwyrm/templates/settings/automod/rules.html +++ b/bookwyrm/templates/settings/automod/rules.html @@ -15,6 +15,7 @@

    {% trans "Auto-moderation rules will create reports for any 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." %}

    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 %} From 93f82fbf18079b7d9dd5a28f5edb7466eb515122 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 13:20:18 -0800 Subject: [PATCH 06/12] Adds notifications --- bookwyrm/migrations/0139_report_status.py | 16 +++++++++--- .../migrations/0140_remove_report_statuses.py | 6 ++--- .../migrations/0141_alter_report_status.py | 13 +++++++--- bookwyrm/models/antispam.py | 26 ++++++++++++++++--- 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/bookwyrm/migrations/0139_report_status.py b/bookwyrm/migrations/0139_report_status.py index fc938633..c85a43b8 100644 --- a/bookwyrm/migrations/0139_report_status.py +++ b/bookwyrm/migrations/0139_report_status.py @@ -13,6 +13,7 @@ def set_report_statuses(apps, schema_editor): report.status = report.statuses.first() report.save() + def set_reverse(apps, schema_editor): """copy over status fields""" db_alias = schema_editor.connection.alias @@ -21,17 +22,24 @@ def set_reverse(apps, schema_editor): for report in reports: report.statuses.set(report.status) + class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0138_automod'), + ("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'), + 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 index 94303ce9..57651c3b 100644 --- a/bookwyrm/migrations/0140_remove_report_statuses.py +++ b/bookwyrm/migrations/0140_remove_report_statuses.py @@ -6,12 +6,12 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0139_report_status'), + ("bookwyrm", "0139_report_status"), ] operations = [ migrations.RemoveField( - model_name='report', - name='statuses', + model_name="report", + name="statuses", ), ] diff --git a/bookwyrm/migrations/0141_alter_report_status.py b/bookwyrm/migrations/0141_alter_report_status.py index e0a7e58e..58102dd5 100644 --- a/bookwyrm/migrations/0141_alter_report_status.py +++ b/bookwyrm/migrations/0141_alter_report_status.py @@ -7,13 +7,18 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0140_remove_report_statuses'), + ("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'), + 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/antispam.py b/bookwyrm/models/antispam.py index d948d419..8508c395 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -57,8 +57,26 @@ def automod_task(): if not AutoMod.objects.exists(): return reporter = AutoMod.objects.first().created_by - automod_users(reporter) - automod_statuses(reporter) + 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): @@ -76,7 +94,7 @@ def automod_users(reporter): report_model = apps.get_model("bookwyrm", "Report", require_ready=True) - report_model.objects.bulk_create( + return report_model.objects.bulk_create( [ report_model( reporter=reporter, @@ -103,7 +121,7 @@ def automod_statuses(reporter): ) report_model = apps.get_model("bookwyrm", "Report", require_ready=True) - report_model.objects.bulk_create( + return report_model.objects.bulk_create( [ report_model( reporter=reporter, From 84b9a19339e969f92cb8fc34f2124807c1fc200f Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 13:27:26 -0800 Subject: [PATCH 07/12] Expands scanned fields --- bookwyrm/models/antispam.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index 8508c395..4632c64e 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -85,12 +85,18 @@ def automod_users(reporter): "string_match", flat=True ) - filters = [{"username__icontains": r} for r in user_rules] - users = User.objects.filter( - reduce(operator.or_, (Q(**f) for f in filters)), - is_active=True, - report__isnull=True, # don't flag users that already have reports - ).values_list("id", flat=True) + 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, + report__isnull=True, # don't flag users that already have reports + ) + .distinct() + .values_list("id", flat=True) + ) report_model = apps.get_model("bookwyrm", "Report", require_ready=True) @@ -112,13 +118,16 @@ def automod_statuses(reporter): "string_match", flat=True ) - filters = [{"content__icontains": r} for r in status_rules] + 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, 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( From f4468281754b07fa59f385c6a7b499de04d6251e Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 14:39:09 -0800 Subject: [PATCH 08/12] Fixes template typo --- bookwyrm/templates/settings/automod/rules.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bookwyrm/templates/settings/automod/rules.html b/bookwyrm/templates/settings/automod/rules.html index 5b4b0c76..78b88eba 100644 --- a/bookwyrm/templates/settings/automod/rules.html +++ b/bookwyrm/templates/settings/automod/rules.html @@ -46,7 +46,7 @@ - + From 1aa6b99d1fb49e691fae925f491fe4643795bd1f Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 17:33:05 -0800 Subject: [PATCH 09/12] Adds tests --- bookwyrm/tests/models/test_automod.py | 75 ++++++++++++++++++++++ bookwyrm/tests/views/admin/test_automod.py | 58 +++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 bookwyrm/tests/models/test_automod.py create mode 100644 bookwyrm/tests/views/admin/test_automod.py 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) From eb8b9fdaed85ee56743f6a6c2352a0e3a3af6e7f Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 17:33:22 -0800 Subject: [PATCH 10/12] Fixes bugs in model task --- bookwyrm/models/antispam.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index 4632c64e..b894ade7 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -84,19 +84,17 @@ def automod_users(reporter): 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, - report__isnull=True, # don't flag users that already have reports - ) - .distinct() - .values_list("id", flat=True) - ) + users = User.objects.filter( + reduce(operator.or_, (Q(**f) for f in filters)), + is_active=True, + report__isnull=True, # don't flag users that already have reports + ).distinct() report_model = apps.get_model("bookwyrm", "Report", require_ready=True) @@ -118,6 +116,9 @@ def automod_statuses(reporter): "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] From 84ef214ca16ab46fb6585b67a02071cfcfe06b28 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 17:34:02 -0800 Subject: [PATCH 11/12] Valid template markup --- .../templates/settings/automod/rules.html | 141 +++++++++++------- 1 file changed, 88 insertions(+), 53 deletions(-) diff --git a/bookwyrm/templates/settings/automod/rules.html b/bookwyrm/templates/settings/automod/rules.html index 78b88eba..5585dfde 100644 --- a/bookwyrm/templates/settings/automod/rules.html +++ b/bookwyrm/templates/settings/automod/rules.html @@ -33,63 +33,98 @@ {% endif %} -
    - +
    +

    {% trans "Add Rule" %}

    +
    {% csrf_token %} -
    - - - - - - - - - - - + +
    +
    +
    + + {{ form.string_match }} + {% include 'snippets/form_errors.html' with errors_list=form.string_match.errors id="desc_string_match" %} +
    +
    + +
    +
    + +
    + +
    + +
    +
    +
    + +
    + +
    - {% for rule in rules %} - - - - - - - {% endfor %} -
    - - - - - - -
    - {{ form.string_match }} - {% include 'snippets/form_errors.html' with errors_list=form.string_match.errors id="desc_string_match" %} - - {{ form.flag_users }} - - {{ form.flag_statuses }} - - -
    - {{ rule.string_match }} - - {{ rule.flag_users|yesno }} - - {{ rule.flag_statuses|yesno }} - -
    - {% csrf_token %} - -
    -
    +
    + + +
    +

    {% trans "Current Rules" %}

    +
    + + + {% trans "Show rules" %} ({{ rules.count }}) + + + + +
    + + + + + + + + {% for rule in rules %} + + + + + + + {% endfor %} +
    + + + + + + +
    + {{ rule.string_match }} + + {{ rule.flag_users|yesno }} + + {{ rule.flag_statuses|yesno }} + +
    + {% csrf_token %} +
    + +
    +
    +
    +
    +
    {% endblock %} From 689be8c94bcfd13c0a2ab4640c70b795cdfce4fb Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 24 Feb 2022 17:42:28 -0800 Subject: [PATCH 12/12] Only scan local data --- bookwyrm/models/antispam.py | 2 ++ bookwyrm/templates/settings/automod/rules.html | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index b894ade7..f506b6f1 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -93,6 +93,7 @@ def automod_users(reporter): 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() @@ -127,6 +128,7 @@ def automod_statuses(reporter): 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() diff --git a/bookwyrm/templates/settings/automod/rules.html b/bookwyrm/templates/settings/automod/rules.html index 5585dfde..8205b3d7 100644 --- a/bookwyrm/templates/settings/automod/rules.html +++ b/bookwyrm/templates/settings/automod/rules.html @@ -14,7 +14,7 @@

    - {% trans "Auto-moderation rules will create reports for any user or status with fields matching the provided string." %} + {% 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." %}