diff --git a/marietje/announcements/__init__.py b/marietje/announcements/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/marietje/announcements/admin.py b/marietje/announcements/admin.py
new file mode 100644
index 0000000..43cadcb
--- /dev/null
+++ b/marietje/announcements/admin.py
@@ -0,0 +1,16 @@
+from django.contrib import admin
+
+from announcements.models import Announcement
+
+
+@admin.register(Announcement)
+class AnnouncementAdmin(admin.ModelAdmin):
+ """Manage the admin pages for the announcements."""
+
+ list_display = ("title", "since", "until", "visible")
+
+ def visible(self, obj):
+ """Is the object visible."""
+ return obj.is_visible
+
+ visible.boolean = True
diff --git a/marietje/announcements/apps.py b/marietje/announcements/apps.py
new file mode 100644
index 0000000..1a9d940
--- /dev/null
+++ b/marietje/announcements/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class AnnouncementsConfig(AppConfig):
+ """Announcements AppConfig."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "announcements"
diff --git a/marietje/announcements/middleware.py b/marietje/announcements/middleware.py
new file mode 100644
index 0000000..0cf4305
--- /dev/null
+++ b/marietje/announcements/middleware.py
@@ -0,0 +1,43 @@
+from announcements.services import (
+ validate_closed_announcements,
+ sanitize_closed_announcements,
+ encode_closed_announcements,
+)
+from django.apps import apps
+
+
+class ClosedAnnouncementsMiddleware:
+ """Closed Announcements Middleware."""
+
+ def __init__(self, get_response):
+ """Initialize."""
+ self.get_response = get_response
+
+ def __call__(self, request):
+ """Update the closed announcements' cookie."""
+ response = self.get_response(request)
+
+ closed_announcements = validate_closed_announcements(
+ sanitize_closed_announcements(request.COOKIES.get("closed-announcements", None))
+ )
+ response.set_cookie("closed-announcements", encode_closed_announcements(closed_announcements))
+
+ return response
+
+
+class AppAnnouncementMiddleware:
+ """Announcement Middleware."""
+
+ def __init__(self, get_response):
+ """Initialize Announcement Middleware."""
+ self.get_response = get_response
+
+ def __call__(self, request):
+ """Call app announcement function if they exist for gathering all announcements."""
+ announcements = []
+ for app in apps.get_app_configs():
+ if hasattr(app, "announcements") and hasattr(app.announcements, "__call__"):
+ announcements += app.announcements(request)
+ setattr(request, "_app_announcements", announcements)
+
+ return self.get_response(request)
diff --git a/marietje/announcements/migrations/0001_initial.py b/marietje/announcements/migrations/0001_initial.py
new file mode 100644
index 0000000..634ed5f
--- /dev/null
+++ b/marietje/announcements/migrations/0001_initial.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.16 on 2022-12-09 19:50
+
+from django.db import migrations, models
+import django.utils.timezone
+import tinymce.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Announcement',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(help_text='This is not shown on the announcement but can be used as an identifier in the admin area.', max_length=100)),
+ ('content', tinymce.models.HTMLField(max_length=500)),
+ ('since', models.DateTimeField(default=django.utils.timezone.now)),
+ ('until', models.DateTimeField(blank=True, null=True)),
+ ('icon', models.CharField(default='bullhorn', help_text='Font Awesome 6 abbreviation for icon to use.', max_length=150, verbose_name='Font Awesome 6 icon')),
+ ],
+ options={
+ 'ordering': ('-since',),
+ },
+ ),
+ ]
diff --git a/marietje/announcements/migrations/__init__.py b/marietje/announcements/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/marietje/announcements/models.py b/marietje/announcements/models.py
new file mode 100644
index 0000000..f8d947d
--- /dev/null
+++ b/marietje/announcements/models.py
@@ -0,0 +1,47 @@
+from django.db import models
+from django.db.models import Q
+from django.utils import timezone
+
+from tinymce.models import HTMLField
+
+
+class AnnouncementManager(models.Manager):
+ """Announcement Manager."""
+
+ def visible(self):
+ """Get only visible announcements."""
+ return self.get_queryset().filter((Q(until__gt=timezone.now()) | Q(until=None)) & Q(since__lte=timezone.now()))
+
+
+class Announcement(models.Model):
+ """Announcement model."""
+
+ title = models.CharField(
+ max_length=100,
+ help_text="This is not shown on the announcement but can be used as an identifier in the admin area.",
+ )
+ content = HTMLField(blank=False, max_length=500)
+ since = models.DateTimeField(default=timezone.now)
+ until = models.DateTimeField(blank=True, null=True)
+ icon = models.CharField(
+ verbose_name="Font Awesome 6 icon",
+ help_text="Font Awesome 6 abbreviation for icon to use.",
+ max_length=150,
+ default="bullhorn",
+ )
+
+ objects = AnnouncementManager()
+
+ class Meta:
+ """Meta class."""
+
+ ordering = ("-since",)
+
+ def __str__(self):
+ """Cast this object to string."""
+ return self.title
+
+ @property
+ def is_visible(self):
+ """Is this announcement currently visible."""
+ return (self.until is None or self.until > timezone.now()) and self.since <= timezone.now()
diff --git a/marietje/announcements/services.py b/marietje/announcements/services.py
new file mode 100644
index 0000000..cb96da8
--- /dev/null
+++ b/marietje/announcements/services.py
@@ -0,0 +1,33 @@
+import json
+import urllib.parse
+
+from announcements.models import Announcement
+
+
+def sanitize_closed_announcements(closed_announcements) -> list:
+ """Convert a cookie (closed_announcements) to a list of id's of closed announcements."""
+ if closed_announcements is None or not isinstance(closed_announcements, str):
+ return []
+ try:
+ closed_announcements_list = json.loads(urllib.parse.unquote(closed_announcements))
+ except json.JSONDecodeError:
+ return []
+
+ if not isinstance(closed_announcements_list, list):
+ return []
+
+ closed_announcements_list_ints = []
+ for closed_announcement in closed_announcements_list:
+ if isinstance(closed_announcement, int):
+ closed_announcements_list_ints.append(closed_announcement)
+ return closed_announcements_list_ints
+
+
+def validate_closed_announcements(closed_announcements) -> list:
+ """Verify the integers in the list such that the ID's that in the database exist only remain."""
+ return list(Announcement.objects.filter(id__in=closed_announcements).values_list("id", flat=True))
+
+
+def encode_closed_announcements(closed_announcements: list) -> str:
+ """Encode the announcement list in URL encoding."""
+ return urllib.parse.quote(json.dumps(closed_announcements))
diff --git a/marietje/announcements/static/announcements/css/announcements.css b/marietje/announcements/static/announcements/css/announcements.css
new file mode 100644
index 0000000..a376436
--- /dev/null
+++ b/marietje/announcements/static/announcements/css/announcements.css
@@ -0,0 +1,34 @@
+.announcement {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: var(--primary-shade);
+ color: var(--primary-contrast);
+ text-align: center;
+ padding: 0.5rem 1rem;
+}
+
+.announcement .btn-close {
+ color: var(--primary-contrast);
+}
+
+.announcement p {
+ display: inline;
+ margin: 0;
+ color: var(--primary-contrast);
+ font-size: 0.9rem;
+ font-weight: bold;
+}
+
+.announcement a {
+ color: var(--primary-contrast);
+ text-decoration: underline;
+}
+
+.announcement a:hover {
+ color: var(--primary-contrast-hover);
+}
+
+.announcement button {
+ background-size: .6rem;
+}
\ No newline at end of file
diff --git a/marietje/announcements/static/announcements/js/announcements.js b/marietje/announcements/static/announcements/js/announcements.js
new file mode 100644
index 0000000..c3650f2
--- /dev/null
+++ b/marietje/announcements/static/announcements/js/announcements.js
@@ -0,0 +1,53 @@
+const ANNOUNCEMENT_COOKIE = "closed-announcements";
+
+function safeGetCookie() {
+ try {
+ return getCookie(ANNOUNCEMENT_COOKIE);
+ } catch {
+ return null;
+ }
+}
+
+function sanitizeAnnouncementsArray(closedAnnouncements) {
+ if (closedAnnouncements === null || typeof closedAnnouncements !== 'string') {
+ return [];
+ }
+ else {
+ try {
+ closedAnnouncements = JSON.parse(closedAnnouncements);
+ } catch {
+ return [];
+ }
+ if (! Array.isArray(closedAnnouncements)) {
+ closedAnnouncements = [];
+ }
+
+ for (let i = 0; i < closedAnnouncements.length; i++) {
+ if (!Number.isInteger(closedAnnouncements[i])) {
+ closedAnnouncements.splice(i, 1);
+ }
+ }
+ return closedAnnouncements;
+ }
+}
+function close_announcement(announcementId) {
+ let announcementElement = document.getElementById("announcement-" + announcementId);
+
+ if (announcementElement !== null) {
+ announcementElement.remove();
+ }
+
+ let closedAnnouncements = safeGetCookie();
+
+ if (closedAnnouncements === null) {
+ closedAnnouncements = [];
+ } else {
+ closedAnnouncements = sanitizeAnnouncementsArray(closedAnnouncements);
+ }
+
+ if (!closedAnnouncements.includes(announcementId)) {
+ closedAnnouncements.push(announcementId);
+ }
+
+ setListCookie(ANNOUNCEMENT_COOKIE, closedAnnouncements, 31);
+}
diff --git a/marietje/announcements/templates/announcements/announcements.html b/marietje/announcements/templates/announcements/announcements.html
new file mode 100644
index 0000000..872e18b
--- /dev/null
+++ b/marietje/announcements/templates/announcements/announcements.html
@@ -0,0 +1,15 @@
+{% load bleach_tags %}
+
+{% for announcement in announcements %}
+