mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-21 23:01:00 +00:00
Post editing
This commit is contained in:
parent
263af996d8
commit
6c7ddedd34
14 changed files with 341 additions and 83 deletions
|
@ -17,11 +17,15 @@ class FanOutStates(StateGraph):
|
|||
"""
|
||||
Sends the fan-out to the right inbox.
|
||||
"""
|
||||
LOCAL_IDENTITY = True
|
||||
REMOTE_IDENTITY = False
|
||||
|
||||
fan_out = await instance.afetch_full()
|
||||
# Handle Posts
|
||||
if fan_out.type == FanOut.Types.post:
|
||||
post = await fan_out.subject_post.afetch_full()
|
||||
if fan_out.identity.local:
|
||||
|
||||
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()
|
||||
# Make a timeline event directly
|
||||
# If it's a reply, we only add it if we follow at least one
|
||||
# of the people mentioned.
|
||||
|
@ -44,63 +48,91 @@ class FanOutStates(StateGraph):
|
|||
identity=fan_out.identity,
|
||||
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
|
||||
await post.author.signed_request(
|
||||
method="post",
|
||||
uri=fan_out.identity.inbox_uri,
|
||||
body=canonicalise(post.to_create_ap()),
|
||||
)
|
||||
# Handle deleting posts
|
||||
elif fan_out.type == FanOut.Types.post_deleted:
|
||||
post = await fan_out.subject_post.afetch_full()
|
||||
if fan_out.identity.local:
|
||||
# Remove all timeline events mentioning it
|
||||
await TimelineEvent.objects.filter(
|
||||
identity=fan_out.identity,
|
||||
subject_post=post,
|
||||
).adelete()
|
||||
else:
|
||||
|
||||
# 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()
|
||||
if fan_out.identity.local:
|
||||
# Remove all timeline events mentioning it
|
||||
await TimelineEvent.objects.filter(
|
||||
identity=fan_out.identity,
|
||||
subject_post=post,
|
||||
).adelete()
|
||||
|
||||
# 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
|
||||
await post.author.signed_request(
|
||||
method="post",
|
||||
uri=fan_out.identity.inbox_uri,
|
||||
body=canonicalise(post.to_delete_ap()),
|
||||
)
|
||||
# Handle boosts/likes
|
||||
elif fan_out.type == FanOut.Types.interaction:
|
||||
interaction = await fan_out.subject_post_interaction.afetch_full()
|
||||
if fan_out.identity.local:
|
||||
|
||||
# Handle local boosts/likes
|
||||
case (FanOut.Types.interaction, LOCAL_IDENTITY):
|
||||
interaction = await fan_out.subject_post_interaction.afetch_full()
|
||||
# Make a timeline event directly
|
||||
await sync_to_async(TimelineEvent.add_post_interaction)(
|
||||
identity=fan_out.identity,
|
||||
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
|
||||
await interaction.identity.signed_request(
|
||||
method="post",
|
||||
uri=fan_out.identity.inbox_uri,
|
||||
body=canonicalise(interaction.to_ap()),
|
||||
)
|
||||
# Handle undoing boosts/likes
|
||||
elif fan_out.type == FanOut.Types.undo_interaction:
|
||||
interaction = await fan_out.subject_post_interaction.afetch_full()
|
||||
if fan_out.identity.local:
|
||||
|
||||
# Handle undoing local boosts/likes
|
||||
case (FanOut.Types.undo_interaction, LOCAL_IDENTITY): # noqa:F841
|
||||
interaction = await fan_out.subject_post_interaction.afetch_full()
|
||||
|
||||
# Delete any local timeline events
|
||||
await sync_to_async(TimelineEvent.delete_post_interaction)(
|
||||
identity=fan_out.identity,
|
||||
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
|
||||
await interaction.identity.signed_request(
|
||||
method="post",
|
||||
uri=fan_out.identity.inbox_uri,
|
||||
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
|
||||
|
||||
|
||||
|
|
|
@ -22,9 +22,17 @@ class PostStates(StateGraph):
|
|||
deleted = State(try_interval=300)
|
||||
deleted_fanned_out = State()
|
||||
|
||||
edited = State(try_interval=300)
|
||||
edited_fanned_out = State(externally_progressed=True)
|
||||
|
||||
new.transitions_to(fanned_out)
|
||||
fanned_out.transitions_to(deleted)
|
||||
fanned_out.transitions_to(edited)
|
||||
|
||||
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
|
||||
async def handle_new(cls, instance: "Post"):
|
||||
|
@ -56,6 +64,21 @@ class PostStates(StateGraph):
|
|||
)
|
||||
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):
|
||||
"""
|
||||
|
@ -140,6 +163,7 @@ class Post(StatorModel):
|
|||
action_boost = "{view}boost/"
|
||||
action_unboost = "{view}unboost/"
|
||||
action_delete = "{view}delete/"
|
||||
action_edit = "{view}edit/"
|
||||
action_reply = "/compose/?reply_to={self.id}"
|
||||
|
||||
def get_scheme(self, url):
|
||||
|
@ -305,6 +329,8 @@ class Post(StatorModel):
|
|||
value["summary"] = self.summary
|
||||
if self.in_reply_to:
|
||||
value["inReplyTo"] = self.in_reply_to
|
||||
if self.edited:
|
||||
value["updated"] = format_ld_date(self.edited)
|
||||
# Mentions
|
||||
for mention in self.mentions.all():
|
||||
value["tag"].append(
|
||||
|
@ -336,6 +362,20 @@ class Post(StatorModel):
|
|||
"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):
|
||||
"""
|
||||
Returns the AP JSON to create this object
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
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.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic import FormView, TemplateView, View
|
||||
|
||||
|
@ -143,11 +145,11 @@ class Delete(TemplateView):
|
|||
template_name = "activities/post_delete.html"
|
||||
|
||||
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.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)
|
||||
|
||||
def get_context_data(self):
|
||||
|
@ -164,6 +166,10 @@ class Compose(FormView):
|
|||
template_name = "activities/compose.html"
|
||||
|
||||
class form_class(forms.Form):
|
||||
id = forms.IntegerField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
text = forms.CharField(
|
||||
widget=forms.Textarea(
|
||||
|
@ -206,33 +212,64 @@ class Compose(FormView):
|
|||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
initial[
|
||||
"visibility"
|
||||
] = self.request.identity.config_identity.default_post_visibility
|
||||
if self.reply_to:
|
||||
initial["reply_to"] = self.reply_to.pk
|
||||
initial["visibility"] = self.reply_to.visibility
|
||||
initial["text"] = f"@{self.reply_to.author.handle} "
|
||||
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[
|
||||
"visibility"
|
||||
] = self.request.identity.config_identity.default_post_visibility
|
||||
if self.reply_to:
|
||||
initial["reply_to"] = self.reply_to.pk
|
||||
initial["visibility"] = self.reply_to.visibility
|
||||
initial["text"] = f"@{self.reply_to.author.handle} "
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
post = Post.create_local(
|
||||
author=self.request.identity,
|
||||
content=form.cleaned_data["text"],
|
||||
summary=form.cleaned_data.get("content_warning"),
|
||||
visibility=form.cleaned_data["visibility"],
|
||||
reply_to=self.reply_to,
|
||||
)
|
||||
# Add their own timeline event for immediate visibility
|
||||
TimelineEvent.add_post(self.request.identity, post)
|
||||
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(
|
||||
author=self.request.identity,
|
||||
content=form.cleaned_data["text"],
|
||||
summary=form.cleaned_data.get("content_warning"),
|
||||
visibility=form.cleaned_data["visibility"],
|
||||
reply_to=self.reply_to,
|
||||
)
|
||||
# Add their own timeline event for immediate visibility
|
||||
TimelineEvent.add_post(self.request.identity, post)
|
||||
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
|
||||
self.reply_to = None
|
||||
reply_to_id = self.request.POST.get("reply_to") or self.request.GET.get(
|
||||
"reply_to"
|
||||
)
|
||||
reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to")
|
||||
if reply_to_id:
|
||||
try:
|
||||
self.reply_to = Post.objects.get(pk=reply_to_id)
|
||||
|
|
|
@ -4,5 +4,6 @@ black==22.10.0
|
|||
flake8==5.0.4
|
||||
isort==5.10.1
|
||||
mock~=4.0.3
|
||||
pytest-asyncio~=0.20.2
|
||||
pytest-django~=4.5.2
|
||||
pytest-httpx~=0.21
|
||||
|
|
|
@ -768,11 +768,17 @@ h1.identity small {
|
|||
content: "HIDE";
|
||||
}
|
||||
|
||||
.post .edited {
|
||||
margin-left: 64px;
|
||||
font-weight: lighter;
|
||||
color: var(--color-text-duller);
|
||||
}
|
||||
|
||||
.post .content {
|
||||
margin-left: 64px;
|
||||
}
|
||||
|
||||
.post.mini .content {
|
||||
.post.mini .content, .post.mini .edited {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
|
|
|
@ -104,6 +104,9 @@ class State:
|
|||
def __repr__(self):
|
||||
return f"<State {self.name}>"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, State):
|
||||
return self is other
|
||||
|
|
|
@ -52,28 +52,11 @@ class StatorRunner:
|
|||
Config.system = await Config.aload_system()
|
||||
print(f"{self.handled} tasks processed so far")
|
||||
print("Running cleaning and scheduling")
|
||||
for model in self.models:
|
||||
asyncio.create_task(model.atransition_clean_locks())
|
||||
asyncio.create_task(model.atransition_schedule_due())
|
||||
self.last_clean = time.monotonic()
|
||||
# Calculate space left for tasks
|
||||
await self.run_cleanup()
|
||||
|
||||
self.remove_completed_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
|
||||
await self.fetch_and_process_tasks()
|
||||
|
||||
# Are we in limited run mode?
|
||||
if self.run_for and (time.monotonic() - self.started) > self.run_for:
|
||||
break
|
||||
|
@ -92,6 +75,33 @@ class StatorRunner:
|
|||
print("Complete")
|
||||
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):
|
||||
"""
|
||||
Wrapper for atransition_attempt with fallback error handling
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
Pydantic-powered settings, to provide consistent error messages, strong
|
||||
|
|
|
@ -106,6 +106,7 @@ urlpatterns = [
|
|||
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>/delete/", posts.Delete.as_view()),
|
||||
path("@<handle>/posts/<int:post_id>/edit/", posts.Compose.as_view()),
|
||||
# Authentication
|
||||
path("auth/login/", auth.Login.as_view(), name="login"),
|
||||
path("auth/logout/", auth.Logout.as_view(), name="logout"),
|
||||
|
|
|
@ -18,13 +18,11 @@
|
|||
{% elif post.visibility == 4 %}
|
||||
<i class="visibility fa-solid fa-link-slash" title="Local Only"></i>
|
||||
{% endif %}
|
||||
<a href="{{ post.url }}">
|
||||
{% if post.published %}
|
||||
{{ post.published | timedeltashort }}
|
||||
{% else %}
|
||||
{{ post.created | timedeltashort }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if post.published %}
|
||||
<a href="{{ post.url }}" title="{{ post.published }}">{{ post.published | timedeltashort }}</a>
|
||||
{% else %}
|
||||
<a href="{{ post.url }}" title="{{ post.created }}">{{ post.created | timedeltashort }}</a>
|
||||
{% endif %}
|
||||
</time>
|
||||
|
||||
{% if request.identity %}
|
||||
|
@ -32,14 +30,19 @@
|
|||
{% include "activities/_reply.html" %}
|
||||
{% include "activities/_like.html" %}
|
||||
{% include "activities/_boost.html" %}
|
||||
{% if post.author == request.identity %}
|
||||
<a title="Menu" class="menu" _="on click toggle .enabled on the next <menu/>">
|
||||
<i class="fa-solid fa-caret-down"></i>
|
||||
</a>
|
||||
<menu>
|
||||
<a href="{{ post.urls.action_edit }}">
|
||||
<i class="fa-solid fa-pen-to-square"></i> Edit
|
||||
</a>
|
||||
<a href="{{ post.urls.action_delete }}">
|
||||
<i class="fa-solid fa-trash"></i> Delete
|
||||
</a>
|
||||
</menu>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
@ -57,6 +60,12 @@
|
|||
{{ post.safe_content_local }}
|
||||
</div>
|
||||
|
||||
{% if post.edited %}
|
||||
<div class="edited" title="{{ post.edited }}">
|
||||
<small>Edited {{ post.edited | timedeltashort }} ago</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if post.attachments.exists %}
|
||||
<div class="attachments">
|
||||
{% for attachment in post.attachments.all %}
|
||||
|
|
|
@ -12,12 +12,13 @@
|
|||
{% include "activities/_mini_post.html" with post=reply_to %}
|
||||
{% endif %}
|
||||
{{ form.reply_to }}
|
||||
{{ form.id }}
|
||||
{% include "forms/_field.html" with field=form.text %}
|
||||
{% include "forms/_field.html" with field=form.content_warning %}
|
||||
{% include "forms/_field.html" with field=form.visibility %}
|
||||
</fieldset>
|
||||
<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>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import asyncio
|
||||
|
||||
import pytest
|
||||
from asgiref.sync import async_to_sync
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from activities.models import Post
|
||||
from activities.models import Post, PostStates
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -112,3 +115,45 @@ def test_linkify_mentions_local(identity, remote_identity):
|
|||
local=True,
|
||||
)
|
||||
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)
|
||||
|
|
|
@ -2,8 +2,10 @@ import re
|
|||
|
||||
import mock
|
||||
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
|
||||
|
@ -22,3 +24,43 @@ def test_content_warning_text(identity, user, rf, config_system):
|
|||
assert re.search(
|
||||
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"
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from core.models import Config
|
||||
from stator.runner import StatorModel, StatorRunner
|
||||
from users.models import Domain, Identity, User
|
||||
|
||||
|
||||
|
@ -120,3 +123,26 @@ def remote_identity() -> Identity:
|
|||
name="Test Remote User",
|
||||
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
|
||||
|
|
Loading…
Reference in a new issue