From 6d8fba77b20bf71a47127ac7a9c235f92b493fa4 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Sat, 11 Jul 2020 13:05:18 -0400 Subject: [PATCH 1/7] ADR for LMS/Studio subdomain boundaries (TNL-7323) Introduce an Architectural Decision Record about the boundaries and responsibilities of Studio (Content Authoring) and the LMS (Learning Experience). These are not new ideas. This is just explicitly writing down the many local decisions that have been made for years (Block Transformers, edx-when, grading, etc.), and showing how they add together to a set of high level principles. I hope to eventually have docs describing Studio/LMS's relation to other subdomains in Open edX. --- .../0005-studio-lms-subdomain-boundaries.rst | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 docs/decisions/0005-studio-lms-subdomain-boundaries.rst diff --git a/docs/decisions/0005-studio-lms-subdomain-boundaries.rst b/docs/decisions/0005-studio-lms-subdomain-boundaries.rst new file mode 100644 index 0000000000..1090e3f0bb --- /dev/null +++ b/docs/decisions/0005-studio-lms-subdomain-boundaries.rst @@ -0,0 +1,178 @@ +Status +====== + +Proposed + + +Context +======= + +The ``edx-platform`` repo contains both Studio and the LMS for Open edX. These +two systems roughly correspond to the Content Authoring and Learning subdomains, +but the precise separation of responsibilities is currently unclear in many +cases. This ADR is intended to clarify those boundaries and offer guidelines for +how developers should compose new functionality across these systems, as well as +providing a direction for migrating existing functionality over the long term. + +Note that it is likely that we'll further separate content authoring (e.g. +content libraries) from course run administration (e.g. grading policy). It's +possible that both of these will evolve under the umbrella of what users see as +"Studio". Even if that happens, there will still be an architectural split +between the Content Authoring and Learning subdomains within that new Studio. + + +Decision +======== + +The high level guidelines for the interaction between the Content Authoring and +Learning subdomains currently represented by Studio and LMS are: + +* Studio should not store Learner information. +* Studio and LMS should use different representations of content. +* Decouple content grouping concepts from user/learning grouping concepts. +* Studio Content data acts as an input to LMS policy and Learner Experience data. +* LMS data should not flow backward into Studio. +* Content Authoring changes require explicit publishing of versioned data. + + +Studio should not store Learner information. +-------------------------------------------- + +Studio's responsibility centers around the content itself. It should not store +information about students, which brings with it many other concerns around +data sensitivity and scale. + + +Studio and LMS should use different representations of content. +-------------------------------------------------------------------- + +Content authoring will require versioned storage of data, ownership tracking, +tagging, and other metadata. The LMS focuses on read-optimization at a much +higher scale. We've long suffered from added code complexity and performance +issues by trying to cover both usage patterns with ModuleStore. + +We have already taken steps to create a read-optimized store in the form of +Block Transformers. We should continue this practice and encourage the LMS to +transform content at publish-time into whatever representation its various +systems (courseware, grading, scheduling, etc.) require to be performant. + + +Decouple content grouping concepts from user/learning grouping concepts. +------------------------------------------------------------------------ + +A common use case for course content is to show different bits of content to +different cohorts of users. For instance, a university might have a licensing +agreement that allows it to show a set of vidoes only to its own staff and +students, and not a wider MOOC audience. Studio needs to be able to annotate +this data somehow, but the list of available cohorts for a given course is +considered Learner information that may change from run to run. + +We solve this by using a level of indirection. Studio doesn't map content into +Cohorts of students (an LMS concept). It maps content into Content Groups. The +LMS is then responsible for both the creation of Cohorts as well as the mapping +of Content Groups to Cohorts. + +While this might sound a little cumbersome, it actually allows for a cleaner +separation of concerns. Content Groups describe what the content is: restricted +copyright, advanced material, labratory exercises, etc. Cohorts describe who is +consuming that material: on campus students, alumni, the general MOOC audience, +etc. The Content Group is an Authoring decision based on the properties of the +content itself. The Cohort mapping is a policy decision about the Learning +experience of a particular set of students. + +Furthermore, the mapping of Content Groups to Cohorts is not 1:1. You could +decide that both on-campus students and alumni get the same content group +experience, while keeping those Cohorts separate for the purposes of other parts +of the LMS like forums discussions. + +A more future looking example might be the interaction between Open edX +courseware and third party forum services. The fact that certain units are +marked as discussable topics might be a Content Authoring decision in Studio, +while the choice of which forum service those discussions happen in might be a +Learning decision in the LMS. + + +Studio Content data acts as an input to LMS policy and Learner Experience data. +------------------------------------------------------------------------------- + +As courseware becomes more dynamic, certain concepts in the LMS are becoming +richer than their equivalent concepts in Studio. In these situations, we should +think of the data relationship as a one way flow of data from Studio to the LMS. +The LMS takes Studio data as an input that it can enrich, transform, or override +as necessary to create the desired student learning experience. + +Content scheduling is a good example of this. In the early days of Open edX, +course teams would set start and due dates for subsections in Studio, and that +would be the end of it. Today, we have personalized schedules, individual due +date extensions, and more. The pattern we use to accomplish this is: + +* Copy the schedule information from Studio to the LMS at the time a course is + published, transforming it into a more easily queryable form in the process. +* Add additional data models in the LMS to support use cases like individual due + date extensions and personalized rescheduling. This is currently handled by + the edx_when app, developed in the edx-when repository. +* Remap field data so that XBlocks in the LMS runtime query this richer data + model. Accessing an XBlock's ``start`` or ``due`` attribute in the Studio + runtime continues to work with simple key/values in ModuleStore, but the LMS + XBlock runtime will fetch those values from edx-when's in-process API. + +This approach allows us to add flexibility to the LMS, while preserving +backwards compatibility with existing XBlock content. + + +LMS data should not flow backward into Studio. +---------------------------------------------- + +Since LMS concepts extend Studio ones, we don't want changes to flow backwards +from the LMS back into Studio. Some reasons: + +* There is no guarantee that Studio course runs will be 1:1 with LMS course + runs. In fact, one to many mappings of course runs already exist if CCX + courses are enabled. +* A unidirectional data flow makes the system easier to debug and reason about. +* The OLX import/export process stays much simpler if it doesn't have to + consider data that the LMS has added. + + +Content Authoring changes require explicit publishing of versioned data. +------------------------------------------------------------------------ + +Changes to content data should be marked with an explicit, versioned publishing +step. Many LMS systems update their representations of content data based on +publish signals from Studio today. Studio also needs to differentiate draft +changes authors want to make from changes that are ready for student use. + +The LMS is permitted to modify the learning experience without any such explicit +publishing step. Deadlines may pass, blocking off student access to certain +parts of a course. Individual students may be placed into different teams or +cohorts, given extensions, re-graded, etc. + + +Goals +===== + +* Developers will have a clearer understanding of where to build authoring and + learning experience functionality. +* Improved separation of these subdomains will allow for easier debugging and + better performance. +* Decoupling these subdomains will allow for more rapid interation and + innovation. + + +Alternatives Considered +======================= + +An early alternative approach (that periodically resurfaces) is to make the +content editing and publishing process happen in a much more integrated way. The +learning and authoring experience blend together so closely that the author is +essentially looking at the same interface as the student, supplemented with an +edit button to modify thing in-line. + +This approach was rejected early on because: + +* Authoring needs differed in the workflow and information that they had to + surface to course authors. +* Separating the authoring and student experience allows multiple authoring + systems (e.g. GitHub based OLX authoring). +* At various points, the content authoring experience has been owned by a + different team than the learning experience. From aa24deb4faca8862b06c0043d4013b55be60cc89 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 14 Jul 2020 09:43:50 -0400 Subject: [PATCH 2/7] Update codejail. --- requirements/edx/base.txt | 4 ++-- requirements/edx/development.txt | 4 ++-- requirements/edx/github.in | 2 +- requirements/edx/testing.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9e6c64fbab..ca366e87bb 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -6,7 +6,7 @@ # -e git+https://github.com/edx/acid-block.git@98aecba94ecbfa934e2d00262741c0ea9f557fc9#egg=acid-xblock # via -r requirements/edx/github.in -e common/lib/capa # via -r requirements/edx/local.in --e git+https://github.com/edx/codejail.git@4127fc4bd5775cc72aee8d7f0a70e31405e22439#egg=codejail # via -r requirements/edx/github.in +-e git+https://github.com/edx/codejail.git@56abd45fcf68c6ab7b523768aa5991d6e04d8241#egg=codejail==3.0.0 # via -r requirements/edx/github.in -e git+https://github.com/edx/django-wiki.git@0.0.27#egg=django-wiki # via -r requirements/edx/github.in -e git+https://github.com/edx/DoneXBlock.git@2.0.2#egg=done-xblock # via -r requirements/edx/github.in -e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme # via -r requirements/edx/github.in @@ -111,7 +111,7 @@ edx-rest-api-client==5.2.1 # via -r requirements/edx/base.in, edx-enterprise, e edx-search==1.4.1 # via -r requirements/edx/base.in edx-sga==0.11.0 # via -r requirements/edx/base.in edx-submissions==3.1.11 # via -r requirements/edx/base.in, ora2 -edx-tincan-py35==0.0.5 # via edx-enterprise +edx-tincan-py35==0.0.9 # via edx-enterprise edx-user-state-client==1.2.0 # via -r requirements/edx/base.in edx-when==1.2.9 # via -r requirements/edx/base.in, edx-proctoring edxval==1.3.8 # via -r requirements/edx/base.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index eedae10004..d91fa85af4 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -6,7 +6,7 @@ # -e git+https://github.com/edx/acid-block.git@98aecba94ecbfa934e2d00262741c0ea9f557fc9#egg=acid-xblock # via -r requirements/edx/testing.txt -e common/lib/capa # via -r requirements/edx/testing.txt --e git+https://github.com/edx/codejail.git@4127fc4bd5775cc72aee8d7f0a70e31405e22439#egg=codejail # via -r requirements/edx/testing.txt +-e git+https://github.com/edx/codejail.git@56abd45fcf68c6ab7b523768aa5991d6e04d8241#egg=codejail==3.0.0 # via -r requirements/edx/testing.txt -e git+https://github.com/edx/django-wiki.git@0.0.27#egg=django-wiki # via -r requirements/edx/testing.txt -e git+https://github.com/edx/DoneXBlock.git@2.0.2#egg=done-xblock # via -r requirements/edx/testing.txt -e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme # via -r requirements/edx/testing.txt @@ -125,7 +125,7 @@ edx-search==1.4.1 # via -r requirements/edx/testing.txt edx-sga==0.11.0 # via -r requirements/edx/testing.txt edx-sphinx-theme==1.5.0 # via -r requirements/edx/development.in edx-submissions==3.1.11 # via -r requirements/edx/testing.txt, ora2 -edx-tincan-py35==0.0.5 # via -r requirements/edx/testing.txt, edx-enterprise +edx-tincan-py35==0.0.9 # via -r requirements/edx/testing.txt, edx-enterprise edx-user-state-client==1.2.0 # via -r requirements/edx/testing.txt edx-when==1.2.9 # via -r requirements/edx/testing.txt, edx-proctoring edxval==1.3.8 # via -r requirements/edx/testing.txt diff --git a/requirements/edx/github.in b/requirements/edx/github.in index 767e19f786..fcbb4f4044 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -59,7 +59,7 @@ git+https://github.com/edx/MongoDBProxy.git@d92bafe9888d2940f647a7b2b2383b29c752 -e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme # Our libraries: --e git+https://github.com/edx/codejail.git@4127fc4bd5775cc72aee8d7f0a70e31405e22439#egg=codejail +-e git+https://github.com/edx/codejail.git@56abd45fcf68c6ab7b523768aa5991d6e04d8241#egg=codejail==3.0.0 -e git+https://github.com/edx/acid-block.git@98aecba94ecbfa934e2d00262741c0ea9f557fc9#egg=acid-xblock -e git+https://github.com/edx/RateXBlock.git@2.0#egg=rate-xblock -e git+https://github.com/edx/DoneXBlock.git@2.0.2#egg=done-xblock diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index a20f7aa4ee..7715266707 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -6,7 +6,7 @@ # -e git+https://github.com/edx/acid-block.git@98aecba94ecbfa934e2d00262741c0ea9f557fc9#egg=acid-xblock # via -r requirements/edx/base.txt -e common/lib/capa # via -r requirements/edx/base.txt --e git+https://github.com/edx/codejail.git@4127fc4bd5775cc72aee8d7f0a70e31405e22439#egg=codejail # via -r requirements/edx/base.txt +-e git+https://github.com/edx/codejail.git@56abd45fcf68c6ab7b523768aa5991d6e04d8241#egg=codejail==3.0.0 # via -r requirements/edx/base.txt -e git+https://github.com/edx/django-wiki.git@0.0.27#egg=django-wiki # via -r requirements/edx/base.txt -e git+https://github.com/edx/DoneXBlock.git@2.0.2#egg=done-xblock # via -r requirements/edx/base.txt -e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme # via -r requirements/edx/base.txt @@ -121,7 +121,7 @@ edx-rest-api-client==5.2.1 # via -r requirements/edx/base.txt, edx-enterprise, edx-search==1.4.1 # via -r requirements/edx/base.txt edx-sga==0.11.0 # via -r requirements/edx/base.txt edx-submissions==3.1.11 # via -r requirements/edx/base.txt, ora2 -edx-tincan-py35==0.0.5 # via -r requirements/edx/base.txt, edx-enterprise +edx-tincan-py35==0.0.9 # via -r requirements/edx/base.txt, edx-enterprise edx-user-state-client==1.2.0 # via -r requirements/edx/base.txt edx-when==1.2.9 # via -r requirements/edx/base.txt, edx-proctoring edxval==1.3.8 # via -r requirements/edx/base.txt From 3c8630bdf40ae142c4f8f33c5cde165f1df9c963 Mon Sep 17 00:00:00 2001 From: Lael Birch Date: Tue, 14 Jul 2020 09:53:07 -0400 Subject: [PATCH 3/7] Update edx-enterprise requirement Unskip a test that the new version fixes --- common/djangoapps/util/tests/test_db.py | 1 - requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/common/djangoapps/util/tests/test_db.py b/common/djangoapps/util/tests/test_db.py index 7567a83aba..108b66a827 100644 --- a/common/djangoapps/util/tests/test_db.py +++ b/common/djangoapps/util/tests/test_db.py @@ -197,7 +197,6 @@ class MigrationTests(TestCase): Tests for migrations. """ - @unittest.skip('migration is purposely out of sync with models atm.') @override_settings(MIGRATION_MODULES={}) def test_migrations_are_in_sync(self): """ diff --git a/requirements/constraints.txt b/requirements/constraints.txt index b995dc66c6..2610ee45b3 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -43,7 +43,7 @@ drf-jwt==1.14.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==3.3.21 +edx-enterprise==3.3.22 # Upgrading to 2.12.0 breaks several test classes due to API changes, need to update our code accordingly factory-boy==2.8.1 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9e6c64fbab..c24bfd71d3 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -99,7 +99,7 @@ edx-django-release-util==0.4.4 # via -r requirements/edx/base.in edx-django-sites-extensions==2.5.1 # via -r requirements/edx/base.in edx-django-utils==3.2.3 # via -r requirements/edx/base.in, django-config-models, edx-drf-extensions, edx-enterprise, edx-rest-api-client, edx-when edx-drf-extensions==6.1.0 # via -r requirements/edx/base.in, edx-completion, edx-enterprise, edx-organizations, edx-proctoring, edx-rbac, edx-when, edxval -edx-enterprise==3.3.21 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in +edx-enterprise==3.3.22 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in edx-i18n-tools==0.5.3 # via ora2 edx-milestones==0.3.0 # via -r requirements/edx/base.in edx-opaque-keys[django]==2.1.1 # via -r requirements/edx/paver.txt, edx-bulk-grades, edx-ccx-keys, edx-completion, edx-drf-extensions, edx-enterprise, edx-milestones, edx-organizations, edx-proctoring, edx-user-state-client, edx-when, xmodule diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index eedae10004..569a4a3ee3 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -111,7 +111,7 @@ edx-django-release-util==0.4.4 # via -r requirements/edx/testing.txt edx-django-sites-extensions==2.5.1 # via -r requirements/edx/testing.txt edx-django-utils==3.2.3 # via -r requirements/edx/testing.txt, django-config-models, edx-drf-extensions, edx-enterprise, edx-rest-api-client, edx-when edx-drf-extensions==6.1.0 # via -r requirements/edx/testing.txt, edx-completion, edx-enterprise, edx-organizations, edx-proctoring, edx-rbac, edx-when, edxval -edx-enterprise==3.3.21 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt +edx-enterprise==3.3.22 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt edx-i18n-tools==0.5.3 # via -r requirements/edx/testing.txt, ora2 edx-lint==1.5.0 # via -r requirements/edx/testing.txt edx-milestones==0.3.0 # via -r requirements/edx/testing.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index a20f7aa4ee..fc552c694f 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -108,7 +108,7 @@ edx-django-release-util==0.4.4 # via -r requirements/edx/base.txt edx-django-sites-extensions==2.5.1 # via -r requirements/edx/base.txt edx-django-utils==3.2.3 # via -r requirements/edx/base.txt, django-config-models, edx-drf-extensions, edx-enterprise, edx-rest-api-client, edx-when edx-drf-extensions==6.1.0 # via -r requirements/edx/base.txt, edx-completion, edx-enterprise, edx-organizations, edx-proctoring, edx-rbac, edx-when, edxval -edx-enterprise==3.3.21 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt +edx-enterprise==3.3.22 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt edx-i18n-tools==0.5.3 # via -r requirements/edx/base.txt, -r requirements/edx/testing.in, ora2 edx-lint==1.5.0 # via -r requirements/edx/testing.in edx-milestones==0.3.0 # via -r requirements/edx/base.txt From e89e1a403843bd59879a4372fbada2eda61028a8 Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Tue, 14 Jul 2020 09:57:30 -0400 Subject: [PATCH 4/7] update oauth_dispatch ADRs to accepted (#24474) The following oauth_dispatch ADRs are being marked as accepted, because they were implemented and are in use in Open edX. - 0006-enforce-scopes-in-LMS-APIs.rst - 0007-include-organizations-in-tokens.rst Given that, scopes have still not been widely adopted in Open edX, which would require additional decisions and investment. --- .../0006-enforce-scopes-in-LMS-APIs.rst | 2 +- .../0007-include-organizations-in-tokens.rst | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0006-enforce-scopes-in-LMS-APIs.rst b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0006-enforce-scopes-in-LMS-APIs.rst index d9d1679889..547d8a7427 100644 --- a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0006-enforce-scopes-in-LMS-APIs.rst +++ b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0006-enforce-scopes-in-LMS-APIs.rst @@ -4,7 +4,7 @@ Status ------ -Proposed +Accepted Context ------- diff --git a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst index 134bdc4509..5f3e4d181c 100644 --- a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst +++ b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst @@ -4,7 +4,7 @@ Status ------ -Proposed +Accepted Context ------- @@ -42,9 +42,9 @@ Here are a few organizational relationships that exist in the edX system: do so either via a. bulk APIs using the *Client Credentials grant type* (e.g., to - synchronize their own data in a background process) or + synchronize their own data in a background process) or - b. a user-specific API on behalf of a logged-in user via the + b. a user-specific API on behalf of a logged-in user via the *Authorization grant type* and *edX as the identity provider* (e.g., to display user-specific data on their own portal). @@ -100,7 +100,7 @@ organization information to the granting end-user. "content_org:Microsoft" -* For a token created on behalf of a user (*not* created via a +* For a token created on behalf of a user (*not* created via a *Client Credentials grant type*), the token is further restricted specifically for the granting user. And so, a "user" filter with the value "me" would be added for this grant type. @@ -137,7 +137,7 @@ Token Examples Client Credentials (server-to-server) grant type ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -When a trusted application makes server-to-server calls, the application's +When a trusted application makes server-to-server calls, the application's service user info is included in the JWT and the *filters* field includes the organization identifier and type associated with the application. @@ -166,7 +166,7 @@ filter “me” in the *filters* field. "version": "1.0", "preferred_username": "ajay_mehta", ... - } + } Consequences ------------ @@ -191,7 +191,7 @@ Consequences make sure there are no security issues introduced where old endpoints that are not aware of the new filter do not enforce it. Possible ways of doing so are: - + * Endpoints that are highly security sensitive should reject any token that includes an unrecognized filter. @@ -206,7 +206,7 @@ Consequences they don't recognize. For example: "grades:read:content_org" - + Additionally, this alternative would allow tokens to specify different filters for different scopes. From ef536e49deb0d28a0866dc760ba2c45a62b5c847 Mon Sep 17 00:00:00 2001 From: Christie Rice <8483753+crice100@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:37:36 -0400 Subject: [PATCH 5/7] MICROBA-393 Add customized partner report headings (#24437) --- .../user_api/accounts/serializers.py | 2 + .../accounts/tests/test_retirement_views.py | 100 +++++++++++++++--- .../djangoapps/user_api/accounts/views.py | 92 +++++++++++++--- 3 files changed, 167 insertions(+), 27 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index 7f197cb8b6..1110bfad9c 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -475,10 +475,12 @@ class UserRetirementPartnerReportSerializer(serializers.Serializer): Perform serialization for the UserRetirementPartnerReportingStatus model """ user_id = serializers.IntegerField() + student_id = serializers.CharField(required=False) original_username = serializers.CharField() original_email = serializers.EmailField() original_name = serializers.CharField() orgs = serializers.ListField(child=serializers.CharField()) + orgs_config = serializers.ListField(required=False) created = serializers.DateTimeField() # Required overrides of abstract base class methods, but we don't use them diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py index b9e17df87c..ab44772a3b 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py @@ -46,6 +46,7 @@ from openedx.core.djangoapps.credit.models import ( CreditRequirement, CreditRequirementStatus ) +from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementPartnerReportView @@ -287,7 +288,7 @@ class TestPartnerReportingCleanup(ModuleStoreTestCase): def create_partner_reporting_statuses(self, is_being_processed=True, num=2): """ - Creates and returnes the given number of test users and UserRetirementPartnerReportingStatuses + Creates and returns the given number of test users and UserRetirementPartnerReportingStatuses with the given is_being_processed value. """ statuses = [] @@ -482,6 +483,17 @@ class TestPartnerReportingList(ModuleStoreTestCase): """ Tests the partner reporting list endpoint """ + EXPECTED_MB_ORGS_CONFIG = [ + { + AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching', + AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [ + AccountRetirementPartnerReportView.STUDENT_ID_KEY, + AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY, + AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY, + AccountRetirementPartnerReportView.DELETION_COMPLETED_KEY + ] + } + ] def setUp(self): super(TestPartnerReportingList, self).setUp() @@ -493,6 +505,7 @@ class TestPartnerReportingList(ModuleStoreTestCase): self.url = reverse('accounts_retirement_partner_report') self.maxDiff = None self.test_created_datetime = datetime.datetime(2018, 1, 1, tzinfo=pytz.UTC) + ExternalIdType.objects.get_or_create(name=ExternalIdType.MICROBACHELORS_COACHING) def get_user_dict(self, user, enrollments): """ @@ -517,9 +530,10 @@ class TestPartnerReportingList(ModuleStoreTestCase): their processing state to "is_being_processed". Returns a list of user dicts representing what we would expect back from the - endpoint for the given user / enrollment. + endpoint for the given user / enrollment, and a list of the users themselves. """ user_dicts = [] + users = [] courses = self.courses if courses is None else courses for _ in range(num): @@ -540,8 +554,9 @@ class TestPartnerReportingList(ModuleStoreTestCase): user_dicts.append( self.get_user_dict(user, enrollments) ) + users.append(user) - return user_dicts + return user_dicts, users def assert_status_and_user_list(self, expected_users, expected_status=status.HTTP_200_OK): """ @@ -561,9 +576,19 @@ class TestPartnerReportingList(ModuleStoreTestCase): # These sub-lists will fail assertCountEqual if they're out of order for expected_user in expected_users: expected_user['orgs'].sort() + if AccountRetirementPartnerReportView.ORGS_CONFIG_KEY in expected_user: + orgs_config = expected_user[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] + orgs_config.sort() + for config in orgs_config: + config[AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY].sort() for returned_user in returned_users: returned_user['orgs'].sort() + if AccountRetirementPartnerReportView.ORGS_CONFIG_KEY in returned_user: + orgs_config = returned_user[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] + orgs_config.sort() + for config in orgs_config: + config[AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY].sort() self.assertCountEqual(returned_users, expected_users) @@ -571,21 +596,72 @@ class TestPartnerReportingList(ModuleStoreTestCase): """ Basic test to make sure that users in two different orgs are returned. """ - users = self.create_partner_reporting_statuses() - users += self.create_partner_reporting_statuses(courses=(self.course_awesome_org,)) + user_dicts, users = self.create_partner_reporting_statuses() + additional_dicts, additional_users = self.create_partner_reporting_statuses(courses=(self.course_awesome_org,)) + user_dicts += additional_dicts - self.assert_status_and_user_list(users) + self.assert_status_and_user_list(user_dicts) def test_success_multiple_statuses(self): """ Checks that only users in the correct is_being_processed state (False) are returned. """ - users = self.create_partner_reporting_statuses() + user_dicts, users = self.create_partner_reporting_statuses() # These should not come back self.create_partner_reporting_statuses(courses=(self.course_awesome_org,), is_being_processed=True) - self.assert_status_and_user_list(users) + self.assert_status_and_user_list(user_dicts) + + def test_success_mb_coaching(self): + """ + Check that MicroBachelors users who have consented to coaching have the proper info + included for the partner report. + """ + path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching' + with mock.patch(path, return_value=True) as mock_has_ever_consented: + user_dicts, users = self.create_partner_reporting_statuses(num=1) + external_id, created = ExternalId.add_new_user_id( + user=users[0], + type_name=ExternalIdType.MICROBACHELORS_COACHING + ) + + expected_user = user_dicts[0] + expected_users = [expected_user] + expected_user[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = str(external_id.external_user_id) + expected_user[ + AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = TestPartnerReportingList.EXPECTED_MB_ORGS_CONFIG + + self.assert_status_and_user_list(expected_users) + mock_has_ever_consented.assert_called_once() + + def test_success_mb_coaching_no_external_id(self): + """ + Check that MicroBachelors users who have consented to coaching, but who do not have an external id, have the + proper info included for the partner report. + """ + path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching' + with mock.patch(path, return_value=True) as mock_has_ever_consented: + user_dicts, users = self.create_partner_reporting_statuses(num=1) + + self.assert_status_and_user_list(user_dicts) + mock_has_ever_consented.assert_called_once() + + def test_success_mb_coaching_no_consent(self): + """ + Check that MicroBachelors users who have not consented to coaching have the proper info + included for the partner report. + """ + path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching' + with mock.patch(path, return_value=False) as mock_has_ever_consented: + user_dicts, users = self.create_partner_reporting_statuses(num=1) + ExternalId.add_new_user_id( + user=users[0], + type_name=ExternalIdType.MICROBACHELORS_COACHING + ) + + self.assert_status_and_user_list(user_dicts) + mock_has_ever_consented.assert_called_once() def test_no_users(self): """ @@ -606,10 +682,10 @@ class TestPartnerReportingList(ModuleStoreTestCase): Checks that users are progressed to "is_being_processed" True upon being returned from this call. """ - users = self.create_partner_reporting_statuses() + user_dicts, users = self.create_partner_reporting_statuses() # First time through we should get the users - self.assert_status_and_user_list(users) + self.assert_status_and_user_list(user_dicts) # Second time they should be updated to is_being_processed=True self.assert_status_and_user_list([]) @@ -1105,7 +1181,7 @@ class TestAccountRetirementUpdate(RetirementTestCase): data = {'new_state': 'LOCKING_ACCOUNT', 'response': 'this should succeed'} self.update_and_assert_status(data) - # Refresh the retirment object and confirm the messages and state are correct + # Refresh the retirement object and confirm the messages and state are correct retirement = UserRetirementStatus.objects.get(id=self.retirement.id) self.assertEqual(retirement.current_state, RetirementState.objects.get(state_name='LOCKING_ACCOUNT')) self.assertEqual(retirement.last_state, RetirementState.objects.get(state_name='PENDING')) @@ -1127,7 +1203,7 @@ class TestAccountRetirementUpdate(RetirementTestCase): for update_data in fake_retire_process: self.update_and_assert_status(update_data) - # Refresh the retirment object and confirm the messages and state are correct + # Refresh the retirement object and confirm the messages and state are correct retirement = UserRetirementStatus.objects.get(id=self.retirement.id) self.assertEqual(retirement.current_state, RetirementState.objects.get(state_name='COMPLETE')) self.assertEqual(retirement.last_state, RetirementState.objects.get(state_name='CREDENTIALS_COMPLETE')) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index c319af66d6..0719ebe217 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -44,6 +44,7 @@ from openedx.core.djangoapps.ace_common.template_context import get_base_templat from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus +from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.profile_images.images import remove_profile_images from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image @@ -82,6 +83,11 @@ from .permissions import CanDeactivateUser, CanReplaceUsername, CanRetireUser from .serializers import UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS +try: + from coaching.api import has_ever_consented_to_coaching +except ImportError: + has_ever_consented_to_coaching = None + log = logging.getLogger(__name__) USER_PROFILE_PII = { @@ -530,6 +536,14 @@ class AccountRetirementPartnerReportView(ViewSet): Provides API endpoints for managing partner reporting of retired users. """ + DELETION_COMPLETED_KEY = 'deletion_completed' + ORGS_CONFIG_KEY = 'orgs_config' + ORGS_CONFIG_ORG_KEY = 'org' + ORGS_CONFIG_FIELD_HEADINGS_KEY = 'field_headings' + ORIGINAL_EMAIL_KEY = 'original_email' + ORIGINAL_NAME_KEY = 'original_name' + STUDENT_ID_KEY = 'student_id' + authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanRetireUser,) parser_classes = (JSONParser,) @@ -544,7 +558,7 @@ class AccountRetirementPartnerReportView(ViewSet): for enrollment in user.courseenrollment_set.all(): org = enrollment.course_id.org - # Org can concievably be blank or this bogus default value + # Org can conceivably be blank or this bogus default value if org and org != 'outdated_entry': orgs.add(org) try: @@ -569,17 +583,9 @@ class AccountRetirementPartnerReportView(ViewSet): is_being_processed=False ).order_by('id') - retirements = [ - { - 'user_id': retirement.user.pk, - 'original_username': retirement.original_username, - 'original_email': retirement.original_email, - 'original_name': retirement.original_name, - 'orgs': self._get_orgs_for_user(retirement.user), - 'created': retirement.created, - } - for retirement in retirement_statuses - ] + retirements = [] + for retirement_status in retirement_statuses: + retirements.append(self._get_retirement_for_partner_report(retirement_status)) serializer = UserRetirementPartnerReportSerializer(retirements, many=True) @@ -587,6 +593,62 @@ class AccountRetirementPartnerReportView(ViewSet): return Response(serializer.data) + def _get_retirement_for_partner_report(self, retirement_status): + """ + Get the retirement for this retirement_status. The retirement info will be included in the partner report. + """ + retirement = { + 'user_id': retirement_status.user.pk, + 'original_username': retirement_status.original_username, + AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY: retirement_status.original_email, + AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY: retirement_status.original_name, + 'orgs': self._get_orgs_for_user(retirement_status.user), + 'created': retirement_status.created, + } + + # Some orgs have a custom list of headings and content for the partner report. Add this, if applicable. + self._add_orgs_config_for_user(retirement, retirement_status.user) + + return retirement + + def _add_orgs_config_for_user(self, retirement, user): + """ + Check to see if the user's info was sent to any partners (orgs) that have a a custom list of headings and + content for the partner report. If so, add this. + """ + # See if the MicroBachelors coaching provider needs to be notified of this user's retirement + if has_ever_consented_to_coaching is not None and has_ever_consented_to_coaching(user): + # See if the user has a MicroBachelors external id. If not, they were never sent to the + # coaching provider. + external_ids = ExternalId.objects.filter( + user=user, + external_id_type__name=ExternalIdType.MICROBACHELORS_COACHING + ) + if external_ids.exists(): + # User has an external id. Add the additional info. + external_id = str(external_ids[0].external_user_id) + self._add_coaching_orgs_config(retirement, external_id) + + def _add_coaching_orgs_config(self, retirement, external_id): + """ + Add the orgs configuration for MicroBachelors coaching + """ + # Add the custom field headings + retirement[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = [ + { + AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching', + AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [ + AccountRetirementPartnerReportView.STUDENT_ID_KEY, + AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY, + AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY, + AccountRetirementPartnerReportView.DELETION_COMPLETED_KEY + ] + } + ] + + # Add the custom field value + retirement[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = external_id + @request_requires_username def retirement_partner_status_create(self, request): """ @@ -707,7 +769,7 @@ class AccountRetirementStatusView(ViewSet): ) serializer = UserRetirementStatusSerializer(retirements, many=True) return Response(serializer.data) - # This should only occur on the int() converstion of cool_off_days at this point + # This should only occur on the int() conversion of cool_off_days at this point except ValueError: return Response('Invalid cool_off_days, should be integer.', status=status.HTTP_400_BAD_REQUEST) except KeyError as exc: @@ -1056,7 +1118,7 @@ class UsernameReplacementView(APIView): updates usernames across all services. DO NOT run this alone or users will not match across the system and things will be broken. - API will recieve a list of current usernames and their requested new + API will receive a list of current usernames and their requested new username. If their new username is taken, it will randomly assign a new username. This API will be called first, before calling the APIs in other services as this @@ -1166,7 +1228,7 @@ class UsernameReplacementView(APIView): """ Generates a unique username. If the desired username is available, that will be returned. - Otherwise it will generate unique suffixs to the desired username until it is an available username. + Otherwise it will generate unique suffixes to the desired username until it is an available username. """ new_username = desired_username # Keep checking usernames in case desired_username + random suffix is already taken From 858c3750b067ebf39dfb7482d419f348621f4ee5 Mon Sep 17 00:00:00 2001 From: Nicholas D'Alfonso Date: Mon, 29 Jun 2020 14:19:27 -0400 Subject: [PATCH 6/7] AA-160 calendar sync initial email -use Amazon SES to send calendar sync email when user initially subscribes to the calendar sync feature --- lms/djangoapps/courseware/courses.py | 9 ++-- openedx/features/calendar_sync/__init__.py | 1 + openedx/features/calendar_sync/admin.py | 4 ++ openedx/features/calendar_sync/apps.py | 18 ++++++++ openedx/features/calendar_sync/ics.py | 23 +++++++---- .../migrations/0002_auto_20200709_1743.py | 23 +++++++++++ openedx/features/calendar_sync/models.py | 1 + openedx/features/calendar_sync/signals.py | 35 ++++++++++++++++ .../features/calendar_sync/tests/factories.py | 12 ++++++ .../features/calendar_sync/tests/test_ics.py | 22 +++++++--- openedx/features/calendar_sync/utils.py | 41 +++++++++++-------- 11 files changed, 153 insertions(+), 36 deletions(-) create mode 100644 openedx/features/calendar_sync/admin.py create mode 100644 openedx/features/calendar_sync/apps.py create mode 100644 openedx/features/calendar_sync/migrations/0002_auto_20200709_1743.py create mode 100644 openedx/features/calendar_sync/signals.py create mode 100644 openedx/features/calendar_sync/tests/factories.py diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 4beabc17b1..58db73568e 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -501,14 +501,14 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None, if num_return is None in date increasing order. """ date_blocks = [] - for assignment in get_course_assignments(course.id, user, request, include_access=include_access): + for assignment in get_course_assignments(course.id, user, include_access=include_access): date_block = CourseAssignmentDate(course, user) date_block.date = assignment.date date_block.contains_gated_content = assignment.contains_gated_content date_block.complete = assignment.complete date_block.assignment_type = assignment.assignment_type date_block.past_due = assignment.past_due - date_block.link = assignment.url + date_block.link = request.build_absolute_uri(assignment.url) if assignment.url else '' date_block.set_title(assignment.title, link=assignment.url) date_blocks.append(date_block) date_blocks = sorted((b for b in date_blocks if b.is_enabled or include_past_dates), key=date_block_key_fn) @@ -518,7 +518,7 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None, @request_cached() -def get_course_assignments(course_key, user, request, include_access=False): +def get_course_assignments(course_key, user, include_access=False): """ Returns a list of assignment (at the subsection/sequential level) due dates for the given course. @@ -544,12 +544,11 @@ def get_course_assignments(course_key, user, request, include_access=False): assignment_type = block_data.get_xblock_field(subsection_key, 'format', None) - url = '' + url = None start = block_data.get_xblock_field(subsection_key, 'start') assignment_released = not start or start < now if assignment_released: url = reverse('jump_to', args=[course_key, subsection_key]) - url = request and request.build_absolute_uri(url) complete = block_data.get_xblock_field(subsection_key, 'complete', False) past_due = not complete and due < now diff --git a/openedx/features/calendar_sync/__init__.py b/openedx/features/calendar_sync/__init__.py index 77fd2dd0b2..47f76c832d 100644 --- a/openedx/features/calendar_sync/__init__.py +++ b/openedx/features/calendar_sync/__init__.py @@ -1,6 +1,7 @@ """ Calendar syncing Course dates with a User. """ +default_app_config = 'openedx.features.calendar_sync.apps.UserCalendarSyncConfig' def get_calendar_event_id(user, block_key, date_type, hostname): diff --git a/openedx/features/calendar_sync/admin.py b/openedx/features/calendar_sync/admin.py new file mode 100644 index 0000000000..02609df7da --- /dev/null +++ b/openedx/features/calendar_sync/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import UserCalendarSyncConfig + +admin.site.register(UserCalendarSyncConfig) diff --git a/openedx/features/calendar_sync/apps.py b/openedx/features/calendar_sync/apps.py new file mode 100644 index 0000000000..ac835080de --- /dev/null +++ b/openedx/features/calendar_sync/apps.py @@ -0,0 +1,18 @@ +""" +Define the calendar_sync Django App. +""" + +# -*- coding: utf-8 -*- + + +from django.apps import AppConfig + + +class UserCalendarSyncConfig(AppConfig): + name = 'openedx.features.calendar_sync' + + def ready(self): + super(UserCalendarSyncConfig, self).ready() + + # noinspection PyUnresolvedReferences + import openedx.features.calendar_sync.signals # pylint: disable=import-outside-toplevel,unused-import diff --git a/openedx/features/calendar_sync/ics.py b/openedx/features/calendar_sync/ics.py index 706b092768..fc465443e7 100644 --- a/openedx/features/calendar_sync/ics.py +++ b/openedx/features/calendar_sync/ics.py @@ -9,12 +9,13 @@ from icalendar import Calendar, Event, vCalAddress, vText from lms.djangoapps.courseware.courses import get_course_assignments from openedx.core.djangoapps.site_configuration.helpers import get_value +from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangolib.markup import HTML from . import get_calendar_event_id -def generate_ics_for_event(uid, title, course_name, now, start, organizer_name, organizer_email): +def generate_ics_for_event(uid, title, course_name, now, start, organizer_name, organizer_email, config): """ Generates an ics-formatted bytestring for the given assignment information. @@ -36,6 +37,7 @@ def generate_ics_for_event(uid, title, course_name, now, start, organizer_name, event.add('dtstart', start) event.add('duration', timedelta(0)) event.add('transp', 'TRANSPARENT') # available, rather than busy + event.add('sequence', config.ics_sequence) cal = Calendar() cal.add('prodid', '-//Open edX//calendar_sync//EN') @@ -46,28 +48,31 @@ def generate_ics_for_event(uid, title, course_name, now, start, organizer_name, return cal.to_ical() -def generate_ics_for_user_course(course, user, request): +def generate_ics_files_for_user_course(course, user, user_calendar_sync_config_instance): """ Generates ics-formatted bytestrings of all assignments for a given course and user. To pretty-print each bytestring, do: `ics.decode('utf8').replace('\r\n', '\n')` - Returns an iterable of ics files, each one representing an assignment. + Returns a dictionary of ics files, each one representing an assignment. """ - assignments = get_course_assignments(course.id, user, request) + assignments = get_course_assignments(course.id, user) platform_name = get_value('platform_name', settings.PLATFORM_NAME) platform_email = get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) now = datetime.now(pytz.utc) + site_config = SiteConfiguration.get_configuration_for_org(course.org) - return ( - generate_ics_for_event( + ics_files = {} + for assignment in assignments: + ics_files[assignment.title] = generate_ics_for_event( now=now, organizer_name=platform_name, organizer_email=platform_email, start=assignment.date, title=assignment.title, course_name=course.display_name_with_default, - uid=get_calendar_event_id(user, str(assignment.block_key), 'due', request.site.domain), + uid=get_calendar_event_id(user, str(assignment.block_key), 'due', site_config.site.domain), + config=user_calendar_sync_config_instance, ) - for assignment in assignments - ) + + return ics_files diff --git a/openedx/features/calendar_sync/migrations/0002_auto_20200709_1743.py b/openedx/features/calendar_sync/migrations/0002_auto_20200709_1743.py new file mode 100644 index 0000000000..5b78da35c7 --- /dev/null +++ b/openedx/features/calendar_sync/migrations/0002_auto_20200709_1743.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.14 on 2020-07-09 17:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calendar_sync', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='historicalusercalendarsyncconfig', + name='ics_sequence', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='usercalendarsyncconfig', + name='ics_sequence', + field=models.IntegerField(default=0), + ), + ] diff --git a/openedx/features/calendar_sync/models.py b/openedx/features/calendar_sync/models.py index 49608c6a42..b3c817575a 100644 --- a/openedx/features/calendar_sync/models.py +++ b/openedx/features/calendar_sync/models.py @@ -19,6 +19,7 @@ class UserCalendarSyncConfig(models.Model): user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) course_key = CourseKeyField(max_length=255, db_index=True) enabled = models.BooleanField(default=False) + ics_sequence = models.IntegerField(default=0) history = HistoricalRecords() diff --git a/openedx/features/calendar_sync/signals.py b/openedx/features/calendar_sync/signals.py new file mode 100644 index 0000000000..86915623f0 --- /dev/null +++ b/openedx/features/calendar_sync/signals.py @@ -0,0 +1,35 @@ +""" +Signal handler for calendar sync models +""" +from django.db.models.signals import post_save +from django.dispatch.dispatcher import receiver + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.features.course_experience import CALENDAR_SYNC_FLAG, RELATIVE_DATES_FLAG + +from .ics import generate_ics_files_for_user_course +from .models import UserCalendarSyncConfig +from .utils import send_email_with_attachment + + +@receiver(post_save, sender=UserCalendarSyncConfig) +def handle_calendar_sync_email(sender, instance, created, **kwargs): + if ( + CALENDAR_SYNC_FLAG.is_enabled(instance.course_key) and + RELATIVE_DATES_FLAG.is_enabled(instance.course_key) and + created + ): + user = instance.user + email = user.email + course_overview = CourseOverview.objects.get(id=instance.course_key) + ics_files = generate_ics_files_for_user_course(course_overview, user, instance) + send_email_with_attachment( + [email], + ics_files, + course_overview.display_name, + created + ) + post_save.disconnect(handle_calendar_sync_email, sender=UserCalendarSyncConfig) + instance.ics_sequence = instance.ics_sequence + 1 + instance.save() + post_save.connect(handle_calendar_sync_email, sender=UserCalendarSyncConfig) diff --git a/openedx/features/calendar_sync/tests/factories.py b/openedx/features/calendar_sync/tests/factories.py new file mode 100644 index 0000000000..4240219620 --- /dev/null +++ b/openedx/features/calendar_sync/tests/factories.py @@ -0,0 +1,12 @@ +from factory.django import DjangoModelFactory +from openedx.features.calendar_sync.models import UserCalendarSyncConfig + + +class UserCalendarSyncConfigFactory(DjangoModelFactory): + """ + Factory class for SiteConfiguration model + """ + class Meta(object): + model = UserCalendarSyncConfig + + enabled = True diff --git a/openedx/features/calendar_sync/tests/test_ics.py b/openedx/features/calendar_sync/tests/test_ics.py index bd0892ce36..8e053134d1 100644 --- a/openedx/features/calendar_sync/tests/test_ics.py +++ b/openedx/features/calendar_sync/tests/test_ics.py @@ -9,9 +9,10 @@ from mock import patch from lms.djangoapps.courseware.courses import _Assignment from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory +from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory from openedx.features.calendar_sync import get_calendar_event_id -from openedx.features.calendar_sync.ics import generate_ics_for_user_course +from openedx.features.calendar_sync.ics import generate_ics_files_for_user_course +from openedx.features.calendar_sync.tests.factories import UserCalendarSyncConfigFactory from student.tests.factories import UserFactory @@ -30,6 +31,13 @@ class TestIcsGeneration(TestCase): self.request = RequestFactory().request() self.request.site = SiteFactory() self.request.user = self.user + self.site_config = SiteConfigurationFactory.create( + site_values={'course_org_filter': self.course.org} + ) + self.user_calendar_sync_config = UserCalendarSyncConfigFactory.create( + user=self.user, + course_key=self.course.id, + ) def make_assigment( self, block_key=None, title=None, url=None, date=None, contains_gated_content=False, complete=False, @@ -50,6 +58,7 @@ DTSTART;VALUE=DATE-TIME:{timedue} DURATION:P0D DTSTAMP;VALUE=DATE-TIME:20131003T082455Z UID:{uid} +SEQUENCE:{sequence} DESCRIPTION:{summary} is due for {course}. ORGANIZER;CN=édX:mailto:registration@example.com TRANSP:TRANSPARENT @@ -61,7 +70,8 @@ END:VCALENDAR summary=assignment.title, course=self.course.display_name_with_default, timedue=assignment.date.strftime('%Y%m%dT%H%M%SZ'), - uid=get_calendar_event_id(self.user, str(assignment.block_key), 'due', self.request.site.domain), + uid=get_calendar_event_id(self.user, str(assignment.block_key), 'due', self.site_config.site.domain), + sequence=self.user_calendar_sync_config.ics_sequence ) for assignment in assignments ) @@ -70,11 +80,13 @@ END:VCALENDAR """ Uses generate_ics_for_user_course to create ics files for the given assignments """ with patch('openedx.features.calendar_sync.ics.get_course_assignments') as mock_get_assignments: mock_get_assignments.return_value = assignments - return generate_ics_for_user_course(self.course, self.user, self.request) + return generate_ics_files_for_user_course(self.course, self.user, self.user_calendar_sync_config) def assert_ics(self, *assignments): """ Asserts that the generated and expected ics for the given assignments are equal """ - generated = [ics.decode('utf8').replace('\r\n', '\n') for ics in self.generate_ics(*assignments)] + generated = [ + file.decode('utf8').replace('\r\n', '\n') for file in sorted(self.generate_ics(*assignments).values()) + ] self.assertEqual(len(generated), len(assignments)) self.assertListEqual(generated, list(self.expected_ics(*assignments))) diff --git a/openedx/features/calendar_sync/utils.py b/openedx/features/calendar_sync/utils.py index 7bade9b7fd..4ecbbf6285 100644 --- a/openedx/features/calendar_sync/utils.py +++ b/openedx/features/calendar_sync/utils.py @@ -6,43 +6,50 @@ from django.conf import settings from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ import os - import boto3 logger = logging.getLogger(__name__) def calendar_sync_initial_email_content(course_name): - subject = _('Stay on Track') - body_text = _('Sticking to a schedule is the best way to ensure that you successfully complete your self-paced ' - 'course. This schedule of assignment due dates for {course} will help you stay on track!' - ).format(course=course_name) - body = format_html('
{text}
', text=body_text) + subject = _('Sync {course} to your calendar').format(course=course_name) + body_paragraph_1 = _('Sticking to a schedule is the best way to ensure that you successfully complete your ' + 'self-paced course. This schedule for {course} will help you stay on track!' + ).format(course=course_name) + body_paragraph_2 = _('Once you sync your course schedule to your calendar, any updates to the course from your ' + 'instructor will be automatically reflected. You can remove the course from your calendar ' + 'at any time.') + body = format_html( + '
{bp1}
{bp2}
', + bp1=body_paragraph_1, + bp2=body_paragraph_2 + ) + return subject, body def calendar_sync_update_email_content(course_name): - subject = _('Updates for Your {course} Schedule').format(course=course_name) - body_text = _('Your assignment due dates for {course} were recently adjusted. Update your calendar with your new ' - 'schedule to ensure that you stay on track!').format(course=course_name) - body = format_html('
{text}
', text=body_text) + subject = _('{course} dates have been updated on your calendar').format(course=course_name) + body_paragraph = _('You have successfully shifted your course schedule and your calendar is up to date.' + ).format(course=course_name) + body = format_html('
{text}
', text=body_paragraph) + return subject, body -def prepare_attachments(attachment_data): +def prepare_attachments(attachment_data, file_ext=''): """ Helper function to create a list contain file attachment objects for use with MIMEMultipart Returns a list of MIMEApplication objects """ - attachments = [] for filename, data in attachment_data.items(): msg_attachment = MIMEApplication(data) msg_attachment.add_header( 'Content-Disposition', 'attachment', - filename=os.path.basename(filename) + filename=os.path.basename(filename) + file_ext ) msg_attachment.set_type('text/calendar') attachments.append(msg_attachment) @@ -50,17 +57,17 @@ def prepare_attachments(attachment_data): return attachments -def send_email_with_attachment(to_emails, attachment_data, course_name, is_update=False): +def send_email_with_attachment(to_emails, attachment_data, course_name, is_initial): # connect to SES client = boto3.client('ses', region_name=settings.AWS_SES_REGION_NAME) - subject, body = (calendar_sync_update_email_content(course_name) if is_update else - calendar_sync_initial_email_content(course_name)) + subject, body = (calendar_sync_initial_email_content(course_name) if is_initial else + calendar_sync_update_email_content(course_name)) # build email body as html msg_body = MIMEText(body, 'html') - attachments = prepare_attachments(attachment_data) + attachments = prepare_attachments(attachment_data, '.ics') # iterate over each email in the list to send emails independently for email in to_emails: From a22957d504f3aad9ab1b118144db5ed02c6fdb2e Mon Sep 17 00:00:00 2001 From: Christie Rice <8483753+crice100@users.noreply.github.com> Date: Tue, 14 Jul 2020 11:30:11 -0400 Subject: [PATCH 7/7] Revert "MICROBA-393 Add customized partner report headings (#24437)" (#24480) This reverts commit ef536e49deb0d28a0866dc760ba2c45a62b5c847. --- .../user_api/accounts/serializers.py | 2 - .../accounts/tests/test_retirement_views.py | 100 +++--------------- .../djangoapps/user_api/accounts/views.py | 92 +++------------- 3 files changed, 27 insertions(+), 167 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index 1110bfad9c..7f197cb8b6 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -475,12 +475,10 @@ class UserRetirementPartnerReportSerializer(serializers.Serializer): Perform serialization for the UserRetirementPartnerReportingStatus model """ user_id = serializers.IntegerField() - student_id = serializers.CharField(required=False) original_username = serializers.CharField() original_email = serializers.EmailField() original_name = serializers.CharField() orgs = serializers.ListField(child=serializers.CharField()) - orgs_config = serializers.ListField(required=False) created = serializers.DateTimeField() # Required overrides of abstract base class methods, but we don't use them diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py index ab44772a3b..b9e17df87c 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py @@ -46,7 +46,6 @@ from openedx.core.djangoapps.credit.models import ( CreditRequirement, CreditRequirementStatus ) -from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementPartnerReportView @@ -288,7 +287,7 @@ class TestPartnerReportingCleanup(ModuleStoreTestCase): def create_partner_reporting_statuses(self, is_being_processed=True, num=2): """ - Creates and returns the given number of test users and UserRetirementPartnerReportingStatuses + Creates and returnes the given number of test users and UserRetirementPartnerReportingStatuses with the given is_being_processed value. """ statuses = [] @@ -483,17 +482,6 @@ class TestPartnerReportingList(ModuleStoreTestCase): """ Tests the partner reporting list endpoint """ - EXPECTED_MB_ORGS_CONFIG = [ - { - AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching', - AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [ - AccountRetirementPartnerReportView.STUDENT_ID_KEY, - AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY, - AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY, - AccountRetirementPartnerReportView.DELETION_COMPLETED_KEY - ] - } - ] def setUp(self): super(TestPartnerReportingList, self).setUp() @@ -505,7 +493,6 @@ class TestPartnerReportingList(ModuleStoreTestCase): self.url = reverse('accounts_retirement_partner_report') self.maxDiff = None self.test_created_datetime = datetime.datetime(2018, 1, 1, tzinfo=pytz.UTC) - ExternalIdType.objects.get_or_create(name=ExternalIdType.MICROBACHELORS_COACHING) def get_user_dict(self, user, enrollments): """ @@ -530,10 +517,9 @@ class TestPartnerReportingList(ModuleStoreTestCase): their processing state to "is_being_processed". Returns a list of user dicts representing what we would expect back from the - endpoint for the given user / enrollment, and a list of the users themselves. + endpoint for the given user / enrollment. """ user_dicts = [] - users = [] courses = self.courses if courses is None else courses for _ in range(num): @@ -554,9 +540,8 @@ class TestPartnerReportingList(ModuleStoreTestCase): user_dicts.append( self.get_user_dict(user, enrollments) ) - users.append(user) - return user_dicts, users + return user_dicts def assert_status_and_user_list(self, expected_users, expected_status=status.HTTP_200_OK): """ @@ -576,19 +561,9 @@ class TestPartnerReportingList(ModuleStoreTestCase): # These sub-lists will fail assertCountEqual if they're out of order for expected_user in expected_users: expected_user['orgs'].sort() - if AccountRetirementPartnerReportView.ORGS_CONFIG_KEY in expected_user: - orgs_config = expected_user[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] - orgs_config.sort() - for config in orgs_config: - config[AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY].sort() for returned_user in returned_users: returned_user['orgs'].sort() - if AccountRetirementPartnerReportView.ORGS_CONFIG_KEY in returned_user: - orgs_config = returned_user[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] - orgs_config.sort() - for config in orgs_config: - config[AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY].sort() self.assertCountEqual(returned_users, expected_users) @@ -596,72 +571,21 @@ class TestPartnerReportingList(ModuleStoreTestCase): """ Basic test to make sure that users in two different orgs are returned. """ - user_dicts, users = self.create_partner_reporting_statuses() - additional_dicts, additional_users = self.create_partner_reporting_statuses(courses=(self.course_awesome_org,)) - user_dicts += additional_dicts + users = self.create_partner_reporting_statuses() + users += self.create_partner_reporting_statuses(courses=(self.course_awesome_org,)) - self.assert_status_and_user_list(user_dicts) + self.assert_status_and_user_list(users) def test_success_multiple_statuses(self): """ Checks that only users in the correct is_being_processed state (False) are returned. """ - user_dicts, users = self.create_partner_reporting_statuses() + users = self.create_partner_reporting_statuses() # These should not come back self.create_partner_reporting_statuses(courses=(self.course_awesome_org,), is_being_processed=True) - self.assert_status_and_user_list(user_dicts) - - def test_success_mb_coaching(self): - """ - Check that MicroBachelors users who have consented to coaching have the proper info - included for the partner report. - """ - path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching' - with mock.patch(path, return_value=True) as mock_has_ever_consented: - user_dicts, users = self.create_partner_reporting_statuses(num=1) - external_id, created = ExternalId.add_new_user_id( - user=users[0], - type_name=ExternalIdType.MICROBACHELORS_COACHING - ) - - expected_user = user_dicts[0] - expected_users = [expected_user] - expected_user[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = str(external_id.external_user_id) - expected_user[ - AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = TestPartnerReportingList.EXPECTED_MB_ORGS_CONFIG - - self.assert_status_and_user_list(expected_users) - mock_has_ever_consented.assert_called_once() - - def test_success_mb_coaching_no_external_id(self): - """ - Check that MicroBachelors users who have consented to coaching, but who do not have an external id, have the - proper info included for the partner report. - """ - path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching' - with mock.patch(path, return_value=True) as mock_has_ever_consented: - user_dicts, users = self.create_partner_reporting_statuses(num=1) - - self.assert_status_and_user_list(user_dicts) - mock_has_ever_consented.assert_called_once() - - def test_success_mb_coaching_no_consent(self): - """ - Check that MicroBachelors users who have not consented to coaching have the proper info - included for the partner report. - """ - path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching' - with mock.patch(path, return_value=False) as mock_has_ever_consented: - user_dicts, users = self.create_partner_reporting_statuses(num=1) - ExternalId.add_new_user_id( - user=users[0], - type_name=ExternalIdType.MICROBACHELORS_COACHING - ) - - self.assert_status_and_user_list(user_dicts) - mock_has_ever_consented.assert_called_once() + self.assert_status_and_user_list(users) def test_no_users(self): """ @@ -682,10 +606,10 @@ class TestPartnerReportingList(ModuleStoreTestCase): Checks that users are progressed to "is_being_processed" True upon being returned from this call. """ - user_dicts, users = self.create_partner_reporting_statuses() + users = self.create_partner_reporting_statuses() # First time through we should get the users - self.assert_status_and_user_list(user_dicts) + self.assert_status_and_user_list(users) # Second time they should be updated to is_being_processed=True self.assert_status_and_user_list([]) @@ -1181,7 +1105,7 @@ class TestAccountRetirementUpdate(RetirementTestCase): data = {'new_state': 'LOCKING_ACCOUNT', 'response': 'this should succeed'} self.update_and_assert_status(data) - # Refresh the retirement object and confirm the messages and state are correct + # Refresh the retirment object and confirm the messages and state are correct retirement = UserRetirementStatus.objects.get(id=self.retirement.id) self.assertEqual(retirement.current_state, RetirementState.objects.get(state_name='LOCKING_ACCOUNT')) self.assertEqual(retirement.last_state, RetirementState.objects.get(state_name='PENDING')) @@ -1203,7 +1127,7 @@ class TestAccountRetirementUpdate(RetirementTestCase): for update_data in fake_retire_process: self.update_and_assert_status(update_data) - # Refresh the retirement object and confirm the messages and state are correct + # Refresh the retirment object and confirm the messages and state are correct retirement = UserRetirementStatus.objects.get(id=self.retirement.id) self.assertEqual(retirement.current_state, RetirementState.objects.get(state_name='COMPLETE')) self.assertEqual(retirement.last_state, RetirementState.objects.get(state_name='CREDENTIALS_COMPLETE')) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 0719ebe217..c319af66d6 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -44,7 +44,6 @@ from openedx.core.djangoapps.ace_common.template_context import get_base_templat from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus -from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.profile_images.images import remove_profile_images from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image @@ -83,11 +82,6 @@ from .permissions import CanDeactivateUser, CanReplaceUsername, CanRetireUser from .serializers import UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS -try: - from coaching.api import has_ever_consented_to_coaching -except ImportError: - has_ever_consented_to_coaching = None - log = logging.getLogger(__name__) USER_PROFILE_PII = { @@ -536,14 +530,6 @@ class AccountRetirementPartnerReportView(ViewSet): Provides API endpoints for managing partner reporting of retired users. """ - DELETION_COMPLETED_KEY = 'deletion_completed' - ORGS_CONFIG_KEY = 'orgs_config' - ORGS_CONFIG_ORG_KEY = 'org' - ORGS_CONFIG_FIELD_HEADINGS_KEY = 'field_headings' - ORIGINAL_EMAIL_KEY = 'original_email' - ORIGINAL_NAME_KEY = 'original_name' - STUDENT_ID_KEY = 'student_id' - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanRetireUser,) parser_classes = (JSONParser,) @@ -558,7 +544,7 @@ class AccountRetirementPartnerReportView(ViewSet): for enrollment in user.courseenrollment_set.all(): org = enrollment.course_id.org - # Org can conceivably be blank or this bogus default value + # Org can concievably be blank or this bogus default value if org and org != 'outdated_entry': orgs.add(org) try: @@ -583,9 +569,17 @@ class AccountRetirementPartnerReportView(ViewSet): is_being_processed=False ).order_by('id') - retirements = [] - for retirement_status in retirement_statuses: - retirements.append(self._get_retirement_for_partner_report(retirement_status)) + retirements = [ + { + 'user_id': retirement.user.pk, + 'original_username': retirement.original_username, + 'original_email': retirement.original_email, + 'original_name': retirement.original_name, + 'orgs': self._get_orgs_for_user(retirement.user), + 'created': retirement.created, + } + for retirement in retirement_statuses + ] serializer = UserRetirementPartnerReportSerializer(retirements, many=True) @@ -593,62 +587,6 @@ class AccountRetirementPartnerReportView(ViewSet): return Response(serializer.data) - def _get_retirement_for_partner_report(self, retirement_status): - """ - Get the retirement for this retirement_status. The retirement info will be included in the partner report. - """ - retirement = { - 'user_id': retirement_status.user.pk, - 'original_username': retirement_status.original_username, - AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY: retirement_status.original_email, - AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY: retirement_status.original_name, - 'orgs': self._get_orgs_for_user(retirement_status.user), - 'created': retirement_status.created, - } - - # Some orgs have a custom list of headings and content for the partner report. Add this, if applicable. - self._add_orgs_config_for_user(retirement, retirement_status.user) - - return retirement - - def _add_orgs_config_for_user(self, retirement, user): - """ - Check to see if the user's info was sent to any partners (orgs) that have a a custom list of headings and - content for the partner report. If so, add this. - """ - # See if the MicroBachelors coaching provider needs to be notified of this user's retirement - if has_ever_consented_to_coaching is not None and has_ever_consented_to_coaching(user): - # See if the user has a MicroBachelors external id. If not, they were never sent to the - # coaching provider. - external_ids = ExternalId.objects.filter( - user=user, - external_id_type__name=ExternalIdType.MICROBACHELORS_COACHING - ) - if external_ids.exists(): - # User has an external id. Add the additional info. - external_id = str(external_ids[0].external_user_id) - self._add_coaching_orgs_config(retirement, external_id) - - def _add_coaching_orgs_config(self, retirement, external_id): - """ - Add the orgs configuration for MicroBachelors coaching - """ - # Add the custom field headings - retirement[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = [ - { - AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching', - AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [ - AccountRetirementPartnerReportView.STUDENT_ID_KEY, - AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY, - AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY, - AccountRetirementPartnerReportView.DELETION_COMPLETED_KEY - ] - } - ] - - # Add the custom field value - retirement[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = external_id - @request_requires_username def retirement_partner_status_create(self, request): """ @@ -769,7 +707,7 @@ class AccountRetirementStatusView(ViewSet): ) serializer = UserRetirementStatusSerializer(retirements, many=True) return Response(serializer.data) - # This should only occur on the int() conversion of cool_off_days at this point + # This should only occur on the int() converstion of cool_off_days at this point except ValueError: return Response('Invalid cool_off_days, should be integer.', status=status.HTTP_400_BAD_REQUEST) except KeyError as exc: @@ -1118,7 +1056,7 @@ class UsernameReplacementView(APIView): updates usernames across all services. DO NOT run this alone or users will not match across the system and things will be broken. - API will receive a list of current usernames and their requested new + API will recieve a list of current usernames and their requested new username. If their new username is taken, it will randomly assign a new username. This API will be called first, before calling the APIs in other services as this @@ -1228,7 +1166,7 @@ class UsernameReplacementView(APIView): """ Generates a unique username. If the desired username is available, that will be returned. - Otherwise it will generate unique suffixes to the desired username until it is an available username. + Otherwise it will generate unique suffixs to the desired username until it is an available username. """ new_username = desired_username # Keep checking usernames in case desired_username + random suffix is already taken