diff --git a/activities/admin.py b/activities/admin.py index 2dec3bf..d4603a4 100644 --- a/activities/admin.py +++ b/activities/admin.py @@ -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"] diff --git a/activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py b/activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py new file mode 100644 index 0000000..7972f18 --- /dev/null +++ b/activities/migrations/0004_rename_authored_post_published_alter_fanout_type_and_more.py @@ -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", + ), + ), + ] diff --git a/activities/models/__init__.py b/activities/models/__init__.py index b0ed474..a0680ad 100644 --- a/activities/models/__init__.py +++ b/activities/models/__init__.py @@ -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 diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index 958fbe2..dbe86c0 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -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) diff --git a/activities/models/post.py b/activities/models/post.py index 75a4388..d847307 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -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): """ diff --git a/activities/models/post_interaction.py b/activities/models/post_interaction.py new file mode 100644 index 0000000..151ab45 --- /dev/null +++ b/activities/models/post_interaction.py @@ -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) diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py index 43fc458..6dba32c 100644 --- a/activities/models/timeline_event.py +++ b/activities/models/timeline_event.py @@ -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] diff --git a/activities/tests/__init__.py b/activities/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/activities/tests/models/__init__.py b/activities/tests/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/activities/tests/models/test_post.py b/activities/tests/models/test_post.py new file mode 100644 index 0000000..5c7fca2 --- /dev/null +++ b/activities/tests/models/test_post.py @@ -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 diff --git a/activities/views/timelines.py b/activities/views/timelines.py index 76cf018..9be988d 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -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 diff --git a/static/css/style.css b/static/css/style.css index 0755baa..59590ef 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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; +} diff --git a/stator/management/commands/runstator.py b/stator/management/commands/runstator.py index 1307fef..a77192e 100644 --- a/stator/management/commands/runstator.py +++ b/stator/management/commands/runstator.py @@ -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)() diff --git a/stator/runner.py b/stator/runner.py index 0b42b27..187aa47 100644 --- a/stator/runner.py +++ b/stator/runner.py @@ -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 diff --git a/takahe/urls.py b/takahe/urls.py index 2517364..bebd63a 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -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()), diff --git a/templates/activities/_boost.html b/templates/activities/_boost.html new file mode 100644 index 0000000..601466e --- /dev/null +++ b/templates/activities/_boost.html @@ -0,0 +1,28 @@ +{% load static %} +{% load activity_tags %} +