diff --git a/bookwyrm/forms/admin.py b/bookwyrm/forms/admin.py index 6b2984b3b..4141327d3 100644 --- a/bookwyrm/forms/admin.py +++ b/bookwyrm/forms/admin.py @@ -5,6 +5,7 @@ from django import forms from django.forms import widgets from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from django_celery_beat.models import IntervalSchedule from bookwyrm import models from .custom_form import CustomForm @@ -127,3 +128,14 @@ class AutoModRuleForm(CustomForm): class Meta: model = models.AutoMod fields = ["string_match", "flag_users", "flag_statuses", "created_by"] + + +class IntervalScheduleForm(CustomForm): + class Meta: + model = IntervalSchedule + fields = ["every", "period"] + + widgets = { + "every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}), + "period": forms.Select(attrs={"aria-describedby": "desc_period"}), + } diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 5ab3c7a13..ec30bd678 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -90,6 +90,7 @@ INSTALLED_APPS = [ "sass_processor", "bookwyrm", "celery", + "django_celery_beat", "imagekit", "storages", ] diff --git a/bookwyrm/templates/settings/automod/rules.html b/bookwyrm/templates/settings/automod/rules.html index 8205b3d71..ef0a49beb 100644 --- a/bookwyrm/templates/settings/automod/rules.html +++ b/bookwyrm/templates/settings/automod/rules.html @@ -1,5 +1,6 @@ {% extends 'settings/layout.html' %} {% load i18n %} +{% load humanize %} {% load utilities %} {% block title %} @@ -16,12 +17,81 @@

{% 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 task %} +
+
+ {% trans "Schedule:" %} +
+
+ {{ task.schedule }} +
+ +
+ {% trans "Last run:" %} +
+
+ {{ task.last_run_at|naturaltime }} +
+ +
+ {% trans "Total run count:" %} +
+
+ {{ task.total_run_count }} +
+ +
+ {% trans "Enabled:" %} +
+
+ + {{ task.enabled|yesno }} + +
+
+ +
+ + {% csrf_token %} + + +
+ {% csrf_token %} + +

{% trans "Last run date will not be updated" %}

+
+
+ + {% else %} +

{% trans "Schedule scan" %}

+
{% csrf_token %} - +
+ + {{ task_form.every }} +

+ {{ task_form.every.help_text }} +

+
+
+ +
+ {{ task_form.period }} +
+

+ {{ task_form.period.help_text }} +

+
+
+ {% endif %}
{% if success %} diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 4d7f503f3..82039394d 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -233,11 +233,23 @@ urlpatterns = [ # auto-moderation rules re_path(r"^settings/automod/?$", views.AutoMod.as_view(), name="settings-automod"), re_path( - r"^settings/automod/(?P\d+)/delete?$", + 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"), + re_path( + r"^settings/automod/schedule/?$", + views.schedule_automod_task, + name="settings-automod-schedule", + ), + re_path( + r"^settings/automod/unschedule/(?P\d+)/?$", + views.unschedule_automod_task, + name="settings-automod-unschedule", + ), + 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 d257d327e..032b2371f 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -3,6 +3,7 @@ from .admin.announcements import Announcements, Announcement from .admin.announcements import EditAnnouncement, delete_announcement from .admin.automod import AutoMod, automod_delete, run_automod +from .admin.automod import schedule_automod_task, unschedule_automod_task 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 d9901d01c..f8c3e8e67 100644 --- a/bookwyrm/views/admin/automod.py +++ b/bookwyrm/views/admin/automod.py @@ -1,10 +1,12 @@ """ moderation via flagged posts and users """ from django.contrib.auth.decorators import login_required, permission_required +from django.db import transaction 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 django_celery_beat.models import PeriodicTask from bookwyrm import forms, models @@ -24,8 +26,9 @@ class AutoMod(View): def get(self, request): """view rules""" - data = {"rules": models.AutoMod.objects.all(), "form": forms.AutoModRuleForm()} - return TemplateResponse(request, "settings/automod/rules.html", data) + return TemplateResponse( + request, "settings/automod/rules.html", automod_view_data() + ) def post(self, request): """add rule""" @@ -35,22 +38,49 @@ class AutoMod(View): form.save() form = forms.AutoModRuleForm() - data = { - "rules": models.AutoMod.objects.all(), - "form": form, - "success": success, - } + data = automod_view_data() + data["form"] = form 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) +def schedule_automod_task(request): + """scheduler""" + form = forms.IntervalScheduleForm(request.POST) + if not form.is_valid(): + data = automod_view_data() + data["task_form"] = form + return TemplateResponse(request, "settings/automod/rules.html", data) + + with transaction.atomic(): + schedule = form.save() + PeriodicTask.objects.get_or_create( + interval=schedule, + name="automod-task", + task="bookwyrm.models.antispam.automod_task", + ) + 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 unschedule_automod_task(request, task_id): + """unscheduler""" + get_object_or_404(PeriodicTask, id=task_id).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 automod_delete(request, rule_id): """Remove a rule""" - rule = get_object_or_404(models.AutoMod, id=rule_id) - rule.delete() + get_object_or_404(models.AutoMod, id=rule_id).delete() return redirect("settings-automod") @@ -62,3 +92,18 @@ def run_automod(request): """run scan""" models.automod_task.delay() return redirect("settings-automod") + + +def automod_view_data(): + """helper to get data used in the template""" + try: + task = PeriodicTask.objects.get(name="automod-task") + except PeriodicTask.DoesNotExist: + task = None + + return { + "task": task, + "task_form": forms.IntervalScheduleForm(), + "rules": models.AutoMod.objects.all(), + "form": forms.AutoModRuleForm(), + } diff --git a/bw-dev b/bw-dev index b4b8cc0d3..9751219a7 100755 --- a/bw-dev +++ b/bw-dev @@ -215,6 +215,7 @@ case "$CMD" in ;; setup) migrate + migrate django_celery_beat initdb runweb python manage.py collectstatic --no-input admin_code diff --git a/celerywyrm/settings.py b/celerywyrm/settings.py index bd7805e51..35eb3933f 100644 --- a/celerywyrm/settings.py +++ b/celerywyrm/settings.py @@ -3,6 +3,7 @@ # pylint: disable=unused-wildcard-import from bookwyrm.settings import * +# pylint: disable=line-too-long REDIS_BROKER_PASSWORD = requests.utils.quote(env("REDIS_BROKER_PASSWORD", None)) REDIS_BROKER_HOST = env("REDIS_BROKER_HOST", "redis_broker") REDIS_BROKER_PORT = env("REDIS_BROKER_PORT", 6379) @@ -16,6 +17,10 @@ CELERY_DEFAULT_QUEUE = "low_priority" CELERY_ACCEPT_CONTENT = ["json"] CELERY_TASK_SERIALIZER = "json" CELERY_RESULT_SERIALIZER = "json" + +CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" +CELERY_TIMEZONE = env("TIME_ZONE", "UTC") + FLOWER_PORT = env("FLOWER_PORT") INSTALLED_APPS = INSTALLED_APPS + [ diff --git a/docker-compose.yml b/docker-compose.yml index 0994aa00f..e45cae0d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,19 @@ services: - db - redis_broker restart: on-failure + celery_beat: + env_file: .env + build: . + networks: + - main + command: celery -A celerywyrm beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler + volumes: + - .:/app + - static_volume:/app/static + - media_volume:/app/images + depends_on: + - celery_worker + restart: on-failure flower: build: . command: celery -A celerywyrm flower --basic_auth=${FLOWER_USER}:${FLOWER_PASSWORD} diff --git a/requirements.txt b/requirements.txt index 26582e00d..8eb44d958 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ celery==5.2.2 colorthief==0.2.1 Django==3.2.12 +django-celery-beat==2.2.1 django-compressor==2.4.1 django-imagekit==4.1.0 django-model-utils==4.0.0