diff --git a/codeforlife/user/fixtures/legacy.json b/codeforlife/user/fixtures/legacy.json index 50c94ea3..da03e4c5 100644 --- a/codeforlife/user/fixtures/legacy.json +++ b/codeforlife/user/fixtures/legacy.json @@ -986,10 +986,10 @@ "pk": 20, "fields": { "_first_name_enc": "ZmFrZV9lbmM6kEW6JpB05mjWAPE/xrxMrbFNoyD6ZBNVl+YCZI/q3+LRROg=", - "_first_name_hash": "1dbd289e95b4925fc85eff99d7e879b2033f60182e05872f8665ff271d0d337c", + "_first_name_hash": "", "_last_name_enc": "ZmFrZV9lbmM6gwxaGy9/aDQWDEVUwUverJ6wmk3ohaTkvF5GYq6koNg=", "_username_enc": "ZmFrZV9lbmM6kUOy1ae7Y1ElSiAiDXnKIZxPkOs1saBSjmH+UCEThmkOR7lKe9Nlmza/K+mW1Slr0UutzlVaqMLU3yWT", - "_username_hash": "daa37b0878d74480285044dccc67750ad597b99719dc7323462cbe16e0b0271d", + "_username_hash": "", "date_joined": "2026-02-04T16:02:37.614Z", "dek": "ZmFrZV9lbmM6Ct68dRt74lesvjRcTVtlbUlPC8xt85anig+MaarMMZAUvcFmkJmno57KkQUR8S5/o5ElV9Bpn3RDpxeS", "groups": [], diff --git a/codeforlife/user/migrations/0006_client_side_encryption_part_4.py b/codeforlife/user/migrations/0006_client_side_encryption_part_4.py index f658f5e1..12ae50bf 100644 --- a/codeforlife/user/migrations/0006_client_side_encryption_part_4.py +++ b/codeforlife/user/migrations/0006_client_side_encryption_part_4.py @@ -1,7 +1,5 @@ from django.db import migrations -from ...models.fields import EncryptedTextField, Sha256Field - user_migrations = [ # Username migrations.RemoveField( diff --git a/codeforlife/user/migrations/0007_empty_hash_when_inactive.py b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py new file mode 100644 index 00000000..7d796719 --- /dev/null +++ b/codeforlife/user/migrations/0007_empty_hash_when_inactive.py @@ -0,0 +1,146 @@ +import typing as t + +from django.db import migrations +from django.db.migrations.state import StateApps +from django.db.models import CheckConstraint, Model, Q + + +def set_hash_fields_to_empty(apps: StateApps, _): + """ + Set hash fields to empty for inactive users, classes, schools, and school + teacher invitations. + """ + + User: t.Type[Model] = apps.get_model("user", "User") + Class: t.Type[Model] = apps.get_model("user", "Class") + School: t.Type[Model] = apps.get_model("user", "School") + SchoolTeacherInvitation: t.Type[Model] = apps.get_model( + "user", "SchoolTeacherInvitation" + ) + + is_not_active = Q(is_active=False) + + User.objects.filter(is_not_active).update( + _username_hash="", _email_hash="", _first_name_hash="" + ) + + Class.objects.filter(is_not_active).update(_name_enc=b"", _name_hash="") + + School.objects.filter(is_not_active).update(_name_hash="") + + SchoolTeacherInvitation.objects.filter(is_not_active).update( + _invited_teacher_first_name_enc=b"", + _invited_teacher_last_name_enc=b"", + _invited_teacher_email_enc=b"", + _token_hash="", + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("user", "0006_client_side_encryption_part_4"), + ] + + operations = [ + migrations.RunPython(set_hash_fields_to_empty), + migrations.AddConstraint( + model_name="class", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_name_enc", b""), _connector="OR" + ), + name="klass__name_enc_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="class", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_name_hash", ""), _connector="OR" + ), + name="klass__name_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="school", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_name_hash", ""), _connector="OR" + ), + name="school__name_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="schoolteacherinvitation", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_invited_teacher_first_name_enc", b""), + _connector="OR", + ), + name="school_teacher_invitation__invited_teacher_first_name_enc_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="schoolteacherinvitation", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_invited_teacher_last_name_enc", b""), + _connector="OR", + ), + name="school_teacher_invitation__invited_teacher_last_name_enc_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="schoolteacherinvitation", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_invited_teacher_email_enc", b""), + _connector="OR", + ), + name="school_teacher_invitation__invited_teacher_email_enc_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="schoolteacherinvitation", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_token_hash", ""), _connector="OR" + ), + name="school_teacher_invitation__token_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="user", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_email_hash", ""), _connector="OR" + ), + name="user__email_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="user", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), ("_username_hash", ""), _connector="OR" + ), + name="user__username_hash_non_empty_when_inactive", + ), + ), + migrations.AddConstraint( + model_name="user", + constraint=CheckConstraint( + condition=Q( + ("is_active", True), + ("_first_name_hash", ""), + _connector="OR", + ), + name="user__first_name_hash_non_empty_when_inactive", + ), + ), + ] diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index f2b81186..84f61bcf 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -234,7 +234,9 @@ def get_requests_message(self): def anonymise(self): """Anonymise the class.""" self.is_active = False - self.save() + self._name_enc = b"" + self._name_hash = "" + self.save(update_fields=["is_active", "_name_enc", "_name_hash"]) # Remove independent students' requests to join this class # pylint: disable-next=no-member @@ -248,6 +250,14 @@ class Meta(TypedModelMeta): condition=~models.Q(_access_code_hash=""), name="unique_access_code_hash_non_empty", ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_name_enc=b""), + name="klass__name_enc_non_empty_when_inactive", + ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_name_hash=""), + name="klass__name_hash_non_empty_when_inactive", + ), ] @property diff --git a/codeforlife/user/models/other.py b/codeforlife/user/models/other.py index 5e2b1ee9..b2d72a8b 100644 --- a/codeforlife/user/models/other.py +++ b/codeforlife/user/models/other.py @@ -439,6 +439,43 @@ class Meta: fields=["_token_hash"], name="unique_token_hash_non_empty", ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) + | models.Q(_invited_teacher_first_name_enc=b"") + ), + name=( + "school_teacher_invitation__" + "invited_teacher_first_name_enc_non_empty_when_inactive" + ), + ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) + | models.Q(_invited_teacher_last_name_enc=b"") + ), + name=( + "school_teacher_invitation__" + "invited_teacher_last_name_enc_non_empty_when_inactive" + ), + ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) + | models.Q(_invited_teacher_email_enc=b"") + ), + name=( + "school_teacher_invitation__" + "invited_teacher_email_enc_non_empty_when_inactive" + ), + ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_token_hash=""), + name=( + "school_teacher_invitation__" + "token_hash_non_empty_when_inactive" + ), + ), ] @property @@ -455,11 +492,20 @@ def __str__(self): def anonymise(self): """Anonymise the invitation.""" - self.invited_teacher_first_name = "" - self.invited_teacher_last_name = "" - self.invited_teacher_email = "" + self._invited_teacher_first_name_enc = b"" + self._invited_teacher_last_name_enc = b"" + self._invited_teacher_email_enc = b"" + self._token_hash = "" self.is_active = False - self.save() + self.save( + update_fields=[ + "_invited_teacher_first_name_enc", + "_invited_teacher_last_name_enc", + "_invited_teacher_email_enc", + "_token_hash", + "is_active", + ] + ) @property def dek_aead(self): diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index 147f80f7..4717361c 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -139,6 +139,10 @@ class Meta(TypedModelMeta): fields=["_name_hash"], name="unique_name_hash_non_empty", ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_name_hash=""), + name="school__name_hash_non_empty_when_inactive", + ), ] def __str__(self): @@ -168,4 +172,5 @@ def anonymise(self): """Anonymize the school.""" self.dek = None self.is_active = False - self.save(update_fields=["dek", "is_active"]) + self._name_hash = "" + self.save(update_fields=["dek", "is_active", "_name_hash"]) diff --git a/codeforlife/user/models/user/user.py b/codeforlife/user/models/user/user.py index 61f69017..6511ad68 100644 --- a/codeforlife/user/models/user/user.py +++ b/codeforlife/user/models/user/user.py @@ -311,6 +311,22 @@ class Meta(TypedModelMeta): fields=["_username_hash"], name="unique_username_hash_non_empty", ), + models.CheckConstraint( + condition=models.Q(is_active=True) | models.Q(_email_hash=""), + name="user__email_hash_non_empty_when_inactive", + ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) | models.Q(_username_hash="") + ), + name="user__username_hash_non_empty_when_inactive", + ), + models.CheckConstraint( + condition=( + models.Q(is_active=True) | models.Q(_first_name_hash="") + ), + name="user__first_name_hash_non_empty_when_inactive", + ), ] # TODO: remove in new schema @@ -427,7 +443,18 @@ def anonymize(self): """Anonymize the user.""" self.dek = None self.is_active = False - self.save(update_fields=["dek", "is_active"]) + self._username_hash = "" + self._email_hash = "" + self._first_name_hash = "" + self.save( + update_fields=[ + "dek", + "is_active", + "_username_hash", + "_email_hash", + "_first_name_hash", + ] + ) # self.userprofile.google_refresh_token = None # self.userprofile.google_sub = None