From 0253899e5323a91f1c76410a225d82451b82da9e Mon Sep 17 00:00:00 2001 From: Jason Birchall Date: Tue, 25 Aug 2026 17:47:16 +0100 Subject: [PATCH] Add matched-instance and version fields to UserRestrictionHistory When a RestrictionChecked check fails we record which restriction class denied the action, but not which specific restriction row matched. The matching row is known at the point of failure, then discarded, so reviewers and Redash can see "an email restriction fired" but never the reason. This adds the schema to hold that, without wiring anything up yet. - it adds two generic foreign keys to the matched restriction row. Generic FKs because restrictions span five different tables. Indexed together so "every failure caused by restrictions X" is a cheap query. - it adds version, tying the auto-approval failure to the version affected. The checker runs before the version exists, so this will be backfilled by Version.from_upload() rather than set by the checker. All new columns are nullable and no existing rows are touched. This is because there are many records that predate this change. --- ...istory_restriction_instance_and_version.py | 36 +++++++++++++++++++ src/olympia/users/models.py | 27 ++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/olympia/users/migrations/0027_userrestrictionhistory_restriction_instance_and_version.py diff --git a/src/olympia/users/migrations/0027_userrestrictionhistory_restriction_instance_and_version.py b/src/olympia/users/migrations/0027_userrestrictionhistory_restriction_instance_and_version.py new file mode 100644 index 000000000000..0398bd3b5949 --- /dev/null +++ b/src/olympia/users/migrations/0027_userrestrictionhistory_restriction_instance_and_version.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.17 on 2026-08-25 14:53 + +import django.db.models.deletion +import olympia.amo.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('users', '0026_alter_userrestrictionhistory_restriction_and_more'), + ('versions', '0053_auto_20260720_1345'), + ] + + operations = [ + migrations.AddField( + model_name='userrestrictionhistory', + name='restriction_content_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='userrestrictionhistory', + name='restriction_object_id', + field=models.PositiveIntegerField(null=True), + ), + migrations.AddField( + model_name='userrestrictionhistory', + name='version', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='restriction_history', to='versions.version'), + ), + migrations.AddIndex( + model_name='userrestrictionhistory', + index=olympia.amo.models.LongNameIndex(fields=['restriction_content_type', 'restriction_object_id'], name='users_userrestrictionhistory_restriction_content_type_object_id'), + ), + ] diff --git a/src/olympia/users/models.py b/src/olympia/users/models.py index e6a5ba017840..f8e50778f825 100644 --- a/src/olympia/users/models.py +++ b/src/olympia/users/models.py @@ -13,6 +13,8 @@ from django.conf import settings from django.contrib.auth.models import AbstractBaseUser, BaseUserManager from django.contrib.auth.signals import user_logged_in +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core import validators from django.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile @@ -1430,6 +1432,27 @@ class UserRestrictionHistory(ModelBase): ) ip_address = models.CharField(default='', max_length=45) last_login_ip = models.CharField(default='', max_length=45) + # The specific restriction row that matched, e.g. an EmailUserRestriction + # or IPNetworkUserRestriction instance. A generic foreign key because the + # restriction classes live in different tables. NULL on rows recorded + # before these fields existed, and always NULL for restrictions that + # aren't backed by the database (developer agreement, reputation). + restriction_content_type = models.ForeignKey( + ContentType, null=True, on_delete=models.SET_NULL + ) + restriction_object_id = models.PositiveIntegerField(null=True) + restriction_instance = GenericForeignKey( + 'restriction_content_type', 'restriction_object_id' + ) + # The version whose auto-approval was being checked. NULL on rows recorded + # before this field existed, and always NULL for checks other than + # auto-approval, which aren't tied to a version. + version = models.ForeignKey( + 'versions.Version', + related_name='restriction_history', + null=True, + on_delete=models.SET_NULL, + ) class Meta: verbose_name_plural = 'User Restriction History' @@ -1442,6 +1465,10 @@ class Meta: fields=('last_login_ip',), name='users_userrestrictionhistory_last_login_ip_d58d95ff', ), + LongNameIndex( + fields=('restriction_content_type', 'restriction_object_id'), + name='users_userrestrictionhistory_restriction_content_type_object_id', + ), ]