diff --git a/src/pretix/api/auth/device.py b/src/pretix/api/auth/device.py
index 00fc03ba00..63fd1892cf 100644
--- a/src/pretix/api/auth/device.py
+++ b/src/pretix/api/auth/device.py
@@ -20,8 +20,11 @@
# .
#
import logging
+from datetime import timedelta
from django.contrib.auth.models import AnonymousUser
+from django.db import DatabaseError
+from django.utils.timezone import now
from django_scopes import scopes_disabled
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
@@ -30,6 +33,7 @@ from pretix.api.auth.devicesecurity import (
FullAccessSecurityProfile, get_all_security_profiles,
)
from pretix.base.models import Device
+from pretix.base.models.devices import DeviceLastSeen
logger = logging.getLogger(__name__)
@@ -42,7 +46,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
model = self.get_model()
try:
with scopes_disabled():
- device = model.objects.select_related('organizer').get(api_token=key)
+ device = model.objects.select_related('organizer', 'last_seen').get(api_token=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.')
@@ -53,6 +57,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
logging.warning(f'Connection attempt of revoked device {device.pk}.')
raise exceptions.AuthenticationFailed('Device access has been revoked.')
+ self._update_last_seen(device)
return AnonymousUser(), device
def authenticate(self, request):
@@ -63,3 +68,22 @@ class DeviceTokenAuthentication(TokenAuthentication):
if not profile.is_allowed(request):
raise exceptions.PermissionDenied('Request denied by device security profile.')
return r
+
+ def _update_last_seen(self, device: Device):
+ try:
+ try:
+ last_seen_obj = device.last_seen
+ except DeviceLastSeen.DoesNotExist:
+ # First request from device, create model, ignore result. Use get_or_create to be safe
+ # against concurrent create requests
+ DeviceLastSeen.objects.get_or_create(device=device, last_seen=now())
+ else:
+ if now() - last_seen_obj.last_seen < timedelta(seconds=10):
+ # We don't need to know the last seen info of a device to more precision than this,
+ # so we can avoid some database writes if the device is bursting a lot of requests.
+ return
+ last_seen_obj.last_seen = now()
+ last_seen_obj.save(update_fields=["last_seen"])
+ except DatabaseError:
+ # Do not stop the request from happening
+ logger.exception("Database error while updating last_seen")
diff --git a/src/pretix/base/migrations/0307_devicelastseen.py b/src/pretix/base/migrations/0307_devicelastseen.py
new file mode 100644
index 0000000000..647055cb58
--- /dev/null
+++ b/src/pretix/base/migrations/0307_devicelastseen.py
@@ -0,0 +1,43 @@
+# Generated by Django 5.2.16 on 2026-08-05 08:00
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import pretix.helpers.database
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("pretixbase", "0306_alter_eventmetaproperty_unique_together"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="DeviceLastSeen",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False
+ ),
+ ),
+ ("last_seen", models.DateTimeField(auto_now=True)),
+ (
+ "device",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="pretixbase.device",
+ ),
+ ),
+ ],
+ ),
+ migrations.AddIndex(
+ model_name="devicelastseen",
+ index=pretix.helpers.database.BrinIndexIgnoredOnSQLite(
+ models.F("last_seen"),
+ autosummarize=True,
+ name="pretixbase_device_last_seen",
+ ),
+ ),
+ ]
diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py
index 726586e8ee..8997da3fe3 100644
--- a/src/pretix/base/models/devices.py
+++ b/src/pretix/base/models/devices.py
@@ -32,6 +32,7 @@ from pretix.base.models import LoggedModel
from pretix.base.permissions import (
AnyPermissionOf, assert_valid_event_permission,
)
+from pretix.helpers import BrinIndexIgnoredOnSQLite
@scopes_disabled()
@@ -287,3 +288,22 @@ class Device(LoggedModel):
return self.get_events_with_any_permission()
else:
return self.organizer.events.none()
+
+
+class DeviceLastSeen(models.Model):
+ # This is a separate model since we expect it to get A LOT of writes and PostgreSQL always
+ # writes full rows and then needs to update all indexes on the row, so this is going to save a
+ # lot of write traffic on the databse
+ device = models.OneToOneField("Device", on_delete=models.CASCADE, related_name="last_seen")
+ last_seen = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ indexes = [
+ BrinIndexIgnoredOnSQLite(
+ # BRIN indexes are highly efficient on lots of updates, especially of chronological data
+ # and especially if we later want to query them by range, as we likely want to.
+ "last_seen",
+ name="pretixbase_device_last_seen",
+ autosummarize=True
+ )
+ ]
diff --git a/src/pretix/helpers/database.py b/src/pretix/helpers/database.py
index d9d8ffb16d..06360cb051 100644
--- a/src/pretix/helpers/database.py
+++ b/src/pretix/helpers/database.py
@@ -22,6 +22,7 @@
import contextlib
from django.conf import settings
+from django.contrib.postgres.indexes import BrinIndex
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db import connection, transaction
from django.db.models import (
@@ -285,3 +286,21 @@ def get_deterministic_ordering(model, ordering):
# on the primary key to provide total ordering.
ordering.append("-pk")
return ordering
+
+
+class IgnoreOnSQLiteMixin:
+ # Mixin to allow defining PostgreSQL-specific indexes that will just not be created
+ # on SQLite. SQLite is supported for testing only anyways!
+ def create_sql(self, model, schema_editor, *args, **kwargs):
+ if "sqlite" in settings.DATABASES["default"]["ENGINE"]:
+ return ""
+ return super().create_sql(model, schema_editor, *args, **kwargs)
+
+ def remove_sql(self, model, schema_editor, **kwargs):
+ if "sqlite" in settings.DATABASES["default"]["ENGINE"]:
+ return ""
+ return super().remove_sql(model, schema_editor, **kwargs)
+
+
+class BrinIndexIgnoredOnSQLite(IgnoreOnSQLiteMixin, BrinIndex):
+ pass
diff --git a/src/tests/api/test_deviceauth.py b/src/tests/api/test_deviceauth.py
index dd29809b79..e1f29c2fb2 100644
--- a/src/tests/api/test_deviceauth.py
+++ b/src/tests/api/test_deviceauth.py
@@ -20,13 +20,16 @@
# .
#
import base64
+from datetime import datetime, timezone
import pytest
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from django_scopes import scopes_disabled
+from freezegun import freeze_time
from pretix.base.models import Device
+from pretix.base.models.devices import DeviceLastSeen
@pytest.fixture
@@ -386,3 +389,26 @@ def test_device_info_key_sets(device_client, device: Device):
base64.b64decode(ks['diversification_key']),
padding.PKCS1v15()
)
+
+
+@pytest.mark.django_db
+def test_update_last_seen(device_client, device: Device):
+ assert not DeviceLastSeen.objects.exists()
+
+ with freeze_time("2020-01-10T14:30:00+00:00"):
+ resp = device_client.get('/api/v1/device/info')
+ assert resp.status_code == 200
+ assert device.last_seen.last_seen == datetime(2020, 1, 10, 14, 30, tzinfo=timezone.utc)
+
+ with freeze_time("2020-01-10T14:30:05+00:00"):
+ resp = device_client.get('/api/v1/device/info')
+ assert resp.status_code == 200
+ # No update, interal too short
+ device.last_seen.refresh_from_db()
+ assert device.last_seen.last_seen == datetime(2020, 1, 10, 14, 30, tzinfo=timezone.utc)
+
+ with freeze_time("2020-01-10T14:30:30+00:00"):
+ resp = device_client.get('/api/v1/device/info')
+ assert resp.status_code == 200
+ device.last_seen.refresh_from_db()
+ assert device.last_seen.last_seen == datetime(2020, 1, 10, 14, 30, 30, tzinfo=timezone.utc)