From 24301d2a0761510143f7bc62bc9d7d0d01abd5ca Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:30:31 -0400 Subject: [PATCH 01/25] Moved helper functions from terrain/steps.py to terrain/helpers.py --- common/djangoapps/terrain/helpers.py | 152 +++++++++++++++++++++++++ common/djangoapps/terrain/steps.py | 164 ++------------------------- 2 files changed, 159 insertions(+), 157 deletions(-) create mode 100644 common/djangoapps/terrain/helpers.py diff --git a/common/djangoapps/terrain/helpers.py b/common/djangoapps/terrain/helpers.py new file mode 100644 index 0000000000..55c8f3db5a --- /dev/null +++ b/common/djangoapps/terrain/helpers.py @@ -0,0 +1,152 @@ +from lettuce import world, step +from .factories import * +from django.conf import settings +from django.http import HttpRequest +from django.contrib.auth.models import User +from django.contrib.auth import authenticate, login +from django.contrib.auth.middleware import AuthenticationMiddleware +from django.contrib.sessions.middleware import SessionMiddleware +from student.models import CourseEnrollment +from bs4 import BeautifulSoup +import os.path +from selenium.common.exceptions import WebDriverException +from urllib import quote_plus +from lettuce.django import django_url + +@world.absorb +def wait(seconds): + time.sleep(float(seconds)) + +@world.absorb +def scroll_to_bottom(): + # Maximize the browser + world.browser.execute_script("window.scrollTo(0, screen.height);") + + +@world.absorb +def create_user(uname): + + # If the user already exists, don't try to create it again + if len(User.objects.filter(username=uname)) > 0: + return + + portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') + portal_user.set_password('test') + portal_user.save() + + registration = world.RegistrationFactory(user=portal_user) + registration.register(portal_user) + registration.activate() + + user_profile = world.UserProfileFactory(user=portal_user) + + +@world.absorb +def log_in(username, password): + ''' + Log the user in programatically + ''' + + # Authenticate the user + user = authenticate(username=username, password=password) + assert(user is not None and user.is_active) + + # Send a fake HttpRequest to log the user in + # We need to process the request using + # Session middleware and Authentication middleware + # to ensure that session state can be stored + request = HttpRequest() + SessionMiddleware().process_request(request) + AuthenticationMiddleware().process_request(request) + login(request, user) + + # Save the session + request.session.save() + + # Retrieve the sessionid and add it to the browser's cookies + cookie_dict = {settings.SESSION_COOKIE_NAME: request.session.session_key} + try: + world.browser.cookies.add(cookie_dict) + + # WebDriver has an issue where we cannot set cookies + # before we make a GET request, so if we get an error, + # we load the '/' page and try again + except: + world.browser.visit(django_url('/')) + world.browser.cookies.add(cookie_dict) + + +@world.absorb +def register_by_course_id(course_id, is_staff=False): + create_user('robot') + u = User.objects.get(username='robot') + if is_staff: + u.is_staff = True + u.save() + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + + +@world.absorb +def save_the_html(path='/tmp'): + u = world.browser.url + html = world.browser.html.encode('ascii', 'ignore') + filename = '%s.html' % quote_plus(u) + f = open('%s/%s' % (path, filename), 'w') + f.write(html) + f.close + + +@world.absorb +def save_the_course_content(path='/tmp'): + html = world.browser.html.encode('ascii', 'ignore') + soup = BeautifulSoup(html) + + # get rid of the header, we only want to compare the body + soup.head.decompose() + + # for now, remove the data-id attributes, because they are + # causing mismatches between cms-master and master + for item in soup.find_all(attrs={'data-id': re.compile('.*')}): + del item['data-id'] + + # we also need to remove them from unrendered problems, + # where they are contained in the text of divs instead of + # in attributes of tags + # Be careful of whether or not it was the last attribute + # and needs a trailing space + for item in soup.find_all(text=re.compile(' data-id=".*?" ')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s)) + + for item in soup.find_all(text=re.compile(' data-id=".*?"')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?"', ' ', s)) + + # prettify the html so it will compare better, with + # each HTML tag on its own line + output = soup.prettify() + + # use string slicing to grab everything after 'courseware/' in the URL + u = world.browser.url + section_url = u[u.find('courseware/') + 11:] + + + if not os.path.exists(path): + os.makedirs(path) + + filename = '%s.html' % (quote_plus(section_url)) + f = open('%s/%s' % (path, filename), 'w') + f.write(output) + f.close + +@world.absorb +def css_click(css_selector): + try: + world.browser.find_by_css(css_selector).click() + + except WebDriverException: + # Occassionally, MathJax or other JavaScript can cover up + # an element temporarily. + # If this happens, wait a second, then try again + time.sleep(1) + world.browser.find_by_css(css_selector).click() diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 3bc838a6af..ae36227fee 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -1,20 +1,8 @@ from lettuce import world, step -from .factories import * +from .helpers import * from lettuce.django import django_url -from django.conf import settings -from django.http import HttpRequest -from django.contrib.auth.models import User -from django.contrib.auth import authenticate, login -from django.contrib.auth.middleware import AuthenticationMiddleware -from django.contrib.sessions.middleware import SessionMiddleware -from student.models import CourseEnrollment -from urllib import quote_plus from nose.tools import assert_equals -from bs4 import BeautifulSoup import time -import re -import os.path -from selenium.common.exceptions import WebDriverException from logging import getLogger logger = getLogger(__name__) @@ -22,8 +10,7 @@ logger = getLogger(__name__) @step(u'I wait (?:for )?"(\d+)" seconds?$') def wait(step, seconds): - time.sleep(float(seconds)) - + world.wait(seconds) @step('I reload the page$') def reload_the_page(step): @@ -87,8 +74,8 @@ def the_page_title_should_contain(step, title): @step('I am a logged in user$') def i_am_logged_in_user(step): - create_user('robot') - log_in('robot', 'test') + world.create_user('robot') + world.log_in('robot', 'test') @step('I am not logged in$') @@ -98,151 +85,14 @@ def i_am_not_logged_in(step): @step('I am staff for course "([^"]*)"$') def i_am_staff_for_course_by_id(step, course_id): - register_by_course_id(course_id, True) + world.register_by_course_id(course_id, True) @step('I log in$') def i_log_in(step): - log_in('robot', 'test') + world.log_in('robot', 'test') @step(u'I am an edX user$') def i_am_an_edx_user(step): - create_user('robot') - -#### helper functions - - -@world.absorb -def scroll_to_bottom(): - # Maximize the browser - world.browser.execute_script("window.scrollTo(0, screen.height);") - - -@world.absorb -def create_user(uname): - - # If the user already exists, don't try to create it again - if len(User.objects.filter(username=uname)) > 0: - return - - portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') - portal_user.set_password('test') - portal_user.save() - - registration = world.RegistrationFactory(user=portal_user) - registration.register(portal_user) - registration.activate() - - user_profile = world.UserProfileFactory(user=portal_user) - - -@world.absorb -def log_in(username, password): - ''' - Log the user in programatically - ''' - - # Authenticate the user - user = authenticate(username=username, password=password) - assert(user is not None and user.is_active) - - # Send a fake HttpRequest to log the user in - # We need to process the request using - # Session middleware and Authentication middleware - # to ensure that session state can be stored - request = HttpRequest() - SessionMiddleware().process_request(request) - AuthenticationMiddleware().process_request(request) - login(request, user) - - # Save the session - request.session.save() - - # Retrieve the sessionid and add it to the browser's cookies - cookie_dict = {settings.SESSION_COOKIE_NAME: request.session.session_key} - try: - world.browser.cookies.add(cookie_dict) - - # WebDriver has an issue where we cannot set cookies - # before we make a GET request, so if we get an error, - # we load the '/' page and try again - except: - world.browser.visit(django_url('/')) - world.browser.cookies.add(cookie_dict) - - -@world.absorb -def register_by_course_id(course_id, is_staff=False): - create_user('robot') - u = User.objects.get(username='robot') - if is_staff: - u.is_staff = True - u.save() - CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) - - -@world.absorb -def save_the_html(path='/tmp'): - u = world.browser.url - html = world.browser.html.encode('ascii', 'ignore') - filename = '%s.html' % quote_plus(u) - f = open('%s/%s' % (path, filename), 'w') - f.write(html) - f.close - - -@world.absorb -def save_the_course_content(path='/tmp'): - html = world.browser.html.encode('ascii', 'ignore') - soup = BeautifulSoup(html) - - # get rid of the header, we only want to compare the body - soup.head.decompose() - - # for now, remove the data-id attributes, because they are - # causing mismatches between cms-master and master - for item in soup.find_all(attrs={'data-id': re.compile('.*')}): - del item['data-id'] - - # we also need to remove them from unrendered problems, - # where they are contained in the text of divs instead of - # in attributes of tags - # Be careful of whether or not it was the last attribute - # and needs a trailing space - for item in soup.find_all(text=re.compile(' data-id=".*?" ')): - s = unicode(item.string) - item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s)) - - for item in soup.find_all(text=re.compile(' data-id=".*?"')): - s = unicode(item.string) - item.string.replace_with(re.sub(' data-id=".*?"', ' ', s)) - - # prettify the html so it will compare better, with - # each HTML tag on its own line - output = soup.prettify() - - # use string slicing to grab everything after 'courseware/' in the URL - u = world.browser.url - section_url = u[u.find('courseware/') + 11:] - - - if not os.path.exists(path): - os.makedirs(path) - - filename = '%s.html' % (quote_plus(section_url)) - f = open('%s/%s' % (path, filename), 'w') - f.write(output) - f.close - -@world.absorb -def css_click(css_selector): - try: - world.browser.find_by_css(css_selector).click() - - except WebDriverException: - # Occassionally, MathJax or other JavaScript can cover up - # an element temporarily. - # If this happens, wait a second, then try again - time.sleep(1) - world.browser.find_by_css(css_selector).click() + world.create_user('robot') From 315b360e4cafeab3fec798272ed2e5ee22cb88d0 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:31:41 -0400 Subject: [PATCH 02/25] Fixed an import error in terrain/helpers.py --- common/djangoapps/terrain/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/common/djangoapps/terrain/helpers.py b/common/djangoapps/terrain/helpers.py index 55c8f3db5a..12d6818659 100644 --- a/common/djangoapps/terrain/helpers.py +++ b/common/djangoapps/terrain/helpers.py @@ -12,6 +12,7 @@ import os.path from selenium.common.exceptions import WebDriverException from urllib import quote_plus from lettuce.django import django_url +import time @world.absorb def wait(seconds): From e494d529fc48f21c1bb01bdee7dc8515035b6219 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:38:30 -0400 Subject: [PATCH 03/25] Split terrain/helpers.py into ui_helpers.py and course_helpers.py --- .../terrain/{helpers.py => course_helpers.py} | 32 ------------------- common/djangoapps/terrain/steps.py | 3 +- common/djangoapps/terrain/ui_helpers.py | 30 +++++++++++++++++ 3 files changed, 32 insertions(+), 33 deletions(-) rename common/djangoapps/terrain/{helpers.py => course_helpers.py} (82%) create mode 100644 common/djangoapps/terrain/ui_helpers.py diff --git a/common/djangoapps/terrain/helpers.py b/common/djangoapps/terrain/course_helpers.py similarity index 82% rename from common/djangoapps/terrain/helpers.py rename to common/djangoapps/terrain/course_helpers.py index 12d6818659..dbdaa2a21c 100644 --- a/common/djangoapps/terrain/helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -12,17 +12,6 @@ import os.path from selenium.common.exceptions import WebDriverException from urllib import quote_plus from lettuce.django import django_url -import time - -@world.absorb -def wait(seconds): - time.sleep(float(seconds)) - -@world.absorb -def scroll_to_bottom(): - # Maximize the browser - world.browser.execute_script("window.scrollTo(0, screen.height);") - @world.absorb def create_user(uname): @@ -87,15 +76,6 @@ def register_by_course_id(course_id, is_staff=False): CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) -@world.absorb -def save_the_html(path='/tmp'): - u = world.browser.url - html = world.browser.html.encode('ascii', 'ignore') - filename = '%s.html' % quote_plus(u) - f = open('%s/%s' % (path, filename), 'w') - f.write(html) - f.close - @world.absorb def save_the_course_content(path='/tmp'): @@ -139,15 +119,3 @@ def save_the_course_content(path='/tmp'): f = open('%s/%s' % (path, filename), 'w') f.write(output) f.close - -@world.absorb -def css_click(css_selector): - try: - world.browser.find_by_css(css_selector).click() - - except WebDriverException: - # Occassionally, MathJax or other JavaScript can cover up - # an element temporarily. - # If this happens, wait a second, then try again - time.sleep(1) - world.browser.find_by_css(css_selector).click() diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index ae36227fee..6e54b71aa6 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -1,5 +1,6 @@ from lettuce import world, step -from .helpers import * +from .course_helpers import * +from .ui_helpers import * from lettuce.django import django_url from nose.tools import assert_equals import time diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py new file mode 100644 index 0000000000..4667957e87 --- /dev/null +++ b/common/djangoapps/terrain/ui_helpers.py @@ -0,0 +1,30 @@ +from lettuce import world, step +import time +from urllib import quote_plus + +@world.absorb +def wait(seconds): + time.sleep(float(seconds)) + + +@world.absorb +def css_click(css_selector): + try: + world.browser.find_by_css(css_selector).click() + + except WebDriverException: + # Occassionally, MathJax or other JavaScript can cover up + # an element temporarily. + # If this happens, wait a second, then try again + time.sleep(1) + world.browser.find_by_css(css_selector).click() + +@world.absorb +def save_the_html(path='/tmp'): + u = world.browser.url + html = world.browser.html.encode('ascii', 'ignore') + filename = '%s.html' % quote_plus(u) + f = open('%s/%s' % (path, filename), 'w') + f.write(html) + f.close + From 0562f11c5622c94214162ac5c43fd69b8851601f Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:41:30 -0400 Subject: [PATCH 04/25] Fixed import issue with WebDriverException --- common/djangoapps/terrain/course_helpers.py | 1 - common/djangoapps/terrain/ui_helpers.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index dbdaa2a21c..8c949de1ad 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -9,7 +9,6 @@ from django.contrib.sessions.middleware import SessionMiddleware from student.models import CourseEnrollment from bs4 import BeautifulSoup import os.path -from selenium.common.exceptions import WebDriverException from urllib import quote_plus from lettuce.django import django_url diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 4667957e87..2ad7150740 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -1,6 +1,7 @@ from lettuce import world, step import time from urllib import quote_plus +from selenium.common.exceptions import WebDriverException @world.absorb def wait(seconds): From b0eb73302b9753acbc53f3ddc4fe86226f51292b Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:50:50 -0400 Subject: [PATCH 05/25] Moved some courseware/features/common.py steps into terrain/steps.py --- common/djangoapps/terrain/steps.py | 38 ++++++++- lms/djangoapps/courseware/features/common.py | 83 -------------------- 2 files changed, 36 insertions(+), 85 deletions(-) diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 6e54b71aa6..8356b5446d 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -72,6 +72,9 @@ def the_page_title_should_be(step, title): def the_page_title_should_contain(step, title): assert(title in world.browser.title) +@step('I log in$') +def i_log_in(step): + world.log_in('robot', 'test') @step('I am a logged in user$') def i_am_logged_in_user(step): @@ -89,11 +92,42 @@ def i_am_staff_for_course_by_id(step, course_id): world.register_by_course_id(course_id, True) -@step('I log in$') -def i_log_in(step): +@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') +def click_the_link_called(step, text): + world.browser.find_link_by_text(text).click() + + +@step(r'should see that the url is "([^"]*)"$') +def should_have_the_url(step, url): + assert_equals(world.browser.url, url) + +@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') +def should_see_a_link_called(step, text): + assert len(world.browser.find_link_by_text(text)) > 0 + +@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') +def should_see_in_the_page(step, text): + assert_in(text, world.browser.html) + + +@step('I am logged in$') +def i_am_logged_in(step): + world.create_user('robot') world.log_in('robot', 'test') + world.browser.visit(django_url('/')) + + +@step('I am not logged in$') +def i_am_not_logged_in(step): + world.browser.cookies.delete() @step(u'I am an edX user$') def i_am_an_edx_user(step): world.create_user('robot') + + +@step(u'User "([^"]*)" is an edX user$') +def registered_edx_user(step, uname): + world.create_user(uname) + diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index 7d41637c8e..8477347580 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -6,83 +6,10 @@ from student.models import CourseEnrollment from xmodule.modulestore import Location from xmodule.modulestore.django import _MODULESTORES, modulestore from xmodule.templates import update_templates -import time from logging import getLogger logger = getLogger(__name__) - -@step(u'I wait (?:for )?"(\d+)" seconds?$') -def wait(step, seconds): - time.sleep(float(seconds)) - - -@step('I (?:visit|access|open) the homepage$') -def i_visit_the_homepage(step): - world.browser.visit(django_url('/')) - assert world.browser.is_element_present_by_css('header.global', 10) - - -@step(u'I (?:visit|access|open) the dashboard$') -def i_visit_the_dashboard(step): - world.browser.visit(django_url('/dashboard')) - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) - - -@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') -def click_the_link_called(step, text): - world.browser.find_link_by_text(text).click() - - -@step('I should be on the dashboard page$') -def i_should_be_on_the_dashboard(step): - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) - assert world.browser.title == 'Dashboard' - - -@step(u'I (?:visit|access|open) the courses page$') -def i_am_on_the_courses_page(step): - world.browser.visit(django_url('/courses')) - assert world.browser.is_element_present_by_css('section.courses') - - -@step('I should see that the path is "([^"]*)"$') -def i_should_see_that_the_path_is(step, path): - assert world.browser.url == django_url(path) - - -@step(u'the page title should be "([^"]*)"$') -def the_page_title_should_be(step, title): - assert world.browser.title == title - - -@step(r'should see that the url is "([^"]*)"$') -def should_have_the_url(step, url): - assert_equals(world.browser.url, url) - - -@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') -def should_see_a_link_called(step, text): - assert len(world.browser.find_link_by_text(text)) > 0 - - -@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') -def should_see_in_the_page(step, text): - assert_in(text, world.browser.html) - - -@step('I am logged in$') -def i_am_logged_in(step): - world.create_user('robot') - world.log_in('robot', 'test') - world.browser.visit(django_url('/')) - - -@step('I am not logged in$') -def i_am_not_logged_in(step): - world.browser.cookies.delete() - - TEST_COURSE_ORG = 'edx' TEST_COURSE_NAME = 'Test Course' TEST_SECTION_NAME = "Problem" @@ -135,16 +62,6 @@ def add_tab_to_course(step, course, extra_tab_name): display_name=str(extra_tab_name)) -@step(u'I am an edX user$') -def i_am_an_edx_user(step): - world.create_user('robot') - - -@step(u'User "([^"]*)" is an edX user$') -def registered_edx_user(step, uname): - world.create_user(uname) - - def flush_xmodule_store(): # Flush and initialize the module store # It needs the templates because it creates new records From c12e1fb1cec0fabd3d825dc7f270381146b1a2e7 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:54:17 -0400 Subject: [PATCH 06/25] Added missing import statement --- common/djangoapps/terrain/steps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 8356b5446d..8dac372a64 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -2,7 +2,7 @@ from lettuce import world, step from .course_helpers import * from .ui_helpers import * from lettuce.django import django_url -from nose.tools import assert_equals +from nose.tools import assert_equals, assert_in import time from logging import getLogger From 5e69050a163fc19e6ce042b206e8a25f105ac509 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 12:01:55 -0400 Subject: [PATCH 07/25] Elminated unused functions from courseware/features/courses.py and moved the rest to common.py --- lms/djangoapps/courseware/features/common.py | 87 +++++++ lms/djangoapps/courseware/features/courses.py | 234 ------------------ .../courseware/features/smart-accordion.py | 2 +- 3 files changed, 88 insertions(+), 235 deletions(-) delete mode 100644 lms/djangoapps/courseware/features/courses.py diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index 8477347580..2d366d462d 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -6,6 +6,9 @@ from student.models import CourseEnrollment from xmodule.modulestore import Location from xmodule.modulestore.django import _MODULESTORES, modulestore from xmodule.templates import update_templates +from xmodule.course_module import CourseDescriptor +from courseware.courses import get_course_by_id +from xmodule import seq_module, vertical_module from logging import getLogger logger = getLogger(__name__) @@ -94,3 +97,87 @@ def section_location(course_num): course=course_num, category='sequential', name=TEST_SECTION_NAME.replace(" ", "_")) + + +def get_courses(): + ''' + Returns dict of lists of courses available, keyed by course.org (ie university). + Courses are sorted by course.number. + ''' + courses = [c for c in modulestore().get_courses() + if isinstance(c, CourseDescriptor)] + courses = sorted(courses, key=lambda course: course.number) + return courses + + +def get_courseware_with_tabs(course_id): + """ + Given a course_id (string), return a courseware array of dictionaries for the + top three levels of navigation. Same as get_courseware() except include + the tabs on the right hand main navigation page. + + This hides the appropriate courseware as defined by the hide_from_toc field: + chapter.lms.hide_from_toc + + Example: + + [{ + 'chapter_name': 'Overview', + 'sections': [{ + 'clickable_tab_count': 0, + 'section_name': 'Welcome', + 'tab_classes': [] + }, { + 'clickable_tab_count': 1, + 'section_name': 'System Usage Sequence', + 'tab_classes': ['VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Lab0: Using the tools', + 'tab_classes': ['HtmlDescriptor', 'HtmlDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Circuit Sandbox', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Week 1', + 'sections': [{ + 'clickable_tab_count': 4, + 'section_name': 'Administrivia and Circuit Elements', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Basic Circuit Analysis', + 'tab_classes': ['CapaDescriptor', 'CapaDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Resistor Divider', + 'tab_classes': [] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Week 1 Tutorials', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Midterm Exam', + 'sections': [{ + 'clickable_tab_count': 2, + 'section_name': 'Midterm Exam', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor'] + }] + }] + """ + + course = get_course_by_id(course_id) + chapters = [chapter for chapter in course.get_children() if not chapter.lms.hide_from_toc] + courseware = [{'chapter_name': c.display_name_with_default, + 'sections': [{'section_name': s.display_name_with_default, + 'clickable_tab_count': len(s.get_children()) if (type(s) == seq_module.SequenceDescriptor) else 0, + 'tabs': [{'children_count': len(t.get_children()) if (type(t) == vertical_module.VerticalDescriptor) else 0, + 'class': t.__class__.__name__} + for t in s.get_children()]} + for s in c.get_children() if not s.lms.hide_from_toc]} + for c in chapters] + + return courseware diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py deleted file mode 100644 index c99fb58b85..0000000000 --- a/lms/djangoapps/courseware/features/courses.py +++ /dev/null @@ -1,234 +0,0 @@ -from lettuce import world -from xmodule.course_module import CourseDescriptor -from xmodule.modulestore.django import modulestore -from courseware.courses import get_course_by_id -from xmodule import seq_module, vertical_module - -from logging import getLogger -logger = getLogger(__name__) - -## support functions - - -def get_courses(): - ''' - Returns dict of lists of courses available, keyed by course.org (ie university). - Courses are sorted by course.number. - ''' - courses = [c for c in modulestore().get_courses() - if isinstance(c, CourseDescriptor)] - courses = sorted(courses, key=lambda course: course.number) - return courses - - -def get_courseware_with_tabs(course_id): - """ - Given a course_id (string), return a courseware array of dictionaries for the - top three levels of navigation. Same as get_courseware() except include - the tabs on the right hand main navigation page. - - This hides the appropriate courseware as defined by the hide_from_toc field: - chapter.lms.hide_from_toc - - Example: - - [{ - 'chapter_name': 'Overview', - 'sections': [{ - 'clickable_tab_count': 0, - 'section_name': 'Welcome', - 'tab_classes': [] - }, { - 'clickable_tab_count': 1, - 'section_name': 'System Usage Sequence', - 'tab_classes': ['VerticalDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Lab0: Using the tools', - 'tab_classes': ['HtmlDescriptor', 'HtmlDescriptor', 'CapaDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Circuit Sandbox', - 'tab_classes': [] - }] - }, { - 'chapter_name': 'Week 1', - 'sections': [{ - 'clickable_tab_count': 4, - 'section_name': 'Administrivia and Circuit Elements', - 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Basic Circuit Analysis', - 'tab_classes': ['CapaDescriptor', 'CapaDescriptor', 'CapaDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Resistor Divider', - 'tab_classes': [] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Week 1 Tutorials', - 'tab_classes': [] - }] - }, { - 'chapter_name': 'Midterm Exam', - 'sections': [{ - 'clickable_tab_count': 2, - 'section_name': 'Midterm Exam', - 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor'] - }] - }] - """ - - course = get_course_by_id(course_id) - chapters = [chapter for chapter in course.get_children() if not chapter.lms.hide_from_toc] - courseware = [{'chapter_name': c.display_name_with_default, - 'sections': [{'section_name': s.display_name_with_default, - 'clickable_tab_count': len(s.get_children()) if (type(s) == seq_module.SequenceDescriptor) else 0, - 'tabs': [{'children_count': len(t.get_children()) if (type(t) == vertical_module.VerticalDescriptor) else 0, - 'class': t.__class__.__name__} - for t in s.get_children()]} - for s in c.get_children() if not s.lms.hide_from_toc]} - for c in chapters] - - return courseware - - -def process_section(element, num_tabs=0): - ''' - Process section reads through whatever is in 'course-content' and classifies it according to sequence module type. - - This function is recursive - - There are 6 types, with 6 actions. - - Sequence Module - -contains one child module - - Vertical Module - -contains other modules - -process it and get its children, then process them - - Capa Module - -problem type, contains only one problem - -for this, the most complex type, we created a separate method, process_problem - - Video Module - -video type, contains only one video - -we only check to ensure that a section with class of video exists - - HTML Module - -html text - -we do not check anything about it - - Custom Tag Module - -a custom 'hack' module type - -there is a large variety of content that could go in a custom tag module, so we just pass if it is of this unusual type - - can be used like this: - e = world.browser.find_by_css('section.course-content section') - process_section(e) - - ''' - if element.has_class('xmodule_display xmodule_SequenceModule'): - logger.debug('####### Processing xmodule_SequenceModule') - child_modules = element.find_by_css("div>div>section[class^='xmodule']") - for mod in child_modules: - process_section(mod) - - elif element.has_class('xmodule_display xmodule_VerticalModule'): - logger.debug('####### Processing xmodule_VerticalModule') - vert_list = element.find_by_css("li section[class^='xmodule']") - for item in vert_list: - process_section(item) - - elif element.has_class('xmodule_display xmodule_CapaModule'): - logger.debug('####### Processing xmodule_CapaModule') - assert element.find_by_css("section[id^='problem']"), "No problems found in Capa Module" - p = element.find_by_css("section[id^='problem']").first - p_id = p['id'] - logger.debug('####################') - logger.debug('id is "%s"' % p_id) - logger.debug('####################') - process_problem(p, p_id) - - elif element.has_class('xmodule_display xmodule_VideoModule'): - logger.debug('####### Processing xmodule_VideoModule') - assert element.find_by_css("section[class^='video']"), "No video found in Video Module" - - elif element.has_class('xmodule_display xmodule_HtmlModule'): - logger.debug('####### Processing xmodule_HtmlModule') - pass - - elif element.has_class('xmodule_display xmodule_CustomTagModule'): - logger.debug('####### Processing xmodule_CustomTagModule') - pass - - else: - assert False, "Class for element not recognized!!" - - -def process_problem(element, problem_id): - ''' - Process problem attempts to - 1) scan all the input fields and reset them - 2) click the 'check' button and look for an incorrect response (p.status text should be 'incorrect') - 3) click the 'show answer' button IF it exists and IF the answer is not already displayed - 4) enter the correct answer in each input box - 5) click the 'check' button and verify that answers are correct - - Because of all the ajax calls happening, sometimes the test fails because objects disconnect from the DOM. - The basic functionality does exist, though, and I'm hoping that someone can take it over and make it super effective. - ''' - - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - - ## clear out all input to ensure an incorrect result - for field in input_fields: - field.find_by_css("input").first.fill('') - - ## because of cookies or the application, only click the 'check' button if the status is not already 'incorrect' - # This would need to be reworked because multiple choice problems don't have this status - # if prob_xmod.find_by_css("p.status").first.text.strip().lower() != 'incorrect': - prob_xmod.find_by_css("section.action input.check").first.click() - - ## all elements become disconnected after the click - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - # Wait for the ajax reload - assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) - element = world.browser.find_by_css("section[id='%s']" % problem_id).first - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - for field in input_fields: - assert field.find_by_css("div.incorrect"), "The 'check' button did not work for %s" % (problem_id) - - show_button = element.find_by_css("section.action input.show").first - ## this logic is to ensure we do not accidentally hide the answers - if show_button.value.lower() == 'show answer': - show_button.click() - else: - pass - - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) - element = world.browser.find_by_css("section[id='%s']" % problem_id).first - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - - ## in each field, find the answer, and send it to the field. - ## Note that this does not work if the answer type is a strange format, e.g. "either a or b" - for field in input_fields: - field.find_by_css("input").first.fill(field.find_by_css("p[id^='answer']").first.text) - - prob_xmod.find_by_css("section.action input.check").first.click() - - ## assert that we entered the correct answers - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) - element = world.browser.find_by_css("section[id='%s']" % problem_id).first - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - for field in input_fields: - ## if you don't use 'starts with ^=' the test will fail because the actual class is 'correct ' (with a space) - assert field.find_by_css("div[class^='correct']"), "The check answer values were not correct for %s" % problem_id diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index a7eb782722..539bce96ce 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -2,7 +2,7 @@ from lettuce import world, step from re import sub from nose.tools import assert_equals from xmodule.modulestore.django import modulestore -from courses import * +from common import * from logging import getLogger logger = getLogger(__name__) From 6dd86f7a97826ec7af6fcb608928d6f0a7c07660 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 12:19:46 -0400 Subject: [PATCH 08/25] Refactored courseware_common and open_ended to use ui helpers --- common/djangoapps/terrain/ui_helpers.py | 16 +++++++++ .../courseware/features/courseware_common.py | 15 +++----- .../courseware/features/openended.py | 36 +++++++------------ 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 2ad7150740..d56ce3649b 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -20,6 +20,22 @@ def css_click(css_selector): time.sleep(1) world.browser.find_by_css(css_selector).click() +@world.absorb +def css_fill(css_selector, text): + world.browser.find_by_css(css_selector).first.fill(text) + +@world.absorb +def click_link(partial_text): + world.browser.find_link_by_partial_text(partial_text).first.click() + +@world.absorb +def css_text(css_selector): + return world.browser.find_by_css(css_selector).first.text + +@world.absorb +def css_visible(css_selector): + return world.browser.find_by_css(css_selector).visible + @world.absorb def save_the_html(path='/tmp'): u = world.browser.url diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py index 96304e016f..567254c334 100644 --- a/lms/djangoapps/courseware/features/courseware_common.py +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -9,11 +9,10 @@ def i_click_on_view_courseware(step): @step('I click on the "([^"]*)" tab$') -def i_click_on_the_tab(step, tab): - world.browser.find_link_by_partial_text(tab).first.click() +def i_click_on_the_tab(step, tab_text): + world.click_link(tab_text) world.save_the_html() - @step('I visit the courseware URL$') def i_visit_the_course_info_url(step): url = django_url('/courses/MITx/6.002x/2012_Fall/courseware') @@ -32,13 +31,9 @@ def i_am_on_the_dashboard_page(step): @step('the "([^"]*)" tab is active$') -def the_tab_is_active(step, tab): - css = '.course-tabs a.active' - active_tab = world.browser.find_by_css(css) - assert (active_tab.text == tab) - +def the_tab_is_active(step, tab_text): + assert world.css_text('.course-tabs a.active') == tab_text @step('the login dialog is visible$') def login_dialog_visible(step): - css = 'form#login_form.login_form' - assert world.browser.find_by_css(css).visible + assert world.css_visible('form#login_form.login_form') diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py index 0725a051ff..7601bfcc53 100644 --- a/lms/djangoapps/courseware/features/openended.py +++ b/lms/djangoapps/courseware/features/openended.py @@ -12,7 +12,7 @@ def navigate_to_an_openended_question(step): problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' world.browser.visit(django_url(problem)) tab_css = 'ol#sequence-list > li > a[data-element="5"]' - world.browser.find_by_css(tab_css).click() + world.css_click(tab_css) @step('I navigate to an openended question as staff$') @@ -22,50 +22,41 @@ def navigate_to_an_openended_question_as_staff(step): problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' world.browser.visit(django_url(problem)) tab_css = 'ol#sequence-list > li > a[data-element="5"]' - world.browser.find_by_css(tab_css).click() + world.css_click(tab_css) @step(u'I enter the answer "([^"]*)"$') def enter_the_answer_text(step, text): - textarea_css = 'textarea' - world.browser.find_by_css(textarea_css).first.fill(text) + world.css_fill('textarea', text) @step(u'I submit the answer "([^"]*)"$') def i_submit_the_answer_text(step, text): - textarea_css = 'textarea' - world.browser.find_by_css(textarea_css).first.fill(text) - check_css = 'input.check' - world.browser.find_by_css(check_css).click() + world.css_fill('textarea', text) + world.css_click('input.check') @step('I click the link for full output$') def click_full_output_link(step): - link_css = 'a.full' - world.browser.find_by_css(link_css).first.click() + world.css_click('a.full') @step(u'I visit the staff grading page$') def i_visit_the_staff_grading_page(step): - # course_u = '/courses/MITx/3.091x/2012_Fall' - # sg_url = '%s/staff_grading' % course_u - world.browser.click_link_by_text('Instructor') - world.browser.click_link_by_text('Staff grading') - # world.browser.visit(django_url(sg_url)) + world.click_link('Instructor') + world.click_link('Staff grading') @step(u'I see the grader message "([^"]*)"$') def see_grader_message(step, msg): message_css = 'div.external-grader-message' - grader_msg = world.browser.find_by_css(message_css).text - assert_in(msg, grader_msg) + assert_in(msg, world.css_text(message_css)) @step(u'I see the grader status "([^"]*)"$') def see_the_grader_status(step, status): status_css = 'div.grader-status' - grader_status = world.browser.find_by_css(status_css).text - assert_equals(status, grader_status) + assert_equals(status, world.css_text(status_css)) @step('I see the red X$') @@ -77,7 +68,7 @@ def see_the_red_x(step): @step(u'I see the grader score "([^"]*)"$') def see_the_grader_score(step, score): score_css = 'div.result-output > p' - score_text = world.browser.find_by_css(score_css).text + score_text = world.css_text(score_css) assert_equals(score_text, 'Score: %s' % score) @@ -89,14 +80,13 @@ def see_full_output_link(step): @step('I see the spelling grading message "([^"]*)"$') def see_spelling_msg(step, msg): - spelling_css = 'div.spelling' - spelling_msg = world.browser.find_by_css(spelling_css).text + spelling_msg = world.css_text('div.spelling') assert_equals('Spelling: %s' % msg, spelling_msg) @step(u'my answer is queued for instructor grading$') def answer_is_queued_for_instructor_grading(step): list_css = 'ul.problem-list > li > a' - actual_msg = world.browser.find_by_css(list_css).text + actual_msg = world.css_text(list_css) expected_msg = "(0 graded, 1 pending)" assert_in(expected_msg, actual_msg) From 4528490fac9050881eba0ff98df07782e71bbabc Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 12:40:33 -0400 Subject: [PATCH 09/25] Refactored lms/coureware lettuce tests to use terrain helpers for common ui manipulations --- common/djangoapps/terrain/course_helpers.py | 1 + common/djangoapps/terrain/steps.py | 6 ++++- common/djangoapps/terrain/ui_helpers.py | 23 ++++++++++++++++++- .../courseware/features/courseware_common.py | 13 +++++------ lms/djangoapps/courseware/features/login.py | 4 +--- .../courseware/features/openended.py | 6 ++--- .../courseware/features/problems.py | 4 ++-- .../courseware/features/registration.py | 8 +++---- lms/djangoapps/courseware/features/signup.py | 2 +- .../courseware/features/smart-accordion.py | 10 ++++---- .../courseware/features/xqueue_setup.py | 1 + 11 files changed, 50 insertions(+), 28 deletions(-) diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index 8c949de1ad..ebf5745f11 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -12,6 +12,7 @@ import os.path from urllib import quote_plus from lettuce.django import django_url + @world.absorb def create_user(uname): diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 8dac372a64..e99dec44b3 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -13,6 +13,7 @@ logger = getLogger(__name__) def wait(step, seconds): world.wait(seconds) + @step('I reload the page$') def reload_the_page(step): world.browser.reload() @@ -72,10 +73,12 @@ def the_page_title_should_be(step, title): def the_page_title_should_contain(step, title): assert(title in world.browser.title) + @step('I log in$') def i_log_in(step): world.log_in('robot', 'test') + @step('I am a logged in user$') def i_am_logged_in_user(step): world.create_user('robot') @@ -101,10 +104,12 @@ def click_the_link_called(step, text): def should_have_the_url(step, url): assert_equals(world.browser.url, url) + @step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') def should_see_a_link_called(step, text): assert len(world.browser.find_link_by_text(text)) > 0 + @step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') def should_see_in_the_page(step, text): assert_in(text, world.browser.html) @@ -130,4 +135,3 @@ def i_am_an_edx_user(step): @step(u'User "([^"]*)" is an edX user$') def registered_edx_user(step, uname): world.create_user(uname) - diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index d56ce3649b..1aac9cc72e 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -2,12 +2,29 @@ from lettuce import world, step import time from urllib import quote_plus from selenium.common.exceptions import WebDriverException +from lettuce.django import django_url + @world.absorb def wait(seconds): time.sleep(float(seconds)) +@world.absorb +def visit(url): + world.browser.visit(django_url(url)) + + +@world.absorb +def url_equals(url): + return world.browser.url == django_url(url) + + +@world.absorb +def is_css_present(css_selector): + return world.browser.is_element_present_by_css(css_selector, wait_time=4) + + @world.absorb def css_click(css_selector): try: @@ -20,22 +37,27 @@ def css_click(css_selector): time.sleep(1) world.browser.find_by_css(css_selector).click() + @world.absorb def css_fill(css_selector, text): world.browser.find_by_css(css_selector).first.fill(text) + @world.absorb def click_link(partial_text): world.browser.find_link_by_partial_text(partial_text).first.click() + @world.absorb def css_text(css_selector): return world.browser.find_by_css(css_selector).first.text + @world.absorb def css_visible(css_selector): return world.browser.find_by_css(css_selector).visible + @world.absorb def save_the_html(path='/tmp'): u = world.browser.url @@ -44,4 +66,3 @@ def save_the_html(path='/tmp'): f = open('%s/%s' % (path, filename), 'w') f.write(html) f.close - diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py index 567254c334..6aa9559e65 100644 --- a/lms/djangoapps/courseware/features/courseware_common.py +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -1,11 +1,9 @@ from lettuce import world, step -from lettuce.django import django_url @step('I click on View Courseware') def i_click_on_view_courseware(step): - css = 'a.enter-course' - world.browser.find_by_css(css).first.click() + world.css_click('a.enter-course') @step('I click on the "([^"]*)" tab$') @@ -13,10 +11,10 @@ def i_click_on_the_tab(step, tab_text): world.click_link(tab_text) world.save_the_html() + @step('I visit the courseware URL$') def i_visit_the_course_info_url(step): - url = django_url('/courses/MITx/6.002x/2012_Fall/courseware') - world.browser.visit(url) + world.visit('/courses/MITx/6.002x/2012_Fall/courseware') @step(u'I do not see "([^"]*)" anywhere on the page') @@ -26,14 +24,15 @@ def i_do_not_see_text_anywhere_on_the_page(step, text): @step(u'I am on the dashboard page$') def i_am_on_the_dashboard_page(step): - assert world.browser.is_element_present_by_css('section.courses') - assert world.browser.url == django_url('/dashboard') + assert world.is_css_present('section.courses') + assert world.url_equals('/dashboard') @step('the "([^"]*)" tab is active$') def the_tab_is_active(step, tab_text): assert world.css_text('.course-tabs a.active') == tab_text + @step('the login dialog is visible$') def login_dialog_visible(step): assert world.css_visible('form#login_form.login_form') diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py index 094db078ca..3e3c0efbc4 100644 --- a/lms/djangoapps/courseware/features/login.py +++ b/lms/djangoapps/courseware/features/login.py @@ -28,9 +28,7 @@ def i_should_see_the_login_error_message(step, msg): @step(u'click the dropdown arrow$') def click_the_dropdown(step): - css = ".dropdown" - e = world.browser.find_by_css(css) - e.click() + world.css_click('.dropdown') #### helper functions diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py index 7601bfcc53..2f14b808a3 100644 --- a/lms/djangoapps/courseware/features/openended.py +++ b/lms/djangoapps/courseware/features/openended.py @@ -61,8 +61,7 @@ def see_the_grader_status(step, status): @step('I see the red X$') def see_the_red_x(step): - x_css = 'div.grader-status > span.incorrect' - assert world.browser.find_by_css(x_css) + assert world.is_css_present('div.grader-status > span.incorrect') @step(u'I see the grader score "([^"]*)"$') @@ -74,8 +73,7 @@ def see_the_grader_score(step, score): @step('I see the link for full output$') def see_full_output_link(step): - link_css = 'a.full' - assert world.browser.find_by_css(link_css) + assert world.is_css_present('a.full') @step('I see the spelling grading message "([^"]*)"$') diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index d2d379a212..bdd9062ef3 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -339,7 +339,7 @@ def assert_answer_mark(step, problem_type, correctness): # At least one of the correct selectors should be present for sel in selector_dict[problem_type]: - has_expected = world.browser.is_element_present_by_css(sel, wait_time=4) + has_expected = world.is_css_present(sel) # As soon as we find the selector, break out of the loop if has_expected: @@ -366,7 +366,7 @@ def inputfield(problem_type, choice=None, input_num=1): # If the input element doesn't exist, fail immediately - assert(world.browser.is_element_present_by_css(sel, wait_time=4)) + assert world.is_css_present(sel) # Retrieve the input element return world.browser.find_by_css(sel) diff --git a/lms/djangoapps/courseware/features/registration.py b/lms/djangoapps/courseware/features/registration.py index 94b9b50f6c..63f044b16f 100644 --- a/lms/djangoapps/courseware/features/registration.py +++ b/lms/djangoapps/courseware/features/registration.py @@ -13,17 +13,17 @@ def i_register_for_the_course(step, course): register_link = intro_section.find_by_css('a.register') register_link.click() - assert world.browser.is_element_present_by_css('section.container.dashboard') + assert world.is_css_present('section.container.dashboard') @step(u'I should see the course numbered "([^"]*)" in my dashboard$') def i_should_see_that_course_in_my_dashboard(step, course): course_link_css = 'section.my-courses a[href*="%s"]' % course - assert world.browser.is_element_present_by_css(course_link_css) + assert world.is_css_present(course_link_css) @step(u'I press the "([^"]*)" button in the Unenroll dialog') def i_press_the_button_in_the_unenroll_dialog(step, value): button_css = 'section#unenroll-modal input[value="%s"]' % value - world.browser.find_by_css(button_css).click() - assert world.browser.is_element_present_by_css('section.container.dashboard') + world.css_click(button_css) + assert world.is_css_present('section.container.dashboard') diff --git a/lms/djangoapps/courseware/features/signup.py b/lms/djangoapps/courseware/features/signup.py index 3a697a6102..d9edcb215b 100644 --- a/lms/djangoapps/courseware/features/signup.py +++ b/lms/djangoapps/courseware/features/signup.py @@ -22,4 +22,4 @@ def i_check_checkbox(step, checkbox): @step('I should see "([^"]*)" in the dashboard banner$') def i_should_see_text_in_the_dashboard_banner_section(step, text): css_selector = "section.dashboard-banner h2" - assert (text in world.browser.find_by_css(css_selector).text) + assert (text in world.css_text(css_selector)) diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index 539bce96ce..8240a13905 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -32,20 +32,20 @@ def i_verify_all_the_content_of_each_course(step): pass for test_course in registered_courses: - test_course.find_by_css('a').click() + test_course.css_click('a') check_for_errors() # Get the course. E.g. 'MITx/6.002x/2012_Fall' current_course = sub('/info', '', sub('.*/courses/', '', world.browser.url)) validate_course(current_course, ids) - world.browser.find_link_by_text('Courseware').click() - assert world.browser.is_element_present_by_id('accordion', wait_time=2) + world.click_link('Courseware') + assert world.is_css_present('accordion') check_for_errors() browse_course(current_course) # clicking the user link gets you back to the user's home page - world.browser.find_by_css('.user-link').click() + world.css_click('.user-link') check_for_errors() @@ -94,7 +94,7 @@ def browse_course(course_id): world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('li')[section_it].find_by_tag('a').click() ## sometimes the course-content takes a long time to load - assert world.browser.is_element_present_by_css('.course-content', wait_time=5) + assert world.is_css_present('.course-content') ## look for server error div check_for_errors() diff --git a/lms/djangoapps/courseware/features/xqueue_setup.py b/lms/djangoapps/courseware/features/xqueue_setup.py index 23706941a9..d6d7a13a5c 100644 --- a/lms/djangoapps/courseware/features/xqueue_setup.py +++ b/lms/djangoapps/courseware/features/xqueue_setup.py @@ -3,6 +3,7 @@ from lettuce import before, after, world from django.conf import settings import threading + @before.all def setup_mock_xqueue_server(): From dde0d1676b8176119d5f33bf234221836c781aac Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 13:02:40 -0400 Subject: [PATCH 10/25] Refactored terrain/steps.py to use ui_helpers Added a wait time before checking the page HTML, and changed it to check just in the HTML body --- common/djangoapps/terrain/steps.py | 27 +++++++++++-------------- common/djangoapps/terrain/ui_helpers.py | 7 ++++++- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index e99dec44b3..dc8d2f8b87 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -26,42 +26,40 @@ def browser_back(step): @step('I (?:visit|access|open) the homepage$') def i_visit_the_homepage(step): - world.browser.visit(django_url('/')) - assert world.browser.is_element_present_by_css('header.global', 10) - + world.visit('/') + assert world.is_css_present('header.global') @step(u'I (?:visit|access|open) the dashboard$') def i_visit_the_dashboard(step): - world.browser.visit(django_url('/dashboard')) - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) - + world.visit('/dashboard') + assert world.is_css_present('section.container.dashboard') @step('I should be on the dashboard page$') def i_should_be_on_the_dashboard(step): - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + assert world.is_css_present('section.container.dashboard') assert world.browser.title == 'Dashboard' @step(u'I (?:visit|access|open) the courses page$') def i_am_on_the_courses_page(step): - world.browser.visit(django_url('/courses')) - assert world.browser.is_element_present_by_css('section.courses') + world.visit('/courses') + assert world.is_css_present('section.courses') @step(u'I press the "([^"]*)" button$') def and_i_press_the_button(step, value): button_css = 'input[value="%s"]' % value - world.browser.find_by_css(button_css).first.click() + world.css_click(button_css) @step(u'I click the link with the text "([^"]*)"$') def click_the_link_with_the_text_group1(step, linktext): - world.browser.find_link_by_text(linktext).first.click() + world.click_link(linktext) @step('I should see that the path is "([^"]*)"$') def i_should_see_that_the_path_is(step, path): - assert world.browser.url == django_url(path) + assert world.url_equals(path) @step(u'the page title should be "([^"]*)"$') @@ -97,8 +95,7 @@ def i_am_staff_for_course_by_id(step, course_id): @step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') def click_the_link_called(step, text): - world.browser.find_link_by_text(text).click() - + world.click_link(text) @step(r'should see that the url is "([^"]*)"$') def should_have_the_url(step, url): @@ -112,7 +109,7 @@ def should_see_a_link_called(step, text): @step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') def should_see_in_the_page(step, text): - assert_in(text, world.browser.html) + assert_in(text, world.css_text('body')) @step('I am logged in$') diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 1aac9cc72e..3009d1fa8d 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -50,7 +50,12 @@ def click_link(partial_text): @world.absorb def css_text(css_selector): - return world.browser.find_by_css(css_selector).first.text + + # Wait for the css selector to appear + if world.is_css_present(css_selector): + return world.browser.find_by_css(css_selector).first.text + else: + return "" @world.absorb From e69931ec5a06ecec9bc57b2875181e94b9b2f059 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 13:45:25 -0400 Subject: [PATCH 11/25] Refactored studio lettuce tests to use terrain/ui_helpers for ui manipulation --- .../features/advanced-settings.py | 40 ++----- .../contentstore/features/common.py | 102 +++++------------- .../contentstore/features/courses.py | 15 ++- .../contentstore/features/section.py | 26 ++--- .../contentstore/features/signup.py | 2 +- .../features/studio-overview-togglesection.py | 24 ++--- .../contentstore/features/subsection.py | 15 ++- common/djangoapps/terrain/ui_helpers.py | 34 ++++++ 8 files changed, 109 insertions(+), 149 deletions(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 7e86e94a31..0232c3b908 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -2,8 +2,6 @@ from lettuce import world, step from common import * import time from terrain.steps import reload_the_page -from selenium.common.exceptions import WebDriverException -from selenium.webdriver.support import expected_conditions as EC from nose.tools import assert_true, assert_false, assert_equal @@ -22,9 +20,9 @@ DISPLAY_NAME_VALUE = '"Robot Super Course"' def i_select_advanced_settings(step): expand_icon_css = 'li.nav-course-settings i.icon-expand' if world.browser.is_element_present_by_css(expand_icon_css): - css_click(expand_icon_css) + world.css_click(expand_icon_css) link_css = 'li.nav-course-settings-advanced a' - css_click(link_css) + world.css_click(link_css) @step('I am on the Advanced Course Settings page in Studio$') @@ -35,24 +33,8 @@ def i_am_on_advanced_course_settings(step): @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(step, name): - def is_visible(driver): - return EC.visibility_of_element_located((By.CSS_SELECTOR, css,)) - - # def is_invisible(driver): - # return EC.invisibility_of_element_located((By.CSS_SELECTOR,css,)) - css = 'a.%s-button' % name.lower() - wait_for(is_visible) - time.sleep(float(1)) - css_click_at(css) - -# is_invisible is not returning a boolean, not working -# try: -# css_click_at(css) -# wait_for(is_invisible) -# except WebDriverException, e: -# css_click_at(css) -# wait_for(is_invisible) + world.css_click_at(css) @step(u'I edit the value of a policy key$') @@ -61,7 +43,7 @@ def edit_the_value_of_a_policy_key(step): It is hard to figure out how to get into the CodeMirror area, so cheat and do it from the policy key field :) """ - e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] + e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X') @@ -85,7 +67,7 @@ def i_see_default_advanced_settings(step): @step('the settings are alphabetized$') def they_are_alphabetized(step): - key_elements = css_find(KEY_CSS) + key_elements = world.css_find(KEY_CSS) all_keys = [] for key in key_elements: all_keys.append(key.value) @@ -118,13 +100,13 @@ def assert_policy_entries(expected_keys, expected_values): for counter in range(len(expected_keys)): index = get_index_of(expected_keys[counter]) assert_false(index == -1, "Could not find key: " + expected_keys[counter]) - assert_equal(expected_values[counter], css_find(VALUE_CSS)[index].value, "value is incorrect") + assert_equal(expected_values[counter], world.css_find(VALUE_CSS)[index].value, "value is incorrect") def get_index_of(expected_key): - for counter in range(len(css_find(KEY_CSS))): + for counter in range(len(world.css_find(KEY_CSS))): # Sometimes get stale reference if I hold on to the array of elements - key = css_find(KEY_CSS)[counter].value + key = world.css_find(KEY_CSS)[counter].value if key == expected_key: return counter @@ -133,14 +115,14 @@ def get_index_of(expected_key): def get_display_name_value(): index = get_index_of(DISPLAY_NAME_KEY) - return css_find(VALUE_CSS)[index].value + return world.css_find(VALUE_CSS)[index].value def change_display_name_value(step, new_value): - e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] + e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] display_name = get_display_name_value() for count in range(len(display_name)): e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE) # Must delete "" before typing the JSON value e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value) - press_the_notification_button(step, "Save") \ No newline at end of file + press_the_notification_button(step, "Save") diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 820b60123b..4cc5759949 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,11 +1,6 @@ from lettuce import world, step -from lettuce.django import django_url from nose.tools import assert_true from nose.tools import assert_equal -from selenium.webdriver.support.ui import WebDriverWait -from selenium.common.exceptions import WebDriverException, StaleElementReferenceException -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.common.by import By from xmodule.modulestore.django import _MODULESTORES, modulestore from xmodule.templates import update_templates @@ -20,9 +15,9 @@ def i_visit_the_studio_homepage(step): # To make this go to port 8001, put # LETTUCE_SERVER_PORT = 8001 # in your settings.py file. - world.browser.visit(django_url('/')) + world.visit('/') signin_css = 'a.action-signin' - assert world.browser.is_element_present_by_css(signin_css, 10) + assert world.is_css_present(signin_css) @step('I am logged into Studio$') @@ -43,7 +38,7 @@ def i_press_the_category_delete_icon(step, category): css = 'a.delete-button.delete-subsection-button span.delete-icon' else: assert False, 'Invalid category: %s' % category - css_click(css) + world.css_click(css) @step('I have opened a new course in Studio$') @@ -87,56 +82,6 @@ def flush_xmodule_store(): update_templates() -def assert_css_with_text(css, text): - assert_true(world.browser.is_element_present_by_css(css, 5)) - assert_equal(world.browser.find_by_css(css).text, text) - - -def css_click(css): - ''' - First try to use the regular click method, - but if clicking in the middle of an element - doesn't work it might be that it thinks some other - element is on top of it there so click in the upper left - ''' - try: - css_find(css).first.click() - except WebDriverException, e: - css_click_at(css) - - -def css_click_at(css, x=10, y=10): - ''' - A method to click at x,y coordinates of the element - rather than in the center of the element - ''' - e = css_find(css).first - e.action_chains.move_to_element_with_offset(e._element, x, y) - e.action_chains.click() - e.action_chains.perform() - - -def css_fill(css, value): - world.browser.find_by_css(css).first.fill(value) - - -def css_find(css): - def is_visible(driver): - return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) - - world.browser.is_element_present_by_css(css, 5) - wait_for(is_visible) - return world.browser.find_by_css(css) - - -def wait_for(func): - WebDriverWait(world.browser.driver, 5).until(func) - - -def id_find(id): - return world.browser.find_by_id(id) - - def clear_courses(): flush_xmodule_store() @@ -145,9 +90,9 @@ def fill_in_course_info( name='Robot Super Course', org='MITx', num='101'): - css_fill('.new-course-name', name) - css_fill('.new-course-org', org) - css_fill('.new-course-number', num) + world.css_fill('.new-course-name', name) + world.css_fill('.new-course-org', org) + world.css_fill('.new-course-number', num) def log_into_studio( @@ -155,21 +100,22 @@ def log_into_studio( email='robot+studio@edx.org', password='test', is_staff=False): - create_studio_user(uname=uname, email=email, is_staff=is_staff) - world.browser.cookies.delete() - world.browser.visit(django_url('/')) - signin_css = 'a.action-signin' - world.browser.is_element_present_by_css(signin_css, 10) - # click the signin button - css_click(signin_css) + create_studio_user(uname=uname, email=email, is_staff=is_staff) + + world.browser.cookies.delete() + world.visit('/') + + signin_css = 'a.action-signin' + world.is_css_present(signin_css) + world.css_click(signin_css) login_form = world.browser.find_by_css('form#login_form') login_form.find_by_name('email').fill(email) login_form.find_by_name('password').fill(password) login_form.find_by_name('submit').click() - assert_true(world.browser.is_element_present_by_css('.new-course-button', 5)) + assert_true(world.is_css_present('.new-course-button')) def create_a_course(): @@ -184,26 +130,26 @@ def create_a_course(): world.browser.reload() course_link_css = 'span.class-name' - css_click(course_link_css) + world.css_click(course_link_css) course_title_css = 'span.course-title' - assert_true(world.browser.is_element_present_by_css(course_title_css, 5)) + assert_true(world.is_css_present(course_title_css)) def add_section(name='My Section'): link_css = 'a.new-courseware-section-button' - css_click(link_css) + world.css_click(link_css) name_css = 'input.new-section-name' save_css = 'input.new-section-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) span_css = 'span.section-name-span' - assert_true(world.browser.is_element_present_by_css(span_css, 5)) + assert_true(world.is_css_present(span_css)) def add_subsection(name='Subsection One'): css = 'a.new-subsection-item' - css_click(css) + world.css_click(css) name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index e394165f08..8301e6708f 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -11,7 +11,7 @@ def no_courses(step): @step('I click the New Course button$') def i_click_new_course(step): - css_click('.new-course-button') + world.css_click('.new-course-button') @step('I fill in the new course information$') @@ -27,7 +27,7 @@ def i_create_a_course(step): @step('I click the course link in My Courses$') def i_click_the_course_link_in_my_courses(step): course_css = 'span.class-name' - css_click(course_css) + world.css_click(course_css) ############ ASSERTIONS ################### @@ -35,28 +35,27 @@ def i_click_the_course_link_in_my_courses(step): @step('the Courseware page has loaded in Studio$') def courseware_page_has_loaded_in_studio(step): course_title_css = 'span.course-title' - assert world.browser.is_element_present_by_css(course_title_css) + assert world.is_css_present(course_title_css) @step('I see the course listed in My Courses$') def i_see_the_course_in_my_courses(step): course_css = 'span.class-name' - assert_css_with_text(course_css, 'Robot Super Course') - + assert world.css_has_text(course_css, 'Robot Super Course') @step('the course is loaded$') def course_is_loaded(step): class_css = 'a.class-name' - assert_css_with_text(class_css, 'Robot Super Course') + assert world.css_has_text(course_css, 'Robot Super Cousre') @step('I am on the "([^"]*)" tab$') def i_am_on_tab(step, tab_name): header_css = 'div.inner-wrapper h1' - assert_css_with_text(header_css, tab_name) + assert world.css_has_text(header_css, tab_name) @step('I see a link for adding a new section$') def i_see_new_section_link(step): link_css = 'a.new-courseware-section-button' - assert_css_with_text(link_css, '+ New Section') + assert world.css_has_text(link_css, '+ New Section') diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index b5ddb48a09..e57d50bbfe 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -10,7 +10,7 @@ import time @step('I click the new section link$') def i_click_new_section_link(step): link_css = 'a.new-courseware-section-button' - css_click(link_css) + world.css_click(link_css) @step('I enter the section name and click save$') @@ -31,19 +31,19 @@ def i_have_added_new_section(step): @step('I click the Edit link for the release date$') def i_click_the_edit_link_for_the_release_date(step): button_css = 'div.section-published-date a.edit-button' - css_click(button_css) + world.css_click(button_css) @step('I save a new section release date$') def i_save_a_new_section_release_date(step): date_css = 'input.start-date.date.hasDatepicker' time_css = 'input.start-time.time.ui-timepicker-input' - css_fill(date_css, '12/25/2013') + world.css_fill(date_css, '12/25/2013') # hit TAB to get to the time field - e = css_find(date_css).first + e = world.css_find(date_css).first e._element.send_keys(Keys.TAB) - css_fill(time_css, '12:00am') - e = css_find(time_css).first + world.css_fill(time_css, '12:00am') + e = world.css_find(time_css).first e._element.send_keys(Keys.TAB) time.sleep(float(1)) world.browser.click_link_by_text('Save') @@ -64,13 +64,13 @@ def i_see_my_section_name_with_quote_on_the_courseware_page(step): @step('I click to edit the section name$') def i_click_to_edit_section_name(step): - css_click('span.section-name-span') + world.css_click('span.section-name-span') @step('I see the complete section name with a quote in the editor$') def i_see_complete_section_name_with_quote_in_editor(step): css = '.edit-section-name' - assert world.browser.is_element_present_by_css(css, 5) + assert world.is_css_present(css) assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"') @@ -85,7 +85,7 @@ def i_see_a_release_date_for_my_section(step): import re css = 'span.published-status' - assert world.browser.is_element_present_by_css(css) + assert world.is_css_present(css) status_text = world.browser.find_by_css(css).text # e.g. 11/06/2012 at 16:25 @@ -99,7 +99,7 @@ def i_see_a_release_date_for_my_section(step): @step('I see a link to create a new subsection$') def i_see_a_link_to_create_a_new_subsection(step): css = 'a.new-subsection-item' - assert world.browser.is_element_present_by_css(css) + assert world.is_css_present(css) @step('the section release date picker is not visible$') @@ -120,10 +120,10 @@ def the_section_release_date_is_updated(step): def save_section_name(name): name_css = '.new-section-name' save_css = '.new-section-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) def see_my_section_on_the_courseware_page(name): section_css = 'span.section-name-span' - assert_css_with_text(section_css, name) + assert world.css_has_text(section_css, name) diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index e8d0dd8229..cd4adb79fb 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -17,7 +17,7 @@ def i_press_the_button_on_the_registration_form(step): submit_css = 'form#register_form button#submit' # Workaround for click not working on ubuntu # for some unknown reason. - e = css_find(submit_css) + e = world.css_find(submit_css) e.type(' ') @step('I should see be on the studio home page$') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 060d592cfd..85a25a55ac 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -49,7 +49,7 @@ def have_a_course_with_two_sections(step): def navigate_to_the_course_overview_page(step): log_into_studio(is_staff=True) course_locator = '.class-name' - css_click(course_locator) + world.css_click(course_locator) @step(u'I navigate to the courseware page of a course with multiple sections') @@ -66,44 +66,44 @@ def i_add_a_section(step): @step(u'I click the "([^"]*)" link$') def i_click_the_text_span(step, text): span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator, 5)) + assert_true(world.browser.is_element_present_by_css(span_locator)) # first make sure that the expand/collapse text is the one you expected assert_equal(world.browser.find_by_css(span_locator).value, text) - css_click(span_locator) + world.css_click(span_locator) @step(u'I collapse the first section$') def i_collapse_a_section(step): collapse_locator = 'section.courseware-section a.collapse' - css_click(collapse_locator) + world.css_click(collapse_locator) @step(u'I expand the first section$') def i_expand_a_section(step): expand_locator = 'section.courseware-section a.expand' - css_click(expand_locator) + world.css_click(expand_locator) @step(u'I see the "([^"]*)" link$') def i_see_the_span_with_text(step, text): span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator, 5)) - assert_equal(world.browser.find_by_css(span_locator).value, text) - assert_true(world.browser.find_by_css(span_locator).visible) + assert_true(world.is_css_present(span_locator)) + assert_equal(world.css_find(span_locator).value, text) + assert_true(world.css_visible(span_locator)) @step(u'I do not see the "([^"]*)" link$') def i_do_not_see_the_span_with_text(step, text): # Note that the span will exist on the page but not be visible span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator)) - assert_false(world.browser.find_by_css(span_locator).visible) + assert_true(world.is_css_present(span_locator)) + assert_false(world.css_visible(span_locator)) @step(u'all sections are expanded$') def all_sections_are_expanded(step): subsection_locator = 'div.subsection-list' - subsections = world.browser.find_by_css(subsection_locator) + subsections = world.css_find(subsection_locator) for s in subsections: assert_true(s.visible) @@ -111,6 +111,6 @@ def all_sections_are_expanded(step): @step(u'all sections are collapsed$') def all_sections_are_expanded(step): subsection_locator = 'div.subsection-list' - subsections = world.browser.find_by_css(subsection_locator) + subsections = world.css_find(subsection_locator) for s in subsections: assert_false(s.visible) diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 88e1424898..f5863be27b 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -15,8 +15,7 @@ def i_have_opened_a_new_course_section(step): @step('I click the New Subsection link') def i_click_the_new_subsection_link(step): - css = 'a.new-subsection-item' - css_click(css) + world.css_click('a.new-subsection-item') @step('I enter the subsection name and click save$') @@ -31,13 +30,13 @@ def i_save_subsection_name_with_quote(step): @step('I click to edit the subsection name$') def i_click_to_edit_subsection_name(step): - css_click('span.subsection-name-value') + world.css_click('span.subsection-name-value') @step('I see the complete subsection name with a quote in the editor$') def i_see_complete_subsection_name_with_quote_in_editor(step): css = '.subsection-display-name-input' - assert world.browser.is_element_present_by_css(css, 5) + assert world.is_css_present(css) assert_equal(world.browser.find_by_css(css).value, 'Subsection With "Quote"') @@ -70,11 +69,11 @@ def the_subsection_does_not_exist(step): def save_subsection_name(name): name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) def see_subsection_name(name): css = 'span.subsection-name' - assert world.browser.is_element_present_by_css(css) + assert world.is_css_present(css) css = 'span.subsection-name-value' - assert_css_with_text(css, name) + assert world.css_has_text(css, name) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 3009d1fa8d..e2f701d089 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -2,6 +2,9 @@ from lettuce import world, step import time from urllib import quote_plus from selenium.common.exceptions import WebDriverException +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait from lettuce.django import django_url @@ -9,6 +12,9 @@ from lettuce.django import django_url def wait(seconds): time.sleep(float(seconds)) +@world.absorb +def wait_for(func): + WebDriverWait(world.browser.driver, 5).until(func) @world.absorb def visit(url): @@ -24,9 +30,27 @@ def url_equals(url): def is_css_present(css_selector): return world.browser.is_element_present_by_css(css_selector, wait_time=4) +@world.absorb +def css_has_text(css_selector, text): + return world.css_text(css_selector) == text + +@world.absorb +def css_find(css): + def is_visible(driver): + return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) + + world.browser.is_element_present_by_css(css, 5) + wait_for(is_visible) + return world.browser.find_by_css(css) @world.absorb def css_click(css_selector): + ''' + First try to use the regular click method, + but if clicking in the middle of an element + doesn't work it might be that it thinks some other + element is on top of it there so click in the upper left + ''' try: world.browser.find_by_css(css_selector).click() @@ -37,6 +61,16 @@ def css_click(css_selector): time.sleep(1) world.browser.find_by_css(css_selector).click() +@world.absorb +def css_click_at(css, x=10, y=10): + ''' + A method to click at x,y coordinates of the element + rather than in the center of the element + ''' + e = css_find(css).first + e.action_chains.move_to_element_with_offset(e._element, x, y) + e.action_chains.click() + e.action_chains.perform() @world.absorb def css_fill(css_selector, text): From a58ae9b62d60450b7bf18a49531487e2150cf094 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 13:49:50 -0400 Subject: [PATCH 12/25] Refactored studio lettuce test section.py to use more of ui helpers --- cms/djangoapps/contentstore/features/section.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index e57d50bbfe..41236f6dfd 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -105,13 +105,13 @@ def i_see_a_link_to_create_a_new_subsection(step): @step('the section release date picker is not visible$') def the_section_release_date_picker_not_visible(step): css = 'div.edit-subsection-publish-settings' - assert False, world.browser.find_by_css(css).visible + assert not world.css_visible(css) @step('the section release date is updated$') def the_section_release_date_is_updated(step): css = 'span.published-status' - status_text = world.browser.find_by_css(css).text + status_text = world.css_text(css) assert_equal(status_text,'Will Release: 12/25/2013 at 12:00am') From 00d25b684cf10bd2c8dd39a5077e365b3259bfde Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 14:04:04 -0400 Subject: [PATCH 13/25] Moved modulestore flush code into terrain/course_helpers --- .../contentstore/features/common.py | 19 +------------------ .../contentstore/features/courses.py | 2 +- .../features/studio-overview-togglesection.py | 6 +++--- .../contentstore/features/subsection.py | 2 +- common/djangoapps/terrain/course_helpers.py | 15 +++++++++++++++ lms/djangoapps/courseware/features/common.py | 15 +-------------- 6 files changed, 22 insertions(+), 37 deletions(-) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 4cc5759949..0b5c9acbed 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -43,7 +43,7 @@ def i_press_the_category_delete_icon(step, category): @step('I have opened a new course in Studio$') def i_have_opened_a_new_course(step): - clear_courses() + world.clear_courses() log_into_studio() create_a_course() @@ -69,23 +69,6 @@ def create_studio_user( user_profile = world.UserProfileFactory(user=studio_user) -def flush_xmodule_store(): - # Flush and initialize the module store - # It needs the templates because it creates new records - # by cloning from the template. - # Note that if your test module gets in some weird state - # (though it shouldn't), do this manually - # from the bash shell to drop it: - # $ mongo test_xmodule --eval "db.dropDatabase()" - _MODULESTORES = {} - modulestore().collection.drop() - update_templates() - - -def clear_courses(): - flush_xmodule_store() - - def fill_in_course_info( name='Robot Super Course', org='MITx', diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 8301e6708f..348cc25e97 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -6,7 +6,7 @@ from common import * @step('There are no courses$') def no_courses(step): - clear_courses() + world.clear_courses() @step('I click the New Course button$') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 85a25a55ac..dc22d3ad1a 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -8,13 +8,13 @@ logger = getLogger(__name__) @step(u'I have a course with no sections$') def have_a_course(step): - clear_courses() + world.clear_courses() course = world.CourseFactory.create() @step(u'I have a course with 1 section$') def have_a_course_with_1_section(step): - clear_courses() + world.clear_courses() course = world.CourseFactory.create() section = world.ItemFactory.create(parent_location=course.location) subsection1 = world.ItemFactory.create( @@ -25,7 +25,7 @@ def have_a_course_with_1_section(step): @step(u'I have a course with multiple sections$') def have_a_course_with_two_sections(step): - clear_courses() + world.clear_courses() course = world.CourseFactory.create() section = world.ItemFactory.create(parent_location=course.location) subsection1 = world.ItemFactory.create( diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index f5863be27b..2094e65ccb 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -7,7 +7,7 @@ from nose.tools import assert_equal @step('I have opened a new course section in Studio$') def i_have_opened_a_new_course_section(step): - clear_courses() + world.clear_courses() log_into_studio() create_a_course() add_section() diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index ebf5745f11..2ac3befd82 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -7,6 +7,8 @@ from django.contrib.auth import authenticate, login from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.sessions.middleware import SessionMiddleware from student.models import CourseEnrollment +from xmodule.modulestore.django import _MODULESTORES, modulestore +from xmodule.templates import update_templates from bs4 import BeautifulSoup import os.path from urllib import quote_plus @@ -119,3 +121,16 @@ def save_the_course_content(path='/tmp'): f = open('%s/%s' % (path, filename), 'w') f.write(output) f.close + +@world.absorb +def clear_courses(): + # Flush and initialize the module store + # It needs the templates because it creates new records + # by cloning from the template. + # Note that if your test module gets in some weird state + # (though it shouldn't), do this manually + # from the bash shell to drop it: + # $ mongo test_xmodule --eval "db.dropDatabase()" + _MODULESTORES = {} + modulestore().collection.drop() + update_templates() diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index 2d366d462d..f015725ae9 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -24,7 +24,7 @@ def create_course(step, course): # First clear the modulestore so we don't try to recreate # the same course twice # This also ensures that the necessary templates are loaded - flush_xmodule_store() + world.clear_courses() # Create the course # We always use the same org and display name, @@ -65,19 +65,6 @@ def add_tab_to_course(step, course, extra_tab_name): display_name=str(extra_tab_name)) -def flush_xmodule_store(): - # Flush and initialize the module store - # It needs the templates because it creates new records - # by cloning from the template. - # Note that if your test module gets in some weird state - # (though it shouldn't), do this manually - # from the bash shell to drop it: - # $ mongo test_xmodule --eval "db.dropDatabase()" - _MODULESTORES = {} - modulestore().collection.drop() - update_templates() - - def course_id(course_num): return "%s/%s/%s" % (TEST_COURSE_ORG, course_num, TEST_COURSE_NAME.replace(" ", "_")) From 27d5ebf027224239c5109820794d6e5c0098930d Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 14:27:10 -0400 Subject: [PATCH 14/25] pep8 fixes --- .../features/advanced-settings.feature | 8 +++--- .../features/advanced-settings.py | 1 + .../contentstore/features/common.py | 1 + .../contentstore/features/courses.feature | 2 +- .../contentstore/features/courses.py | 1 + .../contentstore/features/section.py | 2 +- .../contentstore/features/signup.py | 1 + .../studio-overview-togglesection.feature | 28 +++++++++---------- .../contentstore/features/subsection.py | 1 + common/djangoapps/terrain/course_helpers.py | 1 + common/djangoapps/terrain/steps.py | 3 ++ common/djangoapps/terrain/ui_helpers.py | 11 ++++++-- .../features/high-level-tabs.feature | 2 +- 13 files changed, 39 insertions(+), 23 deletions(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index af97709ad0..66039e19b1 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -1,6 +1,6 @@ Feature: Advanced (manual) course policy In order to specify course policy settings for which no custom user interface exists - I want to be able to manually enter JSON key/value pairs + I want to be able to manually enter JSON key /value pairs Scenario: A course author sees default advanced settings Given I have opened a new course in Studio @@ -27,16 +27,16 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is changed - Scenario: Test how multi-line input appears + Scenario: Test how multi -line input appears Given I am on the Advanced Course Settings page in Studio When I create a JSON object as a value Then it is displayed as formatted And I reload the page Then it is displayed as formatted - Scenario: Test automatic quoting of non-JSON values + Scenario: Test automatic quoting of non -JSON values Given I am on the Advanced Course Settings page in Studio - When I create a non-JSON value not in quotes + When I create a non -JSON value not in quotes Then it is displayed as a string And I reload the page Then it is displayed as a string diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 0232c3b908..a2708d8c96 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -16,6 +16,7 @@ DISPLAY_NAME_KEY = "display_name" DISPLAY_NAME_VALUE = '"Robot Super Course"' ############### ACTIONS #################### + @step('I select the Advanced Settings$') def i_select_advanced_settings(step): expand_icon_css = 'li.nav-course-settings i.icon-expand' diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 0b5c9acbed..870ab89694 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -10,6 +10,7 @@ from logging import getLogger logger = getLogger(__name__) ########### STEP HELPERS ############## + @step('I (?:visit|access|open) the Studio homepage$') def i_visit_the_studio_homepage(step): # To make this go to port 8001, put diff --git a/cms/djangoapps/contentstore/features/courses.feature b/cms/djangoapps/contentstore/features/courses.feature index 39d39b50aa..455313b0e2 100644 --- a/cms/djangoapps/contentstore/features/courses.feature +++ b/cms/djangoapps/contentstore/features/courses.feature @@ -10,4 +10,4 @@ Feature: Create Course And I fill in the new course information And I press the "Save" button Then the Courseware page has loaded in Studio - And I see a link for adding a new section \ No newline at end of file + And I see a link for adding a new section diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 348cc25e97..b3b6f91bdb 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -43,6 +43,7 @@ def i_see_the_course_in_my_courses(step): course_css = 'span.class-name' assert world.css_has_text(course_css, 'Robot Super Course') + @step('the course is loaded$') def course_is_loaded(step): class_css = 'a.class-name' diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 41236f6dfd..65f3bd4897 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -112,7 +112,7 @@ def the_section_release_date_picker_not_visible(step): def the_section_release_date_is_updated(step): css = 'span.published-status' status_text = world.css_text(css) - assert_equal(status_text,'Will Release: 12/25/2013 at 12:00am') + assert_equal(status_text, 'Will Release: 12/25/2013 at 12:00am') ############ HELPER METHODS ################### diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index cd4adb79fb..2dcf0d63fe 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -20,6 +20,7 @@ def i_press_the_button_on_the_registration_form(step): e = world.css_find(submit_css) e.type(' ') + @step('I should see be on the studio home page$') def i_should_see_be_on_the_studio_home_page(step): assert world.browser.find_by_css('div.inner-wrapper') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index 52c10e41a8..88492d55e3 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -1,30 +1,30 @@ Feature: Overview Toggle Section In order to quickly view the details of a course's section or to scan the inventory of sections - As a course author - I want to toggle the visibility of each section's subsection details in the overview listing + As a course author + I want to toggle the visibility of each section's subsection details in the overview listing Scenario: The default layout for the overview page is to show sections in expanded view Given I have a course with multiple sections - When I navigate to the course overview page - Then I see the "Collapse All Sections" link - And all sections are expanded + When I navigate to the course overview page + Then I see the "Collapse All Sections" link + And all sections are expanded - Scenario: Expand/collapse for a course with no sections + Scenario: Expand /collapse for a course with no sections Given I have a course with no sections - When I navigate to the course overview page - Then I do not see the "Collapse All Sections" link + When I navigate to the course overview page + Then I do not see the "Collapse All Sections" link Scenario: Collapse link appears after creating first section of a course Given I have a course with no sections - When I navigate to the course overview page - And I add a section - Then I see the "Collapse All Sections" link - And all sections are expanded + When I navigate to the course overview page + And I add a section + Then I see the "Collapse All Sections" link + And all sections are expanded - @skip-phantom + @skip -phantom Scenario: Collapse link is not removed after last section of a course is deleted Given I have a course with 1 section - And I navigate to the course overview page + And I navigate to the course overview page When I press the "section" delete icon And I confirm the alert Then I see the "Collapse All Sections" link diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 2094e65ccb..8695ea1c4f 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -72,6 +72,7 @@ def save_subsection_name(name): world.css_fill(name_css, name) world.css_click(save_css) + def see_subsection_name(name): css = 'span.subsection-name' assert world.is_css_present(css) diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index 2ac3befd82..85dfa85b37 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -122,6 +122,7 @@ def save_the_course_content(path='/tmp'): f.write(output) f.close + @world.absorb def clear_courses(): # Flush and initialize the module store diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index dc8d2f8b87..bf78a1d2b7 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -29,11 +29,13 @@ def i_visit_the_homepage(step): world.visit('/') assert world.is_css_present('header.global') + @step(u'I (?:visit|access|open) the dashboard$') def i_visit_the_dashboard(step): world.visit('/dashboard') assert world.is_css_present('section.container.dashboard') + @step('I should be on the dashboard page$') def i_should_be_on_the_dashboard(step): assert world.is_css_present('section.container.dashboard') @@ -97,6 +99,7 @@ def i_am_staff_for_course_by_id(step, course_id): def click_the_link_called(step, text): world.click_link(text) + @step(r'should see that the url is "([^"]*)"$') def should_have_the_url(step, url): assert_equals(world.browser.url, url) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index e2f701d089..6dadb976a7 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -12,10 +12,12 @@ from lettuce.django import django_url def wait(seconds): time.sleep(float(seconds)) + @world.absorb def wait_for(func): WebDriverWait(world.browser.driver, 5).until(func) + @world.absorb def visit(url): world.browser.visit(django_url(url)) @@ -30,23 +32,26 @@ def url_equals(url): def is_css_present(css_selector): return world.browser.is_element_present_by_css(css_selector, wait_time=4) + @world.absorb def css_has_text(css_selector, text): return world.css_text(css_selector) == text + @world.absorb def css_find(css): def is_visible(driver): - return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) + return EC.visibility_of_element_located((By.CSS_SELECTOR, css,)) world.browser.is_element_present_by_css(css, 5) wait_for(is_visible) return world.browser.find_by_css(css) + @world.absorb def css_click(css_selector): ''' - First try to use the regular click method, + First try to use the regular click method, but if clicking in the middle of an element doesn't work it might be that it thinks some other element is on top of it there so click in the upper left @@ -61,6 +66,7 @@ def css_click(css_selector): time.sleep(1) world.browser.find_by_css(css_selector).click() + @world.absorb def css_click_at(css, x=10, y=10): ''' @@ -72,6 +78,7 @@ def css_click_at(css, x=10, y=10): e.action_chains.click() e.action_chains.perform() + @world.absorb def css_fill(css_selector, text): world.browser.find_by_css(css_selector).first.fill(text) diff --git a/lms/djangoapps/courseware/features/high-level-tabs.feature b/lms/djangoapps/courseware/features/high-level-tabs.feature index 473f3f1572..c60ec7b374 100644 --- a/lms/djangoapps/courseware/features/high-level-tabs.feature +++ b/lms/djangoapps/courseware/features/high-level-tabs.feature @@ -3,7 +3,7 @@ Feature: All the high level tabs should work As a student I want to navigate through the high level tabs -Scenario: I can navigate to all high -level tabs in a course +Scenario: I can navigate to all high - level tabs in a course Given: I am registered for the course "6.002x" And The course "6.002x" has extra tab "Custom Tab" And I am logged in From 0500ba4dd5e4a8563a31c6557f8ca331cdba8cfa Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 26 Mar 2013 11:17:56 -0400 Subject: [PATCH 15/25] Disabled pylint warnings for lettuce steps: * Missing docstring * Redefining name from outer scope --- cms/djangoapps/contentstore/features/advanced-settings.py | 3 +++ cms/djangoapps/contentstore/features/common.py | 3 +++ cms/djangoapps/contentstore/features/courses.py | 3 +++ cms/djangoapps/contentstore/features/section.py | 3 +++ cms/djangoapps/contentstore/features/signup.py | 3 +++ .../contentstore/features/studio-overview-togglesection.py | 3 +++ cms/djangoapps/contentstore/features/subsection.py | 3 +++ common/djangoapps/terrain/course_helpers.py | 3 +++ common/djangoapps/terrain/steps.py | 3 +++ common/djangoapps/terrain/ui_helpers.py | 3 +++ lms/djangoapps/courseware/features/common.py | 3 +++ lms/djangoapps/courseware/features/courseware.py | 3 +++ lms/djangoapps/courseware/features/courseware_common.py | 3 +++ lms/djangoapps/courseware/features/homepage.py | 3 +++ lms/djangoapps/courseware/features/login.py | 3 +++ lms/djangoapps/courseware/features/openended.py | 3 +++ lms/djangoapps/courseware/features/problems.py | 2 ++ lms/djangoapps/courseware/features/registration.py | 3 +++ lms/djangoapps/courseware/features/signup.py | 4 +++- lms/djangoapps/courseware/features/smart-accordion.py | 3 +++ lms/djangoapps/courseware/features/xqueue_setup.py | 4 +++- 21 files changed, 62 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index a2708d8c96..16562b6b15 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * import time diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 870ab89694..3878340af3 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from nose.tools import assert_true from nose.tools import assert_equal diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index b3b6f91bdb..5da7720945 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 65f3bd4897..0c0f5536a0 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * from nose.tools import assert_equal diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 2dcf0d63fe..6ca358183b 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index dc22d3ad1a..7f717b731c 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * from nose.tools import assert_true, assert_false, assert_equal diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 8695ea1c4f..54f49f2fa6 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * from nose.tools import assert_equal diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index 85dfa85b37..f0df456c80 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from .factories import * from django.conf import settings diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index bf78a1d2b7..a8a32db173 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from .course_helpers import * from .ui_helpers import * diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 6dadb976a7..d4d99e17b5 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step import time from urllib import quote_plus diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index f015725ae9..f6256adfa1 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from nose.tools import assert_equals, assert_in from lettuce.django import django_url diff --git a/lms/djangoapps/courseware/features/courseware.py b/lms/djangoapps/courseware/features/courseware.py index 7e99cc9f55..234f3a84d2 100644 --- a/lms/djangoapps/courseware/features/courseware.py +++ b/lms/djangoapps/courseware/features/courseware.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from lettuce.django import django_url diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py index 6aa9559e65..4e9aa3fb7b 100644 --- a/lms/djangoapps/courseware/features/courseware_common.py +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step diff --git a/lms/djangoapps/courseware/features/homepage.py b/lms/djangoapps/courseware/features/homepage.py index 442098c161..62e9096e70 100644 --- a/lms/djangoapps/courseware/features/homepage.py +++ b/lms/djangoapps/courseware/features/homepage.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from nose.tools import assert_in diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py index 3e3c0efbc4..bc90ea301c 100644 --- a/lms/djangoapps/courseware/features/login.py +++ b/lms/djangoapps/courseware/features/login.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import step, world from django.contrib.auth.models import User diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py index 2f14b808a3..d848eb55d7 100644 --- a/lms/djangoapps/courseware/features/openended.py +++ b/lms/djangoapps/courseware/features/openended.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from lettuce.django import django_url from nose.tools import assert_equals, assert_in diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index bdd9062ef3..b25d606c4e 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -2,6 +2,8 @@ Steps for problem.feature lettuce tests ''' +#pylint: disable=C0111 +#pylint: disable=W0621 from lettuce import world, step from lettuce.django import django_url diff --git a/lms/djangoapps/courseware/features/registration.py b/lms/djangoapps/courseware/features/registration.py index 63f044b16f..72bde65f99 100644 --- a/lms/djangoapps/courseware/features/registration.py +++ b/lms/djangoapps/courseware/features/registration.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from lettuce.django import django_url from common import TEST_COURSE_ORG, TEST_COURSE_NAME diff --git a/lms/djangoapps/courseware/features/signup.py b/lms/djangoapps/courseware/features/signup.py index d9edcb215b..5ba385ef54 100644 --- a/lms/djangoapps/courseware/features/signup.py +++ b/lms/djangoapps/courseware/features/signup.py @@ -1,5 +1,7 @@ -from lettuce import world, step +#pylint: disable=C0111 +#pylint: disable=W0621 +from lettuce import world, step @step('I fill in "([^"]*)" on the registration form with "([^"]*)"$') def when_i_fill_in_field_on_the_registration_form_with_value(step, field, value): diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index 8240a13905..63408d7683 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from re import sub from nose.tools import assert_equals diff --git a/lms/djangoapps/courseware/features/xqueue_setup.py b/lms/djangoapps/courseware/features/xqueue_setup.py index d6d7a13a5c..90a68961ee 100644 --- a/lms/djangoapps/courseware/features/xqueue_setup.py +++ b/lms/djangoapps/courseware/features/xqueue_setup.py @@ -1,9 +1,11 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from courseware.mock_xqueue_server.mock_xqueue_server import MockXQueueServer from lettuce import before, after, world from django.conf import settings import threading - @before.all def setup_mock_xqueue_server(): From 6298a4ceabe221adba40acfda523d17c3f172355 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 26 Mar 2013 13:05:09 -0400 Subject: [PATCH 16/25] Fixed lettuce tests in cms that were broken in the last rebase --- .../contentstore/features/checklists.py | 28 +++++++++++-------- .../contentstore/features/course-settings.py | 26 +++++++++-------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py index 9ef66c8096..dc399f5fac 100644 --- a/cms/djangoapps/contentstore/features/checklists.py +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -1,15 +1,19 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step -from common import * +from nose.tools import assert_true, assert_equal from terrain.steps import reload_the_page +from selenium.common.exceptions import StaleElementReferenceException ############### ACTIONS #################### @step('I select Checklists from the Tools menu$') def i_select_checklists(step): expand_icon_css = 'li.nav-course-tools i.icon-expand' if world.browser.is_element_present_by_css(expand_icon_css): - css_click(expand_icon_css) + world.css_click(expand_icon_css) link_css = 'li.nav-course-tools-checklists a' - css_click(link_css) + world.css_click(link_css) @step('I have opened Checklists$') @@ -20,7 +24,7 @@ def i_have_opened_checklists(step): @step('I see the four default edX checklists$') def i_see_default_checklists(step): - checklists = css_find('.checklist-title') + checklists = world.css_find('.checklist-title') assert_equal(4, len(checklists)) assert_true(checklists[0].text.endswith('Getting Started With Studio')) assert_true(checklists[1].text.endswith('Draft a Rough Course Outline')) @@ -58,7 +62,7 @@ def i_select_a_link_to_the_course_outline(step): @step('I am brought to the course outline page$') def i_am_brought_to_course_outline(step): - assert_equal('Course Outline', css_find('.outline .title-1')[0].text) + assert_equal('Course Outline', world.css_find('.outline .title-1')[0].text) assert_equal(1, len(world.browser.windows)) @@ -90,30 +94,30 @@ def i_am_brought_to_help_page_in_new_window(step): def verifyChecklist2Status(completed, total, percentage): def verify_count(driver): try: - statusCount = css_find('#course-checklist1 .status-count').first + statusCount = world.css_find('#course-checklist1 .status-count').first return statusCount.text == str(completed) except StaleElementReferenceException: return False - wait_for(verify_count) - assert_equal(str(total), css_find('#course-checklist1 .status-amount').first.text) + world.wait_for(verify_count) + assert_equal(str(total), world.css_find('#course-checklist1 .status-amount').first.text) # Would like to check the CSS width, but not sure how to do that. - assert_equal(str(percentage), css_find('#course-checklist1 .viz-checklist-status-value .int').first.text) + assert_equal(str(percentage), world.css_find('#course-checklist1 .viz-checklist-status-value .int').first.text) def toggleTask(checklist, task): - css_click('#course-checklist' + str(checklist) +'-task' + str(task)) + world.css_click('#course-checklist' + str(checklist) +'-task' + str(task)) def clickActionLink(checklist, task, actionText): # toggle checklist item to make sure that the link button is showing toggleTask(checklist, task) - action_link = css_find('#course-checklist' + str(checklist) + ' a')[task] + action_link = world.css_find('#course-checklist' + str(checklist) + ' a')[task] # text will be empty initially, wait for it to populate def verify_action_link_text(driver): return action_link.text == actionText - wait_for(verify_action_link_text) + world.wait_for(verify_action_link_text) action_link.click() diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index a0c25045f2..9eb5b0951d 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -1,5 +1,7 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step -from common import * from terrain.steps import reload_the_page from selenium.webdriver.common.keys import Keys import time @@ -25,9 +27,9 @@ DEFAULT_TIME = "12:00am" def test_i_select_schedule_and_details(step): expand_icon_css = 'li.nav-course-settings i.icon-expand' if world.browser.is_element_present_by_css(expand_icon_css): - css_click(expand_icon_css) + world.css_click(expand_icon_css) link_css = 'li.nav-course-settings-schedule a' - css_click(link_css) + world.css_click(link_css) @step('I have set course dates$') @@ -97,9 +99,9 @@ def test_i_clear_the_course_start_date(step): @step('I receive a warning about course start date$') def test_i_receive_a_warning_about_course_start_date(step): - assert_css_with_text('.message-error', 'The course must have an assigned start date.') - assert_true('error' in css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) - assert_true('error' in css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) + assert_true(world.css_has_text('.message-error', 'The course must have an assigned start date.')) + assert_true('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) + assert_true('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) @step('The previously set start date is shown on refresh$') @@ -124,9 +126,9 @@ def test_i_have_entered_a_new_course_start_date(step): @step('The warning about course start date goes away$') def test_the_warning_about_course_start_date_goes_away(step): - assert_equal(0, len(css_find('.message-error'))) - assert_false('error' in css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) - assert_false('error' in css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) + assert_equal(0, len(world.css_find('.message-error'))) + assert_false('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) + assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) @step('My new course start date is shown on refresh$') @@ -142,8 +144,8 @@ def set_date_or_time(css, date_or_time): """ Sets date or time field. """ - css_fill(css, date_or_time) - e = css_find(css).first + world.css_fill(css, date_or_time) + e = world.css_find(css).first # hit Enter to apply the changes e._element.send_keys(Keys.ENTER) @@ -152,7 +154,7 @@ def verify_date_or_time(css, date_or_time): """ Verifies date or time field. """ - assert_equal(date_or_time, css_find(css).first.value) + assert_equal(date_or_time, world.css_find(css).first.value) def pause(): From 6564cc57e6f7a2bddfab8a9dabbcc012687135a1 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Wed, 27 Mar 2013 16:29:55 -0400 Subject: [PATCH 17/25] Fix typo with hyphen in cms lettuce feature files --- .../contentstore/features/advanced-settings.feature | 6 +++--- .../features/studio-overview-togglesection.feature | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 66039e19b1..db7294c14c 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -27,16 +27,16 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is changed - Scenario: Test how multi -line input appears + Scenario: Test how multi-line input appears Given I am on the Advanced Course Settings page in Studio When I create a JSON object as a value Then it is displayed as formatted And I reload the page Then it is displayed as formatted - Scenario: Test automatic quoting of non -JSON values + Scenario: Test automatic quoting of non-JSON values Given I am on the Advanced Course Settings page in Studio - When I create a non -JSON value not in quotes + When I create a non-JSON value not in quotes Then it is displayed as a string And I reload the page Then it is displayed as a string diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index 88492d55e3..762dea6838 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -21,7 +21,7 @@ Feature: Overview Toggle Section Then I see the "Collapse All Sections" link And all sections are expanded - @skip -phantom + @skip-phantom Scenario: Collapse link is not removed after last section of a course is deleted Given I have a course with 1 section And I navigate to the course overview page From 03f9bb5d38a3855ee56087b9132f8ebbe13be747 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 22:37:07 -0400 Subject: [PATCH 18/25] use a request-scoped cache to keep the metadata inheritence tree around for the whole request. This means we should only do one trip to Memcached/Mongo per course per request. This is expected to keep memory utilization down --- common/djangoapps/request_cache/__init__.py | 0 common/djangoapps/request_cache/middleware.py | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 common/djangoapps/request_cache/__init__.py create mode 100644 common/djangoapps/request_cache/middleware.py diff --git a/common/djangoapps/request_cache/__init__.py b/common/djangoapps/request_cache/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/request_cache/middleware.py b/common/djangoapps/request_cache/middleware.py new file mode 100644 index 0000000000..9d3dffdf27 --- /dev/null +++ b/common/djangoapps/request_cache/middleware.py @@ -0,0 +1,20 @@ +import threading + +_request_cache_threadlocal = threading.local() +_request_cache_threadlocal.data = {} + +class RequestCache(object): + @classmethod + def get_request_cache(cls): + return _request_cache_threadlocal + + def clear_request_cache(self): + _request_cache_threadlocal.data = {} + + def process_request(self, request): + self.clear_request_cache() + return None + + def process_response(self, request, response): + self.clear_request_cache() + return response \ No newline at end of file From b609a96902e6be8c1be4fd423db2c9d2dbe018ca Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 22:51:52 -0400 Subject: [PATCH 19/25] ummm. forgot to commit stuff --- cms/envs/common.py | 1 + cms/one_time_startup.py | 4 +- .../lib/xmodule/xmodule/modulestore/mongo.py | 105 ++++++++++-------- .../xmodule/modulestore/tests/test_mongo.py | 55 --------- lms/envs/common.py | 1 + lms/one_time_startup.py | 4 +- 6 files changed, 64 insertions(+), 106 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index a83f61d8f9..12fa09947a 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -113,6 +113,7 @@ TEMPLATE_LOADERS = ( MIDDLEWARE_CLASSES = ( 'contentserver.middleware.StaticContentServer', + 'request_cache.middleware.RequestCache', 'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/cms/one_time_startup.py b/cms/one_time_startup.py index 38a2fef847..6e88fed439 100644 --- a/cms/one_time_startup.py +++ b/cms/one_time_startup.py @@ -1,13 +1,15 @@ from dogapi import dog_http_api, dog_stats_api from django.conf import settings from xmodule.modulestore.django import modulestore +from request_cache.middleware import RequestCache from django.core.cache import get_cache, InvalidCacheBackendError cache = get_cache('mongo_metadata_inheritance') for store_name in settings.MODULESTORE: store = modulestore(store_name) - store.metadata_inheritance_cache = cache + store.metadata_inheritance_cache_subsystem = cache + store.request_cache = RequestCache.get_request_cache() if hasattr(settings, 'DATADOG_API'): dog_http_api.api_key = settings.DATADOG_API diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 38b15ab76e..b93a95c965 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -27,6 +27,9 @@ from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata log = logging.getLogger(__name__) +import threading +_mongo_metadata_request_cache_threadlocal = threading.local() + # TODO (cpennington): This code currently operates under the assumption that # there is only one revision for each item. Once we start versioning inside the CMS, # that assumption will have to change @@ -109,7 +112,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): references to metadata_inheritance_tree """ def __init__(self, modulestore, module_data, default_class, resources_fs, - error_tracker, render_template, metadata_cache=None): + error_tracker, render_template, cached_metadata=None): """ modulestore: the module store that can be used to retrieve additional modules @@ -134,7 +137,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem): # cdodge: other Systems have a course_id attribute defined. To keep things consistent, let's # define an attribute here as well, even though it's None self.course_id = None - self.metadata_cache = metadata_cache + self.cached_metadata = cached_metadata + def load_item(self, location): """ @@ -170,8 +174,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem): model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location)) module = class_(self, location, model_data) - if self.metadata_cache is not None: - metadata_to_inherit = self.metadata_cache.get(metadata_cache_key(location), {}).get('parent_metadata', {}).get(location.url(), {}) + if self.cached_metadata is not None: + metadata_to_inherit = self.cached_metadata.get(location.url(), {}) inherit_metadata(module, metadata_to_inherit) return module except: @@ -223,7 +227,8 @@ class MongoModuleStore(ModuleStoreBase): def __init__(self, host, db, collection, fs_root, render_template, port=27017, default_class=None, error_tracker=null_error_tracker, - user=None, password=None, **kwargs): + user=None, password=None, request_cache=None, + metadata_inheritance_cache_subsystem=None, **kwargs): ModuleStoreBase.__init__(self) @@ -254,8 +259,10 @@ class MongoModuleStore(ModuleStoreBase): self.error_tracker = error_tracker self.render_template = render_template self.ignore_write_events_on_courses = [] + self.request_cache = request_cache + self.metadata_inheritance_cache_subsystem = metadata_inheritance_cache_subsystem - def get_metadata_inheritance_tree(self, location): + def compute_metadata_inheritance_tree(self, location): ''' TODO (cdodge) This method can be deleted when the 'split module store' work has been completed ''' @@ -323,32 +330,47 @@ class MongoModuleStore(ModuleStoreBase): if root is not None: _compute_inherited_metadata(root) - return {'parent_metadata': metadata_to_inherit, - 'timestamp': datetime.now()} + return metadata_to_inherit - def get_cached_metadata_inheritance_trees(self, locations, force_refresh=False): + def get_cached_metadata_inheritance_tree(self, location, force_refresh=False): ''' TODO (cdodge) This method can be deleted when the 'split module store' work has been completed ''' + key = metadata_cache_key(location) + tree = {} + + if not force_refresh: + # see if we are first in the request cache (if present) + if self.request_cache is not None and key in self.request_cache.data.get('metadata_inheritance', {}): + logging.debug('***** HIT IN REQUEST CACHE') + return self.request_cache.data['metadata_inheritance'][key] - trees = {} - if locations and self.metadata_inheritance_cache is not None and not force_refresh: - trees = self.metadata_inheritance_cache.get_many(list(set([metadata_cache_key(loc) for loc in locations]))) - else: - # This is to help guard against an accident prod runtime without a cache - logging.warning('Running MongoModuleStore without metadata_inheritance_cache. ' - 'This should not happen in production!') + # then look in any caching subsystem (e.g. memcached) + if self.metadata_inheritance_cache_subsystem is not None: + tree = self.metadata_inheritance_cache_subsystem.get(key, {}) + if tree: + logging.debug('***** HIT IN MEMCACHED') + else: + logging.warning('Running MongoModuleStore without a metadata_inheritance_cache_subsystem. This is OK in localdev and testing environment. Not OK in production.') - to_cache = {} - for loc in locations: - cache_key = metadata_cache_key(loc) - if cache_key not in trees: - to_cache[cache_key] = trees[cache_key] = self.get_metadata_inheritance_tree(loc) + if not tree: + # if not in subsystem, or we are on force refresh, then we have to compute + logging.debug('***** COMPUTING METADATA') + tree = self.compute_metadata_inheritance_tree(location) - if to_cache and self.metadata_inheritance_cache is not None: - self.metadata_inheritance_cache.set_many(to_cache) + # now populate a request_cache, if available + if self.request_cache is not None: + # we can't assume the 'metadatat_inheritance' part of the request cache dict has been + # defined + if 'metadata_inheritance' not in self.request_cache.data: + self.request_cache.data['metadata_inheritance'] = {} + self.request_cache.data['metadata_inheritance'][key] = tree - return trees + # now write to caching subsystem (e.g. memcached), if available + if self.metadata_inheritance_cache_subsystem is not None: + self.metadata_inheritance_cache_subsystem.set(key, tree) + + return tree def refresh_cached_metadata_inheritance_tree(self, location): """ @@ -357,15 +379,7 @@ class MongoModuleStore(ModuleStoreBase): """ pseudo_course_id = '/'.join([location.org, location.course]) if pseudo_course_id not in self.ignore_write_events_on_courses: - self.get_cached_metadata_inheritance_trees([location], force_refresh=True) - - def clear_cached_metadata_inheritance_tree(self, location): - """ - Delete the cached metadata inheritance tree for the org/course combination - for location - """ - if self.metadata_inheritance_cache is not None: - self.metadata_inheritance_cache.delete(metadata_cache_key(location)) + self.get_cached_metadata_inheritance_tree(location, force_refresh=True) def _clean_item_data(self, item): """ @@ -411,18 +425,7 @@ class MongoModuleStore(ModuleStoreBase): return data - def _cache_metadata_inheritance(self, items, depth, force_refresh=False): - """ - Retrieves all course metadata inheritance trees needed to load items - """ - - locations = [ - Location(item['location']) for item in items - if not (item['location']['category'] == 'course' and depth == 0) - ] - return self.get_cached_metadata_inheritance_trees(locations, force_refresh=force_refresh) - - def _load_item(self, item, data_cache, metadata_cache): + def _load_item(self, item, data_cache, apply_cached_metadata=True): """ Load an XModuleDescriptor from item, using the children stored in data_cache """ @@ -434,6 +437,10 @@ class MongoModuleStore(ModuleStoreBase): resource_fs = OSFS(root) + cached_metadata = {} + if apply_cached_metadata: + cached_metadata = self.get_cached_metadata_inheritance_tree(Location(item['location'])) + # TODO (cdodge): When the 'split module store' work has been completed, we should remove # the 'metadata_inheritance_tree' parameter system = CachingDescriptorSystem( @@ -443,7 +450,7 @@ class MongoModuleStore(ModuleStoreBase): resource_fs, self.error_tracker, self.render_template, - metadata_cache, + cached_metadata, ) return system.load_item(item['location']) @@ -453,11 +460,11 @@ class MongoModuleStore(ModuleStoreBase): to specified depth """ data_cache = self._cache_children(items, depth) - inheritance_cache = self._cache_metadata_inheritance(items, depth) # if we are loading a course object, if we're not prefetching children (depth != 0) then don't - # bother with the metadata inheritence - return [self._load_item(item, data_cache, inheritance_cache) for item in items] + # bother with the metadata inheritance + return [self._load_item(item, data_cache, + apply_cached_metadata=(item['location']['category']!='course' or depth !=0)) for item in items] def get_courses(self): ''' diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index 3e29c07ea4..061d70d09f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -103,58 +103,3 @@ class TestMongoModuleStore(object): def test_path_to_location(self): '''Make sure that path_to_location works''' check_path_to_location(self.store) - - def test_metadata_inheritance_query_count(self): - ''' - When retrieving items from mongo, we should only query the cache a number of times - equal to the number of courses being retrieved from. - - We should also not query - ''' - self.store.metadata_inheritance_cache = Mock() - get_many = self.store.metadata_inheritance_cache.get_many - set_many = self.store.metadata_inheritance_cache.set_many - get_many.return_value = {('edX', 'toy'): {}} - - self.store.get_item(Location("i4x://edX/toy/course/2012_Fall"), depth=0) - assert_false(get_many.called) - assert_false(set_many.called) - get_many.reset_mock() - - self.store.get_item(Location("i4x://edX/toy/course/2012_Fall"), depth=3) - get_many.assert_called_with([('edX', 'toy')]) - assert_equals(0, set_many.call_count) - get_many.reset_mock() - - self.store.get_items(Location('i4x', 'edX', None, 'course', None), depth=0) - assert_false(get_many.called) - assert_false(set_many.called) - get_many.reset_mock() - - self.store.get_items(Location('i4x', 'edX', None, 'course', None), depth=3) - assert_equals(1, get_many.call_count) - assert_equals([('edX', 'simple'), ('edX', 'toy')], sorted(get_many.call_args[0][0])) - assert_equals(1, set_many.call_count) - assert_equals([('edX', 'simple')], sorted(set_many.call_args[0][0].keys())) - get_many.reset_mock() - - self.store.get_items(Location('i4x', 'edX', None, None, None), depth=0) - assert_equals(1, get_many.call_count) - assert_equals([('edX', 'simple'), ('edX', 'toy')], sorted(get_many.call_args[0][0])) - assert_equals(1, set_many.call_count) - assert_equals([('edX', 'simple')], sorted(set_many.call_args[0][0].keys())) - get_many.reset_mock() - - def test_metadata_inheritance_query_count_forced_refresh(self): - self.store.metadata_inheritance_cache = Mock() - get_many = self.store.metadata_inheritance_cache.get_many - set_many = self.store.metadata_inheritance_cache.set_many - get_many.return_value = {('edX', 'toy'): {}} - - self.store.get_cached_metadata_inheritance_trees( - [Location("i4x://edX/toy/course/2012_Fall"), Location("i4x://edX/simple/course/2012_Fall")], - True - ) - assert_false(get_many.called) - assert_equals(1, set_many.call_count) - assert_equals([('edX', 'simple'), ('edX', 'toy')], sorted(set_many.call_args[0][0].keys())) diff --git a/lms/envs/common.py b/lms/envs/common.py index cfd6fc34de..8654b5ebf5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -364,6 +364,7 @@ TEMPLATE_LOADERS = ( MIDDLEWARE_CLASSES = ( 'contentserver.middleware.StaticContentServer', + 'request_cache.middleware.RequestCache', 'django_comment_client.middleware.AjaxExceptionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/lms/one_time_startup.py b/lms/one_time_startup.py index 6b3c45d60f..e1b1f79444 100644 --- a/lms/one_time_startup.py +++ b/lms/one_time_startup.py @@ -2,13 +2,15 @@ import logging from dogapi import dog_http_api, dog_stats_api from django.conf import settings from xmodule.modulestore.django import modulestore +from request_cache.middleware import RequestCache from django.core.cache import get_cache, InvalidCacheBackendError cache = get_cache('mongo_metadata_inheritance') for store_name in settings.MODULESTORE: store = modulestore(store_name) - store.metadata_inheritance_cache = cache + store.metadata_inheritance_cache_subsystem = cache + store.request_cache = RequestCache.get_request_cache() if hasattr(settings, 'DATADOG_API'): dog_http_api.api_key = settings.DATADOG_API From 446397b23bbcf625a82374c46773720e5e059e28 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 23:12:00 -0400 Subject: [PATCH 20/25] remove unused thread.local() --- common/lib/xmodule/xmodule/modulestore/mongo.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index b93a95c965..b388f81f7c 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -27,9 +27,6 @@ from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata log = logging.getLogger(__name__) -import threading -_mongo_metadata_request_cache_threadlocal = threading.local() - # TODO (cpennington): This code currently operates under the assumption that # there is only one revision for each item. Once we start versioning inside the CMS, # that assumption will have to change From d448aa1365b7aca300bfc67ba3684e162b09e4aa Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 23:13:33 -0400 Subject: [PATCH 21/25] remove debug log messages --- common/lib/xmodule/xmodule/modulestore/mongo.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index b388f81f7c..7bd61924fa 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -339,20 +339,16 @@ class MongoModuleStore(ModuleStoreBase): if not force_refresh: # see if we are first in the request cache (if present) if self.request_cache is not None and key in self.request_cache.data.get('metadata_inheritance', {}): - logging.debug('***** HIT IN REQUEST CACHE') return self.request_cache.data['metadata_inheritance'][key] # then look in any caching subsystem (e.g. memcached) if self.metadata_inheritance_cache_subsystem is not None: tree = self.metadata_inheritance_cache_subsystem.get(key, {}) - if tree: - logging.debug('***** HIT IN MEMCACHED') else: logging.warning('Running MongoModuleStore without a metadata_inheritance_cache_subsystem. This is OK in localdev and testing environment. Not OK in production.') if not tree: # if not in subsystem, or we are on force refresh, then we have to compute - logging.debug('***** COMPUTING METADATA') tree = self.compute_metadata_inheritance_tree(location) # now populate a request_cache, if available From 3f52261b5b44a7d76dd6cc2c71e7ab95ca10e3e5 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 23:18:38 -0400 Subject: [PATCH 22/25] hmmm. actually, we should only write out to memcache if we've recomputed. Otherwise, a memcache hit will end up writing back to memcache... --- common/lib/xmodule/xmodule/modulestore/mongo.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 7bd61924fa..47e35cda93 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -350,8 +350,14 @@ class MongoModuleStore(ModuleStoreBase): if not tree: # if not in subsystem, or we are on force refresh, then we have to compute tree = self.compute_metadata_inheritance_tree(location) + + # now write out computed tree to caching subsystem (e.g. memcached), if available + if self.metadata_inheritance_cache_subsystem is not None: + self.metadata_inheritance_cache_subsystem.set(key, tree) - # now populate a request_cache, if available + # now populate a request_cache, if available. NOTE, we are outside of the + # scope of the above if: statement so that after a memcache hit, it'll get + # put into the request_cache if self.request_cache is not None: # we can't assume the 'metadatat_inheritance' part of the request cache dict has been # defined @@ -359,10 +365,6 @@ class MongoModuleStore(ModuleStoreBase): self.request_cache.data['metadata_inheritance'] = {} self.request_cache.data['metadata_inheritance'][key] = tree - # now write to caching subsystem (e.g. memcached), if available - if self.metadata_inheritance_cache_subsystem is not None: - self.metadata_inheritance_cache_subsystem.set(key, tree) - return tree def refresh_cached_metadata_inheritance_tree(self, location): From 7279f9c4601f7ee769ce689b1bcab4c63b94e377 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 28 Mar 2013 10:54:10 -0400 Subject: [PATCH 23/25] Bug fix for grading type not showing on course outline (#258). --- .../contentstore/features/subsection.feature | 8 ++++++++ cms/djangoapps/contentstore/features/subsection.py | 13 ++++++++++++- cms/templates/overview.html | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 1be5f4aeb9..e913c6a4bf 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -17,6 +17,14 @@ Feature: Create Subsection And I click to edit the subsection name Then I see the complete subsection name with a quote in the editor + Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258) + Given I have opened a new course section in Studio + And I have added a new subsection + And I mark it as Homework + Then I see it marked as Homework + And I reload the page + Then I see it marked as Homework + @skip-phantom Scenario: Delete a subsection Given I have opened a new course section in Studio diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 54f49f2fa6..4ab27fcb49 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -40,7 +40,7 @@ def i_click_to_edit_subsection_name(step): def i_see_complete_subsection_name_with_quote_in_editor(step): css = '.subsection-display-name-input' assert world.is_css_present(css) - assert_equal(world.browser.find_by_css(css).value, 'Subsection With "Quote"') + assert_equal(world.css_find(css).value, 'Subsection With "Quote"') @step('I have added a new subsection$') @@ -48,6 +48,17 @@ def i_have_added_a_new_subsection(step): add_subsection() +@step('I mark it as Homework$') +def i_mark_it_as_homework(step): + world.css_click('a.menu-toggle') + world.browser.click_link_by_text('Homework') + + +@step('I see it marked as Homework$') +def i_see_it_marked__as_homework(step): + assert_equal(world.css_find(".status-label").value, 'Homework') + + ############ ASSERTIONS ################### diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 904f654717..d45a90093e 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -200,7 +200,7 @@ -
+
From 4050da6b4cd7c47f8f1fc06a6192d0a1180dd6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Thu, 28 Mar 2013 12:57:17 -0400 Subject: [PATCH 24/25] Enable meta-universities (organizations that contain other) --- lms/djangoapps/courseware/views.py | 31 +++++++++++++++++++++++------- lms/envs/aws.py | 1 + lms/envs/cms/dev.py | 1 + lms/envs/dev.py | 3 +++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index e75ef8e8cf..9099d21233 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -522,6 +522,12 @@ def static_university_profile(request, org_id): """ Return the profile for the particular org_id that does not have any courses. """ + # Redirect to the properly capitalized org_id + last_path = request.path.split('/')[-1] + if last_path != org_id: + return redirect('static_university_profile', org_id=org_id) + + # Render template template_file = "university_profile/{0}.html".format(org_id).lower() context = dict(courses=[], org_id=org_id) return render_to_response(template_file, context) @@ -533,17 +539,28 @@ def university_profile(request, org_id): """ Return the profile for the particular org_id. 404 if it's not valid. """ + virtual_orgs_ids = settings.VIRTUAL_UNIVERSITIES + meta_orgs = getattr(settings, 'META_UNIVERSITIES', {}) + + # Get all the ids associated with this organization all_courses = modulestore().get_courses() - valid_org_ids = set(c.org for c in all_courses).union(settings.VIRTUAL_UNIVERSITIES) - if org_id not in valid_org_ids: + valid_orgs_ids = set(c.org for c in all_courses) + valid_orgs_ids.update(virtual_orgs_ids + meta_orgs.keys()) + + if org_id not in valid_orgs_ids: raise Http404("University Profile not found for {0}".format(org_id)) - # Only grab courses for this org... - courses = get_courses_by_university(request.user, - domain=request.META.get('HTTP_HOST'))[org_id] - courses = sort_by_announcement(courses) + # Grab all courses for this organization(s) + org_ids = set([org_id] + meta_orgs.get(org_id, [])) + org_courses = [] + domain = request.META.get('HTTP_HOST') + for key in org_ids: + cs = get_courses_by_university(request.user, domain=domain)[key] + org_courses.extend(cs) - context = dict(courses=courses, org_id=org_id) + org_courses = sort_by_announcement(org_courses) + + context = dict(courses=org_courses, org_id=org_id) template_file = "university_profile/{0}.html".format(org_id).lower() return render_to_response(template_file, context) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index cc9247b876..aa30315eca 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -76,6 +76,7 @@ LOGGING = get_logger_config(LOG_DIR, COURSE_LISTINGS = ENV_TOKENS.get('COURSE_LISTINGS', {}) SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {}) VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', []) +META_UNIVERSITIES = ENV_TOKENS.get('META_UNIVERSITIES', {}) COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '') COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '') CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') diff --git a/lms/envs/cms/dev.py b/lms/envs/cms/dev.py index 4b6b0a12f0..9333b7883c 100644 --- a/lms/envs/cms/dev.py +++ b/lms/envs/cms/dev.py @@ -9,6 +9,7 @@ MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = False SUBDOMAIN_BRANDING['edge'] = 'edge' SUBDOMAIN_BRANDING['preview.edge'] = 'edge' VIRTUAL_UNIVERSITIES = ['edge'] +META_UNIVERSITIES = {} modulestore_options = { 'default_class': 'xmodule.raw_module.RawDescriptor', diff --git a/lms/envs/dev.py b/lms/envs/dev.py index f204dc287b..24bad58459 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -113,6 +113,9 @@ SUBDOMAIN_BRANDING = { # have an actual course with that org set VIRTUAL_UNIVERSITIES = [] +# Organization that contain other organizations +META_UNIVERSITIES = {'UTx': ['UTAustinX']} + COMMENTS_SERVICE_KEY = "PUT_YOUR_API_KEY_HERE" ############################## Course static files ########################## From a15baa97c5ba7e333a71c66514155d8e1e9c243b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Thu, 28 Mar 2013 12:57:47 -0400 Subject: [PATCH 25/25] Add UTAustinX landing page --- .../utaustin/utaustin-cover_2025x550.jpg | Bin 0 -> 91807 bytes .../utaustin/utaustin-standalone_187x80.png | Bin 0 -> 4839 bytes .../university_profile/utaustinx.html | 23 +++++++++++ lms/templates/university_profile/utx.html | 4 ++ lms/urls.py | 38 ++++-------------- 5 files changed, 35 insertions(+), 30 deletions(-) create mode 100644 lms/static/images/university/utaustin/utaustin-cover_2025x550.jpg create mode 100644 lms/static/images/university/utaustin/utaustin-standalone_187x80.png create mode 100644 lms/templates/university_profile/utaustinx.html diff --git a/lms/static/images/university/utaustin/utaustin-cover_2025x550.jpg b/lms/static/images/university/utaustin/utaustin-cover_2025x550.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7294b53f1b07603575dc6023006d261b4efe33b8 GIT binary patch literal 91807 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 +<%namespace name='static' file='../static_content.html'/> + +<%block name="title">UTAustinX + +<%block name="university_header"> + + + +<%block name="university_description"> +

The University of Texas at Austin is the top-ranked public university in a nearly 1,000-mile radius, and is ranked in the top 25 universities in the world. Students have been finding their passion in life at UT Austin for more than 130 years, and it has been a member of the prestigious AAU since 1929. UT Austin combines the academic depth and breadth of a world research institute (regularly ranking within the top three producers of doctoral degrees in the country) with the fun and excitement of a big-time collegiate experience. It is currently the fifth-largest university in America, with more than 50,000 students and 3,000 professors across 17 colleges and schools, and is the first major American university to build a medical school in the past 50 years.

+ + +${parent.body()} diff --git a/lms/templates/university_profile/utx.html b/lms/templates/university_profile/utx.html index b9378f6ce3..ea34ddb85b 100644 --- a/lms/templates/university_profile/utx.html +++ b/lms/templates/university_profile/utx.html @@ -1,5 +1,8 @@ <%inherit file="base.html" /> <%namespace name='static' file='../static_content.html'/> +<%! + from django.core.urlresolvers import reverse +%> <%block name="title">UTx @@ -19,6 +22,7 @@ <%block name="university_description">

Educating students, providing care for patients, conducting groundbreaking research and serving the needs of Texans and the nation for more than 130 years, The University of Texas System is one of the largest public university systems in the United States, with nine academic universities and six health science centers. Student enrollment exceeded 215,000 in the 2011 academic year. The UT System confers more than one-third of the state’s undergraduate degrees and educates nearly three-fourths of the state’s health care professionals annually. The UT System has an annual operating budget of $13.1 billion (FY 2012) including $2.3 billion in sponsored programs funded by federal, state, local and private sources. With roughly 87,000 employees, the UT System is one of the largest employers in the state.

+

Find out about the University of Texas Austin.

${parent.body()} diff --git a/lms/urls.py b/lms/urls.py index ee213f2b8c..de5c8184fa 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -69,44 +69,22 @@ urlpatterns = ('', url(r'^heartbeat$', include('heartbeat.urls')), - url(r'^university_profile/UTx$', 'courseware.views.static_university_profile', - name="static_university_profile", kwargs={'org_id': 'UTx'}), - url(r'^university_profile/WellesleyX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/WellesleyX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'WellesleyX'}), - url(r'^university_profile/GeorgetownX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/GeorgetownX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'GeorgetownX'}), - - # Dan accidentally sent out a press release with lower case urls for McGill, Toronto, - # Rice, ANU, Delft, and EPFL. Hence the redirects. - url(r'^university_profile/McGillX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/McGillX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'McGillX'}), - url(r'^university_profile/mcgillx$', - RedirectView.as_view(url='/university_profile/McGillX')), - - url(r'^university_profile/TorontoX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/TorontoX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'TorontoX'}), - url(r'^university_profile/torontox$', - RedirectView.as_view(url='/university_profile/TorontoX')), - - url(r'^university_profile/RiceX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/RiceX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'RiceX'}), - url(r'^university_profile/ricex$', - RedirectView.as_view(url='/university_profile/RiceX')), - - url(r'^university_profile/ANUx$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/ANUx$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'ANUx'}), - url(r'^university_profile/anux$', - RedirectView.as_view(url='/university_profile/ANUx')), - - url(r'^university_profile/DelftX$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/DelftX$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'DelftX'}), - url(r'^university_profile/delftx$', - RedirectView.as_view(url='/university_profile/DelftX')), - - url(r'^university_profile/EPFLx$', 'courseware.views.static_university_profile', + url(r'^(?i)university_profile/EPFLx$', 'courseware.views.static_university_profile', name="static_university_profile", kwargs={'org_id': 'EPFLx'}), - url(r'^university_profile/epflx$', - RedirectView.as_view(url='/university_profile/EPFLx')), url(r'^university_profile/(?P[^/]+)$', 'courseware.views.university_profile', name="university_profile"),