diff --git a/static/css/style.css b/static/css/style.css index c4de68b..2888880 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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 { diff --git a/takahe/settings.py b/takahe/settings.py index 178583b..258898d 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -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", ], }, }, diff --git a/takahe/urls.py b/takahe/urls.py index d4e7ff5..aeb8443 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -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//enable/", admin.EmojiEnable.as_view()), - path("admin/emoji//disable/", admin.EmojiEnable.as_view(enable=False)), - path("admin/emoji//delete/", admin.EmojiDelete.as_view()), + path("admin/emoji//enable/", admin.EmojiEnable.as_view()), + path("admin/emoji//disable/", admin.EmojiEnable.as_view(enable=False)), + path("admin/emoji//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//", + admin.AnnouncementEdit.as_view(), + ), + path( + "admin/announcements//delete/", + admin.AnnouncementDelete.as_view(), + ), + path( + "admin/announcements//publish/", + admin.AnnouncementPublish.as_view(), + ), + path( + "admin/announcements//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//dismiss/", announcements.AnnouncementDismiss.as_view()), # Debug aids path("debug/json/", debug.JsonViewer.as_view()), path("debug/404/", debug.NotFound.as_view()), diff --git a/templates/_announcements.html b/templates/_announcements.html new file mode 100644 index 0000000..fd60116 --- /dev/null +++ b/templates/_announcements.html @@ -0,0 +1,6 @@ +{% for announcement in announcements %} +
+ + {{ announcement.html }} +
+{% endfor %} diff --git a/templates/activities/home.html b/templates/activities/home.html index 497295a..6c20661 100644 --- a/templates/activities/home.html +++ b/templates/activities/home.html @@ -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 %} diff --git a/templates/activities/local.html b/templates/activities/local.html index 84895ee..5a9b6a4 100644 --- a/templates/activities/local.html +++ b/templates/activities/local.html @@ -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 %} diff --git a/templates/activities/notifications.html b/templates/activities/notifications.html index 9103b16..d194d4c 100644 --- a/templates/activities/notifications.html +++ b/templates/activities/notifications.html @@ -3,6 +3,10 @@ {% block title %}Notifications{% endblock %} {% block content %} + {% if page_obj.number == 1 %} + {% include "_announcements.html" %} + {% endif %} +
{% if notification_options.followed %} Followers diff --git a/templates/admin/_pagination.html b/templates/admin/_pagination.html new file mode 100644 index 0000000..3837a49 --- /dev/null +++ b/templates/admin/_pagination.html @@ -0,0 +1,13 @@ +{% load activity_tags %} + + diff --git a/templates/admin/announcement_create.html b/templates/admin/announcement_create.html new file mode 100644 index 0000000..c89e521 --- /dev/null +++ b/templates/admin/announcement_create.html @@ -0,0 +1,23 @@ +{% extends "settings/base.html" %} + +{% block subtitle %}Create Announcement{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+ Announcement + {% include "forms/_field.html" with field=form.text %} +
+
+ Visibility + {% include "forms/_field.html" with field=form.published %} + {% include "forms/_field.html" with field=form.start %} + {% include "forms/_field.html" with field=form.end %} +
+
+ Back + +
+
+{% endblock %} diff --git a/templates/admin/announcement_delete.html b/templates/admin/announcement_delete.html new file mode 100644 index 0000000..3a9ba1a --- /dev/null +++ b/templates/admin/announcement_delete.html @@ -0,0 +1,21 @@ +{% extends "base_plain.html" %} + +{% block title %}Delete Announcement - Admin{% endblock %} + +{% block content %} +

Confirm Delete

+
+
+ {% csrf_token %} + +

Do you want to delete this announcement?

+ +
{{ announcement.html }}
+ +
+ Cancel + +
+
+ +{% endblock %} diff --git a/templates/admin/announcement_edit.html b/templates/admin/announcement_edit.html new file mode 100644 index 0000000..426ad1c --- /dev/null +++ b/templates/admin/announcement_edit.html @@ -0,0 +1,24 @@ +{% extends "settings/base.html" %} + +{% block subtitle %}Announcement #{{ announcement.pk }}{% endblock %} + +{% block content %} + + {% csrf_token %} +
+ Announcement + {% include "forms/_field.html" with field=form.text %} +
+
+ Visibility + {% include "forms/_field.html" with field=form.published %} + {% include "forms/_field.html" with field=form.start %} + {% include "forms/_field.html" with field=form.end %} +
+
+ Back + Delete + +
+ +{% endblock %} diff --git a/templates/admin/announcements.html b/templates/admin/announcements.html new file mode 100644 index 0000000..b93ffab --- /dev/null +++ b/templates/admin/announcements.html @@ -0,0 +1,49 @@ +{% extends "settings/base.html" %} + +{% block subtitle %}Announcements{% endblock %} + +{% block content %} +
+ Create +
+ + {% for announcement in page_obj %} + + + + + + {% empty %} + + {% endfor %} +
+ + + + + {{ announcement.html|truncatewords_html:"10" }} + + {% if announcement.service_announcement %}{{ domain.service_domain }}{% endif %} + + + + {% if not announcement.published %} + Draft + {% elif not announcement.after_start %} + Awaiting Start + {% elif not announcement.before_end %} + Past End + {% else %} + Visible + {% endif %} + State + + {% if not announcement.published %} + + {% else %} + + {% endif %} + +
You have no announcements.
+ {% include "admin/_pagination.html" with nouns="announcement,announcements" %} +{% endblock %} diff --git a/templates/admin/emoji.html b/templates/admin/emoji.html index 2792988..dda415b 100644 --- a/templates/admin/emoji.html +++ b/templates/admin/emoji.html @@ -53,15 +53,5 @@ {% endfor %} - + {% include "admin/_pagination.html" with nouns="emoji,emoji" %} {% endblock %} diff --git a/templates/admin/federation.html b/templates/admin/federation.html index b176d65..442958f 100644 --- a/templates/admin/federation.html +++ b/templates/admin/federation.html @@ -42,15 +42,5 @@ {% endfor %} - + {% include "admin/_pagination.html" with nouns="domain,domains" %} {% endblock %} diff --git a/templates/admin/hashtags.html b/templates/admin/hashtags.html index 14d5d9a..13bd804 100644 --- a/templates/admin/hashtags.html +++ b/templates/admin/hashtags.html @@ -50,15 +50,5 @@ {% endfor %} - + {% include "admin/_pagination.html" with nouns="hashtag,hashtags" %} {% endblock %} diff --git a/templates/admin/identities.html b/templates/admin/identities.html index 898cfc4..3e41332 100644 --- a/templates/admin/identities.html +++ b/templates/admin/identities.html @@ -69,15 +69,5 @@ {% endfor %} - + {% include "admin/_pagination.html" with nouns="identity,identities" %} {% endblock %} diff --git a/templates/admin/invites.html b/templates/admin/invites.html index 7f26bf0..a94661f 100644 --- a/templates/admin/invites.html +++ b/templates/admin/invites.html @@ -51,15 +51,5 @@ {% endfor %} - + {% include "admin/_pagination.html" with nouns="invite,invites" %} {% endblock %} diff --git a/templates/admin/reports.html b/templates/admin/reports.html index c66bf74..453f84b 100644 --- a/templates/admin/reports.html +++ b/templates/admin/reports.html @@ -44,15 +44,5 @@ {% endfor %} - + {% include "admin/_pagination.html" with nouns="report,reports" %} {% endblock %} diff --git a/templates/admin/users.html b/templates/admin/users.html index 500d0f2..d49c5a7 100644 --- a/templates/admin/users.html +++ b/templates/admin/users.html @@ -44,15 +44,5 @@ {% endfor %} - + {% include "admin/_pagination.html" with nouns="user,users" %} {% endblock %} diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html index d0aa62f..3c7878f 100644 --- a/templates/settings/_menu.html +++ b/templates/settings/_menu.html @@ -39,6 +39,9 @@ Policies + + Announcements + Domains diff --git a/users/admin.py b/users/admin.py index 1d7f9a0..6fd91fb 100644 --- a/users/admin.py +++ b/users/admin.py @@ -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"] diff --git a/users/context.py b/users/context.py new file mode 100644 index 0000000..59504f8 --- /dev/null +++ b/users/context.py @@ -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() + ) + } diff --git a/users/migrations/0011_announcement.py b/users/migrations/0011_announcement.py new file mode 100644 index 0000000..036d516 --- /dev/null +++ b/users/migrations/0011_announcement.py @@ -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), + ), + ], + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index 4e271ba..3f146ec 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -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 diff --git a/users/models/announcement.py b/users/models/announcement.py new file mode 100644 index 0000000..cc55fd2 --- /dev/null +++ b/users/models/announcement.py @@ -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: 2023-01-01 or 2023-01-01 12:30:00", + ) + end = models.DateTimeField( + null=True, + blank=True, + help_text="When the announcement will stop appearing.\nLeave blank to have it display indefinitely.\nFormat: 2023-01-01 or 2023-01-01 12:30:00", + ) + + 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 diff --git a/users/services/__init__.py b/users/services/__init__.py index ff392fb..36775a6 100644 --- a/users/services/__init__.py +++ b/users/services/__init__.py @@ -1 +1,2 @@ +from .announcement import AnnouncementService # noqa from .identity import IdentityService # noqa diff --git a/users/services/announcement.py b/users/services/announcement.py new file mode 100644 index 0000000..b7ba6d0 --- /dev/null +++ b/users/services/announcement.py @@ -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) diff --git a/users/views/admin/__init__.py b/users/views/admin/__init__.py index 1a7aee5..7a8f5ca 100644 --- a/users/views/admin/__init__.py +++ b/users/views/admin/__init__.py @@ -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, diff --git a/users/views/admin/announcements.py b/users/views/admin/announcements.py new file mode 100644 index 0000000..5988f23 --- /dev/null +++ b/users/views/admin/announcements.py @@ -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() diff --git a/users/views/admin/emoji.py b/users/views/admin/emoji.py index f9c39c9..522e692 100644 --- a/users/views/admin/emoji.py +++ b/users/views/admin/emoji.py @@ -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() diff --git a/users/views/admin/generic.py b/users/views/admin/generic.py new file mode 100644 index 0000000..a0b66c6 --- /dev/null +++ b/users/views/admin/generic.py @@ -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() diff --git a/users/views/announcements.py b/users/views/announcements.py new file mode 100644 index 0000000..b85f182 --- /dev/null +++ b/users/views/announcements.py @@ -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("")