Skip to content

Commit ed7b3f7

Browse files
authored
fix: char set validators (#154)
* add validators * validators * read only * delete unnecessary comment * clean up * delete codecov.yml * support unicode * fix regex validations * unicode tests * fix linting error * fix tests * fix linting errors * test ascii does not include unicode * feedback * class_access_code_validators * regex validator test case
1 parent c40d032 commit ed7b3f7

33 files changed

+1491
-243
lines changed

.github/workflows/main.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ jobs:
2929
if: |
3030
github.repository_owner_id == 2088731 &&
3131
github.ref_name == github.event.repository.default_branch
32+
env:
33+
CONFIG: releaserc.toml
3234
steps:
3335
- name: 🛫 Checkout
3436
uses: actions/checkout@v4
@@ -59,7 +61,7 @@ jobs:
5961
run: |
6062
PYTHON_SCRIPT="from codeforlife import __version__; print(__version__)"
6163
echo OLD_VERSION=$(python -c "$PYTHON_SCRIPT") >> $GITHUB_ENV
62-
semantic-release version
64+
semantic-release --config=${{ env.CONFIG }} version
6365
echo NEW_VERSION=$(python -c "$PYTHON_SCRIPT") >> $GITHUB_ENV
6466
6567
- name: 🏗️ Build Distributions
@@ -70,7 +72,7 @@ jobs:
7072
if: env.OLD_VERSION != env.NEW_VERSION
7173
env:
7274
GH_TOKEN: ${{ secrets.CFL_BOT_GH_TOKEN }}
73-
run: semantic-release publish
75+
run: semantic-release --config=${{ env.CONFIG }} publish
7476

7577
- name: 🚀 Publish to PyPI
7678
if: env.OLD_VERSION != env.NEW_VERSION

.vscode/settings.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
},
99
"black-formatter.args": [
1010
"--config",
11-
"pyproject.toml"
11+
"../configs/backend/pyproject.toml"
1212
],
1313
"black-formatter.path": [
1414
".venv/bin/python",
@@ -37,23 +37,23 @@
3737
"**/__pycache__": true
3838
},
3939
"isort.args": [
40-
"--settings-file=pyproject.toml"
40+
"--settings-file=../configs/backend/pyproject.toml"
4141
],
4242
"isort.path": [
4343
".venv/bin/python",
4444
"-m",
4545
"isort"
4646
],
4747
"mypy-type-checker.args": [
48-
"--config-file=pyproject.toml"
48+
"--config-file=../configs/backend/pyproject.toml"
4949
],
5050
"mypy-type-checker.path": [
5151
".venv/bin/python",
5252
"-m",
5353
"mypy"
5454
],
5555
"pylint.args": [
56-
"--rcfile=pyproject.toml"
56+
"--rcfile=../configs/backend/pyproject.toml"
5757
],
5858
"pylint.path": [
5959
".venv/bin/python",
@@ -65,7 +65,7 @@
6565
"-n=auto",
6666
"--cov=.",
6767
"--cov-report=html",
68-
"-c=pyproject.toml",
68+
"-c=../configs/backend/pyproject.toml",
6969
"."
7070
],
7171
"python.testing.pytestEnabled": true,

Pipfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ django-storages = {version = "==1.14.4", extras = ["s3"]}
1818
pyotp = "==2.9.0"
1919
python-dotenv = "==1.0.1"
2020
psycopg2-binary = "==2.9.9"
21+
regex = "==2024.11.6"
2122
requests = "==2.32.2"
2223
gunicorn = "==23.0.0"
2324
uvicorn-worker = "==0.2.0"
@@ -48,6 +49,7 @@ isort = "==5.13.2"
4849
mypy = "==1.6.1"
4950
django-stubs = {version = "==4.2.6", extras = ["compatible-mypy"]}
5051
djangorestframework-stubs = {version = "==3.14.4", extras = ["compatible-mypy"]}
52+
types-regex = "==2024.11.6.*"
5153

5254
[requires]
5355
python_version = "3.12"

Pipfile.lock

Lines changed: 250 additions & 140 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codecov.yml

Lines changed: 0 additions & 11 deletions
This file was deleted.

codeforlife/settings/custom.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000"))
2929

3030
# The base url of the current service.
31-
# The root service does not need its name included in the base url.
3231
SERVICE_BASE_URL = f"{SERVICE_PROTOCOL}://{SERVICE_DOMAIN}:{SERVICE_PORT}"
3332

3433
# The domain without the last level and a preceding dot.

codeforlife/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@
2121
from .model_view_set import BaseModelViewSetTestCase, ModelViewSetTestCase
2222
from .model_view_set_client import BaseModelViewSetClient, ModelViewSetClient
2323
from .test import Client, TestCase
24+
from .validator import RegexValidatorTestCase, ValidatorTestCase

codeforlife/tests/validator.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
© Ocado Group
3+
Created on 14/03/2025 at 10:04:16(+00:00).
4+
"""
5+
6+
import typing as t
7+
8+
from django.core.validators import RegexValidator
9+
10+
from .test import TestCase
11+
12+
AnyValidator = t.TypeVar("AnyValidator", bound=t.Callable)
13+
AnyRegexValidator = t.TypeVar("AnyRegexValidator", bound=RegexValidator)
14+
15+
16+
class ValidatorTestCase(TestCase, t.Generic[AnyValidator]):
17+
"""Test case with utilities for testing validators."""
18+
19+
validator_class: t.Type[AnyValidator]
20+
21+
22+
class RegexValidatorTestCase(
23+
ValidatorTestCase[AnyRegexValidator], t.Generic[AnyRegexValidator]
24+
):
25+
"""Test case with utilities for testing regex validators."""
26+
27+
def assert_raises_validation_error(self, *args, **kwargs):
28+
return super().assert_raises_validation_error(
29+
self.validator_class.code, *args, **kwargs
30+
)

codeforlife/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
DataDict = t.Dict[str, t.Any]
2222
OrderedDataDict = t.OrderedDict[str, t.Any]
2323

24+
Validators = t.Sequence[t.Callable]
25+
2426

2527
def get_arg(cls: t.Type[t.Any], index: int, orig_base: int = 0):
2628
"""Get a type arg from a class.

codeforlife/user/models/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
"""
55

66
from .auth_factor import AuthFactor
7-
from .klass import Class
7+
from .klass import Class, class_name_validators
88
from .otp_bypass_token import OtpBypassToken
9-
from .school import School
9+
from .school import School, school_name_validators
1010
from .session import Session
1111
from .session_auth_factor import SessionAuthFactor
1212
from .student import Independent, Student
@@ -35,4 +35,6 @@
3535
TypedUser,
3636
User,
3737
UserProfile,
38+
user_first_name_validators,
39+
user_last_name_validators,
3840
)

codeforlife/user/models/auth_factor.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55

66
import typing as t
77

8+
from django.core.validators import MaxLengthValidator, MinLengthValidator
89
from django.db import models
910
from django.db.models.query import QuerySet
1011
from django.utils.translation import gettext_lazy as _
1112

13+
from ...types import Validators
14+
from ...validators import AsciiNumericCharSetValidator
1215
from .user import User
1316

1417
if t.TYPE_CHECKING: # pragma: no cover
@@ -20,6 +23,12 @@ class AuthFactor(models.Model):
2023

2124
sessions: QuerySet["SessionAuthFactor"]
2225

26+
otp_validators: Validators = [
27+
AsciiNumericCharSetValidator(),
28+
MinLengthValidator(6),
29+
MaxLengthValidator(6),
30+
]
31+
2332
class Type(models.TextChoices):
2433
"""The type of authentication factor."""
2534

codeforlife/user/models/klass.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,22 @@
55

66
# pylint: disable-next=unused-import
77
from common.models import Class # type: ignore[import-untyped]
8+
from django.core.validators import MaxLengthValidator, MinLengthValidator
9+
10+
from ...validators import (
11+
UnicodeAlphanumericCharSetValidator,
12+
UppercaseAsciiAlphanumericCharSetValidator,
13+
)
14+
15+
class_access_code_validators = [
16+
MinLengthValidator(5),
17+
MaxLengthValidator(5),
18+
UppercaseAsciiAlphanumericCharSetValidator(),
19+
]
20+
21+
class_name_validators = [
22+
UnicodeAlphanumericCharSetValidator(
23+
spaces=True,
24+
special_chars="-_",
25+
)
26+
]

codeforlife/user/models/otp_bypass_token.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88

99
from cryptography.fernet import Fernet
1010
from django.conf import settings
11+
from django.core.validators import MaxLengthValidator, MinLengthValidator
1112
from django.db import models
1213
from django.db.utils import IntegrityError
1314
from django.utils.crypto import get_random_string
1415
from django.utils.translation import gettext_lazy as _
1516

17+
from ...types import Validators
18+
from ...validators import CharSetValidatorBuilder
1619
from .user import User
1720

1821
if t.TYPE_CHECKING:
@@ -29,6 +32,14 @@ class OtpBypassToken(models.Model):
2932
length = 8
3033
allowed_chars = string.ascii_lowercase
3134
max_count = 10
35+
validators: Validators = [
36+
MinLengthValidator(length),
37+
MaxLengthValidator(length),
38+
CharSetValidatorBuilder(
39+
allowed_chars,
40+
"lowercase alpha characters (a-z)",
41+
),
42+
]
3243

3344
# pylint: disable-next=missing-class-docstring,too-few-public-methods
3445
class Manager(models.Manager["OtpBypassToken"]):

codeforlife/user/models/school.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,12 @@
55

66
# pylint: disable-next=unused-import
77
from common.models import School # type: ignore[import-untyped]
8+
9+
from ...validators import UnicodeAlphanumericCharSetValidator
10+
11+
school_name_validators = [
12+
UnicodeAlphanumericCharSetValidator(
13+
spaces=True,
14+
special_chars="'.",
15+
)
16+
]

codeforlife/user/models/user.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from ... import mail
2222
from ...models import AbstractBaseUser
23+
from ...validators import UnicodeAlphanumericCharSetValidator
2324
from .klass import Class
2425
from .school import School
2526

@@ -35,6 +36,21 @@
3536
TypedModelMeta = object
3637

3738

39+
# TODO: add to model validators in new schema.
40+
user_first_name_validators = [
41+
UnicodeAlphanumericCharSetValidator(
42+
spaces=True,
43+
special_chars="-'",
44+
)
45+
]
46+
user_last_name_validators = [
47+
UnicodeAlphanumericCharSetValidator(
48+
spaces=True,
49+
special_chars="-'",
50+
)
51+
]
52+
53+
3854
# TODO: remove in new schema
3955
class _AbstractBaseUser(AbstractBaseUser):
4056
password: str = None # type: ignore[assignment]

codeforlife/user/serializers/klass.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ...serializers import ModelSerializer
99
from ..models import Class
1010
from ..models import User as RequestUser
11+
from ..models import class_name_validators
1112

1213
# pylint: disable=missing-class-docstring
1314
# pylint: disable=too-many-ancestors
@@ -19,6 +20,13 @@ class ClassSerializer(ModelSerializer[RequestUser, Class]):
1920
read_only=True,
2021
)
2122

23+
# TODO: add to model validators in new schema.
24+
name = serializers.CharField(
25+
validators=class_name_validators,
26+
max_length=200,
27+
read_only=True,
28+
)
29+
2230
read_classmates_data = serializers.BooleanField(
2331
source="classmates_data_viewable",
2432
read_only=True,

codeforlife/user/serializers/school.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@
88
from ...serializers import ModelSerializer
99
from ..models import School
1010
from ..models import User as RequestUser
11+
from ..models import school_name_validators
1112

1213
# pylint: disable=missing-class-docstring
1314
# pylint: disable=too-many-ancestors
1415

1516

1617
class SchoolSerializer(ModelSerializer[RequestUser, School]):
18+
# TODO: add to model validators in new schema.
19+
name = serializers.CharField(
20+
validators=school_name_validators,
21+
max_length=200,
22+
read_only=True,
23+
)
24+
1725
uk_county = serializers.CharField(source="county", read_only=True)
1826

1927
class Meta:

codeforlife/user/serializers/user.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
from rest_framework import serializers
99

1010
from ...serializers import ModelSerializer
11-
from ..models import AnyUser, Student, Teacher, User
11+
from ..models import (
12+
AnyUser,
13+
Student,
14+
Teacher,
15+
User,
16+
user_first_name_validators,
17+
user_last_name_validators,
18+
)
1219
from .student import StudentSerializer
1320
from .teacher import TeacherSerializer
1421

@@ -21,6 +28,20 @@
2128
class BaseUserSerializer(
2229
ModelSerializer[RequestUser, AnyUser], t.Generic[AnyUser]
2330
):
31+
# TODO: add to model validators in new schema.
32+
first_name = serializers.CharField(
33+
validators=user_first_name_validators,
34+
max_length=150,
35+
read_only=True,
36+
)
37+
38+
# TODO: add to model validators in new schema.
39+
last_name = serializers.CharField(
40+
validators=user_last_name_validators,
41+
max_length=150,
42+
read_only=True,
43+
)
44+
2445
requesting_to_join_class = serializers.CharField(
2546
source="new_student.pending_class_request",
2647
read_only=True,

codeforlife/user/views/school.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class SchoolViewSet(ModelViewSet[User, School]):
1717
http_method_names = ["get"]
1818
serializer_class = SchoolSerializer
1919

20+
# pylint: disable-next=missing-function-docstring
2021
def get_permissions(self):
2122
# No one is allowed to list schools.
2223
if self.action == "list":

codeforlife/validators/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
© Ocado Group
3+
Created on 14/03/2025 at 08:33:12(+00:00).
4+
"""
5+
6+
from .char_set import *
7+
from .enhanced_regex import EnhancedRegexValidator

0 commit comments

Comments
 (0)