Skip to content

Commit

Permalink
reset students' password
Browse files Browse the repository at this point in the history
  • Loading branch information
SKairinos committed Feb 20, 2024
1 parent a22d4f9 commit bacf91b
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 58 deletions.
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.7", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"}
codeforlife = {ref = "reset_students_password", 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.7", git = "https://github.com/ocadotechnology/codeforlife-package-python.git", extras = ["dev"]}
codeforlife = {ref = "reset_students_password", 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
19 changes: 11 additions & 8 deletions backend/Pipfile.lock

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

47 changes: 13 additions & 34 deletions backend/api/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@
Created on 18/01/2024 at 15:14:32(+00:00).
"""

import string
import typing as t
from itertools import groupby

from codeforlife.serializers import ModelListSerializer
from codeforlife.user.models import Class, Student, Teacher, User, UserProfile
from codeforlife.user.models import (
Class,
Student,
StudentUser,
Teacher,
User,
UserProfile,
)
from codeforlife.user.serializers import UserSerializer as _UserSerializer
from django.contrib.auth.hashers import make_password
from django.contrib.auth.password_validation import (
validate_password as _validate_password,
)
from django.utils.crypto import get_random_string
from rest_framework import serializers

from .student import StudentSerializer
Expand All @@ -36,40 +40,15 @@ def create(self, validated_data):

# TODO: replace this logic with bulk creates for each object when we
# switch to PostgreSQL.
users: t.List[User] = []
for user_fields in validated_data:
password = get_random_string(
length=6,
allowed_chars=string.ascii_lowercase,
)

user = User.objects.create_user(
return [
StudentUser.objects.create_user(
first_name=user_fields["first_name"],
username=get_random_string(length=30),
password=make_password(password),
)
users.append(user)

# pylint: disable-next=protected-access
user._password = password

login_id = None
while (
login_id is None
or Student.objects.filter(login_id=login_id).exists()
):
login_id = get_random_string(length=64)

Student.objects.create(
class_field=classes[
klass=classes[
user_fields["new_student"]["class_field"]["access_code"]
],
user=UserProfile.objects.create(user=user),
new_user=user,
login_id=login_id,
)

return users
for user_fields in validated_data
]

def validate(self, attrs):
super().validate(attrs)
Expand Down
68 changes: 57 additions & 11 deletions backend/api/tests/views/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

from codeforlife.permissions import OR
from codeforlife.tests import ModelViewSetTestCase
from codeforlife.types import JsonDict
from codeforlife.user.models import (
AdminSchoolTeacherUser,
Class,
SchoolTeacherUser,
StudentUser,
User,
)
from codeforlife.user.permissions import IsTeacher
Expand Down Expand Up @@ -56,24 +58,35 @@ def _get_pk_and_token_for_user(self, email: str):
# test: get permissions

def test_get_permissions__bulk(self):
"""Only school-teachers can perform bulk actions."""
"""Only admin-teachers or class-teachers can perform bulk actions."""
self.assert_get_permissions(
permissions=[
OR(IsTeacher(is_admin=True), IsTeacher(in_class=True))
],
action="bulk",
)

def test_get_permissions__students__reset_password(self):
"""
Only admin-teachers or class-teachers can reset students' passwords.
"""
self.assert_get_permissions(
permissions=[
OR(IsTeacher(is_admin=True), IsTeacher(in_class=True))
],
action="students__reset_password",
)

def test_get_permissions__partial_update__teacher(self):
"""Only admin-school-teachers can update a teacher."""
"""Only admin-teachers can update a teacher."""
self.assert_get_permissions(
permissions=[IsTeacher(is_admin=True)],
action="partial_update",
request=self.client.request_factory.patch(data={"teacher": {}}),
)

def test_get_permissions__partial_update__student(self):
"""Only school-teachers can update a student."""
"""Only admin-teachers or class-teachers can update a student."""
self.assert_get_permissions(
permissions=[
OR(IsTeacher(is_admin=True), IsTeacher(in_class=True))
Expand All @@ -84,11 +97,7 @@ def test_get_permissions__partial_update__student(self):

# test: get queryset

def _test_get_queryset__bulk(self, request_method: str):
assert User.objects.filter(
new_teacher__school=self.admin_school_teacher_user.teacher.school
).exists()

def _test_get_queryset(self, action: str, request_method: str):
student_users = list(
User.objects.filter(
new_student__class_field__teacher__school=(
Expand All @@ -102,15 +111,21 @@ def _test_get_queryset__bulk(self, request_method: str):
request_method, user=self.admin_school_teacher_user
)

self.assert_get_queryset(student_users, action="bulk", request=request)
self.assert_get_queryset(student_users, action=action, request=request)

def test_get_queryset__bulk__patch(self):
"""Bulk partial-update can only target student-users."""
self._test_get_queryset__bulk("patch")
self._test_get_queryset(action="bulk", request_method="patch")

def test_get_queryset__bulk__delete(self):
"""Bulk destroy can only target student-users."""
self._test_get_queryset__bulk("delete")
self._test_get_queryset(action="bulk", request_method="delete")

def test_get_queryset__students__reset_password(self):
"""Resetting student passwords can only target student-users."""
self._test_get_queryset(
action="students__reset_password", request_method="patch"
)

# test: bulk actions

Expand Down Expand Up @@ -275,6 +290,37 @@ def test_reset_password__patch__indy(self):
self.client.patch(viewname, data={"password": "N3wPassword"})
self.client.login(email=self.indy_email, password="N3wPassword")

# test: students actions

def test_students__reset_password(self):
"""Teacher can bulk reset students' password."""
self.client.login_as(self.admin_school_teacher_user)

student_user_ids = list(
StudentUser.objects.filter(
new_student__class_field__teacher__school=(
self.admin_school_teacher_user.teacher.school
)
).values_list("id", flat=True)
)
assert student_user_ids

response = self.client.patch(
self.reverse_action("students--reset-password"),
student_user_ids,
content_type="application/json",
)

passwords: JsonDict = response.json()
assert all(isinstance(password, str) for password in passwords.values())

updated_student_user_ids = [
int(student_user_id) for student_user_id in passwords.keys()
]
student_user_ids.sort()
updated_student_user_ids.sort()
self.assertListEqual(student_user_ids, updated_student_user_ids)

# test: generic actions

def test_partial_update__teacher(self):
Expand Down
25 changes: 22 additions & 3 deletions backend/api/views/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from codeforlife.permissions import OR
from codeforlife.request import Request
from codeforlife.user.models import User
from codeforlife.user.models import StudentUser, User
from codeforlife.user.permissions import IsTeacher
from codeforlife.user.views import UserViewSet as _UserViewSet
from django.contrib.auth.tokens import (
Expand All @@ -31,7 +31,7 @@ class UserViewSet(_UserViewSet):
serializer_class = UserSerializer

def get_permissions(self):
if self.action == "bulk":
if self.action in ["bulk", "students__reset_password"]:
return [OR(IsTeacher(is_admin=True), IsTeacher(in_class=True))]
if self.action == "partial_update":
if "teacher" in self.request.data:
Expand All @@ -43,7 +43,9 @@ def get_permissions(self):

def get_queryset(self):
queryset = super().get_queryset()
if self.action == "bulk" and self.request.method in ["PATCH", "DELETE"]:
if (
self.action == "bulk" and self.request.method in ["PATCH", "DELETE"]
) or self.action == "students__reset_password":
queryset = queryset.filter(
new_student__isnull=False,
new_student__class_field__isnull=False,
Expand Down Expand Up @@ -142,3 +144,20 @@ def request_password_reset(self, request: Request):
"token": token,
}
)

@action(detail=False, methods=["patch"], url_path="students/reset-password")
def students__reset_password(self, request: Request):
"""Bulk reset students' password."""
queryset = self._get_bulk_queryset(request.data)

passwords: t.Dict[int, str] = {}
for pk in queryset.values_list("pk", flat=True):
student_user = StudentUser(pk=pk)
student_user.set_password()

# pylint: disable-next=protected-access
passwords[pk] = t.cast(str, student_user._password)

student_user.save() # TODO: replace with bulk update

return Response(passwords)

0 comments on commit bacf91b

Please sign in to comment.