This commit is contained in:
Michael Manfre 2022-11-28 23:41:36 -05:00 committed by GitHub
parent 7f838433ed
commit fb8f2d1098
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 897 additions and 45 deletions

View file

@ -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()

View file

@ -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,
},
),
]

View file

@ -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

View file

@ -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))

View file

@ -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:

View file

@ -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)

View file

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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):

View file

@ -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

View file

@ -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,

View file

@ -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()),

View file

@ -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>

View file

@ -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">

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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>"""
)

View file

@ -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>'
)

View file

@ -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

View file

@ -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")

View file

@ -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",