diff --git a/lms/djangoapps/ccx/models.py b/lms/djangoapps/ccx/models.py index 048b63d9c2..9b0e85d564 100644 --- a/lms/djangoapps/ccx/models.py +++ b/lms/djangoapps/ccx/models.py @@ -1,11 +1,21 @@ """ 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 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") +_MARKER = object() class CustomCourseForEdX(models.Model): @@ -16,6 +26,71 @@ class CustomCourseForEdX(models.Model): display_name = models.CharField(max_length=255) coach = models.ForeignKey(User, db_index=True) + _course = None + _start = None + _due = _MARKER + + @property + def course(self): + if self._course is None: + store = modulestore() + with store.bulk_operations(self.course_id): + course = store.get_course(self.course_id) + if course and not isinstance(course, ErrorDescriptor): + self._course = course + else: + 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 self._course + + @property + def start(self): + if self._start is None: + # avoid circular import problems + from .overrides import get_override_for_ccx + start = get_override_for_ccx(self, self.course, 'start') + self._start = start + return self._start + + @property + def due(self): + if self._due is _MARKER: + # avoid circular import problems + from .overrides import get_override_for_ccx + due = get_override_for_ccx(self, self.course, 'due') + self._due = due + return self._due + + def has_started(self): + return datetime.now(UTC()) > self.start + + def has_ended(self): + if self.due is None: + return False + + return datetime.now(UTC()) > self.due + + def start_datetime_text(self, format_string="SHORT_DATE"): + 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"): + 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): """ diff --git a/lms/djangoapps/ccx/tests/test_models.py b/lms/djangoapps/ccx/tests/test_models.py index 49689c4133..5e2d2e31ea 100644 --- a/lms/djangoapps/ccx/tests/test_models.py +++ b/lms/djangoapps/ccx/tests/test_models.py @@ -1,6 +1,8 @@ """ tests for the models """ +from datetime import datetime, timedelta +from django.utils.timezone import UTC 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 @@ -9,7 +11,10 @@ from student.tests.factories import ( # pylint: disable=import-error UserFactory, ) 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 +24,7 @@ from ..models import ( CcxMembership, CcxFutureMembership, ) +from ..overrides import override_field_for_ccx class TestCcxMembership(ModuleStoreTestCase): @@ -125,3 +131,160 @@ 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): + 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): + self.ccx.course + with check_mongo_calls(0): + self.ccx.course + + 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 + diff = expected - actual + self.assertEqual(diff.seconds, 0) + + 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): + self.ccx.start + with check_mongo_calls(0): + self.ccx.start + + def test_ccx_due_without_override(self): + """verify that due returns None when the field has not been set""" + expected = None + actual = self.ccx.due + self.assertTrue(expected is 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 + diff = expected - actual + self.assertEqual(diff.seconds, 0) + + 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): + self.ccx.due + with check_mongo_calls(0): + self.ccx.due + + 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()) + + 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()) + + 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()) + + 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()) + + 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()) + + 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()) + # This relies on SHORT_DATE remaining the same, is there a better way? + expected = "Jan 01, 2015" + self.set_ccx_override('start', start) + actual = self.ccx.start_datetime_text() + self.assertEqual(expected, actual) + + 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()) + # This relies on SHORT_DATE remaining the same, is there a better way? + expected = "Jan 01, 2015 at 12:00 UTC" + self.set_ccx_override('start', start) + actual = self.ccx.start_datetime_text('DATE_TIME') + self.assertEqual(expected, actual) + + 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()) + # This relies on SHORT_DATE remaining the same, is there a better way? + expected = "Jan 01, 2015" + self.set_ccx_override('due', end) + actual = self.ccx.end_datetime_text() + self.assertEqual(expected, actual) + + 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()) + # This relies on SHORT_DATE remaining the same, is there a better way? + expected = "Jan 01, 2015 at 12:00 UTC" + self.set_ccx_override('due', end) + actual = self.ccx.end_datetime_text('DATE_TIME') + self.assertEqual(expected, actual) + + 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() + self.assertEqual(expected, actual) + actual = self.ccx.end_datetime_text('DATE_TIME') + self.assertEqual(expected, actual) diff --git a/lms/templates/ccx/_dashboard_ccx_listing.html b/lms/templates/ccx/_dashboard_ccx_listing.html index 8285a37ec0..e06e676ee1 100644 --- a/lms/templates/ccx/_dashboard_ccx_listing.html +++ b/lms/templates/ccx/_dashboard_ccx_listing.html @@ -1,4 +1,4 @@ -<%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 @@ %>
  • - - ${_('{course_number} {ccx_name} Cover Image').format(course_number=course.number, ccx_name=ccx.display_name) |h} - -
    -
    -

    - Custom Course -

    -

    ${get_course_about_section(course, 'university')}

    -

    - ${course.display_number_with_default | h} ${ccx.display_name} +
    + +
    +

    + % if show_courseware_link: + % if not is_course_blocked: + ${ccx.display_name} + % else: + ${ccx.display_name} + % endif + % else: + ${ccx.display_name} + % endif

    -

    - ${_('View Course')} +
    + ${get_course_about_section(course, 'university')} - + ${course.display_number_with_default | h} + + % 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 + +
    + % if show_courseware_link: +
    +
    + % if ccx.has_ended(): + % if not is_course_blocked: + ${_('View Archived Custom Course')} ${ccx.display_name} + % else: + ${_('View Archived Custom Course')} ${ccx.display_name} + % endif + % else: + % if not is_course_blocked: + ${_('View Custom Course')} ${ccx.display_name} + % else: + ${_('View Custom Course')} ${ccx.display_name} + % endif + % endif + +
    +
    + % endif +
  • diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 1c10c822fc..b33502fbfe 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -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