-
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
API functions for the new generic VerificationAttempt model in the verify_student app #35338
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.
Looks good so far! Just a couple of nits and questions.
5a49c5d
to
b30318a
Compare
f00028b
to
599830e
Compare
599830e
to
d89efd2
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.
Looking good! I left a few more comments.
Could you also update the PR description and answer the prompts so that others can better review your changes? Thanks!
lms/djangoapps/verify_student/api.py
Outdated
Returns: | ||
* None | ||
""" | ||
status_list = [attr for attr in dir(VerificationAttemptStatus) if not attr.startswith('__')] |
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.
My concern here is that this is brittle. If any other attributes are added to the class, they'll be treated as statuses - even methods!
I see three alternatives.
- We could enumerate the list of valid statuses here.
- We could add an attribute like
all_statuses
to theVerificationAttemptStatus
class. - We could add an
is_valid_status
to theVerificationAttemptStatus
class thatupdate_verification_attempt_status
class calls.
I'm partial to #3, because then the status class is responsible for defining what a valid status is, not the API function.
What do you think?
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.
In retrospect, trying to save a non-valid status (i.e. something that isn't in the choices
list on the model) will raise a ValidationError
, so Django handles this for us out of the box. You could consider keeping the default Django behavior instead of explicitly checking the status. My mistake.
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.
Added a comment below with how I decided to handle this: #35338 (comment)
@@ -8,7 +8,7 @@ | |||
import time | |||
from pprint import pformat | |||
|
|||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user | |||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user, unused-import |
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.
Bump.
lms/djangoapps/verify_student/api.py
Outdated
attempt.clean_fields() | ||
attempt.save() | ||
except ValidationError: |
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 had to add this call to attempt.clean_fields()
in order for this ValidationError to work as @MichaelRoytman mentioned in their previous comment. Let me know if there's something else I should do that's best practice.
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 reads like it would take any validation error and report back that the status is invalid. If we're only checking for bad status we should do that a bit more explicitly. I'd fear this would create confusing behavior where another field fails to validate but our system incorrectly tells us the status is bad.
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.
We should probably just call full_clean()
here to validate the changes fully if we take this approach.
That said, maybe it would be easier to do what you had before and validate the status values in Python? I didn't realize that calling attempt.save()
wouldn't perform that validation.
I haven't seen the use of Django forms a lot at edX, but using a form would perform the validation automatically, because calling save()
on a form will call full_clean()
.
But I agree with Zach. The validation error will look something like this, so you could key off that.
django.core.exceptions.ValidationError: {'status': ["Value 'invalid' is not a valid choice."]}
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 put back the code to only validate which I had earlier
lms/djangoapps/verify_student/api.py
Outdated
attempt.clean_fields() | ||
attempt.save() | ||
except ValidationError: |
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.
We should probably just call full_clean()
here to validate the changes fully if we take this approach.
That said, maybe it would be easier to do what you had before and validate the status values in Python? I didn't realize that calling attempt.save()
wouldn't perform that validation.
I haven't seen the use of Django forms a lot at edX, but using a form would perform the validation automatically, because calling save()
on a form will call full_clean()
.
But I agree with Zach. The validation error will look something like this, so you could key off that.
django.core.exceptions.ValidationError: {'status': ["Value 'invalid' is not a valid choice."]}
e4e7551
to
27d8b6f
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.
Minor comments/nits for your consideration. Nothing blocking. Thank you for your patience with my late review.
lms/djangoapps/verify_student/api.py
Outdated
) | ||
raise VerificationAttemptInvalidStatus | ||
|
||
if expiration_datetime is not 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.
Is it the case that if VerificationAttempt.expiration_datetime
is None
, that means "this verification never expires"? If that's the case, then if a expiration_datetime
was set to be sometime next year, and I wanted to update it to never expire, is that possible?
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.
If the value of the expiration_datetime
parameter sent to this function is None
, then the expiration_datetime
of the IDV attempt will not be updated. This is to maintain that all IDV attempts will expire/retire at some point (which at least for IDV attempts via Persona, should be 14 days).
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.
Historically, we have set the expiration date from the time that an IDV attempt is marked approved
. For this reason, I suggested that we make expiration_datetime
optional because it may not make sense for a non-approved IDV attempt to have an expiration date. For example, if you were to update an instance of VerificationAttempt
from the status
created
to pending
, there should be no expiration date.
However, this API should remain flexible, because that depends on a particular implementation of IDV. I'd agree that we should be able to update the expiration_datetime
for any status.
Isaac, to Dave's point, I think that we shouldn't prevent the user of this function from setting expiration_datetime
to None
, necessarily. We should just not require that they supply a value for the parameter. What you think?
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 makes sense to me, thank you for clarifying that! I'll push this change soon.
lms/djangoapps/verify_student/api.py
Outdated
if status is not None: | ||
attempt.status = status | ||
|
||
status_list = [attr for attr in dir(VerificationAttemptStatus) if not attr.startswith('__')] |
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 (optional): FWIW, Python 3.11 has StrEnum now, which might be a more natural way to express this.
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 attempted to use StrEnum for this, however that involved needing to change the VerificationAttemptStatus
class itself, which in turn would require me to refactor quite a bit in this application (especially within tests) so this unfortunately doesn't seem worth it.
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.
a pattern I've used for this in the past is to give my constant 'enum' class a list property with valid values. Eg https://github.com/edx/edx-exams/blob/1b76cf33d8b0fbca6e15742cad8ab49f02960f1d/edx_exams/apps/core/statuses.py#L54
That, or can't we just use VerificationAttempt.status_choices
?
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.
At a minimum, I'd much prefer that we be explicit about the statuses in this list (i.e. status_list = [VerificationAttemptStatus.created, ...]
) over implicit. This is brittle and will stick anything that isn't a dunder method into this list, including any future attributes or methods.
But I do not think that converting that class to use the Enum module would be that hard. Where is this class used in tests? I currently only see it in use in the VerificationAttempt
model.
from enum import StrEnum, auto
class VerificationAttemptStatus(StrEnum):
CREATED = auto()
PENDING = auto()
APPROVED = auto()
DENIED = auto()
Here, you could do the following...
status_list = list(VerificationAttemptStatus)
You'd have to update STATUS_CHOICES
in the model as well. I'm not sure whether that would require a migration.
My only concern is that we sometimes like to add methods to these status classes, like is_terminal_status
, for example. But we don't seem to have a need for that right now, and this does really work like an Enum, so it's probably fine to make this change.
Please let me know if I'm missing something about the difficulty of this change.
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.
Oh I see what happened, I misread the docs and had VerificationAttemptStatus
inherit from enum.Flag
instead of enum.StrEnum
🤦. I implemented what you suggested, and I didn't have to make any migrations, and everything seems to work just fine. Thanks so much!
lms/djangoapps/verify_student/api.py
Outdated
@@ -33,3 +40,76 @@ def send_approval_email(attempt): | |||
else: | |||
email_context = {'user': attempt.user, 'expiration_datetime': expiration_datetime.strftime("%m/%d/%Y")} | |||
send_verification_approved_email(context=email_context) | |||
|
|||
|
|||
def create_verification_attempt(user, name, status, expiration_datetime=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.
Nit (optional): A number of APIs use type annotations these days, which can be helpful for documentation and correctness checking.
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've added annotations to these functions!
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 left some comments. I'll clear my change request so that it's not blocking. However, even if you choose not to use the StrEnum
class, I think that we should address the way status_list
is constructed to be more explicit before merging.
lms/djangoapps/verify_student/api.py
Outdated
) | ||
raise VerificationAttemptInvalidStatus | ||
|
||
if expiration_datetime is not 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.
Historically, we have set the expiration date from the time that an IDV attempt is marked approved
. For this reason, I suggested that we make expiration_datetime
optional because it may not make sense for a non-approved IDV attempt to have an expiration date. For example, if you were to update an instance of VerificationAttempt
from the status
created
to pending
, there should be no expiration date.
However, this API should remain flexible, because that depends on a particular implementation of IDV. I'd agree that we should be able to update the expiration_datetime
for any status.
Isaac, to Dave's point, I think that we shouldn't prevent the user of this function from setting expiration_datetime
to None
, necessarily. We should just not require that they supply a value for the parameter. What you think?
lms/djangoapps/verify_student/api.py
Outdated
if status is not None: | ||
attempt.status = status | ||
|
||
status_list = [attr for attr in dir(VerificationAttemptStatus) if not attr.startswith('__')] |
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.
At a minimum, I'd much prefer that we be explicit about the statuses in this list (i.e. status_list = [VerificationAttemptStatus.created, ...]
) over implicit. This is brittle and will stick anything that isn't a dunder method into this list, including any future attributes or methods.
But I do not think that converting that class to use the Enum module would be that hard. Where is this class used in tests? I currently only see it in use in the VerificationAttempt
model.
from enum import StrEnum, auto
class VerificationAttemptStatus(StrEnum):
CREATED = auto()
PENDING = auto()
APPROVED = auto()
DENIED = auto()
Here, you could do the following...
status_list = list(VerificationAttemptStatus)
You'd have to update STATUS_CHOICES
in the model as well. I'm not sure whether that would require a migration.
My only concern is that we sometimes like to add methods to these status classes, like is_terminal_status
, for example. But we don't seem to have a need for that right now, and this does really work like an Enum, so it's probably fine to make this change.
Please let me know if I'm missing something about the difficulty of this change.
8d1c007
to
7948f48
Compare
This commits adds a VerificationAttempt model to store implementation and provider agnostic information about identity verification attempts in the platform.
- added tests accordingly - also took care of some nits
- can now update name, status, and exp. date on generic attempts - changed tests accordingly - a few nits
- reverted to old status validation method - fixed tests accordingly
- expiration_datetime can be updated to None - VerificationAttemptStatus is now StrEnum - Added type annotations for api functions
7948f48
to
ae312a5
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.
Thank you!
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. |
1 similar comment
2U Release Notice: This PR has been deployed to the edX production environment. |
Description
This PR adds api functions which can be called by IDV plugins to create and update instances of the recently added generic VerificationAttempt model.
Context for this decision can be found in this excerpt from our internal spec doc:
Design decisions and their rationales should be documented in the repo (docstring / ADR), per
OEP-19, and can be
linked here.
Useful information to include:
"Developer", and "Operator".
Supporting information
Info from internal ticket:
introduce a Python API for the generic VerificationAttempt model in the verify_student application. The purpose of this API is to allow plugins that implement different types of IDV to import these methods and effect changes in the verify_student VerificationAttempt model.
Below is an idea for the function signatures.
Note that an id must be returned from create_verification_attempt so that the plugin can specify the id on any following update_verification calls.
Deadline
None
Other information
Other changes will be made in
openedx-events
andedx-name-affirmation
to implement the downstream effects of calling these new API functions.