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
a815003b
Commit
a815003b
authored
Jul 03, 2017
by
Andy Armstrong
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Handle anonymous and unenrolled users on the new course home page
LEARNER-1696
parent
65f876d7
Show whitespace changes
Inline
Side-by-side
Showing
41 changed files
with
552 additions
and
287 deletions
+552
-287
common/djangoapps/student/models.py
+16
-3
common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
+50
-3
lms/djangoapps/courseware/courses.py
+7
-7
lms/djangoapps/courseware/date_summary.py
+1
-1
lms/djangoapps/courseware/tabs.py
+10
-0
lms/djangoapps/courseware/tests/__init__.py
+0
-141
lms/djangoapps/courseware/tests/helpers.py
+129
-1
lms/djangoapps/courseware/tests/test_discussion_xblock.py
+1
-1
lms/djangoapps/courseware/tests/test_lti_integration.py
+1
-1
lms/djangoapps/courseware/tests/test_video_handlers.py
+1
-1
lms/djangoapps/courseware/tests/test_video_mongo.py
+1
-1
lms/djangoapps/courseware/tests/test_word_cloud.py
+1
-1
lms/djangoapps/courseware/views/index.py
+3
-5
lms/djangoapps/courseware/views/views.py
+6
-0
lms/djangoapps/discussion/tests/__init__.py
+0
-0
lms/djangoapps/discussion/tests/test_views.py
+8
-8
lms/djangoapps/django_comment_client/base/tests.py
+4
-4
lms/djangoapps/instructor/views/api.py
+1
-1
lms/static/sass/shared-v2/_header.scss
+14
-6
lms/templates/courseware/info.html
+6
-12
lms/templates/main.html
+3
-1
lms/templates/navigation/navbar-authenticated.html
+4
-4
lms/templates/navigation/navbar-not-authenticated.html
+7
-7
openedx/core/djangoapps/waffle_utils/__init__.py
+6
-4
openedx/features/course_bookmarks/plugins.py
+5
-1
openedx/features/course_bookmarks/tests/__init__.py
+0
-0
openedx/features/course_bookmarks/tests/test_course_bookmarks.py
+48
-0
openedx/features/course_experience/course_tools.py
+8
-0
openedx/features/course_experience/plugins.py
+10
-5
openedx/features/course_experience/templates/course_experience/course-dates-fragment.html
+4
-0
openedx/features/course_experience/templates/course_experience/course-home-fragment.html
+6
-6
openedx/features/course_experience/templates/course_experience/course-outline-fragment.html
+0
-1
openedx/features/course_experience/tests/views/helpers.py
+28
-0
openedx/features/course_experience/tests/views/test_course_home.py
+110
-8
openedx/features/course_experience/tests/views/test_course_outline.py
+0
-1
openedx/features/course_experience/tests/views/test_course_sock.py
+8
-26
openedx/features/course_experience/views/course_home.py
+37
-19
openedx/features/course_experience/views/course_sock.py
+1
-1
requirements/edx/base.txt
+1
-0
themes/edx.org/lms/templates/header.html
+2
-2
themes/red-theme/lms/templates/header.html
+4
-4
No files found.
common/djangoapps/student/models.py
View file @
a815003b
...
...
@@ -134,6 +134,8 @@ def anonymous_id_for_user(user, course_id, save=True):
save -- Whether the id should be saved in an AnonymousUserId object.
"""
# This part is for ability to get xblock instance in xblock_noauth handlers, where user is unauthenticated.
assert
user
if
user
.
is_anonymous
():
return
None
...
...
@@ -681,6 +683,8 @@ class PasswordHistory(models.Model):
Returns whether a password has 'expired' and should be reset. Note there are two different
expiry policies for staff and students
"""
assert
user
if
not
settings
.
FEATURES
[
'ADVANCED_SECURITY'
]:
return
False
...
...
@@ -736,6 +740,8 @@ class PasswordHistory(models.Model):
"""
Verifies that the password adheres to the reuse policies
"""
assert
user
if
not
settings
.
FEATURES
[
'ADVANCED_SECURITY'
]:
return
True
...
...
@@ -1082,6 +1088,10 @@ class CourseEnrollment(models.Model):
Returns:
Course enrollment object or None
"""
assert
user
if
user
.
is_anonymous
():
return
None
try
:
return
cls
.
objects
.
get
(
user
=
user
,
...
...
@@ -1397,9 +1407,6 @@ class CourseEnrollment(models.Model):
`course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
"""
if
not
user
.
is_authenticated
():
return
False
else
:
enrollment_state
=
cls
.
_get_enrollment_state
(
user
,
course_key
)
return
enrollment_state
.
is_active
or
False
...
...
@@ -1497,6 +1504,8 @@ class CourseEnrollment(models.Model):
Returns:
str: Hash of the user's active enrollments. If the user is anonymous, `None` is returned.
"""
assert
user
if
user
.
is_anonymous
():
return
None
...
...
@@ -1704,6 +1713,10 @@ class CourseEnrollment(models.Model):
Returns the CourseEnrollmentState for the given user
and course_key, caching the result for later retrieval.
"""
assert
user
if
user
.
is_anonymous
():
return
CourseEnrollmentState
(
None
,
None
)
enrollment_state
=
cls
.
_get_enrollment_in_request_cache
(
user
,
course_key
)
if
not
enrollment_state
:
try
:
...
...
common/lib/xmodule/xmodule/modulestore/tests/django_utils.py
View file @
a815003b
...
...
@@ -6,15 +6,19 @@ import copy
import
functools
import
os
from
contextlib
import
contextmanager
from
enum
import
Enum
from
courseware.field_overrides
import
OverrideFieldData
# pylint: disable=import-error
from
courseware.tests.factories
import
StaffFactory
from
django.conf
import
settings
from
django.contrib.auth.models
import
User
from
django.contrib.auth.models
import
AnonymousUser
,
User
from
django.test
import
TestCase
from
django.test.utils
import
override_settings
from
mock
import
patch
from
openedx.core.djangolib.testing.utils
import
CacheIsolationMixin
,
CacheIsolationTestCase
,
FilteredQueryCountMixin
from
openedx.core.lib.tempdir
import
mkdtemp_clean
from
student.models
import
CourseEnrollment
from
student.tests.factories
import
UserFactory
from
xmodule.contentstore.django
import
_CONTENTSTORE
from
xmodule.modulestore
import
ModuleStoreEnum
from
xmodule.modulestore.django
import
SignalHandler
,
clear_existing_modulestores
,
modulestore
...
...
@@ -22,6 +26,18 @@ from xmodule.modulestore.tests.factories import XMODULE_FACTORY_LOCK
from
xmodule.modulestore.tests.mongo_connection
import
MONGO_HOST
,
MONGO_PORT_NUM
class
CourseUserType
(
Enum
):
"""
Types of users to be used when testing a course.
"""
ANONYMOUS
=
'anonymous'
COURSE_STAFF
=
'course_staff'
ENROLLED
=
'enrolled'
GLOBAL_STAFF
=
'global_staff'
UNENROLLED
=
'unenrolled'
UNENROLLED_STAFF
=
'unenrolled_staff'
class
StoreConstructors
(
object
):
"""Enumeration of store constructor types."""
draft
,
split
=
range
(
2
)
...
...
@@ -308,7 +324,36 @@ class ModuleStoreIsolationMixin(CacheIsolationMixin, SignalIsolationMixin):
cls
.
enable_all_signals
()
class
SharedModuleStoreTestCase
(
FilteredQueryCountMixin
,
ModuleStoreIsolationMixin
,
CacheIsolationTestCase
):
class
ModuleStoreTestUsersMixin
():
"""
A mixin to help manage test users.
"""
TEST_PASSWORD
=
'test'
def
create_user_for_course
(
self
,
course
,
user_type
=
CourseUserType
.
ENROLLED
):
"""
Create a test user for a course.
"""
if
user_type
is
CourseUserType
.
ANONYMOUS
:
return
AnonymousUser
()
is_enrolled
=
user_type
is
CourseUserType
.
ENROLLED
is_unenrolled_staff
=
user_type
is
CourseUserType
.
UNENROLLED_STAFF
# Set up the test user
if
is_unenrolled_staff
:
user
=
StaffFactory
(
course_key
=
course
.
id
,
password
=
self
.
TEST_PASSWORD
)
else
:
user
=
UserFactory
(
password
=
self
.
TEST_PASSWORD
)
self
.
client
.
login
(
username
=
user
.
username
,
password
=
self
.
TEST_PASSWORD
)
if
is_enrolled
:
CourseEnrollment
.
enroll
(
user
,
course
.
id
)
return
user
class
SharedModuleStoreTestCase
(
ModuleStoreTestUsersMixin
,
FilteredQueryCountMixin
,
ModuleStoreIsolationMixin
,
CacheIsolationTestCase
):
"""
Subclass for any test case that uses a ModuleStore that can be shared
between individual tests. This class ensures that the ModuleStore is cleaned
...
...
@@ -391,7 +436,9 @@ class SharedModuleStoreTestCase(FilteredQueryCountMixin, ModuleStoreIsolationMix
super
(
SharedModuleStoreTestCase
,
self
)
.
setUp
()
class
ModuleStoreTestCase
(
FilteredQueryCountMixin
,
ModuleStoreIsolationMixin
,
TestCase
):
class
ModuleStoreTestCase
(
ModuleStoreTestUsersMixin
,
FilteredQueryCountMixin
,
ModuleStoreIsolationMixin
,
TestCase
):
"""
Subclass for any test case that uses a ModuleStore.
Ensures that the ModuleStore is cleaned before/after each test.
...
...
lms/djangoapps/courseware/courses.py
View file @
a815003b
...
...
@@ -113,21 +113,21 @@ def check_course_access(course, user, action, check_if_enrolled=False):
Check that the user has the access to perform the specified action
on the course (CourseDescriptor|CourseOverview).
check_if_enrolled: If true, additionally verifies that the user is either
enrolled in the course or has staff access.
check_if_enrolled: If true, additionally verifies that the user is enrolled.
"""
access_response
=
has_access
(
user
,
action
,
course
,
course
.
id
)
# Allow staff full access to the course even if not enrolled
if
has_access
(
user
,
'staff'
,
course
.
id
):
return
access_response
=
has_access
(
user
,
action
,
course
,
course
.
id
)
if
not
access_response
:
# Deliberately return a non-specific error message to avoid
# leaking info about access control settings
raise
CoursewareAccessException
(
access_response
)
if
check_if_enrolled
:
# Verify that the user is either enrolled in the course or a staff
# member. If the user is not enrolled, raise a Redirect exception
# that will be handled by middleware.
if
not
((
user
.
id
and
CourseEnrollment
.
is_enrolled
(
user
,
course
.
id
))
or
has_access
(
user
,
'staff'
,
course
)):
# If the user is not enrolled, redirect them to the about page
if
not
CourseEnrollment
.
is_enrolled
(
user
,
course
.
id
):
raise
CourseAccessRedirect
(
reverse
(
'about_course'
,
args
=
[
unicode
(
course
.
id
)]))
...
...
lms/djangoapps/courseware/date_summary.py
View file @
a815003b
...
...
@@ -115,7 +115,7 @@ class DateSummary(object):
future.
"""
if
self
.
date
is
not
None
:
return
datetime
.
now
(
utc
)
<=
self
.
date
return
datetime
.
now
(
utc
)
.
date
()
<=
self
.
date
.
date
()
return
False
def
deadline_has_passed
(
self
):
...
...
lms/djangoapps/courseware/tabs.py
View file @
a815003b
...
...
@@ -36,6 +36,16 @@ class CoursewareTab(EnrolledTab):
is_default
=
False
supports_preview_menu
=
True
@classmethod
def
is_enabled
(
cls
,
course
,
user
=
None
):
"""
Returns true if this tab is enabled.
"""
# If this is the unified course tab then it is always enabled
if
UNIFIED_COURSE_TAB_FLAG
.
is_enabled
(
course
.
id
):
return
True
return
super
(
CoursewareTab
,
cls
)
.
is_enabled
(
course
,
user
)
@property
def
link_func
(
self
):
"""
...
...
lms/djangoapps/courseware/tests/__init__.py
View file @
a815003b
"""
integration tests for xmodule
Contains:
1. BaseTestXmodule class provides course and users
for testing Xmodules with mongo store.
"""
from
django.core.urlresolvers
import
reverse
from
django.test.client
import
Client
from
edxmako.shortcuts
import
render_to_string
from
lms.djangoapps.lms_xblock.field_data
import
LmsFieldData
from
openedx.core.lib.url_utils
import
quote_slashes
from
student.tests.factories
import
UserFactory
,
CourseEnrollmentFactory
from
xblock.field_data
import
DictFieldData
from
xmodule.modulestore.tests.django_utils
import
TEST_DATA_MONGO_MODULESTORE
from
xmodule.tests
import
get_test_system
,
get_test_descriptor_system
from
xmodule.modulestore.django
import
modulestore
from
xmodule.modulestore.tests.factories
import
CourseFactory
,
ItemFactory
from
xmodule.modulestore.tests.django_utils
import
ModuleStoreTestCase
class
BaseTestXmodule
(
ModuleStoreTestCase
):
"""Base class for testing Xmodules with mongo store.
This class prepares course and users for tests:
1. create test course;
2. create, enroll and login users for this course;
Any xmodule should overwrite only next parameters for test:
1. CATEGORY
2. DATA or METADATA
3. MODEL_DATA
4. COURSE_DATA and USER_COUNT if needed
This class should not contain any tests, because CATEGORY
should be defined in child class.
"""
MODULESTORE
=
TEST_DATA_MONGO_MODULESTORE
USER_COUNT
=
2
COURSE_DATA
=
{}
# Data from YAML common/lib/xmodule/xmodule/templates/NAME/default.yaml
CATEGORY
=
"vertical"
DATA
=
''
# METADATA must be overwritten for every instance that uses it. Otherwise,
# if we'll change it in the tests, it will be changed for all other instances
# of parent class.
METADATA
=
{}
MODEL_DATA
=
{
'data'
:
'<some_module></some_module>'
}
def
new_module_runtime
(
self
):
"""
Generate a new ModuleSystem that is minimally set up for testing
"""
return
get_test_system
(
course_id
=
self
.
course
.
id
)
def
new_descriptor_runtime
(
self
):
runtime
=
get_test_descriptor_system
()
runtime
.
get_block
=
modulestore
()
.
get_item
return
runtime
def
initialize_module
(
self
,
**
kwargs
):
kwargs
.
update
({
'parent_location'
:
self
.
section
.
location
,
'category'
:
self
.
CATEGORY
})
self
.
item_descriptor
=
ItemFactory
.
create
(
**
kwargs
)
self
.
runtime
=
self
.
new_descriptor_runtime
()
field_data
=
{}
field_data
.
update
(
self
.
MODEL_DATA
)
student_data
=
DictFieldData
(
field_data
)
self
.
item_descriptor
.
_field_data
=
LmsFieldData
(
self
.
item_descriptor
.
_field_data
,
student_data
)
self
.
item_descriptor
.
xmodule_runtime
=
self
.
new_module_runtime
()
self
.
item_url
=
unicode
(
self
.
item_descriptor
.
location
)
def
setup_course
(
self
):
self
.
course
=
CourseFactory
.
create
(
data
=
self
.
COURSE_DATA
)
# Turn off cache.
modulestore
()
.
request_cache
=
None
modulestore
()
.
metadata_inheritance_cache_subsystem
=
None
chapter
=
ItemFactory
.
create
(
parent_location
=
self
.
course
.
location
,
category
=
"sequential"
,
)
self
.
section
=
ItemFactory
.
create
(
parent_location
=
chapter
.
location
,
category
=
"sequential"
)
# username = robot{0}, password = 'test'
self
.
users
=
[
UserFactory
.
create
()
for
dummy0
in
range
(
self
.
USER_COUNT
)
]
for
user
in
self
.
users
:
CourseEnrollmentFactory
.
create
(
user
=
user
,
course_id
=
self
.
course
.
id
)
# login all users for acces to Xmodule
self
.
clients
=
{
user
.
username
:
Client
()
for
user
in
self
.
users
}
self
.
login_statuses
=
[
self
.
clients
[
user
.
username
]
.
login
(
username
=
user
.
username
,
password
=
'test'
)
for
user
in
self
.
users
]
self
.
assertTrue
(
all
(
self
.
login_statuses
))
def
setUp
(
self
):
super
(
BaseTestXmodule
,
self
)
.
setUp
()
self
.
setup_course
()
self
.
initialize_module
(
metadata
=
self
.
METADATA
,
data
=
self
.
DATA
)
def
get_url
(
self
,
dispatch
):
"""Return item url with dispatch."""
return
reverse
(
'xblock_handler'
,
args
=
(
unicode
(
self
.
course
.
id
),
quote_slashes
(
self
.
item_url
),
'xmodule_handler'
,
dispatch
)
)
class
XModuleRenderingTestBase
(
BaseTestXmodule
):
def
new_module_runtime
(
self
):
"""
Create a runtime that actually does html rendering
"""
runtime
=
super
(
XModuleRenderingTestBase
,
self
)
.
new_module_runtime
()
runtime
.
render_template
=
render_to_string
return
runtime
lms/djangoapps/courseware/tests/helpers.py
View file @
a815003b
...
...
@@ -7,12 +7,140 @@ from django.contrib import messages
from
django.contrib.auth.models
import
User
from
django.core.urlresolvers
import
reverse
from
django.test
import
TestCase
from
django.test.client
import
RequestFactory
from
django.test.client
import
Client
,
RequestFactory
from
courseware.access
import
has_access
from
courseware.masquerade
import
handle_ajax
,
setup_masquerade
from
edxmako.shortcuts
import
render_to_string
from
lms.djangoapps.lms_xblock.field_data
import
LmsFieldData
from
openedx.core.djangoapps.content.course_overviews.models
import
CourseOverview
from
openedx.core.lib.url_utils
import
quote_slashes
from
student.models
import
Registration
from
student.tests.factories
import
CourseEnrollmentFactory
,
UserFactory
from
xblock.field_data
import
DictFieldData
from
xmodule.modulestore.django
import
modulestore
from
xmodule.modulestore.tests.django_utils
import
TEST_DATA_MONGO_MODULESTORE
,
ModuleStoreTestCase
from
xmodule.modulestore.tests.factories
import
CourseFactory
,
ItemFactory
from
xmodule.tests
import
get_test_descriptor_system
,
get_test_system
class
BaseTestXmodule
(
ModuleStoreTestCase
):
"""Base class for testing Xmodules with mongo store.
This class prepares course and users for tests:
1. create test course;
2. create, enroll and login users for this course;
Any xmodule should overwrite only next parameters for test:
1. CATEGORY
2. DATA or METADATA
3. MODEL_DATA
4. COURSE_DATA and USER_COUNT if needed
This class should not contain any tests, because CATEGORY
should be defined in child class.
"""
MODULESTORE
=
TEST_DATA_MONGO_MODULESTORE
USER_COUNT
=
2
COURSE_DATA
=
{}
# Data from YAML common/lib/xmodule/xmodule/templates/NAME/default.yaml
CATEGORY
=
"vertical"
DATA
=
''
# METADATA must be overwritten for every instance that uses it. Otherwise,
# if we'll change it in the tests, it will be changed for all other instances
# of parent class.
METADATA
=
{}
MODEL_DATA
=
{
'data'
:
'<some_module></some_module>'
}
def
new_module_runtime
(
self
):
"""
Generate a new ModuleSystem that is minimally set up for testing
"""
return
get_test_system
(
course_id
=
self
.
course
.
id
)
def
new_descriptor_runtime
(
self
):
runtime
=
get_test_descriptor_system
()
runtime
.
get_block
=
modulestore
()
.
get_item
return
runtime
def
initialize_module
(
self
,
**
kwargs
):
kwargs
.
update
({
'parent_location'
:
self
.
section
.
location
,
'category'
:
self
.
CATEGORY
})
self
.
item_descriptor
=
ItemFactory
.
create
(
**
kwargs
)
self
.
runtime
=
self
.
new_descriptor_runtime
()
field_data
=
{}
field_data
.
update
(
self
.
MODEL_DATA
)
student_data
=
DictFieldData
(
field_data
)
self
.
item_descriptor
.
_field_data
=
LmsFieldData
(
self
.
item_descriptor
.
_field_data
,
student_data
)
self
.
item_descriptor
.
xmodule_runtime
=
self
.
new_module_runtime
()
self
.
item_url
=
unicode
(
self
.
item_descriptor
.
location
)
def
setup_course
(
self
):
self
.
course
=
CourseFactory
.
create
(
data
=
self
.
COURSE_DATA
)
# Turn off cache.
modulestore
()
.
request_cache
=
None
modulestore
()
.
metadata_inheritance_cache_subsystem
=
None
chapter
=
ItemFactory
.
create
(
parent_location
=
self
.
course
.
location
,
category
=
"sequential"
,
)
self
.
section
=
ItemFactory
.
create
(
parent_location
=
chapter
.
location
,
category
=
"sequential"
)
# username = robot{0}, password = 'test'
self
.
users
=
[
UserFactory
.
create
()
for
dummy0
in
range
(
self
.
USER_COUNT
)
]
for
user
in
self
.
users
:
CourseEnrollmentFactory
.
create
(
user
=
user
,
course_id
=
self
.
course
.
id
)
# login all users for acces to Xmodule
self
.
clients
=
{
user
.
username
:
Client
()
for
user
in
self
.
users
}
self
.
login_statuses
=
[
self
.
clients
[
user
.
username
]
.
login
(
username
=
user
.
username
,
password
=
'test'
)
for
user
in
self
.
users
]
self
.
assertTrue
(
all
(
self
.
login_statuses
))
def
setUp
(
self
):
super
(
BaseTestXmodule
,
self
)
.
setUp
()
self
.
setup_course
()
self
.
initialize_module
(
metadata
=
self
.
METADATA
,
data
=
self
.
DATA
)
def
get_url
(
self
,
dispatch
):
"""Return item url with dispatch."""
return
reverse
(
'xblock_handler'
,
args
=
(
unicode
(
self
.
course
.
id
),
quote_slashes
(
self
.
item_url
),
'xmodule_handler'
,
dispatch
)
)
class
XModuleRenderingTestBase
(
BaseTestXmodule
):
def
new_module_runtime
(
self
):
"""
Create a runtime that actually does html rendering
"""
runtime
=
super
(
XModuleRenderingTestBase
,
self
)
.
new_module_runtime
()
runtime
.
render_template
=
render_to_string
return
runtime
class
LoginEnrollmentTestCase
(
TestCase
):
...
...
lms/djangoapps/courseware/tests/test_discussion_xblock.py
View file @
a815003b
...
...
@@ -17,7 +17,7 @@ from xblock.fragment import Fragment
from
course_api.blocks.tests.helpers
import
deserialize_usage_key
from
courseware.module_render
import
get_module_for_descriptor_internal
from
lms.djangoapps.courseware.tests
import
XModuleRenderingTestBase
from
lms.djangoapps.courseware.tests
.helpers
import
XModuleRenderingTestBase
from
student.tests.factories
import
CourseEnrollmentFactory
,
UserFactory
from
xblock_discussion
import
DiscussionXBlock
,
loader
from
xmodule.modulestore
import
ModuleStoreEnum
...
...
lms/djangoapps/courseware/tests/test_lti_integration.py
View file @
a815003b
...
...
@@ -10,7 +10,7 @@ from django.conf import settings
from
django.core.urlresolvers
import
reverse
from
nose.plugins.attrib
import
attr
from
courseware.tests
import
BaseTestXmodule
from
courseware.tests
.helpers
import
BaseTestXmodule
from
courseware.views.views
import
get_course_lti_endpoints
from
openedx.core.lib.url_utils
import
quote_slashes
from
xmodule.modulestore.tests.django_utils
import
SharedModuleStoreTestCase
...
...
lms/djangoapps/courseware/tests/test_video_handlers.py
View file @
a815003b
...
...
@@ -22,7 +22,7 @@ from xmodule.modulestore.django import modulestore
from
xmodule.video_module.transcripts_utils
import
TranscriptException
,
TranscriptsGenerationException
from
xmodule.x_module
import
STUDENT_VIEW
from
.
import
BaseTestXmodule
from
.
helpers
import
BaseTestXmodule
from
.test_video_xml
import
SOURCE_XML
TRANSCRIPT
=
{
"start"
:
[
10
],
"end"
:
[
100
],
"text"
:
[
"Hi, welcome to Edx."
]}
...
...
lms/djangoapps/courseware/tests/test_video_mongo.py
View file @
a815003b
...
...
@@ -25,7 +25,7 @@ from xmodule.video_module import VideoDescriptor, bumper_utils, rewrite_video_ur
from
xmodule.video_module.transcripts_utils
import
Transcript
,
save_to_store
from
xmodule.x_module
import
STUDENT_VIEW
from
.
import
BaseTestXmodule
from
.
helpers
import
BaseTestXmodule
from
.test_video_handlers
import
TestVideo
from
.test_video_xml
import
SOURCE_XML
...
...
lms/djangoapps/courseware/tests/test_word_cloud.py
View file @
a815003b
...
...
@@ -8,7 +8,7 @@ from nose.plugins.attrib import attr
from
xmodule.x_module
import
STUDENT_VIEW
from
.
import
BaseTestXmodule
from
.
helpers
import
BaseTestXmodule
@attr
(
shard
=
1
)
...
...
lms/djangoapps/courseware/views/index.py
View file @
a815003b
"""
View for Courseware Index
"""
# pylint: disable=attribute-defined-outside-init
import
logging
import
urllib
# pylint: disable=attribute-defined-outside-init
from
datetime
import
datetime
import
waffle
from
django.conf
import
settings
from
django.contrib.auth.decorators
import
login_required
from
django.contrib.auth.models
import
User
from
django.core.context_processors
import
csrf
from
django.core.urlresolvers
import
reverse
from
django.http
import
Http404
from
django.shortcuts
import
redirect
from
django.utils.decorators
import
method_decorator
from
django.utils.timezone
import
UTC
from
django.views.decorators.cache
import
cache_control
from
django.views.decorators.csrf
import
ensure_csrf_cookie
from
django.views.generic
import
View
...
...
lms/djangoapps/courseware/views/views.py
View file @
a815003b
...
...
@@ -87,6 +87,7 @@ from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender
from
openedx.core.djangoapps.self_paced.models
import
SelfPacedConfiguration
from
openedx.core.djangoapps.site_configuration
import
helpers
as
configuration_helpers
from
openedx.features.course_experience
import
UNIFIED_COURSE_TAB_FLAG
,
course_home_url_name
from
openedx.features.course_experience.course_tools
import
CourseToolsPluginManager
from
openedx.features.course_experience.views.course_dates
import
CourseDatesFragmentView
from
openedx.features.enterprise_support.api
import
data_sharing_consent_required
from
shoppingcart.utils
import
is_shopping_cart_enabled
...
...
@@ -327,6 +328,9 @@ def course_info(request, course_id):
# Decide whether or not to show the reviews link in the course tools bar
show_reviews_link
=
CourseReviewsModuleFragmentView
.
is_configured
()
# Get the course tools enabled for this user and course
course_tools
=
CourseToolsPluginManager
.
get_enabled_course_tools
(
request
,
course_key
)
context
=
{
'request'
:
request
,
'masquerade_user'
:
user
,
...
...
@@ -342,6 +346,8 @@ def course_info(request, course_id):
'dates_fragment'
:
dates_fragment
,
'url_to_enroll'
:
url_to_enroll
,
'show_reviews_link'
:
show_reviews_link
,
'course_tools'
:
course_tools
,
# TODO: (Experimental Code). See https://openedx.atlassian.net/wiki/display/RET/2.+In-course+Verification+Prompts
'upgrade_link'
:
check_and_get_upgrade_link
(
request
,
user
,
course
.
id
),
'upgrade_price'
:
get_cosmetic_verified_display_price
(
course
),
...
...
lms/djangoapps/discussion/tests/__init__.py
0 → 100644
View file @
a815003b
lms/djangoapps/discussion/tests/test_views.py
View file @
a815003b
...
...
@@ -370,18 +370,18 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase):
# course is outside the context manager that is verifying the number of queries,
# and with split mongo, that method ends up querying disabled_xblocks (which is then
# cached and hence not queried as part of call_single_thread).
(
ModuleStoreEnum
.
Type
.
mongo
,
False
,
1
,
5
,
3
,
1
3
,
1
),
(
ModuleStoreEnum
.
Type
.
mongo
,
False
,
50
,
5
,
3
,
1
3
,
1
),
(
ModuleStoreEnum
.
Type
.
mongo
,
False
,
1
,
5
,
3
,
1
4
,
1
),
(
ModuleStoreEnum
.
Type
.
mongo
,
False
,
50
,
5
,
3
,
1
4
,
1
),
# split mongo: 3 queries, regardless of thread response size.
(
ModuleStoreEnum
.
Type
.
split
,
False
,
1
,
3
,
3
,
1
2
,
1
),
(
ModuleStoreEnum
.
Type
.
split
,
False
,
50
,
3
,
3
,
1
2
,
1
),
(
ModuleStoreEnum
.
Type
.
split
,
False
,
1
,
3
,
3
,
1
3
,
1
),
(
ModuleStoreEnum
.
Type
.
split
,
False
,
50
,
3
,
3
,
1
3
,
1
),
# Enabling Enterprise integration should have no effect on the number of mongo queries made.
(
ModuleStoreEnum
.
Type
.
mongo
,
True
,
1
,
5
,
3
,
1
3
,
1
),
(
ModuleStoreEnum
.
Type
.
mongo
,
True
,
50
,
5
,
3
,
1
3
,
1
),
(
ModuleStoreEnum
.
Type
.
mongo
,
True
,
1
,
5
,
3
,
1
4
,
1
),
(
ModuleStoreEnum
.
Type
.
mongo
,
True
,
50
,
5
,
3
,
1
4
,
1
),
# split mongo: 3 queries, regardless of thread response size.
(
ModuleStoreEnum
.
Type
.
split
,
True
,
1
,
3
,
3
,
1
2
,
1
),
(
ModuleStoreEnum
.
Type
.
split
,
True
,
50
,
3
,
3
,
1
2
,
1
),
(
ModuleStoreEnum
.
Type
.
split
,
True
,
1
,
3
,
3
,
1
3
,
1
),
(
ModuleStoreEnum
.
Type
.
split
,
True
,
50
,
3
,
3
,
1
3
,
1
),
)
@ddt.unpack
def
test_number_of_mongo_queries
(
...
...
lms/djangoapps/django_comment_client/base/tests.py
View file @
a815003b
...
...
@@ -383,8 +383,8 @@ class ViewsQueryCountTestCase(
return
inner
@ddt.data
(
(
ModuleStoreEnum
.
Type
.
mongo
,
3
,
4
,
3
1
),
(
ModuleStoreEnum
.
Type
.
split
,
3
,
12
,
3
1
),
(
ModuleStoreEnum
.
Type
.
mongo
,
3
,
4
,
3
2
),
(
ModuleStoreEnum
.
Type
.
split
,
3
,
12
,
3
2
),
)
@ddt.unpack
@count_queries
...
...
@@ -392,8 +392,8 @@ class ViewsQueryCountTestCase(
self
.
create_thread_helper
(
mock_request
)
@ddt.data
(
(
ModuleStoreEnum
.
Type
.
mongo
,
3
,
3
,
2
7
),
(
ModuleStoreEnum
.
Type
.
split
,
3
,
9
,
2
7
),
(
ModuleStoreEnum
.
Type
.
mongo
,
3
,
3
,
2
8
),
(
ModuleStoreEnum
.
Type
.
split
,
3
,
9
,
2
8
),
)
@ddt.unpack
@count_queries
...
...
lms/djangoapps/instructor/views/api.py
View file @
a815003b
...
...
@@ -681,7 +681,7 @@ def students_update_enrollment(request, course_id):
)
before_enrollment
=
before
.
to_dict
()[
'enrollment'
]
before_allowed
=
before
.
to_dict
()[
'allowed'
]
enrollment_obj
=
CourseEnrollment
.
get_enrollment
(
user
,
course_id
)
enrollment_obj
=
CourseEnrollment
.
get_enrollment
(
user
,
course_id
)
if
user
else
None
if
before_enrollment
:
state_transition
=
ENROLLED_TO_UNENROLLED
...
...
lms/static/sass/shared-v2/_header.scss
View file @
a815003b
...
...
@@ -63,17 +63,25 @@
.list-inline
{
&
.nav-global
{
margin-top
:
12px
;
margin-bottom
:
0
;
}
@include
margin
(
0
,
0
,
0
,
$baseline
/
2
);
.btn
{
text-transform
:
uppercase
;
border
:
none
;
padding
:
0
;
color
:
$lms-active-color
;
background
:
transparent
;
&
.nav-courseware
{
margin-top
:
5px
;
&
:hover
{
background
:
transparent
;
color
:
$link-hover
;
text-decoration
:
underline
;
}
}
}
.item
{
font-weight
:
font-weight
(
semi-bold
);
text-transform
:
uppercase
;
&
.active
{
a
{
...
...
lms/templates/courseware/info.html
View file @
a815003b
...
...
@@ -12,7 +12,6 @@ from django.utils.translation import ugettext as _
from
courseware
.
courses
import
get_course_info_section
,
get_course_date_blocks
from
openedx
.
core
.
djangoapps
.
self_paced
.
models
import
SelfPacedConfiguration
from
openedx
.
core
.
djangolib
.
markup
import
HTML
,
Text
from
openedx
.
features
.
course_experience
import
SHOW_REVIEWS_TOOL_FLAG
%
>
<
%
block
name=
"pagetitle"
>
${_("{course_number} Course Info").format(course_number=course.display_number_with_default)}
</
%
block>
...
...
@@ -85,20 +84,15 @@ from openedx.features.course_experience import SHOW_REVIEWS_TOOL_FLAG
</section>
<section
aria-label=
"${_('Handout Navigation')}"
class=
"handouts"
>
% if course_tools:
<h3
class=
"hd hd-3 handouts-header"
>
${_("Course Tools")}
</h3>
<div>
<a
class=
"action-show-bookmarks"
href=
"${reverse('openedx.course_bookmarks.home', args=[course.id])}"
>
<span
class=
"icon fa fa-bookmark"
aria-hidden=
"true"
></span>
${_("Bookmarks")}
</a>
% if SHOW_REVIEWS_TOOL_FLAG.is_enabled(course.id) and show_reviews_link:
<a
href=
"${reverse('openedx.course_experience.course_reviews', args=[course.id])}"
>
<span
class=
"icon fa fa-star"
aria-hidden=
"true"
></span>
${_("Reviews")}
% for course_tool in course_tools:
<a
href=
"${course_tool.url(course.id)}"
>
<span
class=
"icon ${course_tool.icon_classes()}"
aria-hidden=
"true"
></span>
${course_tool.title()}
</a>
% endfor
% endif
</div>
% if SelfPacedConfiguration.current().enable_course_home_improvements:
${HTML(dates_fragment.body_html())}
% endif
...
...
lms/templates/main.html
View file @
a815003b
...
...
@@ -165,7 +165,9 @@ from pipeline_mako import render_require_js_path_overrides
</html>
<
%
def
name=
"login_query()"
>
${
u"?next={0}".format(urlquote_plus(login_redirect_url)) if login_redirect_url else ""
u"?next={next}".format(
next=urlquote_plus(login_redirect_url if login_redirect_url else request.path)
) if (login_redirect_url or request) else ""
}
</
%
def>
<!-- Performance beacon for onload times -->
...
...
lms/templates/navigation/navbar-authenticated.html
View file @
a815003b
...
...
@@ -13,7 +13,7 @@ from django.utils.translation import ugettext as _
<
%
block
name=
"navigation_global_links_authenticated"
>
% if settings.FEATURES.get('COURSES_ARE_BROWSABLE') and not show_program_listing:
<li
class=
"item nav-global-01"
>
<a
href=
"${marketing_link('COURSES')}"
>
${_('Explore courses')}
</a>
<a
class=
"btn"
href=
"${marketing_link('COURSES')}"
>
${_('Explore courses')}
</a>
</li>
% endif
% if show_program_listing:
...
...
@@ -28,12 +28,12 @@ from django.utils.translation import ugettext as _
</a>
</li>
% endif
%if settings.FEATURES.get('ENABLE_SYSADMIN_DASHBOARD','') and user.is_staff:
%
if settings.FEATURES.get('ENABLE_SYSADMIN_DASHBOARD','') and user.is_staff:
<li
class=
"item"
>
## Translators: This is short for "System administration".
<a
href=
"${reverse('sysadmin')}"
>
${_("Sysadmin")}
</a>
<a
class=
"btn"
href=
"${reverse('sysadmin')}"
>
${_("Sysadmin")}
</a>
</li>
%endif
%
endif
</
%
block>
</ol>
...
...
lms/templates/navigation/navbar-not-authenticated.html
View file @
a815003b
...
...
@@ -13,15 +13,15 @@ from django.utils.translation import ugettext as _
<
%
block
name=
"navigation_global_links"
>
% if static.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)):
<li
class=
"item nav-global-01"
>
<a
href=
"${marketing_link('HOW_IT_WORKS')}"
>
${_("How it Works")}
</a>
<a
class=
"btn"
href=
"${marketing_link('HOW_IT_WORKS')}"
>
${_("How it Works")}
</a>
</li>
% if settings.FEATURES.get('COURSES_ARE_BROWSABLE'):
<li
class=
"item nav-global-02"
>
<a
href=
"${marketing_link('COURSES')}"
>
${_("Courses")}
</a>
<a
class=
"btn"
href=
"${marketing_link('COURSES')}"
>
${_("Courses")}
</a>
</li>
% endif
<li
class=
"item nav-global-03"
>
<a
href=
"${marketing_link('SCHOOLS')}"
>
${_("Schools")}
</a>
<a
class=
"btn"
href=
"${marketing_link('SCHOOLS')}"
>
${_("Schools")}
</a>
</li>
% endif
</
%
block>
...
...
@@ -35,11 +35,11 @@ from django.utils.translation import ugettext as _
%endif
% if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
<li
class=
"item nav-global-04"
>
<a
class=
"btn-neutral btn-register"
href=
"${reverse('course-specific-register', args=[course.id.to_deprecated_string()])}"
>
${_("Register")}
</a>
<a
class=
"btn
btn
-neutral btn-register"
href=
"${reverse('course-specific-register', args=[course.id.to_deprecated_string()])}"
>
${_("Register")}
</a>
</li>
% elif static.get_value('ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION')):
<li
class=
"item nav-global-04"
>
<a
class=
"btn-neutral btn-register"
href=
"/register${login_query()}"
>
${_("Register")}
</a>
<a
class=
"btn
btn
-neutral btn-register"
href=
"/register${login_query()}"
>
${_("Register")}
</a>
</li>
% endif
% endif
...
...
@@ -51,9 +51,9 @@ from django.utils.translation import ugettext as _
<li
class=
"item nav-courseware-01"
>
% if not settings.FEATURES['DISABLE_LOGIN_BUTTON'] and not combined_login_and_register:
% if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
<a
class=
"btn btn-login"
href=
"${reverse('course-specific-login', args=[course.id.to_deprecated_string()])}${login_query()}"
>
${_("Sign in")}
</a>
<a
class=
"btn btn-
brand btn-
login"
href=
"${reverse('course-specific-login', args=[course.id.to_deprecated_string()])}${login_query()}"
>
${_("Sign in")}
</a>
% else:
<a
class=
"btn btn-login"
href=
"/login${login_query()}"
>
${_("Sign in")}
</a>
<a
class=
"btn b
rn-brand b
tn-login"
href=
"/login${login_query()}"
>
${_("Sign in")}
</a>
% endif
% endif
</li>
...
...
openedx/core/djangoapps/waffle_utils/__init__.py
View file @
a815003b
...
...
@@ -290,19 +290,21 @@ class CourseWaffleFlag(WaffleFlag):
return
None
return
course_override_callback
def
is_enabled
(
self
,
course_
id
=
None
):
def
is_enabled
(
self
,
course_
key
=
None
):
"""
Returns whether or not the flag is enabled.
Arguments:
course_
id
(CourseKey): The course to check for override before
course_
key
(CourseKey): The course to check for override before
checking waffle.
"""
# validate arguments
assert
issubclass
(
type
(
course_id
),
CourseKey
),
"The course_id '{}' must be a CourseKey."
.
format
(
str
(
course_id
))
assert
issubclass
(
type
(
course_key
),
CourseKey
),
"The course_id '{}' must be a CourseKey."
.
format
(
str
(
course_key
)
)
return
self
.
waffle_namespace
.
is_flag_active
(
self
.
flag_name
,
check_before_waffle_callback
=
self
.
_get_course_override_callback
(
course_
id
),
check_before_waffle_callback
=
self
.
_get_course_override_callback
(
course_
key
),
flag_undefined_default
=
self
.
flag_undefined_default
)
openedx/features/course_bookmarks/plugins.py
View file @
a815003b
...
...
@@ -2,9 +2,11 @@
Platform plugins to support course bookmarks.
"""
from
courseware.access
import
has_access
from
django.core.urlresolvers
import
reverse
from
django.utils.translation
import
ugettext
as
_
from
openedx.features.course_experience.course_tools
import
CourseTool
from
student.models
import
CourseEnrollment
class
CourseBookmarksTool
(
CourseTool
):
...
...
@@ -14,9 +16,11 @@ class CourseBookmarksTool(CourseTool):
@classmethod
def
is_enabled
(
cls
,
request
,
course_key
):
"""
Always show the bookmarks tool
.
The bookmarks tool is only enabled for enrolled users or staff
.
"""
if
has_access
(
request
.
user
,
'staff'
,
course_key
):
return
True
return
CourseEnrollment
.
is_enrolled
(
request
.
user
,
course_key
)
@classmethod
def
title
(
cls
):
...
...
openedx/features/course_bookmarks/tests/__init__.py
0 → 100644
View file @
a815003b
openedx/features/course_bookmarks/tests/test_course_bookmarks.py
0 → 100644
View file @
a815003b
"""
Unit tests for the course bookmarks feature.
"""
import
ddt
from
django.test
import
RequestFactory
from
student.models
import
CourseEnrollment
from
student.tests.factories
import
UserFactory
from
xmodule.modulestore
import
ModuleStoreEnum
from
xmodule.modulestore.tests.django_utils
import
CourseUserType
,
SharedModuleStoreTestCase
from
xmodule.modulestore.tests.factories
import
CourseFactory
,
ItemFactory
from
..plugins
import
CourseBookmarksTool
@ddt.ddt
class
TestCourseBookmarksTool
(
SharedModuleStoreTestCase
):
"""
Test the course bookmarks tool.
"""
@classmethod
def
setUpClass
(
cls
):
"""
Set up a course to be used for testing.
"""
# setUpClassAndTestData() already calls setUpClass on SharedModuleStoreTestCase
# pylint: disable=super-method-not-called
with
super
(
TestCourseBookmarksTool
,
cls
)
.
setUpClassAndTestData
():
with
cls
.
store
.
default_store
(
ModuleStoreEnum
.
Type
.
split
):
cls
.
course
=
CourseFactory
.
create
()
with
cls
.
store
.
bulk_operations
(
cls
.
course
.
id
):
# Create a basic course structure
chapter
=
ItemFactory
.
create
(
category
=
'chapter'
,
parent_location
=
cls
.
course
.
location
)
section
=
ItemFactory
.
create
(
category
=
'sequential'
,
parent_location
=
chapter
.
location
)
ItemFactory
.
create
(
category
=
'vertical'
,
parent_location
=
section
.
location
)
@ddt.data
(
[
CourseUserType
.
ANONYMOUS
,
False
],
[
CourseUserType
.
ENROLLED
,
True
],
[
CourseUserType
.
UNENROLLED
,
False
],
[
CourseUserType
.
UNENROLLED_STAFF
,
True
],
)
@ddt.unpack
def
test_bookmarks_tool_is_enabled
(
self
,
user_type
,
should_be_enabled
):
request
=
RequestFactory
()
.
request
()
request
.
user
=
self
.
create_user_for_course
(
self
.
course
,
user_type
)
self
.
assertEqual
(
CourseBookmarksTool
.
is_enabled
(
request
,
self
.
course
.
id
),
should_be_enabled
)
openedx/features/course_experience/course_tools.py
View file @
a815003b
...
...
@@ -65,3 +65,11 @@ class CourseToolsPluginManager(PluginManager):
course_tools
=
cls
.
get_available_plugins
()
.
values
()
course_tools
.
sort
(
key
=
lambda
course_tool
:
course_tool
.
title
())
return
course_tools
@classmethod
def
get_enabled_course_tools
(
cls
,
request
,
course_key
):
"""
Returns the course tools applicable to the current user and course.
"""
course_tools
=
CourseToolsPluginManager
.
get_course_tools
()
return
filter
(
lambda
tool
:
tool
.
is_enabled
(
request
,
course_key
),
course_tools
)
openedx/features/course_experience/plugins.py
View file @
a815003b
...
...
@@ -8,6 +8,7 @@ from django.utils.translation import ugettext as _
from
course_tools
import
CourseTool
from
courseware.courses
import
get_course_by_id
from
student.models
import
CourseEnrollment
from
views.course_reviews
import
CourseReviewsModuleFragmentView
from
views.course_updates
import
CourseUpdatesFragmentView
...
...
@@ -35,11 +36,14 @@ class CourseUpdatesTool(CourseTool):
@classmethod
def
is_enabled
(
cls
,
request
,
course_key
):
"""
Returns True if th
is tool is enabled for the specified course key
.
Returns True if th
e user should be shown course updates for this course
.
"""
if
not
UNIFIED_COURSE_TAB_FLAG
.
is_enabled
(
course_key
):
return
False
if
not
CourseEnrollment
.
is_enrolled
(
request
.
user
,
course_key
):
return
False
course
=
get_course_by_id
(
course_key
)
has_updates
=
CourseUpdatesFragmentView
.
has_updates
(
request
,
course
)
return
UNIFIED_COURSE_TAB_FLAG
.
is_enabled
(
course_key
)
and
has_updates
return
CourseUpdatesFragmentView
.
has_updates
(
request
,
course
)
@classmethod
def
url
(
cls
,
course_key
):
...
...
@@ -72,8 +76,9 @@ class CourseReviewsTool(CourseTool):
"""
Returns True if this tool is enabled for the specified course key.
"""
reviews_configured
=
CourseReviewsModuleFragmentView
.
is_configured
()
return
SHOW_REVIEWS_TOOL_FLAG
.
is_enabled
(
course_key
)
and
reviews_configured
if
not
SHOW_REVIEWS_TOOL_FLAG
.
is_enabled
(
course_key
):
return
False
return
CourseReviewsModuleFragmentView
.
is_configured
()
@classmethod
def
url
(
cls
,
course_key
):
...
...
openedx/features/course_experience/templates/course_experience/course-dates-fragment.html
View file @
a815003b
...
...
@@ -34,3 +34,7 @@ from django.utils.translation import ugettext as _
</div>
</div>
% endfor
<
%
static:require_module_async
module_name=
"js/dateutil_factory"
class_name=
"DateUtilFactory"
>
DateUtilFactory.transform('.localized-datetime');
</
%
static:require
_module_async
>
openedx/features/course_experience/templates/course_experience/course-home-fragment.html
View file @
a815003b
...
...
@@ -15,7 +15,6 @@ from django_comment_client.permissions import has_permission
from
openedx
.
core
.
djangolib
.
js_utils
import
dump_js_escaped_json
,
js_escaped_string
from
openedx
.
core
.
djangolib
.
markup
import
HTML
from
openedx
.
features
.
course_experience
import
UNIFIED_COURSE_TAB_FLAG
,
SHOW_REVIEWS_TOOL_FLAG
from
openedx
.
features
.
course_experience
.
course_tools
import
CourseToolsPluginManager
%
>
<
%
block
name=
"content"
>
...
...
@@ -64,32 +63,31 @@ from openedx.features.course_experience.course_tools import CourseToolsPluginMan
</div>
% endif
% if outline_fragment:
${HTML(outline_fragment.body_html())}
% endif
</main>
<aside
class=
"course-sidebar layout-col layout-col-a"
>
<
%
course_tools =
CourseToolsPluginManager.get_course_tools()
%
>
% if course_tools:
<div
class=
"section section-tools"
>
<h3
class=
"hd-6"
>
${_("Course Tools")}
</h3>
<ul
class=
"list-unstyled"
>
% for course_tool in course_tools:
% if course_tool.is_enabled(request, course_key):
<li>
<a
href=
"${course_tool.url(course_key)}"
>
<span
class=
"icon ${course_tool.icon_classes()}"
aria-hidden=
"true"
></span>
${course_tool.title()}
</a>
</li>
% endif
% endfor
</ul>
</div>
% endif
% if dates_fragment:
<div
class=
"section section-dates"
>
${HTML(dates_fragment.body_html())}
</div>
% endif
% if handouts_html:
<div
class=
"section section-handouts"
>
<h3
class=
"hd-6"
>
${_("Course Handouts")}
</h3>
...
...
@@ -99,6 +97,8 @@ from openedx.features.course_experience.course_tools import CourseToolsPluginMan
</aside>
</div>
</div>
% if course_sock_fragment:
${HTML(course_sock_fragment.body_html())}
% endif
</div>
</
%
block>
openedx/features/course_experience/templates/course_experience/course-outline-fragment.html
View file @
a815003b
...
...
@@ -151,7 +151,6 @@ from openedx.core.djangolib.markup import HTML, Text
)}
% endif
</div>
</div>
% endif
</main>
...
...
openedx/features/course_experience/tests/views/helpers.py
0 → 100644
View file @
a815003b
"""
Test helpers for the course experience.
"""
import
datetime
from
course_modes.models
import
CourseMode
TEST_COURSE_PRICE
=
50
def
add_course_mode
(
course
,
upgrade_deadline_expired
=
False
):
"""
Adds a course mode to the test course.
"""
upgrade_exp_date
=
datetime
.
datetime
.
now
()
if
upgrade_deadline_expired
:
upgrade_exp_date
=
upgrade_exp_date
-
datetime
.
timedelta
(
days
=
21
)
else
:
upgrade_exp_date
=
upgrade_exp_date
+
datetime
.
timedelta
(
days
=
21
)
CourseMode
(
course_id
=
course
.
id
,
mode_slug
=
CourseMode
.
VERIFIED
,
mode_display_name
=
"Verified Certificate"
,
min_price
=
TEST_COURSE_PRICE
,
_expiration_datetime
=
upgrade_exp_date
,
# pylint: disable=protected-access
)
.
save
()
openedx/features/course_experience/tests/views/test_course_home.py
View file @
a815003b
"""
Tests for the course home page.
"""
import
ddt
from
courseware.tests.factories
import
StaffFactory
from
django.core.urlresolvers
import
reverse
from
django.utils.http
import
urlquote_plus
from
openedx.core.djangoapps.waffle_utils.testutils
import
WAFFLE_TABLES
,
override_waffle_flag
from
openedx.features.course_experience
import
UNIFIED_COURSE_TAB_FLAG
from
openedx.features.course_experience
import
SHOW_REVIEWS_TOOL_FLAG
,
UNIFIED_COURSE_TAB_FLAG
from
student.models
import
CourseEnrollment
from
student.tests.factories
import
UserFactory
from
xmodule.modulestore
import
ModuleStoreEnum
from
xmodule.modulestore.tests.django_utils
import
SharedModuleStoreTestCase
from
xmodule.modulestore.tests.django_utils
import
CourseUserType
,
SharedModuleStoreTestCase
from
xmodule.modulestore.tests.factories
import
CourseFactory
,
ItemFactory
,
check_mongo_calls
from
.test_course_updates
import
create_course_update
,
remove_course_updates
from
.helpers
import
add_course_mode
from
.test_course_updates
import
create_course_update
TEST_PASSWORD
=
'test'
TEST_CHAPTER_NAME
=
'Test Chapter'
TEST_WELCOME_MESSAGE
=
'<h2>Welcome!</h2>'
TEST_UPDATE_MESSAGE
=
'<h2>Test Update!</h2>'
TEST_COURSE_UPDATES_TOOL
=
'/course/updates">'
...
...
@@ -32,20 +38,26 @@ def course_home_url(course):
)
class
TestCourseHomePag
e
(
SharedModuleStoreTestCase
):
class
CourseHomePageTestCas
e
(
SharedModuleStoreTestCase
):
"""
Test
the course home page.
Base class for testing
the course home page.
"""
@classmethod
def
setUpClass
(
cls
):
"""Set up the simplest course possible."""
"""
Set up a course to be used for testing.
"""
# setUpClassAndTestData() already calls setUpClass on SharedModuleStoreTestCase
# pylint: disable=super-method-not-called
with
super
(
TestCourseHomePag
e
,
cls
)
.
setUpClassAndTestData
():
with
super
(
CourseHomePageTestCas
e
,
cls
)
.
setUpClassAndTestData
():
with
cls
.
store
.
default_store
(
ModuleStoreEnum
.
Type
.
split
):
cls
.
course
=
CourseFactory
.
create
(
org
=
'edX'
,
number
=
'test'
,
display_name
=
'Test Course'
)
with
cls
.
store
.
bulk_operations
(
cls
.
course
.
id
):
chapter
=
ItemFactory
.
create
(
category
=
'chapter'
,
parent_location
=
cls
.
course
.
location
)
chapter
=
ItemFactory
.
create
(
category
=
'chapter'
,
parent_location
=
cls
.
course
.
location
,
display_name
=
TEST_CHAPTER_NAME
,
)
section
=
ItemFactory
.
create
(
category
=
'sequential'
,
parent_location
=
chapter
.
location
)
section2
=
ItemFactory
.
create
(
category
=
'sequential'
,
parent_location
=
chapter
.
location
)
ItemFactory
.
create
(
category
=
'vertical'
,
parent_location
=
section
.
location
)
...
...
@@ -54,9 +66,12 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
@classmethod
def
setUpTestData
(
cls
):
"""Set up and enroll our fake user in the course."""
cls
.
staff_user
=
StaffFactory
(
course_key
=
cls
.
course
.
id
,
password
=
TEST_PASSWORD
)
cls
.
user
=
UserFactory
(
password
=
TEST_PASSWORD
)
CourseEnrollment
.
enroll
(
cls
.
user
,
cls
.
course
.
id
)
class
TestCourseHomePage
(
CourseHomePageTestCase
):
def
setUp
(
self
):
"""
Set up for the tests.
...
...
@@ -109,3 +124,90 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
with
check_mongo_calls
(
4
):
url
=
course_home_url
(
self
.
course
)
self
.
client
.
get
(
url
)
@ddt.ddt
class
TestCourseHomePageAccess
(
CourseHomePageTestCase
):
"""
Test access to the course home page.
"""
def
setUp
(
self
):
super
(
TestCourseHomePageAccess
,
self
)
.
setUp
()
# Make this a verified course so that an upgrade message might be shown
add_course_mode
(
self
.
course
,
upgrade_deadline_expired
=
False
)
# Add a welcome message
create_course_update
(
self
.
course
,
self
.
staff_user
,
TEST_WELCOME_MESSAGE
)
@override_waffle_flag
(
UNIFIED_COURSE_TAB_FLAG
,
active
=
True
)
@override_waffle_flag
(
SHOW_REVIEWS_TOOL_FLAG
,
active
=
True
)
@ddt.data
(
CourseUserType
.
ANONYMOUS
,
CourseUserType
.
ENROLLED
,
CourseUserType
.
UNENROLLED
,
CourseUserType
.
UNENROLLED_STAFF
,
)
def
test_home_page
(
self
,
user_type
):
self
.
user
=
self
.
create_user_for_course
(
self
.
course
,
user_type
)
# Render the course home page
url
=
course_home_url
(
self
.
course
)
response
=
self
.
client
.
get
(
url
)
# Verify that the course tools and dates are always shown
self
.
assertContains
(
response
,
'Course Tools'
)
self
.
assertContains
(
response
,
'Today is'
)
# Verify that the outline, start button, course sock, and welcome message
# are only shown to enrolled users.
is_enrolled
=
user_type
is
CourseUserType
.
ENROLLED
is_unenrolled_staff
=
user_type
is
CourseUserType
.
UNENROLLED_STAFF
expected_count
=
1
if
(
is_enrolled
or
is_unenrolled_staff
)
else
0
self
.
assertContains
(
response
,
TEST_CHAPTER_NAME
,
count
=
expected_count
)
self
.
assertContains
(
response
,
'Start Course'
,
count
=
expected_count
)
self
.
assertContains
(
response
,
'Learn About Verified Certificate'
,
count
=
expected_count
)
self
.
assertContains
(
response
,
TEST_WELCOME_MESSAGE
,
count
=
expected_count
)
@override_waffle_flag
(
UNIFIED_COURSE_TAB_FLAG
,
active
=
False
)
@override_waffle_flag
(
SHOW_REVIEWS_TOOL_FLAG
,
active
=
True
)
@ddt.data
(
CourseUserType
.
ANONYMOUS
,
CourseUserType
.
ENROLLED
,
CourseUserType
.
UNENROLLED
,
CourseUserType
.
UNENROLLED_STAFF
,
)
def
test_home_page_not_unified
(
self
,
user_type
):
"""
Verifies the course home tab when not unified.
"""
self
.
user
=
self
.
create_user_for_course
(
self
.
course
,
user_type
)
# Render the course home page
url
=
course_home_url
(
self
.
course
)
response
=
self
.
client
.
get
(
url
)
# Verify that the course tools and dates are always shown
self
.
assertContains
(
response
,
'Course Tools'
)
self
.
assertContains
(
response
,
'Today is'
)
# Verify that welcome messages are never shown
self
.
assertNotContains
(
response
,
TEST_WELCOME_MESSAGE
)
# Verify that the outline, start button, course sock, and welcome message
# are only shown to enrolled users.
is_enrolled
=
user_type
is
CourseUserType
.
ENROLLED
is_unenrolled_staff
=
user_type
is
CourseUserType
.
UNENROLLED_STAFF
expected_count
=
1
if
(
is_enrolled
or
is_unenrolled_staff
)
else
0
self
.
assertContains
(
response
,
TEST_CHAPTER_NAME
,
count
=
expected_count
)
self
.
assertContains
(
response
,
'Start Course'
,
count
=
expected_count
)
self
.
assertContains
(
response
,
'Learn About Verified Certificate'
,
count
=
expected_count
)
def
test_sign_in_button
(
self
):
"""
Verify that the sign in button will return to this page.
"""
url
=
course_home_url
(
self
.
course
)
response
=
self
.
client
.
get
(
url
)
self
.
assertContains
(
response
,
'/login?next={url}'
.
format
(
url
=
urlquote_plus
(
url
)))
openedx/features/course_experience/tests/views/test_course_outline.py
View file @
a815003b
...
...
@@ -5,7 +5,6 @@ import datetime
import
ddt
import
json
from
markupsafe
import
escape
from
unittest
import
skip
from
django.core.urlresolvers
import
reverse
from
pyquery
import
PyQuery
as
pq
...
...
openedx/features/course_experience/tests/views/test_course_sock.py
View file @
a815003b
...
...
@@ -2,7 +2,6 @@
Tests for course verification sock
"""
import
datetime
import
ddt
from
course_modes.models
import
CourseMode
...
...
@@ -12,11 +11,11 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory
from
xmodule.modulestore.tests.django_utils
import
SharedModuleStoreTestCase
from
xmodule.modulestore.tests.factories
import
CourseFactory
from
.helpers
import
add_course_mode
from
.test_course_home
import
course_home_url
TEST_PASSWORD
=
'test'
TEST_VERIFICATION_SOCK_LOCATOR
=
'<div class="verification-sock"'
TEST_COURSE_PRICE
=
50
@ddt.ddt
...
...
@@ -34,10 +33,10 @@ class TestCourseSockView(SharedModuleStoreTestCase):
cls
.
verified_course_update_expired
=
CourseFactory
.
create
()
cls
.
verified_course_already_enrolled
=
CourseFactory
.
create
()
# Assign each verifiable course a upgrade deadline
cls
.
_
add_course_mode
(
cls
.
verified_course
,
upgrade_deadline_expired
=
False
)
cls
.
_
add_course_mode
(
cls
.
verified_course_update_expired
,
upgrade_deadline_expired
=
True
)
cls
.
_
add_course_mode
(
cls
.
verified_course_already_enrolled
,
upgrade_deadline_expired
=
False
)
# Assign each verifiable course a
n
upgrade deadline
add_course_mode
(
cls
.
verified_course
,
upgrade_deadline_expired
=
False
)
add_course_mode
(
cls
.
verified_course_update_expired
,
upgrade_deadline_expired
=
True
)
add_course_mode
(
cls
.
verified_course_already_enrolled
,
upgrade_deadline_expired
=
False
)
def
setUp
(
self
):
super
(
TestCourseSockView
,
self
)
.
setUp
()
...
...
@@ -47,7 +46,9 @@ class TestCourseSockView(SharedModuleStoreTestCase):
CourseEnrollmentFactory
.
create
(
user
=
self
.
user
,
course_id
=
self
.
standard_course
.
id
)
CourseEnrollmentFactory
.
create
(
user
=
self
.
user
,
course_id
=
self
.
verified_course
.
id
)
CourseEnrollmentFactory
.
create
(
user
=
self
.
user
,
course_id
=
self
.
verified_course_update_expired
.
id
)
CourseEnrollmentFactory
.
create
(
user
=
self
.
user
,
course_id
=
self
.
verified_course_already_enrolled
.
id
,
mode
=
CourseMode
.
VERIFIED
)
CourseEnrollmentFactory
.
create
(
user
=
self
.
user
,
course_id
=
self
.
verified_course_already_enrolled
.
id
,
mode
=
CourseMode
.
VERIFIED
)
# Log the user in
self
.
client
.
login
(
username
=
self
.
user
.
username
,
password
=
TEST_PASSWORD
)
...
...
@@ -101,22 +102,3 @@ class TestCourseSockView(SharedModuleStoreTestCase):
response
.
content
,
msg
=
'Student should not be able to see sock in a unverifiable course.'
,
)
@classmethod
def
_add_course_mode
(
cls
,
course
,
upgrade_deadline_expired
=
False
):
"""
Adds a course mode to the test course.
"""
upgrade_exp_date
=
datetime
.
datetime
.
now
()
if
upgrade_deadline_expired
:
upgrade_exp_date
=
upgrade_exp_date
-
datetime
.
timedelta
(
days
=
21
)
else
:
upgrade_exp_date
=
upgrade_exp_date
+
datetime
.
timedelta
(
days
=
21
)
CourseMode
(
course_id
=
course
.
id
,
mode_slug
=
CourseMode
.
VERIFIED
,
mode_display_name
=
"Verified Certificate"
,
min_price
=
TEST_COURSE_PRICE
,
_expiration_datetime
=
upgrade_exp_date
,
# pylint: disable=protected-access
)
.
save
()
openedx/features/course_experience/views/course_home.py
View file @
a815003b
...
...
@@ -2,19 +2,21 @@
Views for the course home page.
"""
from
django.contrib.auth.decorators
import
login_required
from
django.core.context_processors
import
csrf
from
django.template.loader
import
render_to_string
from
django.utils.decorators
import
method_decorator
from
django.views.decorators.cache
import
cache_control
from
django.views.decorators.csrf
import
ensure_csrf_cookie
from
opaque_keys.edx.keys
import
CourseKey
from
web_fragments.fragment
import
Fragment
from
courseware.access
import
has_access
from
courseware.courses
import
get_course_info_section
,
get_course_with_access
from
lms.djangoapps.courseware.views.views
import
CourseTabView
from
opaque_keys.edx.keys
import
CourseKey
from
openedx.core.djangoapps.plugin_api.views
import
EdxFragmentView
from
openedx.features.course_experience.course_tools
import
CourseToolsPluginManager
from
student.models
import
CourseEnrollment
from
util.views
import
ensure_valid_course_key
from
web_fragments.fragment
import
Fragment
from
..utils
import
get_course_outline_block_tree
from
.course_dates
import
CourseDatesFragmentView
...
...
@@ -22,12 +24,13 @@ from .course_outline import CourseOutlineFragmentView
from
.course_sock
import
CourseSockFragmentView
from
.welcome_message
import
WelcomeMessageFragmentView
EMPTY_HANDOUTS_HTML
=
u'<ol></ol>'
class
CourseHomeView
(
CourseTabView
):
"""
The home page for a course.
"""
@method_decorator
(
login_required
)
@method_decorator
(
ensure_csrf_cookie
)
@method_decorator
(
cache_control
(
no_cache
=
True
,
no_store
=
True
,
must_revalidate
=
True
))
@method_decorator
(
ensure_valid_course_key
)
...
...
@@ -83,34 +86,48 @@ class CourseHomeFragmentView(EdxFragmentView):
return
(
has_visited_course
,
resume_course_url
)
def
_get_course_handouts
(
self
,
request
,
course
):
"""
Returns the handouts for the specified course.
"""
handouts
=
get_course_info_section
(
request
,
request
.
user
,
course
,
'handouts'
)
if
not
handouts
or
handouts
==
EMPTY_HANDOUTS_HTML
:
return
None
return
handouts
def
render_to_fragment
(
self
,
request
,
course_id
=
None
,
**
kwargs
):
"""
Renders the course's home page as a fragment.
"""
course_key
=
CourseKey
.
from_string
(
course_id
)
course
=
get_course_with_access
(
request
.
user
,
'load'
,
course_key
)
# Render the outline as a fragment
outline_fragment
=
CourseOutlineFragmentView
()
.
render_to_fragment
(
request
,
course_id
=
course_id
,
**
kwargs
)
# Get resume course information
has_visited_course
,
resume_course_url
=
self
.
_get_resume_course_info
(
request
,
course_id
)
# Render the course dates as a fragment
dates_fragment
=
CourseDatesFragmentView
()
.
render_to_fragment
(
request
,
course_id
=
course_id
,
**
kwargs
)
# Render the welcome message as a fragment
# Render the full content to enrolled users, as well as to course and global staff.
# Unenrolled users who are not course or global staff are given only a subset.
is_enrolled
=
CourseEnrollment
.
is_enrolled
(
request
.
user
,
course_key
)
is_staff
=
has_access
(
request
.
user
,
'staff'
,
course_key
)
if
is_enrolled
or
is_staff
:
outline_fragment
=
CourseOutlineFragmentView
()
.
render_to_fragment
(
request
,
course_id
=
course_id
,
**
kwargs
)
welcome_message_fragment
=
WelcomeMessageFragmentView
()
.
render_to_fragment
(
request
,
course_id
=
course_id
,
**
kwargs
)
# Render the course dates as a fragment
dates_fragment
=
CourseDatesFragmentView
()
.
render_to_fragment
(
request
,
course_id
=
course_id
,
**
kwargs
)
# TODO: Use get_course_overview_with_access and blocks api
course
=
get_course_with_access
(
request
.
user
,
'load'
,
course_key
,
check_if_enrolled
=
True
)
# Render the verification sock as a fragment
course_sock_fragment
=
CourseSockFragmentView
()
.
render_to_fragment
(
request
,
course
=
course
,
**
kwargs
)
has_visited_course
,
resume_course_url
=
self
.
_get_resume_course_info
(
request
,
course_id
)
else
:
outline_fragment
=
None
welcome_message_fragment
=
None
course_sock_fragment
=
None
has_visited_course
=
None
resume_course_url
=
None
# Get the handouts
handouts_html
=
get_course_info_section
(
request
,
request
.
user
,
course
,
'handouts'
)
handouts_html
=
self
.
_get_course_handouts
(
request
,
course
)
# Get the course tools enabled for this user and course
course_tools
=
CourseToolsPluginManager
.
get_enabled_course_tools
(
request
,
course_key
)
# Render the course home fragment
context
=
{
...
...
@@ -122,6 +139,7 @@ class CourseHomeFragmentView(EdxFragmentView):
'handouts_html'
:
handouts_html
,
'has_visited_course'
:
has_visited_course
,
'resume_course_url'
:
resume_course_url
,
'course_tools'
:
course_tools
,
'dates_fragment'
:
dates_fragment
,
'welcome_message_fragment'
:
welcome_message_fragment
,
'course_sock_fragment'
:
course_sock_fragment
,
...
...
openedx/features/course_experience/views/course_sock.py
View file @
a815003b
...
...
@@ -32,7 +32,7 @@ class CourseSockFragmentView(EdxFragmentView):
has_verified_mode
=
CourseMode
.
has_verified_mode
(
available_modes
)
# Establish whether the user is already enrolled
is_already_verified
=
CourseEnrollment
.
is_enrolled_as_verified
(
request
.
user
.
id
,
course_key
)
is_already_verified
=
CourseEnrollment
.
is_enrolled_as_verified
(
request
.
user
,
course_key
)
# Establish whether the verification deadline has already passed
verification_deadline
=
VerifiedUpgradeDeadlineDate
(
course
,
request
.
user
)
...
...
requirements/edx/base.txt
View file @
a815003b
...
...
@@ -42,6 +42,7 @@ django==1.8.18
django-waffle==0.12.0
djangorestframework-jwt==1.8.0
djangorestframework-oauth==1.1.0
enum34==1.1.6
edx-ccx-keys==0.2.1
edx-celeryutils==0.2.4
edx-drf-extensions==1.2.2
...
...
themes/edx.org/lms/templates/header.html
View file @
a815003b
...
...
@@ -136,11 +136,11 @@ site_status_msg = get_site_status_msg(course_id)
% if not settings.FEATURES['DISABLE_LOGIN_BUTTON'] and not combined_login_and_register:
% if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
<div
class=
"item nav-courseware-02"
>
<a
class=
"btn-neutral btn-register"
href=
"${reverse('course-specific-register', args=[course.id.to_deprecated_string()])}"
>
${_("Register")}
</a>
<a
class=
"btn
btn
-neutral btn-register"
href=
"${reverse('course-specific-register', args=[course.id.to_deprecated_string()])}"
>
${_("Register")}
</a>
</div>
% elif static.get_value('ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION')):
<div
class=
"item nav-courseware-02"
>
<a
class=
"btn-neutral btn-register"
href=
"/register"
>
${_("Register")}
</a>
<a
class=
"btn
btn
-neutral btn-register"
href=
"/register"
>
${_("Register")}
</a>
</div>
% endif
% endif
...
...
themes/red-theme/lms/templates/header.html
View file @
a815003b
...
...
@@ -138,11 +138,11 @@ site_status_msg = get_site_status_msg(course_id)
% if not settings.FEATURES['DISABLE_LOGIN_BUTTON']:
% if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
<li
class=
"nav-global-04"
>
<a
class=
"btn-neutral btn-register"
href=
"${reverse('course-specific-register', args=[course.id.to_deprecated_string()])}"
>
${_("Register Now")}
</a>
<a
class=
"btn
btn
-neutral btn-register"
href=
"${reverse('course-specific-register', args=[course.id.to_deprecated_string()])}"
>
${_("Register Now")}
</a>
</li>
% else:
<li
class=
"nav-global-04"
>
<a
class=
"btn-neutral btn-register"
href=
"/register"
>
${_("Register Now")}
</a>
<a
class=
"btn
btn
-neutral btn-register"
href=
"/register"
>
${_("Register Now")}
</a>
</li>
% endif
% endif
...
...
@@ -152,9 +152,9 @@ site_status_msg = get_site_status_msg(course_id)
<li
class=
"nav-courseware-01"
>
% if not settings.FEATURES['DISABLE_LOGIN_BUTTON']:
% if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
<a
class=
"btn btn-login"
href=
"${reverse('course-specific-login', args=[course.id.to_deprecated_string()])}${login_query()}"
>
${_("Sign in")}
</a>
<a
class=
"btn btn-
brand btn-
login"
href=
"${reverse('course-specific-login', args=[course.id.to_deprecated_string()])}${login_query()}"
>
${_("Sign in")}
</a>
% else:
<a
class=
"btn-brand btn-login"
href=
"/login${login_query()}"
>
${_("Sign in")}
</a>
<a
class=
"btn
btn
-brand btn-login"
href=
"/login${login_query()}"
>
${_("Sign in")}
</a>
% endif
% endif
</li>
...
...
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