-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a great start!
I know that the existing API methods return model instances or QuerySets, but I think we should avoid doing that here. You can see OEP-49: Django App Patterns for an explanation of why. We don't want to expose model instances or QuerySets. That's why we have this API - so we can encapsulate the models. Instead, we can create a data class that is a simple data container for the data, which can safely be passed outside the agreements application.
I mentioned that it might be helpful to share what information we anticipate the frontend needing from the platform when writing these Python APIs. We'll add the below information to the web API, but that web API will use the Python APIs you're writing here. If you're curious, the web API is in the courseware_api Djangoapp. You can see the user_needs_integrity_signature key in that web API, which corresponds to the IntegritySignature feature in this application. Here are the keys I'd anticipating sending from the that web API.
-
pii_receiving_lti_tools
This will be a list of dictionaries describing the LTI tools that receive PII in the course. I anticipate the dictionary only having a key for the LTI tool URL or some other identifier, but I think we should make this a dictionary to make it more flexible in case we need to add additional data in the future. I imagine we'll need your read Python API method that reads the list of PII receiving tools here. This will be used by the frontend so that we can display a list of PII receiving LTI tools to the user. -
user_needs_lti_pii_signature
This value is a boolean that represents whether the user needs to sign the LTI PII agreement dialog. This will be used to determine whether or not to show the acknowledgment dialog to the user. We'll need a few pieces of information to determine this value.We'll need...
- whether there are any PII receiving LTI tools in the course
- whether the user already signed an LTI PII acknowledgment
- whether the user's existing LTI PII signature is out-of-date, if one exists; that's determine by comparing the
lti_tools_hash
of their existing LTI PII Signature and thelti_tools_hash
of the current PII receiving tools in the course
Can you think of what sorts of functions you'd need to write to accomplish this? You'll be able to write the code to combine multiple Python API functions to determine this value; you don't need to necessarily write a singular Python API function that determines this.
These changes cover the information the frontend will need from the agreements application to decide how and when to show the agreement to the user.
But we also need to think about recording LTIPIISignatures
, right? In that case, the frontend will call our REST API. We'll have to add a REST API POST handler to the agreements application.
What information will that POST handler need to create an LTIPIISignature
? We'll probably need the user, the course ID, and the list of PII receiving LTI tools that the user was shown when they signed the acknowledgment, right? Does that match what you'd expect? Do you think we need additional data?
I think you have a good foundation here, but I hope these additional details help you think about what information you'd like to expose via the Python API.
At a minimum, I'd like to see the addition of a data class to those read API functions.
Let me know what you think!
* True or False depending on if the write was successful | ||
""" | ||
course_key = CourseKey.from_string(course_id) | ||
LTIPIITool.objects.update_or_create( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you may have copied and pasted from set_ltipiitool
. This should be LTIPIISignature
.
@@ -68,3 +70,76 @@ def get_integrity_signatures_for_course(course_id): | |||
""" | |||
course_key = CourseKey.from_string(course_id) | |||
return IntegritySignature.objects.filter(course_key=course_key) | |||
|
|||
|
|||
def get_ltipiitool(course_id): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd like to see a function name that is less tied to the underlying model name. Maybe something like get_pii_receiving_lti_tools
? Or whatever you think is best. I know it's virtually the same, but the current functions name reads like it's returning a model instance - because it is - and I don't think it should return a model instance - please see my overall pull request comment.
return None | ||
|
||
|
||
def set_ltipiisignature(course_id, lti_tools, lti_tools_hash): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a learner signs the acknowledgment, we'll make a POST from the frontend to the agreements REST API on the platform. That POST handler will call into this Python API to record the signature. Where in that flow do you think it would make sense to compute the lti_tools_hash
?
I was thinking it would make sense to do here. If that's the case, I think this function should take course_id
and lti_tools
as parameters and computer the LTI hash within the method instead of letting the caller of the REST API or the REST API itself compute it. What do you think?
username = request.user.username | ||
signature = create_lti_pii_signature(username, course_id, lti_tools) | ||
#serializer = IntegritySignatureSerializer(signature) | ||
#return Response(serializer.data) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
ba0a268
to
56be369
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking good! I think there are a few tweaks you could make before closing this out, which I left as comments, but this is getting close. Let me know what you think about my comments. Thanks!
""" | ||
|
||
course_key = CourseKey.from_string(course_id) | ||
course_ltipiitools = LTIPIITool.objects.get(course_key=course_key).lti_tools |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You will need to add the same error handling for the LTIPIITool.DoesNotExist
exception as you have in the get_lti_pii_signature
function here. Otherwise, if a course has no LTI tools and no row in the LTIPIITool
table, this call with raise an exception.
* False if the course does not. | ||
""" | ||
course_key = CourseKey.from_string(course_id) | ||
course_lti_pii_tools = LTIPIITool.objects.get(course_key) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You will need to add the same error handling for the LTIPIITool.DoesNotExist
exception as you have in the get_lti_pii_signature
function here. Otherwise, if a course has no LTI tools and no row in the LTIPIITool
table, this call with raise an exception.
user = User.objects.get(username=username) | ||
course_key = CourseKey.from_string(course_id) | ||
|
||
signature = LTIPIISignature.objects.get(user=user, course_key=course_key) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You'll want the error handling here too.
user = User.objects.get(username=username) | ||
course_key = CourseKey.from_string(course_id) | ||
|
||
user_lti_pii_signature_hash = LTIPIISignature.objects.get(course_key=course_key, user=user).lti_tools_hash |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You'll want the error handling here too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would the try-except clause include the user.objects.get()
line as well? I am thinking that if a user is in the course they'd have to exist, but I'm not sure if it's safe to assume.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good question. You have a good point that we'd really only expect for these APIs to be called in the context of someone being logged in and navigating a course, in which case they would definitely exist in the database and this call would always succeed.
But, to be safe, I think it would be best to wrap this call in a try-except block as well on the off chance that these APIs are used inappropriately without a valid username. Stuff happens!
course_key = CourseKey.from_string(course_id) | ||
course_lti_pii_tools = LTIPIITool.objects.get(course_key) | ||
|
||
if not course_lti_pii_tools: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if there is no entry in the database for this course (i.e. no QuerySet and exception is raised)?
course_lti_pii_tools_hash = LTIPIITool.objects.get(course_key=course_key).lti_tools_hash | ||
|
||
if user_lti_pii_signature_hash == course_lti_pii_tools_hash: | ||
# Hashes are equal, therefor update is not need |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit.
# Hashes are equal, therefor update is not need | |
# Hashes are equal, therefore update is not need |
""" | ||
Class that stores an lti pii signature | ||
""" | ||
lti_pii_signature: LTIPIISignature |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is still exposing the data model, albeit indirectly. Callers that receive an instance of LTIPIISignatureData
will still be able to access the LTIPIISignature
instance through the lti_pii_signature
attribute. We want to try to use Python primitives here or other classes or constructs that are not the model instance itself. The idea is we want to avoid anyone from outside the application (i.e. anyone using the Python API) from getting an instance of the LTIPIISignature
model class.
You want to be able to give the consumer a container of data they need - a representation of the data in the database - without handing them a representation of the way the data is stored in the database or a representation that allows them to interact with the model instance and call its methods and inspect its properties.
For example, you could do the following...
@attr.s(frozen=True, auto_attribs=True)
class LTIPIISignatureData:
"""
Class that stores an lti pii signature
"""
user_id: str
course_id: str
lti_tools: str
lti_tools_hash: str
What do you think of that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for catching this. Not sure why I did that.
""" | ||
Test that duplicate lti pii signatures cannot be created | ||
""" | ||
print(self.user, self.course, self.course_id, self.lti_tools) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Before you merge, please remove this print statement.
print(self.user, self.course, self.course_id, self.lti_tools) | ||
with LogCapture(LOGGER_NAME, level=logging.WARNING) as logger: | ||
create_lti_pii_signature(self.user.username, self.course_id, self.lti_tools) | ||
create_lti_pii_signature(self.user.username, self.course_id, self.lti_tools) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a nice test! I think you could improve it by changing the lti_tools
in the second call to create_lti_pii_signature
. Right now, you don't actually know whether the code re-created the LTIPIISignature
, because the data in the first call and the second call are identical. data.lti_pii_signature
could be representing the first call or the second - they're the same. If you make them different, calling _assert_lti_pii_signature
will tell you absolutely that only the first LTIPIISignature
was created.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah! It seems like by changing the tools of the second call, it stores both signatures.
Now, I'm thinking that using LTIPIISignature.update_or_create()
would be more suitable than LTIPIISignature.get_or_create()
. That way, we won't need separate functions to handle updating existing signatures.
23a20af
to
ac5e15f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking good! I left some more comments.
course_key = CourseKey.from_string(course_id) | ||
try: | ||
course_ltipiitools = LTIPIITool.objects.get(course_key=course_key).lti_tools | ||
return LTIToolsReceivingPIIData( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be better to move the return statement outside of the try
block, since we don't expect any ObjectDoesNotExist
exception to be raised here. Keeping just the code that raises the exception we're handling in the except clause in the try block makes it easier to reason about the code.
course_key=course_key, | ||
lti_tools=lti_tools, | ||
lti_tools_hash=lti_tools_hash) | ||
return signature |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit. Since both the except
and else
clauses return signature
, you could move the return statement outside of the try...except...else
block.
if _user_lti_pii_signature_exists(username, course_id): | ||
if _user_needs_signature_update(username, course_id): | ||
# up to date | ||
return False |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should be True
and the else clause should be False
, right? The _user_needs_signature_update
function returns whether the user needs an updated signature, so this should be returning True
to represent that the user needs to sign the agreement again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes that's right! Thank you fir catching that
* True if the user needs to sign a new acknowledgement. | ||
* False if the acknowledgements are up to date. | ||
""" | ||
if _course_has_lti_pii_tools(course_id): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks good and captures most of the complex logic of determining whether a user needs an LTI PII signature - suggested a small correction below. Nice job!
I think we could simplify this a little bit. I don't think we need three nested if statements. We can accomplish this without that. You'll notice that there are a few duplicated return statements.
To figure out how to write this more succinctly, I created a matrix of the boolean values of has_lti_pii_tools
, lti_pii_signature_exists
, and lti_pii_signature_needs_update
and how they they all resolve to the value of user_needs_lti_pii_signature
.
That led me to write something like this...
course_has_lti_pii_tools = _course_has_lti_pii_tools(course_id)
user_has_signature = _user_lti_pii_signature_exists(username, course_id)
signature_needs_update = _user_needs_signature_update(username, course_id)
if course_has_lti_pii_tools && (not user_has_signature || signature_needs_update):
return True
else:
return False
What do you think of that? You could try creating your own matrix of booleans and see if this does the same thing your code does.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll try out this approach!
|
||
# if user and course exists, update, otherwise create a new signature | ||
try: | ||
LTIPIISignature.objects.get(user=user, course_key=course_key) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
""" | ||
course_key = CourseKey.from_string(course_id) | ||
try: | ||
LTIPIITool.objects.get(course_key) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LTIPIITool.objects.get(course_key) | |
LTIPIITool.objects.get(course_key=course_key) |
Does this work? I think you need to specify the model field against which you're doing the match on course_key
. Or does this work because course_key
is the primary key?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, this was a mistype on my end.
user = User.objects.get(username=username) | ||
LTIPIISignature.objects.get(user=user, course_key=course_key) | ||
except ObjectDoesNotExist: | ||
return None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return None | |
return False |
This cases is when a signature doesn't exist, so would False
be more appropriate here than None
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, it should be False, since the function returns a boolean value.
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 ObjectDoesNotExist: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can specify a set of exceptions to handle here, like User.DoesNotExist
, LTIPIISignature.DoesNotExist
, and LTIPIITool.DoesNotExist
instead of the generic ObjectDoesNotExist
exception; see my comment above.
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 ObjectDoesNotExist: | ||
return None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to above, do you think this should be False
? Or None
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, it should be False
.
Also, would the ObjectDoesNotExist
exception be okay since any exception caught should return False
? Or is it better practice to have separate cases for User.DoesNotExist
, LTIPIITool.DoesNotExist
, and LTIPIISignature.DoesNotExist
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good question. I lean towards being more explicit over implicit. Those are the three exceptions we know are possible and expect to be raised in certain conditions. If we just do ObjectDoesNotExist
, it's implied that we're handling those three exceptions, but it's not explicit because we haven't listed what we're handling. The except
clause will catch any <model>.DoesNotExist
exception. That means that if another exception is raised that we don't expect for another model, it'll get silently handled by this exception handler and we'll never know about it. And it may even incorrectly handle the exception.
I guess it's pretty low risk here since's your explicitly calling the model API, so it would be bizarre to get any other exception, but I still think it's better practice.
""" | ||
Class that stores data about the list of LTI tools sharing PII | ||
""" | ||
lii_tools_receiving_pii: {} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great! There are just one really small thing I would like you to fix. The rest are at your discretion - you can take or leave whatever you like. I think this will be ready to merge really soon! 🎉
There are a few failing linting checks you should take a look at as well - these are the test failures on your pull request.
except LTIPIITool.DoesNotExist: | ||
return None | ||
|
||
return LTIToolsReceivingPIIData(lii_tools_receiving_pii=course_ltipiitools,) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit.
return LTIToolsReceivingPIIData(lii_tools_receiving_pii=course_ltipiitools,) | |
return LTIToolsReceivingPIIData(lii_tools_receiving_pii=course_ltipiitools) |
# 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit.
# 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 |
# no entry in the database | ||
return False | ||
else: | ||
return True |
There was a problem hiding this comment.
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?
except (User.DoesNotExist, LTIPIISignature.DoesNotExist, LTIPIITool.DoesNotExist): | ||
return False | ||
else: | ||
if user_lti_pii_signature_hash == course_lti_pii_tools_hash: |
There was a problem hiding this comment.
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.
username = request.user.username | ||
signature = create_lti_pii_signature(username, course_id, lti_tools) | ||
#serializer = IntegritySignatureSerializer(signature) | ||
#return Response(serializer.data) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for all your hard work getting this done! 🎉
2U Release Notice: This PR has been deployed to the edX staging environment in preparation for a release to production. |
2U Release Notice: This PR has been deployed to the edX production environment. |
Description
JIRA: https://2u-internal.atlassian.net/browse/MST-2014
In order to support the asynchronous Celery task and the courseware API view, we need to add Python APIs to read from and write to the new agreements models LTIPIITool, LTIPIISignature, and ProctoringPIISignature.
Create a Python API to read from and write to these models. Use the guidance documented in OEP-49: Django App Patterns.