diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index cef045d867bd838acc8443c17eb695a48e90b31a..0daff5fd91fe2c45452025dd4d1e99bb7246953e 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -1,10 +1,17 @@
+from datetime import time
+from typing import Optional
+
 from django import forms
 from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
+from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
 
 from django_select2.forms import ModelSelect2MultipleWidget, Select2Widget
+from material import Layout, Fieldset, Row
 
-from .models import Group, Person, School, SchoolTerm
+from .models import Group, Person, School, SchoolTerm, Announcement, AnnouncementRecipient
 
 
 class PersonAccountForm(forms.ModelForm):
@@ -132,3 +139,105 @@ class EditTermForm(forms.ModelForm):
     class Meta:
         model = SchoolTerm
         fields = ["caption", "date_start", "date_end"]
+
+
+class AnnouncementForm(forms.ModelForm):
+    valid_from = forms.DateTimeField(required=False)
+    valid_until = forms.DateTimeField(required=False)
+
+    valid_from_date = forms.DateField(label=_("Date"))
+    valid_from_time = forms.TimeField(label=_("Time"))
+
+    valid_until_date = forms.DateField(label=_("Date"))
+    valid_until_time = forms.TimeField(label=_("Time"))
+
+    persons = forms.ModelMultipleChoiceField(Person.objects.all(), label=_("Persons"), required=False)
+    groups = forms.ModelMultipleChoiceField(Group.objects.all(), label=_("Groups"), required=False)
+
+    layout = Layout(
+        Fieldset(
+            _("From when until when should the announcement be displayed?"),
+            Row("valid_from_date", "valid_from_time", "valid_until_date", "valid_until_time"),
+        ),
+        Fieldset(_("Who should see the announcement?"), Row("groups", "persons")),
+        Fieldset(_("Write your announcement:"), "title", "description"),
+    )
+
+    def __init__(self, *args, **kwargs):
+        if "instance" not in kwargs:
+            kwargs["initial"] = {
+                "valid_from_date": timezone.datetime.now(),
+                "valid_from_time": time(0, 0),
+                "valid_until_date": timezone.datetime.now(),
+                "valid_until_time": time(23, 59),
+            }
+        else:
+            announcement = kwargs["instance"]
+
+            # Fill special fields from given announcement instance
+            kwargs["initial"] = {
+                "valid_from_date": announcement.valid_from.date(),
+                "valid_from_time": announcement.valid_from.time(),
+                "valid_until_date": announcement.valid_until.date(),
+                "valid_until_time": announcement.valid_until.time(),
+                "groups": announcement.get_recipients_for_model(Group),
+                "persons": announcement.get_recipients_for_model(Person),
+            }
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        data = super().clean()
+
+        # Check date and time
+        from_date = data["valid_from_date"]
+        from_time = data["valid_from_time"]
+        until_date = data["valid_until_date"]
+        until_time = data["valid_until_time"]
+
+        valid_from = timezone.datetime.combine(from_date, from_time)
+        valid_until = timezone.datetime.combine(until_date, until_time)
+
+        if valid_until < timezone.datetime.now():
+            raise ValidationError(
+                _("You are not allowed to create announcements which are only valid in the past.")
+            )
+        elif valid_from > valid_until:
+            raise ValidationError(
+                _("The from date and time must be earlier then the until date and time.")
+            )
+
+        data["valid_from"] = valid_from
+        data["valid_until"] = valid_until
+
+        # Check recipients
+        if "groups" not in data and "persons" not in data:
+            raise ValidationError(_("You need at least one recipient."))
+
+        recipients = []
+        recipients += data.get("groups", [])
+        recipients += data.get("persons", [])
+
+        data["recipients"] = recipients
+
+        return data
+
+    def save(self, _=False):
+        # Save announcement
+        a = self.instance if self.instance is not None else Announcement()
+        a.valid_from = self.cleaned_data["valid_from"]
+        a.valid_until = self.cleaned_data["valid_until"]
+        a.title = self.cleaned_data["title"]
+        a.description = self.cleaned_data["description"]
+        a.save()
+
+        # Save recipients
+        a.recipients.all().delete()
+        for recipient in self.cleaned_data["recipients"]:
+            a.recipients.create(recipient=recipient)
+        a.save()
+
+        return a
+
+    class Meta:
+        model = Announcement
+        exclude = []
diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index 1c41dbb704847658c4ae91e4e060ff54bc1548b0..0fdada99f18cf38b1dae32bf55848e4422cbd838 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -57,6 +57,15 @@ MENUS = {
                 "menu_generator.validators.is_superuser",
             ],
             "submenu": [
+                {
+                    "name": _("Announcements"),
+                    "url": "announcements",
+                    "icon": "announcement",
+                    "validators": [
+                        "menu_generator.validators.is_authenticated",
+                        "menu_generator.validators.is_superuser",
+                    ],
+                },
                 {
                     "name": _("Data management"),
                     "url": "data_management",
diff --git a/aleksis/core/migrations/0013_multiple_recipients_announcement.py b/aleksis/core/migrations/0013_multiple_recipients_announcement.py
new file mode 100644
index 0000000000000000000000000000000000000000..cdb9eae7d6769f0cd72ad5f56c0c5a12ffc2521a
--- /dev/null
+++ b/aleksis/core/migrations/0013_multiple_recipients_announcement.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.0.3 on 2020-02-19 18:14
+
+import aleksis.core.models
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('core', '0012_announcement'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='announcement',
+            options={'verbose_name': 'Announcement', 'verbose_name_plural': 'Announcements'},
+        ),
+        migrations.RemoveField(
+            model_name='announcement',
+            name='content_type',
+        ),
+        migrations.RemoveField(
+            model_name='announcement',
+            name='recipient_id',
+        ),
+        migrations.AlterField(
+            model_name='announcement',
+            name='description',
+            field=models.TextField(blank=True, max_length=500, verbose_name='Description'),
+        ),
+        migrations.AlterField(
+            model_name='announcement',
+            name='valid_until',
+            field=models.DateTimeField(default=aleksis.core.models.now_tomorrow, verbose_name='Date and time until when to show'),
+        ),
+        migrations.CreateModel(
+            name='AnnouncementRecipient',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('recipient_id', models.PositiveIntegerField()),
+                ('announcement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='core.Announcement')),
+                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+            ],
+        ),
+        migrations.AlterModelOptions(
+            name='announcementrecipient',
+            options={'verbose_name': 'Announcement recipient', 'verbose_name_plural': 'Announcement recipients'},
+        ),
+    ]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 9f001cc714584c9c4694d44b5f53f72971f3f6b9..aef59f28a6d81af6a0a970bdc90eedfdfe05f501 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -330,6 +330,12 @@ class Announcement(ExtensibleModel):
             persons += recipient.persons
         return persons
 
+    def get_recipients_for_model(self, obj: Union[models.Model]) -> Sequence[models.Model]:
+        """ Get all recipients for this announcement with a special content type (provided through model) """
+
+        ct = ContentType.objects.get_for_model(obj)
+        return [r.recipient for r in self.recipients.filter(content_type=ct)]
+
     def __str__(self):
         return self.title
 
diff --git a/aleksis/core/templates/core/announcement/form.html b/aleksis/core/templates/core/announcement/form.html
new file mode 100644
index 0000000000000000000000000000000000000000..0cd31cdefc11e8f1a2d29a30fb72a001357a946f
--- /dev/null
+++ b/aleksis/core/templates/core/announcement/form.html
@@ -0,0 +1,33 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n material_form %}
+
+
+{% block browser_title %}
+  {% if mode == "edit" %}
+    {% blocktrans %}Edit announcement{% endblocktrans %}
+  {% else %}
+    {% blocktrans %}Publish announcement{% endblocktrans %}
+  {% endif %}
+{% endblock %}
+{% block page_title %}
+  {% if mode == "edit" %}
+    {% blocktrans %}Edit announcement{% endblocktrans %}
+  {% else %}
+    {% blocktrans %}Publish new announcement{% endblocktrans %}
+  {% endif %}
+{% endblock %}
+
+{% block content %}
+  <form action="" method="post">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+
+    <button type="submit" class="btn green waves-effect waves-light">
+      <i class="material-icons left">save</i>
+      {% trans "Save und publish announcement" %}
+    </button>
+  </form>
+{% endblock %}
diff --git a/aleksis/core/templates/core/announcement/list.html b/aleksis/core/templates/core/announcement/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..9f84617e255c78b7ee37e502c212e0704aa1da5e
--- /dev/null
+++ b/aleksis/core/templates/core/announcement/list.html
@@ -0,0 +1,56 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n %}
+
+{% block browser_title %}{% blocktrans %}Announcements{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Announcements{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <a class="btn green waves-effect waves-light" href="{% url "add_announcement" %}">
+    <i class="material-icons left">add</i>
+    {% trans "Publish new announcement" %}
+  </a>
+  <table class="highlight">
+    <thead>
+    <tr>
+      <th>{% trans "Title" %}</th>
+      <th>{% trans "Valid from" %}</th>
+      <th>{% trans "Valid until" %}</th>
+      <th>{% trans "Recipients" %}</th>
+      <th>{% trans "Actions" %}</th>
+    </tr>
+    </thead>
+    <tbody>
+    {% for announcement in announcements %}
+      <tr>
+        <td>{{ announcement.title }}</td>
+        <td>{{ announcement.valid_from }}</td>
+        <td>{{ announcement.valid_until }}</td>
+        <td>{{ announcement.recipients.all|join:", " }}</td>
+        <td>
+          <a class="btn-flat waves-effect waves-orange orange-text"
+             href="{% url "edit_announcement" announcement.id %}">
+            <i class="material-icons left">edit</i>
+            {% trans "Edit" %}
+          </a>
+          <form action="{% url "delete_announcement" announcement.id %}" method="post">
+            {% csrf_token %}
+            <button class="btn-flat waves-effect waves-re red-text" type="submit">
+              <i class="material-icons left">delete</i>
+              {% trans "Delete" %}
+            </button>
+          </form>
+        </td>
+      </tr>
+    {% empty %}
+      <tr>
+        <td colspan="5">
+          <p class="flow-text center-align">{% trans "There are no announcements." %}</p>
+        </td>
+      </tr>
+    {% endfor %}
+    </tbody>
+  </table>
+{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 6f1d4275e0fd15f2b1cd315353beb56d219342fd..e5320dd02f05a884a8891c7793305f8c0672d7fc 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -35,6 +35,10 @@ urlpatterns = [
     path("group/<int:id_>/edit", views.edit_group, name="edit_group_by_id"),
     path("", views.index, name="index"),
     path("notifications/mark-read/<int:id_>", views.notification_mark_read, name="notification_mark_read"),
+    path("announcements/", views.announcements, name="announcements"),
+    path("announcement/create/", views.announcement_form, name="add_announcement"),
+    path("announcement/edit/<int:pk>/", views.announcement_form, name="edit_announcement"),
+    path("announcement/delete/<int:pk>/", views.delete_announcement, name="delete_announcement"),
     path("maintenance-mode/", include("maintenance_mode.urls")),
     path("impersonate/", include("impersonate.urls")),
     path("__i18n__/", include("django.conf.urls.i18n")),
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 98db80da4a5d30df58f6bc2a8d3d037f226a0044..68773c76398542863bef6df3316ca3799a643e6a 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -15,6 +15,7 @@ from .forms import (
     EditSchoolForm,
     EditTermForm,
     PersonsAccountsFormSet,
+    AnnouncementForm,
 )
 from .models import Activity, Group, Notification, Person, School, DashboardWidget, Announcement
 from .tables import GroupsTable, PersonsTable
@@ -271,3 +272,51 @@ def notification_mark_read(request: HttpRequest, id_: int) -> HttpResponse:
         raise PermissionDenied(_("You are not allowed to mark notifications from other users as read!"))
 
     return redirect("index")
+
+
+@admin_required
+def announcements(request: HttpRequest) -> HttpResponse:
+    context = {}
+
+    # Get all persons
+    announcements = Announcement.objects.all()
+    context["announcements"] = announcements
+
+    return render(request, "core/announcement/list.html", context)
+
+
+@admin_required
+def announcement_form(request: HttpRequest, pk: Optional[int] = None) -> HttpResponse:
+    context = {}
+
+    if pk:
+        announcement = get_object_or_404(Announcement, pk=pk)
+        form = AnnouncementForm(
+            request.POST or None,
+            instance=announcement
+        )
+        context["mode"] = "edit"
+    else:
+        form = AnnouncementForm(request.POST or None)
+        context["mode"] = "add"
+
+    if request.method == "POST":
+        if form.is_valid():
+            form.save()
+
+            messages.success(request, _("The announcement has been saved."))
+            return redirect("announcements")
+
+    context["form"] = form
+
+    return render(request, "core/announcement/form.html", context)
+
+
+@admin_required
+def delete_announcement(request: HttpRequest, pk: int) -> HttpResponse:
+    if request.method == "POST":
+        announcement = get_object_or_404(Announcement, pk=pk)
+        announcement.delete()
+        messages.success(request, _("The announcement has been deleted."))
+
+    return redirect("announcements")