Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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