Post editing

This commit is contained in:
Michael Manfre 2022-11-27 13:09:46 -05:00 committed by GitHub
parent 263af996d8
commit 6c7ddedd34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 341 additions and 83 deletions

View File

@ -17,11 +17,15 @@ class FanOutStates(StateGraph):
""" """
Sends the fan-out to the right inbox. Sends the fan-out to the right inbox.
""" """
LOCAL_IDENTITY = True
REMOTE_IDENTITY = False
fan_out = await instance.afetch_full() fan_out = await instance.afetch_full()
# Handle Posts
if fan_out.type == FanOut.Types.post: match (fan_out.type, fan_out.identity.local):
# Handle creating/updating local posts
case (FanOut.Types.post | FanOut.Types.post_edited, LOCAL_IDENTITY):
post = await fan_out.subject_post.afetch_full() post = await fan_out.subject_post.afetch_full()
if fan_out.identity.local:
# Make a timeline event directly # Make a timeline event directly
# If it's a reply, we only add it if we follow at least one # If it's a reply, we only add it if we follow at least one
# of the people mentioned. # of the people mentioned.
@ -44,15 +48,29 @@ class FanOutStates(StateGraph):
identity=fan_out.identity, identity=fan_out.identity,
post=post, post=post,
) )
else:
# Handle sending remote posts create
case (FanOut.Types.post, REMOTE_IDENTITY):
post = await fan_out.subject_post.afetch_full()
# Sign it and send it # Sign it and send it
await post.author.signed_request( await post.author.signed_request(
method="post", method="post",
uri=fan_out.identity.inbox_uri, uri=fan_out.identity.inbox_uri,
body=canonicalise(post.to_create_ap()), body=canonicalise(post.to_create_ap()),
) )
# Handle deleting posts
elif fan_out.type == FanOut.Types.post_deleted: # Handle sending remote posts update
case (FanOut.Types.post_edited, REMOTE_IDENTITY):
post = await fan_out.subject_post.afetch_full()
# Sign it and send it
await post.author.signed_request(
method="post",
uri=fan_out.identity.inbox_uri,
body=canonicalise(post.to_update_ap()),
)
# Handle deleting local posts
case (FanOut.Types.post_deleted, LOCAL_IDENTITY):
post = await fan_out.subject_post.afetch_full() post = await fan_out.subject_post.afetch_full()
if fan_out.identity.local: if fan_out.identity.local:
# Remove all timeline events mentioning it # Remove all timeline events mentioning it
@ -60,47 +78,61 @@ class FanOutStates(StateGraph):
identity=fan_out.identity, identity=fan_out.identity,
subject_post=post, subject_post=post,
).adelete() ).adelete()
else:
# Handle sending remote post deletes
case (FanOut.Types.post_deleted, REMOTE_IDENTITY):
post = await fan_out.subject_post.afetch_full()
# Send it to the remote inbox # Send it to the remote inbox
await post.author.signed_request( await post.author.signed_request(
method="post", method="post",
uri=fan_out.identity.inbox_uri, uri=fan_out.identity.inbox_uri,
body=canonicalise(post.to_delete_ap()), body=canonicalise(post.to_delete_ap()),
) )
# Handle boosts/likes
elif fan_out.type == FanOut.Types.interaction: # Handle local boosts/likes
case (FanOut.Types.interaction, LOCAL_IDENTITY):
interaction = await fan_out.subject_post_interaction.afetch_full() interaction = await fan_out.subject_post_interaction.afetch_full()
if fan_out.identity.local:
# Make a timeline event directly # Make a timeline event directly
await sync_to_async(TimelineEvent.add_post_interaction)( await sync_to_async(TimelineEvent.add_post_interaction)(
identity=fan_out.identity, identity=fan_out.identity,
interaction=interaction, interaction=interaction,
) )
else:
# Handle sending remote boosts/likes
case (FanOut.Types.interaction, REMOTE_IDENTITY):
interaction = await fan_out.subject_post_interaction.afetch_full()
# Send it to the remote inbox # Send it to the remote inbox
await interaction.identity.signed_request( await interaction.identity.signed_request(
method="post", method="post",
uri=fan_out.identity.inbox_uri, uri=fan_out.identity.inbox_uri,
body=canonicalise(interaction.to_ap()), body=canonicalise(interaction.to_ap()),
) )
# Handle undoing boosts/likes
elif fan_out.type == FanOut.Types.undo_interaction: # Handle undoing local boosts/likes
case (FanOut.Types.undo_interaction, LOCAL_IDENTITY): # noqa:F841
interaction = await fan_out.subject_post_interaction.afetch_full() interaction = await fan_out.subject_post_interaction.afetch_full()
if fan_out.identity.local:
# Delete any local timeline events # Delete any local timeline events
await sync_to_async(TimelineEvent.delete_post_interaction)( await sync_to_async(TimelineEvent.delete_post_interaction)(
identity=fan_out.identity, identity=fan_out.identity,
interaction=interaction, interaction=interaction,
) )
else:
# Handle sending remote undoing boosts/likes
case (FanOut.Types.undo_interaction, REMOTE_IDENTITY): # noqa:F841
interaction = await fan_out.subject_post_interaction.afetch_full()
# Send an undo to the remote inbox # Send an undo to the remote inbox
await interaction.identity.signed_request( await interaction.identity.signed_request(
method="post", method="post",
uri=fan_out.identity.inbox_uri, uri=fan_out.identity.inbox_uri,
body=canonicalise(interaction.to_undo_ap()), body=canonicalise(interaction.to_undo_ap()),
) )
else:
raise ValueError(f"Cannot fan out with type {fan_out.type}") case _:
raise ValueError(
f"Cannot fan out with type {fan_out.type} local={fan_out.identity.local}"
)
return cls.sent return cls.sent

View File

@ -22,9 +22,17 @@ class PostStates(StateGraph):
deleted = State(try_interval=300) deleted = State(try_interval=300)
deleted_fanned_out = State() deleted_fanned_out = State()
edited = State(try_interval=300)
edited_fanned_out = State(externally_progressed=True)
new.transitions_to(fanned_out) new.transitions_to(fanned_out)
fanned_out.transitions_to(deleted) fanned_out.transitions_to(deleted)
fanned_out.transitions_to(edited)
deleted.transitions_to(deleted_fanned_out) deleted.transitions_to(deleted_fanned_out)
edited.transitions_to(edited_fanned_out)
edited_fanned_out.transitions_to(edited)
edited_fanned_out.transitions_to(deleted)
@classmethod @classmethod
async def handle_new(cls, instance: "Post"): async def handle_new(cls, instance: "Post"):
@ -56,6 +64,21 @@ class PostStates(StateGraph):
) )
return cls.deleted_fanned_out return cls.deleted_fanned_out
@classmethod
async def handle_edited(cls, instance: "Post"):
"""
Creates all needed fan-out objects for an edited Post.
"""
post = await instance.afetch_full()
# Fan out to each target
for follow in await post.aget_targets():
await FanOut.objects.acreate(
identity=follow,
type=FanOut.Types.post_edited,
subject_post=post,
)
return cls.edited_fanned_out
class Post(StatorModel): class Post(StatorModel):
""" """
@ -140,6 +163,7 @@ class Post(StatorModel):
action_boost = "{view}boost/" action_boost = "{view}boost/"
action_unboost = "{view}unboost/" action_unboost = "{view}unboost/"
action_delete = "{view}delete/" action_delete = "{view}delete/"
action_edit = "{view}edit/"
action_reply = "/compose/?reply_to={self.id}" action_reply = "/compose/?reply_to={self.id}"
def get_scheme(self, url): def get_scheme(self, url):
@ -305,6 +329,8 @@ class Post(StatorModel):
value["summary"] = self.summary value["summary"] = self.summary
if self.in_reply_to: if self.in_reply_to:
value["inReplyTo"] = self.in_reply_to value["inReplyTo"] = self.in_reply_to
if self.edited:
value["updated"] = format_ld_date(self.edited)
# Mentions # Mentions
for mention in self.mentions.all(): for mention in self.mentions.all():
value["tag"].append( value["tag"].append(
@ -336,6 +362,20 @@ class Post(StatorModel):
"object": object, "object": object,
} }
def to_update_ap(self):
"""
Returns the AP JSON to update this object
"""
object = self.to_ap()
return {
"to": object["to"],
"cc": object.get("cc", []),
"type": "Update",
"id": self.object_uri + "#update",
"actor": self.author.actor_uri,
"object": object,
}
def to_delete_ap(self): def to_delete_ap(self):
""" """
Returns the AP JSON to create this object Returns the AP JSON to create this object

View File

@ -1,6 +1,8 @@
from django import forms from django import forms
from django.http import Http404, JsonResponse from django.core.exceptions import PermissionDenied
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView, View from django.views.generic import FormView, TemplateView, View
@ -143,11 +145,11 @@ class Delete(TemplateView):
template_name = "activities/post_delete.html" template_name = "activities/post_delete.html"
def dispatch(self, request, handle, post_id): def dispatch(self, request, handle, post_id):
# Make sure the request identity owns the post!
if handle != request.identity.handle:
raise PermissionDenied("Post author is not requestor")
self.identity = by_handle_or_404(self.request, handle, local=False) self.identity = by_handle_or_404(self.request, handle, local=False)
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id) self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
# Make sure the request identity owns the post!
if self.post_obj.author != request.identity:
raise Http404("Post author is not requestor")
return super().dispatch(request) return super().dispatch(request)
def get_context_data(self): def get_context_data(self):
@ -164,6 +166,10 @@ class Compose(FormView):
template_name = "activities/compose.html" template_name = "activities/compose.html"
class form_class(forms.Form): class form_class(forms.Form):
id = forms.IntegerField(
required=False,
widget=forms.HiddenInput(),
)
text = forms.CharField( text = forms.CharField(
widget=forms.Textarea( widget=forms.Textarea(
@ -206,6 +212,17 @@ class Compose(FormView):
def get_initial(self): def get_initial(self):
initial = super().get_initial() initial = super().get_initial()
if self.post_obj:
initial.update(
{
"id": self.post_obj.id,
"reply_to": self.reply_to.pk if self.reply_to else "",
"visibility": self.post_obj.visibility,
"text": self.post_obj.content,
"content_warning": self.post_obj.summary,
}
)
else:
initial[ initial[
"visibility" "visibility"
] = self.request.identity.config_identity.default_post_visibility ] = self.request.identity.config_identity.default_post_visibility
@ -216,6 +233,20 @@ class Compose(FormView):
return initial return initial
def form_valid(self, form): def form_valid(self, form):
post_id = form.cleaned_data.get("id")
if post_id:
post = get_object_or_404(self.request.identity.posts, pk=post_id)
post.edited = timezone.now()
post.content = form.cleaned_data["text"]
post.summary = form.cleaned_data.get("content_warning")
post.visibility = form.cleaned_data["visibility"]
post.save()
# Should there be a timeline event for edits?
# E.g. "@user edited #123"
post.transition_perform(PostStates.edited)
else:
post = Post.create_local( post = Post.create_local(
author=self.request.identity, author=self.request.identity,
content=form.cleaned_data["text"], content=form.cleaned_data["text"],
@ -227,12 +258,18 @@ class Compose(FormView):
TimelineEvent.add_post(self.request.identity, post) TimelineEvent.add_post(self.request.identity, post)
return redirect("/") return redirect("/")
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, handle=None, post_id=None, *args, **kwargs):
self.post_obj = None
if handle and post_id:
# Make sure the request identity owns the post!
if handle != request.identity.handle:
raise PermissionDenied("Post author is not requestor")
self.post_obj = get_object_or_404(request.identity.posts, pk=post_id)
# Grab the reply-to post info now # Grab the reply-to post info now
self.reply_to = None self.reply_to = None
reply_to_id = self.request.POST.get("reply_to") or self.request.GET.get( reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to")
"reply_to"
)
if reply_to_id: if reply_to_id:
try: try:
self.reply_to = Post.objects.get(pk=reply_to_id) self.reply_to = Post.objects.get(pk=reply_to_id)

View File

@ -4,5 +4,6 @@ black==22.10.0
flake8==5.0.4 flake8==5.0.4
isort==5.10.1 isort==5.10.1
mock~=4.0.3 mock~=4.0.3
pytest-asyncio~=0.20.2
pytest-django~=4.5.2 pytest-django~=4.5.2
pytest-httpx~=0.21 pytest-httpx~=0.21

View File

@ -768,11 +768,17 @@ h1.identity small {
content: "HIDE"; content: "HIDE";
} }
.post .edited {
margin-left: 64px;
font-weight: lighter;
color: var(--color-text-duller);
}
.post .content { .post .content {
margin-left: 64px; margin-left: 64px;
} }
.post.mini .content { .post.mini .content, .post.mini .edited {
margin-left: 0px; margin-left: 0px;
} }

View File

@ -104,6 +104,9 @@ class State:
def __repr__(self): def __repr__(self):
return f"<State {self.name}>" return f"<State {self.name}>"
def __str__(self):
return self.name
def __eq__(self, other): def __eq__(self, other):
if isinstance(other, State): if isinstance(other, State):
return self is other return self is other

View File

@ -52,28 +52,11 @@ class StatorRunner:
Config.system = await Config.aload_system() Config.system = await Config.aload_system()
print(f"{self.handled} tasks processed so far") print(f"{self.handled} tasks processed so far")
print("Running cleaning and scheduling") print("Running cleaning and scheduling")
for model in self.models: await self.run_cleanup()
asyncio.create_task(model.atransition_clean_locks())
asyncio.create_task(model.atransition_schedule_due())
self.last_clean = time.monotonic()
# Calculate space left for tasks
self.remove_completed_tasks() self.remove_completed_tasks()
space_remaining = self.concurrency - len(self.tasks) await self.fetch_and_process_tasks()
# Fetch new tasks
for model in self.models:
if space_remaining > 0:
for instance in await model.atransition_get_with_lock(
number=min(space_remaining, self.concurrency_per_model),
lock_expiry=(
timezone.now()
+ datetime.timedelta(seconds=self.lock_expiry)
),
):
self.tasks.append(
asyncio.create_task(self.run_transition(instance))
)
self.handled += 1
space_remaining -= 1
# Are we in limited run mode? # Are we in limited run mode?
if self.run_for and (time.monotonic() - self.started) > self.run_for: if self.run_for and (time.monotonic() - self.started) > self.run_for:
break break
@ -92,6 +75,33 @@ class StatorRunner:
print("Complete") print("Complete")
return self.handled return self.handled
async def run_cleanup(self):
"""
Do any transition cleanup tasks
"""
for model in self.models:
asyncio.create_task(model.atransition_clean_locks())
asyncio.create_task(model.atransition_schedule_due())
self.last_clean = time.monotonic()
async def fetch_and_process_tasks(self):
# Calculate space left for tasks
space_remaining = self.concurrency - len(self.tasks)
# Fetch new tasks
for model in self.models:
if space_remaining > 0:
for instance in await model.atransition_get_with_lock(
number=min(space_remaining, self.concurrency_per_model),
lock_expiry=(
timezone.now() + datetime.timedelta(seconds=self.lock_expiry)
),
):
self.tasks.append(
asyncio.create_task(self.run_transition(instance))
)
self.handled += 1
space_remaining -= 1
async def run_transition(self, instance: StatorModel): async def run_transition(self, instance: StatorModel):
""" """
Wrapper for atransition_attempt with fallback error handling Wrapper for atransition_attempt with fallback error handling

View File

@ -30,6 +30,11 @@ TAKAHE_ENV_FILE = os.environ.get(
) )
TAKAHE_ENV_FILE = os.environ.get(
"TAKAHE_ENV_FILE", "test.env" if "pytest" in sys.modules else ".env"
)
class Settings(BaseSettings): class Settings(BaseSettings):
""" """
Pydantic-powered settings, to provide consistent error messages, strong Pydantic-powered settings, to provide consistent error messages, strong

View File

@ -106,6 +106,7 @@ urlpatterns = [
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()), path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)), path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()), path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
path("@<handle>/posts/<int:post_id>/edit/", posts.Compose.as_view()),
# Authentication # Authentication
path("auth/login/", auth.Login.as_view(), name="login"), path("auth/login/", auth.Login.as_view(), name="login"),
path("auth/logout/", auth.Logout.as_view(), name="logout"), path("auth/logout/", auth.Logout.as_view(), name="logout"),

View File

@ -18,13 +18,11 @@
{% elif post.visibility == 4 %} {% elif post.visibility == 4 %}
<i class="visibility fa-solid fa-link-slash" title="Local Only"></i> <i class="visibility fa-solid fa-link-slash" title="Local Only"></i>
{% endif %} {% endif %}
<a href="{{ post.url }}">
{% if post.published %} {% if post.published %}
{{ post.published | timedeltashort }} <a href="{{ post.url }}" title="{{ post.published }}">{{ post.published | timedeltashort }}</a>
{% else %} {% else %}
{{ post.created | timedeltashort }} <a href="{{ post.url }}" title="{{ post.created }}">{{ post.created | timedeltashort }}</a>
{% endif %} {% endif %}
</a>
</time> </time>
{% if request.identity %} {% if request.identity %}
@ -32,14 +30,19 @@
{% include "activities/_reply.html" %} {% include "activities/_reply.html" %}
{% include "activities/_like.html" %} {% include "activities/_like.html" %}
{% include "activities/_boost.html" %} {% include "activities/_boost.html" %}
{% if post.author == request.identity %}
<a title="Menu" class="menu" _="on click toggle .enabled on the next <menu/>"> <a title="Menu" class="menu" _="on click toggle .enabled on the next <menu/>">
<i class="fa-solid fa-caret-down"></i> <i class="fa-solid fa-caret-down"></i>
</a> </a>
<menu> <menu>
<a href="{{ post.urls.action_edit }}">
<i class="fa-solid fa-pen-to-square"></i> Edit
</a>
<a href="{{ post.urls.action_delete }}"> <a href="{{ post.urls.action_delete }}">
<i class="fa-solid fa-trash"></i> Delete <i class="fa-solid fa-trash"></i> Delete
</a> </a>
</menu> </menu>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@ -57,6 +60,12 @@
{{ post.safe_content_local }} {{ post.safe_content_local }}
</div> </div>
{% if post.edited %}
<div class="edited" title="{{ post.edited }}">
<small>Edited {{ post.edited | timedeltashort }} ago</small>
</div>
{% endif %}
{% if post.attachments.exists %} {% if post.attachments.exists %}
<div class="attachments"> <div class="attachments">
{% for attachment in post.attachments.all %} {% for attachment in post.attachments.all %}

View File

@ -12,12 +12,13 @@
{% include "activities/_mini_post.html" with post=reply_to %} {% include "activities/_mini_post.html" with post=reply_to %}
{% endif %} {% endif %}
{{ form.reply_to }} {{ form.reply_to }}
{{ form.id }}
{% include "forms/_field.html" with field=form.text %} {% include "forms/_field.html" with field=form.text %}
{% include "forms/_field.html" with field=form.content_warning %} {% include "forms/_field.html" with field=form.content_warning %}
{% include "forms/_field.html" with field=form.visibility %} {% include "forms/_field.html" with field=form.visibility %}
</fieldset> </fieldset>
<div class="buttons"> <div class="buttons">
<button>{% if config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button> <button>{% if form.id %}Edit{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -1,7 +1,10 @@
import asyncio
import pytest import pytest
from asgiref.sync import async_to_sync
from pytest_httpx import HTTPXMock from pytest_httpx import HTTPXMock
from activities.models import Post from activities.models import Post, PostStates
@pytest.mark.django_db @pytest.mark.django_db
@ -112,3 +115,45 @@ def test_linkify_mentions_local(identity, remote_identity):
local=True, local=True,
) )
assert post.safe_content_local() == "<p>@test@example.com, welcome!</p>" assert post.safe_content_local() == "<p>@test@example.com, welcome!</p>"
async def stator_process_tasks(stator):
"""
Guarded wrapper to simply async_to_sync and ensure all stator tasks are
run to completion without blocking indefinitely.
"""
await asyncio.wait_for(stator.fetch_and_process_tasks(), timeout=1)
for _ in range(100):
if not stator.tasks:
break
stator.remove_completed_tasks()
await asyncio.sleep(0.01)
@pytest.mark.django_db
def test_post_transitions(identity, stator_runner):
# Create post
post = Post.objects.create(
content="<p>Hello!</p>",
author=identity,
local=False,
visibility=Post.Visibilities.mentioned,
)
# Test: | --> new --> fanned_out
assert post.state == str(PostStates.new)
async_to_sync(stator_process_tasks)(stator_runner)
post = Post.objects.get(id=post.id)
assert post.state == str(PostStates.fanned_out)
# Test: fanned_out --> (forced) edited --> edited_fanned_out
Post.transition_perform(post, PostStates.edited)
async_to_sync(stator_process_tasks)(stator_runner)
post = Post.objects.get(id=post.id)
assert post.state == str(PostStates.edited_fanned_out)
# Test: edited_fanned_out --> (forced) deleted --> deleted_fanned_out
Post.transition_perform(post, PostStates.deleted)
async_to_sync(stator_process_tasks)(stator_runner)
post = Post.objects.get(id=post.id)
assert post.state == str(PostStates.deleted_fanned_out)

View File

@ -2,8 +2,10 @@ import re
import mock import mock
import pytest import pytest
from django.core.exceptions import PermissionDenied
from activities.views.posts import Compose from activities.models import Post
from activities.views.posts import Compose, Delete
@pytest.mark.django_db @pytest.mark.django_db
@ -22,3 +24,43 @@ def test_content_warning_text(identity, user, rf, config_system):
assert re.search( assert re.search(
r"<label.*>\s*Content Summary\s*</label>", content, flags=re.MULTILINE r"<label.*>\s*Content Summary\s*</label>", content, flags=re.MULTILINE
) )
@pytest.mark.django_db
def test_post_delete_security(identity, user, rf, other_identity):
# Create post
other_post = Post.objects.create(
content="<p>OTHER POST!</p>",
author=other_identity,
local=True,
visibility=Post.Visibilities.public,
)
request = rf.post(other_post.get_absolute_url() + "delete/")
request.user = user
request.identity = identity
view = Delete.as_view()
with pytest.raises(PermissionDenied) as ex:
view(request, handle=other_identity.handle.lstrip("@"), post_id=other_post.id)
assert str(ex.value) == "Post author is not requestor"
@pytest.mark.django_db
def test_post_edit_security(identity, user, rf, other_identity):
# Create post
other_post = Post.objects.create(
content="<p>OTHER POST!</p>",
author=other_identity,
local=True,
visibility=Post.Visibilities.public,
)
request = rf.get(other_post.get_absolute_url() + "edit/")
request.user = user
request.identity = identity
view = Compose.as_view()
with pytest.raises(PermissionDenied) as ex:
view(request, handle=other_identity.handle.lstrip("@"), post_id=other_post.id)
assert str(ex.value) == "Post author is not requestor"

View File

@ -1,6 +1,9 @@
import time
import pytest import pytest
from core.models import Config from core.models import Config
from stator.runner import StatorModel, StatorRunner
from users.models import Domain, Identity, User from users.models import Domain, Identity, User
@ -120,3 +123,26 @@ def remote_identity() -> Identity:
name="Test Remote User", name="Test Remote User",
local=False, local=False,
) )
@pytest.fixture
def stator_runner(config_system) -> StatorRunner:
"""
Return an initialized StatorRunner for tests that need state transitioning
to happen.
Example:
# Do some tasks with state side effects
async_to_sync(stator_runner.fetch_and_process_tasks)()
"""
runner = StatorRunner(
StatorModel.subclasses,
concurrency=100,
schedule_interval=30,
)
runner.handled = 0
runner.started = time.monotonic()
runner.last_clean = time.monotonic() - runner.schedule_interval
runner.tasks = []
return runner