diff --git a/aleksis/apps/resint/admin.py b/aleksis/apps/resint/admin.py
index 98ac6723126b5eecefe06db48f336bb27abab8b2..2f35aabaa9857262caad319dff5d37286ab05f3b 100644
--- a/aleksis/apps/resint/admin.py
+++ b/aleksis/apps/resint/admin.py
@@ -1,5 +1,6 @@
 from django.contrib import admin
 
-from .models import Poster
+from .models import Poster, PosterGroup
 
+admin.site.register(PosterGroup)
 admin.site.register(Poster)
diff --git a/aleksis/apps/resint/default.odt b/aleksis/apps/resint/default.odt
deleted file mode 100644
index 9b52ee32db4631bc506c29cb64d8ba0ea8c24a6d..0000000000000000000000000000000000000000
Binary files a/aleksis/apps/resint/default.odt and /dev/null differ
diff --git a/aleksis/apps/resint/default.pdf b/aleksis/apps/resint/default.pdf
deleted file mode 100644
index 397f82391e8bad15e66ffc8df6845e5f854948f3..0000000000000000000000000000000000000000
Binary files a/aleksis/apps/resint/default.pdf and /dev/null differ
diff --git a/aleksis/apps/resint/forms.py b/aleksis/apps/resint/forms.py
index e4587daaf49a319a602a0658cd9ffc7232957def..5a0ae9822616b4aa78fb6e4851311ea82432940b 100644
--- a/aleksis/apps/resint/forms.py
+++ b/aleksis/apps/resint/forms.py
@@ -1,31 +1,37 @@
 from django import forms
-from django.core.validators import FileExtensionValidator
-from django.utils import timezone
 
 from material import Layout, Row
 
-from .models import Poster
+from .models import Poster, PosterGroup
 
-current_year = timezone.datetime.now().year
-options_for_year = [(current_year, current_year), (current_year + 1, current_year + 1)]
 
-calendar_weeks = [(cw, str(cw)) for cw in range(1, 53)]
-
-
-class PosterUploadForm(forms.ModelForm):
-    calendar_week = forms.ChoiceField(choices=calendar_weeks, initial=timezone.datetime.now().isocalendar()[1])
-    year = forms.ChoiceField(
-        initial=timezone.datetime.now().year, choices=options_for_year
-    )
-    pdf = forms.FileField(
-        validators=[FileExtensionValidator(allowed_extensions=["pdf"])],
-    )
+class PosterGroupForm(forms.ModelForm):
+    """Form to manage poster groups."""
 
     layout = Layout(
-        Row("calendar_week", "year"),
-        Row("pdf")
+        Row("slug"),
+        Row("name"),
+        Row("publishing_day", "publishing_time"),
+        Row("default_pdf"),
+        Row("show_in_menu", "public"),
     )
 
+    class Meta:
+        model = PosterGroup
+        fields = [
+            "slug",
+            "name",
+            "publishing_day",
+            "publishing_time",
+            "default_pdf",
+            "show_in_menu",
+            "public",
+        ]
+
+
+class PosterUploadForm(forms.ModelForm):
+    """Form for uploading new posters."""
+
     class Meta:
         model = Poster
-        fields = ("calendar_week", "year", "pdf")
+        fields = ["group", "week", "year", "pdf"]
diff --git a/aleksis/apps/resint/menus.py b/aleksis/apps/resint/menus.py
index 799d4a6784998373612145be688f8645509053dc..22c93b0d971fe6b88d4d236ef2a9982d79032d64 100644
--- a/aleksis/apps/resint/menus.py
+++ b/aleksis/apps/resint/menus.py
@@ -1,5 +1,30 @@
+from typing import Any, Dict, List
+
+from django.apps import apps
+from django.urls import reverse
+from django.utils.functional import lazy
 from django.utils.translation import ugettext_lazy as _
 
+
+def _get_menu_entries() -> List[Dict[str, Any]]:
+    """Build menu entries for all poster groups.
+
+    This will include only poster groups where ``show_in_menu`` is enabled.
+    """
+    PosterGroup = apps.get_model("resint", "PosterGroup")
+    return [
+        {
+            "name": group.name,
+            "url": reverse("poster_show_current", args=[group.slug]),
+            "icon": "picture_as_pdf",
+            "validators": ["menu_generator.validators.is_authenticated"],
+        }
+        for group in PosterGroup.objects.filter(show_in_menu=True)
+    ]
+
+
+get_menu_entries_lazy = lazy(_get_menu_entries, list)
+
 MENUS = {
     "NAV_MENU_CORE": [
         {
@@ -7,23 +32,22 @@ MENUS = {
             "url": "#",
             "icon": "open_in_browser",
             "root": True,
-            "validators": [
-                "menu_generator.validators.is_authenticated",
-            ],
+            "validators": ["menu_generator.validators.is_authenticated",],
             "submenu": [
                 {
-                    "name": _("Current poster"),
-                    "url": "poster_show_current",
-                    "icon": "picture_as_pdf",
+                    "name": _("Manage posters"),
+                    "url": "poster_index",
+                    "icon": "file_upload",
                     "validators": ["menu_generator.validators.is_authenticated"],
                 },
                 {
-                    "name": _("Upload poster"),
-                    "url": "poster_index",
-                    "icon": "file_upload",
+                    "name": _("Poster groups"),
+                    "url": "poster_group_list",
+                    "icon": "topic",
                     "validators": ["menu_generator.validators.is_authenticated"],
                 },
-            ],
+            ]
+            + get_menu_entries_lazy(),
         }
     ]
 }
diff --git a/aleksis/apps/resint/migrations/0001_initial.py b/aleksis/apps/resint/migrations/0001_initial.py
index 58c47437aa94116ed6b542c580624c2617242ae5..eb439275a716d70c0da8b4d893da1c6aeba25b47 100644
--- a/aleksis/apps/resint/migrations/0001_initial.py
+++ b/aleksis/apps/resint/migrations/0001_initial.py
@@ -1,8 +1,11 @@
-# Generated by Django 3.0.4 on 2020-03-29 16:02
+# Generated by Django 3.2.4 on 2021-06-30 18:23
 
 import aleksis.apps.resint.models
-import django.contrib.postgres.fields.jsonb
+import calendarweek.calendarweek
+import django.contrib.sites.managers
+import django.core.validators
 from django.db import migrations, models
+import django.db.models.deletion
 
 
 class Migration(migrations.Migration):
@@ -10,22 +13,61 @@ class Migration(migrations.Migration):
     initial = True
 
     dependencies = [
+        ('sites', '0002_alter_domain_unique'),
     ]
 
     operations = [
+        migrations.CreateModel(
+            name='PosterGroup',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('slug', models.SlugField(help_text="If you use 'example', the filename will be 'example.pdf'.", verbose_name='Slug used in URL name')),
+                ('name', models.CharField(max_length=255, verbose_name='Name')),
+                ('publishing_day', models.PositiveSmallIntegerField(choices=[(0, 'Montag'), (1, 'Dienstag'), (2, 'Mittwoch'), (3, 'Donnerstag'), (4, 'Freitag'), (5, 'Samstag'), (6, 'Sonntag')], verbose_name='Publishing weekday')),
+                ('publishing_time', models.TimeField(verbose_name='Publishing time')),
+                ('default_pdf', models.FileField(help_text='This PDF file will be shown if there is no current PDF.', upload_to='default_posters/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf'])], verbose_name='Default PDF')),
+                ('show_in_menu', models.BooleanField(default=True, verbose_name='Show in menu')),
+                ('public', models.BooleanField(default=False, verbose_name='Show for not logged-in users')),
+                ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
+            ],
+            options={
+                'verbose_name': 'Poster group',
+                'verbose_name_plural': 'Poster groups',
+            },
+            managers=[
+                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+            ],
+        ),
         migrations.CreateModel(
             name='Poster',
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('extended_data', django.contrib.postgres.fields.jsonb.JSONField(default=dict, editable=False)),
-                ('calendar_week', models.IntegerField(verbose_name='CW')),
-                ('year', models.IntegerField(verbose_name='Year')),
-                ('pdf', models.FileField(upload_to=aleksis.apps.resint.models.path_and_rename_poster, verbose_name='PDF')),
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('week', models.PositiveSmallIntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5'), (6, '6'), (7, '7'), (8, '8'), (9, '9'), (10, '10'), (11, '11'), (12, '12'), (13, '13'), (14, '14'), (15, '15'), (16, '16'), (17, '17'), (18, '18'), (19, '19'), (20, '20'), (21, '21'), (22, '22'), (23, '23'), (24, '24'), (25, '25'), (26, '26'), (27, '27'), (28, '28'), (29, '29'), (30, '30'), (31, '31'), (32, '32'), (33, '33'), (34, '34'), (35, '35'), (36, '36'), (37, '37'), (38, '38'), (39, '39'), (40, '40'), (41, '41'), (42, '42'), (43, '43'), (44, '44'), (45, '45'), (46, '46'), (47, '47'), (48, '48'), (49, '49'), (50, '50'), (51, '51'), (52, '52')], default=calendarweek.calendarweek.CalendarWeek.current_week, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(53)], verbose_name='Calendar week')),
+                ('year', models.PositiveSmallIntegerField(default=aleksis.apps.resint.models._get_current_year, verbose_name='Year')),
+                ('pdf', models.FileField(upload_to='posters/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf'])], verbose_name='PDF')),
+                ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posters', to='resint.postergroup', verbose_name='Poster group')),
+                ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
             ],
             options={
                 'verbose_name': 'Poster',
                 'verbose_name_plural': 'Posters',
-                'unique_together': {('calendar_week', 'year')},
             },
+            managers=[
+                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name='postergroup',
+            constraint=models.UniqueConstraint(fields=('site_id', 'name'), name='unique_site_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='postergroup',
+            constraint=models.UniqueConstraint(fields=('site_id', 'slug'), name='unique_site_slug'),
+        ),
+        migrations.AddConstraint(
+            model_name='poster',
+            constraint=models.UniqueConstraint(fields=('site_id', 'week', 'year'), name='unique_site_week_year'),
         ),
     ]
diff --git a/aleksis/apps/resint/models.py b/aleksis/apps/resint/models.py
index 53dca0d8f550ceec0a04c4d5a62203c294aa2374..2a84336e1a46614d1833785be62a9be83716f565 100644
--- a/aleksis/apps/resint/models.py
+++ b/aleksis/apps/resint/models.py
@@ -1,23 +1,137 @@
+from datetime import datetime
+from typing import Optional
+
+from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator
 from django.db import models
+from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
+from calendarweek import CalendarWeek
+from calendarweek.django import i18n_day_name_choices_lazy
+
 from aleksis.core.mixins import ExtensibleModel
-from aleksis.core.util.core_helpers import path_and_rename
 
 
-def path_and_rename_poster(instance, filename: str) -> str:
-    return path_and_rename(instance, filename, upload_to="poster")
+class PosterGroup(ExtensibleModel):
+    """Group for time-based documents, called posters."""
+
+    slug = models.SlugField(
+        verbose_name=_("Slug used in URL name"),
+        help_text=_("If you use 'example', the filename will be 'example.pdf'."),
+    )
+    name = models.CharField(max_length=255, verbose_name=_("Name"))
+    publishing_day = models.PositiveSmallIntegerField(
+        verbose_name=_("Publishing weekday"), choices=i18n_day_name_choices_lazy()
+    )
+    publishing_time = models.TimeField(verbose_name=_("Publishing time"))
+    default_pdf = models.FileField(
+        upload_to="default_posters/",
+        verbose_name=_("Default PDF"),
+        help_text=_("This PDF file will be shown if there is no current PDF."),
+        validators=[FileExtensionValidator(allowed_extensions=["pdf"])],
+    )
+
+    show_in_menu = models.BooleanField(default=True, verbose_name=_("Show in menu"))
+    public = models.BooleanField(default=False, verbose_name=_("Show for not logged-in users"))
+
+    class Meta:
+        verbose_name = _("Poster group")
+        verbose_name_plural = _("Poster groups")
+        constraints = [
+            models.UniqueConstraint(fields=["site_id", "name"], name="unique_site_name"),
+            models.UniqueConstraint(fields=["site_id", "slug"], name="unique_site_slug"),
+        ]
+
+    def __str__(self) -> str:
+        return f"{self.name} ({self.publishing_day_name}, {self.publishing_time})"
+
+    @property
+    def publishing_day_name(self) -> str:
+        """Return the full name of the publishing day (e. g. Monday)."""
+        return i18n_day_name_choices_lazy()[self.publishing_day][1]
+
+    @property
+    def filename(self) -> str:
+        """Return the filename for the currently valid PDF file."""
+        return f"{self.slug}.pdf"
+
+    @property
+    def current_poster(self) -> Optional["Poster"]:
+        """Get the currently valid poster."""
+        # Get current date with year and calendar week
+        current = timezone.datetime.now()
+        cw = CalendarWeek.from_date(current)
+
+        # Create datetime with the friday of the week and the toggle time
+        day = cw[self.publishing_day]
+        day_and_time = timezone.datetime.combine(day, self.publishing_time)
+
+        # Check whether to show the poster of the next week or the current week
+        if current > day_and_time:
+            cw += 1
+
+        # Look for matching PDF in DB
+        try:
+            obj = self.posters.get(year=cw.year, week=cw.week)
+            return obj
+
+        # Or show the default PDF
+        except Poster.DoesNotExist:
+            return None
+
+
+def _get_current_year() -> int:
+    """Get the current year."""
+    return timezone.now().year
+
+
+calendar_weeks = [(cw, str(cw)) for cw in range(1, 53)]
 
 
 class Poster(ExtensibleModel):
-    calendar_week = models.IntegerField(verbose_name=_("CW"))
-    year = models.IntegerField(verbose_name=_("Year"))
-    pdf = models.FileField(upload_to=path_and_rename_poster, verbose_name=_("PDF"))
+    """A time-based document."""
+
+    group = models.ForeignKey(
+        to=PosterGroup,
+        related_name="posters",
+        on_delete=models.CASCADE,
+        verbose_name=_("Poster group"),
+    )
+    week = models.PositiveSmallIntegerField(
+        verbose_name=_("Calendar week"),
+        validators=[MinValueValidator(1), MaxValueValidator(53)],
+        default=CalendarWeek.current_week,
+        choices=calendar_weeks,
+    )
+    year = models.PositiveSmallIntegerField(verbose_name=_("Year"), default=_get_current_year)
+    pdf = models.FileField(
+        upload_to="posters/",
+        verbose_name=_("PDF"),
+        validators=[FileExtensionValidator(allowed_extensions=["pdf"])],
+    )
 
     class Meta:
-        unique_together = ("calendar_week", "year")
+        constraints = [
+            models.UniqueConstraint(
+                fields=["site_id", "week", "year"], name="unique_site_week_year"
+            )
+        ]
         verbose_name = _("Poster")
         verbose_name_plural = _("Posters")
 
-    def __str__(self):
-        return "{} {}/{}".format(_("CW"), self.calendar_week, self.year)
+    def __str__(self) -> str:
+        return f"{self.group.name}: {self.week}/{self.year}"
+
+    @property
+    def valid_from(self) -> datetime:
+        """Return the time this poster is valid from."""
+        cw = CalendarWeek(week=self.week, year=self.year) - 1
+        day = cw[self.group.publishing_day]
+        return timezone.datetime.combine(day, self.group.publishing_time)
+
+    @property
+    def valid_to(self) -> datetime:
+        """Return the time this poster is valid to."""
+        cw = CalendarWeek(week=self.week, year=self.year)
+        day = cw[self.group.publishing_day]
+        return timezone.datetime.combine(day, self.group.publishing_time)
diff --git a/aleksis/apps/resint/settings.py b/aleksis/apps/resint/settings.py
deleted file mode 100644
index ed40ecdd3440579f339929d0617da61c4308380f..0000000000000000000000000000000000000000
--- a/aleksis/apps/resint/settings.py
+++ /dev/null
@@ -1,13 +0,0 @@
-import os
-from datetime import time
-
-from django.utils.translation import gettext_lazy as _
-
-BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-CONSTANCE_CONFIG = {
-    "RESINT_NEW_WEEK_DAY": (4, _("Weekday at which the poster of the next week is to be shown"), "weekday_field"),
-    "RESINT_NEW_WEEK_TIME": (time(14, 00), _("Time at which the poster of the next week is to be shown"), time)
-}
-CONSTANCE_CONFIG_FIELDSETS = {
-    "Resint settings": ("RESINT_NEW_WEEK_DAY", "RESINT_NEW_WEEK_TIME"),
-}
diff --git a/aleksis/apps/resint/templates/resint/group/create.html b/aleksis/apps/resint/templates/resint/group/create.html
new file mode 100644
index 0000000000000000000000000000000000000000..67dd26368e9245198f1f468b7e000b6bd5e7ce9b
--- /dev/null
+++ b/aleksis/apps/resint/templates/resint/group/create.html
@@ -0,0 +1,17 @@
+{% extends 'core/base.html' %}
+{% load material_form i18n %}
+
+{% block browser_title %}
+  {% blocktrans %}Create poster group{% endblocktrans %}
+{% endblock %}
+{% block page_title %}
+  {% blocktrans %}Create poster group{% endblocktrans %}
+{% endblock %}
+
+{% block content %}
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+{% endblock %}
diff --git a/aleksis/apps/resint/templates/resint/group/edit.html b/aleksis/apps/resint/templates/resint/group/edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..a811160fcf01ab2a88d58ddb8cbf616cc32b11f7
--- /dev/null
+++ b/aleksis/apps/resint/templates/resint/group/edit.html
@@ -0,0 +1,17 @@
+{% extends 'core/base.html' %}
+{% load material_form i18n %}
+
+{% block page_title %}
+  {% trans "Edit poster group" %}
+{% endblock %}
+{% block browser_title %}
+  {% trans "Edit poster group" %}
+{% endblock %}
+
+{% block content %}
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+{% endblock %}
diff --git a/aleksis/apps/resint/templates/resint/group/list.html b/aleksis/apps/resint/templates/resint/group/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..00b2d3c1ce5d2f790d3403392192339a62cc1565
--- /dev/null
+++ b/aleksis/apps/resint/templates/resint/group/list.html
@@ -0,0 +1,59 @@
+{% extends 'core/base.html' %}
+{% load material_form i18n %}
+
+{% block browser_title %}{% blocktrans %}Poster groups{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <a class="waves-effect waves-light btn green modal-trigger right" href="{% url "create_poster_group" %}">
+    <i class="material-icons left">add</i>{% blocktrans %}Create new poster group{% endblocktrans %}
+  </a>
+
+  <h1>{% blocktrans %}Poster groups{% endblocktrans %}</h1>
+
+  <table>
+    <thead>
+    <tr>
+      <th>{% blocktrans %}Name{% endblocktrans %}</th>
+      <th>{% blocktrans %}Filename{% endblocktrans %}</th>
+      <th>{% blocktrans %}Publishing day{% endblocktrans %}</th>
+      <th>{% blocktrans %}Publishing time{% endblocktrans %}</th>
+      <th>{% blocktrans %}Default PDF file{% endblocktrans %}</th>
+      <th>{% blocktrans %}Actions{% endblocktrans %}</th>
+    </tr>
+    </thead>
+    <tbody>
+    {% for poster_group in postergroup_list %}
+      <tr>
+        <td>{{ poster_group.name }}</td>
+        <td>
+          <a href="{% url "poster_show_current" poster_group.slug %}"><code>{{ poster_group.filename }}</code></a>
+        </td>
+        <td>{{ poster_group.publishing_day_name }}</td>
+        <td>{{ poster_group.publishing_time }}</td>
+        <td>
+          <a href="{{ poster_group.default_pdf.url }}" class="btn-flat" target="_blank">
+            <i class="material-icons left">picture_as_pdf</i>
+            {% trans "Open" %}
+          </a>
+        </td>
+        <td>
+          <a href="{% url 'edit_poster_group' poster_group.id %}"
+             class="waves-effect waves-light btn-flat orange-text">
+            <i class="material-icons left">edit</i>
+            {% trans "Edit" %}
+          </a>
+          <a href="{% url 'delete_poster_group' poster_group.id %}"
+             class="waves-effect waves-light btn-flat red-text">
+            <i class="material-icons left">delete</i>
+            {% trans "Delete" %}
+          </a>
+        </td>
+      </tr>
+    {% empty %}
+      <tr>
+        <td colspan="4">{% blocktrans %}There are no poster groups available.{% endblocktrans %}</td>
+      </tr>
+    {% endfor %}
+    </tbody>
+  </table>
+{% endblock %}
diff --git a/aleksis/apps/resint/templates/resint/index.html b/aleksis/apps/resint/templates/resint/index.html
deleted file mode 100644
index 90dc986206617ede153f486e3717871b3920652d..0000000000000000000000000000000000000000
--- a/aleksis/apps/resint/templates/resint/index.html
+++ /dev/null
@@ -1,37 +0,0 @@
-{% extends "core/base.html" %}
-{% load msg_box static i18n %}
-
-{% block content %}
-  <a class="waves-effect waves-light btn green" href="{% url "poster_upload" %}"><i class="material-icons left">add</i>
-    {% trans "Upload new poster" %}
-  </a>
-  <a class="waves-effect waves-light btn orange" href="{% url "poster_show_current" %}"><i class="material-icons left">picture_as_pdf</i>
-    {% trans "Show current poster" %}
-  </a>
-
-  <h5>{% trans "All uploaded posters" %}</h5>
-
-  <ul class="collection">
-    {% for poster in posters %}
-      <li class="collection-item ">
-        <span class="title">{{ poster }}</span>
-        <p>
-          <a class="btn-flat waves-effect waves-green" href="{% get_media_prefix %}{{ poster.pdf }}" target="_blank">
-            <i class="material-icons left">picture_as_pdf</i> {% trans "Show" %}
-          </a>
-          <a class="btn-flat delete-poster waves-effect waves-red" href="{% url "poster_delete"  poster.id %}">
-            <i class="material-icons left">delete</i> {% trans "Delete" %}
-          </a>
-        </p>
-      </li>
-    {% endfor %}
-  </ul>
-
-  <script type="text/javascript">
-    $(".delete-poster").click(function (e) {
-      if (!confirm("Wirklich löschen?")) {
-        e.preventDefault();
-      }
-    })
-  </script>
-{% endblock %}
diff --git a/aleksis/apps/resint/templates/resint/poster/list.html b/aleksis/apps/resint/templates/resint/poster/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..3970ffe8a4b46df69a0050c9f5a0c2588496a565
--- /dev/null
+++ b/aleksis/apps/resint/templates/resint/poster/list.html
@@ -0,0 +1,77 @@
+{% extends "core/base.html" %}
+{% load static i18n %}
+
+{% block content %}
+
+
+  <h1>{% trans "Posters" %}</h1>
+  <div class="row">
+    {% for group in poster_groups %}
+      <div class="col s12 m6 l4 xl3">
+        <div class="card">
+          <div class="card-content">
+            <div class="card-title">{{ group.name }}</div>
+            {% with current_poster=group.current_poster %}
+              {% if current_poster %}
+                <p class="margin-bottom">
+                  <i class="material-icons left">picture_as_pdf</i>
+                  <a href="{{ current_poster.pdf.url }}">
+                    {% blocktrans with week=current_poster.week year=current_poster.year %}
+                      Week {{ week }}/{{ year }}
+                    {% endblocktrans %}
+                  </a>
+                </p>
+                <p>
+                  <i class="material-icons left">schedule</i>
+                  {{ current_poster.valid_from }}–{{ current_poster.valid_to }}
+                </p>
+              {% else %}
+                <p>
+                  <i class="material-icons left">picture_as_pdf</i>
+                  {% trans "There is no poster for this week." %}
+                </p>
+              {% endif %}
+            {% endwith %}
+          </div>
+          <div class="card-action">
+            <a href="{% url "poster_show_current" group.slug %}">
+              {% trans "Show current PDF" %}
+            </a>
+          </div>
+        </div>
+      </div>
+    {% endfor %}
+  </div>
+  <a class="waves-effect waves-light btn green right" href="{% url "poster_upload" %}">
+    <i class="material-icons left">add</i>
+    {% trans "Upload new poster" %}
+  </a>
+  <h2>{% trans "All uploaded posters" %}</h2>
+  <table>
+    <thead>
+    <tr>
+      <th>{% trans "Group" %}</th>
+      <th>{% trans "Week" %}</th>
+      <th>{% trans "Valid from ... to" %}</th>
+      <th>{% trans "Actions" %}</th>
+    </tr>
+    </thead>
+    {% for poster in poster_list %}
+      <tr>
+        <td>{{ poster.group }}</td>
+        <td>{{ poster.week }}/{{ poster.year }}</td>
+        <td>{{ poster.valid_from }}–{{ poster.valid_to }}</td>
+        <td>
+          <a class="btn-flat waves-effect waves-green" href="{{ poster.pdf.url }}" target="_blank">
+            <i class="material-icons left">picture_as_pdf</i> {% trans "Show" %}
+          </a>
+          <a class="btn-flat red-text waves-effect waves-red" href="{% url "poster_delete"  poster.id %}">
+            <i class="material-icons left">delete</i> {% trans "Delete" %}
+          </a>
+        </td>
+      </tr>
+    {% endfor %}
+  </table>
+
+
+{% endblock %}
diff --git a/aleksis/apps/resint/templates/resint/poster/upload.html b/aleksis/apps/resint/templates/resint/poster/upload.html
new file mode 100644
index 0000000000000000000000000000000000000000..9a24ec2d42e63c4f84555a41625d9c803d4829c8
--- /dev/null
+++ b/aleksis/apps/resint/templates/resint/poster/upload.html
@@ -0,0 +1,14 @@
+{% extends 'core/base.html' %}
+{% load material_form i18n %}
+
+{% block content %}
+  <h4>{% blocktrans %}Upload poster{% endblocktrans %}</h4>
+
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    <button type="submit" class="waves-effect waves-light btn green">
+      <i class="material-icons left">save</i>{% blocktrans %}Upload poster{% endblocktrans %}
+    </button>
+  </form>
+{% endblock %}
diff --git a/aleksis/apps/resint/templates/resint/upload.html b/aleksis/apps/resint/templates/resint/upload.html
deleted file mode 100644
index ca07c32682967d1e4c40367358b04ccd8084380d..0000000000000000000000000000000000000000
--- a/aleksis/apps/resint/templates/resint/upload.html
+++ /dev/null
@@ -1,21 +0,0 @@
-{% extends "core/base.html" %}
-{% load msg_box i18n material_form %}
-
-{% block page_title %}{% trans "Upload poster" %}{% endblock %}
-
-{% block content %}
-  <form method="post" enctype="multipart/form-data">
-    {% csrf_token %}
-
-    {% form form=form %}{% endform %}
-
-    <button class="waves-effect waves-light btn green" type="submit">
-      <i class="material-icons left">cloud_upload</i>
-      {% trans "Upload and publish poster" %}
-    </button>
-  </form>
-
-  <p>
-    <a href="{% url 'poster_index' %}" class="waves-effect waves-teal btn-flat">{% trans "Back to overview" %}</a>
-  </p>
-{% endblock %}
diff --git a/aleksis/apps/resint/urls.py b/aleksis/apps/resint/urls.py
index 2830be5752035e33d9b7f6ee3d6deb43aef6280b..86c2fc88a018231af60ab19772cf1786945883fe 100644
--- a/aleksis/apps/resint/urls.py
+++ b/aleksis/apps/resint/urls.py
@@ -1,11 +1,23 @@
 from django.urls import path
 
-from . import views
+from .views import (
+    PosterCurrentView,
+    PosterDeleteView,
+    PosterGroupCreateView,
+    PosterGroupDeleteView,
+    PosterGroupEditView,
+    PosterGroupListView,
+    PosterListView,
+    PosterUploadView,
+)
 
 urlpatterns = [
-    path('', views.index, name="poster_index"),
-    path('upload/', views.upload, name="poster_upload"),
-    path('delete/<int:id>', views.delete, name="poster_delete"),
-    path('current.pdf', views.show_current, name="poster_show_current"),
-    path('<str:msg>', views.index, name="poster_index_msg"),
+    path("", PosterListView.as_view(), name="poster_index"),
+    path("upload/", PosterUploadView.as_view(), name="poster_upload"),
+    path("<int:pk>/delete/", PosterDeleteView.as_view(), name="poster_delete"),
+    path("<str:slug>.pdf", PosterCurrentView.as_view(), name="poster_show_current"),
+    path("groups/", PosterGroupListView.as_view(), name="poster_group_list"),
+    path("groups/create/", PosterGroupCreateView.as_view(), name="create_poster_group"),
+    path("groups/<int:pk>/edit/", PosterGroupEditView.as_view(), name="edit_poster_group"),
+    path("groups/<int:pk>/delete/", PosterGroupDeleteView.as_view(), name="delete_poster_group"),
 ]
diff --git a/aleksis/apps/resint/views.py b/aleksis/apps/resint/views.py
index d5fea916e300b59b47cb223f05390ab69f451d60..4b1be59cdcf42c61520e84e8f7193c07bbb8833a 100644
--- a/aleksis/apps/resint/views.py
+++ b/aleksis/apps/resint/views.py
@@ -1,87 +1,96 @@
-import os
+from typing import Any, Dict
 
-from django.conf import settings
-from django.contrib.auth.decorators import login_required, permission_required
-from django.http import FileResponse
-from django.shortcuts import get_object_or_404, redirect, render
-from django.utils import timezone
-from django.utils.translation import gettext_lazy as _
+from django.db.models import QuerySet
+from django.http import FileResponse, HttpRequest
+from django.urls import reverse_lazy
+from django.utils.translation import gettext as _
+from django.views import View
+from django.views.generic.detail import SingleObjectMixin
+from django.views.generic.list import ListView
 
-from calendarweek import CalendarWeek
-from constance import config
+from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
 
-from aleksis.core.util import messages
+from .forms import PosterGroupForm, PosterUploadForm
+from .models import Poster, PosterGroup
 
-from .forms import PosterUploadForm
-from .models import Poster
-from .settings import BASE_DIR
 
+class PosterGroupListView(ListView):
+    """Show a list of all poster groups."""
 
-@login_required
-@permission_required("resint.add_poster")
-def upload(request):
-    if request.method == 'POST':
-        form = PosterUploadForm(request.POST, request.FILES)
-        if form.is_valid():
-            form.save()
+    template_name = "resint/group/list.html"
+    model = PosterGroup
 
-            messages.success(request, _("The poster was uploaded successfully."))
-            return redirect('poster_index')
-    else:
-        form = PosterUploadForm()
-    return render(request, 'resint/upload.html', {
-        'form': form
-    })
 
+class PosterGroupCreateView(AdvancedCreateView):
+    """Create a new poster group."""
 
-@login_required
-@permission_required("resint.add_poster")
-def delete(request, id):
-    poster = get_object_or_404(Poster, pk=id)
-    poster.delete()
+    model = PosterGroup
+    success_url = reverse_lazy("poster_group_list")
+    template_name = "resint/group/create.html"
+    success_message = _("The poster group has been saved.")
+    form_class = PosterGroupForm
 
-    messages.success(request, _("The poster was deleted successfully."))
-    return redirect("poster_index")
 
+class PosterGroupEditView(AdvancedEditView):
+    """Edit an existing poster group."""
 
-@login_required
-@permission_required("poster.add_poster")
-def index(request):
-    posters = Poster.objects.all().order_by("calendar_week", "year")
-    return render(request, 'resint/index.html', {"posters": posters})
+    model = PosterGroup
+    success_url = reverse_lazy("poster_group_list")
+    template_name = "resint/group/edit.html"
+    success_message = _("The poster group has been saved.")
+    form_class = PosterGroupForm
 
 
-def return_pdf(filename):
-    """Read and response a PDF file"""
+class PosterGroupDeleteView(AdvancedDeleteView):
+    """Delete a poster group."""
 
-    file = open(filename, "rb")
-    return FileResponse(file, content_type="application/pdf")
+    model = PosterGroup
+    success_url = reverse_lazy("poster_group_list")
+    success_message = _("The poster group has been deleted.")
+    template_name = "core/pages/delete.html"
 
 
-def return_default_pdf():
-    """Response the default PDF"""
+class PosterListView(ListView):
+    """Show a list of all uploaded posters."""
 
-    return return_pdf(os.path.join(BASE_DIR, "default.pdf"))
+    template_name = "resint/poster/list.html"
+    model = Poster
 
+    def get_queryset(self) -> QuerySet:
+        return Poster.objects.all().order_by("-year", "-week")
 
-def show_current(request):
-    # Get current date with year and calendar week
-    current_date = timezone.datetime.now()
-    cw = CalendarWeek.from_date(current_date)
+    def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+        context = super().get_context_data(**kwargs)
+        context["poster_groups"] = PosterGroup.objects.all().order_by("name")
+        return context
 
-    # Create datetime with the friday of the week and the toggle time
-    friday = cw[int(config.RESINT_NEW_WEEK_DAY)]
-    friday = timezone.datetime.combine(friday, config.RESINT_NEW_WEEK_TIME)
 
-    # Check whether to show the poster of the next week or the current week
-    if current_date > friday:
-        cw += 1
+class PosterUploadView(AdvancedCreateView):
+    """Upload a new poster."""
 
-    # Look for matching PDF in DB
-    try:
-        obj = Poster.objects.get(year=cw.year, calendar_week=cw.week)
-        return return_pdf(os.path.join(settings.MEDIA_ROOT, str(obj.pdf)))
+    model = Poster
+    success_url = reverse_lazy("poster_index")
+    template_name = "resint/poster/upload.html"
+    success_message = _("The poster has been uploaded.")
+    form_class = PosterUploadForm
 
-    # Or show the default PDF
-    except Poster.DoesNotExist:
-        return return_default_pdf()
+
+class PosterDeleteView(AdvancedDeleteView):
+    """Delete an uploaded poster."""
+
+    model = Poster
+    success_url = reverse_lazy("poster_index")
+    success_message = _("The poster has been deleted.")
+    template_name = "core/pages/delete.html"
+
+
+class PosterCurrentView(SingleObjectMixin, View):
+    """Show the poster which is currently valid."""
+
+    model = PosterGroup
+
+    def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> FileResponse:
+        group = self.get_object()
+        current_poster = group.current_poster
+        file = current_poster.pdf if current_poster else group.default_pdf
+        return FileResponse(file, content_type="application/pdf")