Implement server announcements

Fixes #377
This commit is contained in:
Andrew Godwin 2023-01-13 15:54:21 -07:00
parent 81fa9a6d34
commit 8b3106b852
32 changed files with 558 additions and 95 deletions

View file

@ -1115,6 +1115,11 @@ form .option-row .right button {
margin-right: 5px;
}
blockquote {
padding-left: 20px;
border-left: 2px solid var(--color-bg-menu);
}
/* Logged out homepage */
@ -1309,6 +1314,23 @@ table.metadata td .emoji {
min-width: 16px;
}
/* Announcements */
.announcement {
background-color: var(--color-highlight);
border-radius: 5px;
margin: 0 0 20px 0;
padding: 5px 30px 5px 8px;
position: relative;
}
.announcement .dismiss {
position: absolute;
top: 5px;
right: 10px;
cursor: pointer;
}
/* Identity banner */
.identity-banner {

View file

@ -232,6 +232,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"core.context.config_context",
"users.context.user_context",
],
},
},

View file

@ -7,7 +7,15 @@ from api.views import api_router, oauth
from core import views as core
from mediaproxy import views as mediaproxy
from stator import views as stator
from users.views import activitypub, admin, auth, identity, report, settings
from users.views import (
activitypub,
admin,
announcements,
auth,
identity,
report,
settings,
)
urlpatterns = [
path("", core.homepage),
@ -162,9 +170,35 @@ urlpatterns = [
admin.EmojiCreate.as_view(),
name="admin_emoji_create",
),
path("admin/emoji/<id>/enable/", admin.EmojiEnable.as_view()),
path("admin/emoji/<id>/disable/", admin.EmojiEnable.as_view(enable=False)),
path("admin/emoji/<id>/delete/", admin.EmojiDelete.as_view()),
path("admin/emoji/<pk>/enable/", admin.EmojiEnable.as_view()),
path("admin/emoji/<pk>/disable/", admin.EmojiEnable.as_view(enable=False)),
path("admin/emoji/<pk>/delete/", admin.EmojiDelete.as_view()),
path(
"admin/announcements/",
admin.AnnouncementsRoot.as_view(),
name="admin_announcements",
),
path(
"admin/announcements/create/",
admin.AnnouncementCreate.as_view(),
name="admin_announcement_create",
),
path(
"admin/announcements/<pk>/",
admin.AnnouncementEdit.as_view(),
),
path(
"admin/announcements/<pk>/delete/",
admin.AnnouncementDelete.as_view(),
),
path(
"admin/announcements/<pk>/publish/",
admin.AnnouncementPublish.as_view(),
),
path(
"admin/announcements/<pk>/unpublish/",
admin.AnnouncementUnpublish.as_view(),
),
path(
"admin/stator/",
admin.Stator.as_view(),
@ -222,6 +256,8 @@ urlpatterns = [
core.FlatPage.as_view(title="Server Rules", config_option="policy_rules"),
name="rules",
),
# Annoucements
path("announcements/<id>/dismiss/", announcements.AnnouncementDismiss.as_view()),
# Debug aids
path("debug/json/", debug.JsonViewer.as_view()),
path("debug/404/", debug.NotFound.as_view()),

View file

@ -0,0 +1,6 @@
{% for announcement in announcements %}
<div class="announcement">
<a hx-post="{{ announcement.urls.dismiss }}" hx-target="closest .announcement" hx-swap="delete" class="dismiss" title="Dismiss announcement"><i class="fa-solid fa-xmark"></i></a>
{{ announcement.html }}
</div>
{% endfor %}

View file

@ -4,6 +4,9 @@
{% block title %}Home{% endblock %}
{% block content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
{% for event in page_obj %}
{% if event.type == "post" %}
{% include "activities/_post.html" with post=event.subject_post %}

View file

@ -3,6 +3,10 @@
{% block title %}Local Timeline{% endblock %}
{% block content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
{% for post in page_obj %}
{% include "activities/_post.html" with feedindex=forloop.counter %}
{% empty %}

View file

@ -3,6 +3,10 @@
{% block title %}Notifications{% endblock %}
{% block content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
<div class="view-options">
{% if notification_options.followed %}
<a href=".?followed=false" class="selected"><i class="fa-solid fa-check"></i> Followers</a>

View file

@ -0,0 +1,13 @@
{% load activity_tags %}
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a>
{% endif %}
{% if page_obj.paginator.count %}
<span class="count">{{ page_obj.paginator.count }} {{page_obj.paginator.count|pluralize:nouns }}</span>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}">Next Page</a>
{% endif %}
</div>

View file

@ -0,0 +1,23 @@
{% extends "settings/base.html" %}
{% block subtitle %}Create Announcement{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Announcement</legend>
{% include "forms/_field.html" with field=form.text %}
</fieldset>
<fieldset>
<legend>Visibility</legend>
{% include "forms/_field.html" with field=form.published %}
{% include "forms/_field.html" with field=form.start %}
{% include "forms/_field.html" with field=form.end %}
</fieldset>
<div class="buttons">
<a href="{{ announcement.urls.admin_root }}" class="button secondary left">Back</a>
<button>Create</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends "base_plain.html" %}
{% block title %}Delete Announcement - Admin{% endblock %}
{% block content %}
<h1>Confirm Delete</h1>
<section class="">
<form action="." method="POST">
{% csrf_token %}
<p>Do you want to delete this announcement?</p>
<blockquote>{{ announcement.html }}</blockquote>
<div class="buttons">
<a class="button" href="javascript:history.back()">Cancel</a>
<button class="delete">Delete</button>
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "settings/base.html" %}
{% block subtitle %}Announcement #{{ announcement.pk }}{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Announcement</legend>
{% include "forms/_field.html" with field=form.text %}
</fieldset>
<fieldset>
<legend>Visibility</legend>
{% include "forms/_field.html" with field=form.published %}
{% include "forms/_field.html" with field=form.start %}
{% include "forms/_field.html" with field=form.end %}
</fieldset>
<div class="buttons">
<a href="{{ announcement.urls.admin_root }}" class="button secondary left">Back</a>
<a href="{{ announcement.urls.admin_delete }}" class="button delete">Delete</a>
<button>Save</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends "settings/base.html" %}
{% block subtitle %}Announcements{% endblock %}
{% block content %}
<div class="view-options">
<a href="{% url "admin_announcement_create" %}" class="button"><i class="fa-solid fa-plus"></i> Create</a>
</div>
<table class="items">
{% for announcement in page_obj %}
<tr>
<td class="icon">
<a href="{{ announcement.urls.admin_edit }}" class="overlay"></a>
<i class="fa-solid fa-bullhorn"></i>
</td>
<td class="name">
<a href="{{ announcement.urls.admin_edit }}" class="overlay"></a>
{{ announcement.html|truncatewords_html:"10" }}
<small>
{% if announcement.service_announcement %}{{ domain.service_domain }}{% endif %}
</small>
</span>
<td class="stat">
{% if not announcement.published %}
Draft
{% elif not announcement.after_start %}
Awaiting Start
{% elif not announcement.before_end %}
Past End
{% else %}
Visible
{% endif %}
<small>State</small>
</td>
<td class="actions">
{% if not announcement.published %}
<a hx-post="{{ announcement.urls.admin_publish }}" title="Publish"><i class="fa-solid fa-bullhorn"></i></a>
{% else %}
<a hx-post="{{ announcement.urls.admin_unpublish }}" title="Unpublish"><i class="fa-solid fa-rotate-left"></i></a>
{% endif %}
<a href="{{ announcement.urls.admin_delete }}" title="Delete" class="danger"><i class="fa-solid fa-trash"></i></a>
</td>
</tr>
{% empty %}
<tr class="empty"><td>You have no announcements.</td></tr>
{% endfor %}
</table>
{% include "admin/_pagination.html" with nouns="announcement,announcements" %}
{% endblock %}

View file

@ -53,15 +53,5 @@
</tr>
{% endfor %}
</table>
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?{% urlparams page=page_obj.previous_page_number %}">Previous Page</a>
{% endif %}
{% if page_obj.paginator.count %}
<span class="count">{{ page_obj.paginator.count }} emoji</span>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?{% urlparams page=page_obj.next_page_number %}">Next Page</a>
{% endif %}
</div>
{% include "admin/_pagination.html" with nouns="emoji,emoji" %}
{% endblock %}

View file

@ -42,15 +42,5 @@
</tr>
{% endfor %}
</table>
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?{% urlparams page=page_obj.previous_page_number %}">Previous Page</a>
{% endif %}
{% if page_obj.paginator.count %}
<span class="count">{{ page_obj.paginator.count }} domain{{page_obj.paginator.count|pluralize }}</span>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?{% urlparams page=page_obj.next_page_number %}">Next Page</a>
{% endif %}
</div>
{% include "admin/_pagination.html" with nouns="domain,domains" %}
{% endblock %}

View file

@ -50,15 +50,5 @@
</tr>
{% endfor %}
</table>
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a>
{% endif %}
{% if page_obj.paginator.count %}
<span class="count">{{ page_obj.paginator.count }} hashtag{{page_obj.paginator.count|pluralize }}</span>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}">Next Page</a>
{% endif %}
</div>
{% include "admin/_pagination.html" with nouns="hashtag,hashtags" %}
{% endblock %}

View file

@ -69,15 +69,5 @@
</tr>
{% endfor %}
</table>
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?{% urlparams page=page_obj.previous_page_number %}">Previous Page</a>
{% endif %}
{% if page_obj.paginator.count %}
<span class="count">{{ page_obj.paginator.count }} identit{{page_obj.paginator.count|pluralize:"y,ies" }}</span>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?{% urlparams page=page_obj.next_page_number %}">Next Page</a>
{% endif %}
</div>
{% include "admin/_pagination.html" with nouns="identity,identities" %}
{% endblock %}

View file

@ -51,15 +51,5 @@
</tr>
{% endfor %}
</table>
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a>
{% endif %}
{% if page_obj.paginator.count %}
<span class="count">{{ page_obj.paginator.count }} invite{{page_obj.paginator.count|pluralize }}</span>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}">Next Page</a>
{% endif %}
</div>
{% include "admin/_pagination.html" with nouns="invite,invites" %}
{% endblock %}

View file

@ -44,15 +44,5 @@
</tr>
{% endfor %}
</table>
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}{% if all %}&amp;all=true{% endif %}">Previous Page</a>
{% endif %}
{% if page_obj.paginator.count %}
<span class="count">{{ page_obj.paginator.count }} report{{page_obj.paginator.count|pluralize }}</span>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}{% if all %}&amp;all=true{% endif %}">Next Page</a>
{% endif %}
</div>
{% include "admin/_pagination.html" with nouns="report,reports" %}
{% endblock %}

View file

@ -44,15 +44,5 @@
</tr>
{% endfor %}
</table>
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?{% urlparams page=page_obj.previous_page_number %}">Previous Page</a>
{% endif %}
{% if page_obj.paginator.count %}
<span class="count">{{ page_obj.paginator.count }} user{{page_obj.paginator.count|pluralize }}</span>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?{% urlparams page=page_obj.next_page_number %}">Next Page</a>
{% endif %}
</div>
{% include "admin/_pagination.html" with nouns="user,users" %}
{% endblock %}

View file

@ -39,6 +39,9 @@
<a href="{% url "admin_policies" %}" {% if section == "policies" %}class="selected"{% endif %} title="Policies">
<i class="fa-solid fa-file-lines"></i> Policies
</a>
<a href="{% url "admin_announcements" %}" {% if section == "announcements" %}class="selected"{% endif %} title="Announcements">
<i class="fa-solid fa-bullhorn"></i> Announcements
</a>
<a href="{% url "admin_domains" %}" {% if section == "domains" %}class="selected"{% endif %} title="Domains">
<i class="fa-solid fa-globe"></i> Domains
</a>

View file

@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
from activities.admin import IdentityLocalFilter
from users.models import (
Announcement,
Domain,
Follow,
Identity,
@ -197,3 +198,9 @@ class InviteAdmin(admin.ModelAdmin):
@admin.register(Report)
class ReportAdmin(admin.ModelAdmin):
list_display = ["id", "created", "resolved", "type", "subject_identity"]
@admin.register(Announcement)
class AnnouncementAdmin(admin.ModelAdmin):
list_display = ["id", "published", "start", "end", "text"]
raw_id_fields = ["seen"]

11
users/context.py Normal file
View file

@ -0,0 +1,11 @@
from users.services import AnnouncementService
def user_context(request):
return {
"announcements": (
AnnouncementService(request.user).visible()
if request.user.is_authenticated
else AnnouncementService.visible_anonymous()
)
}

View file

@ -0,0 +1,64 @@
# Generated by Django 4.1.4 on 2023-01-13 22:27
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0010_domain_state"),
]
operations = [
migrations.CreateModel(
name="Announcement",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"text",
models.TextField(
help_text="The text of your announcement.\nAccepts Markdown for formatting."
),
),
(
"published",
models.BooleanField(
default=False,
help_text="If this announcement will appear on the site.\nIt must still be between start and end times, if provided.",
),
),
(
"start",
models.DateTimeField(
blank=True,
help_text="When the announcement will start appearing.\nLeave blank to have it begin as soon as it is published.",
null=True,
),
),
(
"end",
models.DateTimeField(
blank=True,
help_text="When the announcement will stop appearing.\nLeave blank to have it display indefinitely.",
null=True,
),
),
("include_unauthenticated", models.BooleanField(default=False)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"seen",
models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
],
),
]

View file

@ -1,3 +1,4 @@
from .announcement import Announcement # noqa
from .block import Block # noqa
from .domain import Domain # noqa
from .follow import Follow, FollowStates # noqa

View file

@ -0,0 +1,63 @@
import markdown_it
import urlman
from django.db import models
from django.utils import timezone
from django.utils.safestring import mark_safe
class Announcement(models.Model):
"""
A server-wide announcement that users all see and can dismiss.
"""
text = models.TextField(
help_text="The text of your announcement.\nAccepts Markdown for formatting."
)
published = models.BooleanField(
default=False,
help_text="If this announcement will appear on the site.\nIt must still be between start and end times, if provided.",
)
start = models.DateTimeField(
null=True,
blank=True,
help_text="When the announcement will start appearing.\nLeave blank to have it begin as soon as it is published.\nFormat: <tt>2023-01-01</tt> or <tt>2023-01-01 12:30:00</tt>",
)
end = models.DateTimeField(
null=True,
blank=True,
help_text="When the announcement will stop appearing.\nLeave blank to have it display indefinitely.\nFormat: <tt>2023-01-01</tt> or <tt>2023-01-01 12:30:00</tt>",
)
include_unauthenticated = models.BooleanField(default=False)
# Note that this is against User, not Identity - it's one of the few places
# where we want it to be per login.
seen = models.ManyToManyField("users.User", blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class urls(urlman.Urls):
dismiss = "/announcements/{self.pk}/dismiss/"
admin_root = "/admin/announcements/"
admin_edit = "{admin_root}{self.pk}/"
admin_delete = "{admin_edit}delete/"
admin_publish = "{admin_root}{self.pk}/publish/"
admin_unpublish = "{admin_root}{self.pk}/unpublish/"
@property
def html(self) -> str:
return mark_safe(markdown_it.MarkdownIt().render(self.text))
@property
def visible(self) -> bool:
return self.published and self.after_start and self.before_end
@property
def after_start(self) -> bool:
return timezone.now() >= self.start if self.start else True
@property
def before_end(self) -> bool:
return timezone.now() <= self.end if self.end else True

View file

@ -1 +1,2 @@
from .announcement import AnnouncementService # noqa
from .identity import IdentityService # noqa

View file

@ -0,0 +1,45 @@
from django.db import models
from django.utils import timezone
from users.models import Announcement, User
class AnnouncementService:
"""
Handles viewing and dismissing announcements
"""
def __init__(self, user: User):
self.user = user
@classmethod
def visible_queryset(cls) -> models.QuerySet[Announcement]:
"""
Common visibility query
"""
now = timezone.now()
return Announcement.objects.filter(
models.Q(start__lte=now) | models.Q(start__isnull=True),
models.Q(end__gte=now) | models.Q(end__isnull=True),
published=True,
).order_by("-start", "-created")
@classmethod
def visible_anonymous(cls) -> models.QuerySet[Announcement]:
"""
Returns all announcements marked as being showable to all visitors
"""
return cls.visible_queryset().filter(include_unauthenticated=True)
def visible(self) -> models.QuerySet[Announcement]:
"""
Returns all announcements that are currently valid and should be shown
to a given user.
"""
return self.visible_queryset().exclude(seen=self.user)
def mark_seen(self, announcement: Announcement):
"""
Marks an announcement as seen by the user
"""
announcement.seen.add(self.user)

View file

@ -2,6 +2,14 @@ from django.utils.decorators import method_decorator
from django.views.generic import RedirectView
from users.decorators import admin_required
from users.views.admin.announcements import ( # noqa
AnnouncementCreate,
AnnouncementDelete,
AnnouncementEdit,
AnnouncementPublish,
AnnouncementsRoot,
AnnouncementUnpublish,
)
from users.views.admin.domains import ( # noqa
DomainCreate,
DomainDelete,

View file

@ -0,0 +1,87 @@
from django import forms
from django.utils.decorators import method_decorator
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from users.decorators import admin_required
from users.models import Announcement
from users.views.admin.generic import HTMXActionView
@method_decorator(admin_required, name="dispatch")
class AnnouncementsRoot(ListView):
template_name = "admin/announcements.html"
paginate_by = 30
def get(self, request, *args, **kwargs):
self.extra_context = {
"section": "announcements",
}
return super().get(request, *args, **kwargs)
def get_queryset(self):
reports = Announcement.objects.order_by("created")
return reports
@method_decorator(admin_required, name="dispatch")
class AnnouncementCreate(CreateView):
model = Announcement
template_name = "admin/announcement_create.html"
extra_context = {"section": "announcements"}
success_url = Announcement.urls.admin_root
class form_class(forms.ModelForm):
class Meta:
model = Announcement
fields = ["text", "published", "start", "end"]
widgets = {
"published": forms.Select(
choices=[(True, "Published"), (False, "Draft")]
)
}
@method_decorator(admin_required, name="dispatch")
class AnnouncementEdit(UpdateView):
model = Announcement
template_name = "admin/announcement_edit.html"
extra_context = {"section": "announcements"}
success_url = Announcement.urls.admin_root
class form_class(AnnouncementCreate.form_class):
pass
@method_decorator(admin_required, name="dispatch")
class AnnouncementDelete(DeleteView):
model = Announcement
template_name = "admin/announcement_delete.html"
success_url = Announcement.urls.admin_root
class AnnouncementPublish(HTMXActionView):
"""
Marks the announcement as published.
"""
model = Announcement
def action(self, announcement: Announcement):
announcement.published = True
announcement.save()
class AnnouncementUnpublish(HTMXActionView):
"""
Marks the announcement as unpublished.
"""
model = Announcement
def action(self, announcement: Announcement):
announcement.published = False
announcement.save()

View file

@ -1,13 +1,13 @@
from django import forms
from django.conf import settings
from django.db import models
from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.generic import FormView, ListView, View
from django_htmx.http import HttpResponseClientRefresh
from django.views.generic import FormView, ListView
from activities.models import Emoji
from users.decorators import moderator_required
from users.views.admin.generic import HTMXActionView
@method_decorator(moderator_required, name="dispatch")
@ -70,27 +70,26 @@ class EmojiCreate(FormView):
@method_decorator(moderator_required, name="dispatch")
class EmojiDelete(View):
class EmojiDelete(HTMXActionView):
"""
Deletes an emoji
"""
def post(self, request, id):
self.emoji = get_object_or_404(Emoji, pk=id)
self.emoji.delete()
return HttpResponseClientRefresh()
model = Emoji
def action(self, emoji: Emoji):
emoji.delete()
@method_decorator(moderator_required, name="dispatch")
class EmojiEnable(View):
class EmojiEnable(HTMXActionView):
"""
Sets an emoji to be enabled (or not!)
"""
model = Emoji
enable = True
def post(self, request, id):
self.emoji = get_object_or_404(Emoji, pk=id)
self.emoji.public = self.enable
self.emoji.save()
return HttpResponseClientRefresh()
def action(self, emoji: Emoji):
emoji.public = self.enable
emoji.save()

View file

@ -0,0 +1,17 @@
from django.views.generic import View
from django.views.generic.detail import SingleObjectMixin
from django_htmx.http import HttpResponseClientRefresh
class HTMXActionView(SingleObjectMixin, View):
"""
Generic view that performs an action when called via HTMX and then causes
a full page refresh.
"""
def post(self, request, pk):
self.action(self.get_object())
return HttpResponseClientRefresh()
def action(self, instance):
raise NotImplementedError()

View file

@ -0,0 +1,21 @@
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.views.generic import View
from users.decorators import identity_required
from users.models import Announcement
from users.services import AnnouncementService
@method_decorator(identity_required, name="dispatch")
class AnnouncementDismiss(View):
"""
Dismisses an announcement for the current user
"""
def post(self, request, id):
announcement = get_object_or_404(Announcement, pk=id)
AnnouncementService(request.user).mark_seen(announcement)
# In the UI we replace it with nothing anyway
return HttpResponse("")