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

feat: add python apis to agreements models #32967

Merged
merged 12 commits into from
Oct 4, 2023
181 changes: 181 additions & 0 deletions openedx/core/djangoapps/agreements/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
from opaque_keys.edx.keys import CourseKey

from openedx.core.djangoapps.agreements.models import IntegritySignature
from openedx.core.djangoapps.agreements.models import LTIPIITool
from openedx.core.djangoapps.agreements.models import LTIPIISignature

from .data import LTIToolsReceivingPIIData
from .data import LTIPIISignatureData

log = logging.getLogger(__name__)
User = get_user_model()
Expand Down Expand Up @@ -68,3 +73,179 @@ def get_integrity_signatures_for_course(course_id):
"""
course_key = CourseKey.from_string(course_id)
return IntegritySignature.objects.filter(course_key=course_key)


def create_lti_pii_signature(username, course_id, lti_tools):
"""
Creates an lti pii tool signature. If the signature already exist, do not create a new one.

Arguments:
* course_key (str)
* lti_tools (dict)
* lti_tools_hash (int)
Returns:
* An LTIPIISignature, or None if a signature already exists.
"""
course_key = CourseKey.from_string(course_id)
lti_tools_hash = hash(str(lti_tools))

# if user and course exists, update, otherwise create a new signature
try:
user = User.objects.get(username=username)
LTIPIISignature.objects.get(user=user, course_key=course_key)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the ObjectDoesNotExist exception in a try...catch block will "will catch DoesNotExist exceptions for all models". This could be a problem because we might catch exceptions for other models that we don't expect, and then the exception is buried and may not be handled appropriately. I think it would be better to catch the exception that you anticipate and want to handle, which is LTIPIISignature.DoesNotExist. You can see the documentation for this exception here.

I see this ObjectDoesNotExist class in a few places in this file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh, I was wondering how to throw a DoesNotExist exception for specific models. I just resorted to ObjectDoesNotExist. I will correct the rest of them as well.

except User.DoesNotExist:
return None
except LTIPIISignature.DoesNotExist:
signature = LTIPIISignature.objects.create(
user=user,
course_key=course_key,
lti_tools=lti_tools,
lti_tools_hash=lti_tools_hash)
else:
signature = LTIPIISignature.objects.update(
user=user,
course_key=course_key,
lti_tools=lti_tools,
lti_tools_hash=lti_tools_hash)

return signature


def get_lti_pii_signature(username, course_id):
"""
Get the lti pii signature of a user in a course.

Arguments:
* username (str)
* course_id (str)

Returns:
* An LTIPIISignature object, or None if one does not exist for the
user + course combination.
"""
course_key = CourseKey.from_string(course_id)
try:
user = User.objects.get(username=username)
signature = LTIPIISignature.objects.get(user=user, course_key=course_key)
except (User.DoesNotExist, LTIPIISignature.DoesNotExist):
return None
else:
return LTIPIISignatureData(user=signature.user, course_id=str(signature.course_key),
lti_tools=signature.lti_tools, lti_tools_hash=signature.lti_tools_hash)


def get_pii_receiving_lti_tools(course_id):
"""
Get a course's LTI tools that share PII.

Arguments:
* course_id (str)

Returns:
* A List of LTI tools sharing PII.
"""

course_key = CourseKey.from_string(course_id)
try:
course_ltipiitools = LTIPIITool.objects.get(course_key=course_key).lti_tools
except LTIPIITool.DoesNotExist:
return None

return LTIToolsReceivingPIIData(lii_tools_receiving_pii=course_ltipiitools,)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit.

Suggested change
return LTIToolsReceivingPIIData(lii_tools_receiving_pii=course_ltipiitools,)
return LTIToolsReceivingPIIData(lii_tools_receiving_pii=course_ltipiitools)



def user_lti_pii_signature_needed(username, course_id):
"""
Determines if a user needs to acknowledge the LTI PII Agreement

Arguments:
* username (str)

Returns:
* True if the user needs to sign a new acknowledgement.
* False if the acknowledgements are up to date.
"""
course_has_lti_pii_tools = _course_has_lti_pii_tools(course_id)
signature_exists = _user_lti_pii_signature_exists(username, course_id)
signature_out_of_date = _user_signature_out_of_date(username, course_id)

if (course_has_lti_pii_tools and (not signature_exists) or
(course_has_lti_pii_tools and signature_exists and signature_out_of_date)):
# write a new signature, or update an existing signature
return True
else:
# course does not have lti pii tools, or signatue is up to date
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit.

Suggested change
# course does not have lti pii tools, or signatue is up to date
# course does not have lti pii tools, or signature is up to date

return False


def _course_has_lti_pii_tools(course_id):
"""
Determines if a specifc course has lti tools sharing pii

Arguments:
* course_id (str)

Returns:
* True if the course does have a list.
* False if the course does not.
"""
course_key = CourseKey.from_string(course_id)
try:
LTIPIITool.objects.get(course_key=course_key)
except LTIPIITool.DoesNotExist:
# no entry in the database
return False
else:
return True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only potential issue here is in the case that a course is in the database with no tools. This could happen if a course has tools that receive PII and then those tools are configured not to share PII. Because we don't yet know how we're going to handle that (i.e. remove that course's row from the database or just have the list of tools be empty and keep the row), I think it would be safest to check for both the LTIPIITool.DoesNotExist exception and check whether the LTIPIITool.lti_tools is empty. Does that make sense?



def _user_lti_pii_signature_exists(username, course_id):
"""
Determines if a user's lti pii signature exists for a specfic course

Arguments:
* username (str)
* course_id (str)

Returns:
* True if user has a signature for the given course.
* False if the user does not have a signature for the given course.
"""
course_key = CourseKey.from_string(course_id)

try:
user = User.objects.get(username=username)
LTIPIISignature.objects.get(user=user, course_key=course_key)
except (User.DoesNotExist, LTIPIISignature.DoesNotExist):
return False
else:
return True # signature exist


def _user_signature_out_of_date(username, course_id):
"""
Determines if a user's existing lti pii signature is out-of-date for a given course.

Arguments:
* username (str)
* course_id (str)

Returns:
* True if user needs a new signature
* False if the user has an up-to-date signature.
"""
course_key = CourseKey.from_string(course_id)

try:
user = User.objects.get(username=username)
user_lti_pii_signature_hash = LTIPIISignature.objects.get(course_key=course_key, user=user).lti_tools_hash
course_lti_pii_tools_hash = LTIPIITool.objects.get(course_key=course_key).lti_tools_hash
except (User.DoesNotExist, LTIPIISignature.DoesNotExist, LTIPIITool.DoesNotExist):
return False
else:
if user_lti_pii_signature_hash == course_lti_pii_tools_hash:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. You can shorten this a bit, if you want. Because a==b evaluates to a boolean itself, you can just return the result of your logical expression. For example, return user_lti_pii_signature_hash != course_lti_pii_tools_hash will accomplish the exact same thing and is a little easier to read.

# Hashes are equal, therefore update is not need
return False
else:
# Out of date signature, so update is needed
return True
23 changes: 23 additions & 0 deletions openedx/core/djangoapps/agreements/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
Public data structures for this app.
"""
import attr


@attr.s(frozen=True, auto_attribs=True)
class LTIToolsReceivingPIIData:
"""
Class that stores data about the list of LTI tools sharing PII
"""
lii_tools_receiving_pii: {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we get to the modulestore stage of the project, we may want to change this a bit to give more expected structure to each value in the dictionary, but I think this is fine as it is currently.



@attr.s(frozen=True, auto_attribs=True)
class LTIPIISignatureData:
"""
Class that stores an lti pii signature
"""
user: str
course_id: str
lti_tools: str
lti_tools_hash: str
88 changes: 88 additions & 0 deletions openedx/core/djangoapps/agreements/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@
create_integrity_signature,
get_integrity_signature,
get_integrity_signatures_for_course,
get_pii_receiving_lti_tools,
create_lti_pii_signature,
get_lti_pii_signature
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
from ..models import (
LTIPIITool,
)
from opaque_keys.edx.keys import CourseKey

LOGGER_NAME = "openedx.core.djangoapps.agreements.api"

Expand Down Expand Up @@ -98,3 +105,84 @@ def _assert_integrity_signature(self, signature):
"""
self.assertEqual(signature.user, self.user)
self.assertEqual(signature.course_key, self.course.id)


@skip_unless_lms
class TestLTIPIISignatureApi(SharedModuleStoreTestCase):
"""
Tests for the lti pii signature API
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = UserFactory()
cls.course = CourseFactory()
cls.course_id = str(cls.course.id)
cls.lti_tools = {"first_lti_tool": "This is the first tool",
"second_lti_tool": "This is the second tool", }
cls.lti_tools_2 = {"first_lti_tool": "This is the first lti tool",
"second_lti_tool": "This is the second tool",
"third_lti_tool": "This is the third tool", }
LTIPIITool.objects.create(
course_key=CourseKey.from_string(cls.course_id),
lti_tools=cls.lti_tools,
lti_tools_hash=11111,
)

def test_create_lti_pii_signature(self):
"""
Test to check if an lti pii signature is created from a course and its lti tools.
"""
signature = create_lti_pii_signature(self.user.username, self.course_id, self.lti_tools)
self._assert_lti_pii_signature(signature)

def test_create_multiple_lti_pii_signature(self):
"""
Test that lti pii signatures are either created or updated
"""

create_lti_pii_signature(self.user.username, self.course_id, self.lti_tools) # first signature
s1 = get_lti_pii_signature(self.user.username, self.course_id) # retrieve the database entry
create_lti_pii_signature(self.user.username, self.course_id, self.lti_tools_2) # signature with updated tools
s2 = get_lti_pii_signature(self.user.username, self.course_id) # retrieve the updated database entry
self.assertNotEqual(s1, s2) # the signatue retrieved from the database should be the updated version

def _assert_lti_pii_signature(self, signature):
"""
Helper function to assert the returned lti pii signature has the correct
user and course key
"""
self.assertEqual(signature.user, self.user)
self.assertEqual(signature.course_key, self.course.id)


@skip_unless_lms
class TestLTIPIIToolsApi(SharedModuleStoreTestCase):
"""
Tests for the lti pii tool sharing API. To make sure the list of LTI tools can be retreived from the Model.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory()
cls.course_id = str(cls.course.id)
cls.lti_tools = {"first_lti_tool": "This is the first tool",
"second_lti_tool": "This is the second tool", }
LTIPIITool.objects.create(
course_key=CourseKey.from_string(cls.course_id),
lti_tools=cls.lti_tools,
lti_tools_hash=11111,
)

def test_get_pii_receiving_lti_tools(self):
"""
Test to check if a course's lti pii tools can be retrieved.
"""
data = get_pii_receiving_lti_tools(self.course_id)
self._assert_ltitools(data.lii_tools_receiving_pii)

def _assert_ltitools(self, lti_list):
"""
Helper function to assert the returned list has the correct tools
"""
self.assertEqual(self.lti_tools, lti_list)
23 changes: 23 additions & 0 deletions openedx/core/djangoapps/agreements/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,26 @@ def post(self, request, course_id):
signature = create_integrity_signature(username, course_id)
serializer = IntegritySignatureSerializer(signature)
return Response(serializer.data)


class LTIPIISignatureView(AuthenticatedAPIView):
"""
Endpoint for an PII sharing LTI Signature
/integrity_signature/{course_id}

HTTP POST
* If an integrity signature does not exist for the user + course, creates one and
returns it. If one does exist, returns the existing signature.
"""

def post(self, request, course_id):
"""
Create an LTI PII signature for the requesting user and course. If a signature
already exists, returns the existing signature instead of creating a new one.

/api/agreements/v1/lti_pii_signature/{course_id}
"""
username = request.user.username
#signature = create_lti_pii_signature(username, course_id, lti_tools)
#serializer = IntegritySignatureSerializer(signature)
#return Response(serializer.data)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the future for these commented out bits?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think they will be replaced with a serializer specifically for the LTIPIISignature

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this isn't ready yet and will be added a later point, what do you think about removing this from this pull request?

Loading