Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Portal frontend 40 #391

Merged
merged 5 commits into from
Feb 26, 2025
Merged
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
4 changes: 2 additions & 2 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ name = "pypi"
# 5. Run `pipenv install --dev` in your terminal.

[packages]
codeforlife = "==0.25.3"
codeforlife = "==0.25.4"
# 🚫 Don't add [packages] below that are inherited from the CFL package.
pyjwt = "==2.6.0" # TODO: upgrade to latest version
# TODO: Needed by RR. Remove when RR has moved to new system.
Expand All @@ -32,7 +32,7 @@ django-sekizai = "==4.1.0"
django-classy-tags = "==4.1.0"

[dev-packages]
codeforlife = {version = "==0.25.3", extras = ["dev"]}
codeforlife = {version = "==0.25.4", extras = ["dev"]}
# codeforlife = {file = "../codeforlife-package-python", editable = true, extras = ["dev"]}
# 🚫 Don't add [dev-packages] below that are inherited from the CFL package.

Expand Down
646 changes: 438 additions & 208 deletions Pipfile.lock

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@

secrets = set_up_settings(BASE_DIR, service_name="portal")

# pylint: disable-next=wrong-import-position,wildcard-import,unused-wildcard-import
from codeforlife.settings import *

SECRET_KEY = secrets.SECRET_KEY

# ------------------------------------------------------------------------------
# TODO: Clean settings below
# ------------------------------------------------------------------------------
Expand Down Expand Up @@ -80,7 +85,10 @@

if os.environ.get("STATIC_MODE", "") == "pipeline":
STATICFILES_FINDERS = ["pipeline.finders.PipelineFinder"]
STATICFILES_STORAGE = "pipeline.storage.PipelineStorage"
STORAGES = {
**STORAGES, # type: ignore[has-type]
"staticfiles": {"BACKEND": "pipeline.storage.PipelineStorage"},
}

PIPELINE = {
"COMPILERS": ("portal.pipeline_compilers.LibSassCompiler",),
Expand Down Expand Up @@ -251,8 +259,6 @@ def domain():
# TODO: Clean settings above
# ------------------------------------------------------------------------------

# pylint: disable-next=wrong-import-position,wildcard-import,unused-wildcard-import
from codeforlife.settings import *

ROOT_URLCONF = "src.urls"

Expand Down
1 change: 1 addition & 0 deletions src/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .auth_factor import AuthFactorSerializer
from .klass import ReadClassSerializer, WriteClassSerializer
from .otp_bypass_token import OtpBypassTokenSerializer
from .school import SchoolSerializer
from .school_teacher_invitation import (
AcceptSchoolTeacherInvitationSerializer,
Expand Down
9 changes: 7 additions & 2 deletions src/api/serializers/auth_factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import re

from codeforlife.serializers import ModelSerializer
from codeforlife.user.models import AuthFactor, User
from codeforlife.user.models import AuthFactor, OtpBypassToken, User
from rest_framework import serializers

# pylint: disable=missing-class-docstring
Expand Down Expand Up @@ -62,4 +62,9 @@ def validate(self, attrs):
def create(self, validated_data):
validated_data["user_id"] = self.request.auth_user.id
validated_data.pop("otp", None)
return super().create(validated_data)
auth_factor = super().create(validated_data)

if auth_factor.type == AuthFactor.Type.OTP:
OtpBypassToken.objects.bulk_create(auth_factor.user)

return auth_factor
9 changes: 8 additions & 1 deletion src/api/serializers/auth_factor_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
from unittest.mock import Mock, patch

from codeforlife.tests import ModelSerializerTestCase
from codeforlife.user.models import AuthFactor, TeacherUser, User
from codeforlife.user.models import (
AuthFactor,
OtpBypassToken,
TeacherUser,
User,
)

from .auth_factor import AuthFactorSerializer

Expand Down Expand Up @@ -99,3 +104,5 @@ def test_create__otp(self):
new_data={"user": user.id},
context={"request": self.request_factory.post(user=user)},
)

assert user.otp_bypass_tokens.count() == OtpBypassToken.max_count
17 changes: 17 additions & 0 deletions src/api/serializers/otp_bypass_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
© Ocado Group
Created on 20/02/2025 at 15:23:05(+00:00).
"""

from codeforlife.serializers import ModelSerializer
from codeforlife.user.models import OtpBypassToken, User

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


class OtpBypassTokenSerializer(ModelSerializer[User, OtpBypassToken]):
class Meta:
model = OtpBypassToken
fields = ["decrypted_token"]
extra_kwargs = {"decrypted_token": {"read_only": True}}
24 changes: 14 additions & 10 deletions src/api/views/otp_bypass_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,34 @@

from codeforlife.permissions import AllowNone
from codeforlife.request import Request
from codeforlife.user.models import OtpBypassToken, User
from codeforlife.user.models import AuthFactor, OtpBypassToken, User
from codeforlife.user.permissions import IsTeacher
from codeforlife.views import ModelViewSet, action
from rest_framework import status
from rest_framework.response import Response

from ..permissions import HasAuthFactor
from ..serializers import OtpBypassTokenSerializer


# pylint: disable-next=missing-class-docstring,too-many-ancestors
class OtpBypassTokenViewSet(ModelViewSet[User, OtpBypassToken]):
request_user_class = User
model_class = OtpBypassToken
http_method_names = ["post"]
serializer_class = OtpBypassTokenSerializer
http_method_names = ["get", "post"]

# pylint: disable-next=missing-function-docstring
def get_permissions(self):
if self.action in ["create", "bulk"]:
if self.action in ["retrieve", "create", "bulk"]:
return [AllowNone()]

return [IsTeacher()]
return [IsTeacher(), HasAuthFactor(AuthFactor.Type.OTP)]

# pylint: disable-next=missing-function-docstring
def get_queryset(self):
return OtpBypassToken.objects.filter(user=self.request.auth_user)

# TODO: replace this custom action with bulk create and list serializer.
@action(detail=False, methods=["post"])
def generate(self, request: Request):
"""
Expand All @@ -37,9 +44,6 @@ def generate(self, request: Request):
otp_bypass_tokens = OtpBypassToken.objects.bulk_create(
request.auth_user
)
serializer = self.serializer_class(otp_bypass_tokens, many=True)

return Response(
# pylint: disable-next=protected-access
[otp_bypass_token._token for otp_bypass_token in otp_bypass_tokens],
status.HTTP_201_CREATED,
)
return Response(serializer.data, status.HTTP_201_CREATED)
49 changes: 45 additions & 4 deletions src/api/views/otp_bypass_token_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@

from codeforlife.permissions import AllowNone
from codeforlife.tests import ModelViewSetTestCase
from codeforlife.user.models import OtpBypassToken, User
from codeforlife.user.models import AuthFactor, OtpBypassToken, User
from codeforlife.user.permissions import IsTeacher
from rest_framework import status

from ..permissions import HasAuthFactor
from .otp_bypass_token import OtpBypassTokenViewSet


Expand All @@ -27,6 +28,23 @@ def setUp(self):

# test: get permissions

def test_get_permissions__retrieve(self):
"""No one can retrieve a single otp-bypass-token."""
self.assert_get_permissions(
permissions=[AllowNone()],
action="retrieve",
)

def test_get_permissions__list(self):
"""
Only teachers who have enabled OTP as an auth factor can list
otp-bypass-tokens.
"""
self.assert_get_permissions(
permissions=[IsTeacher(), HasAuthFactor(AuthFactor.Type.OTP)],
action="list",
)

def test_get_permissions__create(self):
"""No one can create a single otp-bypass-token."""
self.assert_get_permissions(
Expand All @@ -42,14 +60,32 @@ def test_get_permissions__bulk(self):
)

def test_get_permissions__generate(self):
"""Only teachers can generate otp-bypass-tokens."""
"""
Only teachers who have enabled OTP as an auth factor can generate
otp-bypass-tokens.
"""
self.assert_get_permissions(
permissions=[IsTeacher()],
permissions=[IsTeacher(), HasAuthFactor(AuthFactor.Type.OTP)],
action="generate",
)

# test: get queryset

def test_get_queryset__list(self):
"""Users can only list their own OTP bypass tokens."""
self.assert_get_queryset(
values=self.user.otp_bypass_tokens.all(),
action="list",
request=self.client.request_factory.get(user=self.user),
)

# test: actions

def test_list(self):
"""Can list a user's OTP bypass tokens."""
self.client.login(email=self.user.email, password="password")
self.client.list(self.user.otp_bypass_tokens.all())

def test_generate(self):
"""Generate max number of OTP bypass tokens."""
otp_bypass_token_pks = list(
Expand Down Expand Up @@ -91,7 +127,12 @@ def test_generate(self):
)

# We received the expected tokens.
assert set(response.json()) == tokens
response_json = response.json()
assert isinstance(response_json, list) and tokens == {
otp_bypass_token["decrypted_token"]
for otp_bypass_token in response_json
if isinstance(otp_bypass_token, dict)
}

# The user's pre-existing tokens were deleted.
assert not OtpBypassToken.objects.filter(
Expand Down