Boosting! Incoming, anyway.
This commit is contained in:
parent
68c156fd27
commit
ddb3436275
@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from activities.models import FanOut, Post, TimelineEvent
|
||||
from activities.models import FanOut, Post, PostInteraction, TimelineEvent
|
||||
|
||||
|
||||
@admin.register(Post)
|
||||
@ -19,3 +19,9 @@ class TimelineEventAdmin(admin.ModelAdmin):
|
||||
class FanOutAdmin(admin.ModelAdmin):
|
||||
list_display = ["id", "state", "state_attempted", "type", "identity"]
|
||||
raw_id_fields = ["identity", "subject_post"]
|
||||
|
||||
|
||||
@admin.register(PostInteraction)
|
||||
class PostInteractionAdmin(admin.ModelAdmin):
|
||||
list_display = ["id", "state", "state_attempted", "type", "identity", "post"]
|
||||
raw_id_fields = ["identity", "post"]
|
||||
|
@ -0,0 +1,126 @@
|
||||
# Generated by Django 4.1.3 on 2022-11-14 00:41
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
import activities.models.post_interaction
|
||||
import stator.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0002_identity_public_key_id"),
|
||||
("activities", "0003_alter_post_object_uri"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="post",
|
||||
old_name="authored",
|
||||
new_name="published",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="fanout",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
choices=[("post", "Post"), ("interaction", "Interaction")],
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="timelineevent",
|
||||
name="subject_post",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="timeline_events",
|
||||
to="activities.post",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PostInteraction",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("state_ready", models.BooleanField(default=True)),
|
||||
("state_changed", models.DateTimeField(auto_now_add=True)),
|
||||
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
||||
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"state",
|
||||
stator.models.StateField(
|
||||
choices=[("new", "new"), ("fanned_out", "fanned_out")],
|
||||
default="new",
|
||||
graph=activities.models.post_interaction.PostInteractionStates,
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"object_uri",
|
||||
models.CharField(
|
||||
blank=True, max_length=500, null=True, unique=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[("like", "Like"), ("boost", "Boost")], max_length=100
|
||||
),
|
||||
),
|
||||
("published", models.DateTimeField(default=django.utils.timezone.now)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"identity",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="interactions",
|
||||
to="users.identity",
|
||||
),
|
||||
),
|
||||
(
|
||||
"post",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="interactions",
|
||||
to="activities.post",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"index_together": {("type", "identity", "post")},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fanout",
|
||||
name="subject_post_interaction",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="fan_outs",
|
||||
to="activities.postinteraction",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="timelineevent",
|
||||
name="subject_post_interaction",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="timeline_events",
|
||||
to="activities.postinteraction",
|
||||
),
|
||||
),
|
||||
]
|
@ -1,3 +1,4 @@
|
||||
from .fan_out import FanOut # noqa
|
||||
from .post import Post # noqa
|
||||
from .post_interaction import PostInteraction # noqa
|
||||
from .timeline_event import TimelineEvent # noqa
|
||||
|
@ -19,23 +19,27 @@ class FanOutStates(StateGraph):
|
||||
Sends the fan-out to the right inbox.
|
||||
"""
|
||||
fan_out = await instance.afetch_full()
|
||||
if fan_out.identity.local:
|
||||
# Make a timeline event directly
|
||||
await sync_to_async(TimelineEvent.add_post)(
|
||||
identity=fan_out.identity,
|
||||
post=fan_out.subject_post,
|
||||
)
|
||||
# Handle Posts
|
||||
if fan_out.type == FanOut.Types.post:
|
||||
if fan_out.identity.local:
|
||||
# Make a timeline event directly
|
||||
await sync_to_async(TimelineEvent.add_post)(
|
||||
identity=fan_out.identity,
|
||||
post=fan_out.subject_post,
|
||||
)
|
||||
else:
|
||||
# Send it to the remote inbox
|
||||
post = await fan_out.subject_post.afetch_full()
|
||||
# Sign it and send it
|
||||
await HttpSignature.signed_request(
|
||||
uri=fan_out.identity.inbox_uri,
|
||||
body=canonicalise(post.to_create_ap()),
|
||||
private_key=post.author.private_key,
|
||||
key_id=post.author.public_key_id,
|
||||
)
|
||||
return cls.sent
|
||||
else:
|
||||
# Send it to the remote inbox
|
||||
post = await fan_out.subject_post.afetch_full()
|
||||
# Sign it and send it
|
||||
await HttpSignature.signed_request(
|
||||
uri=fan_out.identity.inbox_uri,
|
||||
body=canonicalise(post.to_create_ap()),
|
||||
private_key=post.author.private_key,
|
||||
key_id=post.author.public_key_id,
|
||||
)
|
||||
return cls.sent
|
||||
raise ValueError(f"Cannot fan out with type {fan_out.type}")
|
||||
|
||||
|
||||
class FanOut(StatorModel):
|
||||
@ -45,7 +49,7 @@ class FanOut(StatorModel):
|
||||
|
||||
class Types(models.TextChoices):
|
||||
post = "post"
|
||||
boost = "boost"
|
||||
interaction = "interaction"
|
||||
|
||||
state = StateField(FanOutStates)
|
||||
|
||||
@ -67,6 +71,13 @@ class FanOut(StatorModel):
|
||||
null=True,
|
||||
related_name="fan_outs",
|
||||
)
|
||||
subject_post_interaction = models.ForeignKey(
|
||||
"activities.PostInteraction",
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="fan_outs",
|
||||
)
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
@ -77,6 +88,8 @@ class FanOut(StatorModel):
|
||||
"""
|
||||
Returns a version of the object with all relations pre-loaded
|
||||
"""
|
||||
return await FanOut.objects.select_related("identity", "subject_post").aget(
|
||||
pk=self.pk
|
||||
)
|
||||
return await FanOut.objects.select_related(
|
||||
"identity",
|
||||
"subject_post",
|
||||
"subject_post_interaction",
|
||||
).aget(pk=self.pk)
|
||||
|
@ -1,5 +1,6 @@
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
import urlman
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
@ -7,7 +8,7 @@ from django.utils import timezone
|
||||
from activities.models.fan_out import FanOut
|
||||
from activities.models.timeline_event import TimelineEvent
|
||||
from core.html import sanitize_post
|
||||
from core.ld import format_ld_date, parse_ld_date
|
||||
from core.ld import canonicalise, format_ld_date, parse_ld_date
|
||||
from stator.models import State, StateField, StateGraph, StatorModel
|
||||
from users.models.follow import Follow
|
||||
from users.models.identity import Identity
|
||||
@ -91,7 +92,7 @@ class Post(StatorModel):
|
||||
)
|
||||
|
||||
# When the post was originally created (as opposed to when we received it)
|
||||
authored = models.DateTimeField(default=timezone.now)
|
||||
published = models.DateTimeField(default=timezone.now)
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
@ -173,7 +174,7 @@ class Post(StatorModel):
|
||||
value = {
|
||||
"type": "Note",
|
||||
"id": self.object_uri,
|
||||
"published": format_ld_date(self.created),
|
||||
"published": format_ld_date(self.published),
|
||||
"attributedTo": self.author.actor_uri,
|
||||
"content": self.safe_content,
|
||||
"to": "as:Public",
|
||||
@ -227,13 +228,37 @@ class Post(StatorModel):
|
||||
post.summary = data.get("summary", None)
|
||||
post.sensitive = data.get("as:sensitive", False)
|
||||
post.url = data.get("url", None)
|
||||
post.authored = parse_ld_date(data.get("published", None))
|
||||
post.published = parse_ld_date(data.get("published", None))
|
||||
# TODO: to
|
||||
# TODO: mentions
|
||||
# TODO: visibility
|
||||
post.save()
|
||||
return post
|
||||
|
||||
@classmethod
|
||||
def by_object_uri(cls, object_uri, fetch=False):
|
||||
"""
|
||||
Gets the post by URI - either looking up locally, or fetching
|
||||
from the other end if it's not here.
|
||||
"""
|
||||
try:
|
||||
return cls.objects.get(object_uri=object_uri)
|
||||
except cls.DoesNotExist:
|
||||
if fetch:
|
||||
# Go grab the data from the URI
|
||||
response = httpx.get(
|
||||
object_uri,
|
||||
headers={"Accept": "application/json"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
if 200 <= response.status_code < 300:
|
||||
return cls.by_ap(
|
||||
canonicalise(response.json(), include_security=True),
|
||||
create=True,
|
||||
update=True,
|
||||
)
|
||||
raise ValueError(f"Cannot find Post with URI {object_uri}")
|
||||
|
||||
@classmethod
|
||||
def handle_create_ap(cls, data):
|
||||
"""
|
||||
|
191
activities/models/post_interaction.py
Normal file
191
activities/models/post_interaction.py
Normal file
@ -0,0 +1,191 @@
|
||||
from typing import Dict
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from activities.models.fan_out import FanOut
|
||||
from activities.models.post import Post
|
||||
from activities.models.timeline_event import TimelineEvent
|
||||
from core.ld import format_ld_date, parse_ld_date
|
||||
from stator.models import State, StateField, StateGraph, StatorModel
|
||||
from users.models.follow import Follow
|
||||
from users.models.identity import Identity
|
||||
|
||||
|
||||
class PostInteractionStates(StateGraph):
|
||||
new = State(try_interval=300)
|
||||
fanned_out = State()
|
||||
|
||||
new.transitions_to(fanned_out)
|
||||
|
||||
@classmethod
|
||||
async def handle_new(cls, instance: "PostInteraction"):
|
||||
"""
|
||||
Creates all needed fan-out objects for a new PostInteraction.
|
||||
"""
|
||||
interaction = await instance.afetch_full()
|
||||
# Boost: send a copy to all people who follow this user
|
||||
if interaction.type == interaction.Types.boost:
|
||||
async for follow in interaction.identity.inbound_follows.select_related(
|
||||
"source", "target"
|
||||
):
|
||||
if follow.source.local or follow.target.local:
|
||||
await FanOut.objects.acreate(
|
||||
identity_id=follow.source_id,
|
||||
type=FanOut.Types.interaction,
|
||||
subject_post=interaction,
|
||||
)
|
||||
# Like: send a copy to the original post author only
|
||||
elif interaction.type == interaction.Types.like:
|
||||
await FanOut.objects.acreate(
|
||||
identity_id=interaction.post.author_id,
|
||||
type=FanOut.Types.interaction,
|
||||
subject_post=interaction,
|
||||
)
|
||||
else:
|
||||
raise ValueError("Cannot fan out unknown type")
|
||||
# And one for themselves if they're local
|
||||
if interaction.identity.local:
|
||||
await FanOut.objects.acreate(
|
||||
identity_id=interaction.identity_id,
|
||||
type=FanOut.Types.interaction,
|
||||
subject_post=interaction,
|
||||
)
|
||||
|
||||
|
||||
class PostInteraction(StatorModel):
|
||||
"""
|
||||
Handles both boosts and likes
|
||||
"""
|
||||
|
||||
class Types(models.TextChoices):
|
||||
like = "like"
|
||||
boost = "boost"
|
||||
|
||||
# The state the boost is in
|
||||
state = StateField(PostInteractionStates)
|
||||
|
||||
# The canonical object ID
|
||||
object_uri = models.CharField(max_length=500, blank=True, null=True, unique=True)
|
||||
|
||||
# What type of interaction it is
|
||||
type = models.CharField(max_length=100, choices=Types.choices)
|
||||
|
||||
# The user who boosted/liked/etc.
|
||||
identity = models.ForeignKey(
|
||||
"users.Identity",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="interactions",
|
||||
)
|
||||
|
||||
# The post that was boosted/liked/etc
|
||||
post = models.ForeignKey(
|
||||
"activities.Post",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="interactions",
|
||||
)
|
||||
|
||||
# When the activity was originally created (as opposed to when we received it)
|
||||
# Mastodon only seems to send this for boosts, not likes
|
||||
published = models.DateTimeField(default=timezone.now)
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
index_together = [["type", "identity", "post"]]
|
||||
|
||||
### Async helpers ###
|
||||
|
||||
async def afetch_full(self):
|
||||
"""
|
||||
Returns a version of the object with all relations pre-loaded
|
||||
"""
|
||||
return await PostInteraction.objects.select_related("identity", "post").aget(
|
||||
pk=self.pk
|
||||
)
|
||||
|
||||
### ActivityPub (outbound) ###
|
||||
|
||||
def to_ap(self) -> Dict:
|
||||
"""
|
||||
Returns the AP JSON for this object
|
||||
"""
|
||||
if self.type == self.Types.boost:
|
||||
value = {
|
||||
"type": "Announce",
|
||||
"id": self.object_uri,
|
||||
"published": format_ld_date(self.published),
|
||||
"actor": self.identity.actor_uri,
|
||||
"object": self.post.object_uri,
|
||||
"to": "as:Public",
|
||||
}
|
||||
elif self.type == self.Types.like:
|
||||
value = {
|
||||
"type": "Like",
|
||||
"id": self.object_uri,
|
||||
"published": format_ld_date(self.published),
|
||||
"actor": self.identity.actor_uri,
|
||||
"object": self.post.object_uri,
|
||||
}
|
||||
else:
|
||||
raise ValueError("Cannot turn into AP")
|
||||
return value
|
||||
|
||||
### ActivityPub (inbound) ###
|
||||
|
||||
@classmethod
|
||||
def by_ap(cls, data, create=False) -> "PostInteraction":
|
||||
"""
|
||||
Retrieves a PostInteraction instance by its ActivityPub JSON object.
|
||||
|
||||
Optionally creates one if it's not present.
|
||||
Raises KeyError if it's not found and create is False.
|
||||
"""
|
||||
# Do we have one with the right ID?
|
||||
try:
|
||||
boost = cls.objects.get(object_uri=data["id"])
|
||||
except cls.DoesNotExist:
|
||||
if create:
|
||||
# Resolve the author
|
||||
identity = Identity.by_actor_uri(data["actor"], create=True)
|
||||
# Resolve the post
|
||||
post = Post.by_object_uri(data["object"], fetch=True)
|
||||
# Get the right type
|
||||
if data["type"].lower() == "like":
|
||||
type = cls.Types.like
|
||||
elif data["type"].lower() == "announce":
|
||||
type = cls.Types.boost
|
||||
else:
|
||||
raise ValueError(f"Cannot handle AP type {data['type']}")
|
||||
# Make the actual interaction
|
||||
boost = cls.objects.create(
|
||||
object_uri=data["id"],
|
||||
identity=identity,
|
||||
post=post,
|
||||
published=parse_ld_date(data.get("published", None))
|
||||
or timezone.now(),
|
||||
type=type,
|
||||
)
|
||||
else:
|
||||
raise KeyError(f"No post with ID {data['id']}", data)
|
||||
return boost
|
||||
|
||||
@classmethod
|
||||
def handle_ap(cls, data):
|
||||
"""
|
||||
Handles an incoming announce/like
|
||||
"""
|
||||
# Create it
|
||||
interaction = cls.by_ap(data, create=True)
|
||||
# Boosts (announces) go to everyone who follows locally
|
||||
if interaction.type == cls.Types.boost:
|
||||
for follow in Follow.objects.filter(
|
||||
target=interaction.identity, source__local=True
|
||||
):
|
||||
TimelineEvent.add_post_interaction(follow.source, interaction)
|
||||
# Likes go to just the author of the post
|
||||
elif interaction.type == cls.Types.like:
|
||||
TimelineEvent.add_post_interaction(interaction.post.author, interaction)
|
||||
# Force it into fanned_out as it's not ours
|
||||
interaction.transition_perform(PostInteractionStates.fanned_out)
|
@ -9,10 +9,11 @@ class TimelineEvent(models.Model):
|
||||
|
||||
class Types(models.TextChoices):
|
||||
post = "post"
|
||||
mention = "mention"
|
||||
like = "like"
|
||||
follow = "follow"
|
||||
boost = "boost"
|
||||
boost = "boost" # A boost from someone (post substitude)
|
||||
mentioned = "mentioned"
|
||||
liked = "liked" # Someone liking one of our posts
|
||||
followed = "followed"
|
||||
boosted = "boosted" # Someone boosting one of our posts
|
||||
|
||||
# The user this event is for
|
||||
identity = models.ForeignKey(
|
||||
@ -30,7 +31,14 @@ class TimelineEvent(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="timeline_events_about_us",
|
||||
related_name="timeline_events",
|
||||
)
|
||||
subject_post_interaction = models.ForeignKey(
|
||||
"activities.PostInteraction",
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="timeline_events",
|
||||
)
|
||||
subject_identity = models.ForeignKey(
|
||||
"users.Identity",
|
||||
@ -74,12 +82,35 @@ class TimelineEvent(models.Model):
|
||||
)[0]
|
||||
|
||||
@classmethod
|
||||
def add_like(cls, identity, post):
|
||||
def add_post_interaction(cls, identity, interaction):
|
||||
"""
|
||||
Adds a like to the timeline if it's not there already
|
||||
Adds a boost/like to the timeline if it's not there already.
|
||||
|
||||
For boosts, may make two objects - one "boost" and one "boosted".
|
||||
It'll return the "boost" in that case.
|
||||
"""
|
||||
return cls.objects.get_or_create(
|
||||
identity=identity,
|
||||
type=cls.Types.like,
|
||||
subject_post=post,
|
||||
)[0]
|
||||
if interaction.type == interaction.Types.like:
|
||||
return cls.objects.get_or_create(
|
||||
identity=identity,
|
||||
type=cls.Types.liked,
|
||||
subject_post_id=interaction.post_id,
|
||||
subject_identity_id=interaction.identity_id,
|
||||
subject_post_interaction=interaction,
|
||||
)[0]
|
||||
elif interaction.type == interaction.Types.boost:
|
||||
# If the boost is on one of our posts, then that's a boosted too
|
||||
if interaction.post.author_id == identity.id:
|
||||
return cls.objects.get_or_create(
|
||||
identity=identity,
|
||||
type=cls.Types.boosted,
|
||||
subject_post_id=interaction.post_id,
|
||||
subject_identity_id=interaction.identity_id,
|
||||
subject_post_interaction=interaction,
|
||||
)[0]
|
||||
return cls.objects.get_or_create(
|
||||
identity=identity,
|
||||
type=cls.Types.boost,
|
||||
subject_post_id=interaction.post_id,
|
||||
subject_identity_id=interaction.identity_id,
|
||||
subject_post_interaction=interaction,
|
||||
)[0]
|
||||
|
0
activities/tests/__init__.py
Normal file
0
activities/tests/__init__.py
Normal file
0
activities/tests/models/__init__.py
Normal file
0
activities/tests/models/__init__.py
Normal file
31
activities/tests/models/test_post.py
Normal file
31
activities/tests/models/test_post.py
Normal file
@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from activities.models import Post
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fetch_post(httpx_mock: HTTPXMock):
|
||||
"""
|
||||
Tests that a post we don't have locally can be fetched by by_object_uri
|
||||
"""
|
||||
httpx_mock.add_response(
|
||||
url="https://example.com/test-post",
|
||||
json={
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
],
|
||||
"id": "https://example.com/test-post",
|
||||
"type": "Note",
|
||||
"published": "2022-11-13T23:20:16Z",
|
||||
"url": "https://example.com/test-post",
|
||||
"attributedTo": "https://example.com/test-actor",
|
||||
"content": "BEEEEEES",
|
||||
},
|
||||
)
|
||||
# Fetch with a HTTP access
|
||||
post = Post.by_object_uri("https://example.com/test-post", fetch=True)
|
||||
assert post.content == "BEEEEEES"
|
||||
assert post.author.actor_uri == "https://example.com/test-actor"
|
||||
# Fetch again with a DB hit
|
||||
assert Post.by_object_uri("https://example.com/test-post").id == post.id
|
@ -33,15 +33,15 @@ class Home(FormView):
|
||||
|
||||
def get_context_data(self):
|
||||
context = super().get_context_data()
|
||||
context["timeline_posts"] = [
|
||||
te.subject_post
|
||||
for te in TimelineEvent.objects.filter(
|
||||
context["events"] = (
|
||||
TimelineEvent.objects.filter(
|
||||
identity=self.request.identity,
|
||||
type=TimelineEvent.Types.post,
|
||||
type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
|
||||
)
|
||||
.select_related("subject_post", "subject_post__author")
|
||||
.order_by("-created")[:100]
|
||||
]
|
||||
)
|
||||
|
||||
context["current_page"] = "home"
|
||||
return context
|
||||
|
||||
@ -54,6 +54,22 @@ class Home(FormView):
|
||||
return redirect(".")
|
||||
|
||||
|
||||
@method_decorator(identity_required, name="dispatch")
|
||||
class Local(TemplateView):
|
||||
|
||||
template_name = "activities/local.html"
|
||||
|
||||
def get_context_data(self):
|
||||
context = super().get_context_data()
|
||||
context["posts"] = (
|
||||
Post.objects.filter(visibility=Post.Visibilities.public, author__local=True)
|
||||
.select_related("author")
|
||||
.order_by("-created")[:100]
|
||||
)
|
||||
context["current_page"] = "local"
|
||||
return context
|
||||
|
||||
|
||||
@method_decorator(identity_required, name="dispatch")
|
||||
class Federated(TemplateView):
|
||||
|
||||
@ -61,10 +77,28 @@ class Federated(TemplateView):
|
||||
|
||||
def get_context_data(self):
|
||||
context = super().get_context_data()
|
||||
context["timeline_posts"] = (
|
||||
context["posts"] = (
|
||||
Post.objects.filter(visibility=Post.Visibilities.public)
|
||||
.select_related("author")
|
||||
.order_by("-created")[:100]
|
||||
)
|
||||
context["current_page"] = "federated"
|
||||
return context
|
||||
|
||||
|
||||
@method_decorator(identity_required, name="dispatch")
|
||||
class Notifications(TemplateView):
|
||||
|
||||
template_name = "activities/notifications.html"
|
||||
|
||||
def get_context_data(self):
|
||||
context = super().get_context_data()
|
||||
context["events"] = (
|
||||
TimelineEvent.objects.filter(
|
||||
identity=self.request.identity,
|
||||
)
|
||||
.exclude(type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost])
|
||||
.select_related("subject_post", "subject_post__author", "subject_identity")
|
||||
)
|
||||
context["current_page"] = "notifications"
|
||||
return context
|
||||
|
@ -459,7 +459,7 @@ form .button:hover {
|
||||
/* Identities */
|
||||
|
||||
h1.identity {
|
||||
margin: 20px 0 20px 20px;
|
||||
margin: 15px 0 20px 15px;
|
||||
}
|
||||
|
||||
h1.identity .icon {
|
||||
@ -482,7 +482,7 @@ h1.identity small {
|
||||
color: var(--color-text-dull);
|
||||
border-radius: 3px;
|
||||
padding: 5px 8px;
|
||||
margin: 20px;
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
.system-note a {
|
||||
@ -527,3 +527,17 @@ h1.identity small {
|
||||
.post .content p {
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
|
||||
.boost-banner {
|
||||
padding: 0 0 3px 5px;
|
||||
}
|
||||
|
||||
.boost-banner::before {
|
||||
content: "\f079";
|
||||
font: var(--fa-font-solid);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.boost-banner a {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
@ -12,9 +12,16 @@ class Command(BaseCommand):
|
||||
help = "Runs a Stator runner for a short period"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--concurrency",
|
||||
"-c",
|
||||
type=int,
|
||||
default=30,
|
||||
help="How many tasks to run at once",
|
||||
)
|
||||
parser.add_argument("model_labels", nargs="*", type=str)
|
||||
|
||||
def handle(self, model_labels: List[str], *args, **options):
|
||||
def handle(self, model_labels: List[str], concurrency: int, *args, **options):
|
||||
# Resolve the models list into names
|
||||
models = cast(
|
||||
List[Type[StatorModel]],
|
||||
@ -24,5 +31,5 @@ class Command(BaseCommand):
|
||||
models = StatorModel.subclasses
|
||||
print("Running for models: " + " ".join(m._meta.label_lower for m in models))
|
||||
# Run a runner
|
||||
runner = StatorRunner(models)
|
||||
runner = StatorRunner(models, concurrency=concurrency)
|
||||
async_to_sync(runner.run)()
|
||||
|
@ -16,16 +16,20 @@ class StatorRunner:
|
||||
Designed to run in a one-shot mode, living inside a request.
|
||||
"""
|
||||
|
||||
START_TIMEOUT = 30
|
||||
TOTAL_TIMEOUT = 60
|
||||
LOCK_TIMEOUT = 120
|
||||
|
||||
MAX_TASKS = 30
|
||||
MAX_TASKS_PER_MODEL = 5
|
||||
|
||||
def __init__(self, models: List[Type[StatorModel]]):
|
||||
def __init__(
|
||||
self,
|
||||
models: List[Type[StatorModel]],
|
||||
concurrency: int = 30,
|
||||
concurrency_per_model: int = 5,
|
||||
run_period: int = 30,
|
||||
wait_period: int = 30,
|
||||
):
|
||||
self.models = models
|
||||
self.runner_id = uuid.uuid4().hex
|
||||
self.concurrency = concurrency
|
||||
self.concurrency_per_model = concurrency_per_model
|
||||
self.run_period = run_period
|
||||
self.total_period = run_period + wait_period
|
||||
|
||||
async def run(self):
|
||||
start_time = time.monotonic()
|
||||
@ -40,15 +44,18 @@ class StatorRunner:
|
||||
await asyncio.gather(*initial_tasks)
|
||||
# For the first time period, launch tasks
|
||||
print("Running main task loop")
|
||||
while (time.monotonic() - start_time) < self.START_TIMEOUT:
|
||||
while (time.monotonic() - start_time) < self.run_period:
|
||||
self.remove_completed_tasks()
|
||||
space_remaining = self.MAX_TASKS - len(self.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(
|
||||
min(space_remaining, self.MAX_TASKS_PER_MODEL),
|
||||
timezone.now() + datetime.timedelta(seconds=self.LOCK_TIMEOUT),
|
||||
number=min(space_remaining, self.concurrency_per_model),
|
||||
lock_expiry=(
|
||||
timezone.now()
|
||||
+ datetime.timedelta(seconds=(self.total_period * 2) + 60)
|
||||
),
|
||||
):
|
||||
self.tasks.append(
|
||||
asyncio.create_task(self.run_transition(instance))
|
||||
@ -59,7 +66,7 @@ class StatorRunner:
|
||||
await asyncio.sleep(0.1)
|
||||
# Then wait for tasks to finish
|
||||
print("Waiting for tasks to complete")
|
||||
while (time.monotonic() - start_time) < self.TOTAL_TIMEOUT:
|
||||
while (time.monotonic() - start_time) < self.total_period:
|
||||
self.remove_completed_tasks()
|
||||
if not self.tasks:
|
||||
break
|
||||
|
@ -9,6 +9,8 @@ from users.views import activitypub, auth, identity
|
||||
urlpatterns = [
|
||||
path("", core.homepage),
|
||||
# Activity views
|
||||
path("notifications/", timelines.Notifications.as_view()),
|
||||
path("local/", timelines.Local.as_view()),
|
||||
path("federated/", timelines.Federated.as_view()),
|
||||
# Authentication
|
||||
path("auth/login/", auth.Login.as_view()),
|
||||
|
28
templates/activities/_boost.html
Normal file
28
templates/activities/_boost.html
Normal file
@ -0,0 +1,28 @@
|
||||
{% load static %}
|
||||
{% load activity_tags %}
|
||||
<div class="post">
|
||||
|
||||
{% if post.author.icon_uri %}
|
||||
<img src="{{post.author.icon_uri}}" class="icon">
|
||||
{% else %}
|
||||
<img src="{% static "img/unknown-icon-128.png" %}" class="icon">
|
||||
{% endif %}
|
||||
|
||||
<time>
|
||||
<a href="{{ post.url }}">
|
||||
{% if post.published %}
|
||||
{{ post.published | timedeltashort }}
|
||||
{% else %}
|
||||
{{ post.created | timedeltashort }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</time>
|
||||
|
||||
<a href="{{ post.author.urls.view }}" class="handle">
|
||||
{{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
|
||||
</a>
|
||||
|
||||
<div class="content">
|
||||
{{ post.safe_content }}
|
||||
</div>
|
||||
</div>
|
20
templates/activities/_event.html
Normal file
20
templates/activities/_event.html
Normal file
@ -0,0 +1,20 @@
|
||||
{% load static %}
|
||||
{% load activity_tags %}
|
||||
<div class="post">
|
||||
|
||||
<time>
|
||||
{% if event.published %}
|
||||
{{ event.published | timedeltashort }}
|
||||
{% else %}
|
||||
{{ event.created | timedeltashort }}
|
||||
{% endif %}
|
||||
</time>
|
||||
|
||||
{% if event.type == "follow" %}
|
||||
{{ event.subject_identity.name_or_handle }} followed you
|
||||
{% elif event.type == "like" %}
|
||||
{{ event.subject_identity.name_or_handle }} liked {{ event.subject_post }}
|
||||
{% else %}
|
||||
Unknown event type {{event.type}}
|
||||
{% endif %}
|
||||
</div>
|
@ -1,6 +1,6 @@
|
||||
<nav>
|
||||
<a href="/" {% if current_page == "home" %}class="selected"{% endif %}>Home</a>
|
||||
<a href="/" {% if current_page == "mentions" %}class="selected"{% endif %}>Mentions</a>
|
||||
<a href="/" {% if current_page == "public" %}class="selected"{% endif %}>Public</a>
|
||||
<a href="/notifications/" {% if current_page == "notifications" %}class="selected"{% endif %}>Notifications</a>
|
||||
<a href="/local/" {% if current_page == "local" %}class="selected"{% endif %}>Local</a>
|
||||
<a href="/federated/" {% if current_page == "federated" %}class="selected"{% endif %}>Federated</a>
|
||||
</nav>
|
||||
|
@ -10,8 +10,8 @@
|
||||
|
||||
<time>
|
||||
<a href="{{ post.url }}">
|
||||
{% if post.authored %}
|
||||
{{ post.authored | timedeltashort }}
|
||||
{% if post.published %}
|
||||
{{ post.published | timedeltashort }}
|
||||
{% else %}
|
||||
{{ post.created | timedeltashort }}
|
||||
{% endif %}
|
||||
|
@ -7,7 +7,7 @@
|
||||
|
||||
<section class="columns">
|
||||
<div class="left-column">
|
||||
{% for post in timeline_posts %}
|
||||
{% for post in posts %}
|
||||
{% include "activities/_post.html" %}
|
||||
{% empty %}
|
||||
No posts yet.
|
||||
|
@ -8,10 +8,19 @@
|
||||
<section class="columns">
|
||||
|
||||
<div class="left-column">
|
||||
{% for post in timeline_posts %}
|
||||
{% include "activities/_post.html" %}
|
||||
{% for event in events %}
|
||||
{% if event.type == "post" %}
|
||||
{% include "activities/_post.html" with post=event.subject_post %}
|
||||
{% elif event.type == "boost" %}
|
||||
<div class="boost-banner">
|
||||
<a href="{{ event.subject_identity.urls.view }}">
|
||||
{{ event.subject_identity.name_or_handle }}
|
||||
</a> boosted
|
||||
</div>
|
||||
{% include "activities/_post.html" with post=event.subject_post %}
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
No posts yet.
|
||||
Nothing to show yet.
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
21
templates/activities/local.html
Normal file
21
templates/activities/local.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Local Timeline{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "activities/_home_menu.html" %}
|
||||
|
||||
<section class="columns">
|
||||
<div class="left-column">
|
||||
{% for post in posts %}
|
||||
{% include "activities/_post.html" %}
|
||||
{% empty %}
|
||||
No posts yet.
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="right-column">
|
||||
<h2>?</h2>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
22
templates/activities/notifications.html
Normal file
22
templates/activities/notifications.html
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Notifications{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "activities/_home_menu.html" %}
|
||||
|
||||
<section class="columns">
|
||||
<div class="left-column">
|
||||
{% for event in events %}
|
||||
{% include "activities/_event.html" %}
|
||||
{% empty %}
|
||||
No events yet.
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="right-column">
|
||||
<h2>?</h2>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
@ -12,12 +12,16 @@ class InboxMessageStates(StateGraph):
|
||||
|
||||
@classmethod
|
||||
async def handle_received(cls, instance: "InboxMessage"):
|
||||
from activities.models import Post
|
||||
from activities.models import Post, PostInteraction
|
||||
from users.models import Follow
|
||||
|
||||
match instance.message_type:
|
||||
case "follow":
|
||||
await sync_to_async(Follow.handle_request_ap)(instance.message)
|
||||
case "announce":
|
||||
await sync_to_async(PostInteraction.handle_ap)(instance.message)
|
||||
case "like":
|
||||
await sync_to_async(PostInteraction.handle_ap)(instance.message)
|
||||
case "create":
|
||||
match instance.message_object_type:
|
||||
case "note":
|
||||
|
Reference in New Issue
Block a user