kopia lustrzana https://github.com/jointakahe/takahe
				
				
				
			Hashtags
							rodzic
							
								
									7f838433ed
								
							
						
					
					
						commit
						fb8f2d1098
					
				| 
						 | 
				
			
			@ -1,7 +1,9 @@
 | 
			
		|||
from asgiref.sync import async_to_sync
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from activities.models import (
 | 
			
		||||
    FanOut,
 | 
			
		||||
    Hashtag,
 | 
			
		||||
    Post,
 | 
			
		||||
    PostAttachment,
 | 
			
		||||
    PostInteraction,
 | 
			
		||||
| 
						 | 
				
			
			@ -9,6 +11,20 @@ from activities.models import (
 | 
			
		|||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Hashtag)
 | 
			
		||||
class HashtagAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ["hashtag", "name_override", "state", "stats_updated", "created"]
 | 
			
		||||
 | 
			
		||||
    readonly_fields = ["created", "updated", "stats_updated"]
 | 
			
		||||
 | 
			
		||||
    actions = ["force_execution"]
 | 
			
		||||
 | 
			
		||||
    @admin.action(description="Force Execution")
 | 
			
		||||
    def force_execution(self, request, queryset):
 | 
			
		||||
        for instance in queryset:
 | 
			
		||||
            instance.transition_perform("outdated")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PostAttachmentInline(admin.StackedInline):
 | 
			
		||||
    model = PostAttachment
 | 
			
		||||
    extra = 0
 | 
			
		||||
| 
						 | 
				
			
			@ -18,7 +34,7 @@ class PostAttachmentInline(admin.StackedInline):
 | 
			
		|||
class PostAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ["id", "state", "author", "created"]
 | 
			
		||||
    raw_id_fields = ["to", "mentions", "author"]
 | 
			
		||||
    actions = ["force_fetch"]
 | 
			
		||||
    actions = ["force_fetch", "reparse_hashtags"]
 | 
			
		||||
    search_fields = ["content"]
 | 
			
		||||
    inlines = [PostAttachmentInline]
 | 
			
		||||
    readonly_fields = ["created", "updated", "object_json"]
 | 
			
		||||
| 
						 | 
				
			
			@ -28,6 +44,13 @@ class PostAdmin(admin.ModelAdmin):
 | 
			
		|||
        for instance in queryset:
 | 
			
		||||
            instance.debug_fetch()
 | 
			
		||||
 | 
			
		||||
    @admin.action(description="Reprocess content for hashtags")
 | 
			
		||||
    def reparse_hashtags(self, request, queryset):
 | 
			
		||||
        for instance in queryset:
 | 
			
		||||
            instance.hashtags = Hashtag.hashtags_from_content(instance.content) or None
 | 
			
		||||
            instance.save()
 | 
			
		||||
            async_to_sync(instance.ensure_hashtags)()
 | 
			
		||||
 | 
			
		||||
    @admin.display(description="ActivityPub JSON")
 | 
			
		||||
    def object_json(self, instance):
 | 
			
		||||
        return instance.to_ap()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,51 @@
 | 
			
		|||
# Generated by Django 4.1.3 on 2022-11-27 20:16
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
import activities.models.hashtag
 | 
			
		||||
import stator.models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("activities", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="Hashtag",
 | 
			
		||||
            fields=[
 | 
			
		||||
                ("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)),
 | 
			
		||||
                (
 | 
			
		||||
                    "hashtag",
 | 
			
		||||
                    models.SlugField(max_length=100, primary_key=True, serialize=False),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "name_override",
 | 
			
		||||
                    models.CharField(blank=True, max_length=100, null=True),
 | 
			
		||||
                ),
 | 
			
		||||
                ("public", models.BooleanField(null=True)),
 | 
			
		||||
                (
 | 
			
		||||
                    "state",
 | 
			
		||||
                    stator.models.StateField(
 | 
			
		||||
                        choices=[("outdated", "outdated"), ("updated", "updated")],
 | 
			
		||||
                        default="outdated",
 | 
			
		||||
                        graph=activities.models.hashtag.HashtagStates,
 | 
			
		||||
                        max_length=100,
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("stats", models.JSONField(blank=True, null=True)),
 | 
			
		||||
                ("stats_updated", models.DateTimeField(blank=True, null=True)),
 | 
			
		||||
                ("aliases", models.JSONField(blank=True, null=True)),
 | 
			
		||||
                ("created", models.DateTimeField(auto_now_add=True)),
 | 
			
		||||
                ("updated", models.DateTimeField(auto_now=True)),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "abstract": False,
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
from .fan_out import FanOut, FanOutStates  # noqa
 | 
			
		||||
from .hashtag import Hashtag, HashtagStates  # noqa
 | 
			
		||||
from .post import Post, PostStates  # noqa
 | 
			
		||||
from .post_attachment import PostAttachment, PostAttachmentStates  # noqa
 | 
			
		||||
from .post_interaction import PostInteraction, PostInteractionStates  # noqa
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,187 @@
 | 
			
		|||
import re
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
from typing import Dict, List
 | 
			
		||||
 | 
			
		||||
import urlman
 | 
			
		||||
from asgiref.sync import sync_to_async
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.safestring import mark_safe
 | 
			
		||||
 | 
			
		||||
from core.models import Config
 | 
			
		||||
from stator.models import State, StateField, StateGraph, StatorModel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HashtagStates(StateGraph):
 | 
			
		||||
    outdated = State(try_interval=300, force_initial=True)
 | 
			
		||||
    updated = State(try_interval=3600, attempt_immediately=False)
 | 
			
		||||
 | 
			
		||||
    outdated.transitions_to(updated)
 | 
			
		||||
    updated.transitions_to(outdated)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    async def handle_outdated(cls, instance: "Hashtag"):
 | 
			
		||||
        """
 | 
			
		||||
        Computes the stats and other things for a Hashtag
 | 
			
		||||
        """
 | 
			
		||||
        from .post import Post
 | 
			
		||||
 | 
			
		||||
        posts_query = Post.objects.local_public().tagged_with(instance)
 | 
			
		||||
        total = await posts_query.acount()
 | 
			
		||||
 | 
			
		||||
        today = timezone.now().date()
 | 
			
		||||
        # TODO: single query
 | 
			
		||||
        total_today = await posts_query.filter(
 | 
			
		||||
            created__gte=today,
 | 
			
		||||
            created__lte=today + timedelta(days=1),
 | 
			
		||||
        ).acount()
 | 
			
		||||
        total_month = await posts_query.filter(
 | 
			
		||||
            created__year=today.year,
 | 
			
		||||
            created__month=today.month,
 | 
			
		||||
        ).acount()
 | 
			
		||||
        total_year = await posts_query.filter(
 | 
			
		||||
            created__year=today.year,
 | 
			
		||||
        ).acount()
 | 
			
		||||
        if total:
 | 
			
		||||
            if not instance.stats:
 | 
			
		||||
                instance.stats = {}
 | 
			
		||||
            instance.stats.update(
 | 
			
		||||
                {
 | 
			
		||||
                    "total": total,
 | 
			
		||||
                    today.isoformat(): total_today,
 | 
			
		||||
                    today.strftime("%Y-%m"): total_month,
 | 
			
		||||
                    today.strftime("%Y"): total_year,
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
            instance.stats_updated = timezone.now()
 | 
			
		||||
            await sync_to_async(instance.save)()
 | 
			
		||||
 | 
			
		||||
        return cls.updated
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    async def handle_updated(cls, instance: "Hashtag"):
 | 
			
		||||
        if instance.state_age > Config.system.hashtag_stats_max_age:
 | 
			
		||||
            return cls.outdated
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HashtagQuerySet(models.QuerySet):
 | 
			
		||||
    def public(self):
 | 
			
		||||
        public_q = models.Q(public=True)
 | 
			
		||||
        if Config.system.hashtag_unreviewed_are_public:
 | 
			
		||||
            public_q |= models.Q(public__isnull=True)
 | 
			
		||||
        return self.filter(public_q)
 | 
			
		||||
 | 
			
		||||
    def hashtag_or_alias(self, hashtag: str):
 | 
			
		||||
        return self.filter(
 | 
			
		||||
            models.Q(hashtag=hashtag) | models.Q(aliases__contains=hashtag)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HashtagManager(models.Manager):
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return HashtagQuerySet(self.model, using=self._db)
 | 
			
		||||
 | 
			
		||||
    def public(self):
 | 
			
		||||
        return self.get_queryset().public()
 | 
			
		||||
 | 
			
		||||
    def hashtag_or_alias(self, hashtag: str):
 | 
			
		||||
        return self.get_queryset().hashtag_or_alias(hashtag)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Hashtag(StatorModel):
 | 
			
		||||
 | 
			
		||||
    # Normalized hashtag without the '#'
 | 
			
		||||
    hashtag = models.SlugField(primary_key=True, max_length=100)
 | 
			
		||||
 | 
			
		||||
    # Friendly display override
 | 
			
		||||
    name_override = models.CharField(max_length=100, null=True, blank=True)
 | 
			
		||||
 | 
			
		||||
    # Should this be shown in the public UI?
 | 
			
		||||
    public = models.BooleanField(null=True)
 | 
			
		||||
 | 
			
		||||
    # State of this Hashtag
 | 
			
		||||
    state = StateField(HashtagStates)
 | 
			
		||||
 | 
			
		||||
    # Metrics for this Hashtag
 | 
			
		||||
    stats = models.JSONField(null=True, blank=True)
 | 
			
		||||
    # Timestamp of last time the stats were updated
 | 
			
		||||
    stats_updated = models.DateTimeField(null=True, blank=True)
 | 
			
		||||
 | 
			
		||||
    # List of other hashtags that are considered similar
 | 
			
		||||
    aliases = models.JSONField(null=True, blank=True)
 | 
			
		||||
 | 
			
		||||
    created = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
    updated = models.DateTimeField(auto_now=True)
 | 
			
		||||
 | 
			
		||||
    objects = HashtagManager()
 | 
			
		||||
 | 
			
		||||
    class urls(urlman.Urls):
 | 
			
		||||
        root = "/admin/hashtags/"
 | 
			
		||||
        create = "/admin/hashtags/create/"
 | 
			
		||||
        edit = "/admin/hashtags/{self.hashtag}/"
 | 
			
		||||
        delete = "{edit}delete/"
 | 
			
		||||
        timeline = "/tags/{self.hashtag}/"
 | 
			
		||||
 | 
			
		||||
    hashtag_regex = re.compile(r"((?:\B#)([a-zA-Z0-9(_)]{1,}\b))")
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        self.hashtag = self.hashtag.lstrip("#")
 | 
			
		||||
        if self.name_override:
 | 
			
		||||
            self.name_override = self.name_override.lstrip("#")
 | 
			
		||||
        return super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def display_name(self):
 | 
			
		||||
        return self.name_override or self.hashtag
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.display_name
 | 
			
		||||
 | 
			
		||||
    def usage_months(self, num: int = 12) -> Dict[date, int]:
 | 
			
		||||
        """
 | 
			
		||||
        Return the most recent num months of stats
 | 
			
		||||
        """
 | 
			
		||||
        if not self.stats:
 | 
			
		||||
            return {}
 | 
			
		||||
        results = {}
 | 
			
		||||
        for key, val in self.stats.items():
 | 
			
		||||
            parts = key.split("-")
 | 
			
		||||
            if len(parts) == 2:
 | 
			
		||||
                year = int(parts[0])
 | 
			
		||||
                month = int(parts[1])
 | 
			
		||||
                results[date(year, month, 1)] = val
 | 
			
		||||
        return dict(sorted(results.items(), reverse=True)[:num])
 | 
			
		||||
 | 
			
		||||
    def usage_days(self, num: int = 7) -> Dict[date, int]:
 | 
			
		||||
        """
 | 
			
		||||
        Return the most recent num days of stats
 | 
			
		||||
        """
 | 
			
		||||
        if not self.stats:
 | 
			
		||||
            return {}
 | 
			
		||||
        results = {}
 | 
			
		||||
        for key, val in self.stats.items():
 | 
			
		||||
            parts = key.split("-")
 | 
			
		||||
            if len(parts) == 3:
 | 
			
		||||
                year = int(parts[0])
 | 
			
		||||
                month = int(parts[1])
 | 
			
		||||
                day = int(parts[2])
 | 
			
		||||
                results[date(year, month, day)] = val
 | 
			
		||||
        return dict(sorted(results.items(), reverse=True)[:num])
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def hashtags_from_content(cls, content) -> List[str]:
 | 
			
		||||
        """
 | 
			
		||||
        Return a parsed and sanitized of hashtags found in content without
 | 
			
		||||
        leading '#'.
 | 
			
		||||
        """
 | 
			
		||||
        hashtag_hits = cls.hashtag_regex.findall(content)
 | 
			
		||||
        hashtags = sorted({tag[1].lower() for tag in hashtag_hits})
 | 
			
		||||
        return list(hashtags)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def linkify_hashtags(cls, content) -> str:
 | 
			
		||||
        def replacer(match):
 | 
			
		||||
            hashtag = match.group()
 | 
			
		||||
            return f'<a class="hashtag" href="/tags/{hashtag.lstrip("#").lower()}/">{hashtag}</a>'
 | 
			
		||||
 | 
			
		||||
        return mark_safe(Hashtag.hashtag_regex.sub(replacer, content))
 | 
			
		||||
| 
						 | 
				
			
			@ -10,6 +10,7 @@ from django.utils import timezone
 | 
			
		|||
from django.utils.safestring import mark_safe
 | 
			
		||||
 | 
			
		||||
from activities.models.fan_out import FanOut
 | 
			
		||||
from activities.models.hashtag import Hashtag
 | 
			
		||||
from core.html import sanitize_post, strip_html
 | 
			
		||||
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
 | 
			
		||||
from stator.models import State, StateField, StateGraph, StatorModel
 | 
			
		||||
| 
						 | 
				
			
			@ -34,19 +35,24 @@ class PostStates(StateGraph):
 | 
			
		|||
    edited_fanned_out.transitions_to(edited)
 | 
			
		||||
    edited_fanned_out.transitions_to(deleted)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    async def targets_fan_out(cls, post: "Post", type_: str) -> None:
 | 
			
		||||
        # Fan out to each target
 | 
			
		||||
        for follow in await post.aget_targets():
 | 
			
		||||
            await FanOut.objects.acreate(
 | 
			
		||||
                identity=follow,
 | 
			
		||||
                type=type_,
 | 
			
		||||
                subject_post=post,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    async def handle_new(cls, instance: "Post"):
 | 
			
		||||
        """
 | 
			
		||||
        Creates all needed fan-out objects for a new 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,
 | 
			
		||||
                subject_post=post,
 | 
			
		||||
            )
 | 
			
		||||
        await cls.targets_fan_out(post, FanOut.Types.post)
 | 
			
		||||
        await post.ensure_hashtags()
 | 
			
		||||
        return cls.fanned_out
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
| 
						 | 
				
			
			@ -55,13 +61,7 @@ class PostStates(StateGraph):
 | 
			
		|||
        Creates all needed fan-out objects needed to delete a 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_deleted,
 | 
			
		||||
                subject_post=post,
 | 
			
		||||
            )
 | 
			
		||||
        await cls.targets_fan_out(post, FanOut.Types.post_deleted)
 | 
			
		||||
        return cls.deleted_fanned_out
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
| 
						 | 
				
			
			@ -70,16 +70,46 @@ class PostStates(StateGraph):
 | 
			
		|||
        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,
 | 
			
		||||
            )
 | 
			
		||||
        await cls.targets_fan_out(post, FanOut.Types.post_edited)
 | 
			
		||||
        await post.ensure_hashtags()
 | 
			
		||||
        return cls.edited_fanned_out
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PostQuerySet(models.QuerySet):
 | 
			
		||||
    def local_public(self, include_replies: bool = False):
 | 
			
		||||
        query = self.filter(
 | 
			
		||||
            visibility__in=[
 | 
			
		||||
                Post.Visibilities.public,
 | 
			
		||||
                Post.Visibilities.local_only,
 | 
			
		||||
            ],
 | 
			
		||||
            author__local=True,
 | 
			
		||||
        )
 | 
			
		||||
        if not include_replies:
 | 
			
		||||
            return query.filter(in_reply_to__isnull=True)
 | 
			
		||||
        return query
 | 
			
		||||
 | 
			
		||||
    def tagged_with(self, hashtag: str | Hashtag):
 | 
			
		||||
        if isinstance(hashtag, str):
 | 
			
		||||
            tag_q = models.Q(hashtags__contains=hashtag)
 | 
			
		||||
        else:
 | 
			
		||||
            tag_q = models.Q(hashtags__contains=hashtag.hashtag)
 | 
			
		||||
            if hashtag.aliases:
 | 
			
		||||
                for alias in hashtag.aliases:
 | 
			
		||||
                    tag_q |= models.Q(hashtags__contains=alias)
 | 
			
		||||
        return self.filter(tag_q)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PostManager(models.Manager):
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return PostQuerySet(self.model, using=self._db)
 | 
			
		||||
 | 
			
		||||
    def local_public(self, include_replies: bool = False):
 | 
			
		||||
        return self.get_queryset().local_public(include_replies=include_replies)
 | 
			
		||||
 | 
			
		||||
    def tagged_with(self, hashtag: str | Hashtag):
 | 
			
		||||
        return self.get_queryset().tagged_with(hashtag=hashtag)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Post(StatorModel):
 | 
			
		||||
    """
 | 
			
		||||
    A post (status, toot) that is either local or remote.
 | 
			
		||||
| 
						 | 
				
			
			@ -155,6 +185,8 @@ class Post(StatorModel):
 | 
			
		|||
    created = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
    updated = models.DateTimeField(auto_now=True)
 | 
			
		||||
 | 
			
		||||
    objects = PostManager()
 | 
			
		||||
 | 
			
		||||
    class urls(urlman.Urls):
 | 
			
		||||
        view = "{self.author.urls.view}posts/{self.id}/"
 | 
			
		||||
        object_uri = "{self.author.actor_uri}posts/{self.id}/"
 | 
			
		||||
| 
						 | 
				
			
			@ -236,7 +268,9 @@ class Post(StatorModel):
 | 
			
		|||
        """
 | 
			
		||||
        Returns the content formatted for local display
 | 
			
		||||
        """
 | 
			
		||||
        return self.linkify_mentions(sanitize_post(self.content), local=True)
 | 
			
		||||
        return Hashtag.linkify_hashtags(
 | 
			
		||||
            self.linkify_mentions(sanitize_post(self.content), local=True)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def safe_content_remote(self):
 | 
			
		||||
        """
 | 
			
		||||
| 
						 | 
				
			
			@ -252,7 +286,7 @@ class Post(StatorModel):
 | 
			
		|||
 | 
			
		||||
    ### Async helpers ###
 | 
			
		||||
 | 
			
		||||
    async def afetch_full(self):
 | 
			
		||||
    async def afetch_full(self) -> "Post":
 | 
			
		||||
        """
 | 
			
		||||
        Returns a version of the object with all relations pre-loaded
 | 
			
		||||
        """
 | 
			
		||||
| 
						 | 
				
			
			@ -281,6 +315,8 @@ class Post(StatorModel):
 | 
			
		|||
                # Maintain local-only for replies
 | 
			
		||||
                if reply_to.visibility == reply_to.Visibilities.local_only:
 | 
			
		||||
                    visibility = reply_to.Visibilities.local_only
 | 
			
		||||
            # Find hashtags in this post
 | 
			
		||||
            hashtags = Hashtag.hashtags_from_content(content) or None
 | 
			
		||||
            # Strip all HTML and apply linebreaks filter
 | 
			
		||||
            content = linebreaks_filter(strip_html(content))
 | 
			
		||||
            # Make the Post object
 | 
			
		||||
| 
						 | 
				
			
			@ -291,6 +327,7 @@ class Post(StatorModel):
 | 
			
		|||
                sensitive=bool(summary),
 | 
			
		||||
                local=True,
 | 
			
		||||
                visibility=visibility,
 | 
			
		||||
                hashtags=hashtags,
 | 
			
		||||
                in_reply_to=reply_to.object_uri if reply_to else None,
 | 
			
		||||
            )
 | 
			
		||||
            post.object_uri = post.urls.object_uri
 | 
			
		||||
| 
						 | 
				
			
			@ -312,6 +349,7 @@ class Post(StatorModel):
 | 
			
		|||
            self.sensitive = bool(summary)
 | 
			
		||||
            self.visibility = visibility
 | 
			
		||||
            self.edited = timezone.now()
 | 
			
		||||
            self.hashtags = Hashtag.hashtags_from_content(content) or None
 | 
			
		||||
            self.mentions.set(self.mentions_from_content(content, self.author))
 | 
			
		||||
            self.save()
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -334,6 +372,18 @@ class Post(StatorModel):
 | 
			
		|||
                mentions.add(identity)
 | 
			
		||||
        return mentions
 | 
			
		||||
 | 
			
		||||
    async def ensure_hashtags(self) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Ensure any of the already parsed hashtags from this Post
 | 
			
		||||
        have a corresponding Hashtag record.
 | 
			
		||||
        """
 | 
			
		||||
        # Ensure hashtags
 | 
			
		||||
        if self.hashtags:
 | 
			
		||||
            for hashtag in self.hashtags:
 | 
			
		||||
                await Hashtag.objects.aget_or_create(
 | 
			
		||||
                    hashtag=hashtag,
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
    ### ActivityPub (outbound) ###
 | 
			
		||||
 | 
			
		||||
    def to_ap(self) -> Dict:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,8 @@ import datetime
 | 
			
		|||
from django import template
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
 | 
			
		||||
from activities.models import Hashtag
 | 
			
		||||
 | 
			
		||||
register = template.Library()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -31,3 +33,14 @@ def timedeltashort(value: datetime.datetime):
 | 
			
		|||
        years = max(days // 365.25, 1)
 | 
			
		||||
        text = f"{years:0n}y"
 | 
			
		||||
    return text
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.filter
 | 
			
		||||
def linkify_hashtags(value: str):
 | 
			
		||||
    """
 | 
			
		||||
    Convert hashtags in content in to /tags/<hashtag>/ links.
 | 
			
		||||
    """
 | 
			
		||||
    if not value:
 | 
			
		||||
        return ""
 | 
			
		||||
 | 
			
		||||
    return Hashtag.linkify_hashtags(value)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
from django.views.generic import ListView
 | 
			
		||||
 | 
			
		||||
from activities.models import Hashtag
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExploreTag(ListView):
 | 
			
		||||
 | 
			
		||||
    template_name = "activities/explore_tag.html"
 | 
			
		||||
    extra_context = {
 | 
			
		||||
        "current_page": "explore",
 | 
			
		||||
        "allows_refresh": True,
 | 
			
		||||
    }
 | 
			
		||||
    paginate_by = 20
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return (
 | 
			
		||||
            Hashtag.objects.public()
 | 
			
		||||
            .filter(
 | 
			
		||||
                stats__total__gt=0,
 | 
			
		||||
            )
 | 
			
		||||
            .order_by("-stats__total")
 | 
			
		||||
        )[:20]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Explore(ExploreTag):
 | 
			
		||||
    pass
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,9 @@
 | 
			
		|||
from typing import Set
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.views.generic import FormView
 | 
			
		||||
 | 
			
		||||
from activities.models import Hashtag
 | 
			
		||||
from users.models import Domain, Identity
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -9,13 +12,13 @@ class Search(FormView):
 | 
			
		|||
    template_name = "activities/search.html"
 | 
			
		||||
 | 
			
		||||
    class form_class(forms.Form):
 | 
			
		||||
        query = forms.CharField(help_text="Search for a user by @username@domain")
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        query = form.cleaned_data["query"].lstrip("@").lower()
 | 
			
		||||
        results = {"identities": set()}
 | 
			
		||||
        # Search identities
 | 
			
		||||
        query = forms.CharField(
 | 
			
		||||
            help_text="Search for a user by @username@domain or hashtag by #tagname"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def search_identities(self, query: str):
 | 
			
		||||
        query = query.lstrip("@")
 | 
			
		||||
        results: Set[Identity] = set()
 | 
			
		||||
        if "@" in query:
 | 
			
		||||
            username, domain = query.split("@", 1)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -35,13 +38,35 @@ class Search(FormView):
 | 
			
		|||
                    )
 | 
			
		||||
                identity = None
 | 
			
		||||
            if identity:
 | 
			
		||||
                results["identities"].add(identity)
 | 
			
		||||
                results.add(identity)
 | 
			
		||||
 | 
			
		||||
        else:
 | 
			
		||||
            for identity in Identity.objects.filter(username=query)[:20]:
 | 
			
		||||
                results["identities"].add(identity)
 | 
			
		||||
                results.add(identity)
 | 
			
		||||
            for identity in Identity.objects.filter(username__startswith=query)[:20]:
 | 
			
		||||
                results["identities"].add(identity)
 | 
			
		||||
                results.add(identity)
 | 
			
		||||
        return results
 | 
			
		||||
 | 
			
		||||
    def search_hashtags(self, query: str):
 | 
			
		||||
        results: Set[Hashtag] = set()
 | 
			
		||||
 | 
			
		||||
        if "@" in query:
 | 
			
		||||
            return results
 | 
			
		||||
 | 
			
		||||
        query = query.lstrip("#")
 | 
			
		||||
        for hashtag in Hashtag.objects.public().hashtag_or_alias(query)[:10]:
 | 
			
		||||
            results.add(hashtag)
 | 
			
		||||
        for hashtag in Hashtag.objects.public().filter(hashtag__startswith=query)[:10]:
 | 
			
		||||
            results.add(hashtag)
 | 
			
		||||
        return results
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        query = form.cleaned_data["query"].lower()
 | 
			
		||||
        results = {
 | 
			
		||||
            "identities": self.search_identities(query),
 | 
			
		||||
            "hashtags": self.search_hashtags(query),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # Render results
 | 
			
		||||
        context = self.get_context_data(form=form)
 | 
			
		||||
        context["results"] = results
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,10 @@
 | 
			
		|||
from django import forms
 | 
			
		||||
from django.shortcuts import redirect
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect
 | 
			
		||||
from django.template.defaultfilters import linebreaks_filter
 | 
			
		||||
from django.utils.decorators import method_decorator
 | 
			
		||||
from django.views.generic import FormView, ListView
 | 
			
		||||
 | 
			
		||||
from activities.models import Post, PostInteraction, TimelineEvent
 | 
			
		||||
from activities.models import Hashtag, Post, PostInteraction, TimelineEvent
 | 
			
		||||
from core.models import Config
 | 
			
		||||
from users.decorators import identity_required
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -61,6 +61,41 @@ class Home(FormView):
 | 
			
		|||
        return redirect(".")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Tag(ListView):
 | 
			
		||||
 | 
			
		||||
    template_name = "activities/tag.html"
 | 
			
		||||
    extra_context = {
 | 
			
		||||
        "current_page": "tag",
 | 
			
		||||
        "allows_refresh": True,
 | 
			
		||||
    }
 | 
			
		||||
    paginate_by = 50
 | 
			
		||||
 | 
			
		||||
    def get(self, request, hashtag, *args, **kwargs):
 | 
			
		||||
        tag = hashtag.lower().lstrip("#")
 | 
			
		||||
        if hashtag != tag:
 | 
			
		||||
            # SEO sanitize
 | 
			
		||||
            return redirect(f"/tags/{tag}/", permanent=True)
 | 
			
		||||
        self.hashtag = get_object_or_404(Hashtag.objects.public(), hashtag=tag)
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return (
 | 
			
		||||
            Post.objects.local_public()
 | 
			
		||||
            .tagged_with(self.hashtag)
 | 
			
		||||
            .select_related("author")
 | 
			
		||||
            .prefetch_related("attachments")
 | 
			
		||||
            .order_by("-created")[:50]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self):
 | 
			
		||||
        context = super().get_context_data()
 | 
			
		||||
        context["hashtag"] = self.hashtag
 | 
			
		||||
        context["interactions"] = PostInteraction.get_post_interactions(
 | 
			
		||||
            context["page_obj"], self.request.identity
 | 
			
		||||
        )
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Local(ListView):
 | 
			
		||||
 | 
			
		||||
    template_name = "activities/local.html"
 | 
			
		||||
| 
						 | 
				
			
			@ -72,11 +107,7 @@ class Local(ListView):
 | 
			
		|||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return (
 | 
			
		||||
            Post.objects.filter(
 | 
			
		||||
                visibility=Post.Visibilities.public,
 | 
			
		||||
                author__local=True,
 | 
			
		||||
                in_reply_to__isnull=True,
 | 
			
		||||
            )
 | 
			
		||||
            Post.objects.local_public()
 | 
			
		||||
            .select_related("author")
 | 
			
		||||
            .prefetch_related("attachments")
 | 
			
		||||
            .order_by("-created")[:50]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -215,6 +215,9 @@ class Config(models.Model):
 | 
			
		|||
        identity_max_age: int = 24 * 60 * 60
 | 
			
		||||
        inbox_message_purge_after: int = 24 * 60 * 60
 | 
			
		||||
 | 
			
		||||
        hashtag_unreviewed_are_public: bool = True
 | 
			
		||||
        hashtag_stats_max_age: int = 60 * 60
 | 
			
		||||
 | 
			
		||||
        restricted_usernames: str = "admin\nadmins\nadministrator\nadministrators\nsystem\nroot\nannounce\nannouncement\nannouncements"
 | 
			
		||||
 | 
			
		||||
    class UserOptions(pydantic.BaseModel):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,6 +22,7 @@ Currently, it supports:
 | 
			
		|||
* Server defederation (blocking)
 | 
			
		||||
* Signup flow
 | 
			
		||||
* Password reset flow
 | 
			
		||||
* Hashtag trending system with moderation
 | 
			
		||||
 | 
			
		||||
Features planned for releases up to 1.0:
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -40,7 +41,6 @@ Features that may make it into 1.0, or might be further out:
 | 
			
		|||
 | 
			
		||||
* Creating polls on posts, and handling received polls
 | 
			
		||||
* Filter system for Home timeline
 | 
			
		||||
* Hashtag trending system with moderation
 | 
			
		||||
* Mastodon-compatible account migration target/source
 | 
			
		||||
* Relay support
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -448,6 +448,23 @@ form .field .label-input {
 | 
			
		|||
    flex-grow: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form .field.stats {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
}
 | 
			
		||||
form .field.stats table {
 | 
			
		||||
    width: 50%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form .field.stats table tr th {
 | 
			
		||||
    color: var(--color-text-main);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
form .field.stats table tbody td {
 | 
			
		||||
    color: var(--color-text-dull);
 | 
			
		||||
    text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.right-column form .field {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    background: none;
 | 
			
		||||
| 
						 | 
				
			
			@ -704,6 +721,17 @@ table.metadata td.name {
 | 
			
		|||
    font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Named Timelines */
 | 
			
		||||
 | 
			
		||||
.left-column .timeline-name {
 | 
			
		||||
    margin: 0 0 10px 0;
 | 
			
		||||
    color: var(--color-text-main);
 | 
			
		||||
    font-size: 130%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.left-column .timeline-name i {
 | 
			
		||||
    margin-right: 10px
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Posts */
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -879,6 +907,14 @@ table.metadata td.name {
 | 
			
		|||
    width: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post a.hashtag, .post.mini a.hashtag {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.post a.hashtag:hover, .post.mini a.hashtag:hover {
 | 
			
		||||
    text-decoration: underline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.boost-banner,
 | 
			
		||||
.mention-banner,
 | 
			
		||||
.follow-banner,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,7 @@ from django.contrib import admin as djadmin
 | 
			
		|||
from django.urls import path, re_path
 | 
			
		||||
from django.views.static import serve
 | 
			
		||||
 | 
			
		||||
from activities.views import posts, search, timelines
 | 
			
		||||
from activities.views import explore, posts, search, timelines
 | 
			
		||||
from core import views as core
 | 
			
		||||
from stator import views as stator
 | 
			
		||||
from users.views import activitypub, admin, auth, follows, identity, settings
 | 
			
		||||
| 
						 | 
				
			
			@ -16,6 +16,9 @@ urlpatterns = [
 | 
			
		|||
    path("local/", timelines.Local.as_view(), name="local"),
 | 
			
		||||
    path("federated/", timelines.Federated.as_view(), name="federated"),
 | 
			
		||||
    path("search/", search.Search.as_view(), name="search"),
 | 
			
		||||
    path("tags/<hashtag>/", timelines.Tag.as_view(), name="tag"),
 | 
			
		||||
    path("explore/", explore.Explore.as_view(), name="explore"),
 | 
			
		||||
    path("explore/tags/", explore.ExploreTag.as_view(), name="explore-tag"),
 | 
			
		||||
    path(
 | 
			
		||||
        "settings/",
 | 
			
		||||
        settings.SettingsRoot.as_view(),
 | 
			
		||||
| 
						 | 
				
			
			@ -94,6 +97,24 @@ urlpatterns = [
 | 
			
		|||
        admin.Invites.as_view(),
 | 
			
		||||
        name="admin_invites",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "admin/hashtags/",
 | 
			
		||||
        admin.Hashtags.as_view(),
 | 
			
		||||
        name="admin_hashtags",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "admin/hashtags/create/",
 | 
			
		||||
        admin.HashtagCreate.as_view(),
 | 
			
		||||
        name="admin_hashtags_create",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "admin/hashtags/<hashtag>/",
 | 
			
		||||
        admin.HashtagEdit.as_view(),
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "admin/hashtags/<hashtag>/delete/",
 | 
			
		||||
        admin.HashtagDelete.as_view(),
 | 
			
		||||
    ),
 | 
			
		||||
    # Identity views
 | 
			
		||||
    path("@<handle>/", identity.ViewIdentity.as_view()),
 | 
			
		||||
    path("@<handle>/inbox/", activitypub.Inbox.as_view()),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
<a class="option" href="{{ hashtag.urls.timeline }}">
 | 
			
		||||
    <i class="fa-solid fa-hashtag"></i>
 | 
			
		||||
    <span class="handle">
 | 
			
		||||
        {{ hashtag.display_name }}
 | 
			
		||||
    </span>
 | 
			
		||||
    {% if not hide_stats %}
 | 
			
		||||
    <span>
 | 
			
		||||
        Post count: {{ hashtag.stats.total }}
 | 
			
		||||
    </span>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
</a>
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +6,9 @@
 | 
			
		|||
        <a href="{% url "notifications" %}" {% if current_page == "notifications" %}class="selected"{% endif %} title="Notifications">
 | 
			
		||||
            <i class="fa-solid fa-at"></i> Notifications
 | 
			
		||||
        </a>
 | 
			
		||||
        <a href="{% url "explore" %}" {% if current_page == "explore" %}class="selected"{% endif %} title="Explore">
 | 
			
		||||
            <i class="fa-solid fa-hashtag"></i> Explore
 | 
			
		||||
        </a>
 | 
			
		||||
        <a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %} title="Local">
 | 
			
		||||
            <i class="fa-solid fa-city"></i> Local
 | 
			
		||||
        </a>
 | 
			
		||||
| 
						 | 
				
			
			@ -19,13 +22,21 @@
 | 
			
		|||
        <a href="{% url "search" %}" {% if top_section == "search" %}class="selected"{% endif %} title="Search">
 | 
			
		||||
            <i class="fa-solid fa-search"></i> Search
 | 
			
		||||
        </a>
 | 
			
		||||
        {% if current_page == "tag" %}
 | 
			
		||||
        <a href="{% url "tag" hashtag.hashtag %}" class="selected" title="Tag {{ hashtag.display_name }}">
 | 
			
		||||
            <i class="fa-solid fa-hashtag"></i> {{ hashtag.display_name }}
 | 
			
		||||
        </a>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        <a href="{% url "settings" %}" {% if top_section == "settings" %}class="selected"{% endif %} title="Settings">
 | 
			
		||||
            <i class="fa-solid fa-gear"></i> Settings
 | 
			
		||||
        </a>
 | 
			
		||||
    {% else %}
 | 
			
		||||
        {% else %}
 | 
			
		||||
        <a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %} title="Local Posts">
 | 
			
		||||
            <i class="fa-solid fa-city"></i> Local Posts
 | 
			
		||||
        </a>
 | 
			
		||||
        <a href="{% url "explore" %}" {% if current_page == "explore" %}class="selected"{% endif %} title="Explore">
 | 
			
		||||
            <i class="fa-solid fa-hashtag"></i> Explore
 | 
			
		||||
        </a>
 | 
			
		||||
        <h3></h3>
 | 
			
		||||
        {% if config.signup_allowed %}
 | 
			
		||||
            <a href="{% url "signup" %}" {% if current_page == "signup" %}class="selected"{% endif %} title="Create Account">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="timeline-name">Explore Trending Tags</div>
 | 
			
		||||
 | 
			
		||||
<section class="icon-menu">
 | 
			
		||||
    {% for hashtag in page_obj %}
 | 
			
		||||
        {% include "activities/_hashtag.html" %}
 | 
			
		||||
    {% empty %}
 | 
			
		||||
        No tags are trending yet.
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 | 
			
		||||
</section>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			@ -18,4 +18,12 @@
 | 
			
		|||
            {% include "activities/_identity.html" %}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    {% if results.hashtags %}
 | 
			
		||||
        <h2>Hashtags</h2>
 | 
			
		||||
        <section class="icon-menu">
 | 
			
		||||
        {% for hashtag in results.hashtags %}
 | 
			
		||||
            {% include "activities/_hashtag.html" with hide_stats=True %}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
        </section>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <div class="timeline-name"><i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }}</div>
 | 
			
		||||
    {% for post in page_obj %}
 | 
			
		||||
        {% include "activities/_post.html" %}
 | 
			
		||||
    {% empty %}
 | 
			
		||||
        No posts yet.
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 | 
			
		||||
    {% if page_obj.has_next %}
 | 
			
		||||
        <div class="load-more"><a class="button" href=".?page={{ page_obj.next_page_number }}">Next Page</a></div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
{% extends "settings/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}Add hashtag - Admin{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form action="." method="POST">
 | 
			
		||||
        <h1>Add A hashtag</h1>
 | 
			
		||||
        <p>
 | 
			
		||||
            Use this form to add a hashtag.
 | 
			
		||||
        </p>
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <legend>hashtag Details</legend>
 | 
			
		||||
            {% include "forms/_field.html" with field=form.hashtag %}
 | 
			
		||||
            {% include "forms/_field.html" with field=form.name_override %}
 | 
			
		||||
        </fieldset>
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <legend>Access Control</legend>
 | 
			
		||||
            {% include "forms/_field.html" with field=form.public %}
 | 
			
		||||
        </fieldset>
 | 
			
		||||
        <div class="buttons">
 | 
			
		||||
            <a href="{% url "admin_hashtags" %}" class="button secondary left">Back</a>
 | 
			
		||||
            <button>Create</button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
{% extends "settings/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}Delete <i class="fa-solid fa-hashtag"></i>{{ hashtag.hashtag }} - Admin{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form action="." method="POST">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
 | 
			
		||||
        <h1>Deleting <i class="fa-solid fa-hashtag"></i>{{ hashtag.hashtag }}</h1>
 | 
			
		||||
 | 
			
		||||
        <p>Please confirm deletion of this hashtag.</p>
 | 
			
		||||
        <div class="buttons">
 | 
			
		||||
            <a class="button" href="{{ hashtag.urls.edit }}">Cancel</a>
 | 
			
		||||
            <button class="delete">Confirm Deletion</button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,46 @@
 | 
			
		|||
{% extends "settings/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block subtitle %}{{ hashtag.hashtag }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form action="." method="POST">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <legend>hashtag Details</legend>
 | 
			
		||||
            {% include "forms/_field.html" with field=form.hashtag %}
 | 
			
		||||
            {% include "forms/_field.html" with field=form.name_override %}
 | 
			
		||||
        </fieldset>
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <legend>Access Control</legend>
 | 
			
		||||
            {% include "forms/_field.html" with field=form.public %}
 | 
			
		||||
        </fieldset>
 | 
			
		||||
        <fieldset>
 | 
			
		||||
            <legend>Stats</legend>
 | 
			
		||||
            <div class="field stats">
 | 
			
		||||
                {% for stat_month, stat_value in hashtag.usage_months.items|slice:":5" %}
 | 
			
		||||
                {% if forloop.first %}
 | 
			
		||||
                <table>
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <th>Month</th>
 | 
			
		||||
                        <th>Usage</th>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <th>{{ stat_month|date:"M Y" }}</th>
 | 
			
		||||
                        <td>{{ stat_value }}</td>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                {% if forloop.last %}
 | 
			
		||||
                </table>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% empty %}
 | 
			
		||||
                <p class="help"></p>Hashtag is either not used or stats have not been computed yet.</p>
 | 
			
		||||
                {% endfor %}
 | 
			
		||||
            </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
        <div class="buttons">
 | 
			
		||||
            <a href="{{ hashtag.urls.root }}" class="button secondary left">Back</a>
 | 
			
		||||
            <a href="{{ hashtag.urls.delete }}" class="button delete">Delete</a>
 | 
			
		||||
            <button>Save</button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
{% extends "settings/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block subtitle %}Hashtags{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <section class="icon-menu">
 | 
			
		||||
        {% for hashtag in hashtags %}
 | 
			
		||||
            <a class="option" href="{{ hashtag.urls.edit }}">
 | 
			
		||||
                <i class="fa-solid fa-hashtag"></i>
 | 
			
		||||
                <span class="handle">
 | 
			
		||||
                    {{ hashtag.display_name }}
 | 
			
		||||
                    <small>
 | 
			
		||||
                        {% if hashtag.public %}Public{% elif hashtag.public is None %}Unreviewed{% else %}Private{% endif %}
 | 
			
		||||
                    </small>
 | 
			
		||||
                </span>
 | 
			
		||||
                {% if hashtag.stats %}
 | 
			
		||||
                <span class="handle">
 | 
			
		||||
                    <small>Total:</small>
 | 
			
		||||
                    {{ hashtag.stats.total }}
 | 
			
		||||
                </span>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% if hashtag.aliases %}
 | 
			
		||||
 | 
			
		||||
                <span class="handle">
 | 
			
		||||
                    <small>Aliases:</small>
 | 
			
		||||
                    {% for alias in hashtag.aliases %}
 | 
			
		||||
                        {{ alias }}{% if not forloop.last %}, {% endif %}
 | 
			
		||||
                    {% endfor %}
 | 
			
		||||
                </span>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
 | 
			
		||||
            </a>
 | 
			
		||||
        {% empty %}
 | 
			
		||||
            <p class="option empty">You have no hashtags set up.</p>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
        <a href="{% url "admin_hashtags_create" %}" class="option new">
 | 
			
		||||
            <i class="fa-solid fa-plus"></i> Add a hashtag
 | 
			
		||||
        </a>
 | 
			
		||||
    </section>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			@ -36,6 +36,9 @@
 | 
			
		|||
    <a href="{% url "admin_invites" %}" {% if section == "invites" %}class="selected"{% endif %} title="Invites">
 | 
			
		||||
        <i class="fa-solid fa-envelope"></i> Invites
 | 
			
		||||
    </a>
 | 
			
		||||
    <a href="{% url "admin_hashtags" %}" {% if section == "hashtags" %}class="selected"{% endif %} title="Hashtags">
 | 
			
		||||
        <i class="fa-solid fa-hashtag"></i> Hashtags
 | 
			
		||||
    </a>
 | 
			
		||||
    <a href="/djadmin" title="">
 | 
			
		||||
        <i class="fa-solid fa-gear"></i> Django Admin
 | 
			
		||||
    </a>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
from activities.models import Hashtag
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_hashtag_from_content():
 | 
			
		||||
    assert Hashtag.hashtags_from_content("#hashtag") == ["hashtag"]
 | 
			
		||||
    assert Hashtag.hashtags_from_content("a#hashtag") == []
 | 
			
		||||
    assert Hashtag.hashtags_from_content("Text #with #hashtag in it") == [
 | 
			
		||||
        "hashtag",
 | 
			
		||||
        "with",
 | 
			
		||||
    ]
 | 
			
		||||
    assert Hashtag.hashtags_from_content("#hashtag.") == ["hashtag"]
 | 
			
		||||
    assert Hashtag.hashtags_from_content("More text\n#one # two ##three #hashtag;") == [
 | 
			
		||||
        "hashtag",
 | 
			
		||||
        "one",
 | 
			
		||||
        "three",
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_linkify_hashtag():
 | 
			
		||||
    linkify = Hashtag.linkify_hashtags
 | 
			
		||||
 | 
			
		||||
    assert linkify("# hashtag") == "# hashtag"
 | 
			
		||||
    assert (
 | 
			
		||||
        linkify('<a href="/url/with#anchor">Text</a>')
 | 
			
		||||
        == '<a href="/url/with#anchor">Text</a>'
 | 
			
		||||
    )
 | 
			
		||||
    assert (
 | 
			
		||||
        linkify("#HashTag") == '<a class="hashtag" href="/tags/hashtag/">#HashTag</a>'
 | 
			
		||||
    )
 | 
			
		||||
    assert (
 | 
			
		||||
        linkify(
 | 
			
		||||
            """A longer text #bigContent
 | 
			
		||||
with #tags, linebreaks, and
 | 
			
		||||
maybe a few <a href="https://awesome.sauce/about#spicy">links</a>
 | 
			
		||||
#allTheTags #AllTheTags #ALLTHETAGS"""
 | 
			
		||||
        )
 | 
			
		||||
        == """A longer text <a class="hashtag" href="/tags/bigcontent/">#bigContent</a>
 | 
			
		||||
with <a class="hashtag" href="/tags/tags/">#tags</a>, linebreaks, and
 | 
			
		||||
maybe a few <a href="https://awesome.sauce/about#spicy">links</a>
 | 
			
		||||
<a class="hashtag" href="/tags/allthetags/">#allTheTags</a> <a class="hashtag" href="/tags/allthetags/">#AllTheTags</a> <a class="hashtag" href="/tags/allthetags/">#ALLTHETAGS</a>"""
 | 
			
		||||
    )
 | 
			
		||||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ from datetime import timedelta
 | 
			
		|||
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
 | 
			
		||||
from activities.templatetags.activity_tags import timedeltashort
 | 
			
		||||
from activities.templatetags.activity_tags import linkify_hashtags, timedeltashort
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_timedeltashort_regress():
 | 
			
		||||
| 
						 | 
				
			
			@ -19,3 +19,13 @@ def test_timedeltashort_regress():
 | 
			
		|||
    assert timedeltashort(value - timedelta(days=364)) == "364d"
 | 
			
		||||
    assert timedeltashort(value - timedelta(days=365)) == "1y"
 | 
			
		||||
    assert timedeltashort(value - timedelta(days=366)) == "1y"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_linkify_hashtags_regres():
 | 
			
		||||
    assert linkify_hashtags(None) == ""
 | 
			
		||||
    assert linkify_hashtags("") == ""
 | 
			
		||||
 | 
			
		||||
    assert (
 | 
			
		||||
        linkify_hashtags("#Takahe")
 | 
			
		||||
        == '<a class="hashtag" href="/tags/takahe/">#Takahe</a>'
 | 
			
		||||
    )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,12 @@ from users.views.admin.domains import (  # noqa
 | 
			
		|||
    Domains,
 | 
			
		||||
)
 | 
			
		||||
from users.views.admin.federation import FederationEdit, FederationRoot  # noqa
 | 
			
		||||
from users.views.admin.hashtags import (  # noqa
 | 
			
		||||
    HashtagCreate,
 | 
			
		||||
    HashtagDelete,
 | 
			
		||||
    HashtagEdit,
 | 
			
		||||
    Hashtags,
 | 
			
		||||
)
 | 
			
		||||
from users.views.admin.settings import BasicSettings  # noqa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,126 @@
 | 
			
		|||
from django import forms
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect
 | 
			
		||||
from django.utils.decorators import method_decorator
 | 
			
		||||
from django.views.generic import FormView, TemplateView
 | 
			
		||||
 | 
			
		||||
from activities.models import Hashtag, HashtagStates
 | 
			
		||||
from users.decorators import admin_required
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@method_decorator(admin_required, name="dispatch")
 | 
			
		||||
class Hashtags(TemplateView):
 | 
			
		||||
 | 
			
		||||
    template_name = "admin/hashtags.html"
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self):
 | 
			
		||||
        return {
 | 
			
		||||
            "hashtags": Hashtag.objects.filter().order_by("hashtag"),
 | 
			
		||||
            "section": "hashtag",
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@method_decorator(admin_required, name="dispatch")
 | 
			
		||||
class HashtagCreate(FormView):
 | 
			
		||||
 | 
			
		||||
    template_name = "admin/hashtag_create.html"
 | 
			
		||||
    extra_context = {"section": "hashtags"}
 | 
			
		||||
 | 
			
		||||
    class form_class(forms.Form):
 | 
			
		||||
        hashtag = forms.SlugField(
 | 
			
		||||
            help_text="The hashtag without the '#'",
 | 
			
		||||
        )
 | 
			
		||||
        name_override = forms.CharField(
 | 
			
		||||
            help_text="Optional - a more human readable hashtag.",
 | 
			
		||||
            required=False,
 | 
			
		||||
        )
 | 
			
		||||
        public = forms.NullBooleanField(
 | 
			
		||||
            help_text="Should this hashtag appear in the UI",
 | 
			
		||||
            widget=forms.Select(
 | 
			
		||||
                choices=[(None, "Unreviewed"), (True, "Public"), (False, "Private")]
 | 
			
		||||
            ),
 | 
			
		||||
            required=False,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        def clean_hashtag(self):
 | 
			
		||||
            hashtag = self.cleaned_data["hashtag"].lstrip("#").lower()
 | 
			
		||||
            if not Hashtag.hashtag_regex.match("#" + hashtag):
 | 
			
		||||
                raise forms.ValidationError("This does not look like a hashtag name")
 | 
			
		||||
            if Hashtag.objects.filter(hashtag=hashtag):
 | 
			
		||||
                raise forms.ValidationError("This hashtag name is already in use")
 | 
			
		||||
            return hashtag
 | 
			
		||||
 | 
			
		||||
        def clean_name_override(self):
 | 
			
		||||
            name_override = self.cleaned_data["name_override"]
 | 
			
		||||
            if not name_override:
 | 
			
		||||
                return None
 | 
			
		||||
            if self.cleaned_data["hashtag"] != name_override.lower():
 | 
			
		||||
                raise forms.ValidationError(
 | 
			
		||||
                    "Name override doesn't match hashtag. Only case changes are allowed."
 | 
			
		||||
                )
 | 
			
		||||
            return self.cleaned_data["name_override"]
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        Hashtag.objects.create(
 | 
			
		||||
            hashtag=form.cleaned_data["hashtag"],
 | 
			
		||||
            name_override=form.cleaned_data["name_override"] or None,
 | 
			
		||||
            public=form.cleaned_data["public"],
 | 
			
		||||
        )
 | 
			
		||||
        return redirect(Hashtag.urls.root)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@method_decorator(admin_required, name="dispatch")
 | 
			
		||||
class HashtagEdit(FormView):
 | 
			
		||||
 | 
			
		||||
    template_name = "admin/hashtag_edit.html"
 | 
			
		||||
    extra_context = {"section": "hashtags"}
 | 
			
		||||
 | 
			
		||||
    class form_class(HashtagCreate.form_class):
 | 
			
		||||
        def __init__(self, *args, **kwargs):
 | 
			
		||||
            super().__init__(*args, **kwargs)
 | 
			
		||||
            self.fields["hashtag"].disabled = True
 | 
			
		||||
 | 
			
		||||
        def clean_hashtag(self):
 | 
			
		||||
            return self.cleaned_data["hashtag"]
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, hashtag):
 | 
			
		||||
        self.hashtag = get_object_or_404(Hashtag.objects, hashtag=hashtag)
 | 
			
		||||
        return super().dispatch(request)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, *args, **kwargs):
 | 
			
		||||
        context = super().get_context_data(*args, **kwargs)
 | 
			
		||||
        context["hashtag"] = self.hashtag
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        self.hashtag.public = form.cleaned_data["public"]
 | 
			
		||||
        self.hashtag.name_override = form.cleaned_data["name_override"]
 | 
			
		||||
        self.hashtag.save()
 | 
			
		||||
        Hashtag.transition_perform(self.hashtag, HashtagStates.outdated)
 | 
			
		||||
        return redirect(Hashtag.urls.root)
 | 
			
		||||
 | 
			
		||||
    def get_initial(self):
 | 
			
		||||
        return {
 | 
			
		||||
            "hashtag": self.hashtag.hashtag,
 | 
			
		||||
            "name_override": self.hashtag.name_override,
 | 
			
		||||
            "public": self.hashtag.public,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@method_decorator(admin_required, name="dispatch")
 | 
			
		||||
class HashtagDelete(TemplateView):
 | 
			
		||||
 | 
			
		||||
    template_name = "admin/hashtag_delete.html"
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, hashtag):
 | 
			
		||||
        self.hashtag = get_object_or_404(Hashtag.objects, hashtag=hashtag)
 | 
			
		||||
        return super().dispatch(request)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self):
 | 
			
		||||
        return {
 | 
			
		||||
            "hashtag": self.hashtag,
 | 
			
		||||
            "section": "hashtags",
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def post(self, request):
 | 
			
		||||
        self.hashtag.delete()
 | 
			
		||||
        return redirect("admin_hashtags")
 | 
			
		||||
| 
						 | 
				
			
			@ -80,6 +80,10 @@ class BasicSettings(AdminSettingsPage):
 | 
			
		|||
            "help_text": "Usernames that only admins can register for identities. One per line.",
 | 
			
		||||
            "display": "textarea",
 | 
			
		||||
        },
 | 
			
		||||
        "hashtag_unreviewed_are_public": {
 | 
			
		||||
            "title": "Unreviewed Hashtags Are Public",
 | 
			
		||||
            "help_text": "Public Hashtags may appear in Trending and have a Tags timeline",
 | 
			
		||||
        },
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    layout = {
 | 
			
		||||
| 
						 | 
				
			
			@ -91,7 +95,11 @@ class BasicSettings(AdminSettingsPage):
 | 
			
		|||
            "highlight_color",
 | 
			
		||||
        ],
 | 
			
		||||
        "Signups": ["signup_allowed", "signup_invite_only", "signup_text"],
 | 
			
		||||
        "Posts": ["post_length", "content_warning_text"],
 | 
			
		||||
        "Posts": [
 | 
			
		||||
            "post_length",
 | 
			
		||||
            "content_warning_text",
 | 
			
		||||
            "hashtag_unreviewed_are_public",
 | 
			
		||||
        ],
 | 
			
		||||
        "Identities": [
 | 
			
		||||
            "identity_max_per_user",
 | 
			
		||||
            "identity_min_length",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Ładowanie…
	
		Reference in New Issue