Skip to content

Commit

Permalink
Featured tags (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
dcwatson authored Apr 29, 2024
1 parent dc3f9ff commit 7c9b20f
Show file tree
Hide file tree
Showing 23 changed files with 644 additions and 126 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by Django 4.2.11 on 2024-04-29 01:28

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("activities", "0021_merge_20240422_1905"),
]

operations = [
migrations.AddField(
model_name="fanout",
name="subject_hashtag",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="fan_outs",
to="activities.hashtag",
),
),
migrations.AlterField(
model_name="fanout",
name="type",
field=models.CharField(
choices=[
("post", "Post"),
("post_edited", "Post Edited"),
("post_deleted", "Post Deleted"),
("interaction", "Interaction"),
("undo_interaction", "Undo Interaction"),
("identity_edited", "Identity Edited"),
("identity_deleted", "Identity Deleted"),
("identity_created", "Identity Created"),
("identity_moved", "Identity Moved"),
("tag_featured", "Tag Featured"),
("tag_unfeatured", "Tag Unfeatured"),
],
max_length=100,
),
),
]
45 changes: 45 additions & 0 deletions activities/models/fan_out.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,42 @@ def handle_new(cls, instance: "FanOut"):
new_identity=instance.subject_identity,
)

case (FanOut.Types.tag_featured, True):
pass

case (FanOut.Types.tag_featured, False):
identity = instance.subject_identity
try:
identity.signed_request(
method="post",
uri=(
instance.identity.shared_inbox_uri
or instance.identity.inbox_uri
),
body=canonicalise(instance.subject_hashtag.to_add_ap(identity)),
)
except httpx.RequestError:
return

case (FanOut.Types.tag_unfeatured, True):
pass

case (FanOut.Types.tag_unfeatured, False):
identity = instance.subject_identity
try:
identity.signed_request(
method="post",
uri=(
instance.identity.shared_inbox_uri
or instance.identity.inbox_uri
),
body=canonicalise(
instance.subject_hashtag.to_remove_ap(identity)
),
)
except httpx.RequestError:
return

case _:
raise ValueError(
f"Cannot fan out with type {instance.type} local={instance.identity.local}"
Expand All @@ -282,6 +318,8 @@ class Types(models.TextChoices):
identity_deleted = "identity_deleted"
identity_created = "identity_created"
identity_moved = "identity_moved"
tag_featured = "tag_featured"
tag_unfeatured = "tag_unfeatured"

state = StateField(FanOutStates)

Expand Down Expand Up @@ -320,6 +358,13 @@ class Types(models.TextChoices):
null=True,
related_name="subject_fan_outs",
)
subject_hashtag = models.ForeignKey(
"activities.Hashtag",
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)
113 changes: 110 additions & 3 deletions activities/models/hashtag.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from datetime import date, timedelta

import urlman
from django.db import models
from django.conf import settings
from django.db import models, transaction
from django.utils import timezone

from core.models import Config
Expand Down Expand Up @@ -163,10 +164,116 @@ def usage_days(self, num: int = 7) -> dict[date, int]:
results[date(year, month, day)] = val
return dict(sorted(results.items(), reverse=True)[:num])

def to_mastodon_json(self, following: bool | None = None):
@classmethod
def ensure_hashtag(cls, name):
"""
Properly strips/trims/lowercases the hashtag name, and makes sure a Hashtag
object exists in the database, and returns it.
"""
name = name.strip().lstrip("#").lower()[: Hashtag.MAXIMUM_LENGTH]
hashtag, created = cls.objects.get_or_create(hashtag=name)
hashtag.transition_perform(HashtagStates.outdated)
return hashtag

@classmethod
def handle_add_ap(cls, data):
"""
Handles an incoming Add activity - sent when someone features a Hashtag.
{
"type": "Add",
"actor": "https://hachyderm.io/users/dcw",
"object": {
"href": "https://hachyderm.io/@dcw/tagged/incarnator",
"name": "#incarnator",
"type": "Hashtag",
},
"target": "https://hachyderm.io/users/dcw/collections/featured",
}
"""

from users.models import Identity

target = data.get("target", None)
if not target:
return

with transaction.atomic():
identity = Identity.by_actor_uri(data["actor"], create=True)
# Featured tags target the featured collection URI, same as pinned posts.
if identity.featured_collection_uri != target:
return

tag = Hashtag.ensure_hashtag(data["object"]["name"])
return identity.hashtag_features.get_or_create(hashtag=tag)[0]

@classmethod
def handle_remove_ap(cls, data):
"""
Handles an incoming Remove activity - sent when someone unfeatures a Hashtag.
{
"type": "Remove",
"actor": "https://hachyderm.io/users/dcw",
"object": {
"href": "https://hachyderm.io/@dcw/tagged/netneutrality",
"name": "#netneutrality",
"type": "Hashtag",
},
"target": "https://hachyderm.io/users/dcw/collections/featured",
}
"""

from users.models import Identity

target = data.get("target", None)
if not target:
return

with transaction.atomic():
identity = Identity.by_actor_uri(data["actor"], create=True)
# Featured tags target the featured collection URI, same as pinned posts.
if identity.featured_collection_uri != target:
return

tag = Hashtag.ensure_hashtag(data["object"]["name"])
identity.hashtag_features.filter(hashtag=tag).delete()

def to_ap(self, domain=None):
hostname = domain.uri_domain if domain else settings.MAIN_DOMAIN
return {
"type": "Hashtag",
"href": f"https://{hostname}/tags/{self.hashtag}/",
"name": "#" + self.hashtag,
}

def to_add_ap(self, identity):
"""
Returns the AP JSON to add a featured tag to the given identity.
"""
return {
"type": "Add",
"actor": identity.actor_uri,
"target": identity.actor_uri + "collections/featured/",
"object": self.to_ap(domain=identity.domain),
}

def to_remove_ap(self, identity):
"""
Returns the AP JSON to remove a featured tag from the given identity.
"""
return {
"type": "Remove",
"actor": identity.actor_uri,
"target": identity.actor_uri + "collections/featured/",
"object": self.to_ap(domain=identity.domain),
}

def to_mastodon_json(self, following: bool | None = None, domain=None):
hostname = domain.uri_domain if domain else settings.MAIN_DOMAIN
value = {
"name": self.hashtag,
"url": self.urls.view.full(), # type: ignore
"url": f"https://{hostname}/tags/{self.hashtag}/",
"history": [],
}

Expand Down
9 changes: 3 additions & 6 deletions activities/models/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from activities.models.emoji import Emoji
from activities.models.fan_out import FanOut
from activities.models.hashtag import Hashtag, HashtagStates
from activities.models.hashtag import Hashtag
from activities.models.post_types import (
PostTypeData,
PostTypeDataDecoder,
Expand All @@ -43,7 +43,7 @@
from stator.exceptions import TryAgainLater
from stator.models import State, StateField, StateGraph, StatorModel
from users.models.follow import FollowStates
from users.models.hashtag_follow import HashtagFollow
from users.models.hashtags import HashtagFollow
from users.models.identity import Identity, IdentityStates
from users.models.inbox_message import InboxMessage
from users.models.system_actor import SystemActor
Expand Down Expand Up @@ -609,10 +609,7 @@ def ensure_hashtags(self) -> None:
# Ensure hashtags
if self.hashtags:
for hashtag in self.hashtags:
tag, _ = Hashtag.objects.get_or_create(
hashtag=hashtag[: Hashtag.MAXIMUM_LENGTH],
)
tag.transition_perform(HashtagStates.outdated)
Hashtag.ensure_hashtag(hashtag)

def calculate_stats(self, save=True):
"""
Expand Down
14 changes: 6 additions & 8 deletions activities/models/post_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,13 +481,12 @@ def handle_add_ap(cls, data):
"""
Handles an incoming Add activity which is a pin
"""
target = data.get("target", None)
target = data.get("target")
if not target:
return

# we only care about pinned posts, not hashtags
object = data.get("object", {})
if isinstance(object, dict) and object.get("type") == "Hashtag":
object = data.get("object")
if not object:
return

with transaction.atomic():
Expand All @@ -513,13 +512,12 @@ def handle_remove_ap(cls, data):
"""
Handles an incoming Remove activity which is an unpin
"""
target = data.get("target", None)
target = data.get("target")
if not target:
return

# we only care about pinned posts, not hashtags
object = data.get("object", {})
if isinstance(object, dict) and object.get("type") == "Hashtag":
object = data.get("object")
if not object:
return

with transaction.atomic():
Expand Down
22 changes: 21 additions & 1 deletion api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,20 @@ def from_hashtag(
cls,
hashtag: activities_models.Hashtag,
following: bool | None = None,
domain: users_models.Domain | None = None,
) -> "Tag":
return cls(**hashtag.to_mastodon_json(following=following))
return cls(**hashtag.to_mastodon_json(following=following, domain=domain))

@classmethod
def map_from_names(
cls,
tag_names: list[str],
domain: users_models.Domain | None = None,
) -> list["Tag"]:
return [
cls.from_hashtag(tag, domain=domain)
for tag in activities_models.Hashtag.objects.filter(hashtag__in=tag_names)
]


class FollowedTag(Tag):
Expand Down Expand Up @@ -332,6 +344,14 @@ class FeaturedTag(Schema):
statuses_count: int
last_status_at: str

@classmethod
def from_feature(
cls,
feature: users_models.HashtagFeature,
domain: users_models.Domain | None = None,
) -> "FeaturedTag":
return cls(**feature.to_mastodon_json(domain=domain))


class Search(Schema):
accounts: list[Account]
Expand Down
9 changes: 8 additions & 1 deletion api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,15 @@
path("v1/statuses/<id>/unpin", statuses.unpin_status),
# Tags
path("v1/followed_tags", tags.followed_tags),
path("v1/featured_tags", tags.featured_tags),
path(
"v1/featured_tags",
methods(
get=tags.featured_tags,
post=tags.feature_tag,
),
),
path("v1/featured_tags/suggestions", tags.featured_tag_suggestions),
path("v1/featured_tags/<id>", tags.unfeature_tag),
path("v1/tags/<hashtag>", tags.hashtag),
path("v1/tags/<id>/follow", tags.follow),
path("v1/tags/<id>/unfollow", tags.unfollow),
Expand Down
11 changes: 8 additions & 3 deletions api/views/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
from django.core.files import File
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from hatchway import ApiResponse, QueryOrBody, api_view

from activities.models import Post, PostInteraction, PostInteractionStates
from activities.services import SearchService
from api import schemas
from api.decorators import scope_required
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
from core.models import Config
from hatchway import ApiResponse, QueryOrBody, api_view
from users.models import Identity, IdentityStates
from users.services import IdentityService
from users.shortcuts import by_handle_or_404
Expand Down Expand Up @@ -371,8 +371,13 @@ def account_followers(

@api_view.get
def account_featured_tags(request: HttpRequest, id: str) -> list[schemas.FeaturedTag]:
# Not implemented yet
return []
identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
return [
schemas.FeaturedTag.from_feature(f, domain=request.domain)
for f in identity.hashtag_features.select_related("hashtag")
]


@scope_required("read:lists")
Expand Down
Loading

0 comments on commit 7c9b20f

Please sign in to comment.