diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 1a9648835e4f4835f2888e1104c3599b69676130..61b49e6022c131229d85a5bdd657524e442d83a1 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -27,7 +27,7 @@ from bs4 import BeautifulSoup from django.core.cache import cache from django_future.csrf import ensure_csrf_cookie, csrf_exempt -from student.models import (Registration, UserProfile, TestCenterUser, TestCenterUserForm, +from student.models import (Registration, UserProfile, TestCenterUser, TestCenterUserForm, TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, @@ -42,7 +42,7 @@ from xmodule.modulestore.django import modulestore #from datetime import date from collections import namedtuple -from courseware.courses import get_courses +from courseware.courses import get_courses, sort_by_announcement from courseware.access import has_access from statsd import statsd @@ -78,10 +78,7 @@ def index(request, extra_context={}, user=None): domain = request.META.get('HTTP_HOST') courses = get_courses(None, domain=domain) - - # Sort courses by how far are they from they start day - key = lambda course: course.days_until_start - courses = sorted(courses, key=key, reverse=True) + courses = sort_by_announcement(courses) # Get the 3 most recent news top_news = _get_news(top=3) @@ -211,7 +208,7 @@ def _cert_info(user, course, cert_status): def dashboard(request): user = request.user enrollments = CourseEnrollment.objects.filter(user=user) - + # Build our courses list for the user, but ignore any courses that no longer # exist (because the course IDs have changed). Still, we don't delete those # enrollments, because it could have been a data push snafu. @@ -473,7 +470,7 @@ def _do_create_account(post_vars): except (ValueError, KeyError): # If they give us garbage, just ignore it instead # of asking them to put an integer. - profile.year_of_birth = None + profile.year_of_birth = None try: profile.save() except Exception: @@ -613,7 +610,7 @@ def exam_registration_info(user, course): exam_info = course.current_test_center_exam if exam_info is None: return None - + exam_code = exam_info.exam_series_code registrations = get_testcenter_registration(user, course.id, exam_code) if registrations: @@ -621,7 +618,7 @@ def exam_registration_info(user, course): else: registration = None return registration - + @login_required @ensure_csrf_cookie def begin_exam_registration(request, course_id): @@ -647,7 +644,7 @@ def begin_exam_registration(request, course_id): # determine if the user is registered for this course: registration = exam_registration_info(user, course) - + # we want to populate the registration page with the relevant information, # if it already exists. Create an empty object otherwise. try: @@ -655,7 +652,7 @@ def begin_exam_registration(request, course_id): except TestCenterUser.DoesNotExist: testcenteruser = TestCenterUser() testcenteruser.user = user - + context = {'course': course, 'user': user, 'testcenteruser': testcenteruser, @@ -672,8 +669,8 @@ def create_exam_registration(request, post_override=None): Called by form in test_center_register.html ''' post_vars = post_override if post_override else request.POST - - # first determine if we need to create a new TestCenterUser, or if we are making any update + + # first determine if we need to create a new TestCenterUser, or if we are making any update # to an existing TestCenterUser. username = post_vars['username'] user = User.objects.get(username=username) @@ -686,10 +683,10 @@ def create_exam_registration(request, post_override=None): for fieldname in TestCenterUser.user_provided_fields(): if fieldname in post_vars: demographic_data[fieldname] = (post_vars[fieldname]).strip() - + try: testcenter_user = TestCenterUser.objects.get(user=user) - needs_updating = testcenter_user.needs_update(demographic_data) + needs_updating = testcenter_user.needs_update(demographic_data) log.info("User {0} enrolled in course {1} {2}updating demographic info for exam registration".format(user.username, course_id, "" if needs_updating else "not ")) except TestCenterUser.DoesNotExist: # do additional initialization here: @@ -699,7 +696,7 @@ def create_exam_registration(request, post_override=None): # perform validation: if needs_updating: - # first perform validation on the user information + # first perform validation on the user information # using a Django Form. form = TestCenterUserForm(instance=testcenter_user, data=demographic_data) if form.is_valid(): @@ -710,7 +707,7 @@ def create_exam_registration(request, post_override=None): response_data['field_errors'] = form.errors response_data['non_field_errors'] = form.non_field_errors() return HttpResponse(json.dumps(response_data), mimetype="application/json") - + # create and save the registration: needs_saving = False exam = course.current_test_center_exam @@ -720,12 +717,12 @@ def create_exam_registration(request, post_override=None): registration = registrations[0] # NOTE: we do not bother to check here to see if the registration has changed, # because at the moment there is no way for a user to change anything about their - # registration. They only provide an optional accommodation request once, and + # registration. They only provide an optional accommodation request once, and # cannot make changes to it thereafter. # It is possible that the exam_info content has been changed, such as the # scheduled exam dates, but those kinds of changes should not be handled through - # this registration screen. - + # this registration screen. + else: accommodation_request = post_vars.get('accommodation_request','') registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request) @@ -733,7 +730,7 @@ def create_exam_registration(request, post_override=None): log.info("User {0} enrolled in course {1} creating new exam registration".format(user.username, course_id)) if needs_saving: - # do validation of registration. (Mainly whether an accommodation request is too long.) + # do validation of registration. (Mainly whether an accommodation request is too long.) form = TestCenterRegistrationForm(instance=registration, data=post_vars) if form.is_valid(): form.update_and_save() @@ -743,14 +740,14 @@ def create_exam_registration(request, post_override=None): response_data['field_errors'] = form.errors response_data['non_field_errors'] = form.non_field_errors() return HttpResponse(json.dumps(response_data), mimetype="application/json") - + # only do the following if there is accommodation text to send, # and a destination to which to send it. # TODO: still need to create the accommodation email templates # if 'accommodation_request' in post_vars and 'TESTCENTER_ACCOMMODATION_REQUEST_EMAIL' in settings: # d = {'accommodation_request': post_vars['accommodation_request'] } -# +# # # composes accommodation email # subject = render_to_string('emails/accommodation_email_subject.txt', d) # # Email subject *must not* contain newlines diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 499247cc2d2a368a45fbabf7e3e7cd3157bc58f5..bc171ca5b96b0d36f67e29c3a0b75706a90acdbb 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -1,4 +1,5 @@ import logging +from math import exp, erf from lxml import etree from path import path # NOTE (THK): Only used for detecting presence of syllabus import requests @@ -183,35 +184,66 @@ class CourseDescriptor(SequenceDescriptor): @property def is_new(self): - # The course is "new" if either if the metadata flag is_new is - # true or if the course has not started yet + """ + Returns if the course has been flagged as new in the metadata. If + there is no flag, return a heuristic value considering the + announcement and the start dates. + """ flag = self.metadata.get('is_new', None) if flag is None: - return self.days_until_start > 1 + # Use a heuristic if the course has not been flagged + announcement, start, now = self._sorting_dates() + if announcement and (now - announcement).days < 30: + # The course has been announced for less that month + return True + elif (now - start).days < 1: + # The course has not started yet + return True + else: + return False elif isinstance(flag, basestring): return flag.lower() in ['true', 'yes', 'y'] else: return bool(flag) @property - def days_until_start(self): - def convert_to_datetime(timestamp): + def sorting_score(self): + """ + Returns a number that can be used to sort the courses according + the how "new"" they are. The "newness"" score is computed using a + heuristic that takes into account the announcement and + (advertized) start dates of the course if available. + + The lower the number the "newer" the course. + """ + # Make courses that have an announcement date shave a lower + # score than courses than don't, older courses should have a + # higher score. + announcement, start, now = self._sorting_dates() + scale = 300.0 # about a year + if announcement: + days = (now - announcement).days + score = -exp(-days/scale) + else: + days = (now - start).days + score = exp(days/scale) + return score + + def _sorting_dates(self): + # utility function to get datetime objects for dates used to + # compute the is_new flag and the sorting_score + def to_datetime(timestamp): return datetime.fromtimestamp(time.mktime(timestamp)) - start_date = convert_to_datetime(self.start) + def get_date(field): + timetuple = self._try_parse_time(field) + return to_datetime(timetuple) if timetuple else None - # Try to use course advertised date if we can parse it - advertised_start = self.metadata.get('advertised_start', None) - if advertised_start: - try: - start_date = datetime.strptime(advertised_start, - "%Y-%m-%dT%H:%M") - except ValueError: - pass # Invalid date, keep using 'start'' + announcement = get_date('announcement') + start = get_date('advertised_start') or to_datetime(self.start) + now = to_datetime(time.gmtime()) - now = convert_to_datetime(time.gmtime()) - days_until_start = (start_date - now).days - return days_until_start + return announcement, start, now @lazyproperty def grading_context(self): @@ -387,9 +419,9 @@ class CourseDescriptor(SequenceDescriptor): self.first_eligible_appointment_date = self._try_parse_time('First_Eligible_Appointment_Date') if self.first_eligible_appointment_date is None: raise ValueError("First appointment date must be specified") - # TODO: If defaulting the last appointment date, it should be the + # TODO: If defaulting the last appointment date, it should be the # *end* of the same day, not the same time. It's going to be used as the - # end of the exam overall, so we don't want the exam to disappear too soon. + # end of the exam overall, so we don't want the exam to disappear too soon. # It's also used optionally as the registration end date, so time matters there too. self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date if self.last_eligible_appointment_date is None: @@ -403,7 +435,7 @@ class CourseDescriptor(SequenceDescriptor): raise ValueError("First appointment date must be before last appointment date") if self.registration_end_date > self.last_eligible_appointment_date: raise ValueError("Registration end date must be before last appointment date") - + def _try_parse_time(self, key): """ @@ -434,7 +466,7 @@ class CourseDescriptor(SequenceDescriptor): def is_registering(self): now = time.gmtime() return now >= self.registration_start_date and now <= self.registration_end_date - + @property def first_eligible_appointment_date_text(self): return time.strftime("%b %d, %Y", self.first_eligible_appointment_date) @@ -451,7 +483,7 @@ class CourseDescriptor(SequenceDescriptor): def current_test_center_exam(self): exams = [exam for exam in self.test_center_exams if exam.has_started_registration() and not exam.has_ended()] if len(exams) > 1: - # TODO: output some kind of warning. This should already be + # TODO: output some kind of warning. This should already be # caught if we decide to do validation at load time. return exams[0] elif len(exams) == 1: diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index 63eaec1f61926a90675bcbdcb71f28c49c2f06fd..712b095696a68b7ecb52067d74856555a9868f8b 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -1,5 +1,5 @@ import unittest -from time import strptime, gmtime +from time import strptime from fs.memoryfs import MemoryFS from mock import Mock, patch @@ -39,52 +39,81 @@ class DummySystem(ImportSystem): class IsNewCourseTestCase(unittest.TestCase): """Make sure the property is_new works on courses""" @staticmethod - def get_dummy_course(start, is_new=None, load_error_modules=True): + def get_dummy_course(start, announcement=None, is_new=None): """Get a dummy course""" - system = DummySystem(load_error_modules) - is_new = '' if is_new is None else 'is_new="{0}"'.format(is_new).lower() + system = DummySystem(load_error_modules=True) + + def to_attrb(n, v): + return '' if v is None else '{0}="{1}"'.format(n, v).lower() + + is_new = to_attrb('is_new', is_new) + announcement = to_attrb('announcement', announcement) start_xml = ''' <course org="{org}" course="{course}" graceperiod="1 day" url_name="test" start="{start}" + {announcement} {is_new}> <chapter url="hi" url_name="ch" display_name="CH"> <html url_name="h" display_name="H">Two houses, ...</html> </chapter> </course> - '''.format(org=ORG, course=COURSE, start=start, is_new=is_new) + '''.format(org=ORG, course=COURSE, start=start, is_new=is_new, + announcement=announcement) return system.process_xml(start_xml) @patch('xmodule.course_module.time.gmtime') - def test_non_started_yet(self, gmtime_mock): - descriptor = self.get_dummy_course(start='2013-01-05T12:00') - gmtime_mock.return_value = NOW - assert(descriptor.is_new == True) - assert(descriptor.days_until_start == 4) - - @patch('xmodule.course_module.time.gmtime') - def test_already_started(self, gmtime_mock): + def test_sorting_score(self, gmtime_mock): gmtime_mock.return_value = NOW + dates = [('2012-10-01T12:00', '2012-09-01T12:00'), # 0 + ('2012-12-01T12:00', '2012-11-01T12:00'), # 1 + ('2013-02-01T12:00', '2012-12-01T12:00'), # 2 + ('2013-02-01T12:00', '2012-11-10T12:00'), # 3 + ('2013-02-01T12:00', None), # 4 + ('2013-03-01T12:00', None), # 5 + ('2013-04-01T12:00', None), # 6 + ('2012-11-01T12:00', None), # 7 + ('2012-09-01T12:00', None), # 8 + ('1990-01-01T12:00', None), # 9 + ('2013-01-02T12:00', None), # 10 + ('2013-01-10T12:00', '2012-12-31T12:00'), # 11 + ('2013-01-10T12:00', '2013-01-01T12:00'), # 12 + ] + + data = [] + for i, d in enumerate(dates): + descriptor = self.get_dummy_course(start=d[0], announcement=d[1]) + score = descriptor.sorting_score + data.append((score, i)) + + result = [d[1] for d in sorted(data)] + assert(result == [12, 11, 2, 3, 1, 0, 6, 5, 4, 10, 7, 8, 9]) - descriptor = self.get_dummy_course(start='2012-12-02T12:00') - assert(descriptor.is_new == False) - assert(descriptor.days_until_start < 0) @patch('xmodule.course_module.time.gmtime') - def test_is_new_set(self, gmtime_mock): + def test_is_new(self, gmtime_mock): gmtime_mock.return_value = NOW descriptor = self.get_dummy_course(start='2012-12-02T12:00', is_new=True) - assert(descriptor.is_new == True) - assert(descriptor.days_until_start < 0) + assert(descriptor.is_new is True) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=False) - assert(descriptor.is_new == False) - assert(descriptor.days_until_start > 0) + assert(descriptor.is_new is False) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=True) - assert(descriptor.is_new == True) - assert(descriptor.days_until_start > 0) + assert(descriptor.is_new is True) + + descriptor = self.get_dummy_course(start='2013-01-15T12:00') + assert(descriptor.is_new is True) + + descriptor = self.get_dummy_course(start='2013-03-00T12:00') + assert(descriptor.is_new is True) + + descriptor = self.get_dummy_course(start='2012-10-15T12:00') + assert(descriptor.is_new is False) + + descriptor = self.get_dummy_course(start='2012-12-31T12:00') + assert(descriptor.is_new is True) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 7c0d30ebd8bcb526386dedb0c73152cd567a55b0..1090c208d174a4c140cb81f991207d0159311722 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -64,6 +64,7 @@ def course_image_url(course): path = course.metadata['data_dir'] + "/images/course_image.jpg" return try_staticfiles_lookup(path) + def find_file(fs, dirs, filename): """ Looks for a filename in a list of dirs on a filesystem, in the specified order. @@ -80,6 +81,7 @@ def find_file(fs, dirs, filename): return filepath raise ResourceNotFoundError("Could not find {0}".format(filename)) + def get_course_about_section(course, section_key): """ This returns the snippet of html to be rendered on the course about page, @@ -234,4 +236,18 @@ def get_courses(user, domain=None): courses = [c for c in courses if has_access(user, c, 'see_exists')] courses = sorted(courses, key=lambda course:course.number) + + return courses + + +def sort_by_announcement(courses): + """ + Sorts a list of courses by their announcement date. If the date is + not available, sort them by their start date. + """ + + # Sort courses by how far are they from they start day + key = lambda course: course.sorting_score + courses = sorted(courses, key=key) + return courses diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 9e52e2b281645ed1436a6d3d3bcee03d960aef84..b3775eb663ac9220928a0811b4d8c319c537ba3f 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -17,7 +17,8 @@ from django.views.decorators.cache import cache_control from courseware import grades from courseware.access import has_access -from courseware.courses import (get_courses, get_course_with_access, get_courses_by_university) +from courseware.courses import (get_courses, get_course_with_access, + get_courses_by_university, sort_by_announcement) import courseware.tabs as tabs from courseware.models import StudentModuleCache from module_render import toc_for_course, get_module, get_instance_module @@ -67,11 +68,8 @@ def courses(request): ''' Render "find courses" page. The course selection work is done in courseware.courses. ''' - courses = get_courses(request.user, domain=request.META.get('HTTP_HOST')) - - # Sort courses by how far are they from they start day - key = lambda course: course.days_until_start - courses = sorted(courses, key=key, reverse=True) + courses = get_courses(request.user, request.META.get('HTTP_HOST')) + courses = sort_by_announcement(courses) return render_to_response("courseware/courses.html", {'courses': courses}) @@ -438,10 +436,7 @@ def university_profile(request, org_id): # Only grab courses for this org... courses = get_courses_by_university(request.user, domain=request.META.get('HTTP_HOST'))[org_id] - - # Sort courses by how far are they from they start day - key = lambda course: course.days_until_start - courses = sorted(courses, key=key, reverse=True) + courses = sort_by_announcement(courses) context = dict(courses=courses, org_id=org_id) template_file = "university_profile/{0}.html".format(org_id).lower()