Multi-site aware enrollments and courses API - #397
Conversation
OmarIthawi
left a comment
There was a problem hiding this comment.
Thanks @johnbaldwin, I'll take another look soon, but for now here's a quick review.
| return courses | ||
|
|
||
|
|
||
| def get_site_for_course(course_id): |
There was a problem hiding this comment.
I've seen this helper existing somewhere in Tahoe. I should double check.
There was a problem hiding this comment.
@OmarIthawi I'll bet one of the places you've seen it is in figures ;)
But if you happen to see it in the platform (w/out going out of your way), then we can see about using that one
There was a problem hiding this comment.
It's a minor issue. Just keep it for now, thanks @johnbaldwin!
|
|
||
| COURSE_ID_STR_TEMPLATE = 'course-v1:StarFleetAcademy+SFA{}+2161' | ||
|
|
||
| class CourseOverviewFactory(factory.DjangoModelFactory): |
There was a problem hiding this comment.
Can we reuse from?
There was a problem hiding this comment.
After I get everything together, I'll drop that in and see if there's any extra work needed
There was a problem hiding this comment.
@OmarIthawi No we can't without reworking tests to explicitly generate course ids since this factory has a static course id.
This means no more self.course_overviews = [CourseOverviewFactory() for i in range(X)]
Instead we either need to create a course id generator OR modify the course_overviews CourseOverviewFactory, which I call out of scope.
There may be other issues. So for now, I want to leave it with our custom one. This does give the option to see if we can enhance the default platform CourseOverviewFactory and push upstream, but we'd have to test everything that uses it.
Sorry, I'm calling this one out of scope.
There was a problem hiding this comment.
Agreed with @johnbaldwin , despite how much we do like re-utilise, this one is out of scope.
| SiteFactory, | ||
| ) | ||
|
|
||
| from .factories import ( |
There was a problem hiding this comment.
Please use fully qualified paths such as:
from openedx.core.djangoapps.appsembler.api.tests.factories import ...
There was a problem hiding this comment.
@OmarIthawi To make you happy I made these absolute paths. Something I'm interested, and please pardon my ignorance. What is the compelling reason not to use explicit relative path imports using the leading dot, which means one is importing from the same package. Is there some definitive reference you can share? Thanks!
There was a problem hiding this comment.
https://realpython.com/absolute-vs-relative-python-imports/
Generally speaking, absolute imports are recommended (PEP 8!). Relative imports are more succinct, but they can cause issues when you refactor, and make it harder to quickly find the other module, especially if you start using ...
We don't have a style guide (well, we have a PR that's outstanding that you submitted, John -- we should all revisit that), but if we did, absolute imports should be in there.
(At least py3 did away with implicit relative imports, which were not great.)
|
@johnbaldwin sounds like you're doing a great progress here! Thank you! Could you please let me know if you think it'll need another round of review? |
|
|
||
| from django.test import RequestFactory, TestCase | ||
| from django.test.utils import override_settings | ||
|
|
| org = 'StarFleetAcademy' | ||
| # number = '2161' | ||
|
|
||
| version = 6L |
There was a problem hiding this comment.
Replace with version = CourseOverview.VERSION as done in course_overviews factory class
| COURSE_ID_STR_TEMPLATE.format(n))) | ||
| display_name = factory.Sequence(lambda n: 'SFA Course {}'.format(n)) | ||
| org = 'StarFleetAcademy' | ||
| # number = '2161' |
| organization=self.my_site_org) | ||
|
|
||
| self.other_enrollments = [CourseEnrollmentFactory()] | ||
| # self.other_course_overviews = [CourseOverviewFactory()] |
| self.assertEqual(res.status_code, 200) | ||
| enroll_list = res.data['results'] | ||
| self.assertEqual(len(enroll_list), len(self.my_enrollments)) | ||
| # TODO: Validate each record |
There was a problem hiding this comment.
Will do as follow-on (Along with other test improvements). My primary focus was getting test coverage and with that getting good test coverage on the bulk enrollment code (see next test class)
|
|
||
| def setUp(self): | ||
| super(EnrollmentApiPostTest, self).setUp() | ||
| self.my_site = Site.objects.get(domain=u'example.com') |
There was a problem hiding this comment.
@OmarIthawi @melvinsoft @abeals
Note: We have redundant fixture code in this class and the previous. What I want to do is shift away from TestClass based tests to pytest based tests, then build a set of fixtures to reduce tests data set up duplication. With a healthy set of fixtures, we can also get away from class based tests toward function based tests.
The more I develop tests here, the less I see the value in class based tests as the test module provides a strong enough context and function based tests help with test development velocity and readability
There was a problem hiding this comment.
Worth a broader discussion within our engineering team. An advantage to class-based tests (not an overriding advantage, but one you can use) is that you can use mix-ins to keep your test cases DRY. I've generally found inheritance in class-based tests to be more of a headache than it's worth, but the mix-ins can be nice.
There was a problem hiding this comment.
@abeals This is where Pytest fixtures and using conftest.py can really shine for reuse and clarity: https://docs.pytest.org/en/latest/fixture.html
Effectively, adding fixture methods as parameters to a test function is an analog to using mixins in class based tests.
However, I think more important than the fixture based vs class mixin based is working to reduce duplication and making the test code clear to read
| organization=self.other_site_org).count() | ||
|
|
||
| assert after_other_site_ce_count == before_other_site_ce_count | ||
| assert after_other_site_user_count == before_other_site_user_count |
There was a problem hiding this comment.
The above two asserts make sure that we're not creating resources in another site. We'll be able to validate that these tests work when we run on staging/simulated production environment.
TODO: Add assert for 'other_site' CourseEnrolledAllowed does not change
There was a problem hiding this comment.
Got it. We should split that out into its own test case. A test case should be testing one thing, not multiple things. This one's become a bit of the latter.
Not a blocker for merging this PR, but please do flag this refactor as a TODO.
There was a problem hiding this comment.
Added "TODO" in docstring with inline comment about this assertion
| _This section is a WIP_ | ||
|
|
||
| ``` | ||
| pytest openedx/core/djangoapps/appsembler/api/tests/test_registration_api.py |
There was a problem hiding this comment.
Assuming this is just an example of usage, right? Not meant to be a full listing of all of the tests in openedx/core/djangoapps/appsembler/api/tests/ ?
There was a problem hiding this comment.
Yes, just an example. But I'll update the readme to be clear
There was a problem hiding this comment.
Updated to say this is an example of running an individual test. See no need to spell out each test here
| # from django.utils.timezone import utc | ||
|
|
||
| import factory | ||
| from factory import fuzzy |
There was a problem hiding this comment.
More curiosity than anything else: Why did you choose to import the fuzzy submodule, but for all other submodules in factory you referred to them via factory.Sequence, et al?
There was a problem hiding this comment.
@abeals No good reason. I did it because I copied/pasted from Figures. There might have been a reason I needed to do it for figures, but I don't recall. I'll do just the import factory and see if that works ok.
There was a problem hiding this comment.
Updated to use factory.fuzzy
| import factory | ||
|
|
||
| import datetime | ||
| # from django.utils.timezone import utc |
| class CourseApiTest(TestCase): | ||
|
|
||
| def setUp(self): | ||
| self.my_site = Site.objects.get(domain=u'example.com') |
There was a problem hiding this comment.
Any reason you're using an existing site vs SiteFactory for self.my_site?
There was a problem hiding this comment.
@abeals Originally because that is the default site in the request object when using the TestCase class's builtin test client. However, I ran into auth issues when running our new code that called existing edx-platform code for enrollment, so I wound up doing it the way I did in figures.
The test environment always creates the default site. I can change it to use SiteFactory. It should behave the same. And would probably be a good test to make sure there's nothing I've overlooked
There was a problem hiding this comment.
Updated to use `SiteFactory and tests pass, so keeping the change. Thanks @abeals !
There was a problem hiding this comment.
Noted. I should update the test_sites.py module to be more robust. Added TODO in module docstring
There was a problem hiding this comment.
Hmm, ran the test again and failed after updating other tests to replace example.com with SiteFactory and running the suite of tests. Then ran just the test_course_api.py and now that fails. Hmm. Investigating
There was a problem hiding this comment.
Hmm. That first time the test_course_api.py run SHOULD have failed. Think there was a docker FS issue where my host changes didn't make it to the docker container FS.
Reworking tests to fix...
There was a problem hiding this comment.
This one is becoming interesting. Trying to use reverse and resolve to get the viewset object from the namespace, however, running into an error where the kwargs, which should contain the pk, are empty when the viewset retrieve method is called.
However, before calling the the view func with the request, we DO have the key for the detail view. It just seems to be getting lost in the call chain somehow. See:
(Pdb) view
ResolverMatch(func=openedx.core.djangoapps.appsembler.api.v1.views.CourseViewSet, args=(), kwargs={u'pk': u'course-v1:StarFleetAcademy+SFA0+2161'}, url_name=courses-detail, app_names=[], namespaces=['tahoe-api', 'v1'])
(Pdb) request
<WSGIRequest: GET '/tahoe/api/v1/courses/course-v1:StarFleetAcademy+SFA0+2161/'>
In the viewset retrieve method:
(Pdb) args
self = <openedx.core.djangoapps.appsembler.api.v1.views.CourseViewSet object at 0x7f316bf97fd0>
request = <rest_framework.request.Request object at 0x7f314b31fd10>
args = ()
kwargs = {}
There was a problem hiding this comment.
On the surface, it appears that the call chain is dropping the PK.
Here's the stack:
/edx/app/edxapp/edx-platform/openedx/core/djangoapps/appsembler/api/tests/test_course_api.py(95)test_get_single()
-> response = view.func(request)
/edx/app/edxapp/venvs/edxapp/local/lib/python2.7/site-packages/django/views/decorators/csrf.py(58)wrapped_view()
-> return view_func(*args, **kwargs)
/edx/app/edxapp/venvs/edxapp/local/lib/python2.7/site-packages/rest_framework/viewsets.py(86)view()
-> return self.dispatch(request, *args, **kwargs)
/edx/app/edxapp/venvs/edxapp/local/lib/python2.7/site-packages/rest_framework/views.py(486)dispatch()
-> response = handler(request, *args, **kwargs)
> /edx/app/edxapp/edx-platform/openedx/core/djangoapps/appsembler/api/v1/views.py(248)retrieve()
There was a problem hiding this comment.
Dang! Silly mistake. I was missing the pk keyword from the view func call.
even though you provide the url with the key in the url:
url = reverse('tahoe-api:v1:courses-detail', args=[course_id])
request = APIRequestFactory().get(url)
You still need to give it to the view func because Django doesn't do this magic
response = view.func(request, pk=course_id)
|
|
||
| from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase | ||
| from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory | ||
| from xmodule.modulestore.django import modulestore |
| organization=self.other_site_org).count() | ||
|
|
||
| assert after_other_site_ce_count == before_other_site_ce_count | ||
| assert after_other_site_user_count == before_other_site_user_count |
There was a problem hiding this comment.
Got it. We should split that out into its own test case. A test case should be testing one thing, not multiple things. This one's become a bit of the latter.
Not a blocker for merging this PR, but please do flag this refactor as a TODO.
| @@ -0,0 +1,11 @@ | |||
| """Paginatiors for Figures | |||
There was a problem hiding this comment.
@abeals Yeah, a good chunk of the new code I copied over and adapted from figures. Will fix. Thx!
| class TahoeLimitOffsetPagination(LimitOffsetPagination): | ||
| '''Custom Tahoe paginator to make the number of records returned consistent | ||
| ''' | ||
| default_limit = 20 |
There was a problem hiding this comment.
Knowing nothing about the standards here... why 20?
There was a problem hiding this comment.
It was an arbitrary number. Copied over from Figures. I chose it because it was twice the size of the default edx-platform value: https://github.com/appsembler/edx-platform/blob/appsembler/tahoe/master/lms/envs/common.py#L2343
I thought 10 was too low and that 20 was a good starting point to see how responsive the api would be.
Good for me to document this point. Thanks!
There was a problem hiding this comment.
Updated class docstring to reflect why '20' was chosen
| replaced with spaces, so we need to put the '+' back in for CourseKey | ||
| to be able to create a course key object from the string | ||
| ''' | ||
| course_key = CourseKey.from_string(value.replace(' ', '+')) |
There was a problem hiding this comment.
don't we have an as_course_key helper method as part of this PR? why not use that?
There was a problem hiding this comment.
Good point! Why? Copied code over from code not refactored. Will refactor
There was a problem hiding this comment.
Updated to use as_course_key
| course_keys = value | ||
| for course in course_keys: | ||
| try: | ||
| CourseKey.from_string(course) |
There was a problem hiding this comment.
As above, what's the difference between this and the helper method as_course_key? The latter seems to have some typing checking as a safety mechanism. Why do we use it in some cases and direct CourseKey.from_string() in others?
There was a problem hiding this comment.
Please see my reply to your prior question. Will use as_course_key
There was a problem hiding this comment.
Updated. Will now raise serializers.ValidationError on any exception raised when trying to convert to a CourseKey instance
|
@abeals Thanks for reviewing and your comments. A common theme: "rolling tech debt" as I copied code over from Figures that I know works. This brings the topic of what our future architectural strategy will be for common multisite handling code |
…the comment on using the default site
|
@OmarIthawi @melvinsoft @abeals Updated added. Please review. If all is goo enough, we can merge and test on staging |
melvinsoft
left a comment
There was a problem hiding this comment.
@johnbaldwin Thanks for putting together! The implementation is top notch. I just have some questions and requests.
| org_courses = OrganizationCourse.objects.filter(course_id=str(course_id)) | ||
| if org_courses: | ||
| # Keep until this assumption analyzed | ||
| msg = 'Multiple orgs found for course: {}' |
There was a problem hiding this comment.
@johnbaldwin I don't fully understand how the messages are being managed, where do we sent them? Please clarify, thanks!
|
|
||
| COURSE_ID_STR_TEMPLATE = 'course-v1:StarFleetAcademy+SFA{}+2161' | ||
|
|
||
| class CourseOverviewFactory(factory.DjangoModelFactory): |
There was a problem hiding this comment.
Agreed with @johnbaldwin , despite how much we do like re-utilise, this one is out of scope.
| if serializer.is_valid(): | ||
| # TODO: Wrap in transaction | ||
| # TODO: trap error on each attempt and log | ||
| # IMPORTANT: THIS IS A WIP to get this working quickly |
There was a problem hiding this comment.
@johnbaldwin Please clarify if is still a WIP, the WIP part has been removed from the PR title already. Thanks!
|
@melvinsoft I added invalid/wrong site course ids checking to the bulk enrollment POST along with a helper method cc @OmarIthawi |
melvinsoft
left a comment
There was a problem hiding this comment.
@johnbaldwin Thanks for implementing the requested changes! There still some pending comments from Omar and I, please address them, but I think we're ready for staging now.
I'm approving the PR.
Up for PR is the site aware enrollment API and course API code
Included
Courses API
** GET a list of courses for the given site + basic data: 'id', 'display_name', 'org'
** GET a record view of a given course. Same serialized data as for in the list
This is a starting point and provides the minimum essential information so API users can get course identifiers. We can build on more functionality from here as needed
Enrollment API
** POST bulk enrollment using the same interface as for Ginkgo:
*** https://github.com/appsembler/edx-platform/blob/appsembler/ginkgo/master/lms/djangoapps/appsembler_api/views.py#L377-L403
*** https://github.com/appsembler/edx-platform/blob/appsembler/ginkgo/master/lms/djangoapps/appsembler_api/apidocs.md#enrollment-codes-endpoints
** GET list of all course enrollments for the site
** GET list of all course enrollments for a given course
NOT INCLUDED