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

Handle join class requests #308

Merged
merged 27 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from 13 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 backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "pypi"
# Before adding a new package, check it's not listed under [packages] at
# https://github.com/ocadotechnology/codeforlife-package-python/blob/{ref}/Pipfile
# Replace "{ref}" in the above URL with the ref set below.
codeforlife = {ref = "v0.13.10", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"}
codeforlife = {ref = "v0.13.13", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"}
# TODO: check if we need the below packages
whitenoise = "==6.5.0"
django-pipeline = "==2.0.8"
Expand All @@ -34,7 +34,7 @@ google-cloud-container = "==2.3.0"
# Before adding a new package, check it's not listed under [dev-packages] at
# https://github.com/ocadotechnology/codeforlife-package-python/blob/{ref}/Pipfile
# Replace "{ref}" in the above URL with the ref set below.
codeforlife = {ref = "v0.13.10", git = "https://github.com/ocadotechnology/codeforlife-package-python.git", extras = ["dev"]}
codeforlife = {ref = "v0.13.13", git = "https://github.com/ocadotechnology/codeforlife-package-python.git", extras = ["dev"]}
# TODO: check if we need the below packages
django-selenium-clean = "==0.3.3"
django-test-migrations = "==1.2.0"
Expand Down
8 changes: 4 additions & 4 deletions backend/Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[
{
"model": "common.student",
"pk": 18,
"fields": {
"user": 28,
"new_user": 28,
"pending_class_request": 7
}
}
]
2 changes: 1 addition & 1 deletion backend/api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
© Ocado Group
Created on 06/02/2024 at 15:13:00(+00:00).
"""

# TODO: Move from common to here and update to match new models pattern
from common.models import SchoolTeacherInvitation
14 changes: 14 additions & 0 deletions backend/api/serializers/klass.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,17 @@ def create(self, validated_data):
),
}
)

def update(self, instance, validated_data):
# TODO: Decide how to handle this field. Currently, teachers select a
# setting which equates to a number of "hours", then we calculate
# how long the class can accept requests for based on that number of
# hours. It's not the most elegant solution, so for now I've written
# this assuming that the user will input a date-time value.
accept_requests_until = validated_data.get("accept_requests_until")

if accept_requests_until is not None:
instance.accept_requests_until = accept_requests_until

instance.save()
return instance
6 changes: 2 additions & 4 deletions backend/api/serializers/student.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ def validate_klass(self, value: str):
klass = Class.objects.get(access_code=value)
except Class.DoesNotExist as ex:
raise serializers.ValidationError(
"Class does not exist.",
code="does_not_exist",
"Class does not exist.", code="does_not_exist"
) from ex

if klass.teacher.school_id != teacher.school_id:
Expand All @@ -47,8 +46,7 @@ def validate(self, attrs):
instance = t.cast(t.Optional[User], self.parent.instance)
if instance and not instance.student:
raise serializers.ValidationError(
"Target user is not a student.",
code="not_student",
"Target user is not a student.", code="not_student"
)

return attrs
121 changes: 110 additions & 11 deletions backend/api/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from django.contrib.auth.password_validation import (
validate_password as _validate_password,
)
from django.utils import timezone
from rest_framework import serializers

from .student import StudentSerializer
Expand Down Expand Up @@ -90,6 +91,9 @@ def get_first_name(user_fields: t.Dict[str, t.Any]):
class UserSerializer(_UserSerializer):
student = StudentSerializer(source="new_student", required=False)
teacher = TeacherSerializer(source="new_teacher", required=False)
requesting_to_join_class = serializers.CharField(
required=False, allow_blank=True
)
current_password = serializers.CharField(
write_only=True,
required=False,
Expand All @@ -101,6 +105,7 @@ class Meta(_UserSerializer.Meta):
"student",
"teacher",
"password",
"requesting_to_join_class",
"current_password",
]
extra_kwargs = {
Expand All @@ -113,22 +118,73 @@ class Meta(_UserSerializer.Meta):
list_serializer_class = UserListSerializer

def validate(self, attrs):
if self.instance is not None and self.view.action != "reset-password":
# TODO: make current password required when changing self-profile.
pass
if self.instance: # Updating
if self.view.action != "reset_password":
# TODO: make current password required when changing
# self-profile.
pass

if self.instance.teacher:
if "last_name" in attrs and not attrs["last_name"]:
raise serializers.ValidationError(
"Last name is required.", code="last_name_required"
)

if "new_teacher" in attrs and "last_name" not in attrs:
raise serializers.ValidationError(
"Last name is required.", code="last_name_required"
)
if "requesting_to_join_class" in attrs:
raise serializers.ValidationError(
"Teacher can't request to join a class.",
code="teacher_cannot_request_to_join_class",
)

if self.instance.student:
if self.view.action != "reset_password":
if self.request.student_user.pk != self.instance.pk:
raise serializers.ValidationError(
"Cannot update another student.", code="is_not_self"
)

if (
self.instance.student.class_field is not None
and "requesting_to_join_class" in attrs
) or (
self.instance.student.pending_class_request is not None
and "new_student" in attrs
and "class_field" in attrs["new_student"]
):
raise serializers.ValidationError(
"Student can't be in a class and requesting to join a "
"class at the same time.",
code="class_and_join_class_mutually_exclusive",
)

else: # Creating
if "new_teacher" in attrs:
if "last_name" not in attrs or attrs["last_name"] is None:
raise serializers.ValidationError(
"Last name is required.", code="last_name_required"
)

if "requesting_to_join_class" in attrs:
raise serializers.ValidationError(
"Teacher can't request to join a class.",
code="teacher_cannot_request_to_join_class",
)

if "new_student" in attrs:
if (
"class_field" in attrs["new_student"]
and "requesting_to_join_class" in attrs
):
raise serializers.ValidationError(
"Student can't be in a class and requesting to join a "
"class at the same time.",
code="class_and_join_class_mutually_exclusive",
)

return attrs

def validate_password(self, value: str):
"""
Validate the new password depending on user type.
"""

"""Validate the new password depending on user type."""
# If we're creating a new user, we do not yet have the user object.
# Therefore, we need to create a dummy user and pass it to the password
# validators so they know what type of user we have.
Expand All @@ -146,6 +202,30 @@ def validate_password(self, value: str):

return value

def validate_requesting_to_join_class(self, value: str):
# NOTE: Error message is purposefully ambiguous to prevent class
# enumeration.
error_message = "Class does not exist or does not accept join requests."
error_code = "does_not_exist_or_accept_join_requests"

if value != "":
try:
klass = Class.objects.get(access_code=value)
except Class.DoesNotExist as ex:
raise serializers.ValidationError(
error_message, code=error_code
) from ex

if (
klass.accept_requests_until is None
or klass.accept_requests_until < timezone.now()
):
raise serializers.ValidationError(
error_message, code=error_code
)

return value

def create(self, validated_data):
user = User.objects.create_user(
username=validated_data["email"],
Expand Down Expand Up @@ -175,6 +255,25 @@ def create(self, validated_data):
return user

def update(self, instance, validated_data):
requesting_to_join_class = validated_data.get(
"requesting_to_join_class"
)
if requesting_to_join_class is not None:
# If value is empty, this means independent wants to revoke their
# join request
if requesting_to_join_class == "":
instance.student.pending_class_request = None
else:
instance.student.pending_class_request = Class.objects.get(
access_code=requesting_to_join_class
)

# TODO: Send email to indy user confirming successful join request.
# TODO: Send email to teacher of selected class to notify them of
# join request.

instance.student.save()

password = validated_data.get("password")

if password is not None:
Expand Down
30 changes: 6 additions & 24 deletions backend/api/tests/serializers/test_klass.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,15 @@ def setUp(self):
self.class_1 = Class.objects.get(name="Class 1 @ School 1")

def test_validate_teacher__does_not_exist(self):
"""
Teacher must exist.
"""

"""Teacher must exist."""
self.assert_validate_field(
name="teacher",
value=-1,
error_code="does_not_exist",
)

def test_validate_teacher__not_in_school(self):
"""
Teacher must be in school.
"""

"""Teacher must be in school."""
teacher = Teacher.objects.exclude(
school=self.school_teacher_user.teacher.school
).first()
Expand All @@ -53,10 +47,7 @@ def test_validate_teacher__not_in_school(self):
)

def test_validate_teacher__not_admin(self):
"""
Teacher cannot assign another teacher if they're not an admin.
"""

"""Teacher cannot assign another teacher if they're not an admin."""
assert not self.school_teacher_user.teacher.is_admin

teacher = (
Expand All @@ -80,10 +71,7 @@ def test_validate_teacher__not_admin(self):
)

def test_validate_name__name_not_unique(self):
"""
Class names must be unique per school.
"""

"""Class names must be unique per school."""
self.assert_validate_field(
name="name",
value=self.class_1.name,
Expand All @@ -96,10 +84,7 @@ def test_validate_name__name_not_unique(self):
)

def test_create__teacher(self):
"""
Can successfully create with setting the teacher field.
"""

"""Can successfully create with setting the teacher field."""
self.assert_create(
{
"name": "ExampleClass",
Expand All @@ -111,10 +96,7 @@ def test_create__teacher(self):
)

def test_create__no_teacher(self):
"""
Can successfully create without setting the teacher field.
"""

"""Can successfully create without setting the teacher field."""
self.assert_create(
{
"name": "ExampleClass",
Expand Down
Loading
Loading