Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
cb85ef1f
Unverified
Commit
cb85ef1f
authored
Feb 18, 2017
by
Brandon DeRosier
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
ENT-162 Create an enterprise enrollment during the enrollment flow
parent
78f235a5
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
226 additions
and
17 deletions
+226
-17
common/djangoapps/enrollment/tests/test_views.py
+74
-1
common/djangoapps/enrollment/views.py
+34
-2
common/djangoapps/util/enterprise_helpers.py
+56
-12
common/djangoapps/util/tests/mixins/__init__.py
+0
-0
common/djangoapps/util/tests/mixins/enterprise.py
+57
-0
lms/envs/aws.py
+1
-0
lms/envs/common.py
+1
-0
lms/envs/devstack_docker.py
+1
-1
lms/envs/test.py
+1
-0
requirements/edx/base.txt
+1
-1
No files found.
common/djangoapps/enrollment/tests/test_views.py
View file @
cb85ef1f
...
@@ -20,11 +20,13 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
...
@@ -20,11 +20,13 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from
xmodule.modulestore.tests.factories
import
CourseFactory
,
check_mongo_calls_range
from
xmodule.modulestore.tests.factories
import
CourseFactory
,
check_mongo_calls_range
from
django.test.utils
import
override_settings
from
django.test.utils
import
override_settings
import
pytz
import
pytz
import
httpretty
from
course_modes.models
import
CourseMode
from
course_modes.models
import
CourseMode
from
enrollment.views
import
EnrollmentUserThrottle
from
enrollment.views
import
EnrollmentUserThrottle
from
util.models
import
RateLimitConfiguration
from
util.models
import
RateLimitConfiguration
from
util.testing
import
UrlResetMixin
from
util.testing
import
UrlResetMixin
from
util.tests.mixins.enterprise
import
EnterpriseServiceMockMixin
from
enrollment
import
api
from
enrollment
import
api
from
enrollment.errors
import
CourseEnrollmentError
from
enrollment.errors
import
CourseEnrollmentError
from
openedx.core.djangoapps.content.course_overviews.models
import
CourseOverview
from
openedx.core.djangoapps.content.course_overviews.models
import
CourseOverview
...
@@ -53,6 +55,7 @@ class EnrollmentTestMixin(object):
...
@@ -53,6 +55,7 @@ class EnrollmentTestMixin(object):
enrollment_attributes
=
None
,
enrollment_attributes
=
None
,
min_mongo_calls
=
0
,
min_mongo_calls
=
0
,
max_mongo_calls
=
0
,
max_mongo_calls
=
0
,
enterprise_course_consent
=
None
,
):
):
"""
"""
Enroll in the course and verify the response's status code. If the expected status is 200, also validates
Enroll in the course and verify the response's status code. If the expected status is 200, also validates
...
@@ -79,6 +82,9 @@ class EnrollmentTestMixin(object):
...
@@ -79,6 +82,9 @@ class EnrollmentTestMixin(object):
if
email_opt_in
is
not
None
:
if
email_opt_in
is
not
None
:
data
[
'email_opt_in'
]
=
email_opt_in
data
[
'email_opt_in'
]
=
email_opt_in
if
enterprise_course_consent
is
not
None
:
data
[
'enterprise_course_consent'
]
=
enterprise_course_consent
extra
=
{}
extra
=
{}
if
as_server
:
if
as_server
:
extra
[
'HTTP_X_EDX_API_KEY'
]
=
self
.
API_KEY
extra
[
'HTTP_X_EDX_API_KEY'
]
=
self
.
API_KEY
...
@@ -130,7 +136,7 @@ class EnrollmentTestMixin(object):
...
@@ -130,7 +136,7 @@ class EnrollmentTestMixin(object):
@override_settings
(
EDX_API_KEY
=
"i am a key"
)
@override_settings
(
EDX_API_KEY
=
"i am a key"
)
@ddt.ddt
@ddt.ddt
@unittest.skipUnless
(
settings
.
ROOT_URLCONF
==
'lms.urls'
,
'Test only valid in lms'
)
@unittest.skipUnless
(
settings
.
ROOT_URLCONF
==
'lms.urls'
,
'Test only valid in lms'
)
class
EnrollmentTest
(
EnrollmentTestMixin
,
ModuleStoreTestCase
,
APITestCase
):
class
EnrollmentTest
(
EnrollmentTestMixin
,
ModuleStoreTestCase
,
APITestCase
,
EnterpriseServiceMockMixin
):
"""
"""
Test user enrollment, especially with different course modes.
Test user enrollment, especially with different course modes.
"""
"""
...
@@ -924,6 +930,73 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
...
@@ -924,6 +930,73 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
self
.
assertTrue
(
is_active
)
self
.
assertTrue
(
is_active
)
self
.
assertEqual
(
course_mode
,
CourseMode
.
DEFAULT_MODE_SLUG
)
self
.
assertEqual
(
course_mode
,
CourseMode
.
DEFAULT_MODE_SLUG
)
def
test_enterprise_course_enrollment_invalid_consent
(
self
):
"""Verify that the enterprise_course_consent must be a boolean. """
CourseModeFactory
.
create
(
course_id
=
self
.
course
.
id
,
mode_slug
=
CourseMode
.
DEFAULT_MODE_SLUG
,
mode_display_name
=
CourseMode
.
DEFAULT_MODE_SLUG
,
)
self
.
assert_enrollment_status
(
expected_status
=
status
.
HTTP_400_BAD_REQUEST
,
enterprise_course_consent
=
'invalid'
,
as_server
=
True
,
)
@httpretty.activate
@override_settings
(
ENTERPRISE_SERVICE_WORKER_USERNAME
=
'enterprise_worker'
)
def
test_enterprise_course_enrollment_api_error
(
self
):
"""Verify that enterprise service errors are handled properly. """
UserFactory
.
create
(
username
=
'enterprise_worker'
,
email
=
self
.
EMAIL
,
password
=
self
.
PASSWORD
,
)
CourseModeFactory
.
create
(
course_id
=
self
.
course
.
id
,
mode_slug
=
CourseMode
.
DEFAULT_MODE_SLUG
,
mode_display_name
=
CourseMode
.
DEFAULT_MODE_SLUG
,
)
self
.
mock_enterprise_course_enrollment_post_api_failure
()
self
.
assert_enrollment_status
(
expected_status
=
status
.
HTTP_400_BAD_REQUEST
,
enterprise_course_consent
=
True
,
as_server
=
True
,
username
=
'enterprise_worker'
)
self
.
assertEqual
(
httpretty
.
last_request
()
.
path
,
'/enterprise/api/v1/enterprise-course-enrollment/'
,
'No request was made to the mocked enterprise-course-enrollment API'
)
@httpretty.activate
@override_settings
(
ENTERPRISE_SERVICE_WORKER_USERNAME
=
'enterprise_worker'
)
def
test_enterprise_course_enrollment_successful
(
self
):
"""Verify that the enrollment completes when the EnterpriseCourseEnrollment creation succeeds. """
UserFactory
.
create
(
username
=
'enterprise_worker'
,
email
=
self
.
EMAIL
,
password
=
self
.
PASSWORD
,
)
CourseModeFactory
.
create
(
course_id
=
self
.
course
.
id
,
mode_slug
=
CourseMode
.
DEFAULT_MODE_SLUG
,
mode_display_name
=
CourseMode
.
DEFAULT_MODE_SLUG
,
)
self
.
mock_enterprise_course_enrollment_post_api
(
username
=
self
.
user
.
username
,
course_id
=
unicode
(
self
.
course
.
id
))
self
.
assert_enrollment_status
(
expected_status
=
status
.
HTTP_200_OK
,
enterprise_course_consent
=
True
,
as_server
=
True
,
username
=
'enterprise_worker'
)
self
.
assertEqual
(
httpretty
.
last_request
()
.
path
,
'/enterprise/api/v1/enterprise-course-enrollment/'
,
'No request was made to the mocked enterprise-course-enrollment API'
)
@unittest.skipUnless
(
settings
.
ROOT_URLCONF
==
'lms.urls'
,
'Test only valid in lms'
)
@unittest.skipUnless
(
settings
.
ROOT_URLCONF
==
'lms.urls'
,
'Test only valid in lms'
)
class
EnrollmentEmbargoTest
(
EnrollmentTestMixin
,
UrlResetMixin
,
ModuleStoreTestCase
):
class
EnrollmentEmbargoTest
(
EnrollmentTestMixin
,
UrlResetMixin
,
ModuleStoreTestCase
):
...
...
common/djangoapps/enrollment/views.py
View file @
cb85ef1f
...
@@ -16,8 +16,6 @@ from rest_framework.throttling import UserRateThrottle
...
@@ -16,8 +16,6 @@ from rest_framework.throttling import UserRateThrottle
from
rest_framework.views
import
APIView
from
rest_framework.views
import
APIView
from
course_modes.models
import
CourseMode
from
course_modes.models
import
CourseMode
from
enrollment
import
api
from
enrollment.errors
import
CourseEnrollmentError
,
CourseModeNotFoundError
,
CourseEnrollmentExistsError
from
openedx.core.djangoapps.cors_csrf.authentication
import
SessionAuthenticationCrossDomainCsrf
from
openedx.core.djangoapps.cors_csrf.authentication
import
SessionAuthenticationCrossDomainCsrf
from
openedx.core.djangoapps.cors_csrf.decorators
import
ensure_csrf_cookie_cross_domain
from
openedx.core.djangoapps.cors_csrf.decorators
import
ensure_csrf_cookie_cross_domain
from
openedx.core.djangoapps.embargo
import
api
as
embargo_api
from
openedx.core.djangoapps.embargo
import
api
as
embargo_api
...
@@ -28,6 +26,13 @@ from openedx.core.lib.api.authentication import (
...
@@ -28,6 +26,13 @@ from openedx.core.lib.api.authentication import (
from
openedx.core.lib.api.permissions
import
ApiKeyHeaderPermission
,
ApiKeyHeaderPermissionIsAuthenticated
from
openedx.core.lib.api.permissions
import
ApiKeyHeaderPermission
,
ApiKeyHeaderPermissionIsAuthenticated
from
openedx.core.lib.exceptions
import
CourseNotFoundError
from
openedx.core.lib.exceptions
import
CourseNotFoundError
from
openedx.core.lib.log_utils
import
audit_log
from
openedx.core.lib.log_utils
import
audit_log
from
util.enterprise_helpers
import
enterprise_enabled
,
EnterpriseApiClient
,
EnterpriseApiException
from
enrollment
import
api
from
enrollment.errors
import
(
CourseEnrollmentError
,
CourseModeNotFoundError
,
CourseEnrollmentExistsError
)
from
student.auth
import
user_has_role
from
student.auth
import
user_has_role
from
student.models
import
User
from
student.models
import
User
from
student.roles
import
CourseStaffRole
,
GlobalStaff
from
student.roles
import
CourseStaffRole
,
GlobalStaff
...
@@ -362,6 +367,10 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
...
@@ -362,6 +367,10 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
* user: Optional. The user ID of the currently logged in user. You
* user: Optional. The user ID of the currently logged in user. You
cannot use the command to enroll a different user.
cannot use the command to enroll a different user.
* enterprise_course_consent: Optional. A Boolean value that
indicates the consent status for an EnterpriseCourseEnrollment
to be posted to the Enterprise service.
**GET Response Values**
**GET Response Values**
If an unspecified error occurs when the user tries to obtain a
If an unspecified error occurs when the user tries to obtain a
...
@@ -574,6 +583,29 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
...
@@ -574,6 +583,29 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
}
}
)
)
enterprise_course_consent
=
request
.
data
.
get
(
'enterprise_course_consent'
)
# Check if the enterprise_course_enrollment is a boolean
if
has_api_key_permissions
and
enterprise_enabled
()
and
enterprise_course_consent
is
not
None
:
if
not
isinstance
(
enterprise_course_consent
,
bool
):
return
Response
(
status
=
status
.
HTTP_400_BAD_REQUEST
,
data
=
{
'message'
:
(
u"'{value}' is an invalid enterprise course consent value."
)
.
format
(
value
=
enterprise_course_consent
)
}
)
try
:
EnterpriseApiClient
()
.
post_enterprise_course_enrollment
(
username
,
unicode
(
course_id
),
enterprise_course_consent
)
except
EnterpriseApiException
as
error
:
log
.
exception
(
"An unexpected error occurred while creating the new EnterpriseCourseEnrollment "
"for user [
%
s] in course run [
%
s]"
,
username
,
course_id
)
raise
CourseEnrollmentError
(
error
.
message
)
enrollment_attributes
=
request
.
data
.
get
(
'enrollment_attributes'
)
enrollment_attributes
=
request
.
data
.
get
(
'enrollment_attributes'
)
enrollment
=
api
.
get_enrollment
(
username
,
unicode
(
course_id
))
enrollment
=
api
.
get_enrollment
(
username
,
unicode
(
course_id
))
mode_changed
=
enrollment
and
mode
is
not
None
and
enrollment
[
'mode'
]
!=
mode
mode_changed
=
enrollment
and
mode
is
not
None
and
enrollment
[
'mode'
]
!=
mode
...
...
common/djangoapps/util/enterprise_helpers.py
View file @
cb85ef1f
"""
"""
Helpers to access the enterprise app
Helpers to access the enterprise app
"""
"""
from
django.conf
import
settings
from
django.core.urlresolvers
import
reverse
from
django.utils.translation
import
ugettext
as
_
import
logging
import
logging
from
django.conf
import
settings
from
django.contrib.auth.models
import
User
from
django.core.urlresolvers
import
reverse
from
django.utils.http
import
urlencode
from
django.utils.http
import
urlencode
from
edx_rest_api_client.client
import
EdxRestApiClient
try
:
try
:
from
enterprise.models
import
EnterpriseCustomer
from
enterprise
import
utils
as
enterprise_utils
from
enterprise
import
utils
as
enterprise_utils
from
enterprise.tpa_pipeline
import
(
active_provider_requests_data_sharing
,
active_provider_enforces_data_sharing
,
get_enterprise_customer_for_request
,
)
from
enterprise.utils
import
consent_necessary_for_course
from
enterprise.utils
import
consent_necessary_for_course
except
ImportError
:
except
ImportError
:
pass
pass
from
openedx.core.djangoapps.site_configuration
import
helpers
as
configuration_helpers
from
openedx.core.djangoapps.site_configuration
import
helpers
as
configuration_helpers
from
openedx.core.lib.token_utils
import
JwtBuilder
from
slumber.exceptions
import
HttpClientError
,
HttpServerError
ENTERPRISE_CUSTOMER_BRANDING_OVERRIDE_DETAILS
=
'enterprise_customer_branding_override_details'
ENTERPRISE_CUSTOMER_BRANDING_OVERRIDE_DETAILS
=
'enterprise_customer_branding_override_details'
LOGGER
=
logging
.
getLogger
(
"edx.enterprise_helpers"
)
LOGGER
=
logging
.
getLogger
(
"edx.enterprise_helpers"
)
class
EnterpriseApiException
(
Exception
):
"""
Exception for errors while communicating with the Enterprise service API.
"""
pass
class
EnterpriseApiClient
(
object
):
"""
Class for producing an Enterprise service API client.
"""
def
__init__
(
self
):
"""
Initialize an Enterprise service API client, authenticated using the Enterprise worker username.
"""
self
.
user
=
User
.
objects
.
get
(
username
=
settings
.
ENTERPRISE_SERVICE_WORKER_USERNAME
)
jwt
=
JwtBuilder
(
self
.
user
)
.
build_token
([])
self
.
client
=
EdxRestApiClient
(
configuration_helpers
.
get_value
(
'ENTERPRISE_API_URL'
,
settings
.
ENTERPRISE_API_URL
),
jwt
=
jwt
)
def
post_enterprise_course_enrollment
(
self
,
username
,
course_id
,
consent_granted
):
"""
Create an EnterpriseCourseEnrollment by using the corresponding serializer (for validation).
"""
data
=
{
'username'
:
username
,
'course_id'
:
course_id
,
'consent_granted'
:
consent_granted
,
}
endpoint
=
getattr
(
self
.
client
,
'enterprise-course-enrollment'
)
# pylint: disable=literal-used-as-attribute
try
:
endpoint
.
post
(
data
=
data
)
except
(
HttpClientError
,
HttpServerError
):
message
=
(
"An error occured while posting EnterpriseCourseEnrollment for user {username} and "
"course run {course_id} (consent_granted value: {consent_granted})"
)
.
format
(
username
=
username
,
course_id
=
course_id
,
consent_granted
=
consent_granted
,
)
LOGGER
.
exception
(
message
)
raise
EnterpriseApiException
(
message
)
def
enterprise_enabled
():
def
enterprise_enabled
():
"""
"""
Determines whether the Enterprise app is installed
Determines whether the Enterprise app is installed
...
...
common/djangoapps/util/tests/mixins/__init__.py
0 → 100644
View file @
cb85ef1f
common/djangoapps/util/tests/mixins/enterprise.py
0 → 100644
View file @
cb85ef1f
"""
Mixins for the EnterpriseApiClient.
"""
import
json
import
httpretty
from
django.conf
import
settings
from
django.core.cache
import
cache
class
EnterpriseServiceMockMixin
(
object
):
"""
Mocks for the Enterprise service responses.
"""
def
setUp
(
self
):
super
(
EnterpriseServiceMockMixin
,
self
)
.
setUp
()
cache
.
clear
()
@staticmethod
def
get_enterprise_url
(
path
):
"""Return a URL to the configured Enterprise API. """
return
'{}{}/'
.
format
(
settings
.
ENTERPRISE_API_URL
,
path
)
def
mock_enterprise_course_enrollment_post_api
(
# pylint: disable=invalid-name
self
,
username
=
'test_user'
,
course_id
=
'course-v1:edX+DemoX+Demo_Course'
,
consent_granted
=
True
):
"""
Helper method to register the enterprise course enrollment API POST endpoint.
"""
api_response
=
{
username
:
username
,
course_id
:
course_id
,
consent_granted
:
consent_granted
,
}
api_response_json
=
json
.
dumps
(
api_response
)
httpretty
.
register_uri
(
method
=
httpretty
.
POST
,
uri
=
self
.
get_enterprise_url
(
'enterprise-course-enrollment'
),
body
=
api_response_json
,
content_type
=
'application/json'
)
def
mock_enterprise_course_enrollment_post_api_failure
(
self
):
# pylint: disable=invalid-name
"""
Helper method to register the enterprise course enrollment API endpoint for a failure.
"""
httpretty
.
register_uri
(
method
=
httpretty
.
POST
,
uri
=
self
.
get_enterprise_url
(
'enterprise-course-enrollment'
),
body
=
'{}'
,
content_type
=
'application/json'
,
status
=
500
)
lms/envs/aws.py
View file @
cb85ef1f
...
@@ -176,6 +176,7 @@ EDXMKTG_LOGGED_IN_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_LOGGED_IN_COOKIE_NAME',
...
@@ -176,6 +176,7 @@ EDXMKTG_LOGGED_IN_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_LOGGED_IN_COOKIE_NAME',
EDXMKTG_USER_INFO_COOKIE_NAME
=
ENV_TOKENS
.
get
(
'EDXMKTG_USER_INFO_COOKIE_NAME'
,
EDXMKTG_USER_INFO_COOKIE_NAME
)
EDXMKTG_USER_INFO_COOKIE_NAME
=
ENV_TOKENS
.
get
(
'EDXMKTG_USER_INFO_COOKIE_NAME'
,
EDXMKTG_USER_INFO_COOKIE_NAME
)
LMS_ROOT_URL
=
ENV_TOKENS
.
get
(
'LMS_ROOT_URL'
)
LMS_ROOT_URL
=
ENV_TOKENS
.
get
(
'LMS_ROOT_URL'
)
ENTERPRISE_API_URL
=
ENV_TOKENS
.
get
(
'ENTERPRISE_API_URL'
,
LMS_ROOT_URL
+
'/enterprise/api/v1/'
)
ENV_FEATURES
=
ENV_TOKENS
.
get
(
'FEATURES'
,
{})
ENV_FEATURES
=
ENV_TOKENS
.
get
(
'FEATURES'
,
{})
for
feature
,
value
in
ENV_FEATURES
.
items
():
for
feature
,
value
in
ENV_FEATURES
.
items
():
...
...
lms/envs/common.py
View file @
cb85ef1f
...
@@ -61,6 +61,7 @@ DISCUSSION_SETTINGS = {
...
@@ -61,6 +61,7 @@ DISCUSSION_SETTINGS = {
}
}
LMS_ROOT_URL
=
"http://localhost:8000"
LMS_ROOT_URL
=
"http://localhost:8000"
ENTERPRISE_API_URL
=
LMS_ROOT_URL
+
'/enterprise/api/v1/'
# Features
# Features
FEATURES
=
{
FEATURES
=
{
...
...
lms/envs/devstack_docker.py
View file @
cb85ef1f
...
@@ -15,7 +15,7 @@ LMS_ROOT_URL = 'http://{}'.format(HOST)
...
@@ -15,7 +15,7 @@ LMS_ROOT_URL = 'http://{}'.format(HOST)
ECOMMERCE_PUBLIC_URL_ROOT
=
'http://localhost:18130'
ECOMMERCE_PUBLIC_URL_ROOT
=
'http://localhost:18130'
ECOMMERCE_API_URL
=
'http://edx.devstack.ecommerce:18130/api/v2'
ECOMMERCE_API_URL
=
'http://edx.devstack.ecommerce:18130/api/v2'
ENTERPRISE_API_URL
=
'http://enterprise.example.com/enterprise/api/v1/'
OAUTH_OIDC_ISSUER
=
'{}/oauth2'
.
format
(
LMS_ROOT_URL
)
OAUTH_OIDC_ISSUER
=
'{}/oauth2'
.
format
(
LMS_ROOT_URL
)
...
...
lms/envs/test.py
View file @
cb85ef1f
...
@@ -590,3 +590,4 @@ COMPREHENSIVE_THEME_LOCALE_PATHS = [REPO_ROOT / "themes/conf/locale", ]
...
@@ -590,3 +590,4 @@ COMPREHENSIVE_THEME_LOCALE_PATHS = [REPO_ROOT / "themes/conf/locale", ]
LMS_ROOT_URL
=
"http://localhost:8000"
LMS_ROOT_URL
=
"http://localhost:8000"
ECOMMERCE_API_URL
=
'https://ecommerce.example.com/api/v2/'
ECOMMERCE_API_URL
=
'https://ecommerce.example.com/api/v2/'
ENTERPRISE_API_URL
=
'http://enterprise.example.com/enterprise/api/v1/'
requirements/edx/base.txt
View file @
cb85ef1f
...
@@ -51,7 +51,7 @@ edx-lint==0.4.3
...
@@ -51,7 +51,7 @@ edx-lint==0.4.3
astroid==1.3.8
astroid==1.3.8
edx-django-oauth2-provider==1.1.4
edx-django-oauth2-provider==1.1.4
edx-django-sites-extensions==2.1.1
edx-django-sites-extensions==2.1.1
edx-enterprise==0.2
2
.0
edx-enterprise==0.2
3
.0
edx-oauth2-provider==1.2.0
edx-oauth2-provider==1.2.0
edx-opaque-keys==0.4.0
edx-opaque-keys==0.4.0
edx-organizations==0.4.3
edx-organizations==0.4.3
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment