Skip to content

Commit

Permalink
Add PII Retirement Code (#657)
Browse files Browse the repository at this point in the history
* Add endpoint for retiring personally-identifiable information
* Add logic for clearing user information from exam attempts
* Add logic for clearing user information from student allowances
* Add VS Code settings to gitignore
  • Loading branch information
nsprenkle authored Mar 4, 2020
1 parent e468973 commit 4d8555f
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 16 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ tramp
*.swp
*.swo

# VS Code
.vscode

# Mac
.DS_Store
._*
Expand Down
8 changes: 8 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ In your lms.auth.json file, please add the following *secure* information::
You will need to restart services after these configuration changes for them to
take effect.

Debugging
------------

To debug with PDB, run ``pytest`` with the ``-n0`` flag. This restricts the number
of processes in a way that is compatible with ``pytest``

pytest -n0 [file-path]

License
-------

Expand Down
2 changes: 1 addition & 1 deletion edx_proctoring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
from __future__ import absolute_import

# Be sure to update the version number in edx_proctoring/package.json
__version__ = '2.3.0'
__version__ = '2.3.1'

default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name
19 changes: 7 additions & 12 deletions edx_proctoring/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,7 @@ class ProctoredExamReviewPolicy(TimeStampedModel):
"""
This is how an instructor can set review policies for a proctored exam
.. pii: records who set a review policy in set_by_user
retirement to be implemented in https://openedx.atlassian.net/browse/EDUCATOR-4776
.. pii_types: id
.. pii_retirement: to_be_implemented
.. no_pii:
"""

# who set this ProctoredExamReviewPolicy
Expand Down Expand Up @@ -301,9 +298,8 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
Proctored Exam.
.. pii: new attempts log the student's name and IP
retirement to be implemented in https://openedx.atlassian.net/browse/EDUCATOR-4776
.. pii_types: name, ip
.. pii_retirement: to_be_implemented
.. pii_retirement: local_api
"""
objects = ProctoredExamStudentAttemptManager()

Expand Down Expand Up @@ -343,6 +339,7 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
# the proctoring software
is_sample_attempt = models.BooleanField(default=False, verbose_name=ugettext_noop("Is Sample Attempt"))

# Note - this is currently unset
student_name = models.CharField(max_length=255)

# what review policy was this exam submitted under
Expand Down Expand Up @@ -394,9 +391,8 @@ class ProctoredExamStudentAttemptHistory(TimeStampedModel):
but will record (for audit history) all entries that have been updated.
.. pii: new attempts log the student's name and IP
retirement to be implemented in https://openedx.atlassian.net/browse/EDUCATOR-4776
.. pii_types: name, ip
.. pii_retirement: to_be_implemented
.. pii_retirement: local_api
"""

user = models.ForeignKey(USER_MODEL, db_index=True, on_delete=models.CASCADE)
Expand Down Expand Up @@ -431,6 +427,7 @@ class ProctoredExamStudentAttemptHistory(TimeStampedModel):
# the proctoring software
is_sample_attempt = models.BooleanField(default=False)

# Note - this is currently unset
student_name = models.CharField(max_length=255)

# what review policy was this exam submitted under
Expand Down Expand Up @@ -512,9 +509,8 @@ class ProctoredExamStudentAllowance(TimeStampedModel):
Information about allowing a student additional time on exam.
.. pii: allowances have a free-form text field which may be identifiable
retirement to be implemented in https://openedx.atlassian.net/browse/EDUCATOR-4776
.. pii_types: other
.. pii_retirement: to_be_implemented
.. pii_retirement: local_api
"""

# DONT EDIT THE KEYS - THE FIRST VALUE OF THE TUPLE - AS ARE THEY ARE STORED IN THE DATABASE
Expand Down Expand Up @@ -654,9 +650,8 @@ class ProctoredExamStudentAllowanceHistory(TimeStampedModel):
but will record (for audit history) all entries that have been updated.
.. pii: allowances have a free-form text field which may be identifiable
retirement to be implemented in https://openedx.atlassian.net/browse/EDUCATOR-4776
.. pii_types: other
.. pii_retirement: to_be_implemented
.. pii_retirement: local_api
"""

# what was the original id of the allowance
Expand Down
120 changes: 119 additions & 1 deletion edx_proctoring/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from edx_proctoring.api import (
_calculate_allowed_mins,
add_allowance_for_user,
create_exam,
create_exam_attempt,
get_backend_provider,
Expand All @@ -34,7 +35,13 @@
ProctoredExamPermissionDenied,
StudentExamAttemptDoesNotExistsException
)
from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAllowance, ProctoredExamStudentAttempt
from edx_proctoring.models import (
ProctoredExam,
ProctoredExamStudentAllowance,
ProctoredExamStudentAllowanceHistory,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory
)
from edx_proctoring.runtime import get_runtime_service, set_runtime_service
from edx_proctoring.serializers import ProctoredExamSerializer
from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus
Expand Down Expand Up @@ -2847,3 +2854,114 @@ def test_no_access(self):

response = self.client.post(deletion_url)
assert response.status_code == 403


class TestUserRetirement(LoggedInTestCase):
"""
Tests for deleting user PII for proctoring
"""
def setUp(self):
super(TestUserRetirement, self).setUp()
self.user.is_staff = True
self.user.save()
self.user_to_retire = User(username='tester2', email='[email protected]')
self.user_to_retire.save()
self.client.login_user(self.user)
self.deletion_url = reverse('edx_proctoring:user_retirement_api', kwargs={'user_id': self.user_to_retire.id})

def _create_proctored_exam(self):
""" Create a mock proctored exam with common values """
return ProctoredExam.objects.create(
course_id='a/b/c',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
is_proctored=True,
is_active=True,
time_limit_mins=90,
backend='test'
)

def test_retire_no_access(self):
""" A user without retirement permissions should not be able to retire other users """
self.client.login_user(self.user_to_retire)
deletion_url = reverse('edx_proctoring:user_retirement_api', kwargs={'user_id': self.user.id})

response = self.client.post(deletion_url)
assert response.status_code == 403

def test_retire_user_no_data(self):
"""
Attempting to retire an unknown user or user with no proctored attempts
returns 204 but does not carry out a retirment
"""
response = self.client.post(self.deletion_url)

assert response.status_code == 204

def test_retire_user_exam_attempt(self):
""" Retiring a user should obfuscate PII for exam attempts and return a 204 status """
# Create an exam attempt
proctored_exam = self._create_proctored_exam()
ProctoredExamStudentAttempt.objects.create(
proctored_exam=proctored_exam,
user=self.user_to_retire,
student_name='me',
last_poll_ipaddr='127.0.0.1'
)

# Run the retirement command
deletion_url = reverse('edx_proctoring:user_retirement_api', kwargs={'user_id': self.user_to_retire.id})
response = self.client.post(deletion_url)
assert response.status_code == 204

retired_attempt = ProctoredExamStudentAttempt.objects.filter(user_id=self.user_to_retire.id).first()
assert retired_attempt.student_name == ''
assert retired_attempt.last_poll_ipaddr is None

def test_retire_user_exam_attempt_history(self):
""" Retiring a user should obfuscate PII for exam attempt history and return a 204 status """
# Create and archive an exam attempt so it appears in the history table
proctored_exam = self._create_proctored_exam()
ProctoredExamStudentAttemptHistory.objects.create(
proctored_exam=proctored_exam,
user=self.user_to_retire,
student_name='me',
last_poll_ipaddr='127.0.0.1'
)

# Run the retirement command
response = self.client.post(self.deletion_url)
assert response.status_code == 204

retired_attempt_history = ProctoredExamStudentAttemptHistory \
.objects.filter(user_id=self.user_to_retire.id).first()
assert retired_attempt_history.student_name == ''
assert retired_attempt_history.last_poll_ipaddr is None

def test_retire_user_allowances(self):
""" Retiring a user should delete their allowances and return a 204 """
proctored_exam = self._create_proctored_exam()
add_allowance_for_user(proctored_exam.id, self.user_to_retire.id, 'a_key', 30)

# Run the retirement command
response = self.client.post(self.deletion_url)
assert response.status_code == 204

retired_allowance = ProctoredExamStudentAllowance \
.objects.filter(user=self.user_to_retire.id).first()
assert retired_allowance.value == ''

def test_retire_user_allowances_history(self):
""" Retiring a user should delete their allowances and return a 204 """
proctored_exam = self._create_proctored_exam()
add_allowance_for_user(proctored_exam.id, self.user_to_retire.id, 'a_key', 30)
add_allowance_for_user(proctored_exam.id, self.user_to_retire.id, 'a_key', 60)

# Run the retirement command
response = self.client.post(self.deletion_url)
assert response.status_code == 204

retired_allowance_history = ProctoredExamStudentAllowanceHistory \
.objects.filter(user=self.user_to_retire.id).first()
assert retired_allowance_history.value == ''
5 changes: 5 additions & 0 deletions edx_proctoring/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@
views.BackendUserManagementAPI.as_view(),
name='backend_user_deletion_api'
),
url(
r'edx_proctoring/v1/retire_user/(?P<user_id>[\d]+)/$',
views.UserRetirement.as_view(),
name='user_retirement_api'
),

# Unauthenticated callbacks from SoftwareSecure. Note we use other
# security token measures to protect data
Expand Down
49 changes: 48 additions & 1 deletion edx_proctoring/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@
ProctoredExam,
ProctoredExamSoftwareSecureComment,
ProctoredExamSoftwareSecureReview,
ProctoredExamStudentAttempt
ProctoredExamStudentAllowance,
ProctoredExamStudentAllowanceHistory,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory
)
from edx_proctoring.runtime import get_runtime_service
from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer
Expand Down Expand Up @@ -1104,3 +1107,47 @@ def post(self, request, user_id): # pylint: disable=unused-argument
code = 500
seen.add(backend_name)
return Response(data=results, status=code)


class UserRetirement(AuthenticatedAPIView):
"""
Retire user personally-identifiable information (PII) for a user
"""
def _retire_exam_attempts_user_info(self, user_id):
""" Remove PII for exam attempts and exam history """
attempts = ProctoredExamStudentAttempt.objects.filter(user_id=user_id)
if attempts:
for attempt in attempts:
attempt.student_name = ''
attempt.last_poll_ipaddr = None
attempt.save()

attempts_history = ProctoredExamStudentAttemptHistory.objects.filter(user_id=user_id)
if attempts_history:
for attempt_history in attempts_history:
attempt_history.student_name = ''
attempt_history.last_poll_ipaddr = None
attempt_history.save()

def _retire_user_allowances(self, user_id):
""" Clear user allowance values """
allowances = ProctoredExamStudentAllowance.objects.filter(user=user_id)
for allowance in allowances:
allowance.value = ''
allowance.save()

allowances_history = ProctoredExamStudentAllowanceHistory.objects.filter(user=user_id)
for allowance_history in allowances_history:
allowance_history.value = ''
allowance_history.save()

def post(self, request, user_id): # pylint: disable=unused-argument
""" Obfuscates all PII for a given user_id """
if not request.user.has_perm('accounts.can_retire_user'):
return Response(status=403)
code = 204

self._retire_exam_attempts_user_info(user_id)
self._retire_user_allowances(user_id)

return Response(status=code)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@edx/edx-proctoring",
"//": "Be sure to update the version number in edx_proctoring/__init__.py",
"//": "Note that the version format is slightly different than that of the Python version when using prereleases.",
"version": "2.3.0",
"version": "2.3.1",
"main": "edx_proctoring/static/index.js",
"repository": {
"type": "git",
Expand Down

0 comments on commit 4d8555f

Please sign in to comment.