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
99fbc890
Commit
99fbc890
authored
Apr 23, 2015
by
Ned Batchelder
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #7771 from jazkarta/mit-ccx-fix-dashboard
WIP: MIT CCX student dashboard display
parents
b2ac33db
870c30fd
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
334 additions
and
16 deletions
+334
-16
lms/djangoapps/ccx/models.py
+79
-0
lms/djangoapps/ccx/tests/test_models.py
+188
-1
lms/templates/ccx/_dashboard_ccx_listing.html
+64
-14
lms/templates/dashboard.html
+3
-1
No files found.
lms/djangoapps/ccx/models.py
View file @
99fbc890
"""
Models for the custom course feature
"""
from
datetime
import
datetime
import
logging
from
django.contrib.auth.models
import
User
from
django.db
import
models
from
django.utils.timezone
import
UTC
from
lazy
import
lazy
from
student.models
import
CourseEnrollment
,
AlreadyEnrolledError
# pylint: disable=import-error
from
xmodule_django.models
import
CourseKeyField
,
LocationKeyField
# pylint: disable=import-error
from
xmodule.error_module
import
ErrorDescriptor
from
xmodule.modulestore.django
import
modulestore
log
=
logging
.
getLogger
(
"edx.ccx"
)
class
CustomCourseForEdX
(
models
.
Model
):
...
...
@@ -16,6 +26,75 @@ class CustomCourseForEdX(models.Model):
display_name
=
models
.
CharField
(
max_length
=
255
)
coach
=
models
.
ForeignKey
(
User
,
db_index
=
True
)
@lazy
def
course
(
self
):
"""Return the CourseDescriptor of the course related to this CCX"""
store
=
modulestore
()
with
store
.
bulk_operations
(
self
.
course_id
):
course
=
store
.
get_course
(
self
.
course_id
)
if
not
course
or
isinstance
(
course
,
ErrorDescriptor
):
log
.
error
(
"CCX {0} from {2} course {1}"
.
format
(
# pylint: disable=logging-format-interpolation
self
.
display_name
,
self
.
course_id
,
"broken"
if
course
else
"non-existent"
))
return
course
@lazy
def
start
(
self
):
"""Get the value of the override of the 'start' datetime for this CCX
"""
# avoid circular import problems
from
.overrides
import
get_override_for_ccx
return
get_override_for_ccx
(
self
,
self
.
course
,
'start'
)
@lazy
def
due
(
self
):
"""Get the value of the override of the 'due' datetime for this CCX
"""
# avoid circular import problems
from
.overrides
import
get_override_for_ccx
return
get_override_for_ccx
(
self
,
self
.
course
,
'due'
)
def
has_started
(
self
):
"""Return True if the CCX start date is in the past"""
return
datetime
.
now
(
UTC
())
>
self
.
start
def
has_ended
(
self
):
"""Return True if the CCX due date is set and is in the past"""
if
self
.
due
is
None
:
return
False
return
datetime
.
now
(
UTC
())
>
self
.
due
def
start_datetime_text
(
self
,
format_string
=
"SHORT_DATE"
):
"""Returns the desired text representation of the CCX start datetime
The returned value is always expressed in UTC
"""
i18n
=
self
.
course
.
runtime
.
service
(
self
.
course
,
"i18n"
)
strftime
=
i18n
.
strftime
value
=
strftime
(
self
.
start
,
format_string
)
if
format_string
==
'DATE_TIME'
:
value
+=
u' UTC'
return
value
def
end_datetime_text
(
self
,
format_string
=
"SHORT_DATE"
):
"""Returns the desired text representation of the CCX due datetime
If the due date for the CCX is not set, the value returned is the empty
string.
The returned value is always expressed in UTC
"""
if
self
.
due
is
None
:
return
''
i18n
=
self
.
course
.
runtime
.
service
(
self
.
course
,
"i18n"
)
strftime
=
i18n
.
strftime
value
=
strftime
(
self
.
due
,
format_string
)
if
format_string
==
'DATE_TIME'
:
value
+=
u' UTC'
return
value
class
CcxMembership
(
models
.
Model
):
"""
...
...
lms/djangoapps/ccx/tests/test_models.py
View file @
99fbc890
"""
tests for the models
"""
from
datetime
import
datetime
,
timedelta
from
django.utils.timezone
import
UTC
from
mock
import
patch
from
student.models
import
CourseEnrollment
# pylint: disable=import-error
from
student.roles
import
CourseCcxCoachRole
# pylint: disable=import-error
from
student.tests.factories
import
(
# pylint: disable=import-error
...
...
@@ -8,8 +11,12 @@ from student.tests.factories import ( # pylint: disable=import-error
CourseEnrollmentFactory
,
UserFactory
,
)
from
util.tests.test_date_utils
import
fake_ugettext
from
xmodule.modulestore.tests.django_utils
import
ModuleStoreTestCase
from
xmodule.modulestore.tests.factories
import
CourseFactory
from
xmodule.modulestore.tests.factories
import
(
CourseFactory
,
check_mongo_calls
)
from
.factories
import
(
CcxFactory
,
...
...
@@ -19,6 +26,7 @@ from ..models import (
CcxMembership
,
CcxFutureMembership
,
)
from
..overrides
import
override_field_for_ccx
class
TestCcxMembership
(
ModuleStoreTestCase
):
...
...
@@ -125,3 +133,182 @@ class TestCcxMembership(ModuleStoreTestCase):
self
.
assertFalse
(
self
.
has_course_enrollment
(
user
))
self
.
assertFalse
(
self
.
has_ccx_membership
(
user
))
self
.
assertTrue
(
self
.
has_ccx_future_membership
(
user
))
class
TestCCX
(
ModuleStoreTestCase
):
"""Unit tests for the CustomCourseForEdX model
"""
def
setUp
(
self
):
"""common setup for all tests"""
super
(
TestCCX
,
self
)
.
setUp
()
self
.
course
=
course
=
CourseFactory
.
create
()
coach
=
AdminFactory
.
create
()
role
=
CourseCcxCoachRole
(
course
.
id
)
role
.
add_users
(
coach
)
self
.
ccx
=
CcxFactory
(
course_id
=
course
.
id
,
coach
=
coach
)
def
set_ccx_override
(
self
,
field
,
value
):
"""Create a field override for the test CCX on <field> with <value>"""
override_field_for_ccx
(
self
.
ccx
,
self
.
course
,
field
,
value
)
def
test_ccx_course_is_correct_course
(
self
):
"""verify that the course property of a ccx returns the right course"""
expected
=
self
.
course
actual
=
self
.
ccx
.
course
self
.
assertEqual
(
expected
,
actual
)
def
test_ccx_course_caching
(
self
):
"""verify that caching the propery works to limit queries"""
with
check_mongo_calls
(
1
):
# these statements are used entirely to demonstrate the
# instance-level caching of these values on CCX objects. The
# check_mongo_calls context is the point here.
self
.
ccx
.
course
# pylint: disable=pointless-statement
with
check_mongo_calls
(
0
):
self
.
ccx
.
course
# pylint: disable=pointless-statement
def
test_ccx_start_is_correct
(
self
):
"""verify that the start datetime for a ccx is correctly retrieved
Note that after setting the start field override microseconds are
truncated, so we can't do a direct comparison between before and after.
For this reason we test the difference between and make sure it is less
than one second.
"""
expected
=
datetime
.
now
(
UTC
())
self
.
set_ccx_override
(
'start'
,
expected
)
actual
=
self
.
ccx
.
start
# pylint: disable=no-member
diff
=
expected
-
actual
self
.
assertTrue
(
abs
(
diff
.
total_seconds
())
<
1
)
def
test_ccx_start_caching
(
self
):
"""verify that caching the start property works to limit queries"""
now
=
datetime
.
now
(
UTC
())
self
.
set_ccx_override
(
'start'
,
now
)
with
check_mongo_calls
(
1
):
# these statements are used entirely to demonstrate the
# instance-level caching of these values on CCX objects. The
# check_mongo_calls context is the point here.
self
.
ccx
.
start
# pylint: disable=pointless-statement, no-member
with
check_mongo_calls
(
0
):
self
.
ccx
.
start
# pylint: disable=pointless-statement, no-member
def
test_ccx_due_without_override
(
self
):
"""verify that due returns None when the field has not been set"""
actual
=
self
.
ccx
.
due
# pylint: disable=no-member
self
.
assertIsNone
(
actual
)
def
test_ccx_due_is_correct
(
self
):
"""verify that the due datetime for a ccx is correctly retrieved"""
expected
=
datetime
.
now
(
UTC
())
self
.
set_ccx_override
(
'due'
,
expected
)
actual
=
self
.
ccx
.
due
# pylint: disable=no-member
diff
=
expected
-
actual
self
.
assertTrue
(
abs
(
diff
.
total_seconds
())
<
1
)
def
test_ccx_due_caching
(
self
):
"""verify that caching the due property works to limit queries"""
expected
=
datetime
.
now
(
UTC
())
self
.
set_ccx_override
(
'due'
,
expected
)
with
check_mongo_calls
(
1
):
# these statements are used entirely to demonstrate the
# instance-level caching of these values on CCX objects. The
# check_mongo_calls context is the point here.
self
.
ccx
.
due
# pylint: disable=pointless-statement, no-member
with
check_mongo_calls
(
0
):
self
.
ccx
.
due
# pylint: disable=pointless-statement, no-member
def
test_ccx_has_started
(
self
):
"""verify that a ccx marked as starting yesterday has started"""
now
=
datetime
.
now
(
UTC
())
delta
=
timedelta
(
1
)
then
=
now
-
delta
self
.
set_ccx_override
(
'start'
,
then
)
self
.
assertTrue
(
self
.
ccx
.
has_started
())
# pylint: disable=no-member
def
test_ccx_has_not_started
(
self
):
"""verify that a ccx marked as starting tomorrow has not started"""
now
=
datetime
.
now
(
UTC
())
delta
=
timedelta
(
1
)
then
=
now
+
delta
self
.
set_ccx_override
(
'start'
,
then
)
self
.
assertFalse
(
self
.
ccx
.
has_started
())
# pylint: disable=no-member
def
test_ccx_has_ended
(
self
):
"""verify that a ccx that has a due date in the past has ended"""
now
=
datetime
.
now
(
UTC
())
delta
=
timedelta
(
1
)
then
=
now
-
delta
self
.
set_ccx_override
(
'due'
,
then
)
self
.
assertTrue
(
self
.
ccx
.
has_ended
())
# pylint: disable=no-member
def
test_ccx_has_not_ended
(
self
):
"""verify that a ccx that has a due date in the future has not eneded
"""
now
=
datetime
.
now
(
UTC
())
delta
=
timedelta
(
1
)
then
=
now
+
delta
self
.
set_ccx_override
(
'due'
,
then
)
self
.
assertFalse
(
self
.
ccx
.
has_ended
())
# pylint: disable=no-member
def
test_ccx_without_due_date_has_not_ended
(
self
):
"""verify that a ccx without a due date has not ended"""
self
.
assertFalse
(
self
.
ccx
.
has_ended
())
# pylint: disable=no-member
# ensure that the expected localized format will be found by the i18n
# service
@patch
(
'util.date_utils.ugettext'
,
fake_ugettext
(
translations
=
{
"SHORT_DATE_FORMAT"
:
"
%
b
%
d,
%
Y"
,
}))
def
test_start_datetime_short_date
(
self
):
"""verify that the start date for a ccx formats properly by default"""
start
=
datetime
(
2015
,
1
,
1
,
12
,
0
,
0
,
tzinfo
=
UTC
())
expected
=
"Jan 01, 2015"
self
.
set_ccx_override
(
'start'
,
start
)
actual
=
self
.
ccx
.
start_datetime_text
()
# pylint: disable=no-member
self
.
assertEqual
(
expected
,
actual
)
@patch
(
'util.date_utils.ugettext'
,
fake_ugettext
(
translations
=
{
"DATE_TIME_FORMAT"
:
"
%
b
%
d,
%
Y at
%
H:
%
M"
,
}))
def
test_start_datetime_date_time_format
(
self
):
"""verify that the DATE_TIME format also works as expected"""
start
=
datetime
(
2015
,
1
,
1
,
12
,
0
,
0
,
tzinfo
=
UTC
())
expected
=
"Jan 01, 2015 at 12:00 UTC"
self
.
set_ccx_override
(
'start'
,
start
)
actual
=
self
.
ccx
.
start_datetime_text
(
'DATE_TIME'
)
# pylint: disable=no-member
self
.
assertEqual
(
expected
,
actual
)
@patch
(
'util.date_utils.ugettext'
,
fake_ugettext
(
translations
=
{
"SHORT_DATE_FORMAT"
:
"
%
b
%
d,
%
Y"
,
}))
def
test_end_datetime_short_date
(
self
):
"""verify that the end date for a ccx formats properly by default"""
end
=
datetime
(
2015
,
1
,
1
,
12
,
0
,
0
,
tzinfo
=
UTC
())
expected
=
"Jan 01, 2015"
self
.
set_ccx_override
(
'due'
,
end
)
actual
=
self
.
ccx
.
end_datetime_text
()
# pylint: disable=no-member
self
.
assertEqual
(
expected
,
actual
)
@patch
(
'util.date_utils.ugettext'
,
fake_ugettext
(
translations
=
{
"DATE_TIME_FORMAT"
:
"
%
b
%
d,
%
Y at
%
H:
%
M"
,
}))
def
test_end_datetime_date_time_format
(
self
):
"""verify that the DATE_TIME format also works as expected"""
end
=
datetime
(
2015
,
1
,
1
,
12
,
0
,
0
,
tzinfo
=
UTC
())
expected
=
"Jan 01, 2015 at 12:00 UTC"
self
.
set_ccx_override
(
'due'
,
end
)
actual
=
self
.
ccx
.
end_datetime_text
(
'DATE_TIME'
)
# pylint: disable=no-member
self
.
assertEqual
(
expected
,
actual
)
@patch
(
'util.date_utils.ugettext'
,
fake_ugettext
(
translations
=
{
"DATE_TIME_FORMAT"
:
"
%
b
%
d,
%
Y at
%
H:
%
M"
,
}))
def
test_end_datetime_no_due_date
(
self
):
"""verify that without a due date, the end date is an empty string"""
expected
=
''
actual
=
self
.
ccx
.
end_datetime_text
()
# pylint: disable=no-member
self
.
assertEqual
(
expected
,
actual
)
actual
=
self
.
ccx
.
end_datetime_text
(
'DATE_TIME'
)
# pylint: disable=no-member
self
.
assertEqual
(
expected
,
actual
)
lms/templates/ccx/_dashboard_ccx_listing.html
View file @
99fbc890
<
%
page
args=
"ccx, membership, course"
/>
<
%
page
args=
"ccx, membership, course
, show_courseware_link, is_course_blocked
"
/>
<
%!
from
django
.
utils
.
translation
import
ugettext
as
_
%
>
<
%!
...
...
@@ -10,20 +10,70 @@
%
>
<li
class=
"course-item"
>
<article
class=
"course"
>
<a
href=
"${ccx_switch_target}"
class=
"cover"
>
<img
src=
"${course_image_url(course)}"
alt=
"${_('{course_number} {ccx_name} Cover Image').format(course_number=course.number, ccx_name=ccx.display_name) |h}"
/>
</a>
<section
class=
"info"
>
<hgroup>
<p
class=
"date-block"
>
Custom Course
</p>
<h2
class=
"university"
>
${get_course_about_section(course, 'university')}
</h2>
<h3>
<a
href=
"${ccx_switch_target}"
>
${course.display_number_with_default | h} ${ccx.display_name}
</a>
<section
class=
"details"
>
<div
class=
"wrapper-course-image"
aria-hidden=
"true"
>
% if show_courseware_link:
% if not is_course_blocked:
<a
href=
"${ccx_switch_target}"
class=
"cover"
>
<img
src=
"${course_image_url(course)}"
class=
"course-image"
alt=
"${_('{course_number} {ccx_name} Cover Image').format(course_number=course.number, ccx_name=ccx.display_name) |h}"
/>
</a>
% else:
<a
class=
"fade-cover"
>
<img
src=
"${course_image_url(course)}"
class=
"course-image"
alt=
"${_('{course_number} {ccx_name} Cover Image').format(course_number=course.number, ccx_name=ccx.display_name) |h}"
/>
</a>
% endif
% else:
<a
class=
"cover"
>
<img
src=
"${course_image_url(course)}"
class=
"course-image"
alt=
"${_('{course_number} {ccx_name} Cover Image').format(course_number=course.number, ccx_name=ccx.display_name) |h}"
/>
</a>
% endif
</div>
<div
class=
"wrapper-course-details"
>
<h3
class=
"course-title"
>
% if show_courseware_link:
% if not is_course_blocked:
<a
href=
"${ccx_switch_target}"
>
${ccx.display_name}
</a>
% else:
<a
class=
"disable-look"
>
${ccx.display_name}
</a>
% endif
% else:
<span>
${ccx.display_name}
</span>
% endif
</h3>
</hgroup>
<a
href=
"${ccx_switch_target}"
class=
"enter-course"
>
${_('View Course')}
</a>
<div
class=
"course-info"
>
<span
class=
"info-university"
>
${get_course_about_section(course, 'university')} -
</span>
<span
class=
"info-course-id"
>
${course.display_number_with_default | h}
</span>
<span
class=
"info-date-block"
data-tooltip=
"Hi"
>
% if ccx.has_ended():
${_("Ended - {end_date}").format(end_date=ccx.end_datetime_text("SHORT_DATE"))}
% elif ccx.has_started():
${_("Started - {start_date}").format(start_date=ccx.start_datetime_text("SHORT_DATE"))}
% else: # hasn't started yet
${_("Starts - {start_date}").format(start_date=ccx.start_datetime_text("SHORT_DATE"))}
% endif
</span>
</div>
% if show_courseware_link:
<div
class=
"wrapper-course-actions"
>
<div
class=
"course-actions"
>
% if ccx.has_ended():
% if not is_course_blocked:
<a
href=
"${ccx_switch_target}"
class=
"enter-course archived"
>
${_('View Archived Custom Course')}
<span
class=
"sr"
>
${ccx.display_name}
</span></a>
% else:
<a
class=
"enter-course-blocked archived"
>
${_('View Archived Custom Course')}
<span
class=
"sr"
>
${ccx.display_name}
</span></a>
% endif
% else:
% if not is_course_blocked:
<a
href=
"${ccx_switch_target}"
class=
"enter-course"
>
${_('View Custom Course')}
<span
class=
"sr"
>
${ccx.display_name}
</span></a>
% else:
<a
class=
"enter-course-blocked"
>
${_('View Custom Course')}
<span
class=
"sr"
>
${ccx.display_name}
</span></a>
% endif
% endif
</div>
</div>
% endif
</div>
</section>
</article>
</li>
lms/templates/dashboard.html
View file @
99fbc890
...
...
@@ -89,7 +89,9 @@
% if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
% for ccx, membership, course in ccx_membership_triplets:
<
%
include
file=
'ccx/_dashboard_ccx_listing.html'
args=
"ccx=ccx, membership=membership, course=course"
/>
<
%
show_courseware_link =
ccx.has_started()
%
>
<
%
is_course_blocked =
False
%
>
<
%
include
file=
'ccx/_dashboard_ccx_listing.html'
args=
"ccx=ccx, membership=membership, course=course, show_courseware_link=show_courseware_link, is_course_blocked=is_course_blocked"
/>
% endfor
% endif
...
...
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