diff --git a/aleksis/core/migrations/0045_auto_20221120_1420.py b/aleksis/core/migrations/0045_auto_20221120_1420.py
new file mode 100644
index 0000000000000000000000000000000000000000..59ff1af5cd71779c533eaf32278828cbd672cf3e
--- /dev/null
+++ b/aleksis/core/migrations/0045_auto_20221120_1420.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.2.15 on 2022-11-20 14:20
+
+import django.contrib.sites.managers
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import oauth2_provider.generators
+import oauth2_provider.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('core', '0044_task_assignment_result_fetched'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Room',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('short_name', models.CharField(max_length=255, verbose_name='Short name')),
+                ('name', models.CharField(max_length=255, verbose_name='Long name')),
+                ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
+            ],
+            options={
+                'verbose_name': 'Room',
+                'verbose_name_plural': 'Rooms',
+                'ordering': ['name', 'short_name'],
+                'permissions': (('view_room_timetable', 'Can view room timetable'),),
+            },
+            managers=[
+                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name='room',
+            constraint=models.UniqueConstraint(fields=('site_id', 'short_name'), name='unique_short_name_per_site_room'),
+        ),
+        # Migrate data from Chronos table; deletion will be handled by Chronos
+        migrations.RunSQL(
+            """
+            -- Use a temporary, empty source table in case Chronos is not installed
+            CREATE TEMPORARY TABLE IF NOT EXISTS chronos_rooms (LIKE core_rooms);
+            INSERT INTO core_rooms SELECT * FROM chronos_rooms;
+            """
+        ),
+    ]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index f01086c8cbe8c786ab5d2497df77e730212c7c1f..5deb23468a7f8a86cafd48e00cc8e4df19bc288f 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -1458,3 +1458,25 @@ class PersonalICalUrl(models.Model):
 
     def get_absolute_url(self):
         return reverse("ical_feed", kwargs={"slug": self.uuid})
+
+
+class Room(ExtensibleModel):
+    short_name = models.CharField(verbose_name=_("Short name"), max_length=255)
+    name = models.CharField(verbose_name=_("Long name"), max_length=255)
+
+    def __str__(self) -> str:
+        return f"{self.name} ({self.short_name})"
+
+    def get_absolute_url(self) -> str:
+        return reverse("timetable", args=["room", self.id])
+
+    class Meta:
+        permissions = (("view_room_timetable", _("Can view room timetable")),)
+        ordering = ["name", "short_name"]
+        verbose_name = _("Room")
+        verbose_name_plural = _("Rooms")
+        constraints = [
+            models.UniqueConstraint(
+                fields=["site_id", "short_name"], name="unique_short_name_per_site_room"
+            ),
+        ]