Skip to content
Merged
55 changes: 55 additions & 0 deletions codeforlife/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
© Ocado Group
Created on 26/05/2026 at 16:02:37(+01:00).
"""

from django.contrib.admin import ModelAdmin
from django.db.models import Q


class HashSearchModelAdmin(ModelAdmin):
"""
Apply hash lookups to the full search term instead of `smart_split()` bits.
"""

def get_search_results(self, request, queryset, search_term):
# Get the search fields and split them into hash and non-hash fields.
hash_fields: list[str] = []
non_hash_fields: list[str] = []
for field in self.get_search_fields(request):
(hash_fields if "__sha256" in field else non_hash_fields).append(
field
)

# Keep Django's default behavior for non-hash fields.
non_hash_queryset = queryset.none()
may_have_duplicates = False
if non_hash_fields:
original_search_fields = self.search_fields
self.search_fields = tuple(non_hash_fields) # type: ignore[misc]
try:
non_hash_queryset, may_have_duplicates = (
super().get_search_results(request, queryset, search_term)
)
finally:
self.search_fields = ( # type: ignore[misc]
original_search_fields
)

# Hash transforms should use the whole input, not `smart_split()` bits.
hash_queryset = queryset.none()
if hash_fields and search_term:
hash_query = Q.create(
[(lookup, search_term) for lookup in hash_fields],
connector=Q.OR,
)
hash_queryset = queryset.filter(hash_query)

# Combine the hash and non-hash querysets.
if non_hash_fields and hash_fields:
queryset = hash_queryset | non_hash_queryset
may_have_duplicates = True
else:
queryset = hash_queryset if hash_fields else non_hash_queryset

return queryset, may_have_duplicates
4 changes: 2 additions & 2 deletions codeforlife/legacy/helpers/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ def update_indy_email(user, request, data):

if new_email != "" and new_email != user.email:
changing_email = True
users_with_email = User.objects.filter(_email_plain=new_email)
users_with_email = User.objects.filter(_email_hash__sha256=new_email)

send_dotdigital_email(
campaign_ids["email_change_notification"],
Expand All @@ -399,7 +399,7 @@ def update_email(user: Teacher or Student, request, data):

if new_email != "" and new_email != user.new_user.email:
changing_email = True
users_with_email = User.objects.filter(_email_plain=new_email)
users_with_email = User.objects.filter(_email_hash__sha256=new_email)

send_dotdigital_email(
campaign_ids["email_change_notification"],
Expand Down
14 changes: 10 additions & 4 deletions codeforlife/legacy/helpers/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,23 @@
def get_random_username():
while True:
random_username = uuid4().hex[:30] # generate a random username
if not User.objects.filter(_username_plain=random_username).exists():
if not User.objects.filter(
_username_hash__sha256=random_username
).exists():
return random_username


def generate_new_student_name(orig_name):
if not Student.objects.filter(new_user___username_plain=orig_name).exists():
if not Student.objects.filter(
new_user___username_hash__sha256=orig_name
).exists():
return orig_name

i = 1
while True:
new_name = orig_name + str(i)
if not Student.objects.filter(
new_user___username_plain=new_name
new_user___username_hash__sha256=new_name
).exists():
return new_name
i += 1
Expand All @@ -40,7 +44,9 @@ def generate_access_code():
random.choice(string.ascii_uppercase) for _ in range(5)
)

if not Class.objects.filter(_access_code_plain=access_code).exists():
if not Class.objects.filter(
_access_code_hash__sha256=access_code
).exists():
return access_code


Expand Down
10 changes: 6 additions & 4 deletions codeforlife/legacy/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ def test_indep_student_pending_class_request_on_delete(self):

klass.anonymise()

indep_student = Student.objects.get(new_user___username_plain=username)
indep_student = Student.objects.get(
new_user___username_hash__sha256=username
)

assert indep_student.pending_class_request is None

Expand Down Expand Up @@ -68,9 +70,9 @@ def test_school_admins(self):
join_teacher_to_organisation(email2, school.name)
join_teacher_to_organisation(email3, school.name, is_admin=True)

teacher1 = Teacher.objects.get(new_user___username_plain=email1)
teacher2 = Teacher.objects.get(new_user___username_plain=email2)
teacher3 = Teacher.objects.get(new_user___username_plain=email3)
teacher1 = Teacher.objects.get(new_user___username_hash__sha256=email1)
teacher2 = Teacher.objects.get(new_user___username_hash__sha256=email2)
teacher3 = Teacher.objects.get(new_user___username_hash__sha256=email3)

assert len(school.admins()) == 2
assert teacher1 in school.admins()
Expand Down
2 changes: 1 addition & 1 deletion codeforlife/legacy/tests/utils/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def create_class_directly(
if class_name is not None:
name = class_name

teacher = Teacher.objects.get(new_user___email_plain=teacher_email)
teacher = Teacher.objects.get(new_user___email_hash__sha256=teacher_email)

klass = Class.objects.create(
name=name, access_code=access_code, teacher=teacher
Expand Down
6 changes: 3 additions & 3 deletions codeforlife/legacy/tests/utils/organisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def create_organisation_directly(teacher_email, **kwargs):

school = School.objects.create(name=name, country="GB")

teacher = Teacher.objects.get(new_user___email_plain=teacher_email)
teacher = Teacher.objects.get(new_user___email_hash__sha256=teacher_email)
teacher.school = school
teacher.is_admin = True
teacher.save()
Expand All @@ -29,8 +29,8 @@ def create_organisation_directly(teacher_email, **kwargs):


def join_teacher_to_organisation(teacher_email, org_name, is_admin=False):
teacher = Teacher.objects.get(new_user___email_plain=teacher_email)
school = School.objects.get(_name_plain=org_name)
teacher = Teacher.objects.get(new_user___email_hash__sha256=teacher_email)
school = School.objects.get(_name_hash__sha256=org_name)

teacher.school = school
teacher.is_admin = is_admin
Expand Down
4 changes: 2 additions & 2 deletions codeforlife/legacy/tests/utils/student.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ def create_school_student_directly(access_code) -> Tuple[str, str, Student]:
"""
name, password = generate_school_details()

klass = Class.objects.get(_access_code_plain=access_code)
klass = Class.objects.get(_access_code_hash__sha256=access_code)

student = Student.objects.schoolFactory(klass, name, password)
return name, password, student


def create_student_with_direct_login(access_code) -> Tuple[Student, str]:
name, password = generate_school_details()
klass = Class.objects.get(_access_code_plain=access_code)
klass = Class.objects.get(_access_code_hash__sha256=access_code)

# use random string for direct login)
login_id, hashed_login_id = generate_login_id()
Expand Down
2 changes: 1 addition & 1 deletion codeforlife/legacy/tests/utils/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
def get_superuser():
"""Get a superuser for testing, or create one if there isn't one."""
try:
return User.objects.get(_username_plain="superuser")
return User.objects.get(_username_hash__sha256="superuser")
except User.DoesNotExist:
return User.objects.create_superuser(
"superuser", "superuser@codeforlife.education", "password"
Expand Down
31 changes: 31 additions & 0 deletions codeforlife/models/fields/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
© Ocado Group
Created on 13/05/2026 at 12:17:05(+01:00).
"""

from functools import wraps

from ...types import PropertySetter, Validator
from .utils import validate_value


def validated_field_setter(*validators: Validator, blank=False, null=False):
"""Decorator to apply validators to a property setter method.

Validators should raise a ValidationError if the value is invalid.

Args:
*validators: Validator functions to apply to the value.
blank: If True, allows empty string values without validation.
null: If True, allows None values without validation.
"""

def decorator(fset: PropertySetter) -> PropertySetter:
@wraps(fset)
def wrapped(instance, value):
validate_value(value, *validators, blank=blank, null=null)
return fset(instance, value)

return wrapped

return decorator
25 changes: 25 additions & 0 deletions codeforlife/models/fields/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""
© Ocado Group
Created on 27/05/2026 at 15:38:57(+01:00).
"""

from ...types import Validator


def validate_value(value, *validators: Validator, blank=False, null=False):
"""Validate a field value using the provided validators.

Validators should raise a ValidationError if the value is invalid.

Args:
value: The value to validate.
*validators: Validator functions to apply to the value.
blank: If True, allows empty string values without validation.
null: If True, allows None values without validation.
"""

if (value == "" and blank) or (value is None and null):
return

for validator in validators:
validator(value) # should raise ValidationError if invalid
10 changes: 10 additions & 0 deletions codeforlife/settings/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,13 @@
},
},
]

# System checks
# https://docs.djangoproject.com/en/5.1/ref/checks/

SILENCED_SYSTEM_CHECKS = [
# The `User` model's `_username_hash` is set to `unique=False` (implicitly)
# but there's a unique constraint in the model's meta on the condition that
# the `_username_hash` is not empty ("").
"auth.W004",
]
7 changes: 6 additions & 1 deletion codeforlife/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@
DataDict = t.Dict[str, t.Any]
OrderedDataDict = t.OrderedDict[str, t.Any]

Validators = t.Sequence[t.Callable]
PropertyGetter = t.Callable[[t.Any], t.Any]
PropertySetter = t.Callable[[t.Any, t.Any], None]
PropertyDeleter = t.Callable[[t.Any], None]

Validator = t.Callable[[t.Any], None]
Validators = t.Sequence[Validator]

LogLevel = t.Literal[
"CRITICAL",
Expand Down
4 changes: 0 additions & 4 deletions codeforlife/user/fixtures/google_users.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@
"fields": {
"_email_enc": "ZmFrZV9lbmM6yMZoeJAUbdxMaVb2rDtkRymEfNCh6Z57unJt0wfyDhpR2SuwDh6iqEWHcwKOiTLFSd2PBjTXnQ==",
"_email_hash": "ee95f43c0012fa1a9d5771313a7034cf94af568b0588b34ca20c34c25701af78",
"_email_plain": "google.teacher@noschool.com",
"_first_name_enc": "ZmFrZV9lbmM6KXn5yJbvaIAq1O8qATyemFxIK7GJRkWh1tdv/QaBc/4PQw==",
"_first_name_hash": "a7e4c63feb2b46212c35276010cfcc7a0a8a021f42aefab89765c211cc794870",
"_first_name_plain": "Google",
"_last_name_enc": "ZmFrZV9lbmM6DYRgnaINtnv2v6s09apDac1iGUCekmo7k4MuZ5TVKwhWdUE=",
"_last_name_plain": "Teacher",
"_username_enc": "ZmFrZV9lbmM6neI4BdO9uaVQxpJirgXjv1zaBdn5G850jNO4G+yLhIFggC2qe6BUBfvGOIfPHcRpOLNjeC/eVA==",
"_username_hash": "ee95f43c0012fa1a9d5771313a7034cf94af568b0588b34ca20c34c25701af78",
"_username_plain": "google.teacher@noschool.com",
"dek": "ZmFrZV9lbmM658n6erabwmKyOMZuC5pc34LlWgk2C+OounRdomC5y4HgPHs82e9t36Ht5XDh3oMcduegpgH6KtzPYofw",
"password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0="
}
Expand Down
8 changes: 0 additions & 8 deletions codeforlife/user/fixtures/independent.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@
"fields": {
"_email_enc": "ZmFrZV9lbmM6BbOMTZgfEvfGWqqJMXewiatccqnrYAwXP0TL2Yg0XcRCkDjU88u6fN7m92841onGLZd38g==",
"_email_hash": "8efc7683cec3a31792d0c22f49a1cc374ba2ae2199b3cb8d228a7cbabe8370b2",
"_email_plain": "indy.requester@email.com",
"_first_name_enc": "ZmFrZV9lbmM6pCLIN/sWZNWKMxn/nDrhIxPqZJOvgjlMCMMbSP6LavQ=",
"_first_name_hash": "fe1fe542767696689c8767d1b1e86734ce210252c07acc349c3a9f6175994e20",
"_first_name_plain": "Indy",
"_last_name_enc": "ZmFrZV9lbmM6/3o0FjosbmbS1oVTg10ezTJdBjsJBeuKxIHRFNBmZPASWT+9dA==",
"_last_name_plain": "Requester",
"_username_enc": "ZmFrZV9lbmM6JGWj0OAH3zI4LTiClJre31xNzlULtOKIzFOS6JgTsFfMkeDtW6VSWme6PwPwn294DfkYbw==",
"_username_hash": "8efc7683cec3a31792d0c22f49a1cc374ba2ae2199b3cb8d228a7cbabe8370b2",
"_username_plain": "indy.requester@email.com",
"dek": "ZmFrZV9lbmM6L2LWK8EitcxU86uC69XSb5nbOso2Hsy7FjBH14+a3er944CPFMfyBfV0x3Hs3fxjYHMqP/hUpoPxRx6g",
"password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0="
}
Expand Down Expand Up @@ -41,15 +37,11 @@
"fields": {
"_email_enc": "ZmFrZV9lbmM6cGgLFQ0NIcL8dvu9xyEsz5Stwyvl7Qp9OYPPnCQAv/+ZO6yF2vjR6iI1",
"_email_hash": "63e90930d7617f596f1006362382d81aecb8308c5a9893007f78c50e954e6d1a",
"_email_plain": "indy@email.com",
"_first_name_enc": "ZmFrZV9lbmM6P0Fsz+sx2cuXNgTPn0AoikMvFz67Uy2F6X+I2kCPTOg=",
"_first_name_hash": "fe1fe542767696689c8767d1b1e86734ce210252c07acc349c3a9f6175994e20",
"_first_name_plain": "Indy",
"_last_name_enc": "ZmFrZV9lbmM6aOhhzg9mD3C0ROh1lCuDC1XKskZ6DYh6ajmv7jRUFFx4GyBY4w==",
"_last_name_plain": "NoRequest",
"_username_enc": "ZmFrZV9lbmM6Mex03gOlRJsMhPARyGhxY10G8lrOTR0l7LSV3AeH2IgllqHHMkd8fkvB",
"_username_hash": "63e90930d7617f596f1006362382d81aecb8308c5a9893007f78c50e954e6d1a",
"_username_plain": "indy@email.com",
"dek": "ZmFrZV9lbmM6kNpc5tLn74rFhZeIxmToumntwifCY5oqPfrL3VTp7Xa962lNlx3jEdSyUbn2WjHaAHLrKWINQQX9f6dp",
"password": "pbkdf2_sha256$720000$Jp50WPBA6WZImUIpj3UcVm$OJWB8+UoW5lLaUkHLYo0cKgMkyRI6qnqVOWxYEsi9T0="
}
Expand Down
Loading
Loading