Implement server announcements

Fixes #377
pull/411/head
Andrew Godwin 2023-01-13 15:54:21 -07:00
rodzic 81fa9a6d34
commit 8b3106b852
32 zmienionych plików z 558 dodań i 95 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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 100644
Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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