mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-22 07:10:59 +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.
|
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):
|
||||||
post = await fan_out.subject_post.afetch_full()
|
# Handle creating/updating local posts
|
||||||
if fan_out.identity.local:
|
case (FanOut.Types.post | FanOut.Types.post_edited, LOCAL_IDENTITY):
|
||||||
|
post = await fan_out.subject_post.afetch_full()
|
||||||
# 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,63 +48,91 @@ 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
|
||||||
post = await fan_out.subject_post.afetch_full()
|
case (FanOut.Types.post_edited, REMOTE_IDENTITY):
|
||||||
if fan_out.identity.local:
|
post = await fan_out.subject_post.afetch_full()
|
||||||
# Remove all timeline events mentioning it
|
# Sign it and send it
|
||||||
await TimelineEvent.objects.filter(
|
await post.author.signed_request(
|
||||||
identity=fan_out.identity,
|
method="post",
|
||||||
subject_post=post,
|
uri=fan_out.identity.inbox_uri,
|
||||||
).adelete()
|
body=canonicalise(post.to_update_ap()),
|
||||||
else:
|
)
|
||||||
|
|
||||||
|
# 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
|
# 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
|
||||||
interaction = await fan_out.subject_post_interaction.afetch_full()
|
case (FanOut.Types.interaction, LOCAL_IDENTITY):
|
||||||
if fan_out.identity.local:
|
interaction = await fan_out.subject_post_interaction.afetch_full()
|
||||||
# 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
|
||||||
interaction = await fan_out.subject_post_interaction.afetch_full()
|
case (FanOut.Types.undo_interaction, LOCAL_IDENTITY): # noqa:F841
|
||||||
if fan_out.identity.local:
|
interaction = await fan_out.subject_post_interaction.afetch_full()
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,33 +212,64 @@ class Compose(FormView):
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
initial = super().get_initial()
|
initial = super().get_initial()
|
||||||
initial[
|
if self.post_obj:
|
||||||
"visibility"
|
initial.update(
|
||||||
] = self.request.identity.config_identity.default_post_visibility
|
{
|
||||||
if self.reply_to:
|
"id": self.post_obj.id,
|
||||||
initial["reply_to"] = self.reply_to.pk
|
"reply_to": self.reply_to.pk if self.reply_to else "",
|
||||||
initial["visibility"] = self.reply_to.visibility
|
"visibility": self.post_obj.visibility,
|
||||||
initial["text"] = f"@{self.reply_to.author.handle} "
|
"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
|
return initial
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
post = Post.create_local(
|
post_id = form.cleaned_data.get("id")
|
||||||
author=self.request.identity,
|
if post_id:
|
||||||
content=form.cleaned_data["text"],
|
post = get_object_or_404(self.request.identity.posts, pk=post_id)
|
||||||
summary=form.cleaned_data.get("content_warning"),
|
post.edited = timezone.now()
|
||||||
visibility=form.cleaned_data["visibility"],
|
post.content = form.cleaned_data["text"]
|
||||||
reply_to=self.reply_to,
|
post.summary = form.cleaned_data.get("content_warning")
|
||||||
)
|
post.visibility = form.cleaned_data["visibility"]
|
||||||
# Add their own timeline event for immediate visibility
|
post.save()
|
||||||
TimelineEvent.add_post(self.request.identity, post)
|
|
||||||
|
# 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("/")
|
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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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 %}
|
<a href="{{ post.url }}" title="{{ post.published }}">{{ post.published | timedeltashort }}</a>
|
||||||
{{ post.published | timedeltashort }}
|
{% else %}
|
||||||
{% else %}
|
<a href="{{ post.url }}" title="{{ post.created }}">{{ post.created | timedeltashort }}</a>
|
||||||
{{ post.created | timedeltashort }}
|
{% 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 %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue