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)